2026-05-23 11:46:25 +02:00
/ * *
2026-05-23 12:59:46 +02:00
* Planungs - KI Phase C3 / E3 : Ziel → Übungspfad vorschlagen → Lücken mit KI anlegen → in Graph speichern .
2026-05-23 11:46:25 +02:00
* /
2026-06-10 07:30:01 +02:00
import React , { useCallback , useEffect , useMemo , useState } from 'react'
2026-05-23 11:46:25 +02:00
import api from '../utils/api'
2026-05-23 12:59:46 +02:00
import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal'
2026-06-10 06:54:49 +02:00
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
2026-05-23 12:59:46 +02:00
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
import {
aiPreviewToQuickCreateDraft ,
buildQuickCreateAiPreview ,
buildQuickCreateExercisePayloadFromDraft ,
} from '../utils/exerciseAiQuickCreate'
2026-06-09 16:27:03 +02:00
import {
buildPathGapPlanningContextForAi ,
gapOfferContextDisplayLines ,
2026-06-10 06:54:49 +02:00
initialStageLearningGoalFromOffer ,
2026-06-09 16:27:03 +02:00
} from '../utils/planningContextForExerciseAi'
2026-05-23 11:46:25 +02:00
2026-06-09 12:54:08 +02:00
function applyResolvedStructuredFromRoadmap ( progressionRoadmap , setters ) {
const rs = progressionRoadmap ? . resolved _structured
if ( ! rs ) return
if ( rs . start _situation ) setters . setStartSituation ( String ( rs . start _situation ) )
if ( rs . target _state ) setters . setTargetState ( String ( rs . target _state ) )
if ( rs . roadmap _notes ) setters . setRoadmapNotes ( String ( rs . roadmap _notes ) )
}
2026-06-09 16:27:03 +02:00
function GapOfferContextPreview ( { lines } ) {
if ( ! Array . isArray ( lines ) || lines . length === 0 ) return null
return (
< details
style = { {
marginTop : '8px' ,
fontSize : '12px' ,
color : 'var(--text2)' ,
borderTop : '1px dashed var(--border)' ,
paddingTop : '8px' ,
} }
>
< summary style = { { cursor : 'pointer' , color : 'var(--accent-dark)' , fontWeight : 600 } } >
KI - Kontext für diese Übung ( { lines . length } Punkte )
< / summary >
< dl style = { { margin : '8px 0 0' , display : 'grid' , gap : '6px' } } >
{ lines . map ( ( { label , value } ) => (
< div key = { label } >
< dt style = { { margin : 0 , fontSize : '11px' , color : 'var(--text3)' } } > { label } < / dt >
< dd style = { { margin : '2px 0 0' , lineHeight : 1.45 } } > { value } < / dd >
< / div >
) ) }
< / dl >
< p style = { { margin : '8px 0 0' , fontSize : '11px' , color : 'var(--text3)' , lineHeight : 1.4 } } >
Dieser Kontext wird an die Übungs - KI übergeben ( Ziel , Fähigkeiten , Anleitung ) — nicht nur das
Stufen - Lernziel oben .
< / p >
< / details >
)
}
2026-06-09 12:54:08 +02:00
function sourceLabel ( source ) {
const map = {
user : 'manuell' ,
llm : 'KI-Extraktion' ,
regex : 'Muster (von … bis …)' ,
merged : 'manuell + KI' ,
heuristic : 'heuristisch' ,
none : '—' ,
}
return map [ source ] || source || '—'
2026-06-09 11:10:46 +02:00
}
function roadmapStructuredPayload ( startSituation , targetState , roadmapNotes ) {
const start = ( startSituation || '' ) . trim ( )
const target = ( targetState || '' ) . trim ( )
const notes = ( roadmapNotes || '' ) . trim ( )
const body = { }
if ( start ) body . start _situation = start
if ( target ) body . target _state = target
if ( notes ) body . roadmap _notes = notes
return body
}
2026-05-23 11:46:25 +02:00
function emptyPathStep ( ) {
return { exerciseId : null , exerciseTitle : '' , variantId : null , variants : [ ] , reasons : [ ] }
}
function mapApiStepToRow ( step ) {
const variants = Array . isArray ( step ? . variants ) ? step . variants : [ ]
const rawVid = step ? . variant _id ? ? step ? . suggested _variant _id ? ? null
const variantId =
rawVid != null && Number . isFinite ( Number ( rawVid ) ) && Number ( rawVid ) > 0 ? Number ( rawVid ) : null
2026-05-23 12:32:14 +02:00
const isAiProposal = Boolean ( step ? . is _ai _proposal ) || step ? . exercise _id == null
2026-05-23 11:46:25 +02:00
return {
exerciseId : step ? . exercise _id != null ? Number ( step . exercise _id ) : null ,
2026-05-23 12:32:14 +02:00
proposalKey : step ? . proposal _key || null ,
exerciseTitle :
( step ? . title || '' ) . trim ( ) ||
( step ? . exercise _id ? ` Übung # ${ step . exercise _id } ` : 'KI-Vorschlag' ) ,
variantId : isAiProposal ? null : variantId ,
variants : isAiProposal ? [ ] : variants ,
2026-05-23 11:46:25 +02:00
reasons : Array . isArray ( step ? . reasons ) ? step . reasons : [ ] ,
2026-05-23 12:02:57 +02:00
isBridge : Boolean ( step ? . is _bridge ) ,
2026-05-23 12:32:14 +02:00
isAiProposal ,
aiSuggestion : step ? . ai _suggestion || null ,
2026-05-23 12:02:57 +02:00
semanticScore : step ? . semantic _score ,
2026-05-23 12:59:46 +02:00
isOffTopic : false ,
2026-06-08 14:59:24 +02:00
roadmapMajorStepIndex :
step ? . roadmap _major _step _index != null ? Number ( step . roadmap _major _step _index ) : null ,
roadmapPhase : step ? . roadmap _phase || null ,
roadmapLearningGoal : step ? . roadmap _learning _goal || null ,
2026-06-10 07:14:18 +02:00
skillExpectations : step ? . skill _expectations || null ,
2026-05-23 11:46:25 +02:00
}
}
2026-05-23 12:59:46 +02:00
function mapCreatedExerciseToRow ( ex , offer ) {
return {
exerciseId : Number ( ex . id ) ,
proposalKey : null ,
exerciseTitle : ( ex . title || offer ? . title _hint || '' ) . trim ( ) || ` Übung # ${ ex . id } ` ,
variantId : null ,
variants : [ ] ,
reasons : [ 'Neu angelegt zur Schließung einer Pfad-Lücke' ] ,
isBridge : true ,
isAiProposal : false ,
aiSuggestion : null ,
semanticScore : null ,
isOffTopic : false ,
}
}
const OFFER _SOURCE _LABELS = {
unfilled _gap : 'Lücke' ,
off _topic : 'Themenfremd' ,
llm _suggested : 'QS-Empfehlung' ,
2026-06-08 12:40:17 +02:00
roadmap _unfilled : 'Roadmap-Stufe' ,
2026-05-23 12:59:46 +02:00
}
2026-06-08 14:51:15 +02:00
const PATH _STEPS _HARD _MAX = 10
2026-06-10 07:30:01 +02:00
const WIZARD _STEPS = [
{ id : 1 , label : 'Ziel & Start/Ziel' , short : 'Ziel' } ,
{ id : 2 , label : 'Roadmap' , short : 'Roadmap' } ,
{ id : 3 , label : 'Match' , short : 'Match' } ,
{ id : 4 , label : 'Lücken & Speichern' , short : 'Speichern' } ,
]
function computeMaxReachableStep ( editableMajorSteps , pathSteps ) {
if ( pathSteps . length > 0 ) return 4
if ( editableMajorSteps . length >= 2 ) return 2
return 1
}
function PlanningWizardStepper ( { currentStep , maxReachable , onStepChange , disabled } ) {
return (
< nav
aria - label = "Planungsfortschritt"
style = { {
display : 'flex' ,
flexWrap : 'wrap' ,
gap : '4px' ,
marginBottom : '16px' ,
padding : '10px 12px' ,
borderRadius : '10px' ,
background : 'var(--surface2)' ,
border : '1px solid var(--border)' ,
} }
>
{ WIZARD _STEPS . map ( ( step , idx ) => {
const reachable = step . id <= maxReachable
const active = currentStep === step . id
const done = step . id < currentStep && reachable
const canClick = reachable && ! disabled && step . id <= maxReachable
return (
< React.Fragment key = { step . id } >
{ idx > 0 ? (
< span
aria - hidden
style = { {
alignSelf : 'center' ,
color : done || active ? 'var(--accent)' : 'var(--text3)' ,
fontSize : '12px' ,
padding : '0 2px' ,
} }
>
→
< / span >
) : null }
< button
type = "button"
className = { active ? 'btn btn-primary' : 'btn btn-secondary' }
disabled = { ! canClick }
onClick = { ( ) => canClick && onStepChange ( step . id ) }
style = { {
flex : '1 1 120px' ,
minWidth : '100px' ,
fontSize : '12px' ,
padding : '8px 10px' ,
opacity : reachable ? 1 : 0.45 ,
borderColor : active
? 'var(--accent)'
: done
? 'color-mix(in srgb, var(--accent) 40%, var(--border))'
: undefined ,
} }
title = { step . label }
aria - current = { active ? 'step' : undefined }
>
< span style = { { marginRight : '6px' , fontWeight : 700 } } > { step . id } < / span >
< span className = "planning-step-label-full" > { step . label } < / span >
< span className = "planning-step-label-short" style = { { display : 'none' } } >
{ step . short }
< / span >
< / button >
< / React.Fragment >
)
} ) }
< style > { `
@ media ( max - width : 520 px ) {
. planning - step - label - full { display : none ! important ; }
. planning - step - label - short { display : inline ! important ; }
}
` }</style>
< / nav >
)
}
2026-06-08 14:59:24 +02:00
const ROADMAP _PHASES = [ 'einstieg' , 'grundlage' , 'vertiefung' , 'anwendung' , 'perfektion' ]
2026-06-10 07:14:18 +02:00
const LOAD _PROFILE _OPTIONS = [
'koordination' ,
'präzision' ,
'kraft' ,
'geschwindigkeit' ,
'timing' ,
'reaktion' ,
'distanz' ,
'gleichgewicht' ,
'kime' ,
'ausdauer' ,
'beweglichkeit' ,
]
2026-06-08 14:59:24 +02:00
function mapMajorStepsFromApi ( apiRoadmap ) {
const raw = apiRoadmap ? . roadmap ? . major _steps
if ( ! Array . isArray ( raw ) ) return [ ]
2026-06-10 07:14:18 +02:00
const rows = raw . map ( ( s , i ) => ( {
2026-06-08 14:59:24 +02:00
index : i ,
phase : s . phase || 'vertiefung' ,
learning _goal : ( s . learning _goal || '' ) . trim ( ) ,
consolidates : Array . isArray ( s . consolidates ) ? s . consolidates : [ ] ,
rationale : s . rationale || '' ,
2026-06-10 07:14:18 +02:00
load _profile : [ ] ,
success _criteria : [ ] ,
anti _patterns : [ ] ,
exercise _type : '' ,
2026-06-08 14:59:24 +02:00
} ) )
2026-06-10 07:14:18 +02:00
return mergeStageSpecsIntoMajorSteps ( rows , apiRoadmap )
}
function mergeStageSpecsIntoMajorSteps ( rows , apiRoadmap ) {
const specs = apiRoadmap ? . stage _specs
if ( ! Array . isArray ( specs ) || ! rows . length ) return rows
return rows . map ( ( row , i ) => {
const spec =
specs . find ( ( s ) => Number ( s . major _step _index ) === i ) ||
specs . find ( ( s ) => Number ( s . major _step _index ) === row . index ) ||
specs [ i ]
if ( ! spec ) return row
return {
... row ,
load _profile : Array . isArray ( spec . load _profile ) ? [ ... spec . load _profile ] : [ ] ,
success _criteria : Array . isArray ( spec . success _criteria ) ? [ ... spec . success _criteria ] : [ ] ,
anti _patterns : Array . isArray ( spec . anti _patterns ) ? [ ... spec . anti _patterns ] : [ ] ,
exercise _type : ( spec . exercise _type || '' ) . trim ( ) ,
}
} )
}
function linesToStringList ( text ) {
return String ( text || '' )
. split ( '\n' )
. map ( ( s ) => s . trim ( ) )
. filter ( Boolean )
}
function listToMultiline ( arr ) {
return Array . isArray ( arr ) ? arr . join ( '\n' ) : ''
2026-06-08 14:59:24 +02:00
}
function reindexMajorSteps ( rows ) {
return rows . map ( ( row , i ) => ( { ... row , index : i } ) )
}
function majorStepsToOverridePayload ( rows ) {
2026-06-10 07:14:18 +02:00
const indexed = reindexMajorSteps ( rows )
2026-06-08 14:59:24 +02:00
return {
2026-06-10 07:14:18 +02:00
major _steps : indexed . map ( ( row ) => ( {
2026-06-08 14:59:24 +02:00
index : row . index ,
phase : row . phase || 'vertiefung' ,
learning _goal : row . learning _goal . trim ( ) ,
consolidates : row . consolidates || [ ] ,
rationale : row . rationale || '' ,
} ) ) ,
2026-06-10 07:14:18 +02:00
stage _specs : indexed . map ( ( row , i ) => ( {
major _step _index : i ,
learning _goal : row . learning _goal . trim ( ) ,
load _profile : Array . isArray ( row . load _profile ) ? row . load _profile : [ ] ,
exercise _type : ( row . exercise _type || '' ) . trim ( ) ,
success _criteria : Array . isArray ( row . success _criteria ) ? row . success _criteria : [ ] ,
anti _patterns : Array . isArray ( row . anti _patterns ) ? row . anti _patterns : [ ] ,
} ) ) ,
2026-06-08 14:59:24 +02:00
}
}
2026-06-10 07:14:18 +02:00
function formatExpectedSkillNames ( skillExpectations , limit = 4 ) {
const items = skillExpectations ? . expected _skills
if ( ! Array . isArray ( items ) ) return [ ]
return items
. map ( ( s ) => String ( s ? . skill _name || '' ) . trim ( ) )
. filter ( Boolean )
. slice ( 0 , limit )
}
2026-06-10 07:25:57 +02:00
const PLANNING _ARTIFACT _SCHEMA = 1
function buildPlanningRoadmapArtifactSnapshot ( {
goalQuery ,
startSituation ,
targetState ,
roadmapNotes ,
maxSteps ,
progressionRoadmap ,
pathSkillExpectations ,
} ) {
const q = ( goalQuery || '' ) . trim ( )
if ( ! q && ! progressionRoadmap ) return null
return {
schema _version : PLANNING _ARTIFACT _SCHEMA ,
goal _query : q ,
start _situation : ( startSituation || '' ) . trim ( ) || null ,
target _state : ( targetState || '' ) . trim ( ) || null ,
roadmap _notes : ( roadmapNotes || '' ) . trim ( ) || null ,
max _steps : Number ( maxSteps ) || 5 ,
progression _roadmap : progressionRoadmap || null ,
path _skill _expectations : pathSkillExpectations || null ,
}
}
2026-06-08 14:51:15 +02:00
/** Einfügen wächst den Pfad; Ersetzen (replace_step_index) nicht. */
function offerGrowsPath ( offer ) {
const replaceIdx = offer ? . replace _step _index
return ! ( replaceIdx != null && Number . isFinite ( Number ( replaceIdx ) ) )
}
function isGapOfferBlockedByPathCapacity ( offer , pathLen , maxSteps ) {
return offerGrowsPath ( offer ) && pathLen >= maxSteps
}
function neededMaxStepsAfterInsert ( pathLen ) {
return Math . min ( PATH _STEPS _HARD _MAX , pathLen + 1 )
}
/ * *
* Pfad voll , aber Einfügen gewünscht → Nutzer fragen , ob maxSteps dynamisch wächst .
* @ returns { boolean } true = fortfahren ( ggf . maxSteps erhöht ) , false = abgebrochen
* /
function confirmPathExpansionIfNeeded ( offer , pathLen , maxSteps , setMaxSteps ) {
if ( ! isGapOfferBlockedByPathCapacity ( offer , pathLen , maxSteps ) ) {
return true
}
if ( maxSteps >= PATH _STEPS _HARD _MAX ) {
alert (
` Maximale Pfadlänge ( ${ PATH _STEPS _HARD _MAX } Schritte) erreicht. Bitte zuerst einen Schritt entfernen. ` ,
)
return false
}
const newMax = neededMaxStepsAfterInsert ( pathLen )
const titleHint = ( offer ? . title _hint || 'diese Übung' ) . trim ( )
const ok = window . confirm (
` Maximale Pfadlänge ( ${ maxSteps } ) ist erreicht. \ n \ n ` +
` Soll die Pfadlänge auf ${ newMax } Schritte vergrößert werden, um „ ${ titleHint } “ einzufügen? \ n \ n ` +
'Es wird kein neuer Pfad-Vorschlag generiert.' ,
)
if ( ! ok ) return false
setMaxSteps ( newMax )
return true
}
2026-06-08 08:10:53 +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-05-23 11:46:25 +02:00
export default function ExerciseProgressionPathBuilder ( {
graphId ,
disabled = false ,
onSaved ,
} ) {
const [ goalQuery , setGoalQuery ] = useState ( '' )
2026-06-09 11:10:46 +02:00
const [ startSituation , setStartSituation ] = useState ( '' )
const [ targetState , setTargetState ] = useState ( '' )
const [ roadmapNotes , setRoadmapNotes ] = useState ( '' )
2026-05-23 11:46:25 +02:00
const [ maxSteps , setMaxSteps ] = useState ( 5 )
const [ segmentNotes , setSegmentNotes ] = useState ( '' )
const [ saving , setSaving ] = useState ( false )
const [ error , setError ] = useState ( '' )
const [ targetSummary , setTargetSummary ] = useState ( null )
2026-05-23 12:02:57 +02:00
const [ semanticBrief , setSemanticBrief ] = useState ( null )
const [ pathQa , setPathQa ] = useState ( null )
2026-05-23 11:46:25 +02:00
const [ pathSteps , setPathSteps ] = useState ( [ ] )
2026-05-23 12:59:46 +02:00
const [ gapFillOffers , setGapFillOffers ] = useState ( [ ] )
2026-06-08 08:10:53 +02:00
const [ progressionRoadmap , setProgressionRoadmap ] = useState ( null )
2026-06-10 07:14:18 +02:00
const [ pathSkillExpectations , setPathSkillExpectations ] = useState ( null )
2026-06-08 14:59:24 +02:00
const [ editableMajorSteps , setEditableMajorSteps ] = useState ( [ ] )
const [ roadmapDirty , setRoadmapDirty ] = useState ( false )
const [ loadingRoadmap , setLoadingRoadmap ] = useState ( false )
2026-06-09 16:22:16 +02:00
const [ loadingStartTarget , setLoadingStartTarget ] = useState ( false )
2026-06-08 14:59:24 +02:00
const [ loadingMatch , setLoadingMatch ] = useState ( false )
2026-06-09 16:22:16 +02:00
const [ startTargetAnalyzed , setStartTargetAnalyzed ] = useState ( false )
const loading = loadingRoadmap || loadingStartTarget || loadingMatch
2026-05-23 12:59:46 +02:00
const [ focusAreas , setFocusAreas ] = useState ( [ ] )
2026-06-08 08:10:53 +02:00
const [ skillsCatalog , setSkillsCatalog ] = useState ( [ ] )
const [ generatingOfferId , setGeneratingOfferId ] = useState ( null )
2026-05-23 12:59:46 +02:00
const [ quickCreateOpen , setQuickCreateOpen ] = useState ( false )
const [ activeOffer , setActiveOffer ] = useState ( null )
const [ quickTitle , setQuickTitle ] = useState ( '' )
const [ quickSketch , setQuickSketch ] = useState ( '' )
const [ quickFocusAreaId , setQuickFocusAreaId ] = useState ( '' )
const [ quickCreateDraft , setQuickCreateDraft ] = useState ( null )
const [ quickSaving , setQuickSaving ] = useState ( false )
const [ quickAiError , setQuickAiError ] = useState ( '' )
2026-06-09 16:27:03 +02:00
const [ activePlanningContextLines , setActivePlanningContextLines ] = useState ( [ ] )
2026-06-10 06:54:49 +02:00
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 ( '' )
2026-06-10 07:25:57 +02:00
const [ loadedPlanningHint , setLoadedPlanningHint ] = useState ( false )
2026-06-10 07:30:01 +02:00
const [ wizardStep , setWizardStep ] = useState ( 1 )
2026-06-10 07:55:51 +02:00
const [ pathInsertNotice , setPathInsertNotice ] = useState ( '' )
2026-06-10 07:30:01 +02:00
const maxReachableStep = useMemo (
( ) => computeMaxReachableStep ( editableMajorSteps , pathSteps ) ,
[ editableMajorSteps , pathSteps ] ,
)
2026-06-10 07:25:57 +02:00
const buildPlanningArtifact = useCallback (
( ) =>
buildPlanningRoadmapArtifactSnapshot ( {
goalQuery ,
startSituation ,
targetState ,
roadmapNotes ,
maxSteps ,
progressionRoadmap ,
pathSkillExpectations ,
} ) ,
[
goalQuery ,
startSituation ,
targetState ,
roadmapNotes ,
maxSteps ,
progressionRoadmap ,
pathSkillExpectations ,
] ,
)
const persistPlanningRoadmapToGraph = useCallback ( async ( ) => {
if ( ! graphId ) return
const artifact = buildPlanningArtifact ( )
if ( ! artifact ? . goal _query && ! artifact ? . progression _roadmap ) return
try {
await api . updateExerciseProgressionGraph ( Number ( graphId ) , { planning _roadmap : artifact } )
} catch ( e ) {
console . warn ( 'Planungs-Artefakt konnte nicht gespeichert werden' , e )
}
} , [ graphId , buildPlanningArtifact ] )
useEffect ( ( ) => {
if ( ! graphId ) return
let cancelled = false
setLoadedPlanningHint ( false )
setPathSteps ( [ ] )
setTargetSummary ( null )
setSemanticBrief ( null )
setPathQa ( null )
setGapFillOffers ( [ ] )
setPathSkillExpectations ( null )
setEditableMajorSteps ( [ ] )
setProgressionRoadmap ( null )
setRoadmapDirty ( false )
setStartTargetAnalyzed ( false )
setError ( '' )
2026-06-10 07:30:01 +02:00
setWizardStep ( 1 )
2026-06-10 07:55:51 +02:00
setPathInsertNotice ( '' )
2026-06-10 07:25:57 +02:00
api
. getExerciseProgressionGraph ( Number ( graphId ) )
. then ( ( g ) => {
if ( cancelled ) return
const art = g ? . planning _roadmap
if ( ! art ) return
if ( art . goal _query ) setGoalQuery ( String ( art . goal _query ) )
if ( art . start _situation ) setStartSituation ( String ( art . start _situation ) )
if ( art . target _state ) setTargetState ( String ( art . target _state ) )
if ( art . roadmap _notes ) setRoadmapNotes ( String ( art . roadmap _notes ) )
if ( art . max _steps ) setMaxSteps ( Number ( art . max _steps ) )
if ( art . path _skill _expectations ) setPathSkillExpectations ( art . path _skill _expectations )
if ( art . progression _roadmap ) {
setProgressionRoadmap ( art . progression _roadmap )
const majors = mapMajorStepsFromApi ( art . progression _roadmap )
if ( majors . length >= 2 ) {
setEditableMajorSteps ( majors )
2026-06-10 07:30:01 +02:00
setWizardStep ( 2 )
2026-06-10 07:25:57 +02:00
}
}
if (
art . start _situation ||
art . target _state ||
art . progression _roadmap ? . resolved _structured
) {
setStartTargetAnalyzed ( true )
}
setLoadedPlanningHint ( true )
} )
. catch ( ( e ) => {
console . warn ( e )
} )
return ( ) => {
cancelled = true
}
} , [ graphId ] )
2026-05-23 12:59:46 +02:00
2026-06-10 07:30:01 +02:00
useEffect ( ( ) => {
if ( wizardStep > maxReachableStep ) {
setWizardStep ( maxReachableStep )
}
} , [ wizardStep , maxReachableStep ] )
2026-05-23 12:59:46 +02:00
useEffect ( ( ) => {
let cancelled = false
2026-06-08 08:10:53 +02:00
Promise . all ( [
api . listFocusAreas ( { status : 'active' } ) ,
api . listSkillsCatalog ( { status : 'active' } ) ,
] )
. then ( ( [ fa , sk ] ) => {
if ( cancelled ) return
setFocusAreas ( Array . isArray ( fa ) ? fa : [ ] )
setSkillsCatalog ( Array . isArray ( sk ) ? sk : [ ] )
2026-05-23 12:59:46 +02:00
} )
. catch ( ( ) => {
2026-06-08 08:10:53 +02:00
if ( ! cancelled ) {
setFocusAreas ( [ ] )
setSkillsCatalog ( [ ] )
}
2026-05-23 12:59:46 +02:00
} )
return ( ) => {
cancelled = true
}
} , [ ] )
2026-05-23 11:46:25 +02:00
const patchStep = useCallback ( ( idx , patch ) => {
setPathSteps ( ( prev ) => prev . map ( ( row , i ) => ( i === idx ? { ... row , ... patch } : row ) ) )
} , [ ] )
const removeStep = useCallback ( ( idx ) => {
setPathSteps ( ( prev ) => ( prev . length <= 2 ? prev : prev . filter ( ( _ , i ) => i !== idx ) ) )
} , [ ] )
2026-06-08 14:59:24 +02:00
const patchMajorStep = useCallback ( ( idx , patch ) => {
setEditableMajorSteps ( ( prev ) =>
reindexMajorSteps ( prev . map ( ( row , i ) => ( i === idx ? { ... row , ... patch } : row ) ) ) ,
)
setRoadmapDirty ( true )
} , [ ] )
const moveMajorStep = useCallback ( ( idx , dir ) => {
setEditableMajorSteps ( ( prev ) => {
const j = idx + dir
if ( j < 0 || j >= prev . length ) return prev
const next = [ ... prev ]
const t = next [ idx ]
next [ idx ] = next [ j ]
next [ j ] = t
return reindexMajorSteps ( next )
} )
setRoadmapDirty ( true )
} , [ ] )
const removeMajorStep = useCallback ( ( idx ) => {
setEditableMajorSteps ( ( prev ) => {
if ( prev . length <= 2 ) return prev
return reindexMajorSteps ( prev . filter ( ( _ , i ) => i !== idx ) )
} )
setRoadmapDirty ( true )
} , [ ] )
const addMajorStep = useCallback ( ( ) => {
setEditableMajorSteps ( ( prev ) => {
if ( prev . length >= PATH _STEPS _HARD _MAX ) return prev
const phase = ROADMAP _PHASES [ Math . min ( prev . length , ROADMAP _PHASES . length - 1 ) ]
return reindexMajorSteps ( [
... prev ,
{
index : prev . length ,
phase ,
learning _goal : '' ,
consolidates : [ ] ,
rationale : '' ,
2026-06-10 07:14:18 +02:00
load _profile : [ ] ,
success _criteria : [ ] ,
anti _patterns : [ ] ,
exercise _type : '' ,
2026-06-08 14:59:24 +02:00
} ,
] )
} )
setRoadmapDirty ( true )
} , [ ] )
2026-05-23 11:46:25 +02:00
const moveStep = useCallback ( ( idx , dir ) => {
setPathSteps ( ( prev ) => {
const j = idx + dir
if ( j < 0 || j >= prev . length ) return prev
const next = [ ... prev ]
const t = next [ idx ]
next [ idx ] = next [ j ]
next [ j ] = t
return next
} )
} , [ ] )
2026-05-23 12:59:46 +02:00
const applyOffTopicFlags = ( rows , qa ) => {
const off = Array . isArray ( qa ? . off _topic _steps ) ? qa . off _topic _steps : [ ]
const indices = new Set ( off . map ( ( o ) => Number ( o . step _index ) ) . filter ( Number . isFinite ) )
return rows . map ( ( row , idx ) => ( { ... row , isOffTopic : indices . has ( idx ) } ) )
}
2026-06-08 08:10:53 +02:00
const trimPathToMaxSteps = useCallback ( ( rows , limit ) => {
let next = [ ... rows ]
while ( next . length > limit ) {
const offIdx = next . findIndex ( ( s ) => s . isOffTopic )
if ( offIdx >= 0 ) {
next . splice ( offIdx , 1 )
continue
2026-05-23 12:59:46 +02:00
}
2026-06-08 08:10:53 +02:00
next . pop ( )
}
return next . map ( ( r ) => ( { ... r , isOffTopic : false } ) )
} , [ ] )
const insertExerciseFromOffer = useCallback (
( created , offer ) => {
const row = mapCreatedExerciseToRow ( created , offer )
setPathSteps ( ( prev ) => {
let next = [ ... prev ]
const afterIdx = Number ( offer ? . insert _after _index )
const replaceIdx =
offer ? . replace _step _index != null ? Number ( offer . replace _step _index ) : null
if ( Number . isFinite ( replaceIdx ) && replaceIdx >= 0 && replaceIdx < next . length ) {
next . splice ( replaceIdx , 1 , row )
} else if ( Number . isFinite ( afterIdx ) && afterIdx >= 0 && afterIdx < next . length ) {
next . splice ( afterIdx + 1 , 0 , row )
} else {
next . push ( row )
}
return trimPathToMaxSteps ( next , maxSteps )
} )
setGapFillOffers ( ( prev ) => prev . filter ( ( o ) => o . offer _id !== offer ? . offer _id ) )
} ,
[ maxSteps , trimPathToMaxSteps ] ,
)
2026-05-23 12:59:46 +02:00
const closeQuickCreate = ( ) => {
if ( quickSaving ) return
setQuickCreateOpen ( false )
setActiveOffer ( null )
setQuickCreateDraft ( null )
setQuickAiError ( '' )
}
2026-06-09 16:27:03 +02:00
const gapContextFallbackParams = {
goalQuery ,
semanticBrief ,
graphId ,
pathSteps ,
editableMajorSteps ,
progressionRoadmap ,
startSituation ,
targetState ,
roadmapNotes ,
}
2026-06-10 06:54:49 +02:00
const closeGapFillPrep = ( ) => {
if ( quickSaving ) return
setGapPrepOpen ( false )
setGapPrepError ( '' )
}
const openGapFillPrep = ( offer ) => {
const defaultFocus = resolveDefaultFocusAreaId ( targetSummary , focusAreas )
setActiveOffer ( offer )
setGapPrepTitle ( ( offer ? . title _hint || '' ) . trim ( ) )
setGapPrepStageGoal ( initialStageLearningGoalFromOffer ( offer , gapContextFallbackParams ) )
setGapPrepSupplements ( '' )
setGapPrepFocusAreaId ( defaultFocus ? String ( defaultFocus ) : '' )
setActivePlanningContextLines ( gapOfferContextDisplayLines ( offer , gapContextFallbackParams ) )
setGapPrepError ( '' )
setGapPrepOpen ( true )
}
const handleGapFillClick = ( offer ) => {
if ( ! confirmPathExpansionIfNeeded ( offer , pathSteps . length , maxSteps , setMaxSteps ) ) {
return
}
openGapFillPrep ( offer )
}
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
setGapPrepError ( '' )
await runGapFillAiSuggest ( activeOffer , {
title ,
stageLearningGoal : ( gapPrepStageGoal || '' ) . trim ( ) ,
supplements : ( gapPrepSupplements || '' ) . trim ( ) ,
focusAreaId : focusId ,
} )
}
const runGapFillAiSuggest = async ( offer , prep = null ) => {
const title = ( prep ? . title || offer ? . title _hint || '' ) . trim ( )
2026-06-08 08:10:53 +02:00
if ( title . length < 3 ) {
2026-06-10 06:54:49 +02:00
alert ( 'Titel: mindestens 3 Zeichen.' )
2026-06-08 08:10:53 +02:00
return
}
2026-06-10 06:54:49 +02:00
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 für diese Übung: \ n ${ supplements } ` . trim ( )
}
const focusId =
prep ? . focusAreaId != null && Number . isFinite ( Number ( prep . focusAreaId ) )
? Number ( prep . focusAreaId )
: resolveDefaultFocusAreaId ( targetSummary , focusAreas )
2026-06-08 08:10:53 +02:00
if ( ! focusId ) {
2026-06-10 06:54:49 +02:00
alert ( 'Kein Fokusbereich verfügbar — bitte im Vorbereitungsdialog wählen.' )
2026-06-08 08:10:53 +02:00
return
}
const focusRow = ( focusAreas || [ ] ) . find ( ( x ) => Number ( x . id ) === focusId )
const focusHint = ( focusRow ? . name || offer ? . primary _topic || '' ) . trim ( )
2026-05-23 12:59:46 +02:00
setActiveOffer ( offer )
2026-06-08 08:10:53 +02:00
setQuickTitle ( title )
setQuickSketch ( goalText )
setQuickFocusAreaId ( String ( focusId ) )
2026-05-23 12:59:46 +02:00
setQuickAiError ( '' )
2026-06-08 08:10:53 +02:00
setQuickCreateDraft ( null )
setQuickSaving ( true )
setGeneratingOfferId ( offer ? . offer _id || null )
2026-06-10 06:54:49 +02:00
const contextParams = {
... gapContextFallbackParams ,
stageLearningGoalOverride : stageGoal ,
gapTrainerSupplements : supplements ,
}
const contextLines = gapOfferContextDisplayLines ( offer , contextParams )
2026-06-09 16:27:03 +02:00
setActivePlanningContextLines ( contextLines )
2026-06-08 15:15:03 +02:00
const planningContext = buildPathGapPlanningContextForAi ( {
offer ,
2026-06-10 06:54:49 +02:00
... contextParams ,
2026-06-08 15:15:03 +02:00
} )
2026-06-08 08:10:53 +02:00
try {
const aiRes = await api . suggestExerciseAi ( {
title ,
goal : goalText || undefined ,
execution : '' ,
preparation : '' ,
2026-06-10 06:54:49 +02:00
trainer _notes : supplements || '' ,
2026-06-08 08:10:53 +02:00
focus _area _hint : focusHint || undefined ,
focus _areas _context : [ { focus _area _id : focusId , is _primary : true } ] ,
2026-06-08 15:15:03 +02:00
planning _context : planningContext || undefined ,
2026-06-08 08:10:53 +02:00
include _summary : true ,
include _skills : true ,
include _instructions : true ,
2026-05-23 12:59:46 +02:00
} )
2026-06-08 08:10:53 +02:00
const preview = buildQuickCreateAiPreview ( aiRes , { sketchPlain : goalText } )
if ( ! preview . hasSummaryProposal && ! preview . hasInstructionChoices && ! preview . hasSkillChoices ) {
throw new Error ( 'Die KI lieferte keinen verwertbaren Vorschlag.' )
2026-05-23 12:59:46 +02:00
}
2026-06-08 08:10:53 +02:00
setQuickCreateDraft (
aiPreviewToQuickCreateDraft ( preview , {
title ,
focusAreaId : focusId ,
sketchPlain : goalText ,
} ) ,
)
2026-06-10 06:54:49 +02:00
setGapPrepOpen ( false )
2026-06-08 08:10:53 +02:00
setQuickCreateOpen ( false )
} catch ( e ) {
console . error ( e )
const msg = e ? . message || String ( e )
2026-06-10 06:54:49 +02:00
setGapPrepError ( msg )
2026-06-08 08:10:53 +02:00
setQuickAiError ( msg )
} finally {
setQuickSaving ( false )
setGeneratingOfferId ( null )
2026-05-23 12:59:46 +02:00
}
}
const runQuickCreateAiSuggest = async ( ) => {
const title = ( quickTitle || '' ) . trim ( )
if ( title . length < 3 ) {
alert ( 'Titel: mindestens 3 Zeichen.' )
return
}
const sketch = ( quickSketch || '' ) . trim ( )
const focusId = parseInt ( String ( quickFocusAreaId ) . trim ( ) , 10 )
if ( ! Number . isFinite ( focusId ) || focusId < 1 ) {
alert ( 'Bitte einen Fokusbereich wählen.' )
return
}
const focusRow = ( focusAreas || [ ] ) . find ( ( x ) => Number ( x . id ) === focusId )
const focusHint = ( focusRow ? . name || '' ) . trim ( )
setQuickAiError ( '' )
setQuickCreateDraft ( null )
setQuickSaving ( true )
try {
const aiRes = await api . suggestExerciseAi ( {
title ,
goal : sketch || undefined ,
execution : '' ,
preparation : '' ,
trainer _notes : '' ,
focus _area _hint : focusHint || undefined ,
focus _areas _context : [ { focus _area _id : focusId , is _primary : true } ] ,
include _summary : true ,
include _skills : true ,
include _instructions : true ,
} )
const preview = buildQuickCreateAiPreview ( aiRes , { sketchPlain : sketch } )
if ( ! preview . hasSummaryProposal && ! preview . hasInstructionChoices && ! preview . hasSkillChoices ) {
throw new Error ( 'Die KI lieferte keinen verwertbaren Vorschlag.' )
}
setQuickCreateDraft (
aiPreviewToQuickCreateDraft ( preview , { title , focusAreaId : focusId , sketchPlain : sketch } ) ,
)
setQuickCreateOpen ( false )
} catch ( e ) {
console . error ( e )
const msg = e ? . message || String ( e )
setQuickAiError ( msg )
alert ( msg || 'KI-Vorschlag fehlgeschlagen' )
} finally {
setQuickSaving ( false )
}
}
const applyQuickCreateDraft = async ( ) => {
if ( ! quickCreateDraft || ! activeOffer ) return
setQuickSaving ( true )
setQuickAiError ( '' )
try {
const payload = buildQuickCreateExercisePayloadFromDraft ( quickCreateDraft )
const created = await api . createExercise ( payload )
if ( ! created ? . id ) throw new Error ( 'Anlegen fehlgeschlagen' )
insertExerciseFromOffer ( created , activeOffer )
setQuickCreateDraft ( null )
setActiveOffer ( null )
2026-06-10 07:55:51 +02:00
const title = ( created . title || quickTitle || 'Übung' ) . trim ( )
setPathInsertNotice (
` „ ${ title } " wurde in den KI-Pfad eingefügt. Speichern Sie jetzt mit «Pfad in Graph speichern» — die Reihe erscheint dann oben unter «Reihen im Graph». ` ,
)
setWizardStep ( 4 )
2026-05-23 12:59:46 +02:00
} catch ( e ) {
console . error ( e )
const msg = e ? . message || String ( e )
setQuickAiError ( msg )
alert ( msg || 'Übung konnte nicht angelegt werden' )
} finally {
setQuickSaving ( false )
}
}
2026-06-08 14:59:24 +02:00
const applyPathMatchResponse = ( res , q ) => {
const qa = res ? . path _qa || null
const rawRows = ( Array . isArray ( res ? . steps ) ? res . steps : [ ] ) . map ( mapApiStepToRow )
const rows =
Array . isArray ( qa ? . stripped _off _topic _steps ) && qa . stripped _off _topic _steps . length > 0
? rawRows
: applyOffTopicFlags ( rawRows , qa )
if ( rows . length < 2 ) {
throw new Error ( 'Zu wenig Schritte im Vorschlag.' )
}
setPathSteps ( rows )
setTargetSummary ( res ? . target _profile _summary || null )
setSemanticBrief ( res ? . semantic _brief _summary || null )
setPathQa ( qa )
setGapFillOffers (
Array . isArray ( res ? . gap _fill _offers )
? res . gap _fill _offers
: Array . isArray ( qa ? . gap _fill _offers )
? qa . gap _fill _offers
: [ ] ,
)
setProgressionRoadmap ( res ? . progression _roadmap || null )
2026-06-10 07:14:18 +02:00
setPathSkillExpectations ( res ? . path _skill _expectations || null )
2026-06-08 14:59:24 +02:00
setRoadmapDirty ( false )
if ( ! segmentNotes . trim ( ) && q ) setSegmentNotes ( q . slice ( 0 , 400 ) )
2026-06-10 07:14:18 +02:00
if ( res ? . progression _roadmap ? . stage _specs ? . length ) {
setEditableMajorSteps ( ( prev ) =>
prev . length ? mergeStageSpecsIntoMajorSteps ( prev , res . progression _roadmap ) : prev ,
)
}
2026-06-08 14:59:24 +02:00
}
2026-06-09 16:22:16 +02:00
const applyStartTargetResponse = ( res ) => {
const roadmap = res ? . progression _roadmap || null
setProgressionRoadmap ( ( prev ) => ( {
... ( prev || { } ) ,
... roadmap ,
roadmap : prev ? . roadmap || roadmap ? . roadmap || null ,
stage _specs : prev ? . stage _specs || roadmap ? . stage _specs || [ ] ,
} ) )
applyResolvedStructuredFromRoadmap ( roadmap , {
setStartSituation ,
setTargetState ,
setRoadmapNotes ,
} )
setSemanticBrief ( res ? . semantic _brief _summary || null )
setStartTargetAnalyzed ( true )
}
const analyzeStartTarget = async ( ) => {
const q = ( goalQuery || '' ) . trim ( )
if ( q . length < 3 ) {
alert ( 'Ziel-Anfrage: mindestens 3 Zeichen.' )
return
}
if ( ! graphId ) {
alert ( 'Zuerst einen Graphen wählen.' )
return
}
setLoadingStartTarget ( true )
setError ( '' )
try {
const res = await api . suggestProgressionPath ( {
query : q ,
max _steps : Number ( maxSteps ) ,
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 ( startSituation , targetState , roadmapNotes ) ,
} )
applyStartTargetResponse ( res )
} catch ( e ) {
console . error ( e )
setError ( e . message || 'Start/Ziel-Analyse fehlgeschlagen' )
} finally {
setLoadingStartTarget ( false )
}
}
2026-06-08 14:59:24 +02:00
const suggestRoadmap = async ( ) => {
2026-05-23 11:46:25 +02:00
const q = ( goalQuery || '' ) . trim ( )
if ( q . length < 3 ) {
alert ( 'Ziel-Anfrage: mindestens 3 Zeichen.' )
return
}
if ( ! graphId ) {
alert ( 'Zuerst einen Graphen wählen.' )
return
}
2026-06-09 16:22:16 +02:00
const fieldsEmpty = ! startSituation . trim ( ) && ! targetState . trim ( )
2026-06-08 14:59:24 +02:00
setLoadingRoadmap ( true )
2026-05-23 11:46:25 +02:00
setError ( '' )
try {
const res = await api . suggestProgressionPath ( {
query : q ,
max _steps : Number ( maxSteps ) ,
2026-06-10 07:25:57 +02:00
include _llm _intent : true ,
2026-06-08 14:59:24 +02:00
include _path _qa : false ,
include _llm _path _qa : false ,
include _path _reorder : false ,
include _ai _gap _fill : false ,
2026-06-08 08:10:53 +02:00
include _roadmap _preview : true ,
include _llm _roadmap : true ,
2026-06-09 16:22:16 +02:00
include _llm _start _target : fieldsEmpty ,
2026-06-08 14:59:24 +02:00
roadmap _only : true ,
2026-05-23 11:46:25 +02:00
progression _graph _id : Number ( graphId ) ,
2026-06-09 12:54:08 +02:00
... roadmapStructuredPayload ( startSituation , targetState , roadmapNotes ) ,
2026-05-23 11:46:25 +02:00
} )
2026-06-08 14:59:24 +02:00
const majors = mapMajorStepsFromApi ( res ? . progression _roadmap )
if ( majors . length < 2 ) {
throw new Error ( 'Roadmap hat zu wenig Major Steps.' )
2026-05-23 11:46:25 +02:00
}
2026-06-08 14:59:24 +02:00
setEditableMajorSteps ( majors )
setMaxSteps ( majors . length )
2026-06-09 12:54:08 +02:00
const roadmap = res ? . progression _roadmap || null
setProgressionRoadmap ( roadmap )
2026-06-09 16:22:16 +02:00
if ( fieldsEmpty ) {
applyResolvedStructuredFromRoadmap ( roadmap , {
setStartSituation ,
setTargetState ,
setRoadmapNotes ,
} )
setStartTargetAnalyzed ( true )
}
2026-06-08 14:59:24 +02:00
setSemanticBrief ( res ? . semantic _brief _summary || null )
2026-05-23 11:46:25 +02:00
setPathSteps ( [ ] )
setTargetSummary ( null )
2026-05-23 12:02:57 +02:00
setPathQa ( null )
2026-05-23 12:59:46 +02:00
setGapFillOffers ( [ ] )
2026-06-10 07:14:18 +02:00
setPathSkillExpectations ( null )
2026-06-08 14:59:24 +02:00
setRoadmapDirty ( false )
2026-06-10 07:25:57 +02:00
setLoadedPlanningHint ( false )
2026-06-10 07:30:01 +02:00
setWizardStep ( 2 )
2026-06-10 07:25:57 +02:00
await persistPlanningRoadmapToGraph ( )
2026-06-08 14:59:24 +02:00
} catch ( e ) {
console . error ( e )
setError ( e . message || 'Roadmap-Vorschlag fehlgeschlagen' )
setEditableMajorSteps ( [ ] )
2026-06-08 08:10:53 +02:00
setProgressionRoadmap ( null )
2026-06-10 07:14:18 +02:00
setPathSkillExpectations ( null )
2026-05-23 11:46:25 +02:00
} finally {
2026-06-08 14:59:24 +02:00
setLoadingRoadmap ( false )
}
}
const matchExercisesFromRoadmap = async ( ) => {
const q = ( goalQuery || '' ) . trim ( )
if ( q . length < 3 ) {
alert ( 'Ziel-Anfrage: mindestens 3 Zeichen.' )
return
}
if ( ! graphId ) {
alert ( 'Zuerst einen Graphen wählen.' )
return
}
const validSteps = editableMajorSteps . filter ( ( s ) => ( s . learning _goal || '' ) . trim ( ) . length >= 3 )
if ( validSteps . length < 2 ) {
alert ( 'Mindestens zwei Major Steps mit Lernziel (je 3+ Zeichen) nötig.' )
return
}
setLoadingMatch ( true )
setError ( '' )
try {
const override = majorStepsToOverridePayload ( validSteps )
const res = await api . suggestProgressionPath ( {
query : q ,
max _steps : validSteps . length ,
include _llm _intent : true ,
include _path _qa : true ,
include _llm _path _qa : true ,
include _path _reorder : true ,
include _ai _gap _fill : true ,
include _roadmap _preview : true ,
include _llm _roadmap : false ,
roadmap _first : true ,
roadmap _override : override ,
progression _graph _id : Number ( graphId ) ,
2026-06-09 11:10:46 +02:00
... roadmapStructuredPayload ( startSituation , targetState , roadmapNotes ) ,
2026-06-08 14:59:24 +02:00
} )
applyPathMatchResponse ( res , q )
setMaxSteps ( validSteps . length )
2026-06-10 07:25:57 +02:00
setLoadedPlanningHint ( false )
2026-06-10 07:30:01 +02:00
setWizardStep ( 3 )
2026-06-10 07:25:57 +02:00
await persistPlanningRoadmapToGraph ( )
2026-06-08 14:59:24 +02:00
} catch ( e ) {
console . error ( e )
setError ( e . message || 'Übungs-Match fehlgeschlagen' )
} finally {
setLoadingMatch ( false )
2026-05-23 11:46:25 +02:00
}
}
const savePathToGraph = async ( ) => {
if ( ! graphId ) {
alert ( 'Zuerst einen Graphen wählen.' )
return
}
const steps = pathSteps . filter ( ( s ) => s . exerciseId != null )
2026-05-23 12:32:14 +02:00
const skippedAi = pathSteps . filter ( ( s ) => s . isAiProposal ) . length
2026-05-23 11:46:25 +02:00
if ( steps . length < 2 ) {
2026-05-23 12:32:14 +02:00
alert (
skippedAi > 0
? 'Mindestens zwei gespeicherte Übungen nötig. KI-Vorschläge zuerst als Übung anlegen.'
2026-05-23 12:59:46 +02:00
: 'Mindestens zwei Schritte mit Übung nötig.' ,
2026-05-23 12:32:14 +02:00
)
2026-05-23 11:46:25 +02:00
return
}
const n = steps . length - 1
const noteRaw = segmentNotes . trim ( )
const segment _notes = Array . from ( { length : n } , ( _ , i ) => {
const reasons = ( steps [ i + 1 ] ? . reasons || [ ] ) . slice ( 0 , 2 ) . join ( ' · ' )
if ( reasons ) return reasons
return noteRaw || null
} )
setSaving ( true )
setError ( '' )
try {
2026-06-10 07:25:57 +02:00
const planningArtifact = buildPlanningArtifact ( )
2026-05-23 11:46:25 +02:00
await api . createExerciseProgressionSequence ( Number ( graphId ) , {
steps : steps . map ( ( s ) => ( {
exercise _id : s . exerciseId ,
variant _id : s . variantId || null ,
} ) ) ,
segment _notes ,
2026-06-10 07:25:57 +02:00
... ( planningArtifact ? { planning _roadmap : planningArtifact } : { } ) ,
2026-05-23 11:46:25 +02:00
} )
setPathSteps ( [ ] )
setTargetSummary ( null )
2026-05-23 12:02:57 +02:00
setSemanticBrief ( null )
setPathQa ( null )
2026-05-23 12:59:46 +02:00
setGapFillOffers ( [ ] )
2026-06-08 08:10:53 +02:00
setProgressionRoadmap ( null )
2026-06-10 07:14:18 +02:00
setPathSkillExpectations ( null )
2026-06-08 14:59:24 +02:00
setEditableMajorSteps ( [ ] )
setRoadmapDirty ( false )
2026-06-10 07:30:01 +02:00
setWizardStep ( 1 )
2026-06-10 07:55:51 +02:00
setPathInsertNotice ( '' )
2026-05-23 11:46:25 +02:00
if ( typeof onSaved === 'function' ) await onSaved ( )
2026-05-23 12:32:14 +02:00
const msg =
skippedAi > 0
? ` ${ n } Kante(n) gespeichert. ${ skippedAi } KI-Vorschlag/Vorschläge nicht im Graph (noch nicht angelegt). `
: ` ${ n } Nachfolger-Kante(n) aus KI-Pfad gespeichert. `
alert ( msg )
2026-05-23 11:46:25 +02:00
} catch ( e ) {
console . error ( e )
setError ( e . message || 'Speichern fehlgeschlagen' )
} finally {
setSaving ( false )
}
}
return (
< div
className = "card"
style = { {
marginBottom : '12px' ,
borderColor : 'color-mix(in srgb, var(--accent) 35%, var(--border))' ,
} }
>
< h3 style = { { marginTop : 0 , fontSize : '1rem' } } > KI : Pfad zum Ziel < / h3 >
< p style = { { fontSize : '12px' , color : 'var(--text3)' , marginTop : 0 , lineHeight : 1.45 } } >
2026-06-10 07:30:01 +02:00
In vier Schritten : Ziel festlegen → Roadmap bearbeiten → Übungen matchen → Lücken schließen und speichern .
2026-05-23 11:46:25 +02:00
< / p >
2026-06-10 07:30:01 +02:00
< PlanningWizardStepper
currentStep = { wizardStep }
maxReachable = { maxReachableStep }
onStepChange = { setWizardStep }
disabled = { disabled || loading || saving }
/ >
{ error ? (
< p className = "form-error" style = { { marginTop : 0 , marginBottom : '12px' } } >
{ error }
2026-06-10 07:25:57 +02:00
< / p >
) : null }
2026-06-10 07:30:01 +02:00
{ wizardStep === 1 ? (
< section aria - labelledby = "planning-step-1-heading" >
< h4 id = "planning-step-1-heading" style = { { margin : '0 0 10px' , fontSize : '0.95rem' } } >
Schritt 1 — Ziel & Start / Ziel
< / h4 >
{ loadedPlanningHint && editableMajorSteps . length > 0 && pathSteps . length === 0 ? (
< p
style = { {
fontSize : '12px' ,
color : 'var(--accent-dark)' ,
margin : '0 0 10px' ,
padding : '8px 10px' ,
borderRadius : '8px' ,
background : 'color-mix(in srgb, var(--accent) 8%, var(--surface))' ,
lineHeight : 1.45 ,
} }
>
Gespeicherte Planung geladen — Sie können bei Schritt 2 weitermachen oder hier neu starten .
< / p >
) : null }
< div style = { { display : 'flex' , flexWrap : 'wrap' , gap : '10px' , alignItems : 'flex-end' } } >
2026-05-23 11:46:25 +02:00
< div className = "form-row" style = { { flex : '2 1 240px' , marginBottom : 0 } } >
< label className = "form-label" > Ziel / Entwicklungsrichtung < / label >
< input
className = "form-input"
value = { goalQuery }
onChange = { ( e ) => setGoalQuery ( e . target . value ) }
2026-05-23 12:59:46 +02:00
placeholder = "z. B. Von Erlernen bis zur Perfektion des Fußtritts Mae Geri …"
2026-05-23 11:46:25 +02:00
disabled = { disabled || loading || saving }
/ >
< / div >
< div className = "form-row" style = { { flex : '0 1 120px' , marginBottom : 0 } } >
< label className = "form-label" > Schritte < / label >
< input
type = "number"
min = { 2 }
max = { 10 }
className = "form-input"
value = { maxSteps }
onChange = { ( e ) => setMaxSteps ( Math . max ( 2 , Math . min ( 10 , Number ( e . target . value ) || 5 ) ) ) }
disabled = { disabled || loading || saving }
/ >
< / div >
2026-06-09 11:10:46 +02:00
< / div >
< div
style = { {
display : 'grid' ,
gridTemplateColumns : 'repeat(auto-fit, minmax(220px, 1fr))' ,
gap : '10px' ,
marginTop : '10px' ,
} }
>
< div className = "form-row" style = { { marginBottom : 0 } } >
< label className = "form-label" > Startpunkt / Ausgangslage < / label >
< textarea
className = "form-input"
rows = { 2 }
value = { startSituation }
onChange = { ( e ) => setStartSituation ( e . target . value ) }
placeholder = "z. B. gleichartige Steppbewegung, vorhersehbar"
disabled = { disabled || loading || saving }
/ >
< / div >
< div className = "form-row" style = { { marginBottom : 0 } } >
< label className = "form-label" > Zielzustand < / label >
< textarea
className = "form-input"
rows = { 2 }
value = { targetState }
onChange = { ( e ) => setTargetState ( e . target . value ) }
placeholder = "z. B. dynamische, unvorhersehbare Bewegung mit explosivem Angriff und Ausweichen"
disabled = { disabled || loading || saving }
/ >
< / 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 = { roadmapNotes }
onChange = { ( e ) => setRoadmapNotes ( e . target . value ) }
placeholder = "optional: Altersgruppe, Kumite-Kontext, Trainingsfokus …"
disabled = { disabled || loading || saving }
/ >
< / div >
< / div >
2026-06-10 07:30:01 +02:00
< p style = { { fontSize : '11px' , color : 'var(--text3)' , margin : '6px 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 mit . Manuelle Eingaben haben immer Vorrang .
< / p >
< div style = { { display : 'flex' , flexWrap : 'wrap' , gap : '10px' , alignItems : 'center' , marginTop : '12px' } } >
< button
type = "button"
className = "btn btn-secondary"
disabled = { disabled || loading || saving || ! graphId }
onClick = { analyzeStartTarget }
title = "Nur Ausgangslage, Zielzustand und Ergänzungen per KI — ohne Roadmap-Stufen"
>
{ loadingStartTarget ? 'Analyse …' : 'Start/Ziel analysieren' }
< / button >
< button
type = "button"
className = "btn btn-primary"
disabled = { disabled || loading || saving || ! graphId }
onClick = { suggestRoadmap }
title = {
startSituation . trim ( ) && targetState . trim ( )
? 'Roadmap-Stufen aus den gesetzten Start/Ziel-Feldern'
: 'Start/Ziel-Analyse und Roadmap-Stufen in einem Schritt'
}
>
{ loadingRoadmap ? 'Roadmap …' : 'Roadmap vorschlagen →' }
< / button >
{ startTargetAnalyzed && ! editableMajorSteps . length ? (
< span className = "exercise-tag" style = { { borderColor : 'var(--accent)' } } >
Start / Ziel bereit
< / span >
) : null }
< / div >
2026-05-23 11:46:25 +02:00
2026-06-10 07:30:01 +02:00
{ ( progressionRoadmap ? . goal _analysis ||
progressionRoadmap ? . pipeline _phase === 'start_target_only' ) ? (
< details
style = { {
marginTop : '12px' ,
padding : '10px 12px' ,
borderRadius : '8px' ,
border : '1px solid var(--border)' ,
background : 'var(--surface2)' ,
fontSize : '12px' ,
} }
>
< summary style = { { cursor : 'pointer' , fontWeight : 600 , color : 'var(--accent-dark)' } } >
KI - Zielanalyse ( Details )
< / summary >
< div style = { { marginTop : '10px' } } >
2026-06-09 11:10:46 +02:00
< div style = { { display : 'flex' , flexWrap : 'wrap' , gap : '8px' , alignItems : 'center' , marginBottom : '8px' } } >
< strong style = { { fontSize : '13px' } } > Zielanalyse < / strong >
2026-06-09 12:54:08 +02:00
{ progressionRoadmap . llm _start _target _applied ? (
< span className = "exercise-tag" style = { { borderColor : 'var(--accent)' } } >
KI Start / Ziel
< / span >
) : null }
2026-06-09 11:10:46 +02:00
{ progressionRoadmap . llm _goal _analysis _applied ? (
< span className = "exercise-tag" style = { { borderColor : 'var(--accent)' } } >
KI - Zielanalyse
< / span >
) : (
< span className = "exercise-tag" > heuristisch < / span >
) }
2026-06-09 12:54:08 +02:00
{ progressionRoadmap . start _target _sources ? (
< span className = "exercise-tag" style = { { fontSize : '11px' } } >
Start : { sourceLabel ( progressionRoadmap . start _target _sources . start ) } · Ziel : { ' ' }
{ sourceLabel ( progressionRoadmap . start _target _sources . target ) }
< / span >
) : null }
2026-06-09 11:10:46 +02:00
{ progressionRoadmap . goal _analysis . primary _topic ? (
< span className = "exercise-tag" > Thema : { progressionRoadmap . goal _analysis . primary _topic } < / span >
) : null }
< / div >
< div style = { { fontSize : '12px' , color : 'var(--text2)' , lineHeight : 1.5 } } >
< div >
< span style = { { color : 'var(--text3)' } } > Ausgang : < / span >
{ progressionRoadmap . goal _analysis . start _assumption }
< / div >
< div style = { { marginTop : '6px' } } >
< span style = { { color : 'var(--text3)' } } > Ziel : < / span >
{ progressionRoadmap . goal _analysis . target _state }
< / div >
{ Array . isArray ( progressionRoadmap . goal _analysis . success _criteria ) &&
progressionRoadmap . goal _analysis . success _criteria . length > 0 ? (
< ul style = { { margin : '8px 0 0' , paddingLeft : '18px' } } >
{ progressionRoadmap . goal _analysis . success _criteria . slice ( 0 , 4 ) . map ( ( c ) => (
< li key = { c } > { c } < / li >
) ) }
< / ul >
) : null }
2026-06-09 12:54:08 +02:00
{ progressionRoadmap . start _target _extract ? . extraction _notes ? (
< p style = { { margin : '8px 0 0' , fontSize : '11px' , color : 'var(--text3)' } } >
{ progressionRoadmap . start _target _extract . extraction _notes }
< / p >
) : null }
2026-06-09 11:10:46 +02:00
< / div >
2026-06-10 07:30:01 +02:00
< / div >
< / details >
2026-05-23 12:02:57 +02:00
) : null }
2026-06-10 07:30:01 +02:00
< / section >
2026-05-23 12:02:57 +02:00
) : null }
2026-06-10 07:30:01 +02:00
{ wizardStep === 2 ? (
< section aria - labelledby = "planning-step-2-heading" >
< h4 id = "planning-step-2-heading" style = { { margin : '0 0 10px' , fontSize : '0.95rem' } } >
Schritt 2 — Didaktische Roadmap
< / h4 >
{ editableMajorSteps . length === 0 ? (
< p style = { { fontSize : '13px' , color : 'var(--text2)' , margin : 0 } } >
Noch keine Roadmap — zuerst in Schritt 1 „ Roadmap vorschlagen “ .
< button
type = "button"
className = "btn btn-secondary"
style = { { marginLeft : '10px' , fontSize : '12px' } }
onClick = { ( ) => setWizardStep ( 1 ) }
>
Zu Schritt 1
< / button >
< / p >
) : (
2026-06-08 08:10:53 +02:00
< div
style = { {
padding : '12px' ,
borderRadius : '8px' ,
border : '1px solid color-mix(in srgb, var(--accent) 45%, var(--border))' ,
background : 'color-mix(in srgb, var(--accent) 6%, var(--surface))' ,
} }
>
2026-06-08 14:59:24 +02:00
< div style = { { display : 'flex' , flexWrap : 'wrap' , gap : '8px' , alignItems : 'center' , marginBottom : '8px' } } >
2026-06-10 07:30:01 +02:00
< strong style = { { fontSize : '13px' } } > Major Steps bearbeiten < / strong >
2026-06-08 14:59:24 +02:00
{ roadmapDirty ? (
< span className = "exercise-tag" style = { { borderColor : 'var(--danger)' } } >
Geändert — bitte erneut matchen
< / span >
) : pathSteps . length > 0 ? (
< span className = "exercise-tag" style = { { borderColor : 'var(--accent)' } } >
Gematcht
< / span >
) : null }
< / div >
< p style = { { fontSize : '12px' , color : 'var(--text3)' , margin : '0 0 10px' , lineHeight : 1.45 } } >
{ progressionRoadmap ? . micro _objective _count != null
? ` ${ progressionRoadmap . micro _objective _count } Zwischenziele → `
: '' }
{ editableMajorSteps . length } Major Steps
{ progressionRoadmap ? . llm _roadmap _applied
? ' (KI-Roadmap)'
: progressionRoadmap
? ' (heuristisch/KI)'
: '' }
2026-06-10 07:14:18 +02:00
. Phasen und Lernziele anpassen ; optional Stufen - Details für Match und KI - Lücken , dann „ Übungen matchen “ .
2026-06-08 08:10:53 +02:00
< / p >
2026-06-08 14:59:24 +02:00
< div style = { { display : 'flex' , flexDirection : 'column' , gap : '10px' } } >
{ editableMajorSteps . map ( ( step , idx ) => (
< div
key = { ` major- ${ idx } - ${ step . index } ` }
style = { {
padding : '10px 12px' ,
borderRadius : '8px' ,
background : 'var(--surface2)' ,
border : '1px solid var(--border)' ,
} }
>
2026-06-10 07:14:18 +02:00
< div
style = { {
display : 'grid' ,
gridTemplateColumns : 'repeat(auto-fit, minmax(160px, 1fr))' ,
gap : '10px' ,
alignItems : 'end' ,
} }
>
< div className = "form-row" style = { { marginBottom : 0 , flex : '0 0 120px' } } >
< label className = "form-label" > Stufe { idx + 1 } · Phase < / label >
< select
className = "form-input"
value = { step . phase }
onChange = { ( e ) => patchMajorStep ( idx , { phase : e . target . value } ) }
disabled = { disabled || loading || saving }
>
{ ROADMAP _PHASES . map ( ( p ) => (
< option key = { p } value = { p } >
{ p }
< / option >
) ) }
< / select >
< / div >
< div className = "form-row" style = { { marginBottom : 0 , flex : '2 1 240px' } } >
< label className = "form-label" > Lernziel ( Major Step ) < / label >
< input
className = "form-input"
value = { step . learning _goal }
onChange = { ( e ) => patchMajorStep ( idx , { learning _goal : e . target . value } ) }
placeholder = "z. B. Grundstellung und Hüftmobilität für Mae Geri"
disabled = { disabled || loading || saving }
/ >
< / div >
< div style = { { display : 'flex' , gap : '6px' , flexWrap : 'wrap' } } >
< button
type = "button"
className = "btn"
style = { { fontSize : '12px' , padding : '4px 8px' } }
onClick = { ( ) => moveMajorStep ( idx , - 1 ) }
disabled = { disabled || loading || saving || idx === 0 }
>
↑
< / button >
< button
type = "button"
className = "btn"
style = { { fontSize : '12px' , padding : '4px 8px' } }
onClick = { ( ) => moveMajorStep ( idx , 1 ) }
disabled = { disabled || loading || saving || idx >= editableMajorSteps . length - 1 }
>
↓
< / button >
< button
type = "button"
className = "btn"
style = { { fontSize : '12px' , padding : '4px 8px' } }
onClick = { ( ) => removeMajorStep ( idx ) }
disabled = { disabled || loading || saving || editableMajorSteps . length <= 2 }
>
Entfernen
< / button >
< / div >
2026-06-08 14:59:24 +02:00
< / div >
2026-06-10 07:14:18 +02:00
< details style = { { marginTop : '10px' , fontSize : '12px' } } >
< summary style = { { cursor : 'pointer' , color : 'var(--accent-dark)' , fontWeight : 600 } } >
Stufen - Details ( Match & KI )
{ step . load _profile ? . length
? ` · ${ step . load _profile . slice ( 0 , 2 ) . join ( ', ' ) } `
: '' }
< / summary >
< div style = { { marginTop : '10px' , display : 'grid' , gap : '10px' } } >
< div >
< label className = "form-label" > Belastungsschwerpunkte < / label >
< div style = { { display : 'flex' , flexWrap : 'wrap' , gap : '6px' } } >
{ LOAD _PROFILE _OPTIONS . map ( ( opt ) => {
const active = ( step . load _profile || [ ] ) . includes ( opt )
return (
< label
key = { opt }
className = "exercise-tag"
style = { {
cursor : disabled || loading || saving ? 'default' : 'pointer' ,
borderColor : active ? 'var(--accent)' : 'var(--border)' ,
opacity : disabled || loading || saving ? 0.6 : 1 ,
} }
>
< input
type = "checkbox"
checked = { active }
disabled = { disabled || loading || saving }
style = { { marginRight : '4px' } }
onChange = { ( ) => {
const current = step . load _profile || [ ]
const next = active
? current . filter ( ( x ) => x !== opt )
: [ ... current , opt ]
patchMajorStep ( idx , { load _profile : next } )
} }
/ >
{ opt }
< / label >
)
} ) }
< / div >
< / div >
< div className = "form-row" style = { { marginBottom : 0 } } >
< label className = "form-label" > Erfolgskriterien ( je Zeile ) < / label >
< textarea
className = "form-input"
rows = { 2 }
value = { listToMultiline ( step . success _criteria ) }
onChange = { ( e ) =>
patchMajorStep ( idx , { success _criteria : linesToStringList ( e . target . value ) } )
}
placeholder = { 'z. B. Bezug zur Technik erkennbar\nPhase vertiefung im Übungsziel' }
disabled = { disabled || loading || saving }
/ >
< / div >
< div className = "form-row" style = { { marginBottom : 0 } } >
< label className = "form-label" > Vermeiden ( optional , je Zeile ) < / label >
< textarea
className = "form-input"
rows = { 2 }
value = { listToMultiline ( step . anti _patterns ) }
onChange = { ( e ) =>
patchMajorStep ( idx , { anti _patterns : linesToStringList ( e . target . value ) } )
}
placeholder = "z. B. reine Kraftübung ohne Technikbezug"
disabled = { disabled || loading || saving }
/ >
< / div >
< / div >
< / details >
2026-06-08 14:59:24 +02:00
< / div >
2026-06-08 08:10:53 +02:00
) ) }
2026-06-08 14:59:24 +02:00
< / div >
{ editableMajorSteps . length < PATH _STEPS _HARD _MAX ? (
< button
type = "button"
className = "btn btn-secondary"
style = { { marginTop : '10px' , fontSize : '12px' } }
onClick = { addMajorStep }
disabled = { disabled || loading || saving }
>
Major Step hinzufügen
< / button >
) : null }
2026-06-08 08:10:53 +02:00
< / div >
2026-06-10 07:30:01 +02:00
) }
{ editableMajorSteps . length >= 2 ? (
< div
style = { {
display : 'flex' ,
flexWrap : 'wrap' ,
gap : '10px' ,
alignItems : 'center' ,
marginTop : '14px' ,
paddingTop : '14px' ,
borderTop : '1px solid var(--border)' ,
} }
>
< button
type = "button"
className = "btn btn-secondary"
onClick = { ( ) => setWizardStep ( 1 ) }
disabled = { disabled || loading || saving }
>
← Zurück
< / button >
< button
type = "button"
className = "btn btn-primary"
disabled = { disabled || loading || saving || ! graphId }
onClick = { matchExercisesFromRoadmap }
title = {
roadmapDirty
? 'Roadmap wurde bearbeitet — erneut matchen'
: 'Bibliothek je Major Step durchsuchen'
}
>
{ loadingMatch ? 'Match …' : roadmapDirty ? 'Übungen neu matchen →' : 'Übungen matchen →' }
< / button >
< / div >
) : null }
< / section >
2026-06-08 08:10:53 +02:00
) : null }
2026-06-10 07:30:01 +02:00
{ wizardStep === 3 ? (
< section aria - labelledby = "planning-step-3-heading" >
< h4 id = "planning-step-3-heading" style = { { margin : '0 0 10px' , fontSize : '0.95rem' } } >
Schritt 3 — Übungen & Qualität
< / h4 >
{ pathSteps . length === 0 ? (
< p style = { { fontSize : '13px' , color : 'var(--text2)' , margin : 0 } } >
Noch kein Match — zuerst in Schritt 2 „ Übungen matchen “ .
< button
type = "button"
className = "btn btn-secondary"
style = { { marginLeft : '10px' , fontSize : '12px' } }
onClick = { ( ) => setWizardStep ( editableMajorSteps . length >= 2 ? 2 : 1 ) }
>
Zurück
< / button >
< / p >
) : (
< >
{ ( semanticBrief || targetSummary || pathSkillExpectations ) ? (
< details style = { { marginBottom : '10px' , fontSize : '12px' } } >
< summary style = { { cursor : 'pointer' , color : 'var(--accent-dark)' , fontWeight : 600 } } >
Pfad - Kontext ( Thema , Fokus , Fähigkeiten )
< / summary >
< div style = { { marginTop : '8px' , display : 'flex' , flexWrap : 'wrap' , gap : '6px' } } >
{ semanticBrief ? . primary _topic ? (
< span className = "exercise-tag" style = { { borderColor : 'var(--accent)' } } >
Thema : { semanticBrief . primary _topic }
< / span >
) : null }
{ Array . isArray ( semanticBrief ? . development _arc ) &&
semanticBrief . development _arc . slice ( 0 , 3 ) . map ( ( phase ) => (
< span key = { phase } className = "exercise-tag" >
{ phase }
< / span >
) ) }
{ Array . isArray ( targetSummary ? . focus _areas ) &&
targetSummary . focus _areas . slice ( 0 , 1 ) . map ( ( fa ) => (
< span key = { fa } className = "exercise-tag" >
Fokus : { fa }
< / span >
) ) }
{ formatExpectedSkillNames ( pathSkillExpectations , 5 ) . map ( ( name ) => (
< span
key = { ` path-skill- ${ name } ` }
className = "exercise-tag"
style = { { borderColor : 'color-mix(in srgb, var(--accent) 55%, var(--border))' } }
title = "Pfadweite Fähigkeiten-Erwartung (Scoring)"
>
{ name }
< / span >
) ) }
< / div >
< / details >
) : null }
{ pathQa ? (
2026-05-23 12:02:57 +02:00
< div
style = { {
marginTop : '10px' ,
padding : '10px 12px' ,
borderRadius : '8px' ,
background : pathQa . overall _ok ? 'color-mix(in srgb, var(--accent) 8%, var(--surface2))' : 'color-mix(in srgb, var(--danger) 8%, var(--surface2))' ,
fontSize : '12px' ,
lineHeight : 1.45 ,
} }
>
< strong >
Pfad - QS : { pathQa . overall _ok ? 'OK' : 'Hinweise' }
{ pathQa . quality _score != null ? ` ( ${ Math . round ( Number ( pathQa . quality _score ) * 100 ) } %) ` : '' }
< / strong >
{ pathQa . topic _coverage ? (
< p style = { { margin : '6px 0 0' , color : 'var(--text2)' } } > { pathQa . topic _coverage } < / p >
) : null }
{ Array . isArray ( pathQa . issues ) && pathQa . issues . length > 0 ? (
< ul style = { { margin : '6px 0 0' , paddingLeft : '16px' , color : 'var(--text2)' } } >
{ pathQa . issues . slice ( 0 , 4 ) . map ( ( issue ) => (
< li key = { issue } > { issue } < / li >
) ) }
< / ul >
) : null }
{ Number ( pathQa . bridge _insert _count ) > 0 ? (
< p style = { { margin : '6px 0 0' , color : 'var(--accent-dark)' } } >
2026-05-23 12:32:14 +02:00
{ pathQa . bridge _insert _count } Brücken - Übung ( en ) aus der Bibliothek eingefügt .
< / p >
) : null }
2026-06-08 08:10:53 +02:00
{ Array . isArray ( pathQa . stripped _off _topic _steps ) && pathQa . stripped _off _topic _steps . length > 0 ? (
< p style = { { margin : '6px 0 0' , color : 'var(--accent-dark)' } } >
{ pathQa . stripped _off _topic _steps . length } themenfremde ( r ) Schritt ( e ) aus dem Pfad entfernt : { ' ' }
{ pathQa . stripped _off _topic _steps . map ( ( s ) => s . removed _title || s . title ) . join ( ', ' ) } .
< / p >
) : Number ( pathQa . off _topic _count ) > 0 ? (
2026-05-23 12:59:46 +02:00
< p style = { { margin : '6px 0 0' , color : 'var(--danger)' } } >
2026-06-10 07:30:01 +02:00
{ pathQa . off _topic _count } Schritt ( e ) ohne Bezug zum Pfad - Thema — Lücken in Schritt 4 schließen .
2026-05-23 12:32:14 +02:00
< / p >
) : null }
{ pathQa . reorder _applied ? (
< p style = { { margin : '6px 0 0' , color : 'var(--text2)' } } >
Reihenfolge nach QS angepasst .
{ Array . isArray ( pathQa . reorder _notes ) && pathQa . reorder _notes [ 0 ]
? ` ${ pathQa . reorder _notes [ 0 ] } `
: '' }
2026-05-23 12:02:57 +02:00
< / p >
) : null }
2026-06-09 10:17:30 +02:00
{ pathQa . roadmap _qa _mode === 'roadmap_first_lite' ? (
< p style = { { margin : '6px 0 0' , color : 'var(--text2)' } } >
QS an Roadmap gekoppelt : keine Brücken / Reihenfolge zwischen Major Steps ( didaktisch bereits geplant ) .
< / p >
) : null }
2026-05-23 12:59:46 +02:00
< / div >
) : null }
< div style = { { display : 'flex' , flexDirection : 'column' , gap : '10px' } } >
2026-05-23 11:46:25 +02:00
{ pathSteps . map ( ( step , idx ) => (
< div
2026-05-23 12:59:46 +02:00
key = { ` ${ step . exerciseId } - ${ step . proposalKey || '' } - ${ idx } ` }
2026-05-23 11:46:25 +02:00
style = { {
display : 'grid' ,
gridTemplateColumns : 'repeat(auto-fit, minmax(200px, 1fr))' ,
gap : '10px' ,
alignItems : 'end' ,
2026-06-10 07:30:01 +02:00
padding : step . isOffTopic ? '8px' : '10px 12px' ,
borderRadius : '8px' ,
border : '1px solid var(--border)' ,
2026-05-23 12:59:46 +02:00
background : step . isOffTopic
2026-06-10 07:30:01 +02:00
? 'color-mix(in srgb, var(--danger) 6%, var(--surface2))'
: 'var(--surface2)' ,
2026-05-23 11:46:25 +02:00
} }
>
< div className = "form-row" style = { { marginBottom : 0 } } >
< label className = "form-label" >
Schritt { idx + 1 }
2026-06-08 14:59:24 +02:00
{ step . roadmapMajorStepIndex != null
? ` · Roadmap ${ step . roadmapMajorStepIndex + 1 } `
: '' }
{ step . roadmapPhase ? ` ( ${ step . roadmapPhase } ) ` : '' }
2026-05-23 12:59:46 +02:00
{ step . isOffTopic ? ' (themenfremd)' : '' }
2026-05-23 12:32:14 +02:00
{ step . isAiProposal ? ' (KI-Neu)' : step . isBridge ? ' (Brücke)' : '' }
2026-05-23 11:46:25 +02:00
< / label >
2026-06-08 14:59:24 +02:00
{ step . roadmapLearningGoal ? (
< p style = { { fontSize : '11px' , color : 'var(--text3)' , margin : '4px 0 0' } } >
Ziel : { step . roadmapLearningGoal }
< / p >
) : null }
2026-06-10 07:14:18 +02:00
{ formatExpectedSkillNames ( step . skillExpectations ) . length ? (
< div
style = { {
display : 'flex' ,
flexWrap : 'wrap' ,
gap : '4px' ,
marginTop : '6px' ,
} }
>
{ formatExpectedSkillNames ( step . skillExpectations ) . map ( ( name ) => (
< span
key = { ` ${ idx } - ${ name } ` }
className = "exercise-tag"
style = { {
fontSize : '10px' ,
borderColor : 'color-mix(in srgb, var(--accent) 50%, var(--border))' ,
} }
title = "Erwartete Fähigkeiten dieser Roadmap-Stufe"
>
{ name }
< / span >
) ) }
< / div >
) : null }
2026-05-23 11:46:25 +02:00
< div style = { { fontSize : '13px' } } >
< strong > { step . exerciseTitle } < / strong >
2026-05-23 12:32:14 +02:00
{ step . exerciseId ? (
< span style = { { color : 'var(--text3)' } } > ( # { step . exerciseId } ) < / span >
) : (
< span style = { { color : 'var(--text3)' } } > — noch nicht in Bibliothek < / span >
) }
2026-05-23 11:46:25 +02:00
< / div >
{ step . reasons ? . length ? (
< ul
style = { {
margin : '6px 0 0' ,
paddingLeft : '16px' ,
fontSize : '11px' ,
color : 'var(--accent-dark)' ,
} }
>
{ step . reasons . slice ( 0 , 2 ) . map ( ( r ) => (
< li key = { r } > { r } < / li >
) ) }
< / ul >
) : null }
< / div >
< div className = "form-row" style = { { marginBottom : 0 } } >
< label className = "form-label" > Variante < / label >
2026-05-23 12:32:14 +02:00
{ step . isAiProposal ? (
< p style = { { fontSize : '12px' , color : 'var(--text3)' , margin : 0 } } >
Nach Anlage der Übung im Graph wählbar .
< / p >
) : (
< select
className = "form-input"
value = { step . variantId ? ? '' }
onChange = { ( e ) =>
patchStep ( idx , {
variantId : e . target . value === '' ? null : parseInt ( e . target . value , 10 ) ,
} )
}
disabled = { ! step . exerciseId }
>
< option value = "" > Gesamte Übung < / option >
{ ( step . variants || [ ] ) . map ( ( v ) => (
< option key = { v . id } value = { v . id } >
{ v . variant _name || ` Variante # ${ v . id } ` }
< / option >
) ) }
< / select >
) }
2026-05-23 11:46:25 +02:00
< / div >
< div style = { { display : 'flex' , gap : '6px' , flexWrap : 'wrap' } } >
< button type = "button" className = "btn" style = { { fontSize : '12px' , padding : '4px 8px' } } onClick = { ( ) => moveStep ( idx , - 1 ) } >
↑
< / button >
< button type = "button" className = "btn" style = { { fontSize : '12px' , padding : '4px 8px' } } onClick = { ( ) => moveStep ( idx , 1 ) } >
↓
< / button >
< button type = "button" className = "btn" style = { { fontSize : '12px' , padding : '4px 8px' } } onClick = { ( ) => removeStep ( idx ) } >
Entfernen
< / button >
< / div >
< / div >
) ) }
< / div >
2026-06-10 07:30:01 +02:00
< div
style = { {
display : 'flex' ,
flexWrap : 'wrap' ,
gap : '10px' ,
alignItems : 'center' ,
marginTop : '14px' ,
paddingTop : '14px' ,
borderTop : '1px solid var(--border)' ,
} }
>
< button
type = "button"
className = "btn btn-secondary"
onClick = { ( ) => setWizardStep ( 2 ) }
disabled = { disabled || loading || saving }
>
← Roadmap
< / button >
< button
type = "button"
className = "btn btn-primary"
onClick = { ( ) => setWizardStep ( 4 ) }
disabled = { disabled || loading || saving }
>
Weiter zu Lücken & Speichern →
< / button >
{ gapFillOffers . length > 0 ? (
< span className = "exercise-tag" style = { { borderColor : 'var(--danger)' } } >
{ gapFillOffers . length } Lücke ( n ) offen
< / span >
) : null }
< / div >
< / >
) }
< / section >
) : null }
{ wizardStep === 4 ? (
< section aria - labelledby = "planning-step-4-heading" >
< h4 id = "planning-step-4-heading" style = { { margin : '0 0 10px' , fontSize : '0.95rem' } } >
Schritt 4 — Lücken schließen & Pfad speichern
< / h4 >
{ pathSteps . length === 0 ? (
< p style = { { fontSize : '13px' , color : 'var(--text2)' , margin : 0 } } >
Noch kein Pfad — zuerst Schritt 3 abschließen .
< button
type = "button"
className = "btn btn-secondary"
style = { { marginLeft : '10px' , fontSize : '12px' } }
onClick = { ( ) => setWizardStep ( 3 ) }
>
Zu Schritt 3
< / button >
< / p >
) : (
< >
2026-06-10 07:55:51 +02:00
< div
style = { {
marginBottom : '14px' ,
padding : '10px 12px' ,
borderRadius : '8px' ,
border : '1px solid color-mix(in srgb, var(--accent) 45%, var(--border))' ,
background : 'color-mix(in srgb, var(--accent) 10%, var(--surface))' ,
fontSize : '12px' ,
lineHeight : 1.5 ,
color : 'var(--accent-dark)' ,
} }
>
< strong > Wichtig : < / strong > Der KI - Pfad ist noch nicht im Graph gespeichert . KI - Übungen werden erst nach
« Pfad in Graph speichern » als neue Reihe oben sichtbar .
{ pathInsertNotice ? < p style = { { margin : '8px 0 0' } } > { pathInsertNotice } < / p > : null }
< / div >
2026-06-10 07:30:01 +02:00
{ gapFillOffers . length > 0 ? (
< div
style = { {
marginBottom : '14px' ,
padding : '12px' ,
borderRadius : '8px' ,
border : '1px solid color-mix(in srgb, var(--accent) 40%, var(--border))' ,
background : 'color-mix(in srgb, var(--accent) 5%, var(--surface))' ,
} }
>
< strong style = { { fontSize : '13px' } } > Fehlende Schritte — mit KI anlegen < / strong >
< p style = { { fontSize : '12px' , color : 'var(--text3)' , margin : '6px 0 10px' , lineHeight : 1.45 } } >
Fehlende oder zu ersetzende Schritte ( { pathSteps . length } / { maxSteps } im Pfad ) .
{ pathSteps . length >= maxSteps
? ' Der Pfad ist voll — beim Einfügen können Sie die Pfadlänge dynamisch vergrößern (ohne neuen Vorschlag); Ersatz-Angebote ersetzen einen Schritt.'
: ' Zuerst Kontext prüfen und ergänzen, dann KI-Entwurf erstellen und einfügen.' }
< / p >
< div style = { { display : 'flex' , flexDirection : 'column' , gap : '10px' } } >
{ gapFillOffers . map ( ( offer ) => (
< div
key = { offer . offer _id }
style = { {
padding : '10px 12px' ,
borderRadius : '8px' ,
background : 'var(--surface2)' ,
border : '1px solid var(--border)' ,
} }
>
< div style = { { display : 'flex' , flexWrap : 'wrap' , gap : '8px' , alignItems : 'flex-start' } } >
< div style = { { flex : '1 1 200px' } } >
< span className = "exercise-tag" style = { { marginBottom : '6px' , display : 'inline-block' } } >
{ OFFER _SOURCE _LABELS [ offer . source ] || offer . source || 'Lücke' }
{ offer . phase ? ` · ${ offer . phase } ` : '' }
< / span >
< div style = { { fontSize : '13px' , fontWeight : 600 } } > { offer . title _hint } < / div >
{ offer . rationale ? (
< p style = { { fontSize : '12px' , color : 'var(--text2)' , margin : '4px 0 0' } } > { offer . rationale } < / p >
) : null }
{ offer . from _title && offer . to _title ? (
< p style = { { fontSize : '11px' , color : 'var(--text3)' , margin : '4px 0 0' } } >
Zwischen „ { offer . from _title } “ und „ { offer . to _title } “
{ offer . replace _step _index != null ? ' (ersetzt themenfremden Schritt)' : '' }
< / p >
) : null }
< GapOfferContextPreview
lines = { gapOfferContextDisplayLines ( offer , gapContextFallbackParams ) }
/ >
< / div >
< button
type = "button"
className = "btn btn-primary"
style = { { fontSize : '12px' , flexShrink : 0 } }
disabled = {
quickSaving ||
( isGapOfferBlockedByPathCapacity ( offer , pathSteps . length , maxSteps ) &&
maxSteps >= PATH _STEPS _HARD _MAX )
}
onClick = { ( ) => handleGapFillClick ( offer ) }
title = {
isGapOfferBlockedByPathCapacity ( offer , pathSteps . length , maxSteps )
? maxSteps >= PATH _STEPS _HARD _MAX
? ` Maximal ${ PATH _STEPS _HARD _MAX } Schritte — zuerst einen Schritt entfernen. `
: 'Pfad voll — Klick fragt, ob die Pfadlänge vergrößert werden soll'
: offer . replace _step _index != null
? 'Ersetzt den themenfremden Schritt im Pfad'
: 'Kontext prüfen, Ergänzungen mitgeben, dann KI-Entwurf'
}
>
{ generatingOfferId === offer . offer _id
? 'KI erstellt Entwurf …'
: 'Vorbereiten & KI anlegen' }
< / button >
< / div >
< / div >
) ) }
< / div >
< / div >
) : (
< p
style = { {
fontSize : '12px' ,
color : 'var(--accent-dark)' ,
margin : '0 0 14px' ,
padding : '8px 10px' ,
borderRadius : '8px' ,
background : 'color-mix(in srgb, var(--accent) 8%, var(--surface))' ,
} }
>
Keine offenen Lücken — Pfad kann direkt gespeichert werden .
< / p >
) }
2026-06-10 07:55:51 +02:00
< div
style = { {
marginBottom : '12px' ,
padding : '10px 12px' ,
borderRadius : '8px' ,
border : '1px solid var(--border)' ,
background : 'var(--surface2)' ,
fontSize : '12px' ,
} }
>
< strong style = { { display : 'block' , marginBottom : '8px' } } >
Pfad vor dem Speichern ( { pathSteps . length } Schritte )
< / strong >
< ol style = { { margin : 0 , paddingLeft : '20px' , lineHeight : 1.55 } } >
2026-06-10 07:30:01 +02:00
{ pathSteps . map ( ( step , idx ) => (
< li key = { ` summary- ${ idx } ` } style = { { marginBottom : '4px' } } >
< strong > { step . exerciseTitle } < / strong >
2026-06-10 07:55:51 +02:00
{ step . exerciseId ? (
< span style = { { color : 'var(--text3)' } } > ( # { step . exerciseId } ) < / span >
) : (
< span style = { { color : 'var(--danger)' } } > — noch nicht angelegt < / span >
) }
{ step . roadmapPhase ? ` · ${ step . roadmapPhase } ` : '' }
2026-06-10 07:30:01 +02:00
< / li >
) ) }
< / ol >
2026-06-10 07:55:51 +02:00
< / div >
2026-06-10 07:30:01 +02:00
2026-05-23 11:46:25 +02:00
< div className = "form-row" >
< label className = "form-label" > Notiz für Kanten ( Fallback , optional ) < / label >
< textarea
className = "form-input"
rows = { 2 }
value = { segmentNotes }
onChange = { ( e ) => setSegmentNotes ( e . target . value ) }
placeholder = "Wird pro Kante genutzt, wenn keine KI-Begründung vorliegt."
/ >
< / div >
2026-06-10 07:30:01 +02:00
< div style = { { display : 'flex' , flexWrap : 'wrap' , gap : '8px' , marginTop : '10px' } } >
< button
type = "button"
className = "btn btn-secondary"
onClick = { ( ) => setWizardStep ( 3 ) }
disabled = { disabled || loading || saving }
>
← Match anpassen
< / button >
2026-05-23 11:46:25 +02:00
< button
type = "button"
className = "btn btn-primary"
disabled = { disabled || saving || pathSteps . filter ( ( s ) => s . exerciseId ) . length < 2 }
onClick = { savePathToGraph }
>
{ saving ? 'Speichern …' : 'Pfad in Graph speichern' }
< / button >
< button
type = "button"
className = "btn btn-secondary"
disabled = { loading || saving }
onClick = { ( ) => {
setPathSteps ( [ ] )
setTargetSummary ( null )
2026-05-23 12:59:46 +02:00
setSemanticBrief ( null )
setPathQa ( null )
setGapFillOffers ( [ ] )
2026-06-10 07:14:18 +02:00
setPathSkillExpectations ( null )
2026-06-10 07:55:51 +02:00
setPathInsertNotice ( '' )
2026-06-10 07:30:01 +02:00
setWizardStep ( editableMajorSteps . length >= 2 ? 2 : 1 )
2026-05-23 11:46:25 +02:00
} }
>
Vorschlag verwerfen
< / button >
< / div >
2026-06-10 07:30:01 +02:00
< / >
) }
< / section >
2026-05-23 11:46:25 +02:00
) : null }
2026-05-23 12:59:46 +02:00
2026-06-10 06:54:49 +02:00
< ExerciseGapFillPrepModal
open = { gapPrepOpen }
onClose = { closeGapFillPrep }
offer = { activeOffer }
contextLines = { activePlanningContextLines }
title = { gapPrepTitle }
onTitleChange = { setGapPrepTitle }
stageLearningGoal = { gapPrepStageGoal }
onStageLearningGoalChange = { setGapPrepStageGoal }
supplements = { gapPrepSupplements }
onSupplementsChange = { setGapPrepSupplements }
focusAreaId = { gapPrepFocusAreaId }
onFocusAreaChange = { setGapPrepFocusAreaId }
focusAreas = { focusAreas }
busy = { quickSaving }
error = { gapPrepError }
onSubmit = { submitGapFillPrep }
/ >
2026-05-23 12:59:46 +02:00
< ExerciseAiQuickCreateModal
open = { quickCreateOpen }
onClose = { closeQuickCreate }
searchLabel = { activeOffer ? . title _hint || goalQuery }
title = { quickTitle }
onTitleChange = { setQuickTitle }
sketch = { quickSketch }
onSketchChange = { setQuickSketch }
focusAreaId = { quickFocusAreaId }
onFocusAreaChange = { setQuickFocusAreaId }
focusAreas = { focusAreas }
catalogsReady = { focusAreas . length > 0 }
busy = { quickSaving }
error = { quickAiError }
onRunAi = { runQuickCreateAiSuggest }
/ >
< ExerciseAiSuggestPreviewModal
draft = { quickCreateDraft }
onDraftChange = { setQuickCreateDraft }
onDiscard = { ( ) => {
setQuickCreateDraft ( null )
2026-06-10 06:54:49 +02:00
if ( activeOffer ) {
setGapPrepOpen ( true )
} else {
setActivePlanningContextLines ( [ ] )
}
2026-05-23 12:59:46 +02:00
} }
2026-06-09 16:27:03 +02:00
planningContextLines = { activePlanningContextLines }
2026-05-23 12:59:46 +02:00
onApply = { applyQuickCreateDraft }
focusAreas = { focusAreas }
2026-06-08 08:10:53 +02:00
skillsCatalog = { skillsCatalog }
2026-05-23 12:59:46 +02:00
dialogTitle = "Pfad-Lücke — KI-Entwurf bearbeiten"
hint = "Texte anpassen, dann als Übung speichern und in den Pfad einfügen."
applyLabel = { quickSaving ? 'Wird angelegt …' : 'Anlegen und in Pfad einfügen' }
applyDisabled = { quickSaving }
zIndex = { 2100 }
/ >
2026-05-23 11:46:25 +02:00
< / div >
)
}