2026-04-30 11:47:50 +02:00
|
|
|
/**
|
2026-06-10 15:42:29 +02:00
|
|
|
* Progressionsgraphen — eine Oberfläche: Graph wählen, Roadmap-Slots bearbeiten, KI & Speichern.
|
2026-04-30 11:47:50 +02:00
|
|
|
*/
|
2026-06-10 15:42:29 +02:00
|
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|
|
|
|
import { Link, useLocation } from 'react-router-dom'
|
2026-04-30 11:47:50 +02:00
|
|
|
import api from '../utils/api'
|
2026-05-20 16:42:25 +02:00
|
|
|
import SkillProfilePanel from './skills/SkillProfilePanel'
|
2026-05-08 10:20:41 +02:00
|
|
|
import { useAuth } from '../context/AuthContext'
|
2026-05-13 16:34:38 +02:00
|
|
|
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
2026-06-10 15:42:29 +02:00
|
|
|
import ProgressionGraphEditor from './ProgressionGraphEditor'
|
2026-05-21 15:08:35 +02:00
|
|
|
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
|
2026-04-30 11:47:50 +02:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}) {
|
2026-05-08 10:20:41 +02:00
|
|
|
const { user } = useAuth()
|
2026-06-10 15:42:29 +02:00
|
|
|
const location = useLocation()
|
2026-05-08 10:20:41 +02:00
|
|
|
const isSuperadmin = user?.role === 'superadmin'
|
2026-05-13 16:34:38 +02:00
|
|
|
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
|
|
|
|
|
|
2026-05-08 10:20:41 +02:00
|
|
|
const filteredGraphVisOptions = useMemo(
|
|
|
|
|
() => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin),
|
|
|
|
|
[isSuperadmin],
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-30 11:47:50 +02:00
|
|
|
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('')
|
2026-05-20 16:42:25 +02:00
|
|
|
const [skillProfileData, setSkillProfileData] = useState(null)
|
|
|
|
|
const [skillProfileLoading, setSkillProfileLoading] = useState(false)
|
|
|
|
|
const [skillProfileError, setSkillProfileError] = useState('')
|
2026-04-30 11:47:50 +02:00
|
|
|
|
2026-06-10 15:42:29 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
const gid = location.state?.progressionGraphId
|
|
|
|
|
if (gid != null && Number.isFinite(Number(gid))) {
|
|
|
|
|
setSelectedGraphId(Number(gid))
|
|
|
|
|
}
|
|
|
|
|
}, [location.state?.progressionGraphId])
|
|
|
|
|
|
2026-05-13 16:34:38 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
setSelectedGraphId(null)
|
|
|
|
|
}, [tenantClubDepKey])
|
|
|
|
|
|
2026-04-30 11:47:50 +02:00
|
|
|
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
|
|
|
|
|
}
|
2026-05-13 16:34:38 +02:00
|
|
|
}, [refreshGraphs, tenantClubDepKey])
|
2026-04-30 11:47:50 +02:00
|
|
|
|
2026-05-20 16:42:25 +02:00
|
|
|
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])
|
|
|
|
|
|
2026-04-30 11:47:50 +02:00
|
|
|
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()
|
2026-06-10 15:42:29 +02:00
|
|
|
alert('Graph-Metadaten gespeichert.')
|
2026-04-30 11:47:50 +02:00
|
|
|
} catch (err) {
|
|
|
|
|
alert(err.message || String(err))
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleDeleteGraph = async () => {
|
|
|
|
|
if (!selectedGraphId) return
|
2026-06-10 15:42:29 +02:00
|
|
|
if (!window.confirm('Diesen Progressionsgraph wirklich löschen?')) return
|
2026-04-30 11:47:50 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 15:42:29 +02:00
|
|
|
const handleEditorSaved = useCallback(async () => {
|
|
|
|
|
if (!selectedGraphId) return
|
|
|
|
|
await Promise.all([refreshEdges(selectedGraphId), refreshGraphs()])
|
|
|
|
|
}, [selectedGraphId, refreshEdges, refreshGraphs])
|
2026-05-03 18:07:52 +02:00
|
|
|
|
2026-04-30 11:47:50 +02:00
|
|
|
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' }}>
|
2026-06-10 15:42:29 +02:00
|
|
|
Ein Progressionsgraph = <strong>Roadmap mit Slots</strong> (Lernziel + Hauptübung + Schwestern).
|
|
|
|
|
Roadmap, KI-Match und Bewertung sind in einer Ansicht — kein separater Wizard.
|
2026-04-30 11:47:50 +02:00
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
{loadErr && (
|
|
|
|
|
<div className="card" style={{ borderColor: 'var(--danger)', marginBottom: '12px' }}>
|
|
|
|
|
<p style={{ margin: 0, color: 'var(--danger)' }}>{loadErr}</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="card" style={{ marginBottom: '12px' }}>
|
|
|
|
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph auswählen</h3>
|
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
|
|
|
|
|
<div className="form-row" style={{ flex: '1 1 220px', marginBottom: 0 }}>
|
|
|
|
|
<label className="form-label">Aktiver Graph</label>
|
|
|
|
|
<select
|
|
|
|
|
className="form-input"
|
|
|
|
|
value={selectedGraphId ?? ''}
|
|
|
|
|
onChange={(e) => setSelectedGraphId(e.target.value ? parseInt(e.target.value, 10) : null)}
|
|
|
|
|
>
|
|
|
|
|
<option value="">— wählen —</option>
|
|
|
|
|
{graphs.map((g) => (
|
|
|
|
|
<option key={g.id} value={g.id}>
|
|
|
|
|
{g.name} ({g.edges_count ?? 0} Kanten)
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="btn"
|
|
|
|
|
disabled={busy || !selectedGraphId}
|
|
|
|
|
onClick={() => refreshEdges(selectedGraphId)}
|
|
|
|
|
>
|
2026-05-03 18:07:52 +02:00
|
|
|
Aktualisieren
|
2026-04-30 11:47:50 +02:00
|
|
|
</button>
|
|
|
|
|
<button type="button" className="btn" disabled={busy || !selectedGraphId} onClick={handleDeleteGraph}>
|
|
|
|
|
Graph löschen
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-10 15:42:29 +02:00
|
|
|
<form
|
|
|
|
|
onSubmit={handleCreateGraph}
|
|
|
|
|
style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid var(--border)' }}
|
|
|
|
|
>
|
2026-04-30 11:47:50 +02:00
|
|
|
<h4 style={{ margin: '0 0 8px', fontSize: '0.95rem' }}>Neuen Graphen anlegen</h4>
|
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
|
|
|
|
|
<div className="form-row" style={{ flex: '2 1 200px', marginBottom: 0 }}>
|
|
|
|
|
<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"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="form-row" style={{ flex: '1 1 140px', marginBottom: 0 }}>
|
2026-05-21 15:08:35 +02:00
|
|
|
<label className="form-label">{EXERCISE_VISIBILITY_FIELD_LABEL}</label>
|
2026-04-30 11:47:50 +02:00
|
|
|
<select
|
|
|
|
|
className="form-input"
|
|
|
|
|
value={newGraphVisibility}
|
|
|
|
|
onChange={(e) => setNewGraphVisibility(e.target.value)}
|
|
|
|
|
>
|
2026-05-08 10:20:41 +02:00
|
|
|
{filteredGraphVisOptions.map((o) => (
|
2026-04-30 11:47:50 +02:00
|
|
|
<option key={o.value} value={o.value}>
|
|
|
|
|
{o.label}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
|
|
|
|
Graph erstellen
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-10 07:55:51 +02:00
|
|
|
{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)}
|
|
|
|
|
/>
|
2026-06-10 15:42:29 +02:00
|
|
|
Technische Kantenliste: nur Kanten mit dieser Übung
|
2026-06-10 07:55:51 +02:00
|
|
|
</label>
|
2026-04-30 11:47:50 +02:00
|
|
|
)}
|
|
|
|
|
|
2026-06-10 15:42:29 +02:00
|
|
|
{selectedGraphId ? (
|
2026-05-03 18:07:52 +02:00
|
|
|
<>
|
2026-06-10 15:42:29 +02:00
|
|
|
<ProgressionGraphEditor
|
|
|
|
|
key={selectedGraphId}
|
|
|
|
|
graphId={selectedGraphId}
|
|
|
|
|
embedded
|
|
|
|
|
onSaved={handleEditorSaved}
|
|
|
|
|
/>
|
2026-05-03 18:07:52 +02:00
|
|
|
|
2026-06-10 15:42:29 +02:00
|
|
|
<details className="card" style={{ marginBottom: '12px', marginTop: '12px' }}>
|
2026-06-10 07:55:51 +02:00
|
|
|
<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>
|
|
|
|
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={handleSaveMeta}>
|
|
|
|
|
Metadaten speichern
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</details>
|
|
|
|
|
|
|
|
|
|
<SkillProfilePanel
|
|
|
|
|
title="Fähigkeiten entlang des Pfads"
|
2026-06-10 15:42:29 +02:00
|
|
|
hint="Alle Übungen als Knoten im Graph."
|
2026-06-10 07:55:51 +02:00
|
|
|
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' }}>
|
2026-06-10 15:42:29 +02:00
|
|
|
{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' }}>
|
2026-04-30 11:47:50 +02:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="btn"
|
2026-06-10 15:42:29 +02:00
|
|
|
style={{
|
|
|
|
|
fontSize: '12px',
|
|
|
|
|
padding: '4px 8px',
|
|
|
|
|
background: 'var(--danger)',
|
|
|
|
|
color: '#fff',
|
|
|
|
|
border: 'none',
|
|
|
|
|
}}
|
|
|
|
|
onClick={() => handleDeleteEdge(row.id)}
|
2026-04-30 11:47:50 +02:00
|
|
|
>
|
2026-06-10 15:42:29 +02:00
|
|
|
Löschen
|
2026-04-30 11:47:50 +02:00
|
|
|
</button>
|
2026-06-10 15:42:29 +02:00
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-06-10 07:55:51 +02:00
|
|
|
</div>
|
|
|
|
|
</details>
|
|
|
|
|
</>
|
2026-06-10 15:42:29 +02:00
|
|
|
) : null}
|
2026-04-30 11:47:50 +02:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|