Notiz für Kanten (Fallback, optional)
@@ -2047,6 +2085,7 @@ export default function ExerciseProgressionPathBuilder({
setPathQa(null)
setGapFillOffers([])
setPathSkillExpectations(null)
+ setPathInsertNotice('')
setWizardStep(editableMajorSteps.length >= 2 ? 2 : 1)
}}
>
diff --git a/frontend/src/components/ProgressionChainEditor.jsx b/frontend/src/components/ProgressionChainEditor.jsx
new file mode 100644
index 0000000..21fc0a3
--- /dev/null
+++ b/frontend/src/components/ProgressionChainEditor.jsx
@@ -0,0 +1,508 @@
+/**
+ * Bearbeitbare Darstellung linearer Progressions-Reihen im Graphen.
+ */
+import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
+import { Link } from 'react-router-dom'
+import api from '../utils/api'
+
+function emptyNode() {
+ return {
+ exerciseId: null,
+ exerciseTitle: '',
+ variantId: null,
+ variantName: null,
+ variants: [],
+ }
+}
+
+function chainToDraft(chain) {
+ return {
+ key: `chain-${chain.edges[0]?.id ?? 'x'}`,
+ edgeIds: chain.edges.map((e) => e.id),
+ segmentNotes: chain.edges.map((e) => e.notes || ''),
+ nodes: chain.nodes.map((n) => ({
+ exerciseId: n.exercise_id,
+ exerciseTitle: n.title || `Übung #${n.exercise_id}`,
+ variantId: n.variant_id ?? null,
+ variantName: n.variant_name ?? null,
+ variants: [],
+ })),
+ dirty: false,
+ isNew: false,
+ }
+}
+
+function newChainDraft() {
+ const key = `new-${Date.now()}`
+ return {
+ key,
+ edgeIds: [],
+ segmentNotes: [],
+ nodes: [emptyNode(), emptyNode()],
+ dirty: true,
+ isNew: true,
+ }
+}
+
+function formatNodeLabel(node) {
+ if (!node.exerciseId) return '— Übung wählen —'
+ return (
+ <>
+
{node.exerciseTitle}
+ {node.variantName ? (
+
{` · ${node.variantName}`}
+ ) : null}
+ >
+ )
+}
+
+const ProgressionChainEditor = forwardRef(function ProgressionChainEditor(
+ {
+ graphId,
+ chains = [],
+ busy = false,
+ anchorExerciseId = null,
+ anchorTitle = null,
+ onRefresh,
+ onPickExercise,
+ loadVariantsForExercise,
+ },
+ ref,
+) {
+ const [drafts, setDrafts] = useState([])
+ const [savingKey, setSavingKey] = useState(null)
+
+ const chainSignature = useMemo(
+ () =>
+ chains
+ .map((c) => c.edges.map((e) => e.id).join(','))
+ .join('|'),
+ [chains],
+ )
+
+ useEffect(() => {
+ setDrafts(chains.map(chainToDraft))
+ }, [chainSignature, chains])
+
+ const patchDraft = useCallback((key, patchFn) => {
+ setDrafts((prev) =>
+ prev.map((d) => {
+ if (d.key !== key) return d
+ const next = patchFn(d)
+ return { ...next, dirty: true }
+ }),
+ )
+ }, [])
+
+ const moveNode = (key, idx, dir) => {
+ patchDraft(key, (d) => {
+ const j = idx + dir
+ if (j < 0 || j >= d.nodes.length) return d
+ const nodes = [...d.nodes]
+ const t = nodes[idx]
+ nodes[idx] = nodes[j]
+ nodes[j] = t
+ return { ...d, nodes }
+ })
+ }
+
+ const removeNode = (key, idx) => {
+ patchDraft(key, (d) => {
+ if (d.nodes.length <= 2) return d
+ const nodes = d.nodes.filter((_, i) => i !== idx)
+ const segmentNotes = d.segmentNotes.slice(0, Math.max(0, nodes.length - 1))
+ return { ...d, nodes, segmentNotes }
+ })
+ }
+
+ const setVariant = (key, idx, variantId) => {
+ patchDraft(key, (d) => ({
+ ...d,
+ nodes: d.nodes.map((n, i) => {
+ if (i !== idx) return n
+ const v = (n.variants || []).find((x) => Number(x.id) === Number(variantId))
+ return {
+ ...n,
+ variantId: variantId === '' || variantId == null ? null : Number(variantId),
+ variantName: v?.variant_name || null,
+ }
+ }),
+ }))
+ }
+
+ const applyExerciseToNode = useCallback(async (key, idx, ex) => {
+ const title = ex.title || `Übung #${ex.id}`
+ const variants =
+ Array.isArray(ex.variants) && ex.variants.length
+ ? ex.variants
+ : await loadVariantsForExercise(ex.id)
+ const variantId = ex.exercise_variant_id ?? ex.suggested_variant_id ?? null
+ patchDraft(key, (d) => ({
+ ...d,
+ nodes: d.nodes.map((n, i) =>
+ i === idx
+ ? {
+ exerciseId: ex.id,
+ exerciseTitle: title,
+ variantId: variantId != null ? Number(variantId) : null,
+ variantName:
+ variantId != null
+ ? variants.find((v) => Number(v.id) === Number(variantId))?.variant_name || null
+ : null,
+ variants,
+ }
+ : n,
+ ),
+ }))
+ }, [patchDraft, loadVariantsForExercise])
+
+ const insertNodeAfter = (key, idx) => {
+ patchDraft(key, (d) => {
+ const nodes = [...d.nodes]
+ nodes.splice(idx + 1, 0, emptyNode())
+ return { ...d, nodes }
+ })
+ onPickExercise({ kind: 'chain', draftKey: key, nodeIndex: idx + 1 })
+ }
+
+ const addNewChain = () => {
+ setDrafts((prev) => [...prev, newChainDraft()])
+ }
+
+ const discardDraft = (key) => {
+ setDrafts((prev) => {
+ const draft = prev.find((d) => d.key === key)
+ if (!draft) return prev
+ if (draft.isNew) return prev.filter((d) => d.key !== key)
+ const original = chains.find((c) => `chain-${c.edges[0]?.id}` === key)
+ if (!original) return prev.filter((d) => d.key !== key)
+ return prev.map((d) => (d.key === key ? chainToDraft(original) : d))
+ })
+ }
+
+ const deleteChain = async (draft) => {
+ if (!graphId) return
+ if (draft.isNew) {
+ setDrafts((prev) => prev.filter((d) => d.key !== draft.key))
+ return
+ }
+ if (!draft.edgeIds.length) return
+ if (!window.confirm(`Reihe mit ${draft.nodes.length} Schritten löschen?`)) return
+ setSavingKey(draft.key)
+ try {
+ await api.deleteExerciseProgressionEdgesBatch(graphId, draft.edgeIds)
+ await onRefresh()
+ } catch (e) {
+ alert(e.message || String(e))
+ } finally {
+ setSavingKey(null)
+ }
+ }
+
+ const saveDraft = async (draft) => {
+ if (!graphId) return
+ const steps = draft.nodes.filter((n) => n.exerciseId != null)
+ if (steps.length < 2) {
+ alert('Mindestens zwei Schritte mit gewählter Übung.')
+ return
+ }
+ const n = steps.length - 1
+ let segment_notes = draft.segmentNotes.slice(0, n)
+ while (segment_notes.length < n) segment_notes.push(null)
+ segment_notes = segment_notes.slice(0, n)
+
+ setSavingKey(draft.key)
+ try {
+ if (!draft.isNew && draft.edgeIds.length) {
+ await api.deleteExerciseProgressionEdgesBatch(graphId, draft.edgeIds)
+ }
+ await api.createExerciseProgressionSequence(graphId, {
+ steps: steps.map((s) => ({
+ exercise_id: s.exerciseId,
+ variant_id: s.variantId || null,
+ })),
+ segment_notes,
+ })
+ await onRefresh()
+ } catch (e) {
+ alert(e.message || String(e))
+ } finally {
+ setSavingKey(null)
+ }
+ }
+
+ const ensureVariantsLoaded = async (key, idx) => {
+ const draft = drafts.find((d) => d.key === key)
+ const node = draft?.nodes[idx]
+ if (!node?.exerciseId || (node.variants || []).length) return
+ const variants = await loadVariantsForExercise(node.exerciseId)
+ setDrafts((prev) =>
+ prev.map((d) => {
+ if (d.key !== key) return d
+ return {
+ ...d,
+ nodes: d.nodes.map((n, i) => (i === idx ? { ...n, variants } : n)),
+ }
+ }),
+ )
+ }
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ applyExercise: applyExerciseToNode,
+ }),
+ [applyExerciseToNode],
+ )
+
+ if (!graphId) return null
+
+ return (
+
+
+
+
Reihen im Graph
+
+ Jede Reihe ist ein linearer Pfad: Schritt 1 → 2 → … Reihenfolge ändern, Übungen tauschen oder
+ dazwischen einfügen, dann speichern.
+
+
+
+ + Neue Reihe
+
+
+
+ {drafts.length === 0 ? (
+
+ Noch keine Reihen in diesem Graph. Legen Sie eine neue Reihe an oder nutzen Sie den KI-Planer unten.
+
+ ) : (
+ drafts.map((draft, chainIdx) => (
+
+
+ Reihe {chainIdx + 1}
+ {draft.dirty ? (
+
+ Ungespeichert
+
+ ) : null}
+ {draft.isNew ? (
+ Neu
+ ) : (
+ {draft.nodes.length} Schritte
+ )}
+
+
+
+ {draft.nodes.map((node, idx) => (
+
+ {idx > 0 ? (
+
+ ↓ Nachfolger
+
+ ) : null}
+
+
+
Schritt {idx + 1}
+
+
+ {formatNodeLabel(node)}
+
+
+ onPickExercise({ kind: 'chain', draftKey: draft.key, nodeIndex: idx })
+ }
+ >
+ {node.exerciseId ? 'Tauschen…' : 'Übung…'}
+
+ {anchorExerciseId != null ? (
+ {
+ const variants = await loadVariantsForExercise(anchorExerciseId)
+ await applyExerciseToNode(draft.key, idx, {
+ id: anchorExerciseId,
+ title: anchorTitle?.trim() || `Übung #${anchorExerciseId}`,
+ variants,
+ })
+ }}
+ >
+ Kontext-Übung
+
+ ) : null}
+
+
+
+ Variante
+ ensureVariantsLoaded(draft.key, idx)}
+ onChange={(e) =>
+ setVariant(
+ draft.key,
+ idx,
+ e.target.value === '' ? null : parseInt(e.target.value, 10),
+ )
+ }
+ >
+ Gesamte Übung
+ {(node.variants || []).map((v) => (
+
+ {v.variant_name || `Variante #${v.id}`}
+
+ ))}
+
+
+
+ moveNode(draft.key, idx, -1)}
+ >
+ ↑
+
+ = draft.nodes.length - 1}
+ onClick={() => moveNode(draft.key, idx, 1)}
+ >
+ ↓
+
+ insertNodeAfter(draft.key, idx)}
+ >
+ + Einfügen
+
+ removeNode(draft.key, idx)}
+ >
+ Entfernen
+
+
+
+
+ ))}
+
+
+
+ saveDraft(draft)}
+ >
+ {savingKey === draft.key ? 'Speichern …' : 'Reihe speichern'}
+
+ {draft.dirty ? (
+ discardDraft(draft.key)}
+ >
+ Verwerfen
+
+ ) : null}
+ deleteChain(draft)}
+ >
+ Reihe löschen
+
+
+
+ ))
+ )}
+
+ )
+})
+
+export default ProgressionChainEditor