Enhance Exercise Progression Graph Panel and Editor with New Features
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m26s

- Refactored `ExerciseProgressionGraphPanel` to support a create dialog for new progression graphs, improving user experience.
- Integrated `ProgressionGraphListCard` for better visualization of existing graphs and streamlined management.
- Updated `ProgressionGraphEditor` to handle start/target analysis and improved draft hydration with AI suggestions.
- Added utility functions for managing structured responses from AI, enhancing the planning process.
- Incremented application version to reflect these updates.
This commit is contained in:
Lars 2026-06-10 16:17:40 +02:00
parent 3b483346de
commit 48d51c07c5
5 changed files with 687 additions and 276 deletions

View File

@ -1,13 +1,21 @@
/** /**
* Progressionsgraphen eine Oberfläche: Graph wählen, Roadmap-Slots bearbeiten, KI & Speichern. * Progressionsgraphen Kachel-Übersicht (wie Übungen) + Editor-Detailansicht.
*/ */
import React, { useCallback, useEffect, useMemo, useState } from 'react' import React, {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from 'react'
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import SkillProfilePanel from './skills/SkillProfilePanel' import SkillProfilePanel from './skills/SkillProfilePanel'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub' import { getTenantClubDependencyKey } from '../utils/activeClub'
import ProgressionGraphEditor from './ProgressionGraphEditor' import ProgressionGraphEditor from './ProgressionGraphEditor'
import ProgressionGraphListCard from './ProgressionGraphListCard'
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels' import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
const VIS_OPTIONS = [ const VIS_OPTIONS = [
@ -22,10 +30,14 @@ function edgeTypeLabel(type) {
return type || '—' return type || '—'
} }
export default function ExerciseProgressionGraphPanel({ function ExerciseProgressionGraphPanel(
anchorExerciseId = null, {
anchorTitle = null, anchorExerciseId = null,
}) { anchorTitle = null,
initialGraphId = null,
},
ref,
) {
const { user } = useAuth() const { user } = useAuth()
const location = useLocation() const location = useLocation()
const isSuperadmin = user?.role === 'superadmin' const isSuperadmin = user?.role === 'superadmin'
@ -42,6 +54,7 @@ export default function ExerciseProgressionGraphPanel({
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [loadErr, setLoadErr] = useState(null) const [loadErr, setLoadErr] = useState(null)
const [createModalOpen, setCreateModalOpen] = useState(false)
const [newGraphName, setNewGraphName] = useState('') const [newGraphName, setNewGraphName] = useState('')
const [newGraphVisibility, setNewGraphVisibility] = useState('private') const [newGraphVisibility, setNewGraphVisibility] = useState('private')
@ -56,16 +69,28 @@ export default function ExerciseProgressionGraphPanel({
const [skillProfileLoading, setSkillProfileLoading] = useState(false) const [skillProfileLoading, setSkillProfileLoading] = useState(false)
const [skillProfileError, setSkillProfileError] = useState('') const [skillProfileError, setSkillProfileError] = useState('')
useImperativeHandle(ref, () => ({
openCreateDialog: () => {
setNewGraphName('')
setNewGraphVisibility('private')
setCreateModalOpen(true)
},
}))
useEffect(() => { useEffect(() => {
const gid = location.state?.progressionGraphId const gid =
initialGraphId ??
location.state?.progressionGraphId
if (gid != null && Number.isFinite(Number(gid))) { if (gid != null && Number.isFinite(Number(gid))) {
setSelectedGraphId(Number(gid)) setSelectedGraphId(Number(gid))
} }
}, [location.state?.progressionGraphId]) }, [location.state?.progressionGraphId, initialGraphId])
useEffect(() => { useEffect(() => {
setSelectedGraphId(null) if (!initialGraphId && !location.state?.progressionGraphId) {
}, [tenantClubDepKey]) setSelectedGraphId(null)
}
}, [tenantClubDepKey, initialGraphId, location.state?.progressionGraphId])
const refreshGraphs = useCallback(async () => { const refreshGraphs = useCallback(async () => {
const list = await api.listExerciseProgressionGraphs() const list = await api.listExerciseProgressionGraphs()
@ -174,6 +199,7 @@ export default function ExerciseProgressionGraphPanel({
name, name,
visibility: newGraphVisibility, visibility: newGraphVisibility,
}) })
setCreateModalOpen(false)
setNewGraphName('') setNewGraphName('')
await refreshGraphs() await refreshGraphs()
if (created?.id != null) setSelectedGraphId(created.id) if (created?.id != null) setSelectedGraphId(created.id)
@ -184,6 +210,22 @@ export default function ExerciseProgressionGraphPanel({
} }
} }
const handleDeleteGraph = async (graph) => {
const gid = graph?.id ?? selectedGraphId
if (!gid) return
if (!window.confirm(`Progressionsgraph „${graph?.name || gid}" wirklich löschen?`)) return
setBusy(true)
try {
await api.deleteExerciseProgressionGraph(gid)
if (selectedGraphId === gid) setSelectedGraphId(null)
await refreshGraphs()
} catch (err) {
alert(err.message || String(err))
} finally {
setBusy(false)
}
}
const handleSaveMeta = async () => { const handleSaveMeta = async () => {
if (!selectedGraphId) return if (!selectedGraphId) return
const name = metaName.trim() const name = metaName.trim()
@ -207,21 +249,6 @@ export default function ExerciseProgressionGraphPanel({
} }
} }
const handleDeleteGraph = async () => {
if (!selectedGraphId) return
if (!window.confirm('Diesen Progressionsgraph wirklich löschen?')) return
setBusy(true)
try {
await api.deleteExerciseProgressionGraph(selectedGraphId)
setSelectedGraphId(null)
await refreshGraphs()
} catch (err) {
alert(err.message || String(err))
} finally {
setBusy(false)
}
}
const handleDeleteEdge = async (edgeId) => { const handleDeleteEdge = async (edgeId) => {
if (!selectedGraphId) return if (!selectedGraphId) return
setBusy(true) setBusy(true)
@ -261,8 +288,159 @@ export default function ExerciseProgressionGraphPanel({
await Promise.all([refreshEdges(selectedGraphId), refreshGraphs()]) await Promise.all([refreshEdges(selectedGraphId), refreshGraphs()])
}, [selectedGraphId, refreshEdges, refreshGraphs]) }, [selectedGraphId, refreshEdges, refreshGraphs])
const selectedGraph = graphs.find((g) => g.id === selectedGraphId)
if (loadErr) {
return (
<div className="card" style={{ borderColor: 'var(--danger)' }}>
<p style={{ margin: 0, color: 'var(--danger)' }}>{loadErr}</p>
</div>
)
}
if (!selectedGraphId) {
return (
<div className="exercise-progression-panel">
{anchorExerciseId != null && (
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '12px' }}>
Kontext:{' '}
<strong>{anchorTitle?.trim() || `Übung #${anchorExerciseId}`}</strong>
{' · '}
<Link to={`/exercises/${anchorExerciseId}`}>Ansehen</Link>
</p>
)}
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '16px' }}>
Progressionsgraphen planen didaktische Entwicklungspfade mit Roadmap-Slots, KI-Match und
Bewertung analog zur Übungsbibliothek als eigene Sammlung.
</p>
{busy && graphs.length === 0 ? (
<div className="empty-state card" style={{ padding: '2rem 1rem' }}>
<div className="spinner" />
<p className="muted" style={{ marginTop: '12px' }}>
Lade Progressionsgraphen
</p>
</div>
) : graphs.length === 0 ? (
<div className="card empty-state" style={{ padding: '2rem 1rem', textAlign: 'center' }}>
<p style={{ margin: '0 0 12px', color: 'var(--text2)' }}>
Noch keine Progressionsgraphen lege den ersten an.
</p>
<button
type="button"
className="btn btn-primary"
onClick={() => {
setCreateModalOpen(true)
}}
>
+ Neu
</button>
</div>
) : (
<div className="exercises-list-grid">
{graphs.map((g) => (
<ProgressionGraphListCard
key={g.id}
graph={g}
userId={user?.id}
disabled={busy}
onOpen={(row) => setSelectedGraphId(row.id)}
onDelete={handleDeleteGraph}
/>
))}
</div>
)}
{createModalOpen ? (
<div
className="admin-modal-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget && !busy) setCreateModalOpen(false)
}}
>
<div
className="admin-modal-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="new-progression-graph-title"
onClick={(e) => e.stopPropagation()}
style={{ maxWidth: '480px' }}
>
<div className="admin-modal-sheet__header">
<h3 id="new-progression-graph-title" className="admin-modal-sheet__title">
Neuer Progressionsgraph
</h3>
<button
type="button"
className="btn btn-secondary admin-modal-sheet__close"
disabled={busy}
onClick={() => setCreateModalOpen(false)}
>
Schließen
</button>
</div>
<form onSubmit={handleCreateGraph}>
<div className="admin-modal-sheet__body">
<div className="form-row">
<label className="form-label">Name *</label>
<input
className="form-input"
value={newGraphName}
onChange={(e) => setNewGraphName(e.target.value)}
placeholder="z. B. Kumite-Einstieg Verein Nord"
autoFocus
/>
</div>
<div className="form-row">
<label className="form-label">{EXERCISE_VISIBILITY_FIELD_LABEL}</label>
<select
className="form-input"
value={newGraphVisibility}
onChange={(e) => setNewGraphVisibility(e.target.value)}
>
{filteredGraphVisOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
</div>
<div className="admin-modal-sheet__footer">
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => setCreateModalOpen(false)}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={busy}>
Anlegen
</button>
</div>
</form>
</div>
</div>
) : null}
</div>
)
}
return ( return (
<div className="exercise-progression-panel"> <div className="exercise-progression-panel">
<div style={{ marginBottom: '12px', display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px' }}
disabled={busy}
onClick={() => setSelectedGraphId(null)}
>
Zur Übersicht
</button>
<h2 style={{ margin: 0, fontSize: '1.1rem', flex: '1 1 auto' }}>
{selectedGraph?.name || `Graph #${selectedGraphId}`}
</h2>
</div>
{anchorExerciseId != null && ( {anchorExerciseId != null && (
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '12px' }}> <p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '12px' }}>
Kontext:{' '} Kontext:{' '}
@ -272,84 +450,6 @@ export default function ExerciseProgressionGraphPanel({
</p> </p>
)} )}
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '16px' }}>
Ein Progressionsgraph = <strong>Roadmap mit Slots</strong> (Lernziel + Hauptübung + Schwestern).
Roadmap, KI-Match und Bewertung sind in einer Ansicht kein separater Wizard.
</p>
{loadErr && (
<div className="card" style={{ borderColor: 'var(--danger)', marginBottom: '12px' }}>
<p style={{ margin: 0, color: 'var(--danger)' }}>{loadErr}</p>
</div>
)}
<div className="card" style={{ marginBottom: '12px' }}>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph auswählen</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
<div className="form-row" style={{ flex: '1 1 220px', marginBottom: 0 }}>
<label className="form-label">Aktiver Graph</label>
<select
className="form-input"
value={selectedGraphId ?? ''}
onChange={(e) => setSelectedGraphId(e.target.value ? parseInt(e.target.value, 10) : null)}
>
<option value=""> wählen </option>
{graphs.map((g) => (
<option key={g.id} value={g.id}>
{g.name} ({g.edges_count ?? 0} Kanten)
</option>
))}
</select>
</div>
<button
type="button"
className="btn"
disabled={busy || !selectedGraphId}
onClick={() => refreshEdges(selectedGraphId)}
>
Aktualisieren
</button>
<button type="button" className="btn" disabled={busy || !selectedGraphId} onClick={handleDeleteGraph}>
Graph löschen
</button>
</div>
<form
onSubmit={handleCreateGraph}
style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid var(--border)' }}
>
<h4 style={{ margin: '0 0 8px', fontSize: '0.95rem' }}>Neuen Graphen anlegen</h4>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
<div className="form-row" style={{ flex: '2 1 200px', marginBottom: 0 }}>
<label className="form-label">Name</label>
<input
className="form-input"
value={newGraphName}
onChange={(e) => setNewGraphName(e.target.value)}
placeholder="z. B. Kumite-Einstieg Verein Nord"
/>
</div>
<div className="form-row" style={{ flex: '1 1 140px', marginBottom: 0 }}>
<label className="form-label">{EXERCISE_VISIBILITY_FIELD_LABEL}</label>
<select
className="form-input"
value={newGraphVisibility}
onChange={(e) => setNewGraphVisibility(e.target.value)}
>
{filteredGraphVisOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<button type="submit" className="btn btn-primary" disabled={busy}>
Graph erstellen
</button>
</div>
</form>
</div>
{selectedGraphId && 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
@ -361,157 +461,160 @@ export default function ExerciseProgressionGraphPanel({
</label> </label>
)} )}
{selectedGraphId ? ( <ProgressionGraphEditor
<> key={selectedGraphId}
<ProgressionGraphEditor graphId={selectedGraphId}
key={selectedGraphId} embedded
graphId={selectedGraphId} onSaved={handleEditorSaved}
embedded />
onSaved={handleEditorSaved}
/>
<details className="card" style={{ marginBottom: '12px', marginTop: '12px' }}> <details className="card" style={{ marginBottom: '12px', marginTop: '12px' }}>
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Graph-Einstellungen (Name, Sichtbarkeit)</summary> <summary style={{ cursor: 'pointer', fontWeight: 600 }}>Graph-Einstellungen (Name, Sichtbarkeit)</summary>
<div style={{ marginTop: '14px' }}> <div style={{ marginTop: '14px' }}>
<div className="form-row"> <div className="form-row">
<label className="form-label">Name</label> <label className="form-label">Name</label>
<input className="form-input" value={metaName} onChange={(e) => setMetaName(e.target.value)} /> <input className="form-input" value={metaName} onChange={(e) => setMetaName(e.target.value)} />
</div> </div>
<div className="form-row"> <div className="form-row">
<label className="form-label">Beschreibung</label> <label className="form-label">Beschreibung</label>
<textarea <textarea
className="form-input" className="form-input"
rows={2} rows={2}
value={metaDescription} value={metaDescription}
onChange={(e) => setMetaDescription(e.target.value)} onChange={(e) => setMetaDescription(e.target.value)}
/> />
</div> </div>
<div className="form-row"> <div className="form-row">
<label className="form-label">{EXERCISE_VISIBILITY_FIELD_LABEL}</label> <label className="form-label">{EXERCISE_VISIBILITY_FIELD_LABEL}</label>
<select <select
className="form-input" className="form-input"
value={metaVisibility} value={metaVisibility}
onChange={(e) => setMetaVisibility(e.target.value)} onChange={(e) => setMetaVisibility(e.target.value)}
> >
{filteredGraphVisOptions.map((o) => ( {filteredGraphVisOptions.map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>
{o.label} {o.label}
</option> </option>
))} ))}
</select> </select>
</div> </div>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={handleSaveMeta}> <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
Metadaten speichern <button type="button" className="btn btn-secondary" disabled={busy} onClick={handleSaveMeta}>
</button> Metadaten speichern
</div> </button>
</details> <button type="button" className="btn" disabled={busy} onClick={() => handleDeleteGraph(selectedGraph)}>
Graph löschen
</button>
</div>
</div>
</details>
<SkillProfilePanel <SkillProfilePanel
title="Fähigkeiten entlang des Pfads" title="Fähigkeiten entlang des Pfads"
hint="Alle Übungen als Knoten im Graph." hint="Alle Übungen als Knoten im Graph."
profile={skillProfileData?.overall} profile={skillProfileData?.overall}
loading={skillProfileLoading} loading={skillProfileLoading}
error={skillProfileError} error={skillProfileError}
defaultExpanded={false} defaultExpanded={false}
artifactType="progression_graph" artifactType="progression_graph"
/> />
<details className="card" style={{ marginBottom: '12px' }}> <details className="card" style={{ marginBottom: '12px' }}>
<summary style={{ cursor: 'pointer', fontWeight: 600 }}> <summary style={{ cursor: 'pointer', fontWeight: 600 }}>
Technische Kantenliste ({filteredEdges.length} Technische Kantenliste ({filteredEdges.length}
{edges.length !== filteredEdges.length ? ` von ${edges.length}` : ''}) {edges.length !== filteredEdges.length ? ` von ${edges.length}` : ''})
</summary> </summary>
<div style={{ marginTop: '14px' }}> <div style={{ marginTop: '14px' }}>
{filteredEdges.length === 0 ? ( {filteredEdges.length === 0 ? (
<p style={{ color: 'var(--text2)', margin: 0 }}>Keine Kanten.</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' }}>
<thead> <thead>
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)' }}> <tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)' }}>
<th style={{ padding: '8px 6px' }}>Von</th> <th style={{ padding: '8px 6px' }}>Von</th>
<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' }}>Notiz</th> <th style={{ padding: '8px 6px' }}>Notiz</th>
<th style={{ padding: '8px 6px' }} /> <th style={{ padding: '8px 6px' }} />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredEdges.map((row) => ( {filteredEdges.map((row) => (
<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}`}> <Link to={`/exercises/${row.from_exercise_id}`}>
{row.from_exercise_title || `#${row.from_exercise_id}`} {row.from_exercise_title || `#${row.from_exercise_id}`}
</Link> </Link>
</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}`}> <Link to={`/exercises/${row.to_exercise_id}`}>
{row.to_exercise_title || `#${row.to_exercise_id}`} {row.to_exercise_title || `#${row.to_exercise_id}`}
</Link> </Link>
</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' }}>
{editingEdgeNotes === row.id ? ( {editingEdgeNotes === row.id ? (
<> <>
<textarea <textarea
className="form-input" className="form-input"
rows={2} rows={2}
value={notesDraft} value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)} onChange={(e) => setNotesDraft(e.target.value)}
/> />
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
style={{ marginTop: '6px', fontSize: '12px', padding: '4px 8px' }} style={{ marginTop: '6px', fontSize: '12px', padding: '4px 8px' }}
onClick={() => saveNotes(row.id)} onClick={() => saveNotes(row.id)}
> >
Speichern Speichern
</button> </button>
</> </>
) : ( ) : (
<> <>
{row.notes || '—'} {row.notes || '—'}
<button
type="button"
className="btn"
style={{ marginLeft: '8px', fontSize: '12px', padding: '4px 8px' }}
onClick={() => startEditNotes(row)}
>
Bearbeiten
</button>
</>
)}
</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
<button <button
type="button" type="button"
className="btn" className="btn"
style={{ style={{ marginLeft: '8px', fontSize: '12px', padding: '4px 8px' }}
fontSize: '12px', onClick={() => startEditNotes(row)}
padding: '4px 8px',
background: 'var(--danger)',
color: '#fff',
border: 'none',
}}
onClick={() => handleDeleteEdge(row.id)}
> >
Löschen Bearbeiten
</button> </button>
</td> </>
</tr> )}
))} </td>
</tbody> <td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
</table> <button
</div> type="button"
)} className="btn"
style={{
fontSize: '12px',
padding: '4px 8px',
background: 'var(--danger)',
color: '#fff',
border: 'none',
}}
onClick={() => handleDeleteEdge(row.id)}
>
Löschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
</details> )}
</> </div>
) : null} </details>
</div> </div>
) )
} }
export default forwardRef(ExerciseProgressionGraphPanel)

