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
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:
parent
3b483346de
commit
48d51c07c5
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
126
frontend/src/components/ProgressionGraphListCard.jsx
Normal file
126
frontend/src/components/ProgressionGraphListCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user