shinkan-jinkendo/frontend/src/components/ExerciseProgressionGraphPanel.jsx

600 lines
22 KiB
React
Raw Normal View History

/**
* 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>
)
}