/** * Progressionsgraphen — Kachel-Übersicht (wie Übungen) + Editor-Detailansicht. */ import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState, } from 'react' import { Link, useLocation } from 'react-router-dom' import api from '../utils/api' import SkillProfilePanel from './skills/SkillProfilePanel' import { useAuth } from '../context/AuthContext' import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub' import ProgressionGraphEditor from './ProgressionGraphEditor' import ProgressionGraphListCard from './ProgressionGraphListCard' import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels' 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 || '—' } function ExerciseProgressionGraphPanel( { anchorExerciseId = null, anchorTitle = null, initialGraphId = null, }, ref, ) { const { user } = useAuth() const location = useLocation() 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 [createModalOpen, setCreateModalOpen] = useState(false) const [newGraphName, setNewGraphName] = useState('') const [newGraphVisibility, setNewGraphVisibility] = useState('private') const [metaName, setMetaName] = useState('') const [metaDescription, setMetaDescription] = useState('') const [metaVisibility, setMetaVisibility] = useState('private') const [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId) const [editingEdgeNotes, setEditingEdgeNotes] = useState(null) const [notesDraft, setNotesDraft] = useState('') const [skillProfileData, setSkillProfileData] = useState(null) const [skillProfileLoading, setSkillProfileLoading] = useState(false) const [skillProfileError, setSkillProfileError] = useState('') useImperativeHandle(ref, () => ({ openCreateDialog: () => { setNewGraphName('') setNewGraphVisibility('private') setCreateModalOpen(true) }, })) useEffect(() => { const gid = initialGraphId ?? location.state?.progressionGraphId if (gid != null && Number.isFinite(Number(gid))) { setSelectedGraphId(Number(gid)) } }, [location.state?.progressionGraphId, initialGraphId]) useEffect(() => { if (!initialGraphId && !location.state?.progressionGraphId) { setSelectedGraphId(null) } }, [tenantClubDepKey, initialGraphId, location.state?.progressionGraphId]) 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 : []) }, []) 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]) 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 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, }) setCreateModalOpen(false) setNewGraphName('') await refreshGraphs() if (created?.id != null) setSelectedGraphId(created.id) } catch (err) { alert(err.message || String(err)) } finally { setBusy(false) } } const handleDeleteGraph = async (graph) => { const gid = graph?.id ?? selectedGraphId if (!gid) return if (!window.confirm(`Progressionsgraph „${graph?.name || gid}" wirklich löschen?`)) return setBusy(true) try { await api.deleteExerciseProgressionGraph(gid) if (selectedGraphId === gid) setSelectedGraphId(null) await refreshGraphs() } catch (err) { alert(err.message || String(err)) } finally { setBusy(false) } } const resolvePromoteClubId = () => { const g = graphs.find((x) => x.id === selectedGraphId) if (g?.club_id != null) return Number(g.club_id) const memberships = activeClubMemberships(user?.clubs) const active = memberships.find((c) => c.is_active) || memberships[0] return active?.club_id != null ? Number(active.club_id) : null } const handleSaveMeta = async () => { if (!selectedGraphId) return const name = metaName.trim() if (!name) { alert('Name ist Pflicht') return } const prevGraph = graphs.find((x) => x.id === selectedGraphId) const prevVis = (prevGraph?.visibility || 'private').trim().toLowerCase() const nextVis = (metaVisibility || 'private').trim().toLowerCase() setBusy(true) try { if (prevVis === 'private' && nextVis === 'club') { const preview = await api.getProgressionGraphVisibilityPromotionCandidates( selectedGraphId, { targetVisibility: 'club' }, ) const privateExercises = Array.isArray(preview?.exercises) ? preview.exercises : [] if (privateExercises.length > 0) { const titles = privateExercises .slice(0, 8) .map((ex) => `• ${ex.title || `Übung #${ex.id}`}`) .join('\n') const more = privateExercises.length > 8 ? `\n… und ${privateExercises.length - 8} weitere` : '' const promote = window.confirm( `Der Graph wird auf „Verein“ gestellt. Im Graph sind noch ${privateExercises.length} private Übung(en):\n\n${titles}${more}\n\nDiese Übungen ebenfalls auf Vereins-Sichtbarkeit anheben?`, ) if (promote) { const clubId = resolvePromoteClubId() if (!clubId) { alert('Kein aktiver Verein — Übungen können nicht auf Verein promoted werden.') } else { const ids = privateExercises.map((ex) => ex.id).filter((id) => id != null) const res = await api.bulkPatchExercisesMetadata({ exercise_ids: ids, visibility: 'club', club_id: clubId, }) if ((res?.failed || []).length) { const f = res.failed[0] throw new Error(f?.detail || 'Übungs-Promotion fehlgeschlagen') } } } } } await api.updateExerciseProgressionGraph(selectedGraphId, { name, description: metaDescription.trim() || null, visibility: metaVisibility, }) await refreshGraphs() alert('Graph-Metadaten gespeichert.') } catch (err) { alert(err.message || String(err)) } finally { setBusy(false) } } const handleDeleteEdge = async (edgeId) => { if (!selectedGraphId) 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 handleEditorSaved = useCallback(async () => { if (!selectedGraphId) return await Promise.all([refreshEdges(selectedGraphId), refreshGraphs()]) }, [selectedGraphId, refreshEdges, refreshGraphs]) const selectedGraph = graphs.find((g) => g.id === selectedGraphId) if (loadErr) { return (

{loadErr}

) } if (!selectedGraphId) { return (
{anchorExerciseId != null && (

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

)}

Progressionsgraphen planen didaktische Entwicklungspfade mit Roadmap-Slots, KI-Match und Bewertung — analog zur Übungsbibliothek als eigene Sammlung.

{busy && graphs.length === 0 ? (

Lade Progressionsgraphen…

) : graphs.length === 0 ? (

Noch keine Progressionsgraphen — lege den ersten an.

) : (
{graphs.map((g) => ( setSelectedGraphId(row.id)} onDelete={handleDeleteGraph} /> ))}
)} {createModalOpen ? (
{ if (e.target === e.currentTarget && !busy) setCreateModalOpen(false) }} >
e.stopPropagation()} style={{ maxWidth: '480px' }} >

Neuer Progressionsgraph

setNewGraphName(e.target.value)} placeholder="z. B. Kumite-Einstieg Verein Nord" autoFocus />
) : null}
) } return (

{selectedGraph?.name || `Graph #${selectedGraphId}`}

{anchorExerciseId != null && (

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

)} {selectedGraphId && anchorExerciseId != null && ( )}
Graph-Einstellungen (Name, Sichtbarkeit)
setMetaName(e.target.value)} />