Enhance Exercise Progression Path Builder with Planning Wizard Stepper
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s

- Introduced a new Planning Wizard Stepper component to guide users through the exercise planning process in four steps.
- Implemented logic to compute the maximum reachable step based on user input and current progress.
- Updated state management to track the current wizard step and ensure it aligns with user interactions.
- Enhanced the user interface to improve clarity and navigation through the planning stages.
- Incremented application version to reflect these changes.
This commit is contained in:
Lars 2026-06-10 07:30:01 +02:00
parent 5692931d07
commit ca3a9c6fa4

View File

@ -1,7 +1,7 @@
/**
* Planungs-KI Phase C3/E3: Ziel Übungspfad vorschlagen Lücken mit KI anlegen in Graph speichern.
*/
import React, { useCallback, useEffect, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import api from '../utils/api'
import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal'
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
@ -136,6 +136,93 @@ const OFFER_SOURCE_LABELS = {
const PATH_STEPS_HARD_MAX = 10
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: 520px) {
.planning-step-label-full { display: none !important; }
.planning-step-label-short { display: inline !important; }
}
`}</style>
</nav>
)
}
const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
const LOAD_PROFILE_OPTIONS = [
@ -355,6 +442,12 @@ export default function ExerciseProgressionPathBuilder({
const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('')
const [gapPrepError, setGapPrepError] = useState('')
const [loadedPlanningHint, setLoadedPlanningHint] = useState(false)
const [wizardStep, setWizardStep] = useState(1)
const maxReachableStep = useMemo(
() => computeMaxReachableStep(editableMajorSteps, pathSteps),
[editableMajorSteps, pathSteps],
)
const buildPlanningArtifact = useCallback(
() =>
@ -404,6 +497,7 @@ export default function ExerciseProgressionPathBuilder({
setRoadmapDirty(false)
setStartTargetAnalyzed(false)
setError('')
setWizardStep(1)
api
.getExerciseProgressionGraph(Number(graphId))
@ -422,6 +516,7 @@ export default function ExerciseProgressionPathBuilder({
const majors = mapMajorStepsFromApi(art.progression_roadmap)
if (majors.length >= 2) {
setEditableMajorSteps(majors)
setWizardStep(2)
}
}
if (
@ -442,6 +537,12 @@ export default function ExerciseProgressionPathBuilder({
}
}, [graphId])
useEffect(() => {
if (wizardStep > maxReachableStep) {
setWizardStep(maxReachableStep)
}
}, [wizardStep, maxReachableStep])
useEffect(() => {
let cancelled = false
Promise.all([
@ -931,6 +1032,7 @@ export default function ExerciseProgressionPathBuilder({
setPathSkillExpectations(null)
setRoadmapDirty(false)
setLoadedPlanningHint(false)
setWizardStep(2)
await persistPlanningRoadmapToGraph()
} catch (e) {
console.error(e)
@ -980,6 +1082,7 @@ export default function ExerciseProgressionPathBuilder({
applyPathMatchResponse(res, q)
setMaxSteps(validSteps.length)
setLoadedPlanningHint(false)
setWizardStep(3)
await persistPlanningRoadmapToGraph()
} catch (e) {
console.error(e)
@ -1033,6 +1136,7 @@ export default function ExerciseProgressionPathBuilder({
setPathSkillExpectations(null)
setEditableMajorSteps([])
setRoadmapDirty(false)
setWizardStep(1)
if (typeof onSaved === 'function') await onSaved()
const msg =
skippedAi > 0
@ -1057,26 +1161,43 @@ export default function ExerciseProgressionPathBuilder({
>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>KI: Pfad zum Ziel</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
Zuerst didaktische Roadmap vorschlagen und anpassen, dann Übungen je Major Step aus der Bibliothek matchen.
Lücken können mit KI als Übung angelegt werden.
In vier Schritten: Ziel festlegen Roadmap bearbeiten Übungen matchen Lücken schließen und speichern.
</p>
{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 für diesen Graph geladen Roadmap anpassen und erneut matchen, oder neuen Vorschlag
starten.
<PlanningWizardStepper
currentStep={wizardStep}
maxReachable={maxReachableStep}
onStepChange={setWizardStep}
disabled={disabled || loading || saving}
/>
{error ? (
<p className="form-error" style={{ marginTop: 0, marginBottom: '12px' }}>
{error}
</p>
) : null}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
{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' }}>
<div className="form-row" style={{ flex: '2 1 240px', marginBottom: 0 }}>
<label className="form-label">Ziel / Entwicklungsrichtung</label>
<input
@ -1142,78 +1263,56 @@ export default function ExerciseProgressionPathBuilder({
/>
</div>
</div>
<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: 'flex-end', marginTop: '10px' }}>
<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 Roadmap als Nächstes
</span>
) : null}
<button
type="button"
className="btn btn-secondary"
disabled={
disabled ||
loading ||
saving ||
!graphId ||
editableMajorSteps.length < 2
}
onClick={matchExercisesFromRoadmap}
title={
editableMajorSteps.length < 2
? 'Zuerst Roadmap vorschlagen'
: roadmapDirty
? 'Roadmap wurde bearbeitet — erneut matchen'
: 'Bibliothek je Major Step durchsuchen'
}
>
{loadingMatch ? 'Match …' : roadmapDirty ? 'Übungen neu matchen' : 'Übungen matchen'}
</button>
</div>
<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>
{error ? (
<p className="form-error" style={{ marginTop: '10px' }}>
{error}
</p>
) : null}
{(progressionRoadmap?.goal_analysis ||
progressionRoadmap?.pipeline_phase === 'start_target_only') ? (
<div
style={{
marginTop: '12px',
padding: '12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
}}
>
{(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' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', marginBottom: '8px' }}>
<strong style={{ fontSize: '13px' }}>Zielanalyse</strong>
{progressionRoadmap.llm_start_target_applied ? (
@ -1261,45 +1360,32 @@ export default function ExerciseProgressionPathBuilder({
</p>
) : null}
</div>
</div>
) : null}
{(semanticBrief || targetSummary || pathSkillExpectations) && pathSteps.length > 0 ? (
<div style={{ marginTop: '10px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{semanticBrief?.primary_topic ? (
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
Thema: {semanticBrief.primary_topic}
</span>
</div>
</details>
) : 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>
</section>
) : null}
{editableMajorSteps.length > 0 ? (
{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>
) : (
<div
style={{
marginTop: '12px',
padding: '12px',
borderRadius: '8px',
border: '1px solid color-mix(in srgb, var(--accent) 45%, var(--border))',
@ -1307,7 +1393,7 @@ export default function ExerciseProgressionPathBuilder({
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', marginBottom: '8px' }}>
<strong style={{ fontSize: '13px' }}>Didaktische Roadmap bearbeiten</strong>
<strong style={{ fontSize: '13px' }}>Major Steps bearbeiten</strong>
{roadmapDirty ? (
<span className="exercise-tag" style={{ borderColor: 'var(--danger)' }}>
Geändert bitte erneut matchen
@ -1489,9 +1575,102 @@ export default function ExerciseProgressionPathBuilder({
</button>
) : null}
</div>
)}
{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>
) : null}
{pathQa && pathSteps.length > 0 ? (
{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 ? (
<div
style={{
marginTop: '10px',
@ -1528,7 +1707,7 @@ export default function ExerciseProgressionPathBuilder({
</p>
) : Number(pathQa.off_topic_count) > 0 ? (
<p style={{ margin: '6px 0 0', color: 'var(--danger)' }}>
{pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema siehe Lücken-Angebote unten.
{pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema Lücken in Schritt 4 schließen.
</p>
) : null}
{pathQa.reorder_applied ? (
@ -1547,88 +1726,7 @@ export default function ExerciseProgressionPathBuilder({
</div>
) : null}
{gapFillOffers.length > 0 ? (
<div
style={{
marginTop: '12px',
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>
) : null}
{pathSteps.length > 0 ? (
<>
<div style={{ marginTop: '14px' }}>
{pathSteps.map((step, idx) => (
<div
key={`${step.exerciseId}-${step.proposalKey || ''}-${idx}`}
@ -1637,14 +1735,12 @@ export default function ExerciseProgressionPathBuilder({
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '10px',
alignItems: 'end',
marginBottom: '12px',
paddingBottom: '12px',
borderBottom: idx < pathSteps.length - 1 ? '1px dashed var(--border)' : 'none',
padding: step.isOffTopic ? '8px' : '10px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: step.isOffTopic
? 'color-mix(in srgb, var(--danger) 6%, transparent)'
: undefined,
borderRadius: step.isOffTopic ? '8px' : undefined,
padding: step.isOffTopic ? '8px' : undefined,
? 'color-mix(in srgb, var(--danger) 6%, var(--surface2))'
: 'var(--surface2)',
}}
>
<div className="form-row" style={{ marginBottom: 0 }}>
@ -1749,6 +1845,170 @@ export default function ExerciseProgressionPathBuilder({
</div>
))}
</div>
<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>
) : (
<>
{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>
)}
<details style={{ marginBottom: '12px', fontSize: '12px' }}>
<summary style={{ cursor: 'pointer', fontWeight: 600, color: 'var(--accent-dark)' }}>
Pfad-Übersicht ({pathSteps.length} Schritte)
</summary>
<ol style={{ margin: '10px 0 0', paddingLeft: '20px', lineHeight: 1.5 }}>
{pathSteps.map((step, idx) => (
<li key={`summary-${idx}`} style={{ marginBottom: '4px' }}>
<strong>{step.exerciseTitle}</strong>
{step.roadmapPhase ? ` (${step.roadmapPhase})` : ''}
{step.isAiProposal ? ' — KI-Vorschlag, noch anlegen' : ''}
</li>
))}
</ol>
</details>
<div className="form-row">
<label className="form-label">Notiz für Kanten (Fallback, optional)</label>
<textarea
@ -1759,7 +2019,15 @@ export default function ExerciseProgressionPathBuilder({
placeholder="Wird pro Kante genutzt, wenn keine KI-Begründung vorliegt."
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
<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>
<button
type="button"
className="btn btn-primary"
@ -1778,14 +2046,16 @@ export default function ExerciseProgressionPathBuilder({
setSemanticBrief(null)
setPathQa(null)
setGapFillOffers([])
setProgressionRoadmap(null)
setPathSkillExpectations(null)
setWizardStep(editableMajorSteps.length >= 2 ? 2 : 1)
}}
>
Vorschlag verwerfen
</button>
</div>
</>
</>
)}
</section>
) : null}
<ExerciseGapFillPrepModal