shinkan-jinkendo/frontend/src/components/ExerciseProgressionGraphPanel.jsx
Lars b464047c3a
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m16s
Enhance Exercise Progression Graph Functionality and Visibility Logic
- Introduced new functions for handling exercise visibility in progression graphs, including `library_content_visibility_for_progression_graph_sql` to manage visibility based on graph context.
- Added `_supplemental_exercise_ids_from_body` to extract exercise IDs from request bodies, improving data handling in path suggestions.
- Implemented visibility promotion candidate retrieval in the API, allowing for the identification of private exercises that need visibility adjustments when promoting graph visibility.
- Enhanced existing SQL queries and retrieval functions to incorporate new visibility logic, ensuring accurate exercise visibility based on user roles and graph settings.
- Updated frontend components to support visibility promotion workflows, including user prompts for managing private exercises during graph visibility changes.
- Added tests to validate new visibility logic and ensure robustness in exercise retrieval and promotion processes.
2026-06-11 12:10:46 +02:00

671 lines
23 KiB
JavaScript

/**
* 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 (
<div className="card" style={{ borderColor: 'var(--danger)' }}>
<p style={{ margin: 0, color: 'var(--danger)' }}>{loadErr}</p>
</div>
)
}
if (!selectedGraphId) {
return (
<div className="exercise-progression-panel">
{anchorExerciseId != null && (
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '12px' }}>
Kontext:{' '}
<strong>{anchorTitle?.trim() || `Übung #${anchorExerciseId}`}</strong>
{' · '}
<Link to={`/exercises/${anchorExerciseId}`}>Ansehen</Link>
</p>
)}
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '16px' }}>
Progressionsgraphen planen didaktische Entwicklungspfade mit Roadmap-Slots, KI-Match und
Bewertung analog zur Übungsbibliothek als eigene Sammlung.
</p>
{busy && graphs.length === 0 ? (
<div className="empty-state card" style={{ padding: '2rem 1rem' }}>
<div className="spinner" />
<p className="muted" style={{ marginTop: '12px' }}>
Lade Progressionsgraphen
</p>
</div>
) : graphs.length === 0 ? (
<div className="card empty-state" style={{ padding: '2rem 1rem', textAlign: 'center' }}>
<p style={{ margin: '0 0 12px', color: 'var(--text2)' }}>
Noch keine Progressionsgraphen lege den ersten an.
</p>
<button
type="button"
className="btn btn-primary"
onClick={() => {
setCreateModalOpen(true)
}}
>
+ Neu
</button>
</div>
) : (
<div className="exercises-list-grid">
{graphs.map((g) => (
<ProgressionGraphListCard
key={g.id}
graph={g}
userId={user?.id}
disabled={busy}
onOpen={(row) => setSelectedGraphId(row.id)}
onDelete={handleDeleteGraph}
/>
))}
</div>
)}
{createModalOpen ? (
<div
className="admin-modal-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget && !busy) setCreateModalOpen(false)
}}
>
<div
className="admin-modal-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="new-progression-graph-title"
onClick={(e) => e.stopPropagation()}
style={{ maxWidth: '480px' }}
>
<div className="admin-modal-sheet__header">
<h3 id="new-progression-graph-title" className="admin-modal-sheet__title">
Neuer Progressionsgraph
</h3>
<button
type="button"
className="btn btn-secondary admin-modal-sheet__close"
disabled={busy}
onClick={() => setCreateModalOpen(false)}
>
Schließen
</button>
</div>
<form onSubmit={handleCreateGraph}>
<div className="admin-modal-sheet__body">
<div className="form-row">
<label className="form-label">Name *</label>
<input
className="form-input"
value={newGraphName}
onChange={(e) => setNewGraphName(e.target.value)}
placeholder="z. B. Kumite-Einstieg Verein Nord"
autoFocus
/>
</div>
<div className="form-row">
<label className="form-label">{EXERCISE_VISIBILITY_FIELD_LABEL}</label>
<select
className="form-input"
value={newGraphVisibility}
onChange={(e) => setNewGraphVisibility(e.target.value)}
>
{filteredGraphVisOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
</div>
<div className="admin-modal-sheet__footer">
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => setCreateModalOpen(false)}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={busy}>
Anlegen
</button>
</div>
</form>
</div>
</div>
) : null}
</div>
)
}
return (
<div className="exercise-progression-panel">
<div style={{ marginBottom: '12px', display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px' }}
disabled={busy}
onClick={() => setSelectedGraphId(null)}
>
Zur Übersicht
</button>
<h2 style={{ margin: 0, fontSize: '1.1rem', flex: '1 1 auto' }}>
{selectedGraph?.name || `Graph #${selectedGraphId}`}
</h2>
</div>
{anchorExerciseId != null && (
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '12px' }}>
Kontext:{' '}
<strong>{anchorTitle?.trim() || `Übung #${anchorExerciseId}`}</strong>
{' · '}
<Link to={`/exercises/${anchorExerciseId}`}>Ansehen</Link>
</p>
)}
{selectedGraphId && anchorExerciseId != null && (
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', fontSize: '13px' }}>
<input
type="checkbox"
checked={filterAnchorOnly}
onChange={(e) => setFilterAnchorOnly(e.target.checked)}
/>
Technische Kantenliste: nur Kanten mit dieser Übung
</label>
)}
<ProgressionGraphEditor
key={selectedGraphId}
graphId={selectedGraphId}
embedded
onSaved={handleEditorSaved}
/>
<details className="card" style={{ marginBottom: '12px', marginTop: '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>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={handleSaveMeta}>
Metadaten speichern
</button>
<button type="button" className="btn" disabled={busy} onClick={() => handleDeleteGraph(selectedGraph)}>
Graph löschen
</button>
</div>
</div>
</details>
<SkillProfilePanel
title="Fähigkeiten entlang des Pfads"
hint="Alle Übungen als Knoten im Graph."
profile={skillProfileData?.overall}
loading={skillProfileLoading}
error={skillProfileError}
defaultExpanded={false}
artifactType="progression_graph"
/>
<details className="card" style={{ marginBottom: '12px' }}>
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>
Technische Kantenliste ({filteredEdges.length}
{edges.length !== filteredEdges.length ? ` von ${edges.length}` : ''})
</summary>
<div style={{ marginTop: '14px' }}>
{filteredEdges.length === 0 ? (
<p style={{ color: 'var(--text2)', margin: 0 }}>Keine Kanten.</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)' }}>
<th style={{ padding: '8px 6px' }}>Von</th>
<th style={{ padding: '8px 6px' }} />
<th style={{ padding: '8px 6px' }}>Nach</th>
<th style={{ padding: '8px 6px' }}>Art</th>
<th style={{ padding: '8px 6px' }}>Notiz</th>
<th style={{ padding: '8px 6px' }} />
</tr>
</thead>
<tbody>
{filteredEdges.map((row) => (
<tr key={row.id} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
<Link to={`/exercises/${row.from_exercise_id}`}>
{row.from_exercise_title || `#${row.from_exercise_id}`}
</Link>
</td>
<td style={{ padding: '8px 6px', color: 'var(--text3)', verticalAlign: 'top' }}>
{row.edge_type === 'sibling' ? '·' : '→'}
</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
<Link to={`/exercises/${row.to_exercise_id}`}>
{row.to_exercise_title || `#${row.to_exercise_id}`}
</Link>
</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>{edgeTypeLabel(row.edge_type)}</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top', maxWidth: '280px' }}>
{editingEdgeNotes === row.id ? (
<>
<textarea
className="form-input"
rows={2}
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
/>
<button
type="button"
className="btn btn-primary"
style={{ marginTop: '6px', fontSize: '12px', padding: '4px 8px' }}
onClick={() => saveNotes(row.id)}
>
Speichern
</button>
</>
) : (
<>
{row.notes || '—'}
<button
type="button"
className="btn"
style={{ marginLeft: '8px', fontSize: '12px', padding: '4px 8px' }}
onClick={() => startEditNotes(row)}
>
Bearbeiten
</button>
</>
)}
</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
<button
type="button"
className="btn"
style={{
fontSize: '12px',
padding: '4px 8px',
background: 'var(--danger)',
color: '#fff',
border: 'none',
}}
onClick={() => handleDeleteEdge(row.id)}
>
Löschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</details>
</div>
)
}
export default forwardRef(ExerciseProgressionGraphPanel)