524 lines
17 KiB
React
524 lines
17 KiB
React
|
|
/**
|
||
|
|
* 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 ProgressionSlotCard from './ProgressionSlotCard'
|
||
|
|
import ProgressionFindingsPanel from './ProgressionFindingsPanel'
|
||
|
|
import {
|
||
|
|
applyGapOfferToSlot,
|
||
|
|
applyMatchStepsToSlots,
|
||
|
|
buildPlanningArtifactFromDraft,
|
||
|
|
hydrateProgressionGraphDraft,
|
||
|
|
librarySlotExercise,
|
||
|
|
majorStepsToOverridePayload,
|
||
|
|
reindexMajorSteps,
|
||
|
|
saveProgressionGraphDraft,
|
||
|
|
slotsToEvaluateSteps,
|
||
|
|
} from '../utils/progressionGraphDraft'
|
||
|
|
|
||
|
|
const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
|
||
|
|
|
||
|
|
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
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function ProgressionGraphEditor({ graphId }) {
|
||
|
|
const [graphMeta, setGraphMeta] = useState(null)
|
||
|
|
const [draft, setDraft] = useState(null)
|
||
|
|
const [busy, setBusy] = useState(false)
|
||
|
|
const [loadErr, setLoadErr] = useState('')
|
||
|
|
const [actionErr, setActionErr] = 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 loadGraph = useCallback(async () => {
|
||
|
|
if (!graphId) return
|
||
|
|
setBusy(true)
|
||
|
|
setLoadErr('')
|
||
|
|
try {
|
||
|
|
const [graph, edges] = await Promise.all([
|
||
|
|
api.getExerciseProgressionGraph(Number(graphId)),
|
||
|
|
api.listExerciseProgressionEdges(Number(graphId)),
|
||
|
|
])
|
||
|
|
setGraphMeta(graph)
|
||
|
|
setDraft(
|
||
|
|
hydrateProgressionGraphDraft({
|
||
|
|
artifact: graph?.planning_roadmap,
|
||
|
|
edges: Array.isArray(edges) ? edges : [],
|
||
|
|
graphName: graph?.name,
|
||
|
|
}),
|
||
|
|
)
|
||
|
|
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])
|
||
|
|
|
||
|
|
const patchDraft = useCallback((patchFn) => {
|
||
|
|
setDraft((prev) => {
|
||
|
|
if (!prev) return prev
|
||
|
|
const next = patchFn(prev)
|
||
|
|
return { ...next, dirty: true }
|
||
|
|
})
|
||
|
|
}, [])
|
||
|
|
|
||
|
|
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 { ...d, slots }
|
||
|
|
})
|
||
|
|
setPickContext(null)
|
||
|
|
}
|
||
|
|
|
||
|
|
const handlePatchLearningGoal = (slotIndex, value) => {
|
||
|
|
patchDraft((d) => {
|
||
|
|
const slots = d.slots.map((s, i) => (i === slotIndex ? { ...s, learning_goal: value } : s))
|
||
|
|
const majorSteps = reindexMajorSteps(
|
||
|
|
(d.majorSteps || []).map((m, i) => (i === slotIndex ? { ...m, learning_goal: value } : m)),
|
||
|
|
)
|
||
|
|
return { ...d, slots, majorSteps }
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
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 { ...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 handleAddSlot = () => {
|
||
|
|
patchDraft((d) => {
|
||
|
|
const idx = d.slots.length
|
||
|
|
const phase = ROADMAP_PHASES[Math.min(idx, ROADMAP_PHASES.length - 1)]
|
||
|
|
const slot = {
|
||
|
|
majorStepIndex: idx,
|
||
|
|
phase,
|
||
|
|
learning_goal: '',
|
||
|
|
consolidates: [],
|
||
|
|
rationale: '',
|
||
|
|
load_profile: [],
|
||
|
|
success_criteria: [],
|
||
|
|
anti_patterns: [],
|
||
|
|
exercise_type: '',
|
||
|
|
primary: { kind: 'empty', exerciseId: null, variantId: null, exerciseTitle: '', variantName: null, proposalKey: null, aiSuggestion: null },
|
||
|
|
siblings: [],
|
||
|
|
}
|
||
|
|
const major = {
|
||
|
|
index: idx,
|
||
|
|
phase,
|
||
|
|
learning_goal: '',
|
||
|
|
consolidates: [],
|
||
|
|
rationale: '',
|
||
|
|
load_profile: [],
|
||
|
|
success_criteria: [],
|
||
|
|
anti_patterns: [],
|
||
|
|
exercise_type: '',
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
...d,
|
||
|
|
slots: [...d.slots, slot],
|
||
|
|
majorSteps: [...(d.majorSteps || []), major],
|
||
|
|
maxSteps: Math.max(d.maxSteps, idx + 1),
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
const validMajorSteps = useMemo(() => {
|
||
|
|
if (!draft?.slots) return []
|
||
|
|
return draft.slots.filter((s) => (s.learning_goal || '').trim().length >= 3)
|
||
|
|
}, [draft?.slots])
|
||
|
|
|
||
|
|
const runRoadmapGenerate = async () => {
|
||
|
|
const q = (draft?.goalQuery || '').trim()
|
||
|
|
if (q.length < 3) {
|
||
|
|
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
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,
|
||
|
|
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 majors = (roadmap?.roadmap?.major_steps || []).map((s, i) => ({
|
||
|
|
index: i,
|
||
|
|
phase: s.phase || ROADMAP_PHASES[Math.min(i, ROADMAP_PHASES.length - 1)],
|
||
|
|
learning_goal: (s.learning_goal || '').trim(),
|
||
|
|
consolidates: Array.isArray(s.consolidates) ? s.consolidates : [],
|
||
|
|
rationale: s.rationale || '',
|
||
|
|
load_profile: [],
|
||
|
|
success_criteria: [],
|
||
|
|
anti_patterns: [],
|
||
|
|
exercise_type: '',
|
||
|
|
}))
|
||
|
|
const hydrated = hydrateProgressionGraphDraft({
|
||
|
|
artifact: {
|
||
|
|
...buildPlanningArtifactFromDraft({ ...draft, progressionRoadmap: roadmap }),
|
||
|
|
progression_roadmap: roadmap,
|
||
|
|
},
|
||
|
|
edges: [],
|
||
|
|
graphName: draft.graphName,
|
||
|
|
})
|
||
|
|
setDraft({ ...hydrated, goalQuery: q, dirty: true })
|
||
|
|
} 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('')
|
||
|
|
try {
|
||
|
|
const override = majorStepsToOverridePayload(draft.slots.map((s) => ({
|
||
|
|
index: s.majorStepIndex,
|
||
|
|
phase: s.phase,
|
||
|
|
learning_goal: s.learning_goal,
|
||
|
|
consolidates: s.consolidates,
|
||
|
|
rationale: s.rationale,
|
||
|
|
load_profile: s.load_profile,
|
||
|
|
success_criteria: s.success_criteria,
|
||
|
|
anti_patterns: s.anti_patterns,
|
||
|
|
exercise_type: s.exercise_type,
|
||
|
|
})))
|
||
|
|
const res = await api.suggestProgressionPath({
|
||
|
|
query: q,
|
||
|
|
max_steps: validMajorSteps.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,
|
||
|
|
progression_graph_id: Number(graphId),
|
||
|
|
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
||
|
|
})
|
||
|
|
const next = applyMatchStepsToSlots(
|
||
|
|
{
|
||
|
|
...draft,
|
||
|
|
progressionRoadmap: res?.progression_roadmap || draft.progressionRoadmap,
|
||
|
|
pathSkillExpectations: res?.path_skill_expectations || draft.pathSkillExpectations,
|
||
|
|
},
|
||
|
|
res?.steps,
|
||
|
|
)
|
||
|
|
setDraft(next)
|
||
|
|
setPathQa(res?.path_qa || null)
|
||
|
|
setGapFillOffers(Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : [])
|
||
|
|
} 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 override =
|
||
|
|
validMajorSteps.length >= 2
|
||
|
|
? majorStepsToOverridePayload(
|
||
|
|
draft.slots.map((s) => ({
|
||
|
|
index: s.majorStepIndex,
|
||
|
|
phase: s.phase,
|
||
|
|
learning_goal: s.learning_goal,
|
||
|
|
consolidates: s.consolidates,
|
||
|
|
rationale: s.rationale,
|
||
|
|
load_profile: s.load_profile,
|
||
|
|
success_criteria: s.success_criteria,
|
||
|
|
anti_patterns: s.anti_patterns,
|
||
|
|
exercise_type: s.exercise_type,
|
||
|
|
})),
|
||
|
|
)
|
||
|
|
: undefined
|
||
|
|
const res = await api.suggestProgressionPath({
|
||
|
|
query: q,
|
||
|
|
max_steps: draft.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(draft),
|
||
|
|
roadmap_override: override,
|
||
|
|
progression_graph_id: Number(graphId),
|
||
|
|
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
||
|
|
})
|
||
|
|
setPathQa(res?.path_qa || null)
|
||
|
|
setGapFillOffers(Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : [])
|
||
|
|
setDraft((prev) => (prev ? { ...prev, lastFindings: res?.path_qa || null } : prev))
|
||
|
|
} 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()
|
||
|
|
alert('Progressionsgraph gespeichert.')
|
||
|
|
} catch (e) {
|
||
|
|
setActionErr(e.message || 'Speichern fehlgeschlagen')
|
||
|
|
} finally {
|
||
|
|
setBusy(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleApplyGapOffer = (offer) => {
|
||
|
|
const idx =
|
||
|
|
offer?.roadmap_major_step_index != null ? Number(offer.roadmap_major_step_index) : null
|
||
|
|
if (idx == null || !Number.isFinite(idx)) {
|
||
|
|
alert('Angebot ohne Slot-Zuordnung — bitte manuell zuweisen.')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
setDraft((prev) => applyGapOfferToSlot(prev, idx, offer))
|
||
|
|
}
|
||
|
|
|
||
|
|
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>
|
||
|
|
<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)' }}>
|
||
|
|
Slot-Editor · Roadmap = Struktur · ein Primärpfad + Schwestern
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<Link to="/exercises" className="btn btn-secondary" style={{ fontSize: '12px' }}>
|
||
|
|
Zur Übersicht
|
||
|
|
</Link>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{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(260px, 320px)',
|
||
|
|
gap: '14px',
|
||
|
|
alignItems: 'start',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<div>
|
||
|
|
<div className="card" style={{ marginBottom: '12px' }}>
|
||
|
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Ziel & Roadmap</h3>
|
||
|
|
<div className="form-row">
|
||
|
|
<label className="form-label">Ziel-Anfrage</label>
|
||
|
|
<textarea
|
||
|
|
className="form-input"
|
||
|
|
rows={2}
|
||
|
|
value={draft.goalQuery}
|
||
|
|
disabled={busy}
|
||
|
|
onChange={(e) => patchDraft((d) => ({ ...d, goalQuery: e.target.value }))}
|
||
|
|
placeholder="z. B. Vom Anfänger zum sauberen Gerade-Tritt"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="form-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||
|
|
<div>
|
||
|
|
<label className="form-label">Start-Situation</label>
|
||
|
|
<input
|
||
|
|
className="form-input"
|
||
|
|
value={draft.startSituation}
|
||
|
|
disabled={busy}
|
||
|
|
onChange={(e) => patchDraft((d) => ({ ...d, startSituation: e.target.value }))}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label className="form-label">Ziel-Zustand</label>
|
||
|
|
<input
|
||
|
|
className="form-input"
|
||
|
|
value={draft.targetState}
|
||
|
|
disabled={busy}
|
||
|
|
onChange={(e) => patchDraft((d) => ({ ...d, targetState: e.target.value }))}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '8px' }}>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="btn btn-secondary"
|
||
|
|
disabled={busy || roadmapLoading}
|
||
|
|
onClick={runRoadmapGenerate}
|
||
|
|
>
|
||
|
|
{roadmapLoading ? 'Roadmap…' : 'Roadmap generieren'}
|
||
|
|
</button>
|
||
|
|
<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>
|
||
|
|
{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 >= 10} onClick={handleAddSlot}>
|
||
|
|
Slot hinzufügen
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{draft.slots.map((slot, idx) => (
|
||
|
|
<ProgressionSlotCard
|
||
|
|
key={`slot-${slot.majorStepIndex}-${idx}`}
|
||
|
|
slot={slot}
|
||
|
|
slotIndex={idx}
|
||
|
|
disabled={busy}
|
||
|
|
onPickPrimary={(i) => setPickContext({ slotIndex: i, role: 'primary' })}
|
||
|
|
onPickSibling={(i) => setPickContext({ slotIndex: i, role: 'sibling' })}
|
||
|
|
onClearPrimary={handleClearPrimary}
|
||
|
|
onRemoveSibling={handleRemoveSibling}
|
||
|
|
onPatchLearningGoal={handlePatchLearningGoal}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<ProgressionFindingsPanel
|
||
|
|
pathQa={pathQa}
|
||
|
|
gapFillOffers={gapFillOffers}
|
||
|
|
loading={evaluating}
|
||
|
|
error={evaluating ? '' : ''}
|
||
|
|
onEvaluate={runEvaluate}
|
||
|
|
onApplyGapOffer={handleApplyGapOffer}
|
||
|
|
evaluateDisabled={busy || !draft.goalQuery?.trim()}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{pickContext ? (
|
||
|
|
<ExercisePickerModal
|
||
|
|
open
|
||
|
|
onClose={() => setPickContext(null)}
|
||
|
|
onSelectExercise={handlePickExercise}
|
||
|
|
/>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
<style>{`
|
||
|
|
@media (max-width: 900px) {
|
||
|
|
.progression-graph-editor-grid {
|
||
|
|
grid-template-columns: 1fr !important;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
`}</style>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|