shinkan-jinkendo/frontend/src/components/ProgressionGraphEditor.jsx
Lars f36a747efa
All checks were successful
Deploy Development / deploy (push) Successful in 45s
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 1m16s
Enhance Progression Path Suggestion with Stage Specification Refinement
- Introduced `auto_refine_stage_spec` to `ProgressionPathSuggestRequest`, enabling optional refinement of stage specifications during the rematch process.
- Updated `_run_roadmap_rematch_loop` to incorporate stage specification refinements, logging changes for better tracking of adjustments made during rematching.
- Enhanced `suggest_progression_path` to include refine logs in the output, providing clearer insights into the refinement process.
- Added utility functions for formatting refine log entries, improving the display of refinement actions in the frontend components.
- Updated frontend components to display refine logs, enhancing user feedback on stage specification adjustments during progression analysis.
- Bumped version to 0.8.227 to reflect the new features and improvements.
2026-06-11 21:43:45 +02:00

1042 lines
37 KiB
JavaScript

/**
* Integrierter Slot-Editor für Progressionsgraphen (Phase B).
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import ExercisePickerModal from './ExercisePickerModal'
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
import ProgressionSlotCard from './ProgressionSlotCard'
import ProgressionFindingsPanel from './ProgressionFindingsPanel'
import {
aiPreviewToQuickCreateDraft,
buildQuickCreateAiPreview,
buildQuickCreateExercisePayloadFromDraft,
ensureQuickCreateDraftFromAiSuggestion,
} from '../utils/exerciseAiQuickCreate'
import {
buildPathGapPlanningContextForAi,
buildSlotGapGoalForAi,
gapOfferContextDisplayLines,
initialStageLearningGoalFromOffer,
} from '../utils/planningContextForExerciseAi'
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
import {
addSlotToDraft,
applyEvaluateResponseToDraft,
applyGapOfferToDraft,
applyMatchResponseToDraft,
applyResolvedStructuredToDraft,
buildPlanningArtifactFromDraft,
hydrateProgressionGraphDraft,
SLOT_MIN,
insertSlotInDraft,
librarySlotExercise,
majorStepsToOverridePayload,
moveSlotInDraft,
patchSlotInDraft,
removeSlotFromDraft,
saveProgressionGraphDraft,
setSlotPrimaryLibrary,
SLOT_MAX,
slotsAsPathStepRows,
slotsToEvaluateSteps,
draftRetrievalBoostExerciseIds,
slotsToSlotAssignments,
syncProgressionRoadmapFromSlots,
syncSlotPhasesFromRoadmap,
} from '../utils/progressionGraphDraft'
function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
const body = {}
const start = (startSituation || '').trim()
const target = (targetState || '').trim()
const notes = (roadmapNotes || '').trim()
if (start) body.start_situation = start
if (target) body.target_state = target
if (notes) body.roadmap_notes = notes
return body
}
function resolveDefaultFocusAreaId(targetSummary, focusAreas) {
const targetName = targetSummary?.focus_areas?.[0]
if (targetName && Array.isArray(focusAreas) && focusAreas.length) {
const norm = String(targetName).trim().toLowerCase()
const hit = focusAreas.find((fa) => String(fa.name || '').trim().toLowerCase() === norm)
if (hit?.id) return Number(hit.id)
}
return focusAreas?.[0]?.id ? Number(focusAreas[0].id) : null
}
export default function ProgressionGraphEditor({ graphId, embedded = false, onSaved }) {
const [graphMeta, setGraphMeta] = useState(null)
const [draft, setDraft] = useState(null)
const [busy, setBusy] = useState(false)
const [loadErr, setLoadErr] = useState('')
const [actionErr, setActionErr] = useState('')
const [matchNotice, setMatchNotice] = useState('')
const [pickContext, setPickContext] = useState(null)
const [pathQa, setPathQa] = useState(null)
const [gapFillOffers, setGapFillOffers] = useState([])
const [evaluating, setEvaluating] = useState(false)
const [matching, setMatching] = useState(false)
const [roadmapLoading, setRoadmapLoading] = useState(false)
const [startTargetLoading, setStartTargetLoading] = useState(false)
const [startTargetReady, setStartTargetReady] = useState(false)
const [semanticBrief, setSemanticBrief] = useState(null)
const [targetSummary, setTargetSummary] = useState(null)
const [focusAreas, setFocusAreas] = useState([])
const [skillsCatalog, setSkillsCatalog] = useState([])
const [activeOffer, setActiveOffer] = useState(null)
const [activeOfferSlotIndex, setActiveOfferSlotIndex] = useState(null)
const [gapPrepOpen, setGapPrepOpen] = useState(false)
const [gapPrepTitle, setGapPrepTitle] = useState('')
const [gapPrepStageGoal, setGapPrepStageGoal] = useState('')
const [gapPrepSupplements, setGapPrepSupplements] = useState('')
const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('')
const [gapPrepError, setGapPrepError] = useState('')
const [generatingOfferId, setGeneratingOfferId] = useState(null)
const [gapAiBusy, setGapAiBusy] = useState(false)
const [currentEdges, setCurrentEdges] = useState([])
const [slotQuickCreateIndex, setSlotQuickCreateIndex] = useState(null)
const [slotQuickCreateDraft, setSlotQuickCreateDraft] = useState(null)
const [slotQuickSaving, setSlotQuickSaving] = useState(false)
const [slotQuickError, setSlotQuickError] = useState('')
const [activePlanningContextLines, setActivePlanningContextLines] = useState([])
const loadGraph = useCallback(async () => {
if (!graphId) return
setBusy(true)
setLoadErr('')
try {
const [graph, edges] = await Promise.all([
api.getExerciseProgressionGraph(Number(graphId)),
api.listExerciseProgressionEdges(Number(graphId)),
])
const edgeList = Array.isArray(edges) ? edges : []
setCurrentEdges(edgeList)
setGraphMeta(graph)
const hydrated = hydrateProgressionGraphDraft({
artifact: graph?.planning_roadmap,
edges: edgeList,
graphName: graph?.name,
})
setDraft(hydrated)
setStartTargetReady(
Boolean((hydrated.startSituation || '').trim() && (hydrated.targetState || '').trim()),
)
const findings = graph?.planning_roadmap?.last_findings
if (findings) setPathQa(findings)
} catch (e) {
setLoadErr(e.message || 'Graph konnte nicht geladen werden')
setDraft(null)
} finally {
setBusy(false)
}
}, [graphId])
useEffect(() => {
loadGraph()
}, [loadGraph])
useEffect(() => {
let cancelled = false
Promise.all([
api.listFocusAreas({ status: 'active' }),
api.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 patchDraft = useCallback((patchFn) => {
setDraft((prev) => {
if (!prev) return prev
const next = patchFn(prev)
return { ...next, dirty: true }
})
}, [])
const gapContextParams = useMemo(() => {
if (!draft) return {}
return {
goalQuery: draft.goalQuery,
semanticBrief,
graphId,
pathSteps: slotsAsPathStepRows(draft),
editableMajorSteps: draft.majorSteps,
progressionRoadmap: draft.progressionRoadmap,
startSituation: draft.startSituation,
targetState: draft.targetState,
roadmapNotes: draft.roadmapNotes,
}
}, [draft, semanticBrief, graphId])
const handlePickExercise = async (exercise) => {
if (!pickContext || !exercise?.id) return
const { slotIndex, role } = pickContext
const entry = librarySlotExercise({
exerciseId: exercise.id,
exerciseTitle: exercise.title || `Übung #${exercise.id}`,
})
patchDraft((d) => {
const slots = d.slots.map((s, i) => {
if (i !== slotIndex) return s
if (role === 'primary') return { ...s, primary: entry }
const siblings = [...(s.siblings || [])]
if (!siblings.some((x) => x.exerciseId === entry.exerciseId)) siblings.push(entry)
return { ...s, siblings }
})
return syncProgressionRoadmapFromSlots({ ...d, slots })
})
setPickContext(null)
}
const handlePatchLearningGoal = (slotIndex, value) => {
patchDraft((d) => patchSlotInDraft(d, slotIndex, { learning_goal: value }))
}
const handlePatchPhase = (slotIndex, value) => {
patchDraft((d) => patchSlotInDraft(d, slotIndex, { phase: value }))
}
const handleMoveSlot = (slotIndex, dir) => {
patchDraft((d) => moveSlotInDraft(d, slotIndex, dir))
}
const handleRemoveSlot = (slotIndex) => {
if ((draft?.slots?.length || 0) <= 2) {
alert('Mindestens zwei Slots müssen bleiben.')
return
}
if (!window.confirm(`Slot ${slotIndex + 1} wirklich entfernen?`)) return
patchDraft((d) => removeSlotFromDraft(d, slotIndex))
}
const handleInsertAfter = (slotIndex) => {
if ((draft?.slots?.length || 0) >= SLOT_MAX) {
alert(`Maximal ${SLOT_MAX} Slots.`)
return
}
patchDraft((d) => insertSlotInDraft(d, slotIndex))
}
const handleAddSlot = () => {
if ((draft?.slots?.length || 0) >= SLOT_MAX) {
alert(`Maximal ${SLOT_MAX} Slots.`)
return
}
patchDraft((d) => addSlotToDraft(d))
}
const handleClearPrimary = (slotIndex) => {
patchDraft((d) => {
const slots = d.slots.map((s, i) =>
i === slotIndex
? {
...s,
primary: {
kind: 'empty',
exerciseId: null,
variantId: null,
exerciseTitle: '',
variantName: null,
proposalKey: null,
aiSuggestion: null,
},
siblings: [],
}
: s,
)
return syncProgressionRoadmapFromSlots({ ...d, slots })
})
}
const handleRemoveSibling = (slotIndex, sibIdx) => {
patchDraft((d) => {
const slots = d.slots.map((s, i) => {
if (i !== slotIndex) return s
return { ...s, siblings: s.siblings.filter((_, j) => j !== sibIdx) }
})
return { ...d, slots }
})
}
const validMajorSteps = useMemo(() => {
if (!draft?.slots) return []
return draft.slots.filter((s) => (s.learning_goal || '').trim().length >= 3)
}, [draft?.slots])
const runAnalyzeStartTarget = async () => {
const q = (draft?.goalQuery || '').trim()
if (q.length < 3) {
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
return
}
setStartTargetLoading(true)
setActionErr('')
try {
const res = await api.suggestProgressionPath({
query: q,
max_steps: draft.maxSteps || 5,
include_llm_intent: false,
include_path_qa: false,
include_llm_path_qa: false,
include_path_reorder: false,
include_ai_gap_fill: false,
include_roadmap_preview: false,
include_llm_roadmap: false,
include_llm_start_target: true,
start_target_only: true,
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
})
const roadmap = res?.progression_roadmap
if (!roadmap) throw new Error('Keine Start/Ziel-Analyse in der Antwort')
setDraft((prev) => {
const structured = applyResolvedStructuredToDraft(
{ ...prev, progressionRoadmap: roadmap },
roadmap,
)
return { ...structured, dirty: true }
})
setStartTargetReady(true)
setSemanticBrief(res?.semantic_brief_summary || null)
} catch (e) {
setActionErr(e.message || 'Start/Ziel-Analyse fehlgeschlagen')
} finally {
setStartTargetLoading(false)
}
}
const runRoadmapGenerate = async () => {
const q = (draft?.goalQuery || '').trim()
if (q.length < 3) {
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
return
}
const fieldsEmpty = !(draft.startSituation || '').trim() && !(draft.targetState || '').trim()
setRoadmapLoading(true)
setActionErr('')
try {
const res = await api.suggestProgressionPath({
query: q,
max_steps: draft.maxSteps || 5,
include_llm_intent: true,
include_path_qa: false,
include_llm_path_qa: false,
include_path_reorder: false,
include_ai_gap_fill: false,
include_roadmap_preview: true,
include_llm_roadmap: true,
include_llm_start_target: fieldsEmpty,
roadmap_only: true,
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
})
const roadmap = res?.progression_roadmap
if (!roadmap) throw new Error('Keine Roadmap in der Antwort')
const majorCount = (roadmap?.roadmap?.major_steps || []).length
if (majorCount < SLOT_MIN) throw new Error('Roadmap hat zu wenig Stufen.')
const preservedArtifact = buildPlanningArtifactFromDraft(draft) || {}
let startSituation = draft.startSituation
let targetState = draft.targetState
let roadmapNotes = draft.roadmapNotes
if (fieldsEmpty) {
const patch = applyResolvedStructuredToDraft(
{ startSituation, targetState, roadmapNotes },
roadmap,
)
startSituation = patch.startSituation
targetState = patch.targetState
roadmapNotes = patch.roadmapNotes
setStartTargetReady(true)
}
const hydrated = hydrateProgressionGraphDraft({
artifact: {
...preservedArtifact,
goal_query: q,
progression_roadmap: roadmap,
start_situation: startSituation,
target_state: targetState,
roadmap_notes: roadmapNotes,
max_steps: majorCount || draft.maxSteps,
},
edges: currentEdges,
graphName: draft.graphName,
})
const withPhases = syncSlotPhasesFromRoadmap(hydrated, roadmap)
setDraft({ ...withPhases, goalQuery: q, maxSteps: majorCount || withPhases.maxSteps, dirty: true })
setSemanticBrief(res?.semantic_brief_summary || null)
} catch (e) {
setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen')
} finally {
setRoadmapLoading(false)
}
}
const runMatch = async () => {
const q = (draft?.goalQuery || '').trim()
if (q.length < 3) {
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
return
}
if (validMajorSteps.length < 2) {
alert('Mindestens zwei Slots mit Lernziel (je 3+ Zeichen) nötig.')
return
}
setMatching(true)
setActionErr('')
setMatchNotice('')
try {
const synced = syncProgressionRoadmapFromSlots(draft)
const override = majorStepsToOverridePayload(synced.slots)
const res = await api.suggestProgressionPath({
query: q,
max_steps: synced.slots.length,
include_llm_intent: true,
include_path_qa: true,
include_llm_path_qa: true,
include_path_reorder: false,
include_ai_gap_fill: true,
include_roadmap_preview: true,
include_llm_roadmap: false,
roadmap_first: true,
roadmap_override: override,
slot_assignments: slotsToSlotAssignments(synced),
retrieval_boost_exercise_ids: draftRetrievalBoostExerciseIds(synced),
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
})
const { draft: matched, remainingOffers } = applyMatchResponseToDraft(
{
...synced,
progressionRoadmap: res?.progression_roadmap || synced.progressionRoadmap,
pathSkillExpectations: res?.path_skill_expectations || synced.pathSkillExpectations,
},
res,
)
setDraft(matched)
setSemanticBrief(res?.semantic_brief_summary || null)
setTargetSummary(res?.target_profile_summary || null)
setPathQa(res?.path_qa || null)
setGapFillOffers(remainingOffers)
const ms = res?.match_summary
const rematchLog = res?.path_qa?.rematch_log
const rematchRounds = res?.path_qa?.rematch_rounds
if (ms) {
const parts = [
`Match: ${ms.library_matches ?? 0}/${ms.slot_count ?? '?'} Slots aus Bibliothek, ${ms.gap_fill_offer_count ?? 0} KI-Angebote.`,
]
if (rematchRounds > 0 && Array.isArray(rematchLog) && rematchLog.length > 0) {
parts.push(
`Auto-Rematch: ${rematchLog.length} Anpassung(en) in ${rematchRounds} Runde(n).`,
)
}
const refineLog = res?.path_qa?.refine_log
if (Array.isArray(refineLog) && refineLog.length > 0) {
parts.push(`Stufen-Spec: ${refineLog.length} Slot(s) verfeinert.`)
}
setMatchNotice(parts.join(' '))
}
try {
await saveProgressionGraphDraft(api, graphId, {
...matched,
lastFindings: res?.path_qa || null,
})
setDraft((prev) => (prev ? { ...prev, dirty: false } : prev))
} catch (saveErr) {
console.warn('Match-Artefakt konnte nicht gespeichert werden', saveErr)
}
} catch (e) {
setActionErr(e.message || 'Übungs-Match fehlgeschlagen')
} finally {
setMatching(false)
}
}
const runEvaluate = async () => {
const q = (draft?.goalQuery || '').trim()
if (q.length < 3) {
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
return
}
setEvaluating(true)
setActionErr('')
try {
const synced = syncProgressionRoadmapFromSlots(draft)
const override =
validMajorSteps.length >= 2 ? majorStepsToOverridePayload(synced.slots) : undefined
const res = await api.suggestProgressionPath({
query: q,
max_steps: synced.slots.length || draft.maxSteps || 5,
include_path_qa: true,
include_llm_path_qa: true,
include_ai_gap_fill: true,
include_path_reorder: false,
include_llm_intent: false,
evaluate_only: true,
evaluate_steps: slotsToEvaluateSteps(synced),
roadmap_override: override,
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
})
setSemanticBrief(res?.semantic_brief_summary || null)
setPathQa(res?.path_qa || null)
const { draft: evaluated, remainingOffers } = applyEvaluateResponseToDraft(synced, res)
setDraft({ ...evaluated, lastFindings: res?.path_qa || null })
setGapFillOffers(remainingOffers)
} catch (e) {
setActionErr(e.message || 'Bewertung fehlgeschlagen')
} finally {
setEvaluating(false)
}
}
const handleSave = async () => {
if (!draft || !graphId) return
setBusy(true)
setActionErr('')
try {
await saveProgressionGraphDraft(api, graphId, { ...draft, lastFindings: pathQa })
await loadGraph()
if (typeof onSaved === 'function') await onSaved()
alert('Progressionsgraph gespeichert.')
} catch (e) {
setActionErr(e.message || 'Speichern fehlgeschlagen')
} finally {
setBusy(false)
}
}
const handleApplyGapOffer = (offer, slotIndex) => {
setDraft((prev) => {
const next = applyGapOfferToDraft(prev, offer, { slotIndex })
return { ...next, dirty: true }
})
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
}
const handleInsertGapSlot = (offer) => {
if ((draft?.slots?.length || 0) >= SLOT_MAX) {
alert(`Maximal ${SLOT_MAX} Slots — zuerst einen Slot entfernen.`)
return
}
setDraft((prev) => {
const next = applyGapOfferToDraft(prev, offer, { insertNewSlot: true })
return { ...next, dirty: true }
})
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
}
const slotOfferContext = (slotIndex) => {
const slot = draft?.slots?.[slotIndex]
if (!draft || !slot) return null
const goalForAi =
buildSlotGapGoalForAi(draft, slotIndex, { goalQuery: draft.goalQuery }) ||
slot.learning_goal
const priorSlot =
slotIndex > 0 && draft.slots[slotIndex - 1]
? draft.slots[slotIndex - 1]
: null
return {
offer_id: `slot-${slotIndex}`,
title_hint: slot.primary?.exerciseTitle || slot.learning_goal,
roadmap_major_step_index: slot.majorStepIndex,
phase: slot.phase,
source: 'roadmap_unfilled',
goal_for_ai: goalForAi,
sketch: goalForAi,
from_title: priorSlot?.primary?.exerciseTitle || null,
}
}
const openGapFillPrep = (offer, slotIndex = null) => {
const defaultFocus = resolveDefaultFocusAreaId(targetSummary, focusAreas)
setActiveOffer(offer)
setActiveOfferSlotIndex(slotIndex)
setGapPrepTitle((offer?.title_hint || '').trim())
setGapPrepStageGoal(initialStageLearningGoalFromOffer(offer, gapContextParams))
setGapPrepSupplements('')
setGapPrepFocusAreaId(defaultFocus ? String(defaultFocus) : '')
setActivePlanningContextLines(gapOfferContextDisplayLines(offer, gapContextParams))
setGapPrepError('')
setGapPrepOpen(true)
}
const runGapFillAiSuggest = async (offer, prep, slotIndex) => {
const title = (prep?.title || offer?.title_hint || '').trim()
if (title.length < 3) {
alert('Titel: mindestens 3 Zeichen.')
return
}
const supplements = (prep?.supplements || '').trim()
const stageGoal = (prep?.stageLearningGoal || '').trim()
let goalText = (offer?.goal_for_ai || offer?.sketch || '').trim()
if (supplements) {
goalText = `${goalText}\n\nTrainer-Ergänzungen:\n${supplements}`.trim()
}
const focusId =
prep?.focusAreaId != null && Number.isFinite(Number(prep.focusAreaId))
? Number(prep.focusAreaId)
: resolveDefaultFocusAreaId(targetSummary, focusAreas)
if (!focusId) {
alert('Bitte einen Fokusbereich wählen.')
return
}
const focusRow = (focusAreas || []).find((x) => Number(x.id) === focusId)
const focusHint = (focusRow?.name || offer?.primary_topic || '').trim()
setGapAiBusy(true)
setGeneratingOfferId(offer?.offer_id || null)
setGapPrepError('')
setSlotQuickError('')
const contextParams = {
...gapContextParams,
stageLearningGoalOverride: stageGoal,
gapTrainerSupplements: supplements,
}
setActivePlanningContextLines(gapOfferContextDisplayLines(offer, contextParams))
try {
const planningContext = buildPathGapPlanningContextForAi({
offer,
...contextParams,
})
const aiRes = await api.suggestExerciseAi({
title,
goal: goalText || undefined,
execution: '',
preparation: '',
trainer_notes: supplements || '',
focus_area_hint: focusHint || undefined,
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
planning_context: planningContext || undefined,
include_summary: true,
include_skills: true,
include_instructions: true,
})
const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: goalText })
if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) {
throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.')
}
const aiDraft = aiPreviewToQuickCreateDraft(preview, {
title,
focusAreaId: focusId,
sketchPlain: goalText,
})
const enrichedOffer = {
...offer,
proposal_title: title,
ai_suggestion: aiDraft,
has_ai_payload: true,
}
const resolvedSlot =
slotIndex != null && Number.isFinite(slotIndex)
? slotIndex
: activeOfferSlotIndex != null && Number.isFinite(activeOfferSlotIndex)
? activeOfferSlotIndex
: null
if (resolvedSlot != null) {
setSlotQuickCreateIndex(resolvedSlot)
setDraft((prev) => applyGapOfferToDraft(prev, enrichedOffer, { slotIndex: resolvedSlot }))
}
setSlotQuickCreateDraft(aiDraft)
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
setGapPrepOpen(false)
} catch (e) {
setGapPrepError(e.message || 'KI-Anlage fehlgeschlagen')
} finally {
setGapAiBusy(false)
setGeneratingOfferId(null)
}
}
const openSlotQuickCreate = (slotIndex) => {
const slot = draft?.slots?.[slotIndex]
if (!slot) return
const primary = slot.primary
const offer = slotOfferContext(slotIndex)
setSlotQuickCreateIndex(slotIndex)
setSlotQuickError('')
setActiveOffer(offer)
setActiveOfferSlotIndex(slotIndex)
setActivePlanningContextLines(gapOfferContextDisplayLines(offer, gapContextParams))
if (primary?.kind === 'proposal' && primary.aiSuggestion) {
const focusId = resolveDefaultFocusAreaId(targetSummary, focusAreas)
const draftReady = ensureQuickCreateDraftFromAiSuggestion(primary.aiSuggestion, {
title: primary.exerciseTitle || slot.learning_goal,
focusAreaId: focusId,
sketchPlain: (offer?.goal_for_ai || slot.learning_goal || '').trim(),
})
if (draftReady) {
setSlotQuickCreateDraft(draftReady)
return
}
}
openGapFillPrep(offer, slotIndex)
}
const applySlotQuickCreate = async () => {
if (slotQuickCreateIndex == null || !slotQuickCreateDraft) return
setSlotQuickSaving(true)
setSlotQuickError('')
try {
const payload = buildQuickCreateExercisePayloadFromDraft(slotQuickCreateDraft)
const created = await api.createExercise(payload)
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
setDraft((prev) => setSlotPrimaryLibrary(prev, slotQuickCreateIndex, created))
setSlotQuickCreateDraft(null)
setSlotQuickCreateIndex(null)
setActiveOffer(null)
setActiveOfferSlotIndex(null)
setActivePlanningContextLines([])
} catch (e) {
const msg = e.message || 'Übung konnte nicht angelegt werden'
setSlotQuickError(msg)
alert(msg)
} finally {
setSlotQuickSaving(false)
}
}
const submitGapFillPrep = async () => {
const title = (gapPrepTitle || '').trim()
if (title.length < 3) {
alert('Titel: mindestens 3 Zeichen.')
return
}
const focusId = parseInt(String(gapPrepFocusAreaId).trim(), 10)
if (!Number.isFinite(focusId) || focusId < 1) {
alert('Bitte einen Fokusbereich wählen.')
return
}
if (!activeOffer) return
await runGapFillAiSuggest(
activeOffer,
{
title,
stageLearningGoal: (gapPrepStageGoal || '').trim(),
supplements: (gapPrepSupplements || '').trim(),
focusAreaId: focusId,
},
activeOfferSlotIndex,
)
}
if (loadErr) {
return (
<div className="card">
<p className="form-error">{loadErr}</p>
<Link to="/exercises" className="btn btn-secondary">
Zurück
</Link>
</div>
)
}
if (!draft) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '40px' }}>
<div className="spinner" />
</div>
)
}
return (
<div>
{!embedded ? (
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '8px' }}>
<div>
<h2 style={{ margin: 0, fontSize: '1.15rem' }}>
{graphMeta?.name || draft.graphName || `Graph #${graphId}`}
</h2>
<p style={{ margin: '4px 0 0', fontSize: '12px', color: 'var(--text3)' }}>
Roadmap-Slots · KI-Match · Graph-Bewertung (max. {SLOT_MAX} Slots)
</p>
</div>
<Link to="/exercises" className="btn btn-secondary" style={{ fontSize: '12px' }}>
Zur Übersicht
</Link>
</div>
) : null}
{actionErr ? (
<p className="form-error" style={{ marginTop: 0 }}>
{actionErr}
</p>
) : null}
<div
className="progression-graph-editor-grid"
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) minmax(280px, 340px)',
gap: '14px',
alignItems: 'start',
}}
>
<div>
<div className="card" style={{ marginBottom: '12px' }}>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Ziel & Roadmap</h3>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '10px',
alignItems: 'flex-end',
marginBottom: '10px',
}}
>
<div className="form-row" style={{ flex: '2 1 240px', marginBottom: 0 }}>
<label className="form-label">Ziel / Entwicklungsrichtung</label>
<input
className="form-input"
value={draft.goalQuery}
disabled={busy}
onChange={(e) => patchDraft((d) => ({ ...d, goalQuery: e.target.value }))}
placeholder="z. B. Von Erlernen bis zur Perfektion des Fußtritts Mae Geri …"
/>
</div>
<div className="form-row" style={{ flex: '0 1 100px', marginBottom: 0 }}>
<label className="form-label">Stufen (Slots)</label>
<input
type="number"
min={SLOT_MIN}
max={SLOT_MAX}
className="form-input"
value={draft.maxSteps}
disabled={busy}
onChange={(e) =>
patchDraft((d) => ({
...d,
maxSteps: Math.max(SLOT_MIN, Math.min(SLOT_MAX, Number(e.target.value) || 5)),
}))
}
/>
</div>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
gap: '10px',
}}
>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Startpunkt / Ausgangslage</label>
<textarea
className="form-input"
rows={2}
value={draft.startSituation}
disabled={busy}
onChange={(e) => patchDraft((d) => ({ ...d, startSituation: e.target.value }))}
placeholder="z. B. gleichartige Steppbewegung, vorhersehbar"
/>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Zielzustand</label>
<textarea
className="form-input"
rows={2}
value={draft.targetState}
disabled={busy}
onChange={(e) => patchDraft((d) => ({ ...d, targetState: e.target.value }))}
placeholder="z. B. dynamische Bewegung mit explosivem Angriff und Ausweichen"
/>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Ergänzungen (Fokus, Gruppe, Besonderheiten)</label>
<textarea
className="form-input"
rows={2}
value={draft.roadmapNotes}
disabled={busy}
onChange={(e) => patchDraft((d) => ({ ...d, roadmapNotes: e.target.value }))}
placeholder="optional: Altersgruppe, Kumite-Kontext, Trainingsfokus …"
/>
</div>
</div>
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '8px 0 0', lineHeight: 1.4 }}>
Optional zuerst Start/Ziel analysieren, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer,
geschieht die Analyse beim Roadmap-Vorschlag automatisch. Manuelle Eingaben haben Vorrang.
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '10px', alignItems: 'center' }}>
<button
type="button"
className="btn btn-secondary"
disabled={busy || startTargetLoading}
onClick={runAnalyzeStartTarget}
title="Nur Ausgangslage, Zielzustand und Ergänzungen per KI — ohne Roadmap-Stufen"
>
{startTargetLoading ? 'Analyse…' : 'Start/Ziel analysieren'}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busy || roadmapLoading}
onClick={runRoadmapGenerate}
>
{roadmapLoading ? 'Roadmap…' : 'Roadmap generieren'}
</button>
{startTargetReady ? (
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
Start/Ziel bereit
</span>
) : null}
<button
type="button"
className="btn btn-secondary"
disabled={busy || matching}
onClick={runMatch}
>
{matching ? 'Match…' : 'Übungen matchen'}
</button>
<button type="button" className="btn btn-primary" disabled={busy} onClick={handleSave}>
{busy ? 'Speichern…' : 'Graph speichern'}
</button>
</div>
{matchNotice ? (
<p style={{ margin: '8px 0 0', fontSize: '11px', color: 'var(--accent-dark)' }}>{matchNotice}</p>
) : null}
{draft.dirty ? (
<p style={{ margin: '8px 0 0', fontSize: '11px', color: 'var(--accent-dark)' }}>
Ungespeicherte Änderungen
</p>
) : null}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<h3 style={{ margin: 0, fontSize: '1rem' }}>Slots ({draft.slots.length})</h3>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px' }}
disabled={busy || draft.slots.length >= SLOT_MAX}
onClick={handleAddSlot}
>
Slot am Ende
</button>
</div>
{draft.slots.map((slot, idx) => (
<ProgressionSlotCard
key={`slot-${idx}-${slot.learning_goal?.slice(0, 12) || 'x'}`}
slot={slot}
slotIndex={idx}
slotCount={draft.slots.length}
disabled={busy}
onPickPrimary={(i) => setPickContext({ slotIndex: i, role: 'primary' })}
onPickSibling={(i) => setPickContext({ slotIndex: i, role: 'sibling' })}
onClearPrimary={handleClearPrimary}
onRemoveSibling={handleRemoveSibling}
onPatchLearningGoal={handlePatchLearningGoal}
onPatchPhase={handlePatchPhase}
onMoveUp={(i) => handleMoveSlot(i, -1)}
onMoveDown={(i) => handleMoveSlot(i, 1)}
onRemoveSlot={handleRemoveSlot}
onInsertAfter={handleInsertAfter}
onCreateFromProposal={openSlotQuickCreate}
/>
))}
</div>
<ProgressionFindingsPanel
pathQa={pathQa}
gapFillOffers={gapFillOffers}
draft={draft}
slotCount={draft.slots.length}
loading={evaluating}
error=""
onEvaluate={runEvaluate}
onApplyGapOffer={handleApplyGapOffer}
onInsertGapSlot={handleInsertGapSlot}
onGenerateGapAi={openGapFillPrep}
onRematchSlots={runMatch}
rematchBusy={matching}
generatingOfferId={generatingOfferId}
aiBusy={gapAiBusy}
evaluateDisabled={busy || !draft.goalQuery?.trim()}
/>
</div>
{pickContext ? (
<ExercisePickerModal
open
onClose={() => setPickContext(null)}
onSelectExercise={handlePickExercise}
/>
) : null}
<ExerciseAiSuggestPreviewModal
draft={slotQuickCreateDraft}
onDraftChange={setSlotQuickCreateDraft}
onDiscard={() => {
if (slotQuickSaving) return
setSlotQuickCreateDraft(null)
setSlotQuickError('')
if (activeOffer) {
setGapPrepOpen(true)
} else {
setActivePlanningContextLines([])
}
}}
planningContextLines={activePlanningContextLines}
onApply={applySlotQuickCreate}
focusAreas={focusAreas}
skillsCatalog={skillsCatalog}
dialogTitle="Progressions-Slot — KI-Entwurf bearbeiten"
hint="Texte prüfen und anpassen, dann als Übung speichern — sie wird dem Slot zugeordnet."
applyLabel={slotQuickSaving ? 'Wird angelegt …' : 'Anlegen und Slot zuweisen'}
applyDisabled={slotQuickSaving}
zIndex={2100}
/>
<ExerciseGapFillPrepModal
open={gapPrepOpen}
offer={activeOffer}
onClose={() => {
if (gapAiBusy) return
setGapPrepOpen(false)
setGapPrepError('')
}}
title={gapPrepTitle}
onTitleChange={setGapPrepTitle}
stageLearningGoal={gapPrepStageGoal}
onStageLearningGoalChange={setGapPrepStageGoal}
supplements={gapPrepSupplements}
onSupplementsChange={setGapPrepSupplements}
focusAreaId={gapPrepFocusAreaId}
onFocusAreaChange={setGapPrepFocusAreaId}
focusAreas={focusAreas}
contextLines={gapOfferContextDisplayLines(activeOffer, gapContextParams)}
error={gapPrepError}
busy={gapAiBusy}
onSubmit={submitGapFillPrep}
/>
<style>{`
@media (max-width: 900px) {
.progression-graph-editor-grid {
grid-template-columns: 1fr !important;
}
}
`}</style>
</div>
)
}