Refactor Progression Graph Components and Consolidate UI
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m15s

- Updated the `ProgressionGraphSlotEditorSpec.md` to reflect UI consolidation, removing separate editors and integrating functionalities into `ExerciseProgressionGraphPanel`.
- Refactored `ExerciseProgressionGraphPanel` to streamline the editing experience, removing unused state and logic for better performance.
- Enhanced `ProgressionGraphEditor` to support embedded usage and trigger callbacks on save, improving integration with other components.
- Simplified `ProgressionGraphEditPage` to redirect users to the exercises list with deep-linking support for selected graphs.
- Incremented application version to reflect these updates.
This commit is contained in:
Lars 2026-06-10 15:42:29 +02:00
parent c1bf9279ad
commit ee22b22970
4 changed files with 145 additions and 550 deletions

View File

@ -58,14 +58,16 @@ Zusätzlich optional:
- `slot_contents[]``{ major_step_index, primary, siblings[] }`
- `last_findings` — letzter `path_qa`-Snapshot
## UI & Routing
## UI (konsolidiert)
- **B.4:** Route `/progression-graphs/:id` — Slots links, Findings rechts
- **Eine Oberfläche:** `ExerciseProgressionGraphPanel` embeddet `ProgressionGraphEditor` (Slots + Findings)
- Kein separater Slot-Editor, kein 4-Schritt-KI-Wizard, kein `ProgressionChainEditor` im Panel
- Route `/progression-graphs/:id` → Redirect nach `/exercises` (Deep-Link wählt Graph)
- **Phase C:** Übersicht mit Kacheln (Name, Start, Ziel)
## Ersetzt schrittweise
## Ersetzt (Legacy, nicht mehr im Panel)
- Getrennte `ExerciseProgressionPathBuilder`-Wizard-UI + `ProgressionChainEditor` → integrierter `ProgressionGraphEditor`
- `ExerciseProgressionPathBuilder` · `ProgressionChainEditor` — Code bleibt vorerst, nicht eingebunden
## Implementierungsreihenfolge

View File

