Progression optimiert Phase A #55
|
|
@ -2,7 +2,7 @@
|
||||||
* Progressionsgraphen: Sequenz-Editor (mehrere Nachfolger auf einmal), Ketten-Ansicht,
|
* Progressionsgraphen: Sequenz-Editor (mehrere Nachfolger auf einmal), Ketten-Ansicht,
|
||||||
* Varianten als eigene Knoten-Endpunkte, Schwester-Kanten gesondert, Tabelle als Fallback.
|
* Varianten als eigene Knoten-Endpunkte, Schwester-Kanten gesondert, Tabelle als Fallback.
|
||||||
*/
|
*/
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import SkillProfilePanel from './skills/SkillProfilePanel'
|
import SkillProfilePanel from './skills/SkillProfilePanel'
|
||||||
|
|
@ -10,6 +10,7 @@ import { useAuth } from '../context/AuthContext'
|
||||||
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
||||||
import ExercisePickerModal from './ExercisePickerModal'
|
import ExercisePickerModal from './ExercisePickerModal'
|
||||||
import ExerciseProgressionPathBuilder from './ExerciseProgressionPathBuilder'
|
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 = [
|
||||||
|
|
@ -92,10 +93,6 @@ function maximalLinearChains(nextEdges) {
|
||||||
return chains
|
return chains
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptySeqStep() {
|
|
||||||
return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyEndpoint() {
|
function emptyEndpoint() {
|
||||||
return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [] }
|
return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [] }
|
||||||
}
|
}
|
||||||
|
|
@ -126,9 +123,8 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
const [metaDescription, setMetaDescription] = useState('')
|
const [metaDescription, setMetaDescription] = useState('')
|
||||||
const [metaVisibility, setMetaVisibility] = useState('private')
|
const [metaVisibility, setMetaVisibility] = useState('private')
|
||||||
|
|
||||||
const [sequenceSteps, setSequenceSteps] = useState([emptySeqStep(), emptySeqStep()])
|
|
||||||
const [sequenceBulkNotes, setSequenceBulkNotes] = useState('')
|
|
||||||
const [pickContext, setPickContext] = useState(null)
|
const [pickContext, setPickContext] = useState(null)
|
||||||
|
const chainEditorRef = useRef(null)
|
||||||
|
|
||||||
const [relationKind, setRelationKind] = useState('progression')
|
const [relationKind, setRelationKind] = useState('progression')
|
||||||
const [firstEp, setFirstEp] = useState(emptyEndpoint)
|
const [firstEp, setFirstEp] = useState(emptyEndpoint)
|
||||||
|
|
@ -138,7 +134,6 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
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('')
|
||||||
const [uiTab, setUiTab] = useState('overview')
|
|
||||||
const [skillProfileData, setSkillProfileData] = useState(null)
|
const [skillProfileData, setSkillProfileData] = useState(null)
|
||||||
const [skillProfileLoading, setSkillProfileLoading] = useState(false)
|
const [skillProfileLoading, setSkillProfileLoading] = useState(false)
|
||||||
const [skillProfileError, setSkillProfileError] = useState('')
|
const [skillProfileError, setSkillProfileError] = useState('')
|
||||||
|
|
@ -343,81 +338,6 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const patchSeqStep = (idx, patch) => {
|
|
||||||
setSequenceSteps((prev) => prev.map((s, i) => (i === idx ? { ...s, ...patch } : s)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const addSeqStep = () => setSequenceSteps((prev) => [...prev, emptySeqStep()])
|
|
||||||
|
|
||||||
const removeSeqStep = (idx) => {
|
|
||||||
setSequenceSteps((prev) => {
|
|
||||||
if (prev.length <= 2) return prev
|
|
||||||
return prev.filter((_, i) => i !== idx)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const moveSeqStep = (idx, dir) => {
|
|
||||||
setSequenceSteps((prev) => {
|
|
||||||
const j = idx + dir
|
|
||||||
if (j < 0 || j >= prev.length) return prev
|
|
||||||
const next = [...prev]
|
|
||||||
const t = next[idx]
|
|
||||||
next[idx] = next[j]
|
|
||||||
next[j] = t
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitSequence = async () => {
|
|
||||||
if (!selectedGraphId) {
|
|
||||||
alert('Zuerst einen Graphen wählen.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const steps = sequenceSteps.filter((s) => s.exerciseId != null)
|
|
||||||
if (steps.length < 2) {
|
|
||||||
alert('Mindestens zwei Schritte mit gewählter Übung.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const n = steps.length - 1
|
|
||||||
const noteRaw = sequenceBulkNotes.trim()
|
|
||||||
const segment_notes = Array.from({ length: n }, () => (noteRaw ? noteRaw : null))
|
|
||||||
|
|
||||||
setBusy(true)
|
|
||||||
try {
|
|
||||||
await api.createExerciseProgressionSequence(selectedGraphId, {
|
|
||||||
steps: steps.map((s) => ({
|
|
||||||
exercise_id: s.exerciseId,
|
|
||||||
variant_id: s.variantId || null,
|
|
||||||
})),
|
|
||||||
segment_notes,
|
|
||||||
})
|
|
||||||
setSequenceBulkNotes('')
|
|
||||||
await refreshEdges(selectedGraphId)
|
|
||||||
alert(`${n} Nachfolger-Kante(n) angelegt.`)
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message || String(err))
|
|
||||||
} finally {
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteChain = async (edgeObjs) => {
|
|
||||||
if (!selectedGraphId || !edgeObjs?.length) return
|
|
||||||
if (!confirm(`${edgeObjs.length} Kante(n) dieser Reihe löschen?`)) return
|
|
||||||
setBusy(true)
|
|
||||||
try {
|
|
||||||
await api.deleteExerciseProgressionEdgesBatch(
|
|
||||||
selectedGraphId,
|
|
||||||
edgeObjs.map((e) => e.id),
|
|
||||||
)
|
|
||||||
await refreshEdges(selectedGraphId)
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message || String(err))
|
|
||||||
} finally {
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddEdge = async () => {
|
const handleAddEdge = async () => {
|
||||||
if (!selectedGraphId) {
|
if (!selectedGraphId) {
|
||||||
alert('Zuerst einen Graphen wählen.')
|
alert('Zuerst einen Graphen wählen.')
|
||||||
|
|
@ -507,13 +427,16 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
const variantId =
|
const variantId =
|
||||||
ex.exercise_variant_id ?? ex.suggested_variant_id ?? null
|
ex.exercise_variant_id ?? ex.suggested_variant_id ?? null
|
||||||
|
|
||||||
if (pickContext?.kind === 'sequence') {
|
if (pickContext?.kind === 'chain') {
|
||||||
patchSeqStep(pickContext.index, {
|
await chainEditorRef.current?.applyExercise(
|
||||||
exerciseId: ex.id,
|
pickContext.draftKey,
|
||||||
exerciseTitle: title,
|
pickContext.nodeIndex,
|
||||||
variantId: variantId != null ? Number(variantId) : null,
|
{
|
||||||
|
...ex,
|
||||||
variants,
|
variants,
|
||||||
})
|
exercise_variant_id: variantId,
|
||||||
|
},
|
||||||
|
)
|
||||||
setPickContext(null)
|
setPickContext(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -555,10 +478,9 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '16px' }}>
|
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '16px' }}>
|
||||||
Pro Graph mehrere <strong>Reihen</strong> und <strong>Alternativen</strong>: eine{' '}
|
Ein Graph enthält eine oder mehrere <strong>Reihen</strong> (lineare Pfade Übung → Übung) sowie optional{' '}
|
||||||
<strong>Sequenz</strong> legt automatisch alle Schritte Übung1 → Übung2 → … als Nachfolger-Kanten an.
|
<strong>Schwester-Alternativen</strong>. Reihen bearbeiten Sie direkt in der Liste; mit dem KI-Planer legen
|
||||||
Optional pro Schritt eine <strong>Variante</strong> — sie wirkt wie ein eigener Knoten. Verzweigungen und
|
Sie neue Pfade in vier Schritten an.
|
||||||
Schwestern trennst du weiterhin mit Einzelkanten oder mehreren Sequenzen aus dem gleichen Knoten.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{loadErr && (
|
{loadErr && (
|
||||||
|
|
@ -631,236 +553,30 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedGraphId && (
|
{selectedGraphId && anchorExerciseId != null && (
|
||||||
<div className="card" style={{ marginBottom: '12px' }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', fontSize: '13px' }}>
|
||||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph bearbeiten</h3>
|
<input
|
||||||
<div className="form-row">
|
type="checkbox"
|
||||||
<label className="form-label">Name</label>
|
checked={filterAnchorOnly}
|
||||||
<input className="form-input" value={metaName} onChange={(e) => setMetaName(e.target.value)} />
|
onChange={(e) => setFilterAnchorOnly(e.target.checked)}
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Beschreibung</label>
|
|
||||||
<textarea
|
|
||||||
className="form-input"
|
|
||||||
rows={2}
|
|
||||||
value={metaDescription}
|
|
||||||
onChange={(e) => setMetaDescription(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
Nur Reihen und Kanten, die diese Übung betreffen
|
||||||
<div className="form-row">
|
</label>
|
||||||
<label className="form-label">{EXERCISE_VISIBILITY_FIELD_LABEL}</label>
|
|
||||||
<select className="form-input" value={metaVisibility} onChange={(e) => setMetaVisibility(e.target.value)}>
|
|
||||||
{filteredGraphVisOptions.map((o) => (
|
|
||||||
<option key={o.value} value={o.value}>
|
|
||||||
{o.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={handleSaveMeta}>
|
|
||||||
Metadaten speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedGraphId && (
|
{selectedGraphId && (
|
||||||
<div
|
|
||||||
role="tablist"
|
|
||||||
style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}
|
|
||||||
aria-label="Darstellung"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={uiTab === 'overview' ? 'btn btn-primary' : 'btn btn-secondary'}
|
|
||||||
onClick={() => setUiTab('overview')}
|
|
||||||
>
|
|
||||||
Übersicht & Sequenz
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={uiTab === 'table' ? 'btn btn-primary' : 'btn btn-secondary'}
|
|
||||||
onClick={() => setUiTab('table')}
|
|
||||||
>
|
|
||||||
Alle Kanten (Tabelle)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedGraphId && uiTab === 'overview' && (
|
|
||||||
<>
|
<>
|
||||||
<SkillProfilePanel
|
<ProgressionChainEditor
|
||||||
title="Fähigkeiten entlang des Pfads"
|
ref={chainEditorRef}
|
||||||
hint="Alle Übungen als Knoten im Graph (Standardgewicht pro Übung; Intensität und Stufen aus der Übungsverknüpfung)."
|
|
||||||
profile={skillProfileData?.overall}
|
|
||||||
loading={skillProfileLoading}
|
|
||||||
error={skillProfileError}
|
|
||||||
defaultExpanded
|
|
||||||
artifactType="progression_graph"
|
|
||||||
/>
|
|
||||||
<ExerciseProgressionPathBuilder
|
|
||||||
graphId={selectedGraphId}
|
graphId={selectedGraphId}
|
||||||
disabled={busy}
|
chains={flowChains}
|
||||||
onSaved={async () => {
|
busy={busy}
|
||||||
await refreshEdges(selectedGraphId)
|
anchorExerciseId={anchorExerciseId}
|
||||||
}}
|
anchorTitle={anchorTitle}
|
||||||
|
onRefresh={async () => refreshEdges(selectedGraphId)}
|
||||||
|
onPickExercise={setPickContext}
|
||||||
|
loadVariantsForExercise={loadVariantsForExercise}
|
||||||
/>
|
/>
|
||||||
<div className="card" style={{ marginBottom: '12px' }}>
|
|
||||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Sequenz / Reihe anlegen</h3>
|
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0 }}>
|
|
||||||
Reihenfolge von links nach rechts: jede Zeile ein Schritt. Es werden automatisch alle Nachfolger-Kanten
|
|
||||||
zwischen benachbarten Schritten erzeugt (ein API-Vorgang).
|
|
||||||
</p>
|
|
||||||
{sequenceSteps.map((step, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
|
||||||
gap: '10px',
|
|
||||||
alignItems: 'end',
|
|
||||||
marginBottom: '12px',
|
|
||||||
paddingBottom: '12px',
|
|
||||||
borderBottom: idx < sequenceSteps.length - 1 ? '1px dashed var(--border)' : 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
|
||||||
<label className="form-label">Schritt {idx + 1}</label>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
|
|
||||||
<span style={{ fontSize: '13px', flex: '1 1 140px' }}>
|
|
||||||
{step.exerciseId ? (
|
|
||||||
<>
|
|
||||||
<strong>{step.exerciseTitle}</strong>
|
|
||||||
<span style={{ color: 'var(--text3)' }}> (#{step.exerciseId})</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: 'var(--text3)' }}>Übung wählen</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
style={{ fontSize: '12px', padding: '4px 10px' }}
|
|
||||||
onClick={() => setPickContext({ kind: 'sequence', index: idx })}
|
|
||||||
>
|
|
||||||
Übung…
|
|
||||||
</button>
|
|
||||||
{anchorExerciseId != null && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn"
|
|
||||||
style={{ fontSize: '12px', padding: '4px 10px' }}
|
|
||||||
onClick={async () => {
|
|
||||||
const variants = await loadVariantsForExercise(anchorExerciseId)
|
|
||||||
patchSeqStep(idx, {
|
|
||||||
exerciseId: anchorExerciseId,
|
|
||||||
exerciseTitle: anchorTitle?.trim() || `Übung #${anchorExerciseId}`,
|
|
||||||
variantId: null,
|
|
||||||
variants,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Diese Übung
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
|
||||||
<label className="form-label">Variante (optional)</label>
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
disabled={!step.exerciseId}
|
|
||||||
value={step.variantId ?? ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
patchSeqStep(idx, {
|
|
||||||
variantId: e.target.value === '' ? null : parseInt(e.target.value, 10),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">Gesamte Übung (ohne feste Variante)</option>
|
|
||||||
{(step.variants || []).map((v) => (
|
|
||||||
<option key={v.id} value={v.id}>
|
|
||||||
{v.variant_name || `Variante #${v.id}`}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
|
||||||
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => moveSeqStep(idx, -1)}>
|
|
||||||
↑
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => moveSeqStep(idx, 1)}>
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => removeSeqStep(idx)}>
|
|
||||||
Zeile entfernen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button type="button" className="btn btn-secondary" style={{ marginBottom: '12px' }} onClick={addSeqStep}>
|
|
||||||
+ Schritt
|
|
||||||
</button>
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Entwicklungsziel für alle neuen Zwischen-Kanten (optional)</label>
|
|
||||||
<textarea
|
|
||||||
className="form-input"
|
|
||||||
rows={2}
|
|
||||||
value={sequenceBulkNotes}
|
|
||||||
onChange={(e) => setSequenceBulkNotes(e.target.value)}
|
|
||||||
placeholder="wird auf jedes neue Segment der Sequenz kopiert"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={submitSequence}>
|
|
||||||
Sequenz als Nachfolger-Kanten speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card" style={{ marginBottom: '12px' }}>
|
|
||||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Nachfolger als Reihen (Lesart)</h3>
|
|
||||||
{flowChains.length === 0 ? (
|
|
||||||
<p style={{ color: 'var(--text2)', margin: 0 }}>Keine Nachfolger-Kanten im aktuellen Filter.</p>
|
|
||||||
) : (
|
|
||||||
flowChains.map((chain, ci) => (
|
|
||||||
<div
|
|
||||||
key={ci}
|
|
||||||
style={{
|
|
||||||
marginBottom: '14px',
|
|
||||||
padding: '12px',
|
|
||||||
borderRadius: '10px',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
background: 'var(--surface2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '6px 10px',
|
|
||||||
fontSize: '13px',
|
|
||||||
lineHeight: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{chain.nodes.map((n, ni) => (
|
|
||||||
<React.Fragment key={`${n.exercise_id}-${n.variant_id}-${ni}`}>
|
|
||||||
{ni > 0 && (
|
|
||||||
<span style={{ color: 'var(--accent)', fontWeight: 700 }} aria-hidden>
|
|
||||||
→
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span style={{ fontWeight: 600 }}>{formatNodeLine(n)}</span>
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: '10px', display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
|
||||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => deleteChain(chain.edges)}>
|
|
||||||
Diese Reihe löschen ({chain.edges.length} Kante(n))
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card" style={{ marginBottom: '12px' }}>
|
<div className="card" style={{ marginBottom: '12px' }}>
|
||||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Schwestern & Alternativen</h3>
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Schwestern & Alternativen</h3>
|
||||||
|
|
@ -919,6 +635,65 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<details className="card" style={{ marginBottom: '12px' }}>
|
||||||
|
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Graph-Einstellungen (Name, Sichtbarkeit)</summary>
|
||||||
|
<div style={{ marginTop: '14px' }}>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Name</label>
|
||||||
|
<input className="form-input" value={metaName} onChange={(e) => setMetaName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Beschreibung</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
rows={2}
|
||||||
|
value={metaDescription}
|
||||||
|
onChange={(e) => setMetaDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">{EXERCISE_VISIBILITY_FIELD_LABEL}</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={metaVisibility}
|
||||||
|
onChange={(e) => setMetaVisibility(e.target.value)}
|
||||||
|
>
|
||||||
|
{filteredGraphVisOptions.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={handleSaveMeta}>
|
||||||
|
Metadaten speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details className="card" style={{ marginBottom: '12px' }}>
|
||||||
|
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>KI: Neuen Pfad planen (4 Schritte)</summary>
|
||||||
|
<div style={{ marginTop: '14px' }}>
|
||||||
|
<ExerciseProgressionPathBuilder
|
||||||
|
graphId={selectedGraphId}
|
||||||
|
disabled={busy}
|
||||||
|
onSaved={async () => {
|
||||||
|
await refreshEdges(selectedGraphId)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<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)."
|
||||||
|
profile={skillProfileData?.overall}
|
||||||
|
loading={skillProfileLoading}
|
||||||
|
error={skillProfileError}
|
||||||
|
defaultExpanded={false}
|
||||||
|
artifactType="progression_graph"
|
||||||
|
/>
|
||||||
|
|
||||||
<details className="card" style={{ marginBottom: '12px' }}>
|
<details className="card" style={{ marginBottom: '12px' }}>
|
||||||
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Einzelkante (Nachfolger oder Schwester)</summary>
|
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Einzelkante (Nachfolger oder Schwester)</summary>
|
||||||
<div style={{ marginTop: '14px' }}>
|
<div style={{ marginTop: '14px' }}>
|
||||||
|
|
@ -1010,26 +785,13 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedGraphId && anchorExerciseId != null && (
|
<details className="card" style={{ marginBottom: '12px' }}>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', fontSize: '13px' }}>
|
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>
|
||||||
<input
|
Technische Kantenliste ({filteredEdges.length}
|
||||||
type="checkbox"
|
|
||||||
checked={filterAnchorOnly}
|
|
||||||
onChange={(e) => setFilterAnchorOnly(e.target.checked)}
|
|
||||||
/>
|
|
||||||
Nur Kanten, die diese Übung betreffen (Übersicht & Tabelle)
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedGraphId && uiTab === 'table' && (
|
|
||||||
<div className="card">
|
|
||||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>
|
|
||||||
Alle Kanten ({filteredEdges.length}
|
|
||||||
{edges.length !== filteredEdges.length ? ` von ${edges.length}` : ''})
|
{edges.length !== filteredEdges.length ? ` von ${edges.length}` : ''})
|
||||||
</h3>
|
</summary>
|
||||||
|
<div style={{ marginTop: '14px' }}>
|
||||||
{filteredEdges.length === 0 ? (
|
{filteredEdges.length === 0 ? (
|
||||||
<p style={{ color: 'var(--text2)', margin: 0 }}>Keine Kanten.</p>
|
<p style={{ color: 'var(--text2)', margin: 0 }}>Keine Kanten.</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -1112,6 +874,8 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ExercisePickerModal
|
<ExercisePickerModal
|
||||||
|
|
|
||||||
|
|
@ -443,6 +443,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
const [gapPrepError, setGapPrepError] = useState('')
|
const [gapPrepError, setGapPrepError] = useState('')
|
||||||
const [loadedPlanningHint, setLoadedPlanningHint] = useState(false)
|
const [loadedPlanningHint, setLoadedPlanningHint] = useState(false)
|
||||||
const [wizardStep, setWizardStep] = useState(1)
|
const [wizardStep, setWizardStep] = useState(1)
|
||||||
|
const [pathInsertNotice, setPathInsertNotice] = useState('')
|
||||||
|
|
||||||
const maxReachableStep = useMemo(
|
const maxReachableStep = useMemo(
|
||||||
() => computeMaxReachableStep(editableMajorSteps, pathSteps),
|
() => computeMaxReachableStep(editableMajorSteps, pathSteps),
|
||||||
|
|
@ -498,6 +499,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
setStartTargetAnalyzed(false)
|
setStartTargetAnalyzed(false)
|
||||||
setError('')
|
setError('')
|
||||||
setWizardStep(1)
|
setWizardStep(1)
|
||||||
|
setPathInsertNotice('')
|
||||||
|
|
||||||
api
|
api
|
||||||
.getExerciseProgressionGraph(Number(graphId))
|
.getExerciseProgressionGraph(Number(graphId))
|
||||||
|
|
@ -883,6 +885,11 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
insertExerciseFromOffer(created, activeOffer)
|
insertExerciseFromOffer(created, activeOffer)
|
||||||
setQuickCreateDraft(null)
|
setQuickCreateDraft(null)
|
||||||
setActiveOffer(null)
|
setActiveOffer(null)
|
||||||
|
const title = (created.title || quickTitle || 'Übung').trim()
|
||||||
|
setPathInsertNotice(
|
||||||
|
`„${title}" wurde in den KI-Pfad eingefügt. Speichern Sie jetzt mit «Pfad in Graph speichern» — die Reihe erscheint dann oben unter «Reihen im Graph».`,
|
||||||
|
)
|
||||||
|
setWizardStep(4)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
const msg = e?.message || String(e)
|
const msg = e?.message || String(e)
|
||||||
|
|
@ -1137,6 +1144,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
setEditableMajorSteps([])
|
setEditableMajorSteps([])
|
||||||
setRoadmapDirty(false)
|
setRoadmapDirty(false)
|
||||||
setWizardStep(1)
|
setWizardStep(1)
|
||||||
|
setPathInsertNotice('')
|
||||||
if (typeof onSaved === 'function') await onSaved()
|
if (typeof onSaved === 'function') await onSaved()
|
||||||
const msg =
|
const msg =
|
||||||
skippedAi > 0
|
skippedAi > 0
|
||||||
|
|
@ -1902,6 +1910,23 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '14px',
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid color-mix(in srgb, var(--accent) 45%, var(--border))',
|
||||||
|
background: 'color-mix(in srgb, var(--accent) 10%, var(--surface))',
|
||||||
|
fontSize: '12px',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: 'var(--accent-dark)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>Wichtig:</strong> Der KI-Pfad ist noch nicht im Graph gespeichert. KI-Übungen werden erst nach
|
||||||
|
«Pfad in Graph speichern» als neue Reihe oben sichtbar.
|
||||||
|
{pathInsertNotice ? <p style={{ margin: '8px 0 0' }}>{pathInsertNotice}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{gapFillOffers.length > 0 ? (
|
{gapFillOffers.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -1994,20 +2019,33 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<details style={{ marginBottom: '12px', fontSize: '12px' }}>
|
<div
|
||||||
<summary style={{ cursor: 'pointer', fontWeight: 600, color: 'var(--accent-dark)' }}>
|
style={{
|
||||||
Pfad-Übersicht ({pathSteps.length} Schritte)
|
marginBottom: '12px',
|
||||||
</summary>
|
padding: '10px 12px',
|
||||||
<ol style={{ margin: '10px 0 0', paddingLeft: '20px', lineHeight: 1.5 }}>
|
borderRadius: '8px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ display: 'block', marginBottom: '8px' }}>
|
||||||
|
Pfad vor dem Speichern ({pathSteps.length} Schritte)
|
||||||
|
</strong>
|
||||||
|
<ol style={{ margin: 0, paddingLeft: '20px', lineHeight: 1.55 }}>
|
||||||
{pathSteps.map((step, idx) => (
|
{pathSteps.map((step, idx) => (
|
||||||
<li key={`summary-${idx}`} style={{ marginBottom: '4px' }}>
|
<li key={`summary-${idx}`} style={{ marginBottom: '4px' }}>
|
||||||
<strong>{step.exerciseTitle}</strong>
|
<strong>{step.exerciseTitle}</strong>
|
||||||
{step.roadmapPhase ? ` (${step.roadmapPhase})` : ''}
|
{step.exerciseId ? (
|
||||||
{step.isAiProposal ? ' — KI-Vorschlag, noch anlegen' : ''}
|
<span style={{ color: 'var(--text3)' }}> (#{step.exerciseId})</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--danger)' }}> — noch nicht angelegt</span>
|
||||||
|
)}
|
||||||
|
{step.roadmapPhase ? ` · ${step.roadmapPhase}` : ''}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</details>
|
</div>
|
||||||
|
|
||||||
<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>
|
||||||
|
|
@ -2047,6 +2085,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
setPathQa(null)
|
setPathQa(null)
|
||||||
setGapFillOffers([])
|
setGapFillOffers([])
|
||||||
setPathSkillExpectations(null)
|
setPathSkillExpectations(null)
|
||||||
|
setPathInsertNotice('')
|
||||||
setWizardStep(editableMajorSteps.length >= 2 ? 2 : 1)
|
setWizardStep(editableMajorSteps.length >= 2 ? 2 : 1)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
508
frontend/src/components/ProgressionChainEditor.jsx
Normal file
508
frontend/src/components/ProgressionChainEditor.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<Link to={`/exercises/${node.exerciseId}`}>{node.exerciseTitle}</Link>
|
||||||
|
{node.variantName ? (
|
||||||
|
<span style={{ color: 'var(--text3)', fontWeight: 400 }}>{` · ${node.variantName}`}</span>
|
||||||
|
) : 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 (
|
||||||
|
<div className="card" style={{ marginBottom: '12px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '10px',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '1rem' }}>Reihen im Graph</h3>
|
||||||
|
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '4px 0 0', lineHeight: 1.45 }}>
|
||||||
|
Jede Reihe ist ein linearer Pfad: Schritt 1 → 2 → … Reihenfolge ändern, Übungen tauschen oder
|
||||||
|
dazwischen einfügen, dann speichern.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-primary" disabled={busy} onClick={addNewChain}>
|
||||||
|
+ Neue Reihe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{drafts.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--text2)', margin: 0, fontSize: '13px' }}>
|
||||||
|
Noch keine Reihen in diesem Graph. Legen Sie eine neue Reihe an oder nutzen Sie den KI-Planer unten.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
drafts.map((draft, chainIdx) => (
|
||||||
|
<div
|
||||||
|
key={draft.key}
|
||||||
|
style={{
|
||||||
|
marginBottom: '16px',
|
||||||
|
padding: '14px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
border: `1px solid ${
|
||||||
|
draft.dirty
|
||||||
|
? 'color-mix(in srgb, var(--accent) 50%, var(--border))'
|
||||||
|
: 'var(--border)'
|
||||||
|
}`,
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '8px',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ fontSize: '13px' }}>Reihe {chainIdx + 1}</strong>
|
||||||
|
{draft.dirty ? (
|
||||||
|
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
|
||||||
|
Ungespeichert
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{draft.isNew ? (
|
||||||
|
<span className="exercise-tag">Neu</span>
|
||||||
|
) : (
|
||||||
|
<span className="exercise-tag">{draft.nodes.length} Schritte</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0' }}>
|
||||||
|
{draft.nodes.map((node, idx) => (
|
||||||
|
<React.Fragment key={`${draft.key}-node-${idx}`}>
|
||||||
|
{idx > 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '4px 0 4px 12px',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
↓ Nachfolger
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||||
|
gap: '10px',
|
||||||
|
alignItems: 'end',
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label">Schritt {idx + 1}</label>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '8px',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '13px', flex: '1 1 140px' }}>
|
||||||
|
{formatNodeLabel(node)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ fontSize: '12px', padding: '4px 10px' }}
|
||||||
|
disabled={busy || savingKey === draft.key}
|
||||||
|
onClick={() =>
|
||||||
|
onPickExercise({ kind: 'chain', draftKey: draft.key, nodeIndex: idx })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{node.exerciseId ? 'Tauschen…' : 'Übung…'}
|
||||||
|
</button>
|
||||||
|
{anchorExerciseId != null ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
style={{ fontSize: '12px', padding: '4px 10px' }}
|
||||||
|
disabled={busy || savingKey === draft.key}
|
||||||
|
onClick={async () => {
|
||||||
|
const variants = await loadVariantsForExercise(anchorExerciseId)
|
||||||
|
await applyExerciseToNode(draft.key, idx, {
|
||||||
|
id: anchorExerciseId,
|
||||||
|
title: anchorTitle?.trim() || `Übung #${anchorExerciseId}`,
|
||||||
|
variants,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Kontext-Übung
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label">Variante</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
disabled={!node.exerciseId || busy || savingKey === draft.key}
|
||||||
|
value={node.variantId ?? ''}
|
||||||
|
onFocus={() => ensureVariantsLoaded(draft.key, idx)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setVariant(
|
||||||
|
draft.key,
|
||||||
|
idx,
|
||||||
|
e.target.value === '' ? null : parseInt(e.target.value, 10),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Gesamte Übung</option>
|
||||||
|
{(node.variants || []).map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.variant_name || `Variante #${v.id}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
style={{ fontSize: '12px', padding: '4px 8px' }}
|
||||||
|
disabled={busy || savingKey === draft.key || idx === 0}
|
||||||
|
onClick={() => moveNode(draft.key, idx, -1)}
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
style={{ fontSize: '12px', padding: '4px 8px' }}
|
||||||
|
disabled={busy || savingKey === draft.key || idx >= draft.nodes.length - 1}
|
||||||
|
onClick={() => moveNode(draft.key, idx, 1)}
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
style={{ fontSize: '12px', padding: '4px 8px' }}
|
||||||
|
disabled={busy || savingKey === draft.key}
|
||||||
|
onClick={() => insertNodeAfter(draft.key, idx)}
|
||||||
|
>
|
||||||
|
+ Einfügen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
style={{ fontSize: '12px', padding: '4px 8px' }}
|
||||||
|
disabled={busy || savingKey === draft.key || draft.nodes.length <= 2}
|
||||||
|
onClick={() => removeNode(draft.key, idx)}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '12px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={busy || savingKey === draft.key || !draft.dirty}
|
||||||
|
onClick={() => saveDraft(draft)}
|
||||||
|
>
|
||||||
|
{savingKey === draft.key ? 'Speichern …' : 'Reihe speichern'}
|
||||||
|
</button>
|
||||||
|
{draft.dirty ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={busy || savingKey === draft.key}
|
||||||
|
onClick={() => discardDraft(draft.key)}
|
||||||
|
>
|
||||||
|
Verwerfen
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
background: 'var(--danger)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
}}
|
||||||
|
disabled={busy || savingKey === draft.key}
|
||||||
|
onClick={() => deleteChain(draft)}
|
||||||
|
>
|
||||||
|
Reihe löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default ProgressionChainEditor
|
||||||
Loading…
Reference in New Issue
Block a user