View File

@ -25,8 +25,10 @@ import {
applyEvaluateResponseToDraft, applyEvaluateResponseToDraft,
applyGapOfferToDraft, applyGapOfferToDraft,
applyMatchResponseToDraft, applyMatchResponseToDraft,
applyResolvedStructuredToDraft,
buildPlanningArtifactFromDraft, buildPlanningArtifactFromDraft,
hydrateProgressionGraphDraft, hydrateProgressionGraphDraft,
SLOT_MIN,
insertSlotInDraft, insertSlotInDraft,
librarySlotExercise, librarySlotExercise,
majorStepsToOverridePayload, majorStepsToOverridePayload,
@ -75,6 +77,8 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const [evaluating, setEvaluating] = useState(false) const [evaluating, setEvaluating] = useState(false)
const [matching, setMatching] = useState(false) const [matching, setMatching] = useState(false)
const [roadmapLoading, setRoadmapLoading] = useState(false) const [roadmapLoading, setRoadmapLoading] = useState(false)
const [startTargetLoading, setStartTargetLoading] = useState(false)
const [startTargetReady, setStartTargetReady] = useState(false)
const [semanticBrief, setSemanticBrief] = useState(null) const [semanticBrief, setSemanticBrief] = useState(null)
const [targetSummary, setTargetSummary] = useState(null) const [targetSummary, setTargetSummary] = useState(null)
const [focusAreas, setFocusAreas] = useState([]) const [focusAreas, setFocusAreas] = useState([])
@ -109,12 +113,14 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const edgeList = Array.isArray(edges) ? edges : [] const edgeList = Array.isArray(edges) ? edges : []
setCurrentEdges(edgeList) setCurrentEdges(edgeList)
setGraphMeta(graph) setGraphMeta(graph)
setDraft( const hydrated = hydrateProgressionGraphDraft({
hydrateProgressionGraphDraft({ artifact: graph?.planning_roadmap,
artifact: graph?.planning_roadmap, edges: edgeList,
edges: edgeList, graphName: graph?.name,
graphName: graph?.name, })
}), setDraft(hydrated)
setStartTargetReady(
Boolean((hydrated.startSituation || '').trim() && (hydrated.targetState || '').trim()),
) )
const findings = graph?.planning_roadmap?.last_findings const findings = graph?.planning_roadmap?.last_findings
if (findings) setPathQa(findings) if (findings) setPathQa(findings)
@ -270,12 +276,55 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
return draft.slots.filter((s) => (s.learning_goal || '').trim().length >= 3) return draft.slots.filter((s) => (s.learning_goal || '').trim().length >= 3)
}, [draft?.slots]) }, [draft?.slots])
const runAnalyzeStartTarget = async () => {
const q = (draft?.goalQuery || '').trim()
if (q.length < 3) {
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
return
}
setStartTargetLoading(true)
setActionErr('')
try {
const res = await api.suggestProgressionPath({
query: q,
max_steps: draft.maxSteps || 5,
include_llm_intent: false,
include_path_qa: false,
include_llm_path_qa: false,
include_path_reorder: false,
include_ai_gap_fill: false,
include_roadmap_preview: false,
include_llm_roadmap: false,
include_llm_start_target: true,
start_target_only: true,
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
})
const roadmap = res?.progression_roadmap
if (!roadmap) throw new Error('Keine Start/Ziel-Analyse in der Antwort')
setDraft((prev) => {
const structured = applyResolvedStructuredToDraft(
{ ...prev, progressionRoadmap: roadmap },
roadmap,
)
return { ...structured, dirty: true }
})
setStartTargetReady(true)
setSemanticBrief(res?.semantic_brief_summary || null)
} catch (e) {
setActionErr(e.message || 'Start/Ziel-Analyse fehlgeschlagen')
} finally {
setStartTargetLoading(false)
}
}
const runRoadmapGenerate = async () => { const runRoadmapGenerate = async () => {
const q = (draft?.goalQuery || '').trim() const q = (draft?.goalQuery || '').trim()
if (q.length < 3) { if (q.length < 3) {
alert('Ziel-Anfrage: mindestens 3 Zeichen.') alert('Ziel-Anfrage: mindestens 3 Zeichen.')
return return
} }
const fieldsEmpty = !(draft.startSituation || '').trim() && !(draft.targetState || '').trim()
setRoadmapLoading(true) setRoadmapLoading(true)
setActionErr('') setActionErr('')
try { try {
@ -289,28 +338,44 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
include_ai_gap_fill: false, include_ai_gap_fill: false,
include_roadmap_preview: true, include_roadmap_preview: true,
include_llm_roadmap: true, include_llm_roadmap: true,
include_llm_start_target: fieldsEmpty,
roadmap_only: true, roadmap_only: true,
progression_graph_id: Number(graphId), progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes), ...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
}) })
const roadmap = res?.progression_roadmap const roadmap = res?.progression_roadmap
if (!roadmap) throw new Error('Keine Roadmap in der Antwort') if (!roadmap) throw new Error('Keine Roadmap in der Antwort')
const majorCount = (roadmap?.roadmap?.major_steps || []).length
if (majorCount < SLOT_MIN) throw new Error('Roadmap hat zu wenig Stufen.')
const preservedArtifact = buildPlanningArtifactFromDraft(draft) || {} const preservedArtifact = buildPlanningArtifactFromDraft(draft) || {}
let startSituation = draft.startSituation
let targetState = draft.targetState
let roadmapNotes = draft.roadmapNotes
if (fieldsEmpty) {
const patch = applyResolvedStructuredToDraft(
{ startSituation, targetState, roadmapNotes },
roadmap,
)
startSituation = patch.startSituation
targetState = patch.targetState
roadmapNotes = patch.roadmapNotes
setStartTargetReady(true)
}
const hydrated = hydrateProgressionGraphDraft({ const hydrated = hydrateProgressionGraphDraft({
artifact: { artifact: {
...preservedArtifact, ...preservedArtifact,
goal_query: q, goal_query: q,
progression_roadmap: roadmap, progression_roadmap: roadmap,
start_situation: draft.startSituation, start_situation: startSituation,
target_state: draft.targetState, target_state: targetState,
roadmap_notes: draft.roadmapNotes, roadmap_notes: roadmapNotes,
max_steps: (roadmap?.roadmap?.major_steps || []).length || draft.maxSteps, max_steps: majorCount || draft.maxSteps,
}, },
edges: currentEdges, edges: currentEdges,
graphName: draft.graphName, graphName: draft.graphName,
}) })
const withPhases = syncSlotPhasesFromRoadmap(hydrated, roadmap) const withPhases = syncSlotPhasesFromRoadmap(hydrated, roadmap)
setDraft({ ...withPhases, goalQuery: q, dirty: true }) setDraft({ ...withPhases, goalQuery: q, maxSteps: majorCount || withPhases.maxSteps, dirty: true })
setSemanticBrief(res?.semantic_brief_summary || null) setSemanticBrief(res?.semantic_brief_summary || null)
} catch (e) { } catch (e) {
setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen') setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen')
@ -686,38 +751,98 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
<div> <div>
<div className="card" style={{ marginBottom: '12px' }}> <div className="card" style={{ marginBottom: '12px' }}>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Ziel & Roadmap</h3> <h3 style={{ marginTop: 0, fontSize: '1rem' }}>Ziel & Roadmap</h3>
<div className="form-row"> <div
<label className="form-label">Ziel-Anfrage</label> style={{
<textarea display: 'flex',
className="form-input" flexWrap: 'wrap',
rows={2} gap: '10px',
value={draft.goalQuery} alignItems: 'flex-end',
disabled={busy} marginBottom: '10px',
onChange={(e) => patchDraft((d) => ({ ...d, goalQuery: e.target.value }))} }}
placeholder="z. B. Vom Anfänger zum sauberen Gerade-Tritt" >
/> <div className="form-row" style={{ flex: '2 1 240px', marginBottom: 0 }}>
</div> <label className="form-label">Ziel / Entwicklungsrichtung</label>
<div className="form-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div>
<label className="form-label">Start-Situation</label>
<input <input
className="form-input" className="form-input"
value={draft.goalQuery}
disabled={busy}
onChange={(e) => patchDraft((d) => ({ ...d, goalQuery: e.target.value }))}
placeholder="z. B. Von Erlernen bis zur Perfektion des Fußtritts Mae Geri …"
/>
</div>
<div className="form-row" style={{ flex: '0 1 100px', marginBottom: 0 }}>
<label className="form-label">Stufen (Slots)</label>
<input
type="number"
min={SLOT_MIN}
max={SLOT_MAX}
className="form-input"
value={draft.maxSteps}
disabled={busy}
onChange={(e) =>
patchDraft((d) => ({
...d,
maxSteps: Math.max(SLOT_MIN, Math.min(SLOT_MAX, Number(e.target.value) || 5)),
}))
}
/>
</div>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
gap: '10px',
}}
>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Startpunkt / Ausgangslage</label>
<textarea
className="form-input"
rows={2}
value={draft.startSituation} value={draft.startSituation}
disabled={busy} disabled={busy}
onChange={(e) => patchDraft((d) => ({ ...d, startSituation: e.target.value }))} onChange={(e) => patchDraft((d) => ({ ...d, startSituation: e.target.value }))}
placeholder="z. B. gleichartige Steppbewegung, vorhersehbar"
/> />
</div> </div>
<div> <div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Ziel-Zustand</label> <label className="form-label">Zielzustand</label>
<input <textarea
className="form-input" className="form-input"
rows={2}
value={draft.targetState} value={draft.targetState}
disabled={busy} disabled={busy}
onChange={(e) => patchDraft((d) => ({ ...d, targetState: e.target.value }))} onChange={(e) => patchDraft((d) => ({ ...d, targetState: e.target.value }))}
placeholder="z. B. dynamische Bewegung mit explosivem Angriff und Ausweichen"
/>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Ergänzungen (Fokus, Gruppe, Besonderheiten)</label>
<textarea
className="form-input"
rows={2}
value={draft.roadmapNotes}
disabled={busy}
onChange={(e) => patchDraft((d) => ({ ...d, roadmapNotes: e.target.value }))}
placeholder="optional: Altersgruppe, Kumite-Kontext, Trainingsfokus …"
/> />
</div> </div>
</div> </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '8px' }}> <p style={{ fontSize: '11px', color: 'var(--text3)', margin: '8px 0 0', lineHeight: 1.4 }}>
Optional zuerst Start/Ziel analysieren, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer,
geschieht die Analyse beim Roadmap-Vorschlag automatisch. Manuelle Eingaben haben Vorrang.
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '10px', alignItems: 'center' }}>
<button
type="button"
className="btn btn-secondary"
disabled={busy || startTargetLoading}
onClick={runAnalyzeStartTarget}
title="Nur Ausgangslage, Zielzustand und Ergänzungen per KI — ohne Roadmap-Stufen"
>
{startTargetLoading ? 'Analyse…' : 'Start/Ziel analysieren'}
</button>
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
@ -726,6 +851,11 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
> >
{roadmapLoading ? 'Roadmap…' : 'Roadmap generieren'} {roadmapLoading ? 'Roadmap…' : 'Roadmap generieren'}
</button> </button>
{startTargetReady ? (
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
Start/Ziel bereit
</span>
) : null}
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"

