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 api from '../utils/api'
import SkillProfilePanel from './skills/SkillProfilePanel'
import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
import ProgressionGraphEditor from './ProgressionGraphEditor'
import ProgressionGraphListCard from './ProgressionGraphListCard'
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
const VIS_OPTIONS = [
@ -22,10 +30,14 @@ function edgeTypeLabel(type) {
return type || '—'
}
export default function ExerciseProgressionGraphPanel({
anchorExerciseId = null,
anchorTitle = null,
}) {
function ExerciseProgressionGraphPanel(
{
anchorExerciseId = null,
anchorTitle = null,
initialGraphId = null,
},
ref,
) {
const { user } = useAuth()
const location = useLocation()
const isSuperadmin = user?.role === 'superadmin'
@ -42,6 +54,7 @@ export default function ExerciseProgressionGraphPanel({
const [busy, setBusy] = useState(false)
const [loadErr, setLoadErr] = useState(null)
const [createModalOpen, setCreateModalOpen] = useState(false)
const [newGraphName, setNewGraphName] = useState('')
const [newGraphVisibility, setNewGraphVisibility] = useState('private')
@ -56,16 +69,28 @@ export default function ExerciseProgressionGraphPanel({
const [skillProfileLoading, setSkillProfileLoading] = useState(false)
const [skillProfileError, setSkillProfileError] = useState('')
useImperativeHandle(ref, () => ({
openCreateDialog: () => {
setNewGraphName('')
setNewGraphVisibility('private')
setCreateModalOpen(true)
},
}))
useEffect(() => {
const gid = location.state?.progressionGraphId
const gid =
initialGraphId ??
location.state?.progressionGraphId
if (gid != null && Number.isFinite(Number(gid))) {
setSelectedGraphId(Number(gid))
}
}, [location.state?.progressionGraphId])
}, [location.state?.progressionGraphId, initialGraphId])
useEffect(() => {
setSelectedGraphId(null)
}, [tenantClubDepKey])
if (!initialGraphId && !location.state?.progressionGraphId) {
setSelectedGraphId(null)
}
}, [tenantClubDepKey, initialGraphId, location.state?.progressionGraphId])
const refreshGraphs = useCallback(async () => {
const list = await api.listExerciseProgressionGraphs()
@ -174,6 +199,7 @@ export default function ExerciseProgressionGraphPanel({
name,
visibility: newGraphVisibility,
})
setCreateModalOpen(false)
setNewGraphName('')
await refreshGraphs()
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 () => {
if (!selectedGraphId) return
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) => {
if (!selectedGraphId) return
setBusy(true)
@ -261,8 +288,159 @@ export default function ExerciseProgressionGraphPanel({
await Promise.all([refreshEdges(selectedGraphId), 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 (
<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 && (
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '12px' }}>
Kontext:{' '}
@ -272,84 +450,6 @@ export default function ExerciseProgressionGraphPanel({
</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 && (
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', fontSize: '13px' }}>
<input
@ -361,157 +461,160 @@ export default function ExerciseProgressionGraphPanel({
</label>
)}
{selectedGraphId ? (
<>
<ProgressionGraphEditor
key={selectedGraphId}
graphId={selectedGraphId}
embedded
onSaved={handleEditorSaved}
/>
<ProgressionGraphEditor
key={selectedGraphId}
graphId={selectedGraphId}
embedded
onSaved={handleEditorSaved}
/>
<details className="card" style={{ marginBottom: '12px', marginTop: '12px' }}>
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Graph-Einstellungen (Name, Sichtbarkeit)</summary>
<div style={{ marginTop: '14px' }}>
<div className="form-row">
<label className="form-label">Name</label>
<input className="form-input" value={metaName} onChange={(e) => setMetaName(e.target.value)} />
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
rows={2}
value={metaDescription}
onChange={(e) => setMetaDescription(e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">{EXERCISE_VISIBILITY_FIELD_LABEL}</label>
<select
className="form-input"
value={metaVisibility}
onChange={(e) => setMetaVisibility(e.target.value)}
>
{filteredGraphVisOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={handleSaveMeta}>
Metadaten speichern
</button>
</div>
</details>
<details className="card" style={{ marginBottom: '12px', marginTop: '12px' }}>
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Graph-Einstellungen (Name, Sichtbarkeit)</summary>
<div style={{ marginTop: '14px' }}>
<div className="form-row">
<label className="form-label">Name</label>
<input className="form-input" value={metaName} onChange={(e) => setMetaName(e.target.value)} />
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
rows={2}
value={metaDescription}
onChange={(e) => setMetaDescription(e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">{EXERCISE_VISIBILITY_FIELD_LABEL}</label>
<select
className="form-input"
value={metaVisibility}
onChange={(e) => setMetaVisibility(e.target.value)}
>
{filteredGraphVisOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={handleSaveMeta}>
Metadaten speichern
</button>
<button type="button" className="btn" disabled={busy} onClick={() => handleDeleteGraph(selectedGraph)}>
Graph löschen
</button>
</div>
</div>
</details>
<SkillProfilePanel
title="Fähigkeiten entlang des Pfads"
hint="Alle Übungen als Knoten im Graph."
profile={skillProfileData?.overall}
loading={skillProfileLoading}
error={skillProfileError}
defaultExpanded={false}
artifactType="progression_graph"
/>
<SkillProfilePanel
title="Fähigkeiten entlang des Pfads"
hint="Alle Übungen als Knoten im Graph."
profile={skillProfileData?.overall}
loading={skillProfileLoading}
error={skillProfileError}
defaultExpanded={false}
artifactType="progression_graph"
/>
<details className="card" style={{ marginBottom: '12px' }}>
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>
Technische Kantenliste ({filteredEdges.length}
{edges.length !== filteredEdges.length ? ` von ${edges.length}` : ''})
</summary>
<div style={{ marginTop: '14px' }}>
{filteredEdges.length === 0 ? (
<p style={{ color: 'var(--text2)', margin: 0 }}>Keine Kanten.</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)' }}>
<th style={{ padding: '8px 6px' }}>Von</th>
<th style={{ padding: '8px 6px' }} />
<th style={{ padding: '8px 6px' }}>Nach</th>
<th style={{ padding: '8px 6px' }}>Art</th>
<th style={{ padding: '8px 6px' }}>Notiz</th>
<th style={{ padding: '8px 6px' }} />
</tr>
</thead>
<tbody>
{filteredEdges.map((row) => (
<tr key={row.id} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
<Link to={`/exercises/${row.from_exercise_id}`}>
{row.from_exercise_title || `#${row.from_exercise_id}`}
</Link>
</td>
<td style={{ padding: '8px 6px', color: 'var(--text3)', verticalAlign: 'top' }}>
{row.edge_type === 'sibling' ? '·' : '→'}
</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
<Link to={`/exercises/${row.to_exercise_id}`}>
{row.to_exercise_title || `#${row.to_exercise_id}`}
</Link>
</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>{edgeTypeLabel(row.edge_type)}</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top', maxWidth: '280px' }}>
{editingEdgeNotes === row.id ? (
<>
<textarea
className="form-input"
rows={2}
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
/>
<button
type="button"
className="btn btn-primary"
style={{ marginTop: '6px', fontSize: '12px', padding: '4px 8px' }}
onClick={() => saveNotes(row.id)}
>
Speichern
</button>
</>
) : (
<>
{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' }}>
<details className="card" style={{ marginBottom: '12px' }}>
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>
Technische Kantenliste ({filteredEdges.length}
{edges.length !== filteredEdges.length ? ` von ${edges.length}` : ''})
</summary>
<div style={{ marginTop: '14px' }}>
{filteredEdges.length === 0 ? (
<p style={{ color: 'var(--text2)', margin: 0 }}>Keine Kanten.</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)' }}>
<th style={{ padding: '8px 6px' }}>Von</th>
<th style={{ padding: '8px 6px' }} />
<th style={{ padding: '8px 6px' }}>Nach</th>
<th style={{ padding: '8px 6px' }}>Art</th>
<th style={{ padding: '8px 6px' }}>Notiz</th>
<th style={{ padding: '8px 6px' }} />
</tr>
</thead>
<tbody>
{filteredEdges.map((row) => (
<tr key={row.id} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
<Link to={`/exercises/${row.from_exercise_id}`}>
{row.from_exercise_title || `#${row.from_exercise_id}`}
</Link>
</td>
<td style={{ padding: '8px 6px', color: 'var(--text3)', verticalAlign: 'top' }}>
{row.edge_type === 'sibling' ? '·' : '→'}
</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
<Link to={`/exercises/${row.to_exercise_id}`}>
{row.to_exercise_title || `#${row.to_exercise_id}`}
</Link>
</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>{edgeTypeLabel(row.edge_type)}</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top', maxWidth: '280px' }}>
{editingEdgeNotes === row.id ? (
<>
<textarea
className="form-input"
rows={2}
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
/>
<button
type="button"
className="btn btn-primary"
style={{ marginTop: '6px', fontSize: '12px', padding: '4px 8px' }}
onClick={() => saveNotes(row.id)}
>
Speichern
</button>
</>
) : (
<>
{row.notes || '—'}
<button
type="button"
className="btn"
style={{
fontSize: '12px',
padding: '4px 8px',
background: 'var(--danger)',
color: '#fff',
border: 'none',
}}
onClick={() => handleDeleteEdge(row.id)}
style={{ marginLeft: '8px', fontSize: '12px', padding: '4px 8px' }}
onClick={() => startEditNotes(row)}
>
Löschen
Bearbeiten
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</td>
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
<button
type="button"
className="btn"
style={{
fontSize: '12px',
padding: '4px 8px',
background: 'var(--danger)',
color: '#fff',
border: 'none',
}}
onClick={() => handleDeleteEdge(row.id)}
>
Löschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
</>
) : null}
)}
</div>
</details>
</div>
)
}
export default forwardRef(ExerciseProgressionGraphPanel)

View File

@ -25,8 +25,10 @@ import {
applyEvaluateResponseToDraft,
applyGapOfferToDraft,
applyMatchResponseToDraft,
applyResolvedStructuredToDraft,
buildPlanningArtifactFromDraft,
hydrateProgressionGraphDraft,
SLOT_MIN,
insertSlotInDraft,
librarySlotExercise,
majorStepsToOverridePayload,
@ -75,6 +77,8 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const [evaluating, setEvaluating] = useState(false)
const [matching, setMatching] = useState(false)
const [roadmapLoading, setRoadmapLoading] = useState(false)
const [startTargetLoading, setStartTargetLoading] = useState(false)
const [startTargetReady, setStartTargetReady] = useState(false)
const [semanticBrief, setSemanticBrief] = useState(null)
const [targetSummary, setTargetSummary] = useState(null)
const [focusAreas, setFocusAreas] = useState([])
@ -109,12 +113,14 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const edgeList = Array.isArray(edges) ? edges : []
setCurrentEdges(edgeList)
setGraphMeta(graph)
setDraft(
hydrateProgressionGraphDraft({
artifact: graph?.planning_roadmap,
edges: edgeList,
graphName: graph?.name,
}),
const hydrated = hydrateProgressionGraphDraft({
artifact: graph?.planning_roadmap,
edges: edgeList,
graphName: graph?.name,
})
setDraft(hydrated)
setStartTargetReady(
Boolean((hydrated.startSituation || '').trim() && (hydrated.targetState || '').trim()),
)
const findings = graph?.planning_roadmap?.last_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)
}, [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 q = (draft?.goalQuery || '').trim()
if (q.length < 3) {
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
return
}
const fieldsEmpty = !(draft.startSituation || '').trim() && !(draft.targetState || '').trim()
setRoadmapLoading(true)
setActionErr('')
try {
@ -289,28 +338,44 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
include_ai_gap_fill: false,
include_roadmap_preview: true,
include_llm_roadmap: true,
include_llm_start_target: fieldsEmpty,
roadmap_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 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) || {}
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({
artifact: {
...preservedArtifact,
goal_query: q,
progression_roadmap: roadmap,
start_situation: draft.startSituation,
target_state: draft.targetState,
roadmap_notes: draft.roadmapNotes,
max_steps: (roadmap?.roadmap?.major_steps || []).length || draft.maxSteps,
start_situation: startSituation,
target_state: targetState,
roadmap_notes: roadmapNotes,
max_steps: majorCount || draft.maxSteps,
},
edges: currentEdges,
graphName: draft.graphName,
})
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)
} catch (e) {
setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen')
@ -686,38 +751,98 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
<div>
<div className="card" style={{ marginBottom: '12px' }}>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Ziel & Roadmap</h3>
<div className="form-row">
<label className="form-label">Ziel-Anfrage</label>
<textarea
className="form-input"
rows={2}
value={draft.goalQuery}
disabled={busy}
onChange={(e) => patchDraft((d) => ({ ...d, goalQuery: e.target.value }))}
placeholder="z. B. Vom Anfänger zum sauberen Gerade-Tritt"
/>
</div>
<div className="form-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div>
<label className="form-label">Start-Situation</label>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '10px',
alignItems: 'flex-end',
marginBottom: '10px',
}}
>
<div className="form-row" style={{ flex: '2 1 240px', marginBottom: 0 }}>
<label className="form-label">Ziel / Entwicklungsrichtung</label>
<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}
disabled={busy}
onChange={(e) => patchDraft((d) => ({ ...d, startSituation: e.target.value }))}
placeholder="z. B. gleichartige Steppbewegung, vorhersehbar"
/>
</div>
<div>
<label className="form-label">Ziel-Zustand</label>
<input
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Zielzustand</label>
<textarea
className="form-input"
rows={2}
value={draft.targetState}
disabled={busy}
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 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
type="button"
className="btn btn-secondary"
@ -726,6 +851,11 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
>
{roadmapLoading ? 'Roadmap…' : 'Roadmap generieren'}
</button>
{startTargetReady ? (
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
Start/Ziel bereit
</span>
) : null}
<button
type="button"
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 [quickAiError, setQuickAiError] = useState('')
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
const progressionPanelRef = useRef(null)
const planningKi = usePlanningExerciseSuggestSearch({
enabled: pageTab === 'list' && aiQuickCreateEnabled,
@ -653,7 +654,13 @@ function ExercisesListPageRoot() {
)}
</div>
) : (
<span aria-hidden="true" />
<button
type="button"
className="btn btn-primary"
onClick={() => progressionPanelRef.current?.openCreateDialog?.()}
>
+ Neu
</button>
)}
</div>
@ -676,7 +683,7 @@ function ExercisesListPageRoot() {
</div>
}
>
<ExerciseProgressionGraphPanel />
<ExerciseProgressionGraphPanel ref={progressionPanelRef} />
</Suspense>
) : (
<>

View File

@ -4,8 +4,53 @@
export const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
export const SLOT_MAX = 10
export const SLOT_MIN = 2
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 = {
unfilled_gap: 'Lücke',
off_topic: 'Themenfremd',