Trainingsplanung und Rahmenplanung #9

Merged
Lars merged 29 commits from develop into main 2026-05-05 16:05:01 +02:00
5 changed files with 860 additions and 175 deletions
Showing only changes of commit ae6c089366 - Show all commits

View File

@ -0,0 +1,45 @@
-- Migration 034: Progressionskanten optional mit Übungsvarianten (Knoten = Übung oder konkrete Variante).
-- UNIQUE und CHECK angepasst; Kanten innerhalb derselben Übung nur zwischen verschiedenen Varianten.
ALTER TABLE exercise_progression_edges
ADD COLUMN IF NOT EXISTS from_exercise_variant_id INT REFERENCES exercise_variants(id) ON DELETE CASCADE,
ADD COLUMN IF NOT EXISTS to_exercise_variant_id INT REFERENCES exercise_variants(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_progression_edges_from_variant ON exercise_progression_edges(from_exercise_variant_id)
WHERE from_exercise_variant_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_progression_edges_to_variant ON exercise_progression_edges(to_exercise_variant_id)
WHERE to_exercise_variant_id IS NOT NULL;
DO $$
BEGIN
ALTER TABLE exercise_progression_edges
DROP CONSTRAINT exercise_progression_edges_graph_id_from_exercise_id_to_exercise_id_edge_type_key;
EXCEPTION
WHEN undefined_object THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE exercise_progression_edges DROP CONSTRAINT exercise_progression_edges_check;
EXCEPTION
WHEN undefined_object THEN NULL;
END $$;
ALTER TABLE exercise_progression_edges ADD CONSTRAINT exercise_progression_edges_endpoints_distinct CHECK (
(from_exercise_id <> to_exercise_id)
OR (
from_exercise_variant_id IS NOT NULL
AND to_exercise_variant_id IS NOT NULL
AND from_exercise_variant_id <> to_exercise_variant_id
)
);
CREATE UNIQUE INDEX IF NOT EXISTS exercise_progression_edges_unique_endpoints
ON exercise_progression_edges (
graph_id,
from_exercise_id,
COALESCE(from_exercise_variant_id, 0),
to_exercise_id,
COALESCE(to_exercise_variant_id, 0),
edge_type
);

View File

@ -1,11 +1,12 @@
""" """
Progressionsgraph zwischen Übungen (Übung Übung), Migration 032. Progressionsgraph zwischen Übungen (Übung Übung), Migration 032034.
Optional Übungsvarianten als Knoten-Endpunkte; Sequenz-Bulk-Anlage.
AuthZ analog training_plan_templates (_template_access / _has_planning_role). AuthZ analog training_plan_templates (_template_access / _has_planning_role).
""" """
from typing import Any, List, Optional from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
from psycopg2 import IntegrityError from psycopg2 import IntegrityError
from auth import require_auth from auth import require_auth
@ -33,6 +34,8 @@ class ProgressionGraphUpdate(BaseModel):
class ProgressionEdgeCreate(BaseModel): class ProgressionEdgeCreate(BaseModel):
from_exercise_id: int = Field(..., gt=0) from_exercise_id: int = Field(..., gt=0)
to_exercise_id: int = Field(..., gt=0) to_exercise_id: int = Field(..., gt=0)
from_exercise_variant_id: Optional[int] = Field(default=None)
to_exercise_variant_id: Optional[int] = Field(default=None)
edge_type: str = Field(default="next_exercise", min_length=1, max_length=50) edge_type: str = Field(default="next_exercise", min_length=1, max_length=50)
notes: Optional[str] = Field(None, max_length=4000) notes: Optional[str] = Field(None, max_length=4000)
@ -41,12 +44,41 @@ class ProgressionEdgeUpdate(BaseModel):
notes: Optional[str] = Field(None, max_length=4000) notes: Optional[str] = Field(None, max_length=4000)
class SequenceStep(BaseModel):
exercise_id: int = Field(..., gt=0)
variant_id: Optional[int] = Field(default=None)
class ProgressionSequenceCreate(BaseModel):
steps: List[SequenceStep] = Field(..., min_length=2)
segment_notes: Optional[List[Optional[str]]] = None
"""Länge muss len(steps)-1 sein, wenn gesetzt; Notiz pro Kante Zwischen je zwei Schritten."""
@model_validator(mode="after")
def check_segment_notes_len(self):
if self.segment_notes is not None and len(self.segment_notes) != len(self.steps) - 1:
raise ValueError(
f"segment_notes muss genau {len(self.steps) - 1} Einträge haben (len(steps)-1)"
)
return self
class EdgeIdsBatch(BaseModel):
edge_ids: List[int] = Field(..., min_length=1)
_EDGE_SELECT = """ _EDGE_SELECT = """
SELECT e.id, e.graph_id, e.from_exercise_id, e.to_exercise_id, e.edge_type, e.notes, e.created_at, SELECT e.id, e.graph_id,
ef.title AS from_exercise_title, et.title AS to_exercise_title e.from_exercise_id, e.from_exercise_variant_id,
e.to_exercise_id, e.to_exercise_variant_id,
e.edge_type, e.notes, e.created_at,
ef.title AS from_exercise_title, et.title AS to_exercise_title,
vf.variant_name AS from_variant_name, vt.variant_name AS to_variant_name
FROM exercise_progression_edges e FROM exercise_progression_edges e
JOIN exercises ef ON ef.id = e.from_exercise_id JOIN exercises ef ON ef.id = e.from_exercise_id
JOIN exercises et ON et.id = e.to_exercise_id JOIN exercises et ON et.id = e.to_exercise_id
LEFT JOIN exercise_variants vf ON vf.id = e.from_exercise_variant_id
LEFT JOIN exercise_variants vt ON vt.id = e.to_exercise_variant_id
""" """
@ -83,6 +115,57 @@ def _assert_exercises_exist(cur, *exercise_ids: int) -> None:
) )
def _assert_variant_for_exercise(cur, exercise_id: int, variant_id: Optional[int]) -> None:
if variant_id is None:
return
cur.execute(
"SELECT exercise_id FROM exercise_variants WHERE id = %s",
(variant_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=400, detail=f"Unbekannte Variante: {variant_id}")
ev_ex = row["exercise_id"] if isinstance(row, dict) else row[0]
if ev_ex != exercise_id:
raise HTTPException(status_code=400, detail="Variante gehört nicht zur gewählten Übung")
def _insert_edge_row(
cur,
graph_id: int,
from_exercise_id: int,
from_variant_id: Optional[int],
to_exercise_id: int,
to_variant_id: Optional[int],
edge_type: str,
notes: Optional[str],
) -> dict:
cur.execute(
"""
INSERT INTO exercise_progression_edges (
graph_id,
from_exercise_id, from_exercise_variant_id,
to_exercise_id, to_exercise_variant_id,
edge_type, notes
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
graph_id,
from_exercise_id,
from_variant_id,
to_exercise_id,
to_variant_id,
edge_type,
notes,
),
)
new_id = cur.fetchone()["id"]
cur.execute(_EDGE_SELECT + " WHERE e.id = %s", (new_id,))
return r2d(cur.fetchone())
@router.get("/exercise-progression-graphs") @router.get("/exercise-progression-graphs")
def list_progression_graphs(session: dict = Depends(require_auth)): def list_progression_graphs(session: dict = Depends(require_auth)):
profile_id = session["profile_id"] profile_id = session["profile_id"]
@ -252,40 +335,91 @@ def create_progression_edge(
): ):
profile_id = session["profile_id"] profile_id = session["profile_id"]
role = session.get("role") role = session.get("role")
if body.from_exercise_id == body.to_exercise_id:
raise HTTPException(status_code=400, detail="from_exercise_id und to_exercise_id müssen unterschiedlich sein")
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role) _graph_access(cur, graph_id, profile_id, role)
_assert_exercises_exist(cur, body.from_exercise_id, body.to_exercise_id) _assert_exercises_exist(cur, body.from_exercise_id, body.to_exercise_id)
fv = body.from_exercise_variant_id
tv = body.to_exercise_variant_id
_assert_variant_for_exercise(cur, body.from_exercise_id, fv)
_assert_variant_for_exercise(cur, body.to_exercise_id, tv)
et = (body.edge_type or "next_exercise").strip() or "next_exercise" et = (body.edge_type or "next_exercise").strip() or "next_exercise"
notes = (body.notes or "").strip() or None notes = (body.notes or "").strip() or None
try: try:
cur.execute( row = _insert_edge_row(
""" cur,
INSERT INTO exercise_progression_edges ( graph_id,
graph_id, from_exercise_id, to_exercise_id, edge_type, notes body.from_exercise_id,
) fv,
VALUES (%s, %s, %s, %s, %s) body.to_exercise_id,
RETURNING id tv,
""", et,
(graph_id, body.from_exercise_id, body.to_exercise_id, et, notes), notes,
) )
new_id = cur.fetchone()["id"]
cur.execute(_EDGE_SELECT + " WHERE e.id = %s", (new_id,))
row = r2d(cur.fetchone())
conn.commit() conn.commit()
except IntegrityError as e: except IntegrityError as e:
conn.rollback() conn.rollback()
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail="Kante existiert bereits (graph_id, von, nach, edge_type)", detail="Kante existiert bereits oder Endpunkte unzulässig (gleiche Übung ohne zwei Varianten)",
) from e ) from e
return row return row
@router.post("/exercise-progression-graphs/{graph_id}/edges/sequence", status_code=201)
def create_progression_sequence(
graph_id: int,
body: ProgressionSequenceCreate,
session: dict = Depends(require_auth),
):
"""Legt n1 Nachfolger-Kanten (next_exercise) für eine geordnete Schrittliste an."""
profile_id = session["profile_id"]
role = session.get("role")
steps = body.steps
n_seg = len(steps) - 1
seg_notes = body.segment_notes
created: List[dict] = []
with get_db() as conn:
cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role)
ex_ids = [s.exercise_id for s in steps]
_assert_exercises_exist(cur, *ex_ids)
try:
for i in range(n_seg):
a, b = steps[i], steps[i + 1]
_assert_variant_for_exercise(cur, a.exercise_id, a.variant_id)
_assert_variant_for_exercise(cur, b.exercise_id, b.variant_id)
note = None
if seg_notes is not None:
raw = seg_notes[i]
note = (raw or "").strip() or None if raw is not None else None
row = _insert_edge_row(
cur,
graph_id,
a.exercise_id,
a.variant_id,
b.exercise_id,
b.variant_id,
"next_exercise",
note,
)
created.append(row)
conn.commit()
except IntegrityError as e:
conn.rollback()
raise HTTPException(
status_code=409,
detail="Sequenz konnte nicht vollständig angelegt werden (Duplikat oder ungültige Endpunkte). Keine Teilmenge gespeichert.",
) from e
return {"created": created, "count": len(created)}
@router.put("/exercise-progression-graphs/{graph_id}/edges/{edge_id}") @router.put("/exercise-progression-graphs/{graph_id}/edges/{edge_id}")
def update_progression_edge( def update_progression_edge(
graph_id: int, graph_id: int,
@ -344,3 +478,30 @@ def delete_progression_edge(
raise HTTPException(status_code=404, detail="Kante nicht gefunden") raise HTTPException(status_code=404, detail="Kante nicht gefunden")
conn.commit() conn.commit()
return {"ok": True} return {"ok": True}
@router.post("/exercise-progression-graphs/{graph_id}/edges/delete-batch")
def delete_progression_edges_batch(
graph_id: int,
body: EdgeIdsBatch,
session: dict = Depends(require_auth),
):
"""Löscht mehrere Kanten (z. B. eine zusammenhängende Kette in einem Schritt)."""
profile_id = session["profile_id"]
role = session.get("role")
ids = body.edge_ids
clean_ids = list(dict.fromkeys(ids))
with get_db() as conn:
cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role)
cur.execute(
f"""
DELETE FROM exercise_progression_edges
WHERE graph_id = %s AND id IN ({",".join(["%s"] * len(clean_ids))})
""",
(graph_id, *clean_ids),
)
deleted = cur.rowcount
conn.commit()
return {"ok": True, "deleted": deleted}

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.6" APP_VERSION = "0.8.7"
BUILD_DATE = "2026-04-30" BUILD_DATE = "2026-04-30"
DB_SCHEMA_VERSION = "20260430033" DB_SCHEMA_VERSION = "20260430034"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.0.0", "auth": "1.0.0",
@ -11,7 +11,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0", "groups": "0.1.0",
"skills": "0.1.0", "skills": "0.1.0",
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.2.0", # Progressionsgraph Übung→Übung (Migration 032, Router exercise_progression_graphs) "exercises": "2.3.0", # Progressionsgraph: Sequenz-API, Varianten-Knoten, UX Ketten (Migration 034)
"training_units": "0.1.0", "training_units": "0.1.0",
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.3.0", "planning": "0.3.0",
@ -23,6 +23,15 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.7",
"date": "2026-04-30",
"changes": [
"DB 034: Progressionskanten mit optionalen Varianten-Endpunkten",
"API: POST …/edges/sequence (Reihe auf einmal); POST …/edges/delete-batch",
"Frontend Progressions-UI: Sequenz-Editor, Ketten-Ansicht, Variantenwahl",
],
},
{ {
"version": "0.8.6", "version": "0.8.6",
"date": "2026-04-30", "date": "2026-04-30",

View File

@ -1,6 +1,6 @@
/** /**
* Verwaltung mehrerer Progressionsgraphen (Übung Übung) mit Kantentypen Nachfolger / Schwester. * Progressionsgraphen: Sequenz-Editor (mehrere Nachfolger auf einmal), Ketten-Ansicht,
* Varianten-Ketten bleiben unter Übungsvarianten der jeweiligen Übung. * Varianten als eigene Knoten-Endpunkte, Schwester-Kanten gesondert, Tabelle als Fallback.
*/ */
import React, { useCallback, useEffect, useMemo, useState } from 'react' import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
@ -19,6 +19,82 @@ function edgeTypeLabel(type) {
return type || '—' 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({ export default function ExerciseProgressionGraphPanel({
anchorExerciseId = null, anchorExerciseId = null,
anchorTitle = null, anchorTitle = null,
@ -36,15 +112,19 @@ export default function ExerciseProgressionGraphPanel({
const [metaDescription, setMetaDescription] = useState('') const [metaDescription, setMetaDescription] = useState('')
const [metaVisibility, setMetaVisibility] = useState('private') 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 [relationKind, setRelationKind] = useState('progression')
const [firstEx, setFirstEx] = useState(null) const [firstEp, setFirstEp] = useState(emptyEndpoint)
const [secondEx, setSecondEx] = useState(null) const [secondEp, setSecondEp] = useState(emptyEndpoint)
const [edgeNotes, setEdgeNotes] = useState('') const [edgeNotes, setEdgeNotes] = useState('')
const [pickSlot, setPickSlot] = useState(null)
const [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId) const [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId)
const [editingEdgeNotes, setEditingEdgeNotes] = useState(null) const [editingEdgeNotes, setEditingEdgeNotes] = useState(null)
const [notesDraft, setNotesDraft] = useState('') const [notesDraft, setNotesDraft] = useState('')
const [uiTab, setUiTab] = useState('overview')
const refreshGraphs = useCallback(async () => { const refreshGraphs = useCallback(async () => {
const list = await api.listExerciseProgressionGraphs() const list = await api.listExerciseProgressionGraphs()
@ -61,6 +141,12 @@ export default function ExerciseProgressionGraphPanel({
setEdges(Array.isArray(list) ? list : []) 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(() => { useEffect(() => {
let cancelled = false let cancelled = false
;(async () => { ;(async () => {
@ -106,6 +192,30 @@ export default function ExerciseProgressionGraphPanel({
} }
}, [selectedGraphId, graphs, refreshEdges]) }, [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(() => { const filteredEdges = useMemo(() => {
if (!filterAnchorOnly || anchorExerciseId == null) return edges if (!filterAnchorOnly || anchorExerciseId == null) return edges
return edges.filter( return edges.filter(
@ -114,6 +224,17 @@ export default function ExerciseProgressionGraphPanel({
) )
}, [edges, filterAnchorOnly, 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) => { const handleCreateGraph = async (e) => {
e.preventDefault() e.preventDefault()
const name = newGraphName.trim() const name = newGraphName.trim()
@ -175,35 +296,109 @@ export default function ExerciseProgressionGraphPanel({
} }
} }
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 () => { const handleAddEdge = async () => {
if (!selectedGraphId) { if (!selectedGraphId) {
alert('Zuerst einen Graphen wählen oder anlegen.') alert('Zuerst einen Graphen wählen.')
return return
} }
if (!firstEx?.id || !secondEx?.id) { if (!firstEp.exerciseId || !secondEp.exerciseId) {
alert('Beide Übungen auswählen.') alert('Beide Enden müssen eine Übung haben.')
return return
} }
if (firstEx.id === secondEx.id) { if (
alert('Es müssen zwei verschiedene Übungen sein.') 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 return
} }
const edge_type = relationKind === 'sibling' ? 'sibling' : 'next_exercise' const edge_type = relationKind === 'sibling' ? 'sibling' : 'next_exercise'
const notes = edgeNotes.trim() || null const notes = edgeNotes.trim() || null
const body = const body = {
relationKind === 'sibling' from_exercise_id: firstEp.exerciseId,
? { to_exercise_id: secondEp.exerciseId,
from_exercise_id: firstEx.id, from_exercise_variant_id: firstEp.variantId || null,
to_exercise_id: secondEx.id, to_exercise_variant_id: secondEp.variantId || null,
edge_type, edge_type,
notes, notes,
} }
: {
from_exercise_id: firstEx.id,
to_exercise_id: secondEx.id,
edge_type: 'next_exercise',
notes,
}
setBusy(true) setBusy(true)
try { try {
await api.createExerciseProgressionEdge(selectedGraphId, body) await api.createExerciseProgressionEdge(selectedGraphId, body)
@ -252,18 +447,51 @@ export default function ExerciseProgressionGraphPanel({
} }
const swapEnds = () => { const swapEnds = () => {
const a = firstEx const a = firstEp
setFirstEx(secondEx) setFirstEp(secondEp)
setSecondEx(a) setSecondEp(a)
} }
const onPicked = (ex) => { const applyPickedExercise = async (ex) => {
const row = { id: ex.id, title: ex.title || `Übung #${ex.id}` } const title = ex.title || `Übung #${ex.id}`
if (pickSlot === 'first') setFirstEx(row) const variants = await loadVariantsForExercise(ex.id)
else if (pickSlot === 'second') setSecondEx(row)
setPickSlot(null) 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 ( return (
<div className="exercise-progression-panel"> <div className="exercise-progression-panel">
{anchorExerciseId != null && ( {anchorExerciseId != null && (
@ -276,9 +504,10 @@ export default function ExerciseProgressionGraphPanel({
)} )}
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '16px' }}> <p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '16px' }}>
Graphen bilden einen gerichteten Wald aus Übungen: <strong>Nachfolger</strong> ist zuerst A, dann B; Pro Graph mehrere <strong>Reihen</strong> und <strong>Alternativen</strong>: eine{' '}
<strong> Schwestern</strong> markieren Alternativen oder parallele Entwicklungsschritte. Fortschritt{' '} <strong>Sequenz</strong> legt automatisch alle Schritte Übung1 Übung2 als Nachfolger-Kanten an.
<em>innerhalb einer Übung</em> (Varianten, Progressionsstufen) pflegst du unter Übungsvarianten. 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> </p>
{loadErr && ( {loadErr && (
@ -311,7 +540,7 @@ export default function ExerciseProgressionGraphPanel({
disabled={busy || !selectedGraphId} disabled={busy || !selectedGraphId}
onClick={() => refreshEdges(selectedGraphId)} onClick={() => refreshEdges(selectedGraphId)}
> >
Kanten neu laden Aktualisieren
</button> </button>
<button type="button" className="btn" disabled={busy || !selectedGraphId} onClick={handleDeleteGraph}> <button type="button" className="btn" disabled={busy || !selectedGraphId} onClick={handleDeleteGraph}>
Graph löschen Graph löschen
@ -384,120 +613,339 @@ export default function ExerciseProgressionGraphPanel({
)} )}
{selectedGraphId && ( {selectedGraphId && (
<div className="card" style={{ marginBottom: '12px' }}> <div
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Kante hinzufügen</h3> role="tablist"
<div className="form-row"> style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}
<label className="form-label">Beziehung</label> aria-label="Darstellung"
<select >
className="form-input" <button
value={relationKind} type="button"
onChange={(e) => setRelationKind(e.target.value)} className={uiTab === 'overview' ? 'btn btn-primary' : 'btn btn-secondary'}
> onClick={() => setUiTab('overview')}
<option value="progression">Nachfolger (zuerst Übung A, danach Übung B)</option> >
<option value="sibling">Schwester (gleiche Entwicklungslage / Alternative)</option> Übersicht & Sequenz
</select> </button>
</div> <button
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0 }}> type="button"
{relationKind === 'progression' className={uiTab === 'table' ? 'btn btn-primary' : 'btn btn-secondary'}
? '„Vorgänger von B ist A“ entspricht dieser Kante: A kommt vor B. Bei Bedarf „Reihenfolge tauschen“.' onClick={() => setUiTab('table')}
: 'Eine gerichtete Schwester-Kante reicht; semantisch gilt die Paarbeziehung als bidirektional.'} >
</p> Alle Kanten (Tabelle)
<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> </button>
</div> </div>
)} )}
{selectedGraphId && uiTab === 'overview' && (
<>
<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 && ( {selectedGraphId && anchorExerciseId != null && (
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', fontSize: '13px' }}> <label style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', fontSize: '13px' }}>
<input <input
@ -505,18 +953,18 @@ export default function ExerciseProgressionGraphPanel({
checked={filterAnchorOnly} checked={filterAnchorOnly}
onChange={(e) => setFilterAnchorOnly(e.target.checked)} onChange={(e) => setFilterAnchorOnly(e.target.checked)}
/> />
Nur Kanten anzeigen, die diese Übung betreffen Nur Kanten, die diese Übung betreffen (Übersicht &amp; Tabelle)
</label> </label>
)} )}
{selectedGraphId && ( {selectedGraphId && uiTab === 'table' && (
<div className="card"> <div className="card">
<h3 style={{ marginTop: 0, fontSize: '1rem' }}> <h3 style={{ marginTop: 0, fontSize: '1rem' }}>
Kanten ({filteredEdges.length} Alle Kanten ({filteredEdges.length}
{edges.length !== filteredEdges.length ? ` von ${edges.length}` : ''}) {edges.length !== filteredEdges.length ? ` von ${edges.length}` : ''})
</h3> </h3>
{filteredEdges.length === 0 ? ( {filteredEdges.length === 0 ? (
<p style={{ color: 'var(--text2)', margin: 0 }}>Noch keine Kanten in diesem Graph.</p> <p style={{ color: 'var(--text2)', margin: 0 }}>Keine Kanten.</p>
) : ( ) : (
<div style={{ overflowX: 'auto' }}> <div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
@ -526,7 +974,7 @@ export default function ExerciseProgressionGraphPanel({
<th style={{ padding: '8px 6px' }} /> <th style={{ padding: '8px 6px' }} />
<th style={{ padding: '8px 6px' }}>Nach</th> <th style={{ padding: '8px 6px' }}>Nach</th>
<th style={{ padding: '8px 6px' }}>Art</th> <th style={{ padding: '8px 6px' }}>Art</th>
<th style={{ padding: '8px 6px' }}>Entwicklungsziel</th> <th style={{ padding: '8px 6px' }}>Notiz</th>
<th style={{ padding: '8px 6px' }} /> <th style={{ padding: '8px 6px' }} />
</tr> </tr>
</thead> </thead>
@ -535,12 +983,18 @@ export default function ExerciseProgressionGraphPanel({
<tr key={row.id} style={{ borderBottom: '1px solid var(--border)' }}> <tr key={row.id} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}> <td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
<Link to={`/exercises/${row.from_exercise_id}`}>{row.from_exercise_title || `#${row.from_exercise_id}`}</Link> <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>
<td style={{ padding: '8px 6px', color: 'var(--text3)', verticalAlign: 'top' }}> <td style={{ padding: '8px 6px', color: 'var(--text3)', verticalAlign: 'top' }}>
{row.edge_type === 'sibling' ? '·' : '→'} {row.edge_type === 'sibling' ? '·' : '→'}
</td> </td>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}> <td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
<Link to={`/exercises/${row.to_exercise_id}`}>{row.to_exercise_title || `#${row.to_exercise_id}`}</Link> <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>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>{edgeTypeLabel(row.edge_type)}</td> <td style={{ padding: '8px 6px', verticalAlign: 'top' }}>{edgeTypeLabel(row.edge_type)}</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top', maxWidth: '280px' }}> <td style={{ padding: '8px 6px', verticalAlign: 'top', maxWidth: '280px' }}>
@ -554,7 +1008,7 @@ export default function ExerciseProgressionGraphPanel({
style={{ marginBottom: '6px' }} style={{ marginBottom: '6px' }}
/> />
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => saveNotes(row.id)}> <button type="button" className="btn btn-secondary" disabled={busy} onClick={() => saveNotes(row.id)}>
Notiz speichern Speichern
</button> </button>
<button type="button" className="btn" disabled={busy} onClick={() => setEditingEdgeNotes(null)}> <button type="button" className="btn" disabled={busy} onClick={() => setEditingEdgeNotes(null)}>
Abbrechen Abbrechen
@ -593,7 +1047,7 @@ export default function ExerciseProgressionGraphPanel({
</div> </div>
)} )}
<ExercisePickerModal open={pickSlot != null} onClose={() => setPickSlot(null)} onSelectExercise={onPicked} /> <ExercisePickerModal open={pickerOpen} onClose={() => setPickContext(null)} onSelectExercise={applyPickedExercise} />
</div> </div>
) )
} }

View File

@ -497,6 +497,20 @@ export async function deleteExerciseProgressionEdge(graphId, edgeId) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, { method: 'DELETE' }) return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, { method: 'DELETE' })
} }
export async function createExerciseProgressionSequence(graphId, data) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/sequence`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function deleteExerciseProgressionEdgesBatch(graphId, edgeIds) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/delete-batch`, {
method: 'POST',
body: JSON.stringify({ edge_ids: edgeIds }),
})
}
/** KI-Ausbaustufe (EXERCISES_API_SPEC): benötigt Backend + z. B. OPENROUTER_API_KEY. */ /** KI-Ausbaustufe (EXERCISES_API_SPEC): benötigt Backend + z. B. OPENROUTER_API_KEY. */
export async function suggestExerciseAi(payload) { export async function suggestExerciseAi(payload) {
return request('/api/exercises/ai/suggest', { return request('/api/exercises/ai/suggest', {
@ -1014,6 +1028,8 @@ export const api = {
createExerciseProgressionEdge, createExerciseProgressionEdge,
updateExerciseProgressionEdge, updateExerciseProgressionEdge,
deleteExerciseProgressionEdge, deleteExerciseProgressionEdge,
createExerciseProgressionSequence,
deleteExerciseProgressionEdgesBatch,
// Training Planning // Training Planning
listTrainingUnits, listTrainingUnits,