/** * Progressionsgraphen: Sequenz-Editor (mehrere Nachfolger auf einmal), Ketten-Ansicht, * Varianten als eigene Knoten-Endpunkte, Schwester-Kanten gesondert, Tabelle als Fallback. */ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import api from '../utils/api' import SkillProfilePanel from './skills/SkillProfilePanel' import { useAuth } from '../context/AuthContext' import { getTenantClubDependencyKey } from '../utils/activeClub' import ExercisePickerModal from './ExercisePickerModal' const VIS_OPTIONS = [ { value: 'private', label: 'Privat' }, { value: 'club', label: 'Verein' }, { value: 'official', label: 'Offiziell' }, ] function edgeTypeLabel(type) { if (type === 'next_exercise') return 'Nachfolger' if (type === 'sibling') return 'Schwester' 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 emptySeqStep() { return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [] } } function emptyEndpoint() { return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [] } } export default function ExerciseProgressionGraphPanel({ anchorExerciseId = null, anchorTitle = null, }) { const { user } = useAuth() const isSuperadmin = user?.role === 'superadmin' const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) const filteredGraphVisOptions = useMemo( () => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin), [isSuperadmin], ) const [graphs, setGraphs] = useState([]) const [selectedGraphId, setSelectedGraphId] = useState(null) const [edges, setEdges] = useState([]) const [busy, setBusy] = useState(false) const [loadErr, setLoadErr] = useState(null) const [newGraphName, setNewGraphName] = useState('') const [newGraphVisibility, setNewGraphVisibility] = useState('private') const [metaName, setMetaName] = useState('') const [metaDescription, setMetaDescription] = useState('') const [metaVisibility, setMetaVisibility] = useState('private') const [sequenceSteps, setSequenceSteps] = useState([emptySeqStep(), emptySeqStep()]) const [sequenceBulkNotes, setSequenceBulkNotes] = useState('') const [pickContext, setPickContext] = useState(null) const [relationKind, setRelationKind] = useState('progression') const [firstEp, setFirstEp] = useState(emptyEndpoint) const [secondEp, setSecondEp] = useState(emptyEndpoint) const [edgeNotes, setEdgeNotes] = useState('') const [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId) const [editingEdgeNotes, setEditingEdgeNotes] = useState(null) const [notesDraft, setNotesDraft] = useState('') const [uiTab, setUiTab] = useState('overview') const [skillProfileData, setSkillProfileData] = useState(null) const [skillProfileLoading, setSkillProfileLoading] = useState(false) const [skillProfileError, setSkillProfileError] = useState('') useEffect(() => { setSelectedGraphId(null) }, [tenantClubDepKey]) const refreshGraphs = useCallback(async () => { const list = await api.listExerciseProgressionGraphs() setGraphs(Array.isArray(list) ? list : []) return list }, []) const refreshEdges = useCallback(async (gid) => { if (!gid) { setEdges([]) return } const list = await api.listExerciseProgressionEdges(gid) setEdges(Array.isArray(list) ? list : []) }, []) const loadVariantsForExercise = useCallback(async (exerciseId) => { if (!exerciseId) return [] const ex = await api.getExercise(exerciseId) return Array.isArray(ex?.variants) ? ex.variants : [] }, []) useEffect(() => { let cancelled = false ;(async () => { setBusy(true) setLoadErr(null) try { await refreshGraphs() } catch (e) { if (!cancelled) setLoadErr(e.message || String(e)) } finally { if (!cancelled) setBusy(false) } })() return () => { cancelled = true } }, [refreshGraphs, tenantClubDepKey]) useEffect(() => { if (!selectedGraphId) { setSkillProfileData(null) return undefined } let cancelled = false ;(async () => { setSkillProfileLoading(true) setSkillProfileError('') try { const data = await api.getProgressionGraphSkillProfile(selectedGraphId) if (!cancelled) setSkillProfileData(data) } catch (e) { if (!cancelled) { setSkillProfileData(null) setSkillProfileError(e.message || 'Fähigkeiten-Profil nicht geladen') } } finally { if (!cancelled) setSkillProfileLoading(false) } })() return () => { cancelled = true } }, [selectedGraphId, edges.length]) useEffect(() => { if (!selectedGraphId) { setEdges([]) setMetaName('') setMetaDescription('') setMetaVisibility('private') return } const g = graphs.find((x) => x.id === selectedGraphId) if (g) { setMetaName(g.name || '') setMetaDescription(g.description || '') setMetaVisibility(g.visibility || 'private') } let cancelled = false ;(async () => { try { await refreshEdges(selectedGraphId) } catch (e) { if (!cancelled) alert(e.message || String(e)) } })() return () => { cancelled = true } }, [selectedGraphId, graphs, refreshEdges]) useEffect(() => { let cancelled = false ;(async () => { if (!firstEp.exerciseId) return const vars = await loadVariantsForExercise(firstEp.exerciseId) if (!cancelled) setFirstEp((p) => ({ ...p, variants: vars })) })() return () => { cancelled = true } }, [firstEp.exerciseId, loadVariantsForExercise]) useEffect(() => { let cancelled = false ;(async () => { if (!secondEp.exerciseId) return const vars = await loadVariantsForExercise(secondEp.exerciseId) if (!cancelled) setSecondEp((p) => ({ ...p, variants: vars })) })() return () => { cancelled = true } }, [secondEp.exerciseId, loadVariantsForExercise]) const filteredEdges = useMemo(() => { if (!filterAnchorOnly || anchorExerciseId == null) return edges return edges.filter( (e) => e.from_exercise_id === anchorExerciseId || e.to_exercise_id === 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 handleCreateGraph = async (e) => { e.preventDefault() const name = newGraphName.trim() if (!name) { alert('Name für den Graphen eingeben') return } setBusy(true) try { const created = await api.createExerciseProgressionGraph({ name, visibility: newGraphVisibility, }) setNewGraphName('') await refreshGraphs() if (created?.id != null) setSelectedGraphId(created.id) } catch (err) { alert(err.message || String(err)) } finally { setBusy(false) } } const handleSaveMeta = async () => { if (!selectedGraphId) return const name = metaName.trim() if (!name) { alert('Name ist Pflicht') return } setBusy(true) try { await api.updateExerciseProgressionGraph(selectedGraphId, { name, description: metaDescription.trim() || null, visibility: metaVisibility, }) await refreshGraphs() alert('Graph gespeichert.') } catch (err) { alert(err.message || String(err)) } finally { setBusy(false) } } const handleDeleteGraph = async () => { if (!selectedGraphId) return if (!confirm('Diesen Progressionsgraphen und alle Kanten wirklich löschen?')) return setBusy(true) try { await api.deleteExerciseProgressionGraph(selectedGraphId) setSelectedGraphId(null) await refreshGraphs() } catch (err) { alert(err.message || String(err)) } finally { setBusy(false) } } 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 () => { if (!selectedGraphId) { alert('Zuerst einen Graphen wählen.') return } if (!firstEp.exerciseId || !secondEp.exerciseId) { alert('Beide Enden müssen eine Übung haben.') return } if ( firstEp.exerciseId === secondEp.exerciseId && (firstEp.variantId == null || secondEp.variantId == null || firstEp.variantId === secondEp.variantId) ) { alert('Bei derselben Übung bitte zwei verschiedene Varianten wählen (oder unterschiedliche Übungen).') return } const edge_type = relationKind === 'sibling' ? 'sibling' : 'next_exercise' const notes = edgeNotes.trim() || null const body = { from_exercise_id: firstEp.exerciseId, to_exercise_id: secondEp.exerciseId, from_exercise_variant_id: firstEp.variantId || null, to_exercise_variant_id: secondEp.variantId || null, edge_type, notes, } setBusy(true) try { await api.createExerciseProgressionEdge(selectedGraphId, body) setEdgeNotes('') await refreshEdges(selectedGraphId) } catch (err) { alert(err.message || String(err)) } finally { setBusy(false) } } const handleDeleteEdge = async (edgeId) => { if (!selectedGraphId) return if (!confirm('Kante löschen?')) return setBusy(true) try { await api.deleteExerciseProgressionEdge(selectedGraphId, edgeId) await refreshEdges(selectedGraphId) } catch (err) { alert(err.message || String(err)) } finally { setBusy(false) } } const startEditNotes = (edge) => { setEditingEdgeNotes(edge.id) setNotesDraft(edge.notes || '') } const saveNotes = async (edgeId) => { if (!selectedGraphId) return setBusy(true) try { await api.updateExerciseProgressionEdge(selectedGraphId, edgeId, { notes: notesDraft.trim() || null, }) setEditingEdgeNotes(null) await refreshEdges(selectedGraphId) } catch (err) { alert(err.message || String(err)) } finally { setBusy(false) } } const swapEnds = () => { const a = firstEp setFirstEp(secondEp) setSecondEp(a) } const applyPickedExercise = async (ex) => { const title = ex.title || `Übung #${ex.id}` const variants = await loadVariantsForExercise(ex.id) if (pickContext?.kind === 'sequence') { patchSeqStep(pickContext.index, { exerciseId: ex.id, exerciseTitle: title, variantId: null, variants, }) setPickContext(null) return } if (pickContext?.kind === 'single') { const patch = { exerciseId: ex.id, exerciseTitle: title, variantId: null, variants, } if (pickContext.slot === 'first') setFirstEp(patch) else setSecondEp(patch) setPickContext(null) } } function formatNodeLine(n) { return ( <> {n.title} {n.variant_name ? ( {` · ${n.variant_name}`} ) : null} ) } const pickerOpen = pickContext != null return (
{anchorExerciseId != null && (

Kontext:{' '} {anchorTitle?.trim() || `Übung #${anchorExerciseId}`} {' · '} Ansehen

)}

Pro Graph mehrere Reihen und Alternativen: eine{' '} Sequenz legt automatisch alle Schritte Übung1 → Übung2 → … als Nachfolger-Kanten an. Optional pro Schritt eine Variante — sie wirkt wie ein eigener Knoten. Verzweigungen und Schwestern trennst du weiterhin mit Einzelkanten oder mehreren Sequenzen aus dem gleichen Knoten.

{loadErr && (

{loadErr}

)}

Graph auswählen

Neuen Graphen anlegen

setNewGraphName(e.target.value)} placeholder="z. B. Kumite-Einstieg Verein Nord" />
{selectedGraphId && (

Graph bearbeiten

setMetaName(e.target.value)} />