Progression optimiert Phase A #55

Merged
Lars merged 33 commits from develop into main 2026-06-11 21:26:54 +02:00
4 changed files with 145 additions and 550 deletions
Showing only changes of commit ee22b22970 - Show all commits

View File

@ -58,14 +58,16 @@ Zusätzlich optional:
- `slot_contents[]``{ major_step_index, primary, siblings[] }` - `slot_contents[]``{ major_step_index, primary, siblings[] }`
- `last_findings` — letzter `path_qa`-Snapshot - `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) - **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 ## Implementierungsreihenfolge

View File

@ -1,16 +1,13 @@
/** /**
* Progressionsgraphen: Sequenz-Editor (mehrere Nachfolger auf einmal), Ketten-Ansicht, * Progressionsgraphen eine Oberfläche: Graph wählen, Roadmap-Slots bearbeiten, KI & Speichern.
* Varianten als eigene Knoten-Endpunkte, Schwester-Kanten gesondert, Tabelle als Fallback.
*/ */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import SkillProfilePanel from './skills/SkillProfilePanel' import SkillProfilePanel from './skills/SkillProfilePanel'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub' import { getTenantClubDependencyKey } from '../utils/activeClub'
import ExercisePickerModal from './ExercisePickerModal' import ProgressionGraphEditor from './ProgressionGraphEditor'
import ExerciseProgressionPathBuilder from './ExerciseProgressionPathBuilder'
import ProgressionChainEditor from './ProgressionChainEditor'
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels' import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
const VIS_OPTIONS = [ const VIS_OPTIONS = [
@ -25,83 +22,12 @@ function edgeTypeLabel(type) {
return 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({ export default function ExerciseProgressionGraphPanel({
anchorExerciseId = null, anchorExerciseId = null,
anchorTitle = null, anchorTitle = null,
}) { }) {
const { user } = useAuth() const { user } = useAuth()
const location = useLocation()
const isSuperadmin = user?.role === 'superadmin' const isSuperadmin = user?.role === 'superadmin'
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
@ -123,14 +49,6 @@ export default function ExerciseProgressionGraphPanel({
const [metaDescription, setMetaDescription] = useState('') const [metaDescription, setMetaDescription] = useState('')
const [metaVisibility, setMetaVisibility] = useState('private') 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 [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId)
const [editingEdgeNotes, setEditingEdgeNotes] = useState(null) const [editingEdgeNotes, setEditingEdgeNotes] = useState(null)
const [notesDraft, setNotesDraft] = useState('') const [notesDraft, setNotesDraft] = useState('')
@ -138,6 +56,13 @@ export default function ExerciseProgressionGraphPanel({
const [skillProfileLoading, setSkillProfileLoading] = useState(false) const [skillProfileLoading, setSkillProfileLoading] = useState(false)
const [skillProfileError, setSkillProfileError] = useState('') const [skillProfileError, setSkillProfileError] = useState('')
useEffect(() => {
const gid = location.state?.progressionGraphId
if (gid != null && Number.isFinite(Number(gid))) {
setSelectedGraphId(Number(gid))
}
}, [location.state?.progressionGraphId])
useEffect(() => { useEffect(() => {
setSelectedGraphId(null) setSelectedGraphId(null)
}, [tenantClubDepKey]) }, [tenantClubDepKey])
@ -157,12 +82,6 @@ export default function ExerciseProgressionGraphPanel({
setEdges(Array.isArray(list) ? list : []) 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(() => { useEffect(() => {
let cancelled = false let cancelled = false
;(async () => { ;(async () => {
@ -234,30 +153,6 @@ export default function ExerciseProgressionGraphPanel({
} }
}, [selectedGraphId, graphs, refreshEdges]) }, [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(() => { const filteredEdges = useMemo(() => {
if (!filterAnchorOnly || anchorExerciseId == null) return edges if (!filterAnchorOnly || anchorExerciseId == null) return edges
return edges.filter( return edges.filter(
@ -266,24 +161,6 @@ export default function ExerciseProgressionGraphPanel({
) )
}, [edges, filterAnchorOnly, anchorExerciseId]) }, [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) => { const handleCreateGraph = async (e) => {
e.preventDefault() e.preventDefault()
const name = newGraphName.trim() const name = newGraphName.trim()
@ -322,7 +199,7 @@ export default function ExerciseProgressionGraphPanel({
visibility: metaVisibility, visibility: metaVisibility,
}) })
await refreshGraphs() await refreshGraphs()
alert('Graph gespeichert.') alert('Graph-Metadaten gespeichert.')
} catch (err) { } catch (err) {
alert(err.message || String(err)) alert(err.message || String(err))
} finally { } finally {
@ -332,7 +209,7 @@ export default function ExerciseProgressionGraphPanel({
const handleDeleteGraph = async () => { const handleDeleteGraph = async () => {
if (!selectedGraphId) return 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) setBusy(true)
try { try {
await api.deleteExerciseProgressionGraph(selectedGraphId) 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) => { const handleDeleteEdge = async (edgeId) => {
if (!selectedGraphId) return if (!selectedGraphId) return
if (!confirm('Kante löschen?')) return
setBusy(true) setBusy(true)
try { try {
await api.deleteExerciseProgressionEdge(selectedGraphId, edgeId) await api.deleteExerciseProgressionEdge(selectedGraphId, edgeId)
@ -420,58 +256,10 @@ export default function ExerciseProgressionGraphPanel({
} }
} }
const swapEnds = () => { const handleEditorSaved = useCallback(async () => {
const a = firstEp if (!selectedGraphId) return
setFirstEp(secondEp) await Promise.all([refreshEdges(selectedGraphId), refreshGraphs()])
setSecondEp(a) }, [selectedGraphId, refreshEdges, refreshGraphs])
}
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
return ( return (
<div className="exercise-progression-panel"> <div className="exercise-progression-panel">
@ -485,9 +273,8 @@ export default function ExerciseProgressionGraphPanel({
)} )}
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '16px' }}> <p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '16px' }}>
Ein Graph = ein linearer <strong>Primärpfad</strong> (Roadmap-Slots) plus optionale{' '} Ein Progressionsgraph = <strong>Roadmap mit Slots</strong> (Lernziel + Hauptübung + Schwestern).
<strong>Schwestern</strong>. Für die integrierte Bearbeitung (Slots, KI-Entwürfe, Graph-Bewertung){' '} Roadmap, KI-Match und Bewertung sind in einer Ansicht kein separater Wizard.
<strong>Slot-Editor öffnen</strong> unten weiterhin Kurzansicht und KI-Wizard.
</p> </p>
{loadErr && ( {loadErr && (
@ -525,18 +312,12 @@ export default function ExerciseProgressionGraphPanel({
<button type="button" className="btn" disabled={busy || !selectedGraphId} onClick={handleDeleteGraph}> <button type="button" className="btn" disabled={busy || !selectedGraphId} onClick={handleDeleteGraph}>
Graph löschen Graph löschen
</button> </button>
{selectedGraphId ? (
<Link
to={`/progression-graphs/${selectedGraphId}`}
className="btn btn-primary"
style={{ textDecoration: 'none' }}
>
Slot-Editor öffnen
</Link>
) : null}
</div> </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> <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 style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
<div className="form-row" style={{ flex: '2 1 200px', marginBottom: 0 }}> <div className="form-row" style={{ flex: '2 1 200px', marginBottom: 0 }}>
@ -576,107 +357,20 @@ export default function ExerciseProgressionGraphPanel({
checked={filterAnchorOnly} checked={filterAnchorOnly}
onChange={(e) => setFilterAnchorOnly(e.target.checked)} onChange={(e) => setFilterAnchorOnly(e.target.checked)}
/> />
Nur Reihen und Kanten, die diese Übung betreffen Technische Kantenliste: nur Kanten mit dieser Übung
</label> </label>
)} )}
{selectedGraphId && ( {selectedGraphId ? (
<> <>
<div <ProgressionGraphEditor
className="card" key={selectedGraphId}
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>
<ProgressionChainEditor
ref={chainEditorRef}
graphId={selectedGraphId} graphId={selectedGraphId}
chains={primaryChain ? [primaryChain] : []} embedded
busy={busy} onSaved={handleEditorSaved}
anchorExerciseId={anchorExerciseId}
anchorTitle={anchorTitle}
onRefresh={async () => refreshEdges(selectedGraphId)}
onPickExercise={setPickContext}
loadVariantsForExercise={loadVariantsForExercise}
singlePathMode
/> />
<ExerciseProgressionPathBuilder <details className="card" style={{ marginBottom: '12px', marginTop: '12px' }}>
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' }}>
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Graph-Einstellungen (Name, Sichtbarkeit)</summary> <summary style={{ cursor: 'pointer', fontWeight: 600 }}>Graph-Einstellungen (Name, Sichtbarkeit)</summary>
<div style={{ marginTop: '14px' }}> <div style={{ marginTop: '14px' }}>
<div className="form-row"> <div className="form-row">
@ -714,7 +408,7 @@ export default function ExerciseProgressionGraphPanel({
<SkillProfilePanel <SkillProfilePanel
title="Fähigkeiten entlang des Pfads" 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} profile={skillProfileData?.overall}
loading={skillProfileLoading} loading={skillProfileLoading}
error={skillProfileError} error={skillProfileError}
@ -722,98 +416,6 @@ export default function ExerciseProgressionGraphPanel({
artifactType="progression_graph" 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' }}> <details className="card" style={{ marginBottom: '12px' }}>
<summary style={{ cursor: 'pointer', fontWeight: 600 }}> <summary style={{ cursor: 'pointer', fontWeight: 600 }}>
Technische Kantenliste ({filteredEdges.length} Technische Kantenliste ({filteredEdges.length}
@ -839,19 +441,17 @@ export default function ExerciseProgressionGraphPanel({
{filteredEdges.map((row) => ( {filteredEdges.map((row) => (
<tr key={row.id} style={{ borderBottom: '1px solid var(--border)' }}> <tr key={row.id} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}> <td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
<Link to={`/exercises/${row.from_exercise_id}`}>{row.from_exercise_title || `#${row.from_exercise_id}`}</Link> <Link to={`/exercises/${row.from_exercise_id}`}>
{row.from_variant_name ? ( {row.from_exercise_title || `#${row.from_exercise_id}`}
<div style={{ color: 'var(--text3)', fontSize: '12px' }}>{row.from_variant_name}</div> </Link>
) : null}
</td> </td>
<td style={{ padding: '8px 6px', color: 'var(--text3)', verticalAlign: 'top' }}> <td style={{ padding: '8px 6px', color: 'var(--text3)', verticalAlign: 'top' }}>
{row.edge_type === 'sibling' ? '·' : '→'} {row.edge_type === 'sibling' ? '·' : '→'}
</td> </td>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}> <td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
<Link to={`/exercises/${row.to_exercise_id}`}>{row.to_exercise_title || `#${row.to_exercise_id}`}</Link> <Link to={`/exercises/${row.to_exercise_id}`}>
{row.to_variant_name ? ( {row.to_exercise_title || `#${row.to_exercise_id}`}
<div style={{ color: 'var(--text3)', fontSize: '12px' }}>{row.to_variant_name}</div> </Link>
) : null}
</td> </td>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>{edgeTypeLabel(row.edge_type)}</td> <td style={{ padding: '8px 6px', verticalAlign: 'top' }}>{edgeTypeLabel(row.edge_type)}</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top', maxWidth: '280px' }}> <td style={{ padding: '8px 6px', verticalAlign: 'top', maxWidth: '280px' }}>
@ -862,18 +462,19 @@ export default function ExerciseProgressionGraphPanel({
rows={2} rows={2}
value={notesDraft} value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)} onChange={(e) => setNotesDraft(e.target.value)}
style={{ marginBottom: '6px' }}
/> />
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => saveNotes(row.id)}> <button
type="button"
className="btn btn-primary"
style={{ marginTop: '6px', fontSize: '12px', padding: '4px 8px' }}
onClick={() => saveNotes(row.id)}
>
Speichern Speichern
</button> </button>
<button type="button" className="btn" disabled={busy} onClick={() => setEditingEdgeNotes(null)}>
Abbrechen
</button>
</> </>
) : ( ) : (
<> <>
<span style={{ whiteSpace: 'pre-wrap' }}>{row.notes || '—'}</span> {row.notes || '—'}
<button <button
type="button" type="button"
className="btn" className="btn"
@ -889,7 +490,13 @@ export default function ExerciseProgressionGraphPanel({
<button <button
type="button" type="button"
className="btn" className="btn"
style={{ fontSize: '12px', padding: '4px 8px', background: 'var(--danger)', color: '#fff', border: 'none' }} style={{
fontSize: '12px',
padding: '4px 8px',
background: 'var(--danger)',
color: '#fff',
border: 'none',
}}
onClick={() => handleDeleteEdge(row.id)} onClick={() => handleDeleteEdge(row.id)}
> >
Löschen Löschen
@ -904,14 +511,7 @@ export default function ExerciseProgressionGraphPanel({
</div> </div>
</details> </details>
</> </>
)} ) : null}
<ExercisePickerModal
open={pickerOpen}
onClose={() => setPickContext(null)}
onSelectExercise={applyPickedExercise}
exerciseKindAny={['simple']}
/>
</div> </div>
) )
} }

