shinkan-jinkendo/frontend/src/components/ExerciseProgressionPathBuilder.jsx
Lars 8d5f0b533c
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 48s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m31s
Enhance Exercise Progression Graph Panel and Path Builder with New Features
- Introduced a primary chain selection in the Exercise Progression Graph Panel to streamline exercise path management.
- Updated the ProgressionChainEditor to support single path mode, allowing users to manage a single progression path more effectively.
- Enhanced the ExerciseProgressionPathBuilder with improved logic for merging graph nodes into path steps and filtering gap offers.
- Updated UI elements for better clarity and user experience, including new notifications and styling adjustments.
- Incremented application version to reflect these updates.
2026-06-10 11:17:05 +02:00

2302 lines
85 KiB
JavaScript

/**
* Planungs-KI Phase C3/E3: Ziel → Übungspfad vorschlagen → Lücken mit KI anlegen → in Graph speichern.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal'
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
import {
aiPreviewToQuickCreateDraft,
buildQuickCreateAiPreview,
buildQuickCreateExercisePayloadFromDraft,
} from '../utils/exerciseAiQuickCreate'
import {
buildPathGapPlanningContextForAi,
gapOfferContextDisplayLines,
initialStageLearningGoalFromOffer,
} from '../utils/planningContextForExerciseAi'
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))
}
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>
)
}
function sourceLabel(source) {
const map = {
user: 'manuell',
llm: 'KI-Extraktion',
regex: 'Muster (von … bis …)',
merged: 'manuell + KI',
heuristic: 'heuristisch',
none: '—',
}
return map[source] || source || '—'
}
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
}
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
const isAiProposal = Boolean(step?.is_ai_proposal) || step?.exercise_id == null
return {
exerciseId: step?.exercise_id != null ? Number(step.exercise_id) : null,
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,
reasons: Array.isArray(step?.reasons) ? step.reasons : [],
isBridge: Boolean(step?.is_bridge),
isAiProposal,
aiSuggestion: step?.ai_suggestion || null,
semanticScore: step?.semantic_score,
isOffTopic: false,
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,
skillExpectations: step?.skill_expectations || null,
}
}
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',
roadmap_unfilled: 'Roadmap-Stufe',
}
function normalizeTitleKey(text) {
return String(text || '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ')
}
function mergeGraphIntoPathSteps(pathRows, graphNodes) {
if (!Array.isArray(graphNodes) || !graphNodes.length || !pathRows.length) return pathRows
return pathRows.map((row, i) => {
const node = graphNodes[i]
if (!node?.exercise_id) return row
if (row.exerciseId != null) return row
return {
...row,
exerciseId: Number(node.exercise_id),
exerciseTitle: node.title || `Übung #${node.exercise_id}`,
variantId: node.variant_id != null ? Number(node.variant_id) : null,
variants: row.variants || [],
isFromGraph: true,
reasons: [...(row.reasons || []), 'Aus bestehendem Graph übernommen'],
}
})
}
function filterGapOffersForGraph(offers, pathRows, graphNodes) {
if (!Array.isArray(offers) || !offers.length) return offers
const graphIds = new Set(
(graphNodes || []).map((n) => Number(n.exercise_id)).filter(Number.isFinite),
)
const graphTitles = new Set(
(graphNodes || []).map((n) => normalizeTitleKey(n.title)).filter(Boolean),
)
const pathIds = new Set(
(pathRows || []).map((r) => r.exerciseId).filter((id) => id != null),
)
return offers.filter((offer) => {
const hint = normalizeTitleKey(offer?.title_hint)
const majorIdx =
offer?.roadmap_major_step_index != null ? Number(offer.roadmap_major_step_index) : null
if (majorIdx != null && Number.isFinite(majorIdx) && graphNodes?.[majorIdx]) {
const gid = Number(graphNodes[majorIdx].exercise_id)
if (graphIds.has(gid) && pathIds.has(gid)) return false
}
for (const gid of graphIds) {
if (pathIds.has(gid) && hint) {
const node = graphNodes.find((n) => Number(n.exercise_id) === gid)
const nodeTitle = normalizeTitleKey(node?.title)
if (nodeTitle && (nodeTitle === hint || nodeTitle.includes(hint) || hint.includes(nodeTitle))) {
return false
}
}
}
for (const t of graphTitles) {
if (hint && (t === hint || t.includes(hint) || hint.includes(t))) return false
}
return true
})
}
function SavedGraphPathStrip({ nodes, hasDraft }) {
if (!Array.isArray(nodes) || nodes.length === 0) {
return (
<div
style={{
marginBottom: '14px',
padding: '10px 12px',
borderRadius: '8px',
border: '1px dashed var(--border)',
background: 'var(--surface2)',
fontSize: '12px',
color: 'var(--text2)',
}}
>
<strong style={{ color: 'var(--text1)' }}>Im Graph gespeichert:</strong> noch kein Pfad der erste
Speichervorgang legt die Übungsfolge an.
</div>
)
}
return (
<div
style={{
marginBottom: '14px',
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
fontSize: '12px',
}}
>
<strong style={{ display: 'block', marginBottom: '8px', color: 'var(--accent-dark)' }}>
Im Graph gespeichert ({nodes.length} Schritte)
{hasDraft ? (
<span style={{ fontWeight: 400, color: 'var(--text3)', marginLeft: '8px' }}>
KI-Entwurf unten; Speichern ersetzt diesen Pfad
</span>
) : null}
</strong>
<ol style={{ margin: 0, paddingLeft: '20px', lineHeight: 1.55 }}>
{nodes.map((node, idx) => (
<li key={`saved-${node.exercise_id}-${node.variant_id}-${idx}`}>
<Link to={`/exercises/${node.exercise_id}`}>{node.title || `Übung #${node.exercise_id}`}</Link>
{node.variant_name ? (
<span style={{ color: 'var(--text3)' }}>{` · ${node.variant_name}`}</span>
) : null}
</li>
))}
</ol>
</div>
)
}
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 = [
'koordination',
'präzision',
'kraft',
'geschwindigkeit',
'timing',
'reaktion',
'distanz',
'gleichgewicht',
'kime',
'ausdauer',
'beweglichkeit',
]
function mapMajorStepsFromApi(apiRoadmap) {
const raw = apiRoadmap?.roadmap?.major_steps
if (!Array.isArray(raw)) return []
const rows = raw.map((s, i) => ({
index: i,
phase: s.phase || 'vertiefung',
learning_goal: (s.learning_goal || '').trim(),
consolidates: Array.isArray(s.consolidates) ? s.consolidates : [],
rationale: s.rationale || '',
load_profile: [],
success_criteria: [],
anti_patterns: [],
exercise_type: '',
}))
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') : ''
}
function reindexMajorSteps(rows) {
return rows.map((row, i) => ({ ...row, index: i }))
}
function majorStepsToOverridePayload(rows) {
const indexed = reindexMajorSteps(rows)
return {
major_steps: indexed.map((row) => ({
index: row.index,
phase: row.phase || 'vertiefung',
learning_goal: row.learning_goal.trim(),
consolidates: row.consolidates || [],
rationale: row.rationale || '',
})),
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 : [],
})),
}
}
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)
}
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,
}
}
/** 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
}
function resolveDefaultFocusAreaId(targetSummary, focusAreas) {
const targetName = targetSummary?.focus_areas?.[0]
if (targetName && Array.isArray(focusAreas) && focusAreas.length) {
const norm = String(targetName).trim().toLowerCase()
const hit = focusAreas.find((fa) => String(fa.name || '').trim().toLowerCase() === norm)
if (hit?.id) return Number(hit.id)
}
return focusAreas?.[0]?.id ? Number(focusAreas[0].id) : null
}
export default function ExerciseProgressionPathBuilder({
graphId,
disabled = false,
onSaved,
graphChainNodes = null,
graphChainEdgeIds = null,
}) {
const [goalQuery, setGoalQuery] = useState('')
const [startSituation, setStartSituation] = useState('')
const [targetState, setTargetState] = useState('')
const [roadmapNotes, setRoadmapNotes] = useState('')
const [maxSteps, setMaxSteps] = useState(5)
const [segmentNotes, setSegmentNotes] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [targetSummary, setTargetSummary] = useState(null)
const [semanticBrief, setSemanticBrief] = useState(null)
const [pathQa, setPathQa] = useState(null)
const [pathSteps, setPathSteps] = useState([])
const [gapFillOffers, setGapFillOffers] = useState([])
const [progressionRoadmap, setProgressionRoadmap] = useState(null)
const [pathSkillExpectations, setPathSkillExpectations] = useState(null)
const [editableMajorSteps, setEditableMajorSteps] = useState([])
const [roadmapDirty, setRoadmapDirty] = useState(false)
const [loadingRoadmap, setLoadingRoadmap] = useState(false)
const [loadingStartTarget, setLoadingStartTarget] = useState(false)
const [loadingMatch, setLoadingMatch] = useState(false)
const [startTargetAnalyzed, setStartTargetAnalyzed] = useState(false)
const loading = loadingRoadmap || loadingStartTarget || loadingMatch
const [focusAreas, setFocusAreas] = useState([])
const [skillsCatalog, setSkillsCatalog] = useState([])
const [generatingOfferId, setGeneratingOfferId] = useState(null)
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('')
const [activePlanningContextLines, setActivePlanningContextLines] = useState([])
const [gapPrepOpen, setGapPrepOpen] = useState(false)
const [gapPrepTitle, setGapPrepTitle] = useState('')
const [gapPrepStageGoal, setGapPrepStageGoal] = useState('')
const [gapPrepSupplements, setGapPrepSupplements] = useState('')
const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('')
const [gapPrepError, setGapPrepError] = useState('')
const [loadedPlanningHint, setLoadedPlanningHint] = useState(false)
const [wizardStep, setWizardStep] = useState(1)
const [pathInsertNotice, setPathInsertNotice] = useState('')
const maxReachableStep = useMemo(
() => computeMaxReachableStep(editableMajorSteps, pathSteps),
[editableMajorSteps, pathSteps],
)
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('')
setWizardStep(1)
setPathInsertNotice('')
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)
setWizardStep(2)
}
}
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])
useEffect(() => {
if (wizardStep > maxReachableStep) {
setWizardStep(maxReachableStep)
}
}, [wizardStep, maxReachableStep])
useEffect(() => {
let cancelled = false
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 : [])
})
.catch(() => {
if (!cancelled) {
setFocusAreas([])
setSkillsCatalog([])
}
})
return () => {
cancelled = true
}
}, [])
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)))
}, [])
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: '',
load_profile: [],
success_criteria: [],
anti_patterns: [],
exercise_type: '',
},
])
})
setRoadmapDirty(true)
}, [])
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
})
}, [])
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) }))
}
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
}
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],
)
const closeQuickCreate = () => {
if (quickSaving) return
setQuickCreateOpen(false)
setActiveOffer(null)
setQuickCreateDraft(null)
setQuickAiError('')
}
const gapContextFallbackParams = {
goalQuery,
semanticBrief,
graphId,
pathSteps,
editableMajorSteps,
progressionRoadmap,
startSituation,
targetState,
roadmapNotes,
}
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()
if (title.length < 3) {
alert('Titel: mindestens 3 Zeichen.')
return
}
const supplements = (prep?.supplements || '').trim()
const stageGoal = (prep?.stageLearningGoal || '').trim()
let goalText = (offer?.goal_for_ai || offer?.sketch || '').trim()
if (supplements) {
goalText = `${goalText}\n\nTrainer-Ergänzungen für diese Übung:\n${supplements}`.trim()
}
const focusId =
prep?.focusAreaId != null && Number.isFinite(Number(prep.focusAreaId))
? Number(prep.focusAreaId)
: resolveDefaultFocusAreaId(targetSummary, focusAreas)
if (!focusId) {
alert('Kein Fokusbereich verfügbar — bitte im Vorbereitungsdialog wählen.')
return
}
const focusRow = (focusAreas || []).find((x) => Number(x.id) === focusId)
const focusHint = (focusRow?.name || offer?.primary_topic || '').trim()
setActiveOffer(offer)
setQuickTitle(title)
setQuickSketch(goalText)
setQuickFocusAreaId(String(focusId))
setQuickAiError('')
setQuickCreateDraft(null)
setQuickSaving(true)
setGeneratingOfferId(offer?.offer_id || null)
const contextParams = {
...gapContextFallbackParams,
stageLearningGoalOverride: stageGoal,
gapTrainerSupplements: supplements,
}
const contextLines = gapOfferContextDisplayLines(offer, contextParams)
setActivePlanningContextLines(contextLines)
const planningContext = buildPathGapPlanningContextForAi({
offer,
...contextParams,
})
try {
const aiRes = await api.suggestExerciseAi({
title,
goal: goalText || undefined,
execution: '',
preparation: '',
trainer_notes: supplements || '',
focus_area_hint: focusHint || undefined,
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
planning_context: planningContext || undefined,
include_summary: true,
include_skills: true,
include_instructions: true,
})
const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: goalText })
if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) {
throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.')
}
setQuickCreateDraft(
aiPreviewToQuickCreateDraft(preview, {
title,
focusAreaId: focusId,
sketchPlain: goalText,
}),
)
setGapPrepOpen(false)
setQuickCreateOpen(false)
} catch (e) {
console.error(e)
const msg = e?.message || String(e)
setGapPrepError(msg)
setQuickAiError(msg)
} finally {
setQuickSaving(false)
setGeneratingOfferId(null)
}
}
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)
const title = (created.title || quickTitle || 'Übung').trim()
setPathInsertNotice(
`${title}" wurde in den KI-Entwurf eingefügt. Mit «Pfad im Graph speichern» wird der gesamte Pfad übernommen.`,
)
setWizardStep(4)
} catch (e) {
console.error(e)
const msg = e?.message || String(e)
setQuickAiError(msg)
alert(msg || 'Übung konnte nicht angelegt werden')
} finally {
setQuickSaving(false)
}
}
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.')
}
const mergedRows = mergeGraphIntoPathSteps(rows, graphChainNodes)
const rawGaps = Array.isArray(res?.gap_fill_offers)
? res.gap_fill_offers
: Array.isArray(qa?.gap_fill_offers)
? qa.gap_fill_offers
: []
const gaps = filterGapOffersForGraph(rawGaps, mergedRows, graphChainNodes)
setPathSteps(mergedRows)
setTargetSummary(res?.target_profile_summary || null)
setSemanticBrief(res?.semantic_brief_summary || null)
setPathQa(qa)
setGapFillOffers(gaps)
setProgressionRoadmap(res?.progression_roadmap || null)
setPathSkillExpectations(res?.path_skill_expectations || null)
setRoadmapDirty(false)
if (!segmentNotes.trim() && q) setSegmentNotes(q.slice(0, 400))
if (res?.progression_roadmap?.stage_specs?.length) {
setEditableMajorSteps((prev) =>
prev.length ? mergeStageSpecsIntoMajorSteps(prev, res.progression_roadmap) : prev,
)
}
}
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)
}
}
const suggestRoadmap = 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 fieldsEmpty = !startSituation.trim() && !targetState.trim()
setLoadingRoadmap(true)
setError('')
try {
const res = await api.suggestProgressionPath({
query: q,
max_steps: Number(maxSteps),
include_llm_intent: true,
include_path_qa: false,
include_llm_path_qa: false,
include_path_reorder: false,
include_ai_gap_fill: false,
include_roadmap_preview: true,
include_llm_roadmap: true,
include_llm_start_target: fieldsEmpty,
roadmap_only: true,
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(startSituation, targetState, roadmapNotes),
})
const majors = mapMajorStepsFromApi(res?.progression_roadmap)
if (majors.length < 2) {
throw new Error('Roadmap hat zu wenig Major Steps.')
}
setEditableMajorSteps(majors)
setMaxSteps(majors.length)
const roadmap = res?.progression_roadmap || null
setProgressionRoadmap(roadmap)
if (fieldsEmpty) {
applyResolvedStructuredFromRoadmap(roadmap, {
setStartSituation,
setTargetState,
setRoadmapNotes,
})
setStartTargetAnalyzed(true)
}
setSemanticBrief(res?.semantic_brief_summary || null)
setPathSteps([])
setTargetSummary(null)
setPathQa(null)
setGapFillOffers([])
setPathSkillExpectations(null)
setRoadmapDirty(false)
setLoadedPlanningHint(false)
setWizardStep(2)
await persistPlanningRoadmapToGraph()
} catch (e) {
console.error(e)
setError(e.message || 'Roadmap-Vorschlag fehlgeschlagen')
setEditableMajorSteps([])
setProgressionRoadmap(null)
setPathSkillExpectations(null)
} finally {
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),
...roadmapStructuredPayload(startSituation, targetState, roadmapNotes),
})
applyPathMatchResponse(res, q)
setMaxSteps(validSteps.length)
setLoadedPlanningHint(false)
setWizardStep(3)
await persistPlanningRoadmapToGraph()
} catch (e) {
console.error(e)
setError(e.message || 'Übungs-Match fehlgeschlagen')
} finally {
setLoadingMatch(false)
}
}
const savePathToGraph = async () => {
if (!graphId) {
alert('Zuerst einen Graphen wählen.')
return
}
const steps = pathSteps.filter((s) => s.exerciseId != null)
const skippedAi = pathSteps.filter((s) => s.isAiProposal).length
if (steps.length < 2) {
alert(
skippedAi > 0
? 'Mindestens zwei gespeicherte Übungen nötig. KI-Vorschläge zuerst als Übung anlegen.'
: 'Mindestens zwei Schritte mit Übung nötig.',
)
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 {
const edgeIds = Array.isArray(graphChainEdgeIds)
? graphChainEdgeIds.filter((id) => Number.isFinite(Number(id)))
: []
if (edgeIds.length > 0) {
await api.deleteExerciseProgressionEdgesBatch(Number(graphId), edgeIds)
}
const planningArtifact = buildPlanningArtifact()
await api.createExerciseProgressionSequence(Number(graphId), {
steps: steps.map((s) => ({
exercise_id: s.exerciseId,
variant_id: s.variantId || null,
})),
segment_notes,
...(planningArtifact ? { planning_roadmap: planningArtifact } : {}),
})
setPathSteps([])
setTargetSummary(null)
setSemanticBrief(null)
setPathQa(null)
setGapFillOffers([])
setProgressionRoadmap(null)
setPathSkillExpectations(null)
setEditableMajorSteps([])
setRoadmapDirty(false)
setWizardStep(1)
setPathInsertNotice('')
if (typeof onSaved === 'function') await onSaved()
const msg =
skippedAi > 0
? `Pfad gespeichert (${n} Kante(n)). ${skippedAi} KI-Vorschlag/Vorschläge noch nicht angelegt.`
: edgeIds.length > 0
? `Progressionspfad aktualisiert (${n} Kante(n)).`
: `Progressionspfad angelegt (${n} Kante(n)).`
alert(msg)
} catch (e) {
console.error(e)
const detail = e?.message || String(e)
setError(
detail.includes('409') || detail.toLowerCase().includes('duplikat')
? 'Speichern fehlgeschlagen: Pfad-Konflikt. Bitte erneut versuchen — bestehende Kanten werden beim Speichern ersetzt.'
: detail || 'Speichern fehlgeschlagen',
)
} finally {
setSaving(false)
}
}
return (
<div
style={{
marginTop: '16px',
paddingTop: '16px',
borderTop: '1px solid var(--border)',
}}
>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Progressionspfad planen (KI)</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
Ein Graph hat einen linearen Pfad. Oben der gespeicherte Stand, darunter der KI-Entwurf in vier Schritten.
Speichern übernimmt den Entwurf und ersetzt den bisherigen Pfad.
</p>
<SavedGraphPathStrip nodes={graphChainNodes} hasDraft={pathSteps.length > 0} />
<PlanningWizardStepper
currentStep={wizardStep}
maxReachable={maxReachableStep}
onStepChange={setWizardStep}
disabled={disabled || loading || saving}
/>
{error ? (
<p className="form-error" style={{ marginTop: 0, marginBottom: '12px' }}>
{error}
</p>
) : null}
{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
className="form-input"
value={goalQuery}
onChange={(e) => setGoalQuery(e.target.value)}
placeholder="z. B. Von Erlernen bis zur Perfektion des Fußtritts Mae Geri …"
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>
</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>
<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>
{(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 ? (
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
KI Start/Ziel
</span>
) : null}
{progressionRoadmap.llm_goal_analysis_applied ? (
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
KI-Zielanalyse
</span>
) : (
<span className="exercise-tag">heuristisch</span>
)}
{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}
{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}
{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}
</div>
</div>
</details>
) : null}
</section>
) : null}
{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={{
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))',
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', marginBottom: '8px' }}>
<strong style={{ fontSize: '13px' }}>Major Steps bearbeiten</strong>
{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)'
: ''}
. Phasen und Lernziele anpassen; optional Stufen-Details für Match und KI-Lücken, dann Übungen matchen.
</p>
<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)',
}}
>
<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>
</div>
<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>
</div>
))}
</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}
</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}
{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',
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)' }}>
{pathQa.bridge_insert_count} Brücken-Übung(en) aus der Bibliothek eingefügt.
</p>
) : null}
{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 ? (
<p style={{ margin: '6px 0 0', color: 'var(--danger)' }}>
{pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema Lücken in Schritt 4 schließen.
</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]}`
: ''}
</p>
) : null}
{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}
</div>
) : null}
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{pathSteps.map((step, idx) => (
<div
key={`${step.exerciseId}-${step.proposalKey || ''}-${idx}`}
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '10px',
alignItems: 'end',
padding: step.isOffTopic ? '8px' : '10px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: step.isOffTopic
? 'color-mix(in srgb, var(--danger) 6%, var(--surface2))'
: 'var(--surface2)',
}}
>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">
Schritt {idx + 1}
{step.roadmapMajorStepIndex != null
? ` · Roadmap ${step.roadmapMajorStepIndex + 1}`
: ''}
{step.roadmapPhase ? ` (${step.roadmapPhase})` : ''}
{step.isOffTopic ? ' (themenfremd)' : ''}
{step.isFromGraph ? ' (im Graph)' : ''}
{step.isAiProposal ? ' (KI-Neu)' : step.isBridge ? ' (Brücke)' : ''}
</label>
{step.roadmapLearningGoal ? (
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '4px 0 0' }}>
Ziel: {step.roadmapLearningGoal}
</p>
) : null}
{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}
<div style={{ fontSize: '13px' }}>
<strong>{step.exerciseTitle}</strong>
{step.exerciseId ? (
<span style={{ color: 'var(--text3)' }}> (#{step.exerciseId})</span>
) : (
<span style={{ color: 'var(--text3)' }}> noch nicht in Bibliothek</span>
)}
</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>
{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>
)}
</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>
<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>
) : (
<>
<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-Entwurf ist noch nicht gespeichert. «Pfad im Graph speichern» übernimmt
ihn und ersetzt den oben gezeigten Pfad.
{pathInsertNotice ? <p style={{ margin: '8px 0 0' }}>{pathInsertNotice}</p> : null}
</div>
{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>
)}
<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 }}>
{pathSteps.map((step, idx) => (
<li key={`summary-${idx}`} style={{ marginBottom: '4px' }}>
<strong>{step.exerciseTitle}</strong>
{step.exerciseId ? (
<span style={{ color: 'var(--text3)' }}> (#{step.exerciseId})</span>
) : (
<span style={{ color: 'var(--danger)' }}> noch nicht angelegt</span>
)}
{step.roadmapPhase ? ` · ${step.roadmapPhase}` : ''}
{step.isFromGraph ? ' · bereits im Graph' : ''}
</li>
))}
</ol>
</div>
<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>
<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"
disabled={disabled || saving || pathSteps.filter((s) => s.exerciseId).length < 2}
onClick={savePathToGraph}
>
{saving
? 'Speichern …'
: graphChainEdgeIds?.length
? 'Pfad im Graph speichern (ersetzen)'
: 'Pfad im Graph speichern'}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={loading || saving}
onClick={() => {
setPathSteps([])
setTargetSummary(null)
setSemanticBrief(null)
setPathQa(null)
setGapFillOffers([])
setPathSkillExpectations(null)
setPathInsertNotice('')
setWizardStep(editableMajorSteps.length >= 2 ? 2 : 1)
}}
>
Vorschlag verwerfen
</button>
</div>
</>
)}
</section>
) : null}
<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}
/>
<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)
if (activeOffer) {
setGapPrepOpen(true)
} else {
setActivePlanningContextLines([])
}
}}
planningContextLines={activePlanningContextLines}
onApply={applyQuickCreateDraft}
focusAreas={focusAreas}
skillsCatalog={skillsCatalog}
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}
/>
</div>
)
}