2026-06-10 13:05:49 +02:00
/ * *
* 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 ExercisePickerModal from './ExercisePickerModal'
2026-06-10 15:34:37 +02:00
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
2026-06-10 13:05:49 +02:00
import ProgressionSlotCard from './ProgressionSlotCard'
import ProgressionFindingsPanel from './ProgressionFindingsPanel'
2026-06-12 12:25:52 +02:00
import PlanningCatalogContextFields from './PlanningCatalogContextFields'
2026-06-10 13:05:49 +02:00
import {
2026-06-10 15:34:37 +02:00
aiPreviewToQuickCreateDraft ,
buildQuickCreateAiPreview ,
2026-06-10 15:56:30 +02:00
buildQuickCreateExercisePayloadFromDraft ,
2026-06-10 16:04:15 +02:00
ensureQuickCreateDraftFromAiSuggestion ,
2026-06-10 15:34:37 +02:00
} from '../utils/exerciseAiQuickCreate'
import {
buildPathGapPlanningContextForAi ,
2026-06-11 11:48:25 +02:00
buildSlotGapGoalForAi ,
2026-06-10 15:34:37 +02:00
gapOfferContextDisplayLines ,
initialStageLearningGoalFromOffer ,
} from '../utils/planningContextForExerciseAi'
2026-06-10 16:04:15 +02:00
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
2026-06-12 13:22:04 +02:00
import ProgressionOptimizeCompareModal from './ProgressionOptimizeCompareModal'
2026-06-10 15:34:37 +02:00
import {
addSlotToDraft ,
2026-06-10 15:56:30 +02:00
applyEvaluateResponseToDraft ,
2026-06-10 15:34:37 +02:00
applyGapOfferToDraft ,
2026-06-12 13:22:04 +02:00
applySelectedCompareSteps ,
2026-06-13 07:55:47 +02:00
compareSlotDiffs ,
2026-06-13 08:17:59 +02:00
collectGapOffersFromApiResponse ,
dedupeGapOffersBySlot ,
filterGapOffersForUnfilledSlots ,
2026-06-13 08:36:53 +02:00
mergeGapOffersForDraft ,
2026-06-12 13:22:04 +02:00
pathQaQualityPercent ,
2026-06-10 16:17:40 +02:00
applyResolvedStructuredToDraft ,
2026-06-10 15:56:30 +02:00
buildPlanningArtifactFromDraft ,
2026-06-10 13:05:49 +02:00
hydrateProgressionGraphDraft ,
2026-06-10 16:17:40 +02:00
SLOT _MIN ,
2026-06-10 15:34:37 +02:00
insertSlotInDraft ,
2026-06-10 13:05:49 +02:00
librarySlotExercise ,
majorStepsToOverridePayload ,
2026-06-10 15:34:37 +02:00
moveSlotInDraft ,
patchSlotInDraft ,
removeSlotFromDraft ,
2026-06-10 13:05:49 +02:00
saveProgressionGraphDraft ,
2026-06-10 15:56:30 +02:00
setSlotPrimaryLibrary ,
2026-06-10 15:34:37 +02:00
SLOT _MAX ,
slotsAsPathStepRows ,
2026-06-10 13:05:49 +02:00
slotsToEvaluateSteps ,
2026-06-11 12:20:41 +02:00
draftRetrievalBoostExerciseIds ,
2026-06-12 13:22:04 +02:00
draftHasLibrarySlotAssignments ,
2026-06-11 12:33:02 +02:00
slotsToSlotAssignments ,
2026-06-10 15:34:37 +02:00
syncProgressionRoadmapFromSlots ,
2026-06-10 15:56:30 +02:00
syncSlotPhasesFromRoadmap ,
2026-06-12 10:16:55 +02:00
EMPTY _PLANNING _CATALOG _CONTEXT ,
planningCatalogContextToApi ,
setCatalogSelectItems ,
2026-06-10 13:05:49 +02:00
} 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
}
2026-06-10 15:34:37 +02:00
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
}
2026-06-10 15:42:29 +02:00
export default function ProgressionGraphEditor ( { graphId , embedded = false , onSaved } ) {
2026-06-10 13:05:49 +02:00
const [ graphMeta , setGraphMeta ] = useState ( null )
const [ draft , setDraft ] = useState ( null )
const [ busy , setBusy ] = useState ( false )
const [ loadErr , setLoadErr ] = useState ( '' )
const [ actionErr , setActionErr ] = useState ( '' )
2026-06-11 11:17:53 +02:00
const [ matchNotice , setMatchNotice ] = useState ( '' )
2026-06-10 13:05:49 +02:00
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 )
2026-06-10 16:17:40 +02:00
const [ startTargetLoading , setStartTargetLoading ] = useState ( false )
const [ startTargetReady , setStartTargetReady ] = useState ( false )
2026-06-10 15:34:37 +02:00
const [ semanticBrief , setSemanticBrief ] = useState ( null )
const [ targetSummary , setTargetSummary ] = useState ( null )
const [ focusAreas , setFocusAreas ] = useState ( [ ] )
2026-06-12 10:16:55 +02:00
const [ styleDirections , setStyleDirections ] = useState ( [ ] )
const [ trainingTypes , setTrainingTypes ] = useState ( [ ] )
const [ targetGroups , setTargetGroups ] = useState ( [ ] )
2026-06-10 16:04:15 +02:00
const [ skillsCatalog , setSkillsCatalog ] = useState ( [ ] )
2026-06-10 15:34:37 +02:00
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 )
2026-06-10 15:56:30 +02:00
const [ currentEdges , setCurrentEdges ] = useState ( [ ] )
const [ slotQuickCreateIndex , setSlotQuickCreateIndex ] = useState ( null )
const [ slotQuickCreateDraft , setSlotQuickCreateDraft ] = useState ( null )
const [ slotQuickSaving , setSlotQuickSaving ] = useState ( false )
const [ slotQuickError , setSlotQuickError ] = useState ( '' )
2026-06-10 16:04:15 +02:00
const [ activePlanningContextLines , setActivePlanningContextLines ] = useState ( [ ] )
2026-06-12 13:22:04 +02:00
const [ compareOpen , setCompareOpen ] = useState ( false )
const [ comparePayload , setComparePayload ] = useState ( null )
2026-06-13 08:17:59 +02:00
const [ compareSource , setCompareSource ] = useState ( 'manual' )
2026-06-12 13:22:04 +02:00
const [ comparing , setComparing ] = useState ( false )
const [ compareApplying , setCompareApplying ] = useState ( false )
2026-06-12 13:33:36 +02:00
const [ proposedPathQa , setProposedPathQa ] = useState ( null )
2026-06-10 13:05:49 +02:00
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 ) ) ,
] )
2026-06-10 15:56:30 +02:00
const edgeList = Array . isArray ( edges ) ? edges : [ ]
setCurrentEdges ( edgeList )
2026-06-10 13:05:49 +02:00
setGraphMeta ( graph )
2026-06-10 16:17:40 +02:00
const hydrated = hydrateProgressionGraphDraft ( {
artifact : graph ? . planning _roadmap ,
edges : edgeList ,
graphName : graph ? . name ,
} )
setDraft ( hydrated )
setStartTargetReady (
Boolean ( ( hydrated . startSituation || '' ) . trim ( ) && ( hydrated . targetState || '' ) . trim ( ) ) ,
2026-06-10 13:05:49 +02:00
)
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 ] )
2026-06-10 15:34:37 +02:00
useEffect ( ( ) => {
let cancelled = false
2026-06-10 16:04:15 +02:00
Promise . all ( [
api . listFocusAreas ( { status : 'active' } ) ,
2026-06-12 10:16:55 +02:00
api . listStyleDirections ( { status : 'active' } ) ,
api . listTrainingTypes ( { status : 'active' } ) ,
api . listTargetGroups ( { status : 'active' } ) ,
2026-06-10 16:04:15 +02:00
api . listSkillsCatalog ( { status : 'active' } ) ,
] )
2026-06-12 10:16:55 +02:00
. then ( ( [ fa , sd , tt , tg , sk ] ) => {
2026-06-10 16:04:15 +02:00
if ( cancelled ) return
setFocusAreas ( Array . isArray ( fa ) ? fa : [ ] )
2026-06-12 10:16:55 +02:00
setStyleDirections ( Array . isArray ( sd ) ? sd : [ ] )
setTrainingTypes ( Array . isArray ( tt ) ? tt : [ ] )
setTargetGroups ( Array . isArray ( tg ) ? tg : [ ] )
2026-06-10 16:04:15 +02:00
setSkillsCatalog ( Array . isArray ( sk ) ? sk : [ ] )
2026-06-10 15:34:37 +02:00
} )
. catch ( ( ) => {
2026-06-10 16:04:15 +02:00
if ( ! cancelled ) {
setFocusAreas ( [ ] )
2026-06-12 10:16:55 +02:00
setStyleDirections ( [ ] )
setTrainingTypes ( [ ] )
setTargetGroups ( [ ] )
2026-06-10 16:04:15 +02:00
setSkillsCatalog ( [ ] )
}
2026-06-10 15:34:37 +02:00
} )
return ( ) => {
cancelled = true
}
} , [ ] )
2026-06-10 13:05:49 +02:00
const patchDraft = useCallback ( ( patchFn ) => {
setDraft ( ( prev ) => {
if ( ! prev ) return prev
const next = patchFn ( prev )
return { ... next , dirty : true }
} )
} , [ ] )
2026-06-10 15:34:37 +02:00
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 ] )
2026-06-10 13:05:49 +02:00
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 }
} )
2026-06-10 15:34:37 +02:00
return syncProgressionRoadmapFromSlots ( { ... d , slots } )
2026-06-10 13:05:49 +02:00
} )
setPickContext ( null )
}
const handlePatchLearningGoal = ( slotIndex , value ) => {
2026-06-10 15:34:37 +02:00
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 ) )
2026-06-10 13:05:49 +02:00
}
const handleClearPrimary = ( slotIndex ) => {
patchDraft ( ( d ) => {
const slots = d . slots . map ( ( s , i ) =>
2026-06-10 15:34:37 +02:00
i === slotIndex
? {
... s ,
primary : {
kind : 'empty' ,
exerciseId : null ,
variantId : null ,
exerciseTitle : '' ,
variantName : null ,
proposalKey : null ,
aiSuggestion : null ,
} ,
siblings : [ ] ,
}
: s ,
2026-06-10 13:05:49 +02:00
)
2026-06-10 15:34:37 +02:00
return syncProgressionRoadmapFromSlots ( { ... d , slots } )
2026-06-10 13:05:49 +02:00
} )
}
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 ] )
2026-06-12 10:16:55 +02:00
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 ] ,
)
2026-06-10 16:17:40 +02:00
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 ) ,
2026-06-12 10:16:55 +02:00
... catalogApiPayload ,
2026-06-10 16:17:40 +02:00
} )
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 }
} )
setStartTargetReady ( true )
setSemanticBrief ( res ? . semantic _brief _summary || null )
} catch ( e ) {
setActionErr ( e . message || 'Start/Ziel-Analyse fehlgeschlagen' )
} finally {
setStartTargetLoading ( false )
}
}
2026-06-10 13:05:49 +02:00
const runRoadmapGenerate = async ( ) => {
const q = ( draft ? . goalQuery || '' ) . trim ( )
if ( q . length < 3 ) {
alert ( 'Ziel-Anfrage: mindestens 3 Zeichen.' )
return
}
2026-06-10 16:17:40 +02:00
const fieldsEmpty = ! ( draft . startSituation || '' ) . trim ( ) && ! ( draft . targetState || '' ) . trim ( )
2026-06-10 13:05:49 +02:00
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 ,
2026-06-10 16:17:40 +02:00
include _llm _start _target : fieldsEmpty ,
2026-06-10 13:05:49 +02:00
roadmap _only : true ,
progression _graph _id : Number ( graphId ) ,
... roadmapStructuredPayload ( draft . startSituation , draft . targetState , draft . roadmapNotes ) ,
2026-06-12 10:16:55 +02:00
... catalogApiPayload ,
2026-06-10 13:05:49 +02:00
} )
const roadmap = res ? . progression _roadmap
if ( ! roadmap ) throw new Error ( 'Keine Roadmap in der Antwort' )
2026-06-10 16:17:40 +02:00
const majorCount = ( roadmap ? . roadmap ? . major _steps || [ ] ) . length
if ( majorCount < SLOT _MIN ) throw new Error ( 'Roadmap hat zu wenig Stufen.' )
2026-06-10 15:56:30 +02:00
const preservedArtifact = buildPlanningArtifactFromDraft ( draft ) || { }
2026-06-10 16:17:40 +02:00
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 )
}
2026-06-10 13:05:49 +02:00
const hydrated = hydrateProgressionGraphDraft ( {
artifact : {
2026-06-10 15:56:30 +02:00
... preservedArtifact ,
2026-06-10 15:34:37 +02:00
goal _query : q ,
2026-06-10 13:05:49 +02:00
progression _roadmap : roadmap ,
2026-06-10 16:17:40 +02:00
start _situation : startSituation ,
target _state : targetState ,
roadmap _notes : roadmapNotes ,
max _steps : majorCount || draft . maxSteps ,
2026-06-10 13:05:49 +02:00
} ,
2026-06-10 15:56:30 +02:00
edges : currentEdges ,
2026-06-10 13:05:49 +02:00
graphName : draft . graphName ,
} )
2026-06-10 15:56:30 +02:00
const withPhases = syncSlotPhasesFromRoadmap ( hydrated , roadmap )
2026-06-10 16:17:40 +02:00
setDraft ( { ... withPhases , goalQuery : q , maxSteps : majorCount || withPhases . maxSteps , dirty : true } )
2026-06-10 15:34:37 +02:00
setSemanticBrief ( res ? . semantic _brief _summary || null )
2026-06-10 13:05:49 +02:00
} catch ( e ) {
setActionErr ( e . message || 'Roadmap-Generierung fehlgeschlagen' )
} finally {
setRoadmapLoading ( false )
}
}
2026-06-12 13:33:36 +02:00
const buildEvaluateRequest = ( synced ) => {
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 : true ,
include _ai _gap _fill : true ,
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 ) => api . suggestProgressionPath ( buildEvaluateRequest ( synced ) )
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 } , remainingOffers }
}
2026-06-12 13:22:04 +02:00
const buildMatchRequestBase = ( synced ) => {
const override = majorStepsToOverridePayload ( synced . slots )
return {
query : ( synced . goalQuery || '' ) . trim ( ) ,
max _steps : synced . slots . length ,
include _llm _intent : true ,
include _path _qa : true ,
include _llm _path _qa : true ,
include _path _reorder : false ,
include _ai _gap _fill : true ,
include _roadmap _preview : true ,
include _llm _roadmap : false ,
roadmap _first : true ,
roadmap _override : override ,
slot _assignments : slotsToSlotAssignments ( synced ) ,
retrieval _boost _exercise _ids : draftRetrievalBoostExerciseIds ( synced ) ,
progression _graph _id : Number ( graphId ) ,
... roadmapStructuredPayload ( synced . startSituation , synced . targetState , synced . roadmapNotes ) ,
... catalogApiPayload ,
}
}
2026-06-13 08:17:59 +02:00
const fetchMatchCompare = async ( synced ) => {
2026-06-12 13:22:04 +02:00
const res = await api . suggestProgressionPath ( {
... buildMatchRequestBase ( synced ) ,
2026-06-12 13:33:36 +02:00
evaluate _steps : slotsToEvaluateSteps ( synced ) ,
2026-06-12 13:22:04 +02:00
compare _with _assignments : true ,
} )
if ( ! res ? . comparison _mode ) {
throw new Error ( 'Kein Vergleich in der Antwort' )
}
return res
}
2026-06-13 08:36:53 +02:00
const runMatchCompareFlow = async ( synced , { source = 'match' } = { } ) => {
const res = await fetchMatchCompare ( synced )
const evalRes = await fetchPathEvaluate ( synced )
setGapFillOffers ( mergeGapOffersForDraft ( synced , res , evalRes ) )
presentMatchCompare ( res , { source } )
setPathQa ( evalRes ? . path _qa || res ? . baseline _path _qa || null )
return res
}
2026-06-13 08:17:59 +02:00
const presentMatchCompare = ( res , { source = 'manual' } = { } ) => {
2026-06-12 13:22:04 +02:00
setSemanticBrief ( res ? . semantic _brief _summary || null )
setTargetSummary ( res ? . target _profile _summary || null )
2026-06-13 08:17:59 +02:00
setComparePayload ( res )
setCompareSource ( source )
setProposedPathQa ( res ? . proposed _path _qa _pipeline || null )
setCompareOpen ( true )
2026-06-12 13:22:04 +02:00
const baselineQa = res ? . baseline _path _qa || null
const proposedQa = res ? . proposed _path _qa || res ? . path _qa || null
2026-06-13 08:17:59 +02:00
const diffCount =
2026-06-13 07:55:47 +02:00
res ? . slot _diff _count ? ? compareSlotDiffs ( res , { actionableOnly : true } ) . length
2026-06-13 08:17:59 +02:00
const bPct = pathQaQualityPercent ( baselineQa )
const pPct = pathQaQualityPercent ( proposedQa )
let notice =
diffCount > 0
? ` Match: ${ diffCount } Slot-Vorschlag/Vorschläge — bitte im Dialog prüfen und auswählen. `
2026-06-13 08:36:53 +02:00
: 'Match: Keine abweichenden Bibliotheks-Slots — Dialog zur Kontrolle geöffnet.'
const gapCount = collectGapOffersFromApiResponse ( res ) . length
if ( gapCount > 0 ) {
notice += ` ${ gapCount } KI-Angebot(e) für leere Slots im Panel „Graph-Bewertung“. `
}
2026-06-13 08:17:59 +02:00
if ( bPct != null && pPct != null && pPct !== bPct ) {
notice += ` Pfad-QS Vorschlag fair bewertet: ${ bPct } % → ${ pPct } %. `
2026-06-12 13:22:04 +02:00
}
2026-06-13 08:17:59 +02:00
setMatchNotice ( notice )
}
2026-06-12 13:22:04 +02:00
2026-06-10 13:05:49 +02:00
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 ( '' )
2026-06-11 11:17:53 +02:00
setMatchNotice ( '' )
2026-06-10 13:05:49 +02:00
try {
2026-06-10 15:34:37 +02:00
const synced = syncProgressionRoadmapFromSlots ( draft )
2026-06-12 13:33:36 +02:00
setProposedPathQa ( null )
2026-06-13 08:17:59 +02:00
await runMatchCompareFlow ( synced , { source : 'match' } )
2026-06-10 13:05:49 +02:00
} catch ( e ) {
setActionErr ( e . message || 'Übungs-Match fehlgeschlagen' )
} finally {
setMatching ( false )
}
}
2026-06-12 13:22:04 +02:00
const runOptimizeCompare = async ( ) => {
const q = ( draft ? . goalQuery || '' ) . trim ( )
if ( q . length < 3 ) {
alert ( 'Ziel-Anfrage: mindestens 3 Zeichen.' )
return
}
2026-06-13 08:17:59 +02:00
if ( validMajorSteps . length < 2 ) {
alert ( 'Mindestens zwei Slots mit Lernziel (je 3+ Zeichen) nötig.' )
2026-06-12 13:22:04 +02:00
return
}
setComparing ( true )
setActionErr ( '' )
setMatchNotice ( '' )
try {
const synced = syncProgressionRoadmapFromSlots ( draft )
2026-06-13 08:17:59 +02:00
setProposedPathQa ( null )
await runMatchCompareFlow ( synced , { source : 'manual' } )
2026-06-12 13:22:04 +02:00
} catch ( e ) {
setActionErr ( e . message || 'Optimierungs-Vergleich fehlgeschlagen' )
} finally {
setComparing ( false )
}
}
const applyOptimizeCompare = async ( selectedMajorIndices ) => {
if ( ! comparePayload || ! draft ) return
setCompareApplying ( true )
try {
const synced = syncProgressionRoadmapFromSlots ( draft )
const nextDraft = applySelectedCompareSteps (
synced ,
comparePayload . proposed _steps || comparePayload . steps ,
selectedMajorIndices ,
)
2026-06-12 13:33:36 +02:00
const syncedNext = syncProgressionRoadmapFromSlots ( nextDraft )
const evalRes = await fetchPathEvaluate ( syncedNext )
const { draft : evaluated , remainingOffers } = applyEvaluateResult ( syncedNext , evalRes )
setDraft ( { ... evaluated , dirty : true } )
2026-06-13 08:36:53 +02:00
const mergedOffers = mergeGapOffersForDraft ( evaluated , comparePayload , evalRes )
setGapFillOffers ( mergedOffers . length > 0 ? mergedOffers : remainingOffers )
2026-06-12 13:33:36 +02:00
setProposedPathQa ( null )
2026-06-12 13:22:04 +02:00
setCompareOpen ( false )
setComparePayload ( null )
2026-06-12 13:33:36 +02:00
setMatchNotice ( 'Ausgewählte Optimierungen übernommen — Pfad-QS neu bewertet.' )
2026-06-12 13:22:04 +02:00
await saveProgressionGraphDraft ( api , graphId , {
2026-06-12 13:33:36 +02:00
... evaluated ,
lastFindings : evalRes ? . path _qa || null ,
2026-06-12 13:22:04 +02:00
} )
setDraft ( ( prev ) => ( prev ? { ... prev , dirty : false } : prev ) )
} catch ( e ) {
setActionErr ( e . message || 'Übernahme fehlgeschlagen' )
} finally {
setCompareApplying ( false )
}
}
2026-06-10 13:05:49 +02:00
const runEvaluate = async ( ) => {
const q = ( draft ? . goalQuery || '' ) . trim ( )
if ( q . length < 3 ) {
alert ( 'Ziel-Anfrage: mindestens 3 Zeichen.' )
return
}
setEvaluating ( true )
setActionErr ( '' )
2026-06-12 13:33:36 +02:00
setProposedPathQa ( null )
2026-06-10 13:05:49 +02:00
try {
2026-06-10 15:34:37 +02:00
const synced = syncProgressionRoadmapFromSlots ( draft )
2026-06-12 13:33:36 +02:00
const res = await fetchPathEvaluate ( synced )
const { draft : evaluated , remainingOffers } = applyEvaluateResult ( synced , res )
setDraft ( evaluated )
2026-06-13 08:36:53 +02:00
const mergedOffers = mergeGapOffersForDraft ( evaluated , res )
setGapFillOffers ( mergedOffers . length > 0 ? mergedOffers : remainingOffers )
2026-06-10 13:05:49 +02:00
} 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 ( )
2026-06-10 15:42:29 +02:00
if ( typeof onSaved === 'function' ) await onSaved ( )
2026-06-10 13:05:49 +02:00
alert ( 'Progressionsgraph gespeichert.' )
} catch ( e ) {
setActionErr ( e . message || 'Speichern fehlgeschlagen' )
} finally {
setBusy ( false )
}
}
2026-06-10 15:34:37 +02:00
const handleApplyGapOffer = ( offer , slotIndex ) => {
setDraft ( ( prev ) => {
const next = applyGapOfferToDraft ( prev , offer , { slotIndex } )
return { ... next , dirty : 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 }
} )
setGapFillOffers ( ( prev ) => prev . filter ( ( o ) => o . offer _id !== offer ? . offer _id ) )
}
2026-06-10 16:04:15 +02:00
const slotOfferContext = ( slotIndex ) => {
const slot = draft ? . slots ? . [ slotIndex ]
2026-06-11 11:48:25 +02:00
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
2026-06-10 16:04:15 +02:00
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' ,
2026-06-11 11:48:25 +02:00
goal _for _ai : goalForAi ,
sketch : goalForAi ,
from _title : priorSlot ? . primary ? . exerciseTitle || null ,
2026-06-10 16:04:15 +02:00
}
}
2026-06-10 15:34:37 +02:00
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 ) : '' )
2026-06-10 16:04:15 +02:00
setActivePlanningContextLines ( gapOfferContextDisplayLines ( offer , gapContextParams ) )
2026-06-10 15:34:37 +02:00
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 ( '' )
2026-06-10 16:04:15 +02:00
setSlotQuickError ( '' )
const contextParams = {
... gapContextParams ,
stageLearningGoalOverride : stageGoal ,
gapTrainerSupplements : supplements ,
}
setActivePlanningContextLines ( gapOfferContextDisplayLines ( offer , contextParams ) )
2026-06-10 15:34:37 +02:00
try {
const planningContext = buildPathGapPlanningContextForAi ( {
offer ,
2026-06-10 16:04:15 +02:00
... contextParams ,
2026-06-10 15:34:37 +02:00
} )
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 ,
}
2026-06-10 16:04:15 +02:00
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 } ) )
2026-06-10 15:56:30 +02:00
}
2026-06-10 16:04:15 +02:00
setSlotQuickCreateDraft ( aiDraft )
2026-06-10 15:34:37 +02:00
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 )
}
}
2026-06-10 15:56:30 +02:00
const openSlotQuickCreate = ( slotIndex ) => {
const slot = draft ? . slots ? . [ slotIndex ]
if ( ! slot ) return
const primary = slot . primary
2026-06-10 16:04:15 +02:00
const offer = slotOfferContext ( slotIndex )
2026-06-10 15:56:30 +02:00
setSlotQuickCreateIndex ( slotIndex )
setSlotQuickError ( '' )
2026-06-10 16:04:15 +02:00
setActiveOffer ( offer )
setActiveOfferSlotIndex ( slotIndex )
setActivePlanningContextLines ( gapOfferContextDisplayLines ( offer , gapContextParams ) )
2026-06-10 15:56:30 +02:00
if ( primary ? . kind === 'proposal' && primary . aiSuggestion ) {
2026-06-10 16:04:15 +02:00
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
}
2026-06-10 15:56:30 +02:00
}
2026-06-10 16:04:15 +02:00
openGapFillPrep ( offer , slotIndex )
2026-06-10 15:56:30 +02:00
}
const applySlotQuickCreate = async ( ) => {
if ( slotQuickCreateIndex == null || ! slotQuickCreateDraft ) return
setSlotQuickSaving ( true )
setSlotQuickError ( '' )
try {
const payload = buildQuickCreateExercisePayloadFromDraft ( slotQuickCreateDraft )
const created = await api . createExercise ( payload )
if ( ! created ? . id ) throw new Error ( 'Anlegen fehlgeschlagen' )
setDraft ( ( prev ) => setSlotPrimaryLibrary ( prev , slotQuickCreateIndex , created ) )
setSlotQuickCreateDraft ( null )
setSlotQuickCreateIndex ( null )
2026-06-10 16:04:15 +02:00
setActiveOffer ( null )
setActiveOfferSlotIndex ( null )
setActivePlanningContextLines ( [ ] )
2026-06-10 15:56:30 +02:00
} catch ( e ) {
2026-06-10 16:04:15 +02:00
const msg = e . message || 'Übung konnte nicht angelegt werden'
setSlotQuickError ( msg )
alert ( msg )
2026-06-10 15:56:30 +02:00
} finally {
setSlotQuickSaving ( false )
}
}
2026-06-10 15:34:37 +02:00
const submitGapFillPrep = async ( ) => {
const title = ( gapPrepTitle || '' ) . trim ( )
if ( title . length < 3 ) {
alert ( 'Titel: mindestens 3 Zeichen.' )
2026-06-10 13:05:49 +02:00
return
}
2026-06-10 15:34:37 +02:00
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 ,
)
2026-06-10 13:05:49 +02:00
}
if ( loadErr ) {
return (
< div className = "card" >
< p className = "form-error" > { loadErr } < / p >
< Link to = "/exercises" className = "btn btn-secondary" >
Zurück
< / Link >
< / div >
)
}
if ( ! draft ) {
return (
< div style = { { display : 'flex' , justifyContent : 'center' , padding : '40px' } } >
< div className = "spinner" / >
< / div >
)
}
return (
< div >
2026-06-10 15:42:29 +02:00
{ ! embedded ? (
< div style = { { marginBottom : '12px' , display : 'flex' , justifyContent : 'space-between' , alignItems : 'flex-start' , flexWrap : 'wrap' , gap : '8px' } } >
< div >
< h2 style = { { margin : 0 , fontSize : '1.15rem' } } >
{ graphMeta ? . name || draft . graphName || ` Graph # ${ graphId } ` }
< / h2 >
< p style = { { margin : '4px 0 0' , fontSize : '12px' , color : 'var(--text3)' } } >
Roadmap - Slots · KI - Match · Graph - Bewertung ( max . { SLOT _MAX } Slots )
< / p >
< / div >
< Link to = "/exercises" className = "btn btn-secondary" style = { { fontSize : '12px' } } >
Zur Übersicht
< / Link >
2026-06-10 13:05:49 +02:00
< / div >
2026-06-10 15:42:29 +02:00
) : null }
2026-06-10 13:05:49 +02:00
{ actionErr ? (
< p className = "form-error" style = { { marginTop : 0 } } >
{ actionErr }
< / p >
) : null }
< div
className = "progression-graph-editor-grid"
style = { {
display : 'grid' ,
2026-06-10 15:34:37 +02:00
gridTemplateColumns : 'minmax(0, 1fr) minmax(280px, 340px)' ,
2026-06-10 13:05:49 +02:00
gap : '14px' ,
alignItems : 'start' ,
} }
>
< div >
< div className = "card" style = { { marginBottom : '12px' } } >
< h3 style = { { marginTop : 0 , fontSize : '1rem' } } > Ziel & Roadmap < / h3 >
2026-06-10 16:17:40 +02:00
< div
style = { {
display : 'flex' ,
flexWrap : 'wrap' ,
gap : '10px' ,
alignItems : 'flex-end' ,
marginBottom : '10px' ,
} }
>
< div className = "form-row" style = { { flex : '2 1 240px' , marginBottom : 0 } } >
< label className = "form-label" > Ziel / Entwicklungsrichtung < / label >
2026-06-10 13:05:49 +02:00
< input
className = "form-input"
2026-06-10 16:17:40 +02:00
value = { draft . goalQuery }
disabled = { busy }
onChange = { ( e ) => patchDraft ( ( d ) => ( { ... d , goalQuery : e . target . value } ) ) }
placeholder = "z. B. Von Erlernen bis zur Perfektion des Fußtritts Mae Geri …"
/ >
< / div >
< div className = "form-row" style = { { flex : '0 1 100px' , marginBottom : 0 } } >
< label className = "form-label" > Stufen ( Slots ) < / label >
< input
type = "number"
min = { SLOT _MIN }
max = { SLOT _MAX }
className = "form-input"
value = { draft . maxSteps }
disabled = { busy }
onChange = { ( e ) =>
patchDraft ( ( d ) => ( {
... d ,
maxSteps : Math . max ( SLOT _MIN , Math . min ( SLOT _MAX , Number ( e . target . value ) || 5 ) ) ,
} ) )
}
/ >
< / div >
< / div >
< div
style = { {
display : 'grid' ,
gridTemplateColumns : 'repeat(auto-fit, minmax(220px, 1fr))' ,
gap : '10px' ,
} }
>
< div className = "form-row" style = { { marginBottom : 0 } } >
< label className = "form-label" > Startpunkt / Ausgangslage < / label >
< textarea
className = "form-input"
rows = { 2 }
2026-06-10 13:05:49 +02:00
value = { draft . startSituation }
disabled = { busy }
onChange = { ( e ) => patchDraft ( ( d ) => ( { ... d , startSituation : e . target . value } ) ) }
2026-06-10 16:17:40 +02:00
placeholder = "z. B. gleichartige Steppbewegung, vorhersehbar"
2026-06-10 13:05:49 +02:00
/ >
< / div >
2026-06-10 16:17:40 +02:00
< div className = "form-row" style = { { marginBottom : 0 } } >
< label className = "form-label" > Zielzustand < / label >
< textarea
2026-06-10 13:05:49 +02:00
className = "form-input"
2026-06-10 16:17:40 +02:00
rows = { 2 }
2026-06-10 13:05:49 +02:00
value = { draft . targetState }
disabled = { busy }
onChange = { ( e ) => patchDraft ( ( d ) => ( { ... d , targetState : e . target . value } ) ) }
2026-06-10 16:17:40 +02:00
placeholder = "z. B. dynamische Bewegung mit explosivem Angriff und Ausweichen"
/ >
< / div >
< div className = "form-row" style = { { marginBottom : 0 } } >
< label className = "form-label" > Ergänzungen ( Fokus , Gruppe , Besonderheiten ) < / label >
< textarea
className = "form-input"
rows = { 2 }
value = { draft . roadmapNotes }
disabled = { busy }
onChange = { ( e ) => patchDraft ( ( d ) => ( { ... d , roadmapNotes : e . target . value } ) ) }
placeholder = "optional: Altersgruppe, Kumite-Kontext, Trainingsfokus …"
2026-06-10 13:05:49 +02:00
/ >
< / div >
< / div >
2026-06-12 12:25:52 +02:00
< PlanningCatalogContextFields
catalogCtx = { catalogCtx }
onPatchDimension = { patchCatalogDimension }
focusAreas = { focusAreas }
styleDirections = { styleDirections }
trainingTypes = { trainingTypes }
targetGroups = { targetGroups }
disabled = { busy }
helperText = "Planungskontext steuert Bibliotheks-Matching (Fokusbereich, Stil, Trainingsstil, Zielgruppe) — unabhängig von Technik-Pfaden wie Mae Geri. Wird mit dem Graph gespeichert."
/ >
2026-06-10 16:17:40 +02:00
< p style = { { fontSize : '11px' , color : 'var(--text3)' , margin : '8px 0 0' , lineHeight : 1.4 } } >
Optional zuerst „ Start / Ziel analysieren “ , anpassen , dann Roadmap - Stufen . Sind Start und Ziel leer ,
geschieht die Analyse beim Roadmap - Vorschlag automatisch . Manuelle Eingaben haben Vorrang .
< / p >
< div style = { { display : 'flex' , flexWrap : 'wrap' , gap : '8px' , marginTop : '10px' , alignItems : 'center' } } >
< button
type = "button"
className = "btn btn-secondary"
disabled = { busy || startTargetLoading }
onClick = { runAnalyzeStartTarget }
title = "Nur Ausgangslage, Zielzustand und Ergänzungen per KI — ohne Roadmap-Stufen"
>
{ startTargetLoading ? 'Analyse…' : 'Start/Ziel analysieren' }
< / button >
2026-06-10 13:05:49 +02:00
< button
type = "button"
className = "btn btn-secondary"
disabled = { busy || roadmapLoading }
onClick = { runRoadmapGenerate }
>
{ roadmapLoading ? 'Roadmap…' : 'Roadmap generieren' }
< / button >
2026-06-10 16:17:40 +02:00
{ startTargetReady ? (
< span className = "exercise-tag" style = { { borderColor : 'var(--accent)' } } >
Start / Ziel bereit
< / span >
) : null }
2026-06-10 13:05:49 +02:00
< button
type = "button"
className = "btn btn-secondary"
disabled = { busy || matching }
onClick = { runMatch }
2026-06-12 13:22:04 +02:00
title = {
draftHasLibrarySlotAssignments ( draft )
? 'Voller Match mit Auto-Optimierung — bei Abweichungen öffnet sich der Vergleichsdialog'
: 'Bibliotheks-Übungen für leere Slots finden'
}
2026-06-10 13:05:49 +02:00
>
{ matching ? 'Match…' : 'Übungen matchen' }
< / button >
2026-06-12 13:22:04 +02:00
{ draftHasLibrarySlotAssignments ( draft ) ? (
< button
type = "button"
className = "btn btn-secondary"
disabled = { busy || comparing || matching }
onClick = { runOptimizeCompare }
title = "Aktuellen Pfad vs. voller Match mit Auto-Optimierung — du wählst pro Slot"
>
{ comparing ? 'Vergleich…' : 'Optimierung vergleichen' }
< / button >
) : null }
2026-06-10 13:05:49 +02:00
< button type = "button" className = "btn btn-primary" disabled = { busy } onClick = { handleSave } >
{ busy ? 'Speichern…' : 'Graph speichern' }
< / button >
< / div >
2026-06-11 11:17:53 +02:00
{ matchNotice ? (
< p style = { { margin : '8px 0 0' , fontSize : '11px' , color : 'var(--accent-dark)' } } > { matchNotice } < / p >
) : null }
2026-06-10 13:05:49 +02:00
{ draft . dirty ? (
< p style = { { margin : '8px 0 0' , fontSize : '11px' , color : 'var(--accent-dark)' } } >
Ungespeicherte Änderungen
< / p >
) : null }
< / div >
< div style = { { display : 'flex' , justifyContent : 'space-between' , alignItems : 'center' , marginBottom : '8px' } } >
< h3 style = { { margin : 0 , fontSize : '1rem' } } > Slots ( { draft . slots . length } ) < / h3 >
2026-06-10 15:34:37 +02:00
< button
type = "button"
className = "btn btn-secondary"
style = { { fontSize : '12px' } }
disabled = { busy || draft . slots . length >= SLOT _MAX }
onClick = { handleAddSlot }
>
Slot am Ende
2026-06-10 13:05:49 +02:00
< / button >
< / div >
{ draft . slots . map ( ( slot , idx ) => (
< ProgressionSlotCard
2026-06-10 15:34:37 +02:00
key = { ` slot- ${ idx } - ${ slot . learning _goal ? . slice ( 0 , 12 ) || 'x' } ` }
2026-06-10 13:05:49 +02:00
slot = { slot }
slotIndex = { idx }
2026-06-10 15:34:37 +02:00
slotCount = { draft . slots . length }
2026-06-10 13:05:49 +02:00
disabled = { busy }
onPickPrimary = { ( i ) => setPickContext ( { slotIndex : i , role : 'primary' } ) }
onPickSibling = { ( i ) => setPickContext ( { slotIndex : i , role : 'sibling' } ) }
onClearPrimary = { handleClearPrimary }
onRemoveSibling = { handleRemoveSibling }
onPatchLearningGoal = { handlePatchLearningGoal }
2026-06-10 15:34:37 +02:00
onPatchPhase = { handlePatchPhase }
onMoveUp = { ( i ) => handleMoveSlot ( i , - 1 ) }
onMoveDown = { ( i ) => handleMoveSlot ( i , 1 ) }
onRemoveSlot = { handleRemoveSlot }
onInsertAfter = { handleInsertAfter }
2026-06-10 15:56:30 +02:00
onCreateFromProposal = { openSlotQuickCreate }
2026-06-10 13:05:49 +02:00
/ >
) ) }
< / div >
< ProgressionFindingsPanel
pathQa = { pathQa }
gapFillOffers = { gapFillOffers }
2026-06-10 15:34:37 +02:00
draft = { draft }
slotCount = { draft . slots . length }
2026-06-10 13:05:49 +02:00
loading = { evaluating }
2026-06-10 15:34:37 +02:00
error = ""
2026-06-10 13:05:49 +02:00
onEvaluate = { runEvaluate }
onApplyGapOffer = { handleApplyGapOffer }
2026-06-10 15:34:37 +02:00
onInsertGapSlot = { handleInsertGapSlot }
onGenerateGapAi = { openGapFillPrep }
2026-06-11 21:39:16 +02:00
onRematchSlots = { runMatch }
2026-06-12 13:22:04 +02:00
onOptimizeCompare = { runOptimizeCompare }
2026-06-13 08:17:59 +02:00
canOptimizeCompare = { validMajorSteps . length >= 2 }
2026-06-12 13:22:04 +02:00
optimizeCompareBusy = { comparing }
2026-06-11 21:39:16 +02:00
rematchBusy = { matching }
2026-06-10 15:34:37 +02:00
generatingOfferId = { generatingOfferId }
aiBusy = { gapAiBusy }
2026-06-10 13:05:49 +02:00
evaluateDisabled = { busy || ! draft . goalQuery ? . trim ( ) }
/ >
< / div >
{ pickContext ? (
< ExercisePickerModal
open
onClose = { ( ) => setPickContext ( null ) }
onSelectExercise = { handlePickExercise }
/ >
) : null }
2026-06-10 16:04:15 +02:00
< ExerciseAiSuggestPreviewModal
draft = { slotQuickCreateDraft }
onDraftChange = { setSlotQuickCreateDraft }
onDiscard = { ( ) => {
2026-06-10 15:56:30 +02:00
if ( slotQuickSaving ) return
setSlotQuickCreateDraft ( null )
setSlotQuickError ( '' )
2026-06-10 16:04:15 +02:00
if ( activeOffer ) {
setGapPrepOpen ( true )
} else {
setActivePlanningContextLines ( [ ] )
}
2026-06-10 15:56:30 +02:00
} }
2026-06-10 16:04:15 +02:00
planningContextLines = { activePlanningContextLines }
onApply = { applySlotQuickCreate }
focusAreas = { focusAreas }
skillsCatalog = { skillsCatalog }
dialogTitle = "Progressions-Slot — KI-Entwurf bearbeiten"
hint = "Texte prüfen und anpassen, dann als Übung speichern — sie wird dem Slot zugeordnet."
applyLabel = { slotQuickSaving ? 'Wird angelegt …' : 'Anlegen und Slot zuweisen' }
applyDisabled = { slotQuickSaving }
zIndex = { 2100 }
2026-06-10 15:56:30 +02:00
/ >
2026-06-12 13:22:04 +02:00
< ProgressionOptimizeCompareModal
open = { compareOpen }
comparison = { comparePayload }
2026-06-13 08:17:59 +02:00
mode = { compareSource }
2026-06-12 13:22:04 +02:00
onClose = { ( ) => {
if ( compareApplying ) return
setCompareOpen ( false )
setComparePayload ( null )
2026-06-12 13:33:36 +02:00
setProposedPathQa ( null )
2026-06-12 13:22:04 +02:00
} }
onApplySelected = { applyOptimizeCompare }
applying = { compareApplying }
/ >
2026-06-10 15:34:37 +02:00
< ExerciseGapFillPrepModal
open = { gapPrepOpen }
offer = { activeOffer }
onClose = { ( ) => {
if ( gapAiBusy ) return
setGapPrepOpen ( false )
setGapPrepError ( '' )
} }
title = { gapPrepTitle }
onTitleChange = { setGapPrepTitle }
stageLearningGoal = { gapPrepStageGoal }
onStageLearningGoalChange = { setGapPrepStageGoal }
supplements = { gapPrepSupplements }
onSupplementsChange = { setGapPrepSupplements }
focusAreaId = { gapPrepFocusAreaId }
onFocusAreaChange = { setGapPrepFocusAreaId }
focusAreas = { focusAreas }
contextLines = { gapOfferContextDisplayLines ( activeOffer , gapContextParams ) }
error = { gapPrepError }
busy = { gapAiBusy }
onSubmit = { submitGapFillPrep }
/ >
2026-06-10 13:05:49 +02:00
< style > { `
@ media ( max - width : 900 px ) {
. progression - graph - editor - grid {
grid - template - columns : 1 fr ! important ;
}
}
` }</style>
< / div >
)
}