Progression optimiert Phase A #55
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user