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
- 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.
671 lines
23 KiB
JavaScript
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)
|