diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx
index a8be046..a0a2988 100644
--- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx
+++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx
@@ -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 (
+
+ )
+ }
+
+ if (!selectedGraphId) {
+ return (
+
+ {anchorExerciseId != null && (
+
+ Kontext:{' '}
+ {anchorTitle?.trim() || `Übung #${anchorExerciseId}`}
+ {' · '}
+ Ansehen
+
+ )}
+
+
+ Progressionsgraphen planen didaktische Entwicklungspfade mit Roadmap-Slots, KI-Match und
+ Bewertung — analog zur Übungsbibliothek als eigene Sammlung.
+
+
+ {busy && graphs.length === 0 ? (
+
+
+
+ Lade Progressionsgraphen…
+
+
+ ) : graphs.length === 0 ? (
+
+
+ Noch keine Progressionsgraphen — lege den ersten an.
+
+
{
+ setCreateModalOpen(true)
+ }}
+ >
+ + Neu
+
+
+ ) : (
+
+ {graphs.map((g) => (
+
setSelectedGraphId(row.id)}
+ onDelete={handleDeleteGraph}
+ />
+ ))}
+
+ )}
+
+ {createModalOpen ? (
+
{
+ if (e.target === e.currentTarget && !busy) setCreateModalOpen(false)
+ }}
+ >
+
e.stopPropagation()}
+ style={{ maxWidth: '480px' }}
+ >
+
+
+ Neuer Progressionsgraph
+
+ setCreateModalOpen(false)}
+ >
+ Schließen
+
+
+
+
+
+ ) : null}
+
+ )
+ }
+
return (
+
+ setSelectedGraphId(null)}
+ >
+ ← Zur Übersicht
+
+
+ {selectedGraph?.name || `Graph #${selectedGraphId}`}
+
+
+
{anchorExerciseId != null && (
Kontext:{' '}
@@ -272,84 +450,6 @@ export default function ExerciseProgressionGraphPanel({
)}
-
- Ein Progressionsgraph = Roadmap mit Slots (Lernziel + Hauptübung + Schwestern).
- Roadmap, KI-Match und Bewertung sind in einer Ansicht — kein separater Wizard.
-
-
- {loadErr && (
-
- )}
-
-
-
Graph auswählen
-
-
- Aktiver Graph
- setSelectedGraphId(e.target.value ? parseInt(e.target.value, 10) : null)}
- >
- — wählen —
- {graphs.map((g) => (
-
- {g.name} ({g.edges_count ?? 0} Kanten)
-
- ))}
-
-
-
refreshEdges(selectedGraphId)}
- >
- Aktualisieren
-
-
- Graph löschen
-
-
-
-
-
-
{selectedGraphId && anchorExerciseId != null && (
)}
- {selectedGraphId ? (
- <>
-
+
-
- Graph-Einstellungen (Name, Sichtbarkeit)
-
-
- Name
- setMetaName(e.target.value)} />
-
-
- Beschreibung
-
-
- {EXERCISE_VISIBILITY_FIELD_LABEL}
- setMetaVisibility(e.target.value)}
- >
- {filteredGraphVisOptions.map((o) => (
-
- {o.label}
-
- ))}
-
-
-
- Metadaten speichern
-
-
-
+
+ Graph-Einstellungen (Name, Sichtbarkeit)
+
+
+ Name
+ setMetaName(e.target.value)} />
+
+
+ Beschreibung
+
+
+ {EXERCISE_VISIBILITY_FIELD_LABEL}
+ setMetaVisibility(e.target.value)}
+ >
+ {filteredGraphVisOptions.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+
+
+ Metadaten speichern
+
+ handleDeleteGraph(selectedGraph)}>
+ Graph löschen
+
+
+
+
-
+
-
-
- Technische Kantenliste ({filteredEdges.length}
- {edges.length !== filteredEdges.length ? ` von ${edges.length}` : ''})
-
-
- {filteredEdges.length === 0 ? (
-
Keine Kanten.
- ) : (
-
-
-
-
- Von
-
- Nach
- Art
- Notiz
-
-
-
-
- {filteredEdges.map((row) => (
-
-
-
- {row.from_exercise_title || `#${row.from_exercise_id}`}
-
-
-
- {row.edge_type === 'sibling' ? '·' : '→'}
-
-
-
- {row.to_exercise_title || `#${row.to_exercise_id}`}
-
-
- {edgeTypeLabel(row.edge_type)}
-
- {editingEdgeNotes === row.id ? (
- <>
-
-
+
+
+ Technische Kantenliste ({filteredEdges.length}
+ {edges.length !== filteredEdges.length ? ` von ${edges.length}` : ''})
+
+
+ {filteredEdges.length === 0 ? (
+
Keine Kanten.
+ ) : (
+
+
+
+
+ Von
+
+ Nach
+ Art
+ Notiz
+
+
+
+
+ {filteredEdges.map((row) => (
+
+
+
+ {row.from_exercise_title || `#${row.from_exercise_id}`}
+
+
+
+ {row.edge_type === 'sibling' ? '·' : '→'}
+
+
+
+ {row.to_exercise_title || `#${row.to_exercise_id}`}
+
+
+ {edgeTypeLabel(row.edge_type)}
+
+ {editingEdgeNotes === row.id ? (
+ <>
+
-
- ))}
-
-
-
- )}
+ >
+ )}
+
+
+ handleDeleteEdge(row.id)}
+ >
+ Löschen
+
+
+
+ ))}
+
+
-
- >
- ) : null}
+ )}
+
+
)
}
+
+export default forwardRef(ExerciseProgressionGraphPanel)
diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx
index 04c0ae4..3a02888 100644
--- a/frontend/src/components/ProgressionGraphEditor.jsx
+++ b/frontend/src/components/ProgressionGraphEditor.jsx
@@ -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
Ziel & Roadmap
-
- Ziel-Anfrage
-
-
-
-
Start-Situation
+
+
+
+ Startpunkt / Ausgangslage
+
-
- Ziel-Zustand
-
+ Zielzustand
+
+
+ Ergänzungen (Fokus, Gruppe, Besonderheiten)
+
-
+
+ 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.
+
+
+
+ {startTargetLoading ? 'Analyse…' : 'Start/Ziel analysieren'}
+
{roadmapLoading ? 'Roadmap…' : 'Roadmap generieren'}
+ {startTargetReady ? (
+
+ Start/Ziel bereit
+
+ ) : null}
+ if (visibility === 'club') return
+ return
+}
+
+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 (
+
+
+
+
+ onOpen?.(graph)}
+ >
+ {graph.name || `Graph #${graph.id}`}
+
+
+
+
+
+
+ {visibilityLabel(graph.visibility)}
+
+ {slotCount != null ? (
+ {slotCount} Stufen
+ ) : null}
+
+ {edgesCount === 1 ? '1 Kante' : `${edgesCount} Kanten`}
+
+
+
+ {goalQuery ? (
+
+ Ziel:
+ {goalQuery.length > 160 ? `${goalQuery.slice(0, 160)}…` : goalQuery}
+
+ ) : description ? (
+
+ {description.length > 160 ? `${description.slice(0, 160)}…` : description}
+
+ ) : (
+
+ Noch kein Planungsziel hinterlegt — öffnen und Roadmap anlegen.
+
+ )}
+
+
+
+
onOpen?.(graph)}
+ >
+
+ Bearbeiten
+
+
onDelete?.(graph)}
+ >
+
+ Löschen
+
+
+
+ )
+}
diff --git a/frontend/src/components/exercises/ExercisesListPageRoot.jsx b/frontend/src/components/exercises/ExercisesListPageRoot.jsx
index 361a38a..ea03bee 100644
--- a/frontend/src/components/exercises/ExercisesListPageRoot.jsx
+++ b/frontend/src/components/exercises/ExercisesListPageRoot.jsx
@@ -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() {
)}
) : (
-
+
progressionPanelRef.current?.openCreateDialog?.()}
+ >
+ + Neu
+
)}
@@ -676,7 +683,7 @@ function ExercisesListPageRoot() {
}
>
-
+
) : (
<>
diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js
index 2718e63..18ee771 100644
--- a/frontend/src/utils/progressionGraphDraft.js
+++ b/frontend/src/utils/progressionGraphDraft.js
@@ -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',