/** * Progressionsgraphen — eine Oberfläche: Graph wählen, Roadmap-Slots bearbeiten, KI & Speichern. */ import React, { useCallback, useEffect, 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 { getTenantClubDependencyKey } from '../utils/activeClub' import ProgressionGraphEditor from './ProgressionGraphEditor' 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 || '—' } export default function ExerciseProgressionGraphPanel({ anchorExerciseId = null, anchorTitle = null, }) { 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 [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('') useEffect(() => { const gid = location.state?.progressionGraphId if (gid != null && Number.isFinite(Number(gid))) { setSelectedGraphId(Number(gid)) } }, [location.state?.progressionGraphId]) 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 : []) }, []) 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, }) 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-Metadaten gespeichert.') } catch (err) { alert(err.message || String(err)) } finally { setBusy(false) } } const handleDeleteGraph = async () => { if (!selectedGraphId) return if (!window.confirm('Diesen Progressionsgraph 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 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]) return (
Kontext:{' '} {anchorTitle?.trim() || `Übung #${anchorExerciseId}`} {' · '} Ansehen
)}Ein Progressionsgraph = Roadmap mit Slots (Lernziel + Hauptübung + Schwestern). Roadmap, KI-Match und Bewertung sind in einer Ansicht — kein separater Wizard.
{loadErr && ({loadErr}
Keine Kanten.
) : (| Von | Nach | Art | Notiz | ||
|---|---|---|---|---|---|
| {row.from_exercise_title || `#${row.from_exercise_id}`} | {row.edge_type === 'sibling' ? '·' : '→'} | {row.to_exercise_title || `#${row.to_exercise_id}`} | {edgeTypeLabel(row.edge_type)} | {editingEdgeNotes === row.id ? ( <> |