View File

@ -57,7 +57,7 @@ function resolveDefaultFocusAreaId(targetSummary, focusAreas) {
return focusAreas?.[0]?.id ? Number(focusAreas[0].id) : null 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 [graphMeta, setGraphMeta] = useState(null)
const [draft, setDraft] = useState(null) const [draft, setDraft] = useState(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@ -390,6 +390,7 @@ export default function ProgressionGraphEditor({ graphId }) {
try { try {
await saveProgressionGraphDraft(api, graphId, { ...draft, lastFindings: pathQa }) await saveProgressionGraphDraft(api, graphId, { ...draft, lastFindings: pathQa })
await loadGraph() await loadGraph()
if (typeof onSaved === 'function') await onSaved()
alert('Progressionsgraph gespeichert.') alert('Progressionsgraph gespeichert.')
} catch (e) { } catch (e) {
setActionErr(e.message || 'Speichern fehlgeschlagen') setActionErr(e.message || 'Speichern fehlgeschlagen')
@ -551,19 +552,21 @@ export default function ProgressionGraphEditor({ graphId }) {
return ( return (
<div> <div>
{!embedded ? (
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '8px' }}> <div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '8px' }}>
<div> <div>
<h2 style={{ margin: 0, fontSize: '1.15rem' }}> <h2 style={{ margin: 0, fontSize: '1.15rem' }}>
{graphMeta?.name || draft.graphName || `Graph #${graphId}`} {graphMeta?.name || draft.graphName || `Graph #${graphId}`}
</h2> </h2>
<p style={{ margin: '4px 0 0', fontSize: '12px', color: 'var(--text3)' }}> <p style={{ margin: '4px 0 0', fontSize: '12px', color: 'var(--text3)' }}>
Slots verschieben, ergänzen · KI-Angebote zuordnen · dynamisch erweitern (max. {SLOT_MAX}) Roadmap-Slots · KI-Match · Graph-Bewertung (max. {SLOT_MAX} Slots)
</p> </p>
</div> </div>
<Link to="/exercises" className="btn btn-secondary" style={{ fontSize: '12px' }}> <Link to="/exercises" className="btn btn-secondary" style={{ fontSize: '12px' }}>
Zur Übersicht Zur Übersicht
</Link> </Link>
</div> </div>
) : null}
{actionErr ? ( {actionErr ? (
<p className="form-error" style={{ marginTop: 0 }}> <p className="form-error" style={{ marginTop: 0 }}>

View File

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