@ -1,16 +1,13 @@
/**
* Progressionsgraphen: Sequenz-Editor (mehrere Nachfolger auf einmal), Ketten-Ansicht,
* Varianten als eigene Knoten-Endpunkte, Schwester-Kanten gesondert, Tabelle als Fallback.
* Progressionsgraphen eine Oberfläche: Graph wählen, Roadmap-Slots bearbeiten, KI & Speichern.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import api from '../utils/api'
import SkillProfilePanel from './skills/SkillProfilePanel'
import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
import ExercisePickerModal from './ExercisePickerModal'
import ExerciseProgressionPathBuilder from './ExerciseProgressionPathBuilder'
import ProgressionChainEditor from './ProgressionChainEditor'
import ProgressionGraphEditor from './ProgressionGraphEditor'
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
const VIS_OPTIONS = [
@ -25,83 +22,12 @@ function edgeTypeLabel(type) {
return type || '—'
}
/** Maximale lineare Segmente aus next_exercise-Kanten (jedes Segment deckt zusammenhängende „Pfade“ ab). */
function maximalLinearChains(nextEdges) {
if (!nextEdges?.length) return []
const outMap = new Map()
const inMap = new Map()
const nodeKey = (ex, v) => `${ex}:${v ?? ''}`
for (const e of nextEdges) {
const f = nodeKey(e.from_exercise_id, e.from_exercise_variant_id)
const t = nodeKey(e.to_exercise_id, e.to_exercise_variant_id)
if (!outMap.has(f)) outMap.set(f, [])
outMap.get(f).push(e)
if (!inMap.has(t)) inMap.set(t, [])
inMap.get(t).push(e)
}
const used = new Set()
const chains = []
for (const startEdge of nextEdges) {
if (used.has(startEdge.id)) continue
const edgesSeq = [startEdge]
let fk = nodeKey(startEdge.from_exercise_id, startEdge.from_exercise_variant_id)
while (true) {
const preds = inMap.get(fk)
if (!preds || preds.length !== 1) break
const pred = preds[0]
if (used.has(pred.id)) break
edgesSeq.unshift(pred)
fk = nodeKey(pred.from_exercise_id, pred.from_exercise_variant_id)
}
let tk = nodeKey(startEdge.to_exercise_id, startEdge.to_exercise_variant_id)
while (true) {
const outs = outMap.get(tk)
if (!outs || outs.length !== 1) break
const nx = outs[0]
if (used.has(nx.id)) break
edgesSeq.push(nx)
tk = nodeKey(nx.to_exercise_id, nx.to_exercise_variant_id)
}
edgesSeq.forEach((ed) => used.add(ed.id))
const first = edgesSeq[0]
const nodes = [
{
exercise_id: first.from_exercise_id,
variant_id: first.from_exercise_variant_id ?? null,
title: first.from_exercise_title,
variant_name: first.from_variant_name ?? null,
},
]
for (const ed of edgesSeq) {
nodes.push({
exercise_id: ed.to_exercise_id,
variant_id: ed.to_exercise_variant_id ?? null,
title: ed.to_exercise_title,
variant_name: ed.to_variant_name ?? null,
})
}
chains.push({ nodes, edges: edgesSeq })
}
return chains
}
function emptyEndpoint() {
return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [] }
}
export default function ExerciseProgressionGraphPanel({
anchorExerciseId = null,
anchorTitle = null,
}) {
const { user } = useAuth()
const location = useLocation()
const isSuperadmin = user?.role === 'superadmin'
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
@ -123,14 +49,6 @@ export default function ExerciseProgressionGraphPanel({
const [metaDescription, setMetaDescription] = useState('')
const [metaVisibility, setMetaVisibility] = useState('private')
const [pickContext, setPickContext] = useState(null)
const chainEditorRef = useRef(null)
const [relationKind, setRelationKind] = useState('progression')
const [firstEp, setFirstEp] = useState(emptyEndpoint)
const [secondEp, setSecondEp] = useState(emptyEndpoint)
const [edgeNotes, setEdgeNotes] = useState('')
const [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId)
const [editingEdgeNotes, setEditingEdgeNotes] = useState(null)
const [notesDraft, setNotesDraft] = useState('')
@ -138,6 +56,13 @@ export default function ExerciseProgressionGraphPanel({
const [skillProfileLoading, setSkillProfileLoading] = useState(false)
const [skillProfileError, setSkillProfileError] = useState('')
useEffect(() => {
const gid = location.state?.progressionGraphId
if (gid != null && Number.isFinite(Number(gid))) {
setSelectedGraphId(Number(gid))
}
}, [location.state?.progressionGraphId])
useEffect(() => {
setSelectedGraphId(null)
}, [tenantClubDepKey])
@ -157,12 +82,6 @@ export default function ExerciseProgressionGraphPanel({
setEdges(Array.isArray(list) ? list : [])
}, [])
const loadVariantsForExercise = useCallback(async (exerciseId) => {
if (!exerciseId) return []
const ex = await api.getExercise(exerciseId)
return Array.isArray(ex?.variants) ? ex.variants : []
}, [])
useEffect(() => {
let cancelled = false
;(async () => {
@ -234,30 +153,6 @@ export default function ExerciseProgressionGraphPanel({
}
}, [selectedGraphId, graphs, refreshEdges])
useEffect(() => {
let cancelled = false
;(async () => {
if (!firstEp.exerciseId) return
const vars = await loadVariantsForExercise(firstEp.exerciseId)
if (!cancelled) setFirstEp((p) => ({ ...p, variants: vars }))
})()
return () => {
cancelled = true
}
}, [firstEp.exerciseId, loadVariantsForExercise])
useEffect(() => {
let cancelled = false
;(async () => {
if (!secondEp.exerciseId) return
const vars = await loadVariantsForExercise(secondEp.exerciseId)
if (!cancelled) setSecondEp((p) => ({ ...p, variants: vars }))
})()
return () => {
cancelled = true
}
}, [secondEp.exerciseId, loadVariantsForExercise])
const filteredEdges = useMemo(() => {
if (!filterAnchorOnly || anchorExerciseId == null) return edges
return edges.filter(
@ -266,24 +161,6 @@ export default function ExerciseProgressionGraphPanel({
)
}, [edges, filterAnchorOnly, anchorExerciseId])
const nextEdgesFiltered = useMemo(
() => filteredEdges.filter((e) => e.edge_type === 'next_exercise'),
[filteredEdges],
)
const siblingEdgesFiltered = useMemo(
() => filteredEdges.filter((e) => e.edge_type === 'sibling'),
[filteredEdges],
)
const flowChains = useMemo(() => maximalLinearChains(nextEdgesFiltered), [nextEdgesFiltered])
const primaryChain = useMemo(() => {
if (!flowChains.length) return null
return flowChains.reduce((best, chain) =>
chain.nodes.length >= best.nodes.length ? chain : best,
)
}, [flowChains])
const handleCreateGraph = async (e) => {
e.preventDefault()
const name = newGraphName.trim()
@ -322,7 +199,7 @@ export default function ExerciseProgressionGraphPanel({
visibility: metaVisibility,
})
await refreshGraphs()
alert('Graph gespeichert.')
alert('Graph-Metadaten gespeichert.')
} catch (err) {
alert(err.message || String(err))
} finally {
@ -332,7 +209,7 @@ export default function ExerciseProgressionGraphPanel({
const handleDeleteGraph = async () => {
if (!selectedGraphId) return
if (!confirm('Diesen Progressionsgraphen und alle Kanten wirklich löschen?')) return
if (!window.confirm('Diesen Progressionsgraph wirklich löschen?')) return
setBusy(true)
try {
await api.deleteExerciseProgressionGraph(selectedGraphId)
@ -345,49 +222,8 @@ export default function ExerciseProgressionGraphPanel({
}
}
const handleAddEdge = async () => {
if (!selectedGraphId) {
alert('Zuerst einen Graphen wählen.')
return
}
if (!firstEp.exerciseId || !secondEp.exerciseId) {
alert('Beide Enden müssen eine Übung haben.')
return
}
if (
firstEp.exerciseId === secondEp.exerciseId &&
(firstEp.variantId == null ||
secondEp.variantId == null ||
firstEp.variantId === secondEp.variantId)
) {
alert('Bei derselben Übung bitte zwei verschiedene Varianten wählen (oder unterschiedliche Übungen).')
return
}
const edge_type = relationKind === 'sibling' ? 'sibling' : 'next_exercise'
const notes = edgeNotes.trim() || null
const body = {
from_exercise_id: firstEp.exerciseId,
to_exercise_id: secondEp.exerciseId,
from_exercise_variant_id: firstEp.variantId || null,
to_exercise_variant_id: secondEp.variantId || null,
edge_type,
notes,
}
setBusy(true)
try {
await api.createExerciseProgressionEdge(selectedGraphId, body)
setEdgeNotes('')
await refreshEdges(selectedGraphId)
} catch (err) {
alert(err.message || String(err))
} finally {
setBusy(false)
}
}
const handleDeleteEdge = async (edgeId) => {
if (!selectedGraphId) return
if (!confirm('Kante löschen?')) return
setBusy(true)
try {
await api.deleteExerciseProgressionEdge(selectedGraphId, edgeId)
@ -420,58 +256,10 @@ export default function ExerciseProgressionGraphPanel({
}
}
const swapEnds = () => {
const a = firstEp
setFirstEp(secondEp)
setSecondEp(a)
}
const applyPickedExercise = async (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
if (pickContext?.kind === 'chain') {
await chainEditorRef.current?.applyExercise(
pickContext.draftKey,
pickContext.nodeIndex,
{
...ex,
variants,
exercise_variant_id: variantId,
},
)
setPickContext(null)
return
}
if (pickContext?.kind === 'single') {
const patch = {
exerciseId: ex.id,
exerciseTitle: title,
variantId: variantId != null ? Number(variantId) : null,
variants,
}
if (pickContext.slot === 'first') setFirstEp(patch)
else setSecondEp(patch)
setPickContext(null)
}
}
function formatNodeLine(n) {
return (
<>
<Link to={`/exercises/${n.exercise_id}`}>{n.title}</Link>
{n.variant_name ? (
<span style={{ color: 'var(--text3)', fontWeight: 400 }}>{` · ${n.variant_name}`}</span>
) : null}
</>
)
}
const pickerOpen = pickContext != null
const handleEditorSaved = useCallback(async () => {
if (!selectedGraphId) return
await Promise.all([refreshEdges(selectedGraphId), refreshGraphs()])
}, [selectedGraphId, refreshEdges, refreshGraphs])
return (
<div className="exercise-progression-panel">
@ -485,9 +273,8 @@ export default function ExerciseProgressionGraphPanel({
)}
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '16px' }}>
Ein Graph = ein linearer <strong>Primärpfad</strong> (Roadmap-Slots) plus optionale{' '}
<strong>Schwestern</strong>. Für die integrierte Bearbeitung (Slots, KI-Entwürfe, Graph-Bewertung){' '}
<strong>Slot-Editor öffnen</strong> unten weiterhin Kurzansicht und KI-Wizard.
Ein Progressionsgraph = <strong>Roadmap mit Slots</strong> (Lernziel + Hauptübung + Schwestern).
Roadmap, KI-Match und Bewertung sind in einer Ansicht kein separater Wizard.
</p>
{loadErr && (
@ -525,18 +312,12 @@ export default function ExerciseProgressionGraphPanel({
<button type="button" className="btn" disabled={busy || !selectedGraphId} onClick={handleDeleteGraph}>
Graph löschen
</button>
{selectedGraphId ? (
<Link
to={`/progression-graphs/${selectedGraphId}`}
className="btn btn-primary"
style={{ textDecoration: 'none' }}
>
Slot-Editor öffnen
</Link>
) : null}
</div>
<form onSubmit={handleCreateGraph} style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid var(--border)' }}>
<form
onSubmit={handleCreateGraph}
style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid var(--border)' }}
>
<h4 style={{ margin: '0 0 8px', fontSize: '0.95rem' }}>Neuen Graphen anlegen</h4>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
<div className="form-row" style={{ flex: '2 1 200px', marginBottom: 0 }}>
@ -576,107 +357,20 @@ export default function ExerciseProgressionGraphPanel({
checked={filterAnchorOnly}
onChange={(e) => setFilterAnchorOnly(e.target.checked)}
/>
Nur Reihen und Kanten, die diese Übung betreffen
Technische Kantenliste: nur Kanten mit dieser Übung
</label>
)}
{selectedGraphId && (
{selectedGraphId ? (
<>
<div
className="card"
style={{
marginBottom: '12px',
borderColor: 'color-mix(in srgb, var(--accent) 30%, var(--border))',
}}
>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Progressionspfad</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
Der Graph enthält typischerweise <strong>einen linearen Pfad</strong> (Übung Übung). Unten
manuell bearbeiten oder mit dem KI-Planer erweitern Speichern im KI-Wizard ersetzt den Pfad.
</p>
<ProgressionGraphEditor
key={selectedGraphId}
graphId={selectedGraphId}
embedded
onSaved={handleEditorSaved}
/>
<ProgressionChainEditor
ref={chainEditorRef}
graphId={selectedGraphId}
chains={primaryChain ? [primaryChain] : []}
busy={busy}
anchorExerciseId={anchorExerciseId}
anchorTitle={anchorTitle}
onRefresh={async () => refreshEdges(selectedGraphId)}
onPickExercise={setPickContext}
loadVariantsForExercise={loadVariantsForExercise}
singlePathMode
/>
<ExerciseProgressionPathBuilder
graphId={selectedGraphId}
disabled={busy}
graphChainNodes={primaryChain?.nodes ?? null}
graphChainEdgeIds={primaryChain?.edges?.map((e) => e.id) ?? null}
onSaved={async () => {
await refreshEdges(selectedGraphId)
}}
/>
</div>
<div className="card" style={{ marginBottom: '12px' }}>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Schwestern & Alternativen</h3>
{siblingEdgesFiltered.length === 0 ? (
<p style={{ color: 'var(--text2)', margin: 0 }}>Keine Schwester-Kanten im aktuellen Filter.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{siblingEdgesFiltered.map((row) => (
<li
key={row.id}
style={{
padding: '10px 12px',
marginBottom: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'center',
fontSize: '13px',
}}
>
<span>
<strong>{formatNodeLine({
exercise_id: row.from_exercise_id,
variant_id: row.from_exercise_variant_id,
title: row.from_exercise_title,
variant_name: row.from_variant_name,
})}</strong>
<span style={{ color: 'var(--text3)', margin: '0 8px' }}>· Schwester ·</span>
<strong>{formatNodeLine({
exercise_id: row.to_exercise_id,
variant_id: row.to_exercise_variant_id,
title: row.to_exercise_title,
variant_name: row.to_variant_name,
})}</strong>
{row.notes ? (
<span style={{ display: 'block', marginTop: '6px', color: 'var(--text2)', fontWeight: 400 }}>
{row.notes}
</span>
) : null}
</span>
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 10px', background: 'var(--danger)', color: '#fff', border: 'none' }}
onClick={() => handleDeleteEdge(row.id)}
>
Löschen
</button>
</li>
))}
</ul>
)}
</div>
<details className="card" style={{ marginBottom: '12px' }}>
<details className="card" style={{ marginBottom: '12px', marginTop: '12px' }}>
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Graph-Einstellungen (Name, Sichtbarkeit)</summary>
<div style={{ marginTop: '14px' }}>
<div className="form-row">
@ -714,7 +408,7 @@ export default function ExerciseProgressionGraphPanel({
<SkillProfilePanel
title="Fähigkeiten entlang des Pfads"
hint="Alle Übungen als Knoten im Graph (Standardgewicht pro Übung; Intensität und Stufen aus der Übungsverknüpfung)."
hint="Alle Übungen als Knoten im Graph."
profile={skillProfileData?.overall}
loading={skillProfileLoading}
error={skillProfileError}
@ -722,196 +416,102 @@ export default function ExerciseProgressionGraphPanel({
artifactType="progression_graph"
/>
<details className="card" style={{ marginBottom: '12px' }}>
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Einzelkante (Nachfolger oder Schwester)</summary>
<div style={{ marginTop: '14px' }}>
<div className="form-row">
<label className="form-label">Beziehung</label>
<select className="form-input" value={relationKind} onChange={(e) => setRelationKind(e.target.value)}>
<option value="progression">Nachfolger</option>
<option value="sibling">Schwester</option>
</select>
</div>
{['first', 'second'].map((slot) => {
const ep = slot === 'first' ? firstEp : secondEp
const setEp = slot === 'first' ? setFirstEp : setSecondEp
return (
<div key={slot} style={{ marginBottom: '12px' }}>
<label className="form-label">{slot === 'first' ? 'Von (Quelle)' : 'Nach (Ziel)'}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', marginBottom: '8px' }}>
<span style={{ fontSize: '13px', flex: '1 1 160px' }}>
{ep.exerciseId ? (
<>
<strong>{ep.exerciseTitle}</strong>
<span style={{ color: 'var(--text3)' }}> (#{ep.exerciseId})</span>
</>
) : (
<span style={{ color: 'var(--text3)' }}> Übung wählen </span>
)}
</span>
<button
type="button"
className="btn btn-secondary"
onClick={() => setPickContext({ kind: 'single', slot })}
>
Übung
</button>
{anchorExerciseId != null && (
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 10px' }}
onClick={async () => {
const variants = await loadVariantsForExercise(anchorExerciseId)
setEp({
exerciseId: anchorExerciseId,
exerciseTitle: anchorTitle?.trim() || `Übung #${anchorExerciseId}`,
variantId: null,
variants,
})
}}
>
Diese Übung
</button>
)}
</div>
<select
className="form-input"
disabled={!ep.exerciseId}
value={ep.variantId ?? ''}
onChange={(e) =>
setEp((p) => ({
...p,
variantId: e.target.value === '' ? null : parseInt(e.target.value, 10),
}))
}
>
<option value="">Gesamte Übung</option>
{(ep.variants || []).map((v) => (
<option key={v.id} value={v.id}>
{v.variant_name || `Variante #${v.id}`}
</option>
))}
</select>
</div>
)
})}
{relationKind === 'progression' && (
<button type="button" className="btn" style={{ marginBottom: '10px' }} onClick={swapEnds}>
Reihenfolge tauschen
</button>
)}
<div className="form-row">
<label className="form-label">Notiz</label>
<textarea className="form-input" rows={2} value={edgeNotes} onChange={(e) => setEdgeNotes(e.target.value)} />
</div>
<button type="button" className="btn btn-primary" disabled={busy} onClick={handleAddEdge}>
Kante speichern
</button>
</div>
</details>
<details className="card" style={{ marginBottom: '12px' }}>
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>
Technische Kantenliste ({filteredEdges.length}
{edges.length !== filteredEdges.length ? ` von ${edges.length}` : ''})
</summary>
<div style={{ marginTop: '14px' }}>
{filteredEdges.length === 0 ? (
<p style={{ color: 'var(--text2)', margin: 0 }}>Keine Kanten.</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)' }}>
<th style={{ padding: '8px 6px' }}>Von</th>
<th style={{ padding: '8px 6px' }} />
<th style={{ padding: '8px 6px' }}>Nach</th>
<th style={{ padding: '8px 6px' }}>Art</th>
<th style={{ padding: '8px 6px' }}>Notiz</th>
<th style={{ padding: '8px 6px' }} />
</tr>
</thead>
<tbody>
{filteredEdges.map((row) => (
<tr key={row.id} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
<Link to={`/exercises/${row.from_exercise_id}`}>{row.from_exercise_title || `#${row.from_exercise_id}`}</Link>
{row.from_variant_name ? (
<div style={{ color: 'var(--text3)', fontSize: '12px' }}>{row.from_variant_name}</div>
) : null}
</td>
<td style={{ padding: '8px 6px', color: 'var(--text3)', verticalAlign: 'top' }}>
{row.edge_type === 'sibling' ? '·' : '→'}
</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
<Link to={`/exercises/${row.to_exercise_id}`}>{row.to_exercise_title || `#${row.to_exercise_id}`}</Link>
{row.to_variant_name ? (
<div style={{ color: 'var(--text3)', fontSize: '12px' }}>{row.to_variant_name}</div>
) : null}
</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>{edgeTypeLabel(row.edge_type)}</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top', maxWidth: '280px' }}>
{editingEdgeNotes === row.id ? (
<>
<textarea
className="form-input"
rows={2}
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
style={{ marginBottom: '6px' }}
/>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => saveNotes(row.id)}>
Speichern
</button>
<button type="button" className="btn" disabled={busy} onClick={() => setEditingEdgeNotes(null)}>
Abbrechen
</button>
</>
) : (
<>
<span style={{ whiteSpace: 'pre-wrap' }}>{row.notes || '—'}</span>
{filteredEdges.length === 0 ? (
<p style={{ color: 'var(--text2)', margin: 0 }}>Keine Kanten.</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)' }}>
<th style={{ padding: '8px 6px' }}>Von</th>
<th style={{ padding: '8px 6px' }} />
<th style={{ padding: '8px 6px' }}>Nach</th>
<th style={{ padding: '8px 6px' }}>Art</th>
<th style={{ padding: '8px 6px' }}>Notiz</th>
<th style={{ padding: '8px 6px' }} />
</tr>
</thead>
<tbody>
{filteredEdges.map((row) => (
<tr key={row.id} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
<Link to={`/exercises/${row.from_exercise_id}`}>
{row.from_exercise_title || `#${row.from_exercise_id}`}
</Link>
</td>
<td style={{ padding: '8px 6px', color: 'var(--text3)', verticalAlign: 'top' }}>
{row.edge_type === 'sibling' ? '·' : '→'}
</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
<Link to={`/exercises/${row.to_exercise_id}`}>
{row.to_exercise_title || `#${row.to_exercise_id}`}
</Link>
</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>{edgeTypeLabel(row.edge_type)}</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top', maxWidth: '280px' }}>
{editingEdgeNotes === row.id ? (
<>
<textarea
className="form-input"
rows={2}
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
/>
<button
type="button"
className="btn btn-primary"
style={{ marginTop: '6px', fontSize: '12px', padding: '4px 8px' }}
onClick={() => saveNotes(row.id)}
>
Speichern
</button>
</>
) : (
<>
{row.notes || '—'}
<button
type="button"
className="btn"
style={{ marginLeft: '8px', fontSize: '12px', padding: '4px 8px' }}
onClick={() => startEditNotes(row)}
>
Bearbeiten
</button>
</>
)}
</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
<button
type="button"
className="btn"
style={{ marginLeft: '8px', fontSize: '12px', padding: '4px 8px' }}
onClick={() => startEditNotes(row)}
style={{
fontSize: '12px',
padding: '4px 8px',
background: 'var(--danger)',
color: '#fff',
border: 'none',
}}
onClick={() => handleDeleteEdge(row.id)}
>
Bearbeiten
Löschen
</button>
</>
)}
</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 8px', background: 'var(--danger)', color: '#fff', border: 'none' }}
onClick={() => handleDeleteEdge(row.id)}
>
Löschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</details>
</>
)}
<ExercisePickerModal
open={pickerOpen}
onClose={() => setPickContext(null)}
onSelectExercise={applyPickedExercise}
exerciseKindAny={['simple']}
/>
) : null}
</div>
)
}

View File

@ -57,7 +57,7 @@ function resolveDefaultFocusAreaId(targetSummary, focusAreas) {
return focusAreas?.[0]?.id ? Number(focusAreas[0].id) : null
}
export default function ProgressionGraphEditor({ graphId }) {
export default function ProgressionGraphEditor({ graphId, embedded = false, onSaved }) {
const [graphMeta, setGraphMeta] = useState(null)
const [draft, setDraft] = useState(null)
const [busy, setBusy] = useState(false)
@ -390,6 +390,7 @@ export default function ProgressionGraphEditor({ graphId }) {
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')
@ -551,19 +552,21 @@ export default function ProgressionGraphEditor({ graphId }) {
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)' }}>
Slots verschieben, ergänzen · KI-Angebote zuordnen · dynamisch erweitern (max. {SLOT_MAX})
</p>
{!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>
<Link to="/exercises" className="btn btn-secondary" style={{ fontSize: '12px' }}>
Zur Übersicht
</Link>
</div>
) : null}
{actionErr ? (
<p className="form-error" style={{ marginTop: 0 }}>

View File

@ -1,25 +1,15 @@
import React from 'react'
import { Link, useParams } from 'react-router-dom'
import ProgressionGraphEditor from '../components/ProgressionGraphEditor'
import { Navigate, useParams } from 'react-router-dom'
/** Alte Deep-Links → Übungen-Liste mit Graph-Auswahl. */
export default function ProgressionGraphEditPage() {
const { id } = useParams()
const graphId = Number(id)
if (!Number.isFinite(graphId) || graphId < 1) {
return (
<div className="page" style={{ padding: '16px' }}>
<p className="form-error">Ungültige Graph-ID.</p>
<Link to="/exercises" className="btn btn-secondary">
Zurück
</Link>
</div>
)
}
return (
<div className="page" style={{ padding: '16px', paddingBottom: '80px' }}>
<ProgressionGraphEditor graphId={graphId} />
</div>
<Navigate
to="/exercises"
replace
state={Number.isFinite(graphId) && graphId > 0 ? { progressionGraphId: graphId } : undefined}
/>
)
}