View File

@ -0,0 +1,126 @@
import React from 'react'
import { GitBranch, Lock, Users, Globe, Pencil, Trash2 } from 'lucide-react'
import { graphGoalQueryFromRow, graphSlotCountFromRow } from '../utils/progressionGraphDraft'
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
const VIS_LABELS = { official: 'Global', club: 'Verein', private: 'Privat' }
function visibilityLabel(v) {
return VIS_LABELS[v] || v || '—'
}
function cardClassName(graph, userId) {
const vis = graph.visibility || 'private'
const visKey = vis === 'official' || vis === 'club' || vis === 'private' ? vis : 'private'
const mine = userId != null && Number(graph.created_by) === Number(userId)
return ['card', 'exercise-card', 'progression-graph-card', `exercise-card--scope-${visKey}`, mine ? 'exercise-card--mine' : '']
.filter(Boolean)
.join(' ')
}
function VisIcon({ visibility }) {
if (visibility === 'official') return <Globe size={14} aria-hidden="true" />
if (visibility === 'club') return <Users size={14} aria-hidden="true" />
return <Lock size={14} aria-hidden="true" />
}
export default function ProgressionGraphListCard({
graph,
userId = null,
onOpen,
onDelete,
disabled = false,
}) {
const goalQuery = graphGoalQueryFromRow(graph)
const slotCount = graphSlotCountFromRow(graph)
const edgesCount = Number(graph.edges_count) || 0
const description = (graph.description || '').trim()
return (
<article className={cardClassName(graph, userId)}>
<div className="exercise-card__body">
<div className="exercise-card-title" style={{ display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
<GitBranch size={18} style={{ flexShrink: 0, marginTop: '2px', color: 'var(--accent)' }} aria-hidden="true" />
<button
type="button"
className="exercise-card__body--clickable"
style={{
border: 'none',
background: 'none',
padding: 0,
textAlign: 'left',
font: 'inherit',
color: 'inherit',
cursor: disabled ? 'default' : 'pointer',
width: '100%',
}}
disabled={disabled}
onClick={() => onOpen?.(graph)}
>
{graph.name || `Graph #${graph.id}`}
</button>
</div>
<div className="exercise-card-tags" style={{ marginTop: '8px' }}>
<span className="exercise-tag" title={EXERCISE_VISIBILITY_FIELD_LABEL}>
<VisIcon visibility={graph.visibility} />
{visibilityLabel(graph.visibility)}
</span>
{slotCount != null ? (
<span className="exercise-tag">{slotCount} Stufen</span>
) : null}
<span className="exercise-tag">
{edgesCount === 1 ? '1 Kante' : `${edgesCount} Kanten`}
</span>
</div>
{goalQuery ? (
<p className="exercise-card-summary" style={{ marginTop: '10px' }}>
<strong style={{ fontWeight: 600, color: 'var(--text2)' }}>Ziel: </strong>
{goalQuery.length > 160 ? `${goalQuery.slice(0, 160)}` : goalQuery}
</p>
) : description ? (
<p className="exercise-card-summary" style={{ marginTop: '10px' }}>
{description.length > 160 ? `${description.slice(0, 160)}` : description}
</p>
) : (
<p className="exercise-card-summary muted" style={{ marginTop: '10px' }}>
Noch kein Planungsziel hinterlegt öffnen und Roadmap anlegen.
</p>
)}
</div>
<div
className="exercise-card-layout"
style={{
padding: '10px 14px 14px',
borderTop: '1px solid var(--border)',
display: 'flex',
gap: '8px',
flexWrap: 'wrap',
}}
>
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '12px', padding: '6px 12px' }}
disabled={disabled}
onClick={() => onOpen?.(graph)}
>
<Pencil size={14} style={{ marginRight: '4px', verticalAlign: '-2px' }} aria-hidden="true" />
Bearbeiten
</button>
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '6px 12px' }}
disabled={disabled}
onClick={() => onDelete?.(graph)}
>
<Trash2 size={14} style={{ marginRight: '4px', verticalAlign: '-2px' }} aria-hidden="true" />
Löschen
</button>
</div>
</article>
)
}

