600 lines
22 KiB
React
600 lines
22 KiB
React
|
|
/**
|
||
|
|
* Verwaltung mehrerer Progressionsgraphen (Übung → Übung) mit Kantentypen Nachfolger / Schwester.
|
||
|
|
* Varianten-Ketten bleiben unter „Übungsvarianten“ der jeweiligen Übung.
|
||
|
|
*/
|
||
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||
|
|
import { Link } from 'react-router-dom'
|
||
|
|
import api from '../utils/api'
|
||
|
|
import ExercisePickerModal from './ExercisePickerModal'
|
||
|
|
|
||
|
|
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 [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 [relationKind, setRelationKind] = useState('progression')
|
||
|
|
const [firstEx, setFirstEx] = useState(null)
|
||
|
|
const [secondEx, setSecondEx] = useState(null)
|
||
|
|
const [edgeNotes, setEdgeNotes] = useState('')
|
||
|
|
const [pickSlot, setPickSlot] = useState(null)
|
||
|
|
|
||
|
|
const [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId)
|
||
|
|
const [editingEdgeNotes, setEditingEdgeNotes] = useState(null)
|
||
|
|
const [notesDraft, setNotesDraft] = useState('')
|
||
|
|
|
||
|
|
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])
|
||
|
|
|
||
|
|
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 gespeichert.')
|
||
|
|
} catch (err) {
|
||
|
|
alert(err.message || String(err))
|
||
|
|
} finally {
|
||
|
|
setBusy(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleDeleteGraph = async () => {
|
||
|
|
if (!selectedGraphId) return
|
||
|
|
if (!confirm('Diesen Progressionsgraphen und alle Kanten 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 handleAddEdge = async () => {
|
||
|
|
if (!selectedGraphId) {
|
||
|
|
alert('Zuerst einen Graphen wählen oder anlegen.')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if (!firstEx?.id || !secondEx?.id) {
|
||
|
|
alert('Beide Übungen auswählen.')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if (firstEx.id === secondEx.id) {
|
||
|
|
alert('Es müssen zwei verschiedene Übungen sein.')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
const edge_type = relationKind === 'sibling' ? 'sibling' : 'next_exercise'
|
||
|
|
const notes = edgeNotes.trim() || null
|
||
|
|
const body =
|
||
|
|
relationKind === 'sibling'
|
||
|
|
? {
|
||
|
|
from_exercise_id: firstEx.id,
|
||
|
|
to_exercise_id: secondEx.id,
|
||
|
|
edge_type,
|
||
|
|
notes,
|
||
|
|
}
|
||
|
|
: {
|
||
|
|
from_exercise_id: firstEx.id,
|
||
|
|
to_exercise_id: secondEx.id,
|
||
|
|
edge_type: 'next_exercise',
|
||
|
|
notes,
|
||
|
|
}
|
||
|
|
setBusy(true)
|
||
|
|
try {
|
||
|
|
await api.createExerciseProgressionEdge(selectedGraphId, body)
|
||
|
|
setEdgeNotes('')
|
||
|
|
await refreshEdges(selectedGraphId)
|
||
|
|
} catch (err) {
|
||
|
|
alert(err.message || String(err))
|
||
|
|
} finally {
|
||
|
|
setBusy(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleDeleteEdge = async (edgeId) => {
|
||
|
|
if (!selectedGraphId) return
|
||
|
|
if (!confirm('Kante löschen?')) 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 swapEnds = () => {
|
||
|
|
const a = firstEx
|
||
|
|
setFirstEx(secondEx)
|
||
|
|
setSecondEx(a)
|
||
|
|
}
|
||
|
|
|
||
|
|
const onPicked = (ex) => {
|
||
|
|
const row = { id: ex.id, title: ex.title || `Übung #${ex.id}` }
|
||
|
|
if (pickSlot === 'first') setFirstEx(row)
|
||
|
|
else if (pickSlot === 'second') setSecondEx(row)
|
||
|
|
setPickSlot(null)
|
||
|
|
}
|
||
|
|
|
||
|
|
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' }}>
|
||
|
|
Graphen bilden einen gerichteten Wald aus Übungen: <strong>Nachfolger</strong> ist „zuerst A, dann B“;
|
||
|
|
<strong> Schwestern</strong> markieren Alternativen oder parallele Entwicklungsschritte. Fortschritt{' '}
|
||
|
|
<em>innerhalb einer Übung</em> (Varianten, Progressionsstufen) pflegst du unter „Übungsvarianten“.
|
||
|
|
</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)}
|
||
|
|
>
|
||
|
|
Kanten neu laden
|
||
|
|
</button>
|
||
|
|
<button type="button" className="btn" disabled={busy || !selectedGraphId} onClick={handleDeleteGraph}>
|
||
|
|
Graph löschen
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<form onSubmit={handleCreateGraph} style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid var(--border)' }}>
|
||
|
|
<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 }}>
|
||
|
|
<label className="form-label">Sichtbarkeit</label>
|
||
|
|
<select
|
||
|
|
className="form-input"
|
||
|
|
value={newGraphVisibility}
|
||
|
|
onChange={(e) => setNewGraphVisibility(e.target.value)}
|
||
|
|
>
|
||
|
|
{VIS_OPTIONS.map((o) => (
|
||
|
|
<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>
|
||
|
|
|
||
|
|
{selectedGraphId && (
|
||
|
|
<div className="card" style={{ marginBottom: '12px' }}>
|
||
|
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph bearbeiten</h3>
|
||
|
|
<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">Sichtbarkeit</label>
|
||
|
|
<select className="form-input" value={metaVisibility} onChange={(e) => setMetaVisibility(e.target.value)}>
|
||
|
|
{VIS_OPTIONS.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>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{selectedGraphId && (
|
||
|
|
<div className="card" style={{ marginBottom: '12px' }}>
|
||
|
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Kante hinzufügen</h3>
|
||
|
|
<div className="form-row">
|
||
|
|
<label className="form-label">Beziehung</label>
|
||
|
|
<select
|
||
|
|
className="form-input"
|
||
|
|
value={relationKind}
|
||
|
|
onChange={(e) => setRelationKind(e.target.value)}
|
||
|
|
>
|
||
|
|
<option value="progression">Nachfolger (zuerst Übung A, danach Übung B)</option>
|
||
|
|
<option value="sibling">Schwester (gleiche Entwicklungslage / Alternative)</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0 }}>
|
||
|
|
{relationKind === 'progression'
|
||
|
|
? '„Vorgänger von B ist A“ entspricht dieser Kante: A kommt vor B. Bei Bedarf „Reihenfolge tauschen“.'
|
||
|
|
: 'Eine gerichtete Schwester-Kante reicht; semantisch gilt die Paarbeziehung als bidirektional.'}
|
||
|
|
</p>
|
||
|
|
|
||
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '12px' }}>
|
||
|
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||
|
|
<label className="form-label">
|
||
|
|
{relationKind === 'progression' ? 'Übung A (kommt zuerst)' : 'Übung A'}
|
||
|
|
</label>
|
||
|
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||
|
|
<span style={{ flex: '1 1 160px', fontSize: '13px' }}>
|
||
|
|
{firstEx ? (
|
||
|
|
<>
|
||
|
|
<strong>{firstEx.title}</strong>
|
||
|
|
<span style={{ color: 'var(--text3)' }}> (#{firstEx.id})</span>
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
<span style={{ color: 'var(--text3)' }}>— nicht gewählt —</span>
|
||
|
|
)}
|
||
|
|
</span>
|
||
|
|
<button type="button" className="btn btn-secondary" onClick={() => setPickSlot('first')}>
|
||
|
|
Übung wählen…
|
||
|
|
</button>
|
||
|
|
{anchorExerciseId != null && (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="btn"
|
||
|
|
style={{ fontSize: '12px', padding: '4px 10px' }}
|
||
|
|
onClick={() =>
|
||
|
|
setFirstEx({
|
||
|
|
id: anchorExerciseId,
|
||
|
|
title: anchorTitle?.trim() || `Übung #${anchorExerciseId}`,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
>
|
||
|
|
Diese Übung
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||
|
|
<label className="form-label">
|
||
|
|
{relationKind === 'progression' ? 'Übung B (kommt danach)' : 'Übung B'}
|
||
|
|
</label>
|
||
|
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||
|
|
<span style={{ flex: '1 1 160px', fontSize: '13px' }}>
|
||
|
|
{secondEx ? (
|
||
|
|
<>
|
||
|
|
<strong>{secondEx.title}</strong>
|
||
|
|
<span style={{ color: 'var(--text3)' }}> (#{secondEx.id})</span>
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
<span style={{ color: 'var(--text3)' }}>— nicht gewählt —</span>
|
||
|
|
)}
|
||
|
|
</span>
|
||
|
|
<button type="button" className="btn btn-secondary" onClick={() => setPickSlot('second')}>
|
||
|
|
Übung wählen…
|
||
|
|
</button>
|
||
|
|
{anchorExerciseId != null && (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="btn"
|
||
|
|
style={{ fontSize: '12px', padding: '4px 10px' }}
|
||
|
|
onClick={() =>
|
||
|
|
setSecondEx({
|
||
|
|
id: anchorExerciseId,
|
||
|
|
title: anchorTitle?.trim() || `Übung #${anchorExerciseId}`,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
>
|
||
|
|
Diese Übung
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{relationKind === 'progression' && (
|
||
|
|
<button type="button" className="btn" style={{ marginTop: '8px' }} onClick={swapEnds}>
|
||
|
|
Reihenfolge tauschen (A ↔ B)
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className="form-row">
|
||
|
|
<label className="form-label">Entwicklungsziel / Notiz (optional)</label>
|
||
|
|
<textarea
|
||
|
|
className="form-input"
|
||
|
|
rows={2}
|
||
|
|
value={edgeNotes}
|
||
|
|
onChange={(e) => setEdgeNotes(e.target.value)}
|
||
|
|
placeholder="z. B. Fokus auf sicheren Abstand, dann dynamischer Eintritt …"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<button type="button" className="btn btn-primary" disabled={busy} onClick={handleAddEdge}>
|
||
|
|
Kante speichern
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{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)}
|
||
|
|
/>
|
||
|
|
Nur Kanten anzeigen, die diese Übung betreffen
|
||
|
|
</label>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{selectedGraphId && (
|
||
|
|
<div className="card">
|
||
|
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>
|
||
|
|
Kanten ({filteredEdges.length}
|
||
|
|
{edges.length !== filteredEdges.length ? ` von ${edges.length}` : ''})
|
||
|
|
</h3>
|
||
|
|
{filteredEdges.length === 0 ? (
|
||
|
|
<p style={{ color: 'var(--text2)', margin: 0 }}>Noch keine Kanten in diesem Graph.</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' }}>Entwicklungsziel</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)}
|
||
|
|
style={{ marginBottom: '6px' }}
|
||
|
|
/>
|
||
|
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => saveNotes(row.id)}>
|
||
|
|
Notiz speichern
|
||
|
|
</button>
|
||
|
|
<button type="button" className="btn" disabled={busy} onClick={() => setEditingEdgeNotes(null)}>
|
||
|
|
Abbrechen
|
||
|
|
</button>
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<span style={{ whiteSpace: 'pre-wrap' }}>{row.notes || '—'}</span>
|
||
|
|
<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>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<ExercisePickerModal open={pickSlot != null} onClose={() => setPickSlot(null)} onSelectExercise={onPicked} />
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|