All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 49s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m26s
- Added new fields for goal query, user notes, max steps, and search query in the AiPromptPreviewBody to support planning prompts. - Integrated planning prompt handling in the preview_ai_prompt function, allowing for distinct processing of planning and exercise prompts. - Introduced LLM usage tracking in openrouter_chat_completion and planning_exercise_suggest functions to monitor AI call metrics. - Updated frontend components to accommodate new input fields for planning prompts, enhancing user experience and functionality.
1264 lines
45 KiB
JavaScript
1264 lines
45 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 { useAuth } from '../context/AuthContext'
|
|
import { getDefaultClubIdForGovernanceForms } from '../utils/activeClub'
|
|
import ExercisePickerModal from './ExercisePickerModal'
|
|
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
|
|
import ProgressionSlotCard from './ProgressionSlotCard'
|
|
import ProgressionFindingsPanel from './ProgressionFindingsPanel'
|
|
import PlanningCatalogContextFields from './PlanningCatalogContextFields'
|
|
import {
|
|
aiPreviewToQuickCreateDraft,
|
|
buildQuickCreateAiPreview,
|
|
buildQuickCreateExercisePayloadFromDraft,
|
|
ensureQuickCreateDraftFromAiSuggestion,
|
|
} from '../utils/exerciseAiQuickCreate'
|
|
import {
|
|
buildPathGapPlanningContextForAi,
|
|
buildSlotGapGoalForAi,
|
|
gapOfferContextDisplayLines,
|
|
initialStageLearningGoalFromOffer,
|
|
} from '../utils/planningContextForExerciseAi'
|
|
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
|
|
import ProgressionOptimizeCompareModal from './ProgressionOptimizeCompareModal'
|
|
import {
|
|
addSlotToDraft,
|
|
applyEvaluateResponseToDraft,
|
|
applyGapOfferToDraft,
|
|
applySelectedCompareSteps,
|
|
applySelectedSlotSuggestions,
|
|
applyResolvedStructuredToDraft,
|
|
buildPlanningArtifactFromDraft,
|
|
buildProgressionComparePayload,
|
|
collectGapOffersFromApiResponse,
|
|
compareSlotDiffs,
|
|
compareDiffsForDialog,
|
|
dedupeGapOffersBySlot,
|
|
draftHasLibrarySlotAssignments,
|
|
EMPTY_PLANNING_CATALOG_CONTEXT,
|
|
filterGapOffersForUnfilledSlots,
|
|
hydrateProgressionGraphDraft,
|
|
insertSlotInDraft,
|
|
librarySlotExercise,
|
|
majorStepsToOverridePayload,
|
|
mergeGapOffersForDraft,
|
|
moveSlotInDraft,
|
|
patchSlotInDraft,
|
|
pathQaQualityPercent,
|
|
planningCatalogContextToApi,
|
|
rejectedCompareDiffs,
|
|
removeSlotFromDraft,
|
|
saveProgressionGraphDraft,
|
|
setCatalogSelectItems,
|
|
setSlotPrimaryLibrary,
|
|
SLOT_MAX,
|
|
SLOT_MIN,
|
|
slotsAsPathStepRows,
|
|
slotsToEvaluateSteps,
|
|
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 { user } = useAuth()
|
|
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 [styleDirections, setStyleDirections] = useState([])
|
|
const [trainingTypes, setTrainingTypes] = useState([])
|
|
const [targetGroups, setTargetGroups] = 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 [compareOpen, setCompareOpen] = useState(false)
|
|
const [comparePayload, setComparePayload] = useState(null)
|
|
const [compareSource, setCompareSource] = useState('manual')
|
|
const [comparing, setComparing] = useState(false)
|
|
const [compareApplying, setCompareApplying] = useState(false)
|
|
const [proposedPathQa, setProposedPathQa] = useState(null)
|
|
|
|
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.listStyleDirections({ status: 'active' }),
|
|
api.listTrainingTypes({ status: 'active' }),
|
|
api.listTargetGroups({ status: 'active' }),
|
|
api.listSkillsCatalog({ status: 'active' }),
|
|
])
|
|
.then(([fa, sd, tt, tg, sk]) => {
|
|
if (cancelled) return
|
|
setFocusAreas(Array.isArray(fa) ? fa : [])
|
|
setStyleDirections(Array.isArray(sd) ? sd : [])
|
|
setTrainingTypes(Array.isArray(tt) ? tt : [])
|
|
setTargetGroups(Array.isArray(tg) ? tg : [])
|
|
setSkillsCatalog(Array.isArray(sk) ? sk : [])
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) {
|
|
setFocusAreas([])
|
|
setStyleDirections([])
|
|
setTrainingTypes([])
|
|
setTargetGroups([])
|
|
setSkillsCatalog([])
|
|
}
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
const patchDraft = useCallback((patchFn) => {
|
|
setDraft((prev) => {
|
|
if (!prev) return prev
|
|
const next = patchFn(prev)
|
|
return { ...next, dirty: true, findingsStale: 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 catalogCtx = draft?.planningCatalogContext || EMPTY_PLANNING_CATALOG_CONTEXT
|
|
|
|
const patchCatalogDimension = (key, value) => {
|
|
patchDraft((d) => ({
|
|
...d,
|
|
dirty: true,
|
|
planningCatalogContext: {
|
|
...(d.planningCatalogContext || EMPTY_PLANNING_CATALOG_CONTEXT),
|
|
[key]: setCatalogSelectItems(d.planningCatalogContext?.[key], value),
|
|
},
|
|
}))
|
|
}
|
|
|
|
const catalogApiPayload = useMemo(
|
|
() => planningCatalogContextToApi(catalogCtx),
|
|
[catalogCtx],
|
|
)
|
|
|
|
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),
|
|
...catalogApiPayload,
|
|
})
|
|
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, findingsStale: 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),
|
|
...catalogApiPayload,
|
|
})
|
|
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, findingsStale: true })
|
|
setSemanticBrief(res?.semantic_brief_summary || null)
|
|
} catch (e) {
|
|
setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen')
|
|
} finally {
|
|
setRoadmapLoading(false)
|
|
}
|
|
}
|
|
|
|
const buildEvaluateRequest = (synced, { llmPathQa = true, aiGapFill = true } = {}) => {
|
|
const override = majorStepsToOverridePayload(synced.slots)
|
|
return {
|
|
query: (synced.goalQuery || '').trim(),
|
|
max_steps: synced.slots.length || draft?.maxSteps || 5,
|
|
include_path_qa: true,
|
|
include_llm_path_qa: llmPathQa,
|
|
include_ai_gap_fill: aiGapFill,
|
|
include_path_reorder: false,
|
|
include_llm_intent: false,
|
|
evaluate_only: true,
|
|
evaluate_steps: slotsToEvaluateSteps(synced),
|
|
roadmap_override: override,
|
|
slot_assignments: slotsToSlotAssignments(synced),
|
|
progression_graph_id: Number(graphId),
|
|
...roadmapStructuredPayload(synced.startSituation, synced.targetState, synced.roadmapNotes),
|
|
...catalogApiPayload,
|
|
}
|
|
}
|
|
|
|
const fetchPathEvaluate = async (synced, options) =>
|
|
api.suggestProgressionPath(buildEvaluateRequest(synced, options))
|
|
|
|
const applyEvaluateResult = (synced, res) => {
|
|
setSemanticBrief(res?.semantic_brief_summary || null)
|
|
setPathQa(res?.path_qa || null)
|
|
const { draft: evaluated, remainingOffers } = applyEvaluateResponseToDraft(synced, res)
|
|
return {
|
|
draft: { ...evaluated, lastFindings: res?.path_qa || null, findingsStale: false },
|
|
remainingOffers,
|
|
}
|
|
}
|
|
|
|
const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => {
|
|
setMatchNotice('Schritt 1/2: Pfad bewerten (wie „Graph bewerten“)…')
|
|
const baselineRes = await fetchPathEvaluate(synced)
|
|
const { draft: evaluated, remainingOffers } = applyEvaluateResult(synced, baselineRes)
|
|
setDraft(evaluated)
|
|
const mergedAfterEval = mergeGapOffersForDraft(evaluated, baselineRes)
|
|
setGapFillOffers(mergedAfterEval.length > 0 ? mergedAfterEval : remainingOffers)
|
|
|
|
setMatchNotice('Schritt 2/2: Slot-Alternativen prüfen…')
|
|
let compareRes
|
|
let reviewError = null
|
|
try {
|
|
const reviewRes = await api.suggestProgressionPath({
|
|
...buildEvaluateRequest(synced),
|
|
evaluate_only: false,
|
|
unified_slot_review: true,
|
|
baseline_evaluate_steps: slotsToEvaluateSteps(synced),
|
|
baseline_path_qa_snapshot: baselineRes?.path_qa || null,
|
|
baseline_quality_score:
|
|
baselineRes?.path_qa?.quality_score != null
|
|
? Number(baselineRes.path_qa.quality_score)
|
|
: null,
|
|
include_llm_path_qa: false,
|
|
include_llm_intent: false,
|
|
auto_rematch_after_qa: false,
|
|
})
|
|
if (!reviewRes?.unified_slot_review) {
|
|
reviewError =
|
|
'Slot-Review nicht verfügbar — Backend neu starten/deployen (unified_slot_review fehlt).'
|
|
compareRes = buildProgressionComparePayload(baselineRes, {
|
|
...reviewRes,
|
|
unified_slot_review: true,
|
|
slot_reviews: [],
|
|
review_error: reviewError,
|
|
})
|
|
} else {
|
|
compareRes = buildProgressionComparePayload(baselineRes, reviewRes)
|
|
}
|
|
setGapFillOffers(mergeGapOffersForDraft(evaluated, baselineRes, reviewRes))
|
|
} catch (e) {
|
|
reviewError = e.message || 'Slot-Review fehlgeschlagen'
|
|
compareRes = buildProgressionComparePayload(baselineRes, {
|
|
unified_slot_review: true,
|
|
slot_reviews: [],
|
|
review_error: reviewError,
|
|
path_qa: baselineRes?.path_qa,
|
|
})
|
|
}
|
|
|
|
presentMatchCompare(compareRes, { source, reviewError })
|
|
return compareRes
|
|
}
|
|
|
|
const presentMatchCompare = (res, { source = 'manual', reviewError = null } = {}) => {
|
|
setSemanticBrief(res?.semantic_brief_summary || null)
|
|
setTargetSummary(res?.target_profile_summary || null)
|
|
setComparePayload(reviewError ? { ...res, review_error: reviewError } : res)
|
|
setCompareSource(source)
|
|
setProposedPathQa(res?.proposed_path_qa_pipeline || null)
|
|
setCompareOpen(true)
|
|
|
|
const baselineQa = res?.baseline_path_qa || null
|
|
const slotReviews = res?.slot_reviews || []
|
|
const autoCount = slotReviews.filter((r) => r?.library_alternative?.auto_select).length
|
|
const diffCount = autoCount || res?.slot_diff_count || 0
|
|
const rejectedCount = res?.slot_diff_count_rejected ?? rejectedCompareDiffs(res).length
|
|
const problemCount = res?.match_summary?.problem_slot_count
|
|
?? (res?.problem_slots ? Object.keys(res.problem_slots).length : 0)
|
|
const bPct = pathQaQualityPercent(baselineQa)
|
|
let notice = reviewError
|
|
? `Match: Dialog geöffnet — ${reviewError}`
|
|
: slotReviews.length > 0
|
|
? `Match: ${slotReviews.length} Slot(s) geprüft, ${autoCount} Empfehlung(en) vorausgewählt.`
|
|
: diffCount > 0
|
|
? `Match: ${diffCount} Verbesserung(en).`
|
|
: problemCount > 0
|
|
? `Match: ${problemCount} Schachstelle(n), keine bessere Bibliotheks-Alternative.`
|
|
: 'Match: Pfad geprüft — siehe Dialog.'
|
|
if (rejectedCount > 0) {
|
|
notice += ` ${rejectedCount} Vorschlag/Vorschläge verworfen (Verschlechterung oder neutral).`
|
|
}
|
|
const gapCount = collectGapOffersFromApiResponse(res).length
|
|
if (gapCount > 0) {
|
|
notice += ` ${gapCount} KI-Angebot(e) für leere Slots im Panel „Graph-Bewertung“.`
|
|
}
|
|
setMatchNotice(notice)
|
|
}
|
|
|
|
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)
|
|
setProposedPathQa(null)
|
|
await runMatchCompareFlow(synced, { source: 'match' })
|
|
} catch (e) {
|
|
setActionErr(e.message || 'Übungs-Match fehlgeschlagen')
|
|
} finally {
|
|
setMatching(false)
|
|
}
|
|
}
|
|
|
|
const runOptimizeCompare = 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
|
|
}
|
|
setComparing(true)
|
|
setActionErr('')
|
|
setMatchNotice('')
|
|
try {
|
|
const synced = syncProgressionRoadmapFromSlots(draft)
|
|
setProposedPathQa(null)
|
|
await runMatchCompareFlow(synced, { source: 'manual' })
|
|
} catch (e) {
|
|
setActionErr(e.message || 'Optimierungs-Vergleich fehlgeschlagen')
|
|
} finally {
|
|
setComparing(false)
|
|
}
|
|
}
|
|
|
|
const applyOptimizeCompare = async (selectedMajorIndices) => {
|
|
if (!comparePayload || !draft) return
|
|
setCompareApplying(true)
|
|
setMatchNotice('Übernahme: Slots aktualisieren …')
|
|
try {
|
|
const synced = syncProgressionRoadmapFromSlots(draft)
|
|
const nextDraft = comparePayload?.unified_slot_review
|
|
? applySelectedSlotSuggestions(synced, comparePayload, selectedMajorIndices)
|
|
: applySelectedCompareSteps(
|
|
synced,
|
|
comparePayload.proposed_steps || comparePayload.steps,
|
|
selectedMajorIndices,
|
|
)
|
|
const syncedNext = syncProgressionRoadmapFromSlots(nextDraft)
|
|
|
|
setDraft({ ...syncedNext, dirty: false, findingsStale: true })
|
|
setCompareOpen(false)
|
|
setComparePayload(null)
|
|
setProposedPathQa(null)
|
|
|
|
await saveProgressionGraphDraft(api, graphId, { ...syncedNext, findingsStale: true })
|
|
setMatchNotice(
|
|
'Übernommen und gespeichert. Bewertung bezieht sich noch auf den vorherigen Stand — bitte „Graph bewerten“.',
|
|
)
|
|
} catch (e) {
|
|
setActionErr(e.message || 'Übernahme fehlgeschlagen')
|
|
} finally {
|
|
setCompareApplying(false)
|
|
}
|
|
}
|
|
|
|
const runEvaluate = async () => {
|
|
const q = (draft?.goalQuery || '').trim()
|
|
if (q.length < 3) {
|
|
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
|
|
return
|
|
}
|
|
setEvaluating(true)
|
|
setActionErr('')
|
|
setProposedPathQa(null)
|
|
try {
|
|
const synced = syncProgressionRoadmapFromSlots(draft)
|
|
const res = await fetchPathEvaluate(synced)
|
|
const { draft: evaluated, remainingOffers } = applyEvaluateResult(synced, res)
|
|
setDraft(evaluated)
|
|
const mergedOffers = mergeGapOffersForDraft(evaluated, res)
|
|
setGapFillOffers(mergedOffers.length > 0 ? mergedOffers : 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, findingsStale: 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, findingsStale: 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 }),
|
|
findingsStale: true,
|
|
}))
|
|
}
|
|
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 graphVis = (graphMeta?.visibility || 'private').trim().toLowerCase()
|
|
const graphClubId =
|
|
graphMeta?.club_id != null
|
|
? graphMeta.club_id
|
|
: graphVis === 'club'
|
|
? getDefaultClubIdForGovernanceForms(user)
|
|
: null
|
|
const payload = buildQuickCreateExercisePayloadFromDraft(slotQuickCreateDraft, {
|
|
visibility: graphVis,
|
|
clubId: graphClubId,
|
|
})
|
|
const created = await api.createExercise(payload)
|
|
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
|
|
setDraft((prev) => ({
|
|
...setSlotPrimaryLibrary(prev, slotQuickCreateIndex, created),
|
|
dirty: true,
|
|
findingsStale: true,
|
|
}))
|
|
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>
|
|
<PlanningCatalogContextFields
|
|
catalogCtx={catalogCtx}
|
|
onPatchDimension={patchCatalogDimension}
|
|
focusAreas={focusAreas}
|
|
styleDirections={styleDirections}
|
|
trainingTypes={trainingTypes}
|
|
targetGroups={targetGroups}
|
|
disabled={busy}
|
|
helperText="Planungskontext steuert Bibliotheks-Matching (Fokusbereich, Stil, Trainingsstil, Zielgruppe) — unabhängig von Technik-Pfaden wie Mae Geri. Wird mit dem Graph gespeichert."
|
|
/>
|
|
<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}
|
|
title={
|
|
draftHasLibrarySlotAssignments(draft)
|
|
? 'Voller Match mit Auto-Optimierung — bei Abweichungen öffnet sich der Vergleichsdialog'
|
|
: 'Bibliotheks-Übungen für leere Slots finden'
|
|
}
|
|
>
|
|
{matching ? 'Match…' : 'Übungen matchen'}
|
|
</button>
|
|
{draftHasLibrarySlotAssignments(draft) ? (
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
disabled={busy || comparing || matching}
|
|
onClick={runOptimizeCompare}
|
|
title="Aktuellen Pfad vs. voller Match mit Auto-Optimierung — du wählst pro Slot"
|
|
>
|
|
{comparing ? 'Vergleich…' : 'Optimierung vergleichen'}
|
|
</button>
|
|
) : null}
|
|
<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={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=""
|
|
evaluationStale={Boolean(draft?.findingsStale)}
|
|
onEvaluate={runEvaluate}
|
|
onApplyGapOffer={handleApplyGapOffer}
|
|
onInsertGapSlot={handleInsertGapSlot}
|
|
onGenerateGapAi={openGapFillPrep}
|
|
onRematchSlots={runMatch}
|
|
onOptimizeCompare={runOptimizeCompare}
|
|
canOptimizeCompare={validMajorSteps.length >= 2}
|
|
optimizeCompareBusy={comparing}
|
|
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}
|
|
/>
|
|
|
|
<ProgressionOptimizeCompareModal
|
|
open={compareOpen}
|
|
comparison={comparePayload}
|
|
mode={compareSource}
|
|
onClose={() => {
|
|
if (compareApplying) return
|
|
setCompareOpen(false)
|
|
setComparePayload(null)
|
|
setProposedPathQa(null)
|
|
}}
|
|
onApplySelected={applyOptimizeCompare}
|
|
applying={compareApplying}
|
|
/>
|
|
|
|
<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>
|
|
)
|
|
}
|