View File

@ -95,6 +95,7 @@ function ExercisesListPageRoot() {
const [quickSaving, setQuickSaving] = useState(false) const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('') const [quickAiError, setQuickAiError] = useState('')
const [quickCreateDraft, setQuickCreateDraft] = useState(null) const [quickCreateDraft, setQuickCreateDraft] = useState(null)
const progressionPanelRef = useRef(null)
const planningKi = usePlanningExerciseSuggestSearch({ const planningKi = usePlanningExerciseSuggestSearch({
enabled: pageTab === 'list' && aiQuickCreateEnabled, enabled: pageTab === 'list' && aiQuickCreateEnabled,
@ -653,7 +654,13 @@ function ExercisesListPageRoot() {
)} )}
</div> </div>
) : ( ) : (
<span aria-hidden="true" /> <button
type="button"
className="btn btn-primary"
onClick={() => progressionPanelRef.current?.openCreateDialog?.()}
>
+ Neu
</button>
)} )}
</div> </div>
@ -676,7 +683,7 @@ function ExercisesListPageRoot() {
</div> </div>
} }
> >
<ExerciseProgressionGraphPanel /> <ExerciseProgressionGraphPanel ref={progressionPanelRef} />
</Suspense> </Suspense>
) : ( ) : (
<> <>

View File

@ -4,8 +4,53 @@
export const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion'] export const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
export const SLOT_MAX = 10 export const SLOT_MAX = 10
export const SLOT_MIN = 2
export const PLANNING_ARTIFACT_SCHEMA = 1 export const PLANNING_ARTIFACT_SCHEMA = 1
/** Start/Ziel/Ergänzungen aus KI-Roadmap-Antwort (resolved_structured). */
export function resolvedStructuredFromRoadmap(progressionRoadmap) {
const rs = progressionRoadmap?.resolved_structured
if (!rs) return null
const patch = {}
if (rs.start_situation) patch.startSituation = String(rs.start_situation)
if (rs.target_state) patch.targetState = String(rs.target_state)
if (rs.roadmap_notes) patch.roadmapNotes = String(rs.roadmap_notes)
return Object.keys(patch).length ? patch : null
}
export function applyResolvedStructuredToDraft(draft, progressionRoadmap, { onlyIfEmpty = false } = {}) {
const patch = resolvedStructuredFromRoadmap(progressionRoadmap)
if (!patch) return draft
const next = { ...draft }
if (patch.startSituation && (!onlyIfEmpty || !(draft.startSituation || '').trim())) {
next.startSituation = patch.startSituation
}
if (patch.targetState && (!onlyIfEmpty || !(draft.targetState || '').trim())) {
next.targetState = patch.targetState
}
if (patch.roadmapNotes && (!onlyIfEmpty || !(draft.roadmapNotes || '').trim())) {
next.roadmapNotes = patch.roadmapNotes
}
return { ...next, dirty: true }
}
export function graphGoalQueryFromRow(graph) {
const art = graph?.planning_roadmap
if (!art || typeof art !== 'object') return ''
return (art.goal_query || '').trim()
}
export function graphSlotCountFromRow(graph) {
const art = graph?.planning_roadmap
if (!art || typeof art !== 'object') return null
const slots = art.slot_contents
if (Array.isArray(slots) && slots.length) return slots.length
const majors = art.progression_roadmap?.roadmap?.major_steps
if (Array.isArray(majors) && majors.length) return majors.length
const ms = Number(art.max_steps)
return Number.isFinite(ms) && ms > 0 ? ms : null
}
const OFFER_SOURCE_LABELS = { const OFFER_SOURCE_LABELS = {
unfilled_gap: 'Lücke', unfilled_gap: 'Lücke',
off_topic: 'Themenfremd', off_topic: 'Themenfremd',