chore: update versioning and enhance exercise progression graph functionality
- Incremented application version to 0.8.7 and updated database schema version to 20260430034. - Enhanced exercise progression graph functionality by adding support for exercise variants as node endpoints and bulk creation of progression sequences. - Updated changelog to reflect new features and improvements related to progression graphs and API enhancements.
This commit is contained in:
parent
1b7a0405e9
commit
ae6c089366
|
|
@ -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
|
||||
);
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
"""
|
||||
Progressionsgraph zwischen Übungen (Übung → Übung), Migration 032.
|
||||
Progressionsgraph zwischen Übungen (Übung → Übung), Migration 032–034.
|
||||
Optional Übungsvarianten als Knoten-Endpunkte; Sequenz-Bulk-Anlage.
|
||||
AuthZ analog training_plan_templates (_template_access / _has_planning_role).
|
||||
"""
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from psycopg2 import IntegrityError
|
||||
|
||||
from auth import require_auth
|
||||
|
|
@ -33,6 +34,8 @@ class ProgressionGraphUpdate(BaseModel):
|
|||
class ProgressionEdgeCreate(BaseModel):
|
||||
from_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)
|
||||
notes: Optional[str] = Field(None, max_length=4000)
|
||||
|
||||
|
|
@ -41,12 +44,41 @@ class ProgressionEdgeUpdate(BaseModel):
|
|||
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 = """
|
||||
SELECT e.id, e.graph_id, e.from_exercise_id, e.to_exercise_id, e.edge_type, e.notes, e.created_at,
|
||||
ef.title AS from_exercise_title, et.title AS to_exercise_title
|
||||
SELECT e.id, e.graph_id,
|
||||
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
|
||||
JOIN exercises ef ON ef.id = e.from_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")
|
||||
def list_progression_graphs(session: dict = Depends(require_auth)):
|
||||
profile_id = session["profile_id"]
|
||||
|
|
@ -252,40 +335,91 @@ def create_progression_edge(
|
|||
):
|
||||
profile_id = session["profile_id"]
|
||||
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:
|
||||
cur = get_cursor(conn)
|
||||
_graph_access(cur, graph_id, profile_id, role)
|
||||
_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"
|
||||
notes = (body.notes or "").strip() or None
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO exercise_progression_edges (
|
||||
graph_id, from_exercise_id, to_exercise_id, edge_type, notes
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(graph_id, body.from_exercise_id, body.to_exercise_id, et, notes),
|
||||
row = _insert_edge_row(
|
||||
cur,
|
||||
graph_id,
|
||||
body.from_exercise_id,
|
||||
fv,
|
||||
body.to_exercise_id,
|
||||
tv,
|
||||
et,
|
||||
notes,
|
||||
)
|
||||
new_id = cur.fetchone()["id"]
|
||||
cur.execute(_EDGE_SELECT + " WHERE e.id = %s", (new_id,))
|
||||
row = r2d(cur.fetchone())
|
||||
conn.commit()
|
||||
except IntegrityError as e:
|
||||
conn.rollback()
|
||||
raise HTTPException(
|
||||
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
|
||||
|
||||
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 n−1 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}")
|
||||
def update_progression_edge(
|
||||
graph_id: int,
|
||||
|
|
@ -344,3 +478,30 @@ def delete_progression_edge(
|
|||
raise HTTPException(status_code=404, detail="Kante nicht gefunden")
|
||||
conn.commit()
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.6"
|
||||
APP_VERSION = "0.8.7"
|
||||
BUILD_DATE = "2026-04-30"
|
||||
DB_SCHEMA_VERSION = "20260430033"
|
||||
DB_SCHEMA_VERSION = "20260430034"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"auth": "1.0.0",
|
||||
|
|
@ -11,7 +11,7 @@ MODULE_VERSIONS = {
|
|||
"groups": "0.1.0",
|
||||
"skills": "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_programs": "0.1.0",
|
||||
"planning": "0.3.0",
|
||||
|
|
@ -23,6 +23,15 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
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",
|
||||
"date": "2026-04-30",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Verwaltung mehrerer Progressionsgraphen (Übung → Übung) mit Kantentypen Nachfolger / Schwester.
|
||||
* Varianten-Ketten bleiben unter „Übungsvarianten“ der jeweiligen Übung.
|
||||
* 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'
|
||||
|
|
@ -19,6 +19,82 @@ function edgeTypeLabel(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({
|
||||
anchorExerciseId = null,
|
||||
anchorTitle = null,
|
||||
|
|
@ -36,15 +112,19 @@ export default function ExerciseProgressionGraphPanel({
|
|||
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 [firstEx, setFirstEx] = useState(null)
|
||||
const [secondEx, setSecondEx] = useState(null)
|
||||
const [firstEp, setFirstEp] = useState(emptyEndpoint)
|
||||
const [secondEp, setSecondEp] = useState(emptyEndpoint)
|
||||
const [edgeNotes, setEdgeNotes] = useState('')
|
||||
const [pickSlot, setPickSlot] = useState(null)
|
||||
|
||||
const [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId)
|
||||
const [editingEdgeNotes, setEditingEdgeNotes] = useState(null)
|
||||
const [notesDraft, setNotesDraft] = useState('')
|
||||
const [uiTab, setUiTab] = useState('overview')
|
||||
|
||||
const refreshGraphs = useCallback(async () => {
|
||||
const list = await api.listExerciseProgressionGraphs()
|
||||
|
|
@ -61,6 +141,12 @@ export default function ExerciseProgressionGraphPanel({
|
|||
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 () => {
|
||||
|
|
@ -106,6 +192,30 @@ export default function ExerciseProgressionGraphPanel({
|
|||
}
|
||||
}, [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(
|
||||
|
|
@ -114,6 +224,17 @@ export default function ExerciseProgressionGraphPanel({
|
|||
)
|
||||
}, [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()
|
||||
|
|
@ -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 () => {
|
||||
if (!selectedGraphId) {
|
||||
alert('Zuerst einen Graphen wählen oder anlegen.')
|
||||
alert('Zuerst einen Graphen wählen.')
|
||||
return
|
||||
}
|
||||
if (!firstEx?.id || !secondEx?.id) {
|
||||
alert('Beide Übungen auswählen.')
|
||||
if (!firstEp.exerciseId || !secondEp.exerciseId) {
|
||||
alert('Beide Enden müssen eine Übung haben.')
|
||||
return
|
||||
}
|
||||
if (firstEx.id === secondEx.id) {
|
||||
alert('Es müssen zwei verschiedene Übungen sein.')
|
||||
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 =
|
||||
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,
|
||||
}
|
||||
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)
|
||||
|
|
@ -252,18 +447,51 @@ export default function ExerciseProgressionGraphPanel({
|
|||
}
|
||||
|
||||
const swapEnds = () => {
|
||||
const a = firstEx
|
||||
setFirstEx(secondEx)
|
||||
setSecondEx(a)
|
||||
const a = firstEp
|
||||
setFirstEp(secondEp)
|
||||
setSecondEp(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)
|
||||
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 && (
|
||||
|
|
@ -276,9 +504,10 @@ export default function ExerciseProgressionGraphPanel({
|
|||
)}
|
||||
|
||||
<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“.
|
||||
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 && (
|
||||
|
|
@ -311,7 +540,7 @@ export default function ExerciseProgressionGraphPanel({
|
|||
disabled={busy || !selectedGraphId}
|
||||
onClick={() => refreshEdges(selectedGraphId)}
|
||||
>
|
||||
Kanten neu laden
|
||||
Aktualisieren
|
||||
</button>
|
||||
<button type="button" className="btn" disabled={busy || !selectedGraphId} onClick={handleDeleteGraph}>
|
||||
Graph löschen
|
||||
|
|
@ -384,120 +613,339 @@ export default function ExerciseProgressionGraphPanel({
|
|||
)}
|
||||
|
||||
{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
|
||||
<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' && (
|
||||
<>
|
||||
<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
|
||||
|
|
@ -505,18 +953,18 @@ export default function ExerciseProgressionGraphPanel({
|
|||
checked={filterAnchorOnly}
|
||||
onChange={(e) => setFilterAnchorOnly(e.target.checked)}
|
||||
/>
|
||||
Nur Kanten anzeigen, die diese Übung betreffen
|
||||
Nur Kanten, die diese Übung betreffen (Übersicht & Tabelle)
|
||||
</label>
|
||||
)}
|
||||
|
||||
{selectedGraphId && (
|
||||
{selectedGraphId && uiTab === 'table' && (
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>
|
||||
Kanten ({filteredEdges.length}
|
||||
Alle 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>
|
||||
<p style={{ color: 'var(--text2)', margin: 0 }}>Keine Kanten.</p>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<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' }}>Nach</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' }} />
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -535,12 +983,18 @@ export default function ExerciseProgressionGraphPanel({
|
|||
<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' }}>
|
||||
|
|
@ -554,7 +1008,7 @@ export default function ExerciseProgressionGraphPanel({
|
|||
style={{ marginBottom: '6px' }}
|
||||
/>
|
||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => saveNotes(row.id)}>
|
||||
Notiz speichern
|
||||
Speichern
|
||||
</button>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => setEditingEdgeNotes(null)}>
|
||||
Abbrechen
|
||||
|
|
@ -593,7 +1047,7 @@ export default function ExerciseProgressionGraphPanel({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<ExercisePickerModal open={pickSlot != null} onClose={() => setPickSlot(null)} onSelectExercise={onPicked} />
|
||||
<ExercisePickerModal open={pickerOpen} onClose={() => setPickContext(null)} onSelectExercise={applyPickedExercise} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -497,6 +497,20 @@ export async function deleteExerciseProgressionEdge(graphId, edgeId) {
|
|||
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. */
|
||||
export async function suggestExerciseAi(payload) {
|
||||
return request('/api/exercises/ai/suggest', {
|
||||
|
|
@ -1014,6 +1028,8 @@ export const api = {
|
|||
createExerciseProgressionEdge,
|
||||
updateExerciseProgressionEdge,
|
||||
deleteExerciseProgressionEdge,
|
||||
createExerciseProgressionSequence,
|
||||
deleteExerciseProgressionEdgesBatch,
|
||||
|
||||
// Training Planning
|
||||
listTrainingUnits,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user