Enhance Exercise Progression Path Builder with Planning Wizard Stepper
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s
- Introduced a new Planning Wizard Stepper component to guide users through the exercise planning process in four steps. - Implemented logic to compute the maximum reachable step based on user input and current progress. - Updated state management to track the current wizard step and ensure it aligns with user interactions. - Enhanced the user interface to improve clarity and navigation through the planning stages. - Incremented application version to reflect these changes.
This commit is contained in:
parent
5692931d07
commit
ca3a9c6fa4
|
|
@ -1,7 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* Planungs-KI Phase C3/E3: Ziel → Übungspfad vorschlagen → Lücken mit KI anlegen → in Graph speichern.
|
* Planungs-KI Phase C3/E3: Ziel → Übungspfad vorschlagen → Lücken mit KI anlegen → in Graph speichern.
|
||||||
*/
|
*/
|
||||||
import React, { useCallback, useEffect, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal'
|
import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal'
|
||||||
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
|
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
|
||||||
|
|
@ -136,6 +136,93 @@ const OFFER_SOURCE_LABELS = {
|
||||||
|
|
||||||
const PATH_STEPS_HARD_MAX = 10
|
const PATH_STEPS_HARD_MAX = 10
|
||||||
|
|
||||||
|
const WIZARD_STEPS = [
|
||||||
|
{ id: 1, label: 'Ziel & Start/Ziel', short: 'Ziel' },
|
||||||
|
{ id: 2, label: 'Roadmap', short: 'Roadmap' },
|
||||||
|
{ id: 3, label: 'Match', short: 'Match' },
|
||||||
|
{ id: 4, label: 'Lücken & Speichern', short: 'Speichern' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function computeMaxReachableStep(editableMajorSteps, pathSteps) {
|
||||||
|
if (pathSteps.length > 0) return 4
|
||||||
|
if (editableMajorSteps.length >= 2) return 2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlanningWizardStepper({ currentStep, maxReachable, onStepChange, disabled }) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="Planungsfortschritt"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '4px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{WIZARD_STEPS.map((step, idx) => {
|
||||||
|
const reachable = step.id <= maxReachable
|
||||||
|
const active = currentStep === step.id
|
||||||
|
const done = step.id < currentStep && reachable
|
||||||
|
const canClick = reachable && !disabled && step.id <= maxReachable
|
||||||
|
return (
|
||||||
|
<React.Fragment key={step.id}>
|
||||||
|
{idx > 0 ? (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
alignSelf: 'center',
|
||||||
|
color: done || active ? 'var(--accent)' : 'var(--text3)',
|
||||||
|
fontSize: '12px',
|
||||||
|
padding: '0 2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={active ? 'btn btn-primary' : 'btn btn-secondary'}
|
||||||
|
disabled={!canClick}
|
||||||
|
onClick={() => canClick && onStepChange(step.id)}
|
||||||
|
style={{
|
||||||
|
flex: '1 1 120px',
|
||||||
|
minWidth: '100px',
|
||||||
|
fontSize: '12px',
|
||||||
|
padding: '8px 10px',
|
||||||
|
opacity: reachable ? 1 : 0.45,
|
||||||
|
borderColor: active
|
||||||
|
? 'var(--accent)'
|
||||||
|
: done
|
||||||
|
? 'color-mix(in srgb, var(--accent) 40%, var(--border))'
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
title={step.label}
|
||||||
|
aria-current={active ? 'step' : undefined}
|
||||||
|
>
|
||||||
|
<span style={{ marginRight: '6px', fontWeight: 700 }}>{step.id}</span>
|
||||||
|
<span className="planning-step-label-full">{step.label}</span>
|
||||||
|
<span className="planning-step-label-short" style={{ display: 'none' }}>
|
||||||
|
{step.short}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<style>{`
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.planning-step-label-full { display: none !important; }
|
||||||
|
.planning-step-label-short { display: inline !important; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
|
const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
|
||||||
|
|
||||||
const LOAD_PROFILE_OPTIONS = [
|
const LOAD_PROFILE_OPTIONS = [
|
||||||
|
|
@ -355,6 +442,12 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('')
|
const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('')
|
||||||
const [gapPrepError, setGapPrepError] = useState('')
|
const [gapPrepError, setGapPrepError] = useState('')
|
||||||
const [loadedPlanningHint, setLoadedPlanningHint] = useState(false)
|
const [loadedPlanningHint, setLoadedPlanningHint] = useState(false)
|
||||||
|
const [wizardStep, setWizardStep] = useState(1)
|
||||||
|
|
||||||
|
const maxReachableStep = useMemo(
|
||||||
|
() => computeMaxReachableStep(editableMajorSteps, pathSteps),
|
||||||
|
[editableMajorSteps, pathSteps],
|
||||||
|
)
|
||||||
|
|
||||||
const buildPlanningArtifact = useCallback(
|
const buildPlanningArtifact = useCallback(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -404,6 +497,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
setRoadmapDirty(false)
|
setRoadmapDirty(false)
|
||||||
setStartTargetAnalyzed(false)
|
setStartTargetAnalyzed(false)
|
||||||
setError('')
|
setError('')
|
||||||
|
setWizardStep(1)
|
||||||
|
|
||||||
api
|
api
|
||||||
.getExerciseProgressionGraph(Number(graphId))
|
.getExerciseProgressionGraph(Number(graphId))
|
||||||
|
|
@ -422,6 +516,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
const majors = mapMajorStepsFromApi(art.progression_roadmap)
|
const majors = mapMajorStepsFromApi(art.progression_roadmap)
|
||||||
if (majors.length >= 2) {
|
if (majors.length >= 2) {
|
||||||
setEditableMajorSteps(majors)
|
setEditableMajorSteps(majors)
|
||||||
|
setWizardStep(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|
@ -442,6 +537,12 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
}
|
}
|
||||||
}, [graphId])
|
}, [graphId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (wizardStep > maxReachableStep) {
|
||||||
|
setWizardStep(maxReachableStep)
|
||||||
|
}
|
||||||
|
}, [wizardStep, maxReachableStep])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
Promise.all([
|
Promise.all([
|
||||||
|
|
@ -931,6 +1032,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
setPathSkillExpectations(null)
|
setPathSkillExpectations(null)
|
||||||
setRoadmapDirty(false)
|
setRoadmapDirty(false)
|
||||||
setLoadedPlanningHint(false)
|
setLoadedPlanningHint(false)
|
||||||
|
setWizardStep(2)
|
||||||
await persistPlanningRoadmapToGraph()
|
await persistPlanningRoadmapToGraph()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|
@ -980,6 +1082,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
applyPathMatchResponse(res, q)
|
applyPathMatchResponse(res, q)
|
||||||
setMaxSteps(validSteps.length)
|
setMaxSteps(validSteps.length)
|
||||||
setLoadedPlanningHint(false)
|
setLoadedPlanningHint(false)
|
||||||
|
setWizardStep(3)
|
||||||
await persistPlanningRoadmapToGraph()
|
await persistPlanningRoadmapToGraph()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|
@ -1033,6 +1136,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
setPathSkillExpectations(null)
|
setPathSkillExpectations(null)
|
||||||
setEditableMajorSteps([])
|
setEditableMajorSteps([])
|
||||||
setRoadmapDirty(false)
|
setRoadmapDirty(false)
|
||||||
|
setWizardStep(1)
|
||||||
if (typeof onSaved === 'function') await onSaved()
|
if (typeof onSaved === 'function') await onSaved()
|
||||||
const msg =
|
const msg =
|
||||||
skippedAi > 0
|
skippedAi > 0
|
||||||
|
|
@ -1057,9 +1161,27 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
>
|
>
|
||||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>KI: Pfad zum Ziel</h3>
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>KI: Pfad zum Ziel</h3>
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
|
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
|
||||||
Zuerst didaktische Roadmap vorschlagen und anpassen, dann Übungen je Major Step aus der Bibliothek matchen.
|
In vier Schritten: Ziel festlegen → Roadmap bearbeiten → Übungen matchen → Lücken schließen und speichern.
|
||||||
Lücken können mit KI als Übung angelegt werden.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<PlanningWizardStepper
|
||||||
|
currentStep={wizardStep}
|
||||||
|
maxReachable={maxReachableStep}
|
||||||
|
onStepChange={setWizardStep}
|
||||||
|
disabled={disabled || loading || saving}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="form-error" style={{ marginTop: 0, marginBottom: '12px' }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{wizardStep === 1 ? (
|
||||||
|
<section aria-labelledby="planning-step-1-heading">
|
||||||
|
<h4 id="planning-step-1-heading" style={{ margin: '0 0 10px', fontSize: '0.95rem' }}>
|
||||||
|
Schritt 1 — Ziel & Start/Ziel
|
||||||
|
</h4>
|
||||||
{loadedPlanningHint && editableMajorSteps.length > 0 && pathSteps.length === 0 ? (
|
{loadedPlanningHint && editableMajorSteps.length > 0 && pathSteps.length === 0 ? (
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -1072,8 +1194,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
lineHeight: 1.45,
|
lineHeight: 1.45,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Gespeicherte Planung für diesen Graph geladen — Roadmap anpassen und erneut matchen, oder neuen Vorschlag
|
Gespeicherte Planung geladen — Sie können bei Schritt 2 weitermachen oder hier neu starten.
|
||||||
starten.
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
|
||||||
|
|
@ -1146,7 +1267,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
Optional zuerst „Start/Ziel analysieren“, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer,
|
Optional zuerst „Start/Ziel analysieren“, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer,
|
||||||
geschieht die Analyse beim Roadmap-Vorschlag automatisch mit. Manuelle Eingaben haben immer Vorrang.
|
geschieht die Analyse beim Roadmap-Vorschlag automatisch mit. Manuelle Eingaben haben immer Vorrang.
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end', marginTop: '10px' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'center', marginTop: '12px' }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
|
|
@ -1167,53 +1288,31 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
: 'Start/Ziel-Analyse und Roadmap-Stufen in einem Schritt'
|
: 'Start/Ziel-Analyse und Roadmap-Stufen in einem Schritt'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{loadingRoadmap ? 'Roadmap …' : 'Roadmap vorschlagen'}
|
{loadingRoadmap ? 'Roadmap …' : 'Roadmap vorschlagen →'}
|
||||||
</button>
|
</button>
|
||||||
{startTargetAnalyzed && !editableMajorSteps.length ? (
|
{startTargetAnalyzed && !editableMajorSteps.length ? (
|
||||||
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
|
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
|
||||||
Start/Ziel bereit — Roadmap als Nächstes
|
Start/Ziel bereit
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
disabled={
|
|
||||||
disabled ||
|
|
||||||
loading ||
|
|
||||||
saving ||
|
|
||||||
!graphId ||
|
|
||||||
editableMajorSteps.length < 2
|
|
||||||
}
|
|
||||||
onClick={matchExercisesFromRoadmap}
|
|
||||||
title={
|
|
||||||
editableMajorSteps.length < 2
|
|
||||||
? 'Zuerst Roadmap vorschlagen'
|
|
||||||
: roadmapDirty
|
|
||||||
? 'Roadmap wurde bearbeitet — erneut matchen'
|
|
||||||
: 'Bibliothek je Major Step durchsuchen'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{loadingMatch ? 'Match …' : roadmapDirty ? 'Übungen neu matchen' : 'Übungen matchen'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="form-error" style={{ marginTop: '10px' }}>
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{(progressionRoadmap?.goal_analysis ||
|
{(progressionRoadmap?.goal_analysis ||
|
||||||
progressionRoadmap?.pipeline_phase === 'start_target_only') ? (
|
progressionRoadmap?.pipeline_phase === 'start_target_only') ? (
|
||||||
<div
|
<details
|
||||||
style={{
|
style={{
|
||||||
marginTop: '12px',
|
marginTop: '12px',
|
||||||
padding: '12px',
|
padding: '10px 12px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
background: 'var(--surface2)',
|
background: 'var(--surface2)',
|
||||||
|
fontSize: '12px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<summary style={{ cursor: 'pointer', fontWeight: 600, color: 'var(--accent-dark)' }}>
|
||||||
|
KI-Zielanalyse (Details)
|
||||||
|
</summary>
|
||||||
|
<div style={{ marginTop: '10px' }}>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', marginBottom: '8px' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', marginBottom: '8px' }}>
|
||||||
<strong style={{ fontSize: '13px' }}>Zielanalyse</strong>
|
<strong style={{ fontSize: '13px' }}>Zielanalyse</strong>
|
||||||
{progressionRoadmap.llm_start_target_applied ? (
|
{progressionRoadmap.llm_start_target_applied ? (
|
||||||
|
|
@ -1262,44 +1361,31 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{(semanticBrief || targetSummary || pathSkillExpectations) && pathSteps.length > 0 ? (
|
{wizardStep === 2 ? (
|
||||||
<div style={{ marginTop: '10px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
<section aria-labelledby="planning-step-2-heading">
|
||||||
{semanticBrief?.primary_topic ? (
|
<h4 id="planning-step-2-heading" style={{ margin: '0 0 10px', fontSize: '0.95rem' }}>
|
||||||
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
|
Schritt 2 — Didaktische Roadmap
|
||||||
Thema: {semanticBrief.primary_topic}
|
</h4>
|
||||||
</span>
|
{editableMajorSteps.length === 0 ? (
|
||||||
) : null}
|
<p style={{ fontSize: '13px', color: 'var(--text2)', margin: 0 }}>
|
||||||
{Array.isArray(semanticBrief?.development_arc) &&
|
Noch keine Roadmap — zuerst in Schritt 1 „Roadmap vorschlagen“.
|
||||||
semanticBrief.development_arc.slice(0, 3).map((phase) => (
|
<button
|
||||||
<span key={phase} className="exercise-tag">
|
type="button"
|
||||||
{phase}
|
className="btn btn-secondary"
|
||||||
</span>
|
style={{ marginLeft: '10px', fontSize: '12px' }}
|
||||||
))}
|
onClick={() => setWizardStep(1)}
|
||||||
{Array.isArray(targetSummary?.focus_areas) &&
|
|
||||||
targetSummary.focus_areas.slice(0, 1).map((fa) => (
|
|
||||||
<span key={fa} className="exercise-tag">
|
|
||||||
Fokus: {fa}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{formatExpectedSkillNames(pathSkillExpectations, 5).map((name) => (
|
|
||||||
<span
|
|
||||||
key={`path-skill-${name}`}
|
|
||||||
className="exercise-tag"
|
|
||||||
style={{ borderColor: 'color-mix(in srgb, var(--accent) 55%, var(--border))' }}
|
|
||||||
title="Pfadweite Fähigkeiten-Erwartung (Scoring)"
|
|
||||||
>
|
>
|
||||||
{name}
|
Zu Schritt 1
|
||||||
</span>
|
</button>
|
||||||
))}
|
</p>
|
||||||
</div>
|
) : (
|
||||||
) : null}
|
|
||||||
|
|
||||||
{editableMajorSteps.length > 0 ? (
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: '12px',
|
|
||||||
padding: '12px',
|
padding: '12px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
border: '1px solid color-mix(in srgb, var(--accent) 45%, var(--border))',
|
border: '1px solid color-mix(in srgb, var(--accent) 45%, var(--border))',
|
||||||
|
|
@ -1307,7 +1393,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', marginBottom: '8px' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', marginBottom: '8px' }}>
|
||||||
<strong style={{ fontSize: '13px' }}>Didaktische Roadmap — bearbeiten</strong>
|
<strong style={{ fontSize: '13px' }}>Major Steps bearbeiten</strong>
|
||||||
{roadmapDirty ? (
|
{roadmapDirty ? (
|
||||||
<span className="exercise-tag" style={{ borderColor: 'var(--danger)' }}>
|
<span className="exercise-tag" style={{ borderColor: 'var(--danger)' }}>
|
||||||
Geändert — bitte erneut matchen
|
Geändert — bitte erneut matchen
|
||||||
|
|
@ -1489,9 +1575,102 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{editableMajorSteps.length >= 2 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '10px',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: '14px',
|
||||||
|
paddingTop: '14px',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setWizardStep(1)}
|
||||||
|
disabled={disabled || loading || saving}
|
||||||
|
>
|
||||||
|
← Zurück
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={disabled || loading || saving || !graphId}
|
||||||
|
onClick={matchExercisesFromRoadmap}
|
||||||
|
title={
|
||||||
|
roadmapDirty
|
||||||
|
? 'Roadmap wurde bearbeitet — erneut matchen'
|
||||||
|
: 'Bibliothek je Major Step durchsuchen'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loadingMatch ? 'Match …' : roadmapDirty ? 'Übungen neu matchen →' : 'Übungen matchen →'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{pathQa && pathSteps.length > 0 ? (
|
{wizardStep === 3 ? (
|
||||||
|
<section aria-labelledby="planning-step-3-heading">
|
||||||
|
<h4 id="planning-step-3-heading" style={{ margin: '0 0 10px', fontSize: '0.95rem' }}>
|
||||||
|
Schritt 3 — Übungen & Qualität
|
||||||
|
</h4>
|
||||||
|
{pathSteps.length === 0 ? (
|
||||||
|
<p style={{ fontSize: '13px', color: 'var(--text2)', margin: 0 }}>
|
||||||
|
Noch kein Match — zuerst in Schritt 2 „Übungen matchen“.
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ marginLeft: '10px', fontSize: '12px' }}
|
||||||
|
onClick={() => setWizardStep(editableMajorSteps.length >= 2 ? 2 : 1)}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{(semanticBrief || targetSummary || pathSkillExpectations) ? (
|
||||||
|
<details style={{ marginBottom: '10px', fontSize: '12px' }}>
|
||||||
|
<summary style={{ cursor: 'pointer', color: 'var(--accent-dark)', fontWeight: 600 }}>
|
||||||
|
Pfad-Kontext (Thema, Fokus, Fähigkeiten)
|
||||||
|
</summary>
|
||||||
|
<div style={{ marginTop: '8px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||||
|
{semanticBrief?.primary_topic ? (
|
||||||
|
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
|
||||||
|
Thema: {semanticBrief.primary_topic}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{Array.isArray(semanticBrief?.development_arc) &&
|
||||||
|
semanticBrief.development_arc.slice(0, 3).map((phase) => (
|
||||||
|
<span key={phase} className="exercise-tag">
|
||||||
|
{phase}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{Array.isArray(targetSummary?.focus_areas) &&
|
||||||
|
targetSummary.focus_areas.slice(0, 1).map((fa) => (
|
||||||
|
<span key={fa} className="exercise-tag">
|
||||||
|
Fokus: {fa}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{formatExpectedSkillNames(pathSkillExpectations, 5).map((name) => (
|
||||||
|
<span
|
||||||
|
key={`path-skill-${name}`}
|
||||||
|
className="exercise-tag"
|
||||||
|
style={{ borderColor: 'color-mix(in srgb, var(--accent) 55%, var(--border))' }}
|
||||||
|
title="Pfadweite Fähigkeiten-Erwartung (Scoring)"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{pathQa ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: '10px',
|
marginTop: '10px',
|
||||||
|
|
@ -1528,7 +1707,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
</p>
|
</p>
|
||||||
) : Number(pathQa.off_topic_count) > 0 ? (
|
) : Number(pathQa.off_topic_count) > 0 ? (
|
||||||
<p style={{ margin: '6px 0 0', color: 'var(--danger)' }}>
|
<p style={{ margin: '6px 0 0', color: 'var(--danger)' }}>
|
||||||
{pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema — siehe Lücken-Angebote unten.
|
{pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema — Lücken in Schritt 4 schließen.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{pathQa.reorder_applied ? (
|
{pathQa.reorder_applied ? (
|
||||||
|
|
@ -1547,88 +1726,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{gapFillOffers.length > 0 ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: '12px',
|
|
||||||
padding: '12px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: '1px solid color-mix(in srgb, var(--accent) 40%, var(--border))',
|
|
||||||
background: 'color-mix(in srgb, var(--accent) 5%, var(--surface))',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong style={{ fontSize: '13px' }}>Fehlende Schritte — mit KI anlegen</strong>
|
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '6px 0 10px', lineHeight: 1.45 }}>
|
|
||||||
Fehlende oder zu ersetzende Schritte ({pathSteps.length}/{maxSteps} im Pfad).
|
|
||||||
{pathSteps.length >= maxSteps
|
|
||||||
? ' Der Pfad ist voll — beim Einfügen können Sie die Pfadlänge dynamisch vergrößern (ohne neuen Vorschlag); Ersatz-Angebote ersetzen einen Schritt.'
|
|
||||||
: ' Zuerst Kontext prüfen und ergänzen, dann KI-Entwurf erstellen und einfügen.'}
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
{gapFillOffers.map((offer) => (
|
|
||||||
<div
|
|
||||||
key={offer.offer_id}
|
|
||||||
style={{
|
|
||||||
padding: '10px 12px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
background: 'var(--surface2)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'flex-start' }}>
|
|
||||||
<div style={{ flex: '1 1 200px' }}>
|
|
||||||
<span className="exercise-tag" style={{ marginBottom: '6px', display: 'inline-block' }}>
|
|
||||||
{OFFER_SOURCE_LABELS[offer.source] || offer.source || 'Lücke'}
|
|
||||||
{offer.phase ? ` · ${offer.phase}` : ''}
|
|
||||||
</span>
|
|
||||||
<div style={{ fontSize: '13px', fontWeight: 600 }}>{offer.title_hint}</div>
|
|
||||||
{offer.rationale ? (
|
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text2)', margin: '4px 0 0' }}>{offer.rationale}</p>
|
|
||||||
) : null}
|
|
||||||
{offer.from_title && offer.to_title ? (
|
|
||||||
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '4px 0 0' }}>
|
|
||||||
Zwischen „{offer.from_title}“ und „{offer.to_title}“
|
|
||||||
{offer.replace_step_index != null ? ' (ersetzt themenfremden Schritt)' : ''}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<GapOfferContextPreview
|
|
||||||
lines={gapOfferContextDisplayLines(offer, gapContextFallbackParams)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary"
|
|
||||||
style={{ fontSize: '12px', flexShrink: 0 }}
|
|
||||||
disabled={
|
|
||||||
quickSaving ||
|
|
||||||
(isGapOfferBlockedByPathCapacity(offer, pathSteps.length, maxSteps) &&
|
|
||||||
maxSteps >= PATH_STEPS_HARD_MAX)
|
|
||||||
}
|
|
||||||
onClick={() => handleGapFillClick(offer)}
|
|
||||||
title={
|
|
||||||
isGapOfferBlockedByPathCapacity(offer, pathSteps.length, maxSteps)
|
|
||||||
? maxSteps >= PATH_STEPS_HARD_MAX
|
|
||||||
? `Maximal ${PATH_STEPS_HARD_MAX} Schritte — zuerst einen Schritt entfernen.`
|
|
||||||
: 'Pfad voll — Klick fragt, ob die Pfadlänge vergrößert werden soll'
|
|
||||||
: offer.replace_step_index != null
|
|
||||||
? 'Ersetzt den themenfremden Schritt im Pfad'
|
|
||||||
: 'Kontext prüfen, Ergänzungen mitgeben, dann KI-Entwurf'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{generatingOfferId === offer.offer_id
|
|
||||||
? 'KI erstellt Entwurf …'
|
|
||||||
: 'Vorbereiten & KI anlegen'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{pathSteps.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div style={{ marginTop: '14px' }}>
|
|
||||||
{pathSteps.map((step, idx) => (
|
{pathSteps.map((step, idx) => (
|
||||||
<div
|
<div
|
||||||
key={`${step.exerciseId}-${step.proposalKey || ''}-${idx}`}
|
key={`${step.exerciseId}-${step.proposalKey || ''}-${idx}`}
|
||||||
|
|
@ -1637,14 +1735,12 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||||
gap: '10px',
|
gap: '10px',
|
||||||
alignItems: 'end',
|
alignItems: 'end',
|
||||||
marginBottom: '12px',
|
padding: step.isOffTopic ? '8px' : '10px 12px',
|
||||||
paddingBottom: '12px',
|
borderRadius: '8px',
|
||||||
borderBottom: idx < pathSteps.length - 1 ? '1px dashed var(--border)' : 'none',
|
border: '1px solid var(--border)',
|
||||||
background: step.isOffTopic
|
background: step.isOffTopic
|
||||||
? 'color-mix(in srgb, var(--danger) 6%, transparent)'
|
? 'color-mix(in srgb, var(--danger) 6%, var(--surface2))'
|
||||||
: undefined,
|
: 'var(--surface2)',
|
||||||
borderRadius: step.isOffTopic ? '8px' : undefined,
|
|
||||||
padding: step.isOffTopic ? '8px' : undefined,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
|
|
@ -1749,6 +1845,170 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '10px',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: '14px',
|
||||||
|
paddingTop: '14px',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setWizardStep(2)}
|
||||||
|
disabled={disabled || loading || saving}
|
||||||
|
>
|
||||||
|
← Roadmap
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setWizardStep(4)}
|
||||||
|
disabled={disabled || loading || saving}
|
||||||
|
>
|
||||||
|
Weiter zu Lücken & Speichern →
|
||||||
|
</button>
|
||||||
|
{gapFillOffers.length > 0 ? (
|
||||||
|
<span className="exercise-tag" style={{ borderColor: 'var(--danger)' }}>
|
||||||
|
{gapFillOffers.length} Lücke(n) offen
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{wizardStep === 4 ? (
|
||||||
|
<section aria-labelledby="planning-step-4-heading">
|
||||||
|
<h4 id="planning-step-4-heading" style={{ margin: '0 0 10px', fontSize: '0.95rem' }}>
|
||||||
|
Schritt 4 — Lücken schließen & Pfad speichern
|
||||||
|
</h4>
|
||||||
|
{pathSteps.length === 0 ? (
|
||||||
|
<p style={{ fontSize: '13px', color: 'var(--text2)', margin: 0 }}>
|
||||||
|
Noch kein Pfad — zuerst Schritt 3 abschließen.
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ marginLeft: '10px', fontSize: '12px' }}
|
||||||
|
onClick={() => setWizardStep(3)}
|
||||||
|
>
|
||||||
|
Zu Schritt 3
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{gapFillOffers.length > 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '14px',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid color-mix(in srgb, var(--accent) 40%, var(--border))',
|
||||||
|
background: 'color-mix(in srgb, var(--accent) 5%, var(--surface))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ fontSize: '13px' }}>Fehlende Schritte — mit KI anlegen</strong>
|
||||||
|
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '6px 0 10px', lineHeight: 1.45 }}>
|
||||||
|
Fehlende oder zu ersetzende Schritte ({pathSteps.length}/{maxSteps} im Pfad).
|
||||||
|
{pathSteps.length >= maxSteps
|
||||||
|
? ' Der Pfad ist voll — beim Einfügen können Sie die Pfadlänge dynamisch vergrößern (ohne neuen Vorschlag); Ersatz-Angebote ersetzen einen Schritt.'
|
||||||
|
: ' Zuerst Kontext prüfen und ergänzen, dann KI-Entwurf erstellen und einfügen.'}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
{gapFillOffers.map((offer) => (
|
||||||
|
<div
|
||||||
|
key={offer.offer_id}
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'flex-start' }}>
|
||||||
|
<div style={{ flex: '1 1 200px' }}>
|
||||||
|
<span className="exercise-tag" style={{ marginBottom: '6px', display: 'inline-block' }}>
|
||||||
|
{OFFER_SOURCE_LABELS[offer.source] || offer.source || 'Lücke'}
|
||||||
|
{offer.phase ? ` · ${offer.phase}` : ''}
|
||||||
|
</span>
|
||||||
|
<div style={{ fontSize: '13px', fontWeight: 600 }}>{offer.title_hint}</div>
|
||||||
|
{offer.rationale ? (
|
||||||
|
<p style={{ fontSize: '12px', color: 'var(--text2)', margin: '4px 0 0' }}>{offer.rationale}</p>
|
||||||
|
) : null}
|
||||||
|
{offer.from_title && offer.to_title ? (
|
||||||
|
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '4px 0 0' }}>
|
||||||
|
Zwischen „{offer.from_title}“ und „{offer.to_title}“
|
||||||
|
{offer.replace_step_index != null ? ' (ersetzt themenfremden Schritt)' : ''}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<GapOfferContextPreview
|
||||||
|
lines={gapOfferContextDisplayLines(offer, gapContextFallbackParams)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ fontSize: '12px', flexShrink: 0 }}
|
||||||
|
disabled={
|
||||||
|
quickSaving ||
|
||||||
|
(isGapOfferBlockedByPathCapacity(offer, pathSteps.length, maxSteps) &&
|
||||||
|
maxSteps >= PATH_STEPS_HARD_MAX)
|
||||||
|
}
|
||||||
|
onClick={() => handleGapFillClick(offer)}
|
||||||
|
title={
|
||||||
|
isGapOfferBlockedByPathCapacity(offer, pathSteps.length, maxSteps)
|
||||||
|
? maxSteps >= PATH_STEPS_HARD_MAX
|
||||||
|
? `Maximal ${PATH_STEPS_HARD_MAX} Schritte — zuerst einen Schritt entfernen.`
|
||||||
|
: 'Pfad voll — Klick fragt, ob die Pfadlänge vergrößert werden soll'
|
||||||
|
: offer.replace_step_index != null
|
||||||
|
? 'Ersetzt den themenfremden Schritt im Pfad'
|
||||||
|
: 'Kontext prüfen, Ergänzungen mitgeben, dann KI-Entwurf'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{generatingOfferId === offer.offer_id
|
||||||
|
? 'KI erstellt Entwurf …'
|
||||||
|
: 'Vorbereiten & KI anlegen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--accent-dark)',
|
||||||
|
margin: '0 0 14px',
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'color-mix(in srgb, var(--accent) 8%, var(--surface))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Keine offenen Lücken — Pfad kann direkt gespeichert werden.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<details style={{ marginBottom: '12px', fontSize: '12px' }}>
|
||||||
|
<summary style={{ cursor: 'pointer', fontWeight: 600, color: 'var(--accent-dark)' }}>
|
||||||
|
Pfad-Übersicht ({pathSteps.length} Schritte)
|
||||||
|
</summary>
|
||||||
|
<ol style={{ margin: '10px 0 0', paddingLeft: '20px', lineHeight: 1.5 }}>
|
||||||
|
{pathSteps.map((step, idx) => (
|
||||||
|
<li key={`summary-${idx}`} style={{ marginBottom: '4px' }}>
|
||||||
|
<strong>{step.exerciseTitle}</strong>
|
||||||
|
{step.roadmapPhase ? ` (${step.roadmapPhase})` : ''}
|
||||||
|
{step.isAiProposal ? ' — KI-Vorschlag, noch anlegen' : ''}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label">Notiz für Kanten (Fallback, optional)</label>
|
<label className="form-label">Notiz für Kanten (Fallback, optional)</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|
@ -1759,7 +2019,15 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
placeholder="Wird pro Kante genutzt, wenn keine KI-Begründung vorliegt."
|
placeholder="Wird pro Kante genutzt, wenn keine KI-Begründung vorliegt."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '10px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setWizardStep(3)}
|
||||||
|
disabled={disabled || loading || saving}
|
||||||
|
>
|
||||||
|
← Match anpassen
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
|
|
@ -1778,14 +2046,16 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
setSemanticBrief(null)
|
setSemanticBrief(null)
|
||||||
setPathQa(null)
|
setPathQa(null)
|
||||||
setGapFillOffers([])
|
setGapFillOffers([])
|
||||||
setProgressionRoadmap(null)
|
|
||||||
setPathSkillExpectations(null)
|
setPathSkillExpectations(null)
|
||||||
|
setWizardStep(editableMajorSteps.length >= 2 ? 2 : 1)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Vorschlag verwerfen
|
Vorschlag verwerfen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<ExerciseGapFillPrepModal
|
<ExerciseGapFillPrepModal
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user