All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m17s
- Introduced new helper functions for managing artifact type corpus, improving code organization and readability. - Updated the `compute_club_corpus_reference` function to utilize the new corpus handling methods, enhancing clarity and maintainability. - Refactored skill profile functions to leverage the new corpus structure, ensuring consistent data retrieval across different artifact types. - Improved the handling of visibility clauses for library content, streamlining database queries for skill profiles. - Enhanced the batch skill profile summary function to aggregate reference data by artifact type, improving performance and accuracy.
1113 lines
41 KiB
JavaScript
1113 lines
41 KiB
JavaScript
/**
|
|
* Progressionsgraphen: Sequenz-Editor (mehrere Nachfolger auf einmal), Ketten-Ansicht,
|
|
* Varianten als eigene Knoten-Endpunkte, Schwester-Kanten gesondert, Tabelle als Fallback.
|
|
*/
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import api from '../utils/api'
|
|
import SkillProfilePanel from './skills/SkillProfilePanel'
|
|
import { useAuth } from '../context/AuthContext'
|
|
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
|
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 || '—'
|
|
}
|
|
|
|
/** Maximale lineare Segmente aus next_exercise-Kanten (jedes Segment deckt zusammenhängende „Pfade“ ab). */
|
|
function maximalLinearChains(nextEdges) {
|
|
if (!nextEdges?.length) return []
|
|
const outMap = new Map()
|
|
const inMap = new Map()
|
|
const nodeKey = (ex, v) => `${ex}:${v ?? ''}`
|
|
|
|
for (const e of nextEdges) {
|
|
const f = nodeKey(e.from_exercise_id, e.from_exercise_variant_id)
|
|
const t = nodeKey(e.to_exercise_id, e.to_exercise_variant_id)
|
|
if (!outMap.has(f)) outMap.set(f, [])
|
|
outMap.get(f).push(e)
|
|
if (!inMap.has(t)) inMap.set(t, [])
|
|
inMap.get(t).push(e)
|
|
}
|
|
|
|
const used = new Set()
|
|
const chains = []
|
|
|
|
for (const startEdge of nextEdges) {
|
|
if (used.has(startEdge.id)) continue
|
|
|
|
const edgesSeq = [startEdge]
|
|
|
|
let fk = nodeKey(startEdge.from_exercise_id, startEdge.from_exercise_variant_id)
|
|
while (true) {
|
|
const preds = inMap.get(fk)
|
|
if (!preds || preds.length !== 1) break
|
|
const pred = preds[0]
|
|
if (used.has(pred.id)) break
|
|
edgesSeq.unshift(pred)
|
|
fk = nodeKey(pred.from_exercise_id, pred.from_exercise_variant_id)
|
|
}
|
|
|
|
let tk = nodeKey(startEdge.to_exercise_id, startEdge.to_exercise_variant_id)
|
|
while (true) {
|
|
const outs = outMap.get(tk)
|
|
if (!outs || outs.length !== 1) break
|
|
const nx = outs[0]
|
|
if (used.has(nx.id)) break
|
|
edgesSeq.push(nx)
|
|
tk = nodeKey(nx.to_exercise_id, nx.to_exercise_variant_id)
|
|
}
|
|
|
|
edgesSeq.forEach((ed) => used.add(ed.id))
|
|
|
|
const first = edgesSeq[0]
|
|
const nodes = [
|
|
{
|
|
exercise_id: first.from_exercise_id,
|
|
variant_id: first.from_exercise_variant_id ?? null,
|
|
title: first.from_exercise_title,
|
|
variant_name: first.from_variant_name ?? null,
|
|
},
|
|
]
|
|
for (const ed of edgesSeq) {
|
|
nodes.push({
|
|
exercise_id: ed.to_exercise_id,
|
|
variant_id: ed.to_exercise_variant_id ?? null,
|
|
title: ed.to_exercise_title,
|
|
variant_name: ed.to_variant_name ?? null,
|
|
})
|
|
}
|
|
chains.push({ nodes, edges: edgesSeq })
|
|
}
|
|
return chains
|
|
}
|
|
|
|
function emptySeqStep() {
|
|
return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [] }
|
|
}
|
|
|
|
function emptyEndpoint() {
|
|
return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [] }
|
|
}
|
|
|
|
export default function ExerciseProgressionGraphPanel({
|
|
anchorExerciseId = null,
|
|
anchorTitle = null,
|
|
}) {
|
|
const { user } = useAuth()
|
|
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 [newGraphName, setNewGraphName] = useState('')
|
|
const [newGraphVisibility, setNewGraphVisibility] = useState('private')
|
|
|
|
const [metaName, setMetaName] = useState('')
|
|
const [metaDescription, setMetaDescription] = useState('')
|
|
const [metaVisibility, setMetaVisibility] = useState('private')
|
|
|
|
const [sequenceSteps, setSequenceSteps] = useState([emptySeqStep(), emptySeqStep()])
|
|
const [sequenceBulkNotes, setSequenceBulkNotes] = useState('')
|
|
const [pickContext, setPickContext] = useState(null)
|
|
|
|
const [relationKind, setRelationKind] = useState('progression')
|
|
const [firstEp, setFirstEp] = useState(emptyEndpoint)
|
|
const [secondEp, setSecondEp] = useState(emptyEndpoint)
|
|
const [edgeNotes, setEdgeNotes] = useState('')
|
|
|
|
const [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId)
|
|
const [editingEdgeNotes, setEditingEdgeNotes] = useState(null)
|
|
const [notesDraft, setNotesDraft] = useState('')
|
|
const [uiTab, setUiTab] = useState('overview')
|
|
const [skillProfileData, setSkillProfileData] = useState(null)
|
|
const [skillProfileLoading, setSkillProfileLoading] = useState(false)
|
|
const [skillProfileError, setSkillProfileError] = useState('')
|
|
|
|
useEffect(() => {
|
|
setSelectedGraphId(null)
|
|
}, [tenantClubDepKey])
|
|
|
|
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 : [])
|
|
}, [])
|
|
|
|
const loadVariantsForExercise = useCallback(async (exerciseId) => {
|
|
if (!exerciseId) return []
|
|
const ex = await api.getExercise(exerciseId)
|
|
return Array.isArray(ex?.variants) ? ex.variants : []
|
|
}, [])
|
|
|
|
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])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
;(async () => {
|
|
if (!firstEp.exerciseId) return
|
|
const vars = await loadVariantsForExercise(firstEp.exerciseId)
|
|
if (!cancelled) setFirstEp((p) => ({ ...p, variants: vars }))
|
|
})()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [firstEp.exerciseId, loadVariantsForExercise])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
;(async () => {
|
|
if (!secondEp.exerciseId) return
|
|
const vars = await loadVariantsForExercise(secondEp.exerciseId)
|
|
if (!cancelled) setSecondEp((p) => ({ ...p, variants: vars }))
|
|
})()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [secondEp.exerciseId, loadVariantsForExercise])
|
|
|
|
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 nextEdgesFiltered = useMemo(
|
|
() => filteredEdges.filter((e) => e.edge_type === 'next_exercise'),
|
|
[filteredEdges],
|
|
)
|
|
const siblingEdgesFiltered = useMemo(
|
|
() => filteredEdges.filter((e) => e.edge_type === 'sibling'),
|
|
[filteredEdges],
|
|
)
|
|
|
|
const flowChains = useMemo(() => maximalLinearChains(nextEdgesFiltered), [nextEdgesFiltered])
|
|
|
|
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 patchSeqStep = (idx, patch) => {
|
|
setSequenceSteps((prev) => prev.map((s, i) => (i === idx ? { ...s, ...patch } : s)))
|
|
}
|
|
|
|
const addSeqStep = () => setSequenceSteps((prev) => [...prev, emptySeqStep()])
|
|
|
|
const removeSeqStep = (idx) => {
|
|
setSequenceSteps((prev) => {
|
|
if (prev.length <= 2) return prev
|
|
return prev.filter((_, i) => i !== idx)
|
|
})
|
|
}
|
|
|
|
const moveSeqStep = (idx, dir) => {
|
|
setSequenceSteps((prev) => {
|
|
const j = idx + dir
|
|
if (j < 0 || j >= prev.length) return prev
|
|
const next = [...prev]
|
|
const t = next[idx]
|
|
next[idx] = next[j]
|
|
next[j] = t
|
|
return next
|
|
})
|
|
}
|
|
|
|
const submitSequence = async () => {
|
|
if (!selectedGraphId) {
|
|
alert('Zuerst einen Graphen wählen.')
|
|
return
|
|
}
|
|
const steps = sequenceSteps.filter((s) => s.exerciseId != null)
|
|
if (steps.length < 2) {
|
|
alert('Mindestens zwei Schritte mit gewählter Übung.')
|
|
return
|
|
}
|
|
const n = steps.length - 1
|
|
const noteRaw = sequenceBulkNotes.trim()
|
|
const segment_notes = Array.from({ length: n }, () => (noteRaw ? noteRaw : null))
|
|
|
|
setBusy(true)
|
|
try {
|
|
await api.createExerciseProgressionSequence(selectedGraphId, {
|
|
steps: steps.map((s) => ({
|
|
exercise_id: s.exerciseId,
|
|
variant_id: s.variantId || null,
|
|
})),
|
|
segment_notes,
|
|
})
|
|
setSequenceBulkNotes('')
|
|
await refreshEdges(selectedGraphId)
|
|
alert(`${n} Nachfolger-Kante(n) angelegt.`)
|
|
} catch (err) {
|
|
alert(err.message || String(err))
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const deleteChain = async (edgeObjs) => {
|
|
if (!selectedGraphId || !edgeObjs?.length) return
|
|
if (!confirm(`${edgeObjs.length} Kante(n) dieser Reihe löschen?`)) return
|
|
setBusy(true)
|
|
try {
|
|
await api.deleteExerciseProgressionEdgesBatch(
|
|
selectedGraphId,
|
|
edgeObjs.map((e) => e.id),
|
|
)
|
|
await refreshEdges(selectedGraphId)
|
|
} catch (err) {
|
|
alert(err.message || String(err))
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const handleAddEdge = async () => {
|
|
if (!selectedGraphId) {
|
|
alert('Zuerst einen Graphen wählen.')
|
|
return
|
|
}
|
|
if (!firstEp.exerciseId || !secondEp.exerciseId) {
|
|
alert('Beide Enden müssen eine Übung haben.')
|
|
return
|
|
}
|
|
if (
|
|
firstEp.exerciseId === secondEp.exerciseId &&
|
|
(firstEp.variantId == null ||
|
|
secondEp.variantId == null ||
|
|
firstEp.variantId === secondEp.variantId)
|
|
) {
|
|
alert('Bei derselben Übung bitte zwei verschiedene Varianten wählen (oder unterschiedliche Übungen).')
|
|
return
|
|
}
|
|
const edge_type = relationKind === 'sibling' ? 'sibling' : 'next_exercise'
|
|
const notes = edgeNotes.trim() || null
|
|
const body = {
|
|
from_exercise_id: firstEp.exerciseId,
|
|
to_exercise_id: secondEp.exerciseId,
|
|
from_exercise_variant_id: firstEp.variantId || null,
|
|
to_exercise_variant_id: secondEp.variantId || null,
|
|
edge_type,
|
|
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 = firstEp
|
|
setFirstEp(secondEp)
|
|
setSecondEp(a)
|
|
}
|
|
|
|
const applyPickedExercise = async (ex) => {
|
|
const title = ex.title || `Übung #${ex.id}`
|
|
const variants = await loadVariantsForExercise(ex.id)
|
|
|
|
if (pickContext?.kind === 'sequence') {
|
|
patchSeqStep(pickContext.index, {
|
|
exerciseId: ex.id,
|
|
exerciseTitle: title,
|
|
variantId: null,
|
|
variants,
|
|
})
|
|
setPickContext(null)
|
|
return
|
|
}
|
|
if (pickContext?.kind === 'single') {
|
|
const patch = {
|
|
exerciseId: ex.id,
|
|
exerciseTitle: title,
|
|
variantId: null,
|
|
variants,
|
|
}
|
|
if (pickContext.slot === 'first') setFirstEp(patch)
|
|
else setSecondEp(patch)
|
|
setPickContext(null)
|
|
}
|
|
}
|
|
|
|
function formatNodeLine(n) {
|
|
return (
|
|
<>
|
|
<Link to={`/exercises/${n.exercise_id}`}>{n.title}</Link>
|
|
{n.variant_name ? (
|
|
<span style={{ color: 'var(--text3)', fontWeight: 400 }}>{` · ${n.variant_name}`}</span>
|
|
) : null}
|
|
</>
|
|
)
|
|
}
|
|
|
|
const pickerOpen = pickContext != 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' }}>
|
|
Pro Graph mehrere <strong>Reihen</strong> und <strong>Alternativen</strong>: eine{' '}
|
|
<strong>Sequenz</strong> legt automatisch alle Schritte Übung1 → Übung2 → … als Nachfolger-Kanten an.
|
|
Optional pro Schritt eine <strong>Variante</strong> — sie wirkt wie ein eigener Knoten. Verzweigungen und
|
|
Schwestern trennst du weiterhin mit Einzelkanten oder mehreren Sequenzen aus dem gleichen Knoten.
|
|
</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)}
|
|
>
|
|
Aktualisieren
|
|
</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)}
|
|
>
|
|
{filteredGraphVisOptions.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)}>
|
|
{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>
|
|
)}
|
|
|
|
{selectedGraphId && (
|
|
<div
|
|
role="tablist"
|
|
style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}
|
|
aria-label="Darstellung"
|
|
>
|
|
<button
|
|
type="button"
|
|
className={uiTab === 'overview' ? 'btn btn-primary' : 'btn btn-secondary'}
|
|
onClick={() => setUiTab('overview')}
|
|
>
|
|
Übersicht & Sequenz
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={uiTab === 'table' ? 'btn btn-primary' : 'btn btn-secondary'}
|
|
onClick={() => setUiTab('table')}
|
|
>
|
|
Alle Kanten (Tabelle)
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{selectedGraphId && uiTab === 'overview' && (
|
|
<>
|
|
<SkillProfilePanel
|
|
title="Fähigkeiten entlang des Pfads"
|
|
hint="Alle Übungen als Knoten im Graph (Standardgewicht pro Übung; Intensität und Stufen aus der Übungsverknüpfung)."
|
|
profile={skillProfileData?.overall}
|
|
loading={skillProfileLoading}
|
|
error={skillProfileError}
|
|
defaultExpanded
|
|
artifactType="progression_graph"
|
|
/>
|
|
<div className="card" style={{ marginBottom: '12px' }}>
|
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Sequenz / Reihe anlegen</h3>
|
|
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0 }}>
|
|
Reihenfolge von links nach rechts: jede Zeile ein Schritt. Es werden automatisch alle Nachfolger-Kanten
|
|
zwischen benachbarten Schritten erzeugt (ein API-Vorgang).
|
|
</p>
|
|
{sequenceSteps.map((step, idx) => (
|
|
<div
|
|
key={idx}
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
|
gap: '10px',
|
|
alignItems: 'end',
|
|
marginBottom: '12px',
|
|
paddingBottom: '12px',
|
|
borderBottom: idx < sequenceSteps.length - 1 ? '1px dashed var(--border)' : 'none',
|
|
}}
|
|
>
|
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
|
<label className="form-label">Schritt {idx + 1}</label>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
|
|
<span style={{ fontSize: '13px', flex: '1 1 140px' }}>
|
|
{step.exerciseId ? (
|
|
<>
|
|
<strong>{step.exerciseTitle}</strong>
|
|
<span style={{ color: 'var(--text3)' }}> (#{step.exerciseId})</span>
|
|
</>
|
|
) : (
|
|
<span style={{ color: 'var(--text3)' }}>Übung wählen</span>
|
|
)}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
style={{ fontSize: '12px', padding: '4px 10px' }}
|
|
onClick={() => setPickContext({ kind: 'sequence', index: idx })}
|
|
>
|
|
Übung…
|
|
</button>
|
|
{anchorExerciseId != null && (
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
style={{ fontSize: '12px', padding: '4px 10px' }}
|
|
onClick={async () => {
|
|
const variants = await loadVariantsForExercise(anchorExerciseId)
|
|
patchSeqStep(idx, {
|
|
exerciseId: anchorExerciseId,
|
|
exerciseTitle: anchorTitle?.trim() || `Übung #${anchorExerciseId}`,
|
|
variantId: null,
|
|
variants,
|
|
})
|
|
}}
|
|
>
|
|
Diese Übung
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
|
<label className="form-label">Variante (optional)</label>
|
|
<select
|
|
className="form-input"
|
|
disabled={!step.exerciseId}
|
|
value={step.variantId ?? ''}
|
|
onChange={(e) =>
|
|
patchSeqStep(idx, {
|
|
variantId: e.target.value === '' ? null : parseInt(e.target.value, 10),
|
|
})
|
|
}
|
|
>
|
|
<option value="">Gesamte Übung (ohne feste Variante)</option>
|
|
{(step.variants || []).map((v) => (
|
|
<option key={v.id} value={v.id}>
|
|
{v.variant_name || `Variante #${v.id}`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
|
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => moveSeqStep(idx, -1)}>
|
|
↑
|
|
</button>
|
|
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => moveSeqStep(idx, 1)}>
|
|
↓
|
|
</button>
|
|
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => removeSeqStep(idx)}>
|
|
Zeile entfernen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<button type="button" className="btn btn-secondary" style={{ marginBottom: '12px' }} onClick={addSeqStep}>
|
|
+ Schritt
|
|
</button>
|
|
<div className="form-row">
|
|
<label className="form-label">Entwicklungsziel für alle neuen Zwischen-Kanten (optional)</label>
|
|
<textarea
|
|
className="form-input"
|
|
rows={2}
|
|
value={sequenceBulkNotes}
|
|
onChange={(e) => setSequenceBulkNotes(e.target.value)}
|
|
placeholder="wird auf jedes neue Segment der Sequenz kopiert"
|
|
/>
|
|
</div>
|
|
<button type="button" className="btn btn-primary" disabled={busy} onClick={submitSequence}>
|
|
Sequenz als Nachfolger-Kanten speichern
|
|
</button>
|
|
</div>
|
|
|
|
<div className="card" style={{ marginBottom: '12px' }}>
|
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Nachfolger als Reihen (Lesart)</h3>
|
|
{flowChains.length === 0 ? (
|
|
<p style={{ color: 'var(--text2)', margin: 0 }}>Keine Nachfolger-Kanten im aktuellen Filter.</p>
|
|
) : (
|
|
flowChains.map((chain, ci) => (
|
|
<div
|
|
key={ci}
|
|
style={{
|
|
marginBottom: '14px',
|
|
padding: '12px',
|
|
borderRadius: '10px',
|
|
border: '1px solid var(--border)',
|
|
background: 'var(--surface2)',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
alignItems: 'center',
|
|
gap: '6px 10px',
|
|
fontSize: '13px',
|
|
lineHeight: 1.5,
|
|
}}
|
|
>
|
|
{chain.nodes.map((n, ni) => (
|
|
<React.Fragment key={`${n.exercise_id}-${n.variant_id}-${ni}`}>
|
|
{ni > 0 && (
|
|
<span style={{ color: 'var(--accent)', fontWeight: 700 }} aria-hidden>
|
|
→
|
|
</span>
|
|
)}
|
|
<span style={{ fontWeight: 600 }}>{formatNodeLine(n)}</span>
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
<div style={{ marginTop: '10px', display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => deleteChain(chain.edges)}>
|
|
Diese Reihe löschen ({chain.edges.length} Kante(n))
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<div className="card" style={{ marginBottom: '12px' }}>
|
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Schwestern & Alternativen</h3>
|
|
{siblingEdgesFiltered.length === 0 ? (
|
|
<p style={{ color: 'var(--text2)', margin: 0 }}>Keine Schwester-Kanten im aktuellen Filter.</p>
|
|
) : (
|
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
|
{siblingEdgesFiltered.map((row) => (
|
|
<li
|
|
key={row.id}
|
|
style={{
|
|
padding: '10px 12px',
|
|
marginBottom: '8px',
|
|
borderRadius: '8px',
|
|
border: '1px solid var(--border)',
|
|
background: 'var(--surface2)',
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
flexWrap: 'wrap',
|
|
gap: '8px',
|
|
alignItems: 'center',
|
|
fontSize: '13px',
|
|
}}
|
|
>
|
|
<span>
|
|
<strong>{formatNodeLine({
|
|
exercise_id: row.from_exercise_id,
|
|
variant_id: row.from_exercise_variant_id,
|
|
title: row.from_exercise_title,
|
|
variant_name: row.from_variant_name,
|
|
})}</strong>
|
|
<span style={{ color: 'var(--text3)', margin: '0 8px' }}>· Schwester ·</span>
|
|
<strong>{formatNodeLine({
|
|
exercise_id: row.to_exercise_id,
|
|
variant_id: row.to_exercise_variant_id,
|
|
title: row.to_exercise_title,
|
|
variant_name: row.to_variant_name,
|
|
})}</strong>
|
|
{row.notes ? (
|
|
<span style={{ display: 'block', marginTop: '6px', color: 'var(--text2)', fontWeight: 400 }}>
|
|
{row.notes}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
style={{ fontSize: '12px', padding: '4px 10px', background: 'var(--danger)', color: '#fff', border: 'none' }}
|
|
onClick={() => handleDeleteEdge(row.id)}
|
|
>
|
|
Löschen
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
<details className="card" style={{ marginBottom: '12px' }}>
|
|
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Einzelkante (Nachfolger oder Schwester)</summary>
|
|
<div style={{ marginTop: '14px' }}>
|
|
<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</option>
|
|
<option value="sibling">Schwester</option>
|
|
</select>
|
|
</div>
|
|
|
|
{['first', 'second'].map((slot) => {
|
|
const ep = slot === 'first' ? firstEp : secondEp
|
|
const setEp = slot === 'first' ? setFirstEp : setSecondEp
|
|
return (
|
|
<div key={slot} style={{ marginBottom: '12px' }}>
|
|
<label className="form-label">{slot === 'first' ? 'Von (Quelle)' : 'Nach (Ziel)'}</label>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', marginBottom: '8px' }}>
|
|
<span style={{ fontSize: '13px', flex: '1 1 160px' }}>
|
|
{ep.exerciseId ? (
|
|
<>
|
|
<strong>{ep.exerciseTitle}</strong>
|
|
<span style={{ color: 'var(--text3)' }}> (#{ep.exerciseId})</span>
|
|
</>
|
|
) : (
|
|
<span style={{ color: 'var(--text3)' }}>— Übung wählen —</span>
|
|
)}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
onClick={() => setPickContext({ kind: 'single', slot })}
|
|
>
|
|
Übung…
|
|
</button>
|
|
{anchorExerciseId != null && (
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
style={{ fontSize: '12px', padding: '4px 10px' }}
|
|
onClick={async () => {
|
|
const variants = await loadVariantsForExercise(anchorExerciseId)
|
|
setEp({
|
|
exerciseId: anchorExerciseId,
|
|
exerciseTitle: anchorTitle?.trim() || `Übung #${anchorExerciseId}`,
|
|
variantId: null,
|
|
variants,
|
|
})
|
|
}}
|
|
>
|
|
Diese Übung
|
|
</button>
|
|
)}
|
|
</div>
|
|
<select
|
|
className="form-input"
|
|
disabled={!ep.exerciseId}
|
|
value={ep.variantId ?? ''}
|
|
onChange={(e) =>
|
|
setEp((p) => ({
|
|
...p,
|
|
variantId: e.target.value === '' ? null : parseInt(e.target.value, 10),
|
|
}))
|
|
}
|
|
>
|
|
<option value="">Gesamte Übung</option>
|
|
{(ep.variants || []).map((v) => (
|
|
<option key={v.id} value={v.id}>
|
|
{v.variant_name || `Variante #${v.id}`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{relationKind === 'progression' && (
|
|
<button type="button" className="btn" style={{ marginBottom: '10px' }} onClick={swapEnds}>
|
|
Reihenfolge tauschen
|
|
</button>
|
|
)}
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Notiz</label>
|
|
<textarea className="form-input" rows={2} value={edgeNotes} onChange={(e) => setEdgeNotes(e.target.value)} />
|
|
</div>
|
|
<button type="button" className="btn btn-primary" disabled={busy} onClick={handleAddEdge}>
|
|
Kante speichern
|
|
</button>
|
|
</div>
|
|
</details>
|
|
</>
|
|
)}
|
|
|
|
{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, die diese Übung betreffen (Übersicht & Tabelle)
|
|
</label>
|
|
)}
|
|
|
|
{selectedGraphId && uiTab === 'table' && (
|
|
<div className="card">
|
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>
|
|
Alle Kanten ({filteredEdges.length}
|
|
{edges.length !== filteredEdges.length ? ` von ${edges.length}` : ''})
|
|
</h3>
|
|
{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>
|
|
{row.from_variant_name ? (
|
|
<div style={{ color: 'var(--text3)', fontSize: '12px' }}>{row.from_variant_name}</div>
|
|
) : null}
|
|
</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>
|
|
{row.to_variant_name ? (
|
|
<div style={{ color: 'var(--text3)', fontSize: '12px' }}>{row.to_variant_name}</div>
|
|
) : null}
|
|
</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)}>
|
|
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={pickerOpen}
|
|
onClose={() => setPickContext(null)}
|
|
onSelectExercise={applyPickedExercise}
|
|
exerciseKindAny={['simple']}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|