Progression optimiert Phase A #55
|
|
@ -1,13 +1,21 @@
|
||||||
/**
|
/**
|
||||||
* Progressionsgraphen — eine Oberfläche: Graph wählen, Roadmap-Slots bearbeiten, KI & Speichern.
|
* Progressionsgraphen — Kachel-Übersicht (wie Übungen) + Editor-Detailansicht.
|
||||||
*/
|
*/
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
import React, {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
import { Link, useLocation } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import SkillProfilePanel from './skills/SkillProfilePanel'
|
import SkillProfilePanel from './skills/SkillProfilePanel'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
||||||
import ProgressionGraphEditor from './ProgressionGraphEditor'
|
import ProgressionGraphEditor from './ProgressionGraphEditor'
|
||||||
|
import ProgressionGraphListCard from './ProgressionGraphListCard'
|
||||||
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
|
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
|
||||||
|
|
||||||
const VIS_OPTIONS = [
|
const VIS_OPTIONS = [
|
||||||
|
|
@ -22,10 +30,14 @@ function edgeTypeLabel(type) {
|
||||||
return type || '—'
|
return type || '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExerciseProgressionGraphPanel({
|
function ExerciseProgressionGraphPanel(
|
||||||
anchorExerciseId = null,
|
{
|
||||||
anchorTitle = null,
|
anchorExerciseId = null,
|
||||||
}) {
|
anchorTitle = null,
|
||||||
|
initialGraphId = null,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const isSuperadmin = user?.role === 'superadmin'
|
const isSuperadmin = user?.role === 'superadmin'
|
||||||
|
|
@ -42,6 +54,7 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [loadErr, setLoadErr] = useState(null)
|
const [loadErr, setLoadErr] = useState(null)
|
||||||
|
|
||||||
|
const [createModalOpen, setCreateModalOpen] = useState(false)
|
||||||
const [newGraphName, setNewGraphName] = useState('')
|
const [newGraphName, setNewGraphName] = useState('')
|
||||||
const [newGraphVisibility, setNewGraphVisibility] = useState('private')
|
const [newGraphVisibility, setNewGraphVisibility] = useState('private')
|
||||||
|
|
||||||
|
|
@ -56,16 +69,28 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
const [skillProfileLoading, setSkillProfileLoading] = useState(false)
|
const [skillProfileLoading, setSkillProfileLoading] = useState(false)
|
||||||
const [skillProfileError, setSkillProfileError] = useState('')
|
const [skillProfileError, setSkillProfileError] = useState('')
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
openCreateDialog: () => {
|
||||||
|
setNewGraphName('')
|
||||||
|
setNewGraphVisibility('private')
|
||||||
|
setCreateModalOpen(true)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const gid = location.state?.progressionGraphId
|
const gid =
|
||||||
|
initialGraphId ??
|
||||||
|
location.state?.progressionGraphId
|
||||||
if (gid != null && Number.isFinite(Number(gid))) {
|
if (gid != null && Number.isFinite(Number(gid))) {
|
||||||
setSelectedGraphId(Number(gid))
|
setSelectedGraphId(Number(gid))
|
||||||
}
|
}
|
||||||
}, [location.state?.progressionGraphId])
|
}, [location.state?.progressionGraphId, initialGraphId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedGraphId(null)
|
if (!initialGraphId && !location.state?.progressionGraphId) {
|
||||||
}, [tenantClubDepKey])
|
setSelectedGraphId(null)
|
||||||
|
}
|
||||||
|
}, [tenantClubDepKey, initialGraphId, location.state?.progressionGraphId])
|
||||||
|
|
||||||
const refreshGraphs = useCallback(async () => {
|
const refreshGraphs = useCallback(async () => {
|
||||||
const list = await api.listExerciseProgressionGraphs()
|
const list = await api.listExerciseProgressionGraphs()
|
||||||
|
|
@ -174,6 +199,7 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
name,
|
name,
|
||||||
visibility: newGraphVisibility,
|
visibility: newGraphVisibility,
|
||||||
})
|
})
|
||||||
|
setCreateModalOpen(false)
|
||||||
setNewGraphName('')
|
setNewGraphName('')
|
||||||
await refreshGraphs()
|
await refreshGraphs()
|
||||||
if (created?.id != null) setSelectedGraphId(created.id)
|
if (created?.id != null) setSelectedGraphId(created.id)
|
||||||
|
|
@ -184,6 +210,22 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDeleteGraph = async (graph) => {
|
||||||
|
const gid = graph?.id ?? selectedGraphId
|
||||||
|
if (!gid) return
|
||||||
|
if (!window.confirm(`Progressionsgraph „${graph?.name || gid}" wirklich löschen?`)) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.deleteExerciseProgressionGraph(gid)
|
||||||
|
if (selectedGraphId === gid) setSelectedGraphId(null)
|
||||||
|
await refreshGraphs()
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || String(err))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSaveMeta = async () => {
|
const handleSaveMeta = async () => {
|
||||||
if (!selectedGraphId) return
|
if (!selectedGraphId) return
|
||||||
const name = metaName.trim()
|
const name = metaName.trim()
|
||||||
|
|
@ -207,21 +249,6 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteGraph = async () => {
|
|
||||||
if (!selectedGraphId) return
|
|
||||||
if (!window.confirm('Diesen Progressionsgraph wirklich löschen?')) return
|
|
||||||
setBusy(true)
|
|
||||||
try {
|
|
||||||
await api.deleteExerciseProgressionGraph(selectedGraphId)
|
|
||||||
setSelectedGraphId(null)
|
|
||||||
await refreshGraphs()
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message || String(err))
|
|
||||||
} finally {
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteEdge = async (edgeId) => {
|
const handleDeleteEdge = async (edgeId) => {
|
||||||
if (!selectedGraphId) return
|
if (!selectedGraphId) return
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
|
|
@ -261,8 +288,159 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
await Promise.all([refreshEdges(selectedGraphId), refreshGraphs()])
|
await Promise.all([refreshEdges(selectedGraphId), refreshGraphs()])
|
||||||
}, [selectedGraphId, refreshEdges, refreshGraphs])
|
}, [selectedGraphId, refreshEdges, refreshGraphs])
|
||||||
|
|
||||||
|
const selectedGraph = graphs.find((g) => g.id === selectedGraphId)
|
||||||
|
|
||||||
|
if (loadErr) {
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ borderColor: 'var(--danger)' }}>
|
||||||
|
<p style={{ margin: 0, color: 'var(--danger)' }}>{loadErr}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedGraphId) {
|
||||||
|
return (
|
||||||
|
<div className="exercise-progression-panel">
|
||||||
|
{anchorExerciseId != null && (
|
||||||
|
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '12px' }}>
|
||||||
|
Kontext:{' '}
|
||||||
|
<strong>{anchorTitle?.trim() || `Übung #${anchorExerciseId}`}</strong>
|
||||||
|
{' · '}
|
||||||
|
<Link to={`/exercises/${anchorExerciseId}`}>Ansehen</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '16px' }}>
|
||||||
|
Progressionsgraphen planen didaktische Entwicklungspfade mit Roadmap-Slots, KI-Match und
|
||||||
|
Bewertung — analog zur Übungsbibliothek als eigene Sammlung.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{busy && graphs.length === 0 ? (
|
||||||
|
<div className="empty-state card" style={{ padding: '2rem 1rem' }}>
|
||||||
|
<div className="spinner" />
|
||||||
|
<p className="muted" style={{ marginTop: '12px' }}>
|
||||||
|
Lade Progressionsgraphen…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : graphs.length === 0 ? (
|
||||||
|
<div className="card empty-state" style={{ padding: '2rem 1rem', textAlign: 'center' }}>
|
||||||
|
<p style={{ margin: '0 0 12px', color: 'var(--text2)' }}>
|
||||||
|
Noch keine Progressionsgraphen — lege den ersten an.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
setCreateModalOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Neu
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="exercises-list-grid">
|
||||||
|
{graphs.map((g) => (
|
||||||
|
<ProgressionGraphListCard
|
||||||
|
key={g.id}
|
||||||
|
graph={g}
|
||||||
|
userId={user?.id}
|
||||||
|
disabled={busy}
|
||||||
|
onOpen={(row) => setSelectedGraphId(row.id)}
|
||||||
|
onDelete={handleDeleteGraph}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{createModalOpen ? (
|
||||||
|
<div
|
||||||
|
className="admin-modal-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget && !busy) setCreateModalOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="admin-modal-sheet"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="new-progression-graph-title"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ maxWidth: '480px' }}
|
||||||
|
>
|
||||||
|
<div className="admin-modal-sheet__header">
|
||||||
|
<h3 id="new-progression-graph-title" className="admin-modal-sheet__title">
|
||||||
|
Neuer Progressionsgraph
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary admin-modal-sheet__close"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setCreateModalOpen(false)}
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleCreateGraph}>
|
||||||
|
<div className="admin-modal-sheet__body">
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Name *</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={newGraphName}
|
||||||
|
onChange={(e) => setNewGraphName(e.target.value)}
|
||||||
|
placeholder="z. B. Kumite-Einstieg Verein Nord"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">{EXERCISE_VISIBILITY_FIELD_LABEL}</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={newGraphVisibility}
|
||||||
|
onChange={(e) => setNewGraphVisibility(e.target.value)}
|
||||||
|
>
|
||||||
|
{filteredGraphVisOptions.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-modal-sheet__footer">
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => setCreateModalOpen(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
Anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="exercise-progression-panel">
|
<div className="exercise-progression-panel">
|
||||||
|
<div style={{ marginBottom: '12px', display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ fontSize: '12px' }}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setSelectedGraphId(null)}
|
||||||
|
>
|
||||||
|
← Zur Übersicht
|
||||||
|
</button>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1.1rem', flex: '1 1 auto' }}>
|
||||||
|
{selectedGraph?.name || `Graph #${selectedGraphId}`}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
{anchorExerciseId != null && (
|
{anchorExerciseId != null && (
|
||||||
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '12px' }}>
|
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '12px' }}>
|
||||||
Kontext:{' '}
|
Kontext:{' '}
|
||||||
|
|
@ -272,84 +450,6 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '16px' }}>
|
|
||||||
Ein Progressionsgraph = <strong>Roadmap mit Slots</strong> (Lernziel + Hauptübung + Schwestern).
|
|
||||||
Roadmap, KI-Match und Bewertung sind in einer Ansicht — kein separater Wizard.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{loadErr && (
|
|
||||||
<div className="card" style={{ borderColor: 'var(--danger)', marginBottom: '12px' }}>
|
|
||||||
<p style={{ margin: 0, color: 'var(--danger)' }}>{loadErr}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="card" style={{ marginBottom: '12px' }}>
|
|
||||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph auswählen</h3>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
|
|
||||||
<div className="form-row" style={{ flex: '1 1 220px', marginBottom: 0 }}>
|
|
||||||
<label className="form-label">Aktiver Graph</label>
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
value={selectedGraphId ?? ''}
|
|
||||||
onChange={(e) => setSelectedGraphId(e.target.value ? parseInt(e.target.value, 10) : null)}
|
|
||||||
>
|
|
||||||
<option value="">— wählen —</option>
|
|
||||||
{graphs.map((g) => (
|
|
||||||
<option key={g.id} value={g.id}>
|
|
||||||
{g.name} ({g.edges_count ?? 0} Kanten)
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn"
|
|
||||||
disabled={busy || !selectedGraphId}
|
|
||||||
onClick={() => refreshEdges(selectedGraphId)}
|
|
||||||
>
|
|
||||||
Aktualisieren
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn" disabled={busy || !selectedGraphId} onClick={handleDeleteGraph}>
|
|
||||||
Graph löschen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form
|
|
||||||
onSubmit={handleCreateGraph}
|
|
||||||
style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid var(--border)' }}
|
|
||||||
>
|
|
||||||
<h4 style={{ margin: '0 0 8px', fontSize: '0.95rem' }}>Neuen Graphen anlegen</h4>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
|
|
||||||
<div className="form-row" style={{ flex: '2 1 200px', marginBottom: 0 }}>
|
|
||||||
<label className="form-label">Name</label>
|
|
||||||
<input
|
|
||||||
className="form-input"
|
|
||||||
value={newGraphName}
|
|
||||||
onChange={(e) => setNewGraphName(e.target.value)}
|
|
||||||
placeholder="z. B. Kumite-Einstieg Verein Nord"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-row" style={{ flex: '1 1 140px', marginBottom: 0 }}>
|
|
||||||
<label className="form-label">{EXERCISE_VISIBILITY_FIELD_LABEL}</label>
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
value={newGraphVisibility}
|
|
||||||
onChange={(e) => setNewGraphVisibility(e.target.value)}
|
|
||||||
>
|
|
||||||
{filteredGraphVisOptions.map((o) => (
|
|
||||||
<option key={o.value} value={o.value}>
|
|
||||||
{o.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
|
||||||
Graph erstellen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedGraphId && anchorExerciseId != null && (
|
{selectedGraphId && anchorExerciseId != null && (
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', fontSize: '13px' }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', fontSize: '13px' }}>
|
||||||
<input
|
<input
|
||||||
|
|
@ -361,157 +461,160 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedGraphId ? (
|
<ProgressionGraphEditor
|
||||||
<>
|
key={selectedGraphId}
|
||||||
<ProgressionGraphEditor
|
graphId={selectedGraphId}
|
||||||
key={selectedGraphId}
|
embedded
|
||||||
graphId={selectedGraphId}
|
onSaved={handleEditorSaved}
|
||||||
embedded
|
/>
|
||||||
onSaved={handleEditorSaved}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<details className="card" style={{ marginBottom: '12px', marginTop: '12px' }}>
|
<details className="card" style={{ marginBottom: '12px', marginTop: '12px' }}>
|
||||||
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Graph-Einstellungen (Name, Sichtbarkeit)</summary>
|
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Graph-Einstellungen (Name, Sichtbarkeit)</summary>
|
||||||
<div style={{ marginTop: '14px' }}>
|
<div style={{ marginTop: '14px' }}>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label">Name</label>
|
<label className="form-label">Name</label>
|
||||||
<input className="form-input" value={metaName} onChange={(e) => setMetaName(e.target.value)} />
|
<input className="form-input" value={metaName} onChange={(e) => setMetaName(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label">Beschreibung</label>
|
<label className="form-label">Beschreibung</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="form-input"
|
className="form-input"
|
||||||
rows={2}
|
rows={2}
|
||||||
value={metaDescription}
|
value={metaDescription}
|
||||||
onChange={(e) => setMetaDescription(e.target.value)}
|
onChange={(e) => setMetaDescription(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label">{EXERCISE_VISIBILITY_FIELD_LABEL}</label>
|
<label className="form-label">{EXERCISE_VISIBILITY_FIELD_LABEL}</label>
|
||||||
<select
|
<select
|
||||||
className="form-input"
|
className="form-input"
|
||||||
value={metaVisibility}
|
value={metaVisibility}
|
||||||
onChange={(e) => setMetaVisibility(e.target.value)}
|
onChange={(e) => setMetaVisibility(e.target.value)}
|
||||||
>
|
>
|
||||||
{filteredGraphVisOptions.map((o) => (
|
{filteredGraphVisOptions.map((o) => (
|
||||||
<option key={o.value} value={o.value}>
|
<option key={o.value} value={o.value}>
|
||||||
{o.label}
|
{o.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={handleSaveMeta}>
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||||
Metadaten speichern
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={handleSaveMeta}>
|
||||||
</button>
|
Metadaten speichern
|
||||||
</div>
|
</button>
|
||||||
</details>
|
<button type="button" className="btn" disabled={busy} onClick={() => handleDeleteGraph(selectedGraph)}>
|
||||||
|
Graph löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<SkillProfilePanel
|
<SkillProfilePanel
|
||||||
title="Fähigkeiten entlang des Pfads"
|
title="Fähigkeiten entlang des Pfads"
|
||||||
hint="Alle Übungen als Knoten im Graph."
|
hint="Alle Übungen als Knoten im Graph."
|
||||||
profile={skillProfileData?.overall}
|
profile={skillProfileData?.overall}
|
||||||
loading={skillProfileLoading}
|
loading={skillProfileLoading}
|
||||||
error={skillProfileError}
|
error={skillProfileError}
|
||||||
defaultExpanded={false}
|
defaultExpanded={false}
|
||||||
artifactType="progression_graph"
|
artifactType="progression_graph"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<details className="card" style={{ marginBottom: '12px' }}>
|
<details className="card" style={{ marginBottom: '12px' }}>
|
||||||
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>
|
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>
|
||||||
Technische Kantenliste ({filteredEdges.length}
|
Technische Kantenliste ({filteredEdges.length}
|
||||||
{edges.length !== filteredEdges.length ? ` von ${edges.length}` : ''})
|
{edges.length !== filteredEdges.length ? ` von ${edges.length}` : ''})
|
||||||
</summary>
|
</summary>
|
||||||
<div style={{ marginTop: '14px' }}>
|
<div style={{ marginTop: '14px' }}>
|
||||||
{filteredEdges.length === 0 ? (
|
{filteredEdges.length === 0 ? (
|
||||||
<p style={{ color: 'var(--text2)', margin: 0 }}>Keine Kanten.</p>
|
<p style={{ color: 'var(--text2)', margin: 0 }}>Keine Kanten.</p>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)' }}>
|
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)' }}>
|
||||||
<th style={{ padding: '8px 6px' }}>Von</th>
|
<th style={{ padding: '8px 6px' }}>Von</th>
|
||||||
<th style={{ padding: '8px 6px' }} />
|
<th style={{ padding: '8px 6px' }} />
|
||||||
<th style={{ padding: '8px 6px' }}>Nach</th>
|
<th style={{ padding: '8px 6px' }}>Nach</th>
|
||||||
<th style={{ padding: '8px 6px' }}>Art</th>
|
<th style={{ padding: '8px 6px' }}>Art</th>
|
||||||
<th style={{ padding: '8px 6px' }}>Notiz</th>
|
<th style={{ padding: '8px 6px' }}>Notiz</th>
|
||||||
<th style={{ padding: '8px 6px' }} />
|
<th style={{ padding: '8px 6px' }} />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredEdges.map((row) => (
|
{filteredEdges.map((row) => (
|
||||||
<tr key={row.id} style={{ borderBottom: '1px solid var(--border)' }}>
|
<tr key={row.id} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
|
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
|
||||||
<Link to={`/exercises/${row.from_exercise_id}`}>
|
<Link to={`/exercises/${row.from_exercise_id}`}>
|
||||||
{row.from_exercise_title || `#${row.from_exercise_id}`}
|
{row.from_exercise_title || `#${row.from_exercise_id}`}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '8px 6px', color: 'var(--text3)', verticalAlign: 'top' }}>
|
<td style={{ padding: '8px 6px', color: 'var(--text3)', verticalAlign: 'top' }}>
|
||||||
{row.edge_type === 'sibling' ? '·' : '→'}
|
{row.edge_type === 'sibling' ? '·' : '→'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
|
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
|
||||||
<Link to={`/exercises/${row.to_exercise_id}`}>
|
<Link to={`/exercises/${row.to_exercise_id}`}>
|
||||||
{row.to_exercise_title || `#${row.to_exercise_id}`}
|
{row.to_exercise_title || `#${row.to_exercise_id}`}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>{edgeTypeLabel(row.edge_type)}</td>
|
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>{edgeTypeLabel(row.edge_type)}</td>
|
||||||
<td style={{ padding: '8px 6px', verticalAlign: 'top', maxWidth: '280px' }}>
|
<td style={{ padding: '8px 6px', verticalAlign: 'top', maxWidth: '280px' }}>
|
||||||
{editingEdgeNotes === row.id ? (
|
{editingEdgeNotes === row.id ? (
|
||||||
<>
|
<>
|
||||||
<textarea
|
<textarea
|
||||||
className="form-input"
|
className="form-input"
|
||||||
rows={2}
|
rows={2}
|
||||||
value={notesDraft}
|
value={notesDraft}
|
||||||
onChange={(e) => setNotesDraft(e.target.value)}
|
onChange={(e) => setNotesDraft(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
style={{ marginTop: '6px', fontSize: '12px', padding: '4px 8px' }}
|
style={{ marginTop: '6px', fontSize: '12px', padding: '4px 8px' }}
|
||||||
onClick={() => saveNotes(row.id)}
|
onClick={() => saveNotes(row.id)}
|
||||||
>
|
>
|
||||||
Speichern
|
Speichern
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{row.notes || '—'}
|
{row.notes || '—'}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn"
|
|
||||||
style={{ marginLeft: '8px', fontSize: '12px', padding: '4px 8px' }}
|
|
||||||
onClick={() => startEditNotes(row)}
|
|
||||||
>
|
|
||||||
Bearbeiten
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="btn"
|
||||||
style={{
|
style={{ marginLeft: '8px', fontSize: '12px', padding: '4px 8px' }}
|
||||||
fontSize: '12px',
|
onClick={() => startEditNotes(row)}
|
||||||
padding: '4px 8px',
|
|
||||||
background: 'var(--danger)',
|
|
||||||
color: '#fff',
|
|
||||||
border: 'none',
|
|
||||||
}}
|
|
||||||
onClick={() => handleDeleteEdge(row.id)}
|
|
||||||
>
|
>
|
||||||
Löschen
|
Bearbeiten
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</>
|
||||||
</tr>
|
)}
|
||||||
))}
|
</td>
|
||||||
</tbody>
|
<td style={{ padding: '8px 6px', verticalAlign: 'top' }}>
|
||||||
</table>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
)}
|
className="btn"
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
padding: '4px 8px',
|
||||||
|
background: 'var(--danger)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
}}
|
||||||
|
onClick={() => handleDeleteEdge(row.id)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
)}
|
||||||
</>
|
</div>
|
||||||
) : null}
|
</details>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default forwardRef(ExerciseProgressionGraphPanel)
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,10 @@ import {
|
||||||
applyEvaluateResponseToDraft,
|
applyEvaluateResponseToDraft,
|
||||||
applyGapOfferToDraft,
|
applyGapOfferToDraft,
|
||||||
applyMatchResponseToDraft,
|
applyMatchResponseToDraft,
|
||||||
|
applyResolvedStructuredToDraft,
|
||||||
buildPlanningArtifactFromDraft,
|
buildPlanningArtifactFromDraft,
|
||||||
hydrateProgressionGraphDraft,
|
hydrateProgressionGraphDraft,
|
||||||
|
SLOT_MIN,
|
||||||
insertSlotInDraft,
|
insertSlotInDraft,
|
||||||
librarySlotExercise,
|
librarySlotExercise,
|
||||||
majorStepsToOverridePayload,
|
majorStepsToOverridePayload,
|
||||||
|
|
@ -75,6 +77,8 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
const [evaluating, setEvaluating] = useState(false)
|
const [evaluating, setEvaluating] = useState(false)
|
||||||
const [matching, setMatching] = useState(false)
|
const [matching, setMatching] = useState(false)
|
||||||
const [roadmapLoading, setRoadmapLoading] = useState(false)
|
const [roadmapLoading, setRoadmapLoading] = useState(false)
|
||||||
|
const [startTargetLoading, setStartTargetLoading] = useState(false)
|
||||||
|
const [startTargetReady, setStartTargetReady] = useState(false)
|
||||||
const [semanticBrief, setSemanticBrief] = useState(null)
|
const [semanticBrief, setSemanticBrief] = useState(null)
|
||||||
const [targetSummary, setTargetSummary] = useState(null)
|
const [targetSummary, setTargetSummary] = useState(null)
|
||||||
const [focusAreas, setFocusAreas] = useState([])
|
const [focusAreas, setFocusAreas] = useState([])
|
||||||
|
|
@ -109,12 +113,14 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
const edgeList = Array.isArray(edges) ? edges : []
|
const edgeList = Array.isArray(edges) ? edges : []
|
||||||
setCurrentEdges(edgeList)
|
setCurrentEdges(edgeList)
|
||||||
setGraphMeta(graph)
|
setGraphMeta(graph)
|
||||||
setDraft(
|
const hydrated = hydrateProgressionGraphDraft({
|
||||||
hydrateProgressionGraphDraft({
|
artifact: graph?.planning_roadmap,
|
||||||
artifact: graph?.planning_roadmap,
|
edges: edgeList,
|
||||||
edges: edgeList,
|
graphName: graph?.name,
|
||||||
graphName: graph?.name,
|
})
|
||||||
}),
|
setDraft(hydrated)
|
||||||
|
setStartTargetReady(
|
||||||
|
Boolean((hydrated.startSituation || '').trim() && (hydrated.targetState || '').trim()),
|
||||||
)
|
)
|
||||||
const findings = graph?.planning_roadmap?.last_findings
|
const findings = graph?.planning_roadmap?.last_findings
|
||||||
if (findings) setPathQa(findings)
|
if (findings) setPathQa(findings)
|
||||||
|
|
@ -270,12 +276,55 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
return draft.slots.filter((s) => (s.learning_goal || '').trim().length >= 3)
|
return draft.slots.filter((s) => (s.learning_goal || '').trim().length >= 3)
|
||||||
}, [draft?.slots])
|
}, [draft?.slots])
|
||||||
|
|
||||||
|
const runAnalyzeStartTarget = async () => {
|
||||||
|
const q = (draft?.goalQuery || '').trim()
|
||||||
|
if (q.length < 3) {
|
||||||
|
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStartTargetLoading(true)
|
||||||
|
setActionErr('')
|
||||||
|
try {
|
||||||
|
const res = await api.suggestProgressionPath({
|
||||||
|
query: q,
|
||||||
|
max_steps: draft.maxSteps || 5,
|
||||||
|
include_llm_intent: false,
|
||||||
|
include_path_qa: false,
|
||||||
|
include_llm_path_qa: false,
|
||||||
|
include_path_reorder: false,
|
||||||
|
include_ai_gap_fill: false,
|
||||||
|
include_roadmap_preview: false,
|
||||||
|
include_llm_roadmap: false,
|
||||||
|
include_llm_start_target: true,
|
||||||
|
start_target_only: true,
|
||||||
|
progression_graph_id: Number(graphId),
|
||||||
|
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
||||||
|
})
|
||||||
|
const roadmap = res?.progression_roadmap
|
||||||
|
if (!roadmap) throw new Error('Keine Start/Ziel-Analyse in der Antwort')
|
||||||
|
setDraft((prev) => {
|
||||||
|
const structured = applyResolvedStructuredToDraft(
|
||||||
|
{ ...prev, progressionRoadmap: roadmap },
|
||||||
|
roadmap,
|
||||||
|
)
|
||||||
|
return { ...structured, dirty: true }
|
||||||
|
})
|
||||||
|
setStartTargetReady(true)
|
||||||
|
setSemanticBrief(res?.semantic_brief_summary || null)
|
||||||
|
} catch (e) {
|
||||||
|
setActionErr(e.message || 'Start/Ziel-Analyse fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setStartTargetLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const runRoadmapGenerate = async () => {
|
const runRoadmapGenerate = async () => {
|
||||||
const q = (draft?.goalQuery || '').trim()
|
const q = (draft?.goalQuery || '').trim()
|
||||||
if (q.length < 3) {
|
if (q.length < 3) {
|
||||||
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
|
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const fieldsEmpty = !(draft.startSituation || '').trim() && !(draft.targetState || '').trim()
|
||||||
setRoadmapLoading(true)
|
setRoadmapLoading(true)
|
||||||
setActionErr('')
|
setActionErr('')
|
||||||
try {
|
try {
|
||||||
|
|
@ -289,28 +338,44 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
include_ai_gap_fill: false,
|
include_ai_gap_fill: false,
|
||||||
include_roadmap_preview: true,
|
include_roadmap_preview: true,
|
||||||
include_llm_roadmap: true,
|
include_llm_roadmap: true,
|
||||||
|
include_llm_start_target: fieldsEmpty,
|
||||||
roadmap_only: true,
|
roadmap_only: true,
|
||||||
progression_graph_id: Number(graphId),
|
progression_graph_id: Number(graphId),
|
||||||
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
||||||
})
|
})
|
||||||
const roadmap = res?.progression_roadmap
|
const roadmap = res?.progression_roadmap
|
||||||
if (!roadmap) throw new Error('Keine Roadmap in der Antwort')
|
if (!roadmap) throw new Error('Keine Roadmap in der Antwort')
|
||||||
|
const majorCount = (roadmap?.roadmap?.major_steps || []).length
|
||||||
|
if (majorCount < SLOT_MIN) throw new Error('Roadmap hat zu wenig Stufen.')
|
||||||
const preservedArtifact = buildPlanningArtifactFromDraft(draft) || {}
|
const preservedArtifact = buildPlanningArtifactFromDraft(draft) || {}
|
||||||
|
let startSituation = draft.startSituation
|
||||||
|
let targetState = draft.targetState
|
||||||
|
let roadmapNotes = draft.roadmapNotes
|
||||||
|
if (fieldsEmpty) {
|
||||||
|
const patch = applyResolvedStructuredToDraft(
|
||||||
|
{ startSituation, targetState, roadmapNotes },
|
||||||
|
roadmap,
|
||||||
|
)
|
||||||
|
startSituation = patch.startSituation
|
||||||
|
targetState = patch.targetState
|
||||||
|
roadmapNotes = patch.roadmapNotes
|
||||||
|
setStartTargetReady(true)
|
||||||
|
}
|
||||||
const hydrated = hydrateProgressionGraphDraft({
|
const hydrated = hydrateProgressionGraphDraft({
|
||||||
artifact: {
|
artifact: {
|
||||||
...preservedArtifact,
|
...preservedArtifact,
|
||||||
goal_query: q,
|
goal_query: q,
|
||||||
progression_roadmap: roadmap,
|
progression_roadmap: roadmap,
|
||||||
start_situation: draft.startSituation,
|
start_situation: startSituation,
|
||||||
target_state: draft.targetState,
|
target_state: targetState,
|
||||||
roadmap_notes: draft.roadmapNotes,
|
roadmap_notes: roadmapNotes,
|
||||||
max_steps: (roadmap?.roadmap?.major_steps || []).length || draft.maxSteps,
|
max_steps: majorCount || draft.maxSteps,
|
||||||
},
|
},
|
||||||
edges: currentEdges,
|
edges: currentEdges,
|
||||||
graphName: draft.graphName,
|
graphName: draft.graphName,
|
||||||
})
|
})
|
||||||
const withPhases = syncSlotPhasesFromRoadmap(hydrated, roadmap)
|
const withPhases = syncSlotPhasesFromRoadmap(hydrated, roadmap)
|
||||||
setDraft({ ...withPhases, goalQuery: q, dirty: true })
|
setDraft({ ...withPhases, goalQuery: q, maxSteps: majorCount || withPhases.maxSteps, dirty: true })
|
||||||
setSemanticBrief(res?.semantic_brief_summary || null)
|
setSemanticBrief(res?.semantic_brief_summary || null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen')
|
setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen')
|
||||||
|
|
@ -686,38 +751,98 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
<div>
|
<div>
|
||||||
<div className="card" style={{ marginBottom: '12px' }}>
|
<div className="card" style={{ marginBottom: '12px' }}>
|
||||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Ziel & Roadmap</h3>
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Ziel & Roadmap</h3>
|
||||||
<div className="form-row">
|
<div
|
||||||
<label className="form-label">Ziel-Anfrage</label>
|
style={{
|
||||||
<textarea
|
display: 'flex',
|
||||||
className="form-input"
|
flexWrap: 'wrap',
|
||||||
rows={2}
|
gap: '10px',
|
||||||
value={draft.goalQuery}
|
alignItems: 'flex-end',
|
||||||
disabled={busy}
|
marginBottom: '10px',
|
||||||
onChange={(e) => patchDraft((d) => ({ ...d, goalQuery: e.target.value }))}
|
}}
|
||||||
placeholder="z. B. Vom Anfänger zum sauberen Gerade-Tritt"
|
>
|
||||||
/>
|
<div className="form-row" style={{ flex: '2 1 240px', marginBottom: 0 }}>
|
||||||
</div>
|
<label className="form-label">Ziel / Entwicklungsrichtung</label>
|
||||||
<div className="form-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
|
||||||
<div>
|
|
||||||
<label className="form-label">Start-Situation</label>
|
|
||||||
<input
|
<input
|
||||||
className="form-input"
|
className="form-input"
|
||||||
|
value={draft.goalQuery}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => patchDraft((d) => ({ ...d, goalQuery: e.target.value }))}
|
||||||
|
placeholder="z. B. Von Erlernen bis zur Perfektion des Fußtritts Mae Geri …"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ flex: '0 1 100px', marginBottom: 0 }}>
|
||||||
|
<label className="form-label">Stufen (Slots)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={SLOT_MIN}
|
||||||
|
max={SLOT_MAX}
|
||||||
|
className="form-input"
|
||||||
|
value={draft.maxSteps}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchDraft((d) => ({
|
||||||
|
...d,
|
||||||
|
maxSteps: Math.max(SLOT_MIN, Math.min(SLOT_MAX, Number(e.target.value) || 5)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
|
||||||
|
gap: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label">Startpunkt / Ausgangslage</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
rows={2}
|
||||||
value={draft.startSituation}
|
value={draft.startSituation}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(e) => patchDraft((d) => ({ ...d, startSituation: e.target.value }))}
|
onChange={(e) => patchDraft((d) => ({ ...d, startSituation: e.target.value }))}
|
||||||
|
placeholder="z. B. gleichartige Steppbewegung, vorhersehbar"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
<label className="form-label">Ziel-Zustand</label>
|
<label className="form-label">Zielzustand</label>
|
||||||
<input
|
<textarea
|
||||||
className="form-input"
|
className="form-input"
|
||||||
|
rows={2}
|
||||||
value={draft.targetState}
|
value={draft.targetState}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(e) => patchDraft((d) => ({ ...d, targetState: e.target.value }))}
|
onChange={(e) => patchDraft((d) => ({ ...d, targetState: e.target.value }))}
|
||||||
|
placeholder="z. B. dynamische Bewegung mit explosivem Angriff und Ausweichen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label">Ergänzungen (Fokus, Gruppe, Besonderheiten)</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
rows={2}
|
||||||
|
value={draft.roadmapNotes}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => patchDraft((d) => ({ ...d, roadmapNotes: e.target.value }))}
|
||||||
|
placeholder="optional: Altersgruppe, Kumite-Kontext, Trainingsfokus …"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '8px' }}>
|
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '8px 0 0', lineHeight: 1.4 }}>
|
||||||
|
Optional zuerst „Start/Ziel analysieren“, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer,
|
||||||
|
geschieht die Analyse beim Roadmap-Vorschlag automatisch. Manuelle Eingaben haben Vorrang.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '10px', alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={busy || startTargetLoading}
|
||||||
|
onClick={runAnalyzeStartTarget}
|
||||||
|
title="Nur Ausgangslage, Zielzustand und Ergänzungen per KI — ohne Roadmap-Stufen"
|
||||||
|
>
|
||||||
|
{startTargetLoading ? 'Analyse…' : 'Start/Ziel analysieren'}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
|
|
@ -726,6 +851,11 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
>
|
>
|
||||||
{roadmapLoading ? 'Roadmap…' : 'Roadmap generieren'}
|
{roadmapLoading ? 'Roadmap…' : 'Roadmap generieren'}
|
||||||
</button>
|
</button>
|
||||||
|
{startTargetReady ? (
|
||||||
|
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
|
||||||
|
Start/Ziel bereit
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
|
|
|
||||||
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 [quickSaving, setQuickSaving] = useState(false)
|
||||||
const [quickAiError, setQuickAiError] = useState('')
|
const [quickAiError, setQuickAiError] = useState('')
|
||||||
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
|
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
|
||||||
|
const progressionPanelRef = useRef(null)
|
||||||
|
|
||||||
const planningKi = usePlanningExerciseSuggestSearch({
|
const planningKi = usePlanningExerciseSuggestSearch({
|
||||||
enabled: pageTab === 'list' && aiQuickCreateEnabled,
|
enabled: pageTab === 'list' && aiQuickCreateEnabled,
|
||||||
|
|
@ -653,7 +654,13 @@ function ExercisesListPageRoot() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span aria-hidden="true" />
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => progressionPanelRef.current?.openCreateDialog?.()}
|
||||||
|
>
|
||||||
|
+ Neu
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -676,7 +683,7 @@ function ExercisesListPageRoot() {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ExerciseProgressionGraphPanel />
|
<ExerciseProgressionGraphPanel ref={progressionPanelRef} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,53 @@
|
||||||
|
|
||||||
export const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
|
export const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
|
||||||
export const SLOT_MAX = 10
|
export const SLOT_MAX = 10
|
||||||
|
export const SLOT_MIN = 2
|
||||||
export const PLANNING_ARTIFACT_SCHEMA = 1
|
export const PLANNING_ARTIFACT_SCHEMA = 1
|
||||||
|
|
||||||
|
/** Start/Ziel/Ergänzungen aus KI-Roadmap-Antwort (resolved_structured). */
|
||||||
|
export function resolvedStructuredFromRoadmap(progressionRoadmap) {
|
||||||
|
const rs = progressionRoadmap?.resolved_structured
|
||||||
|
if (!rs) return null
|
||||||
|
const patch = {}
|
||||||
|
if (rs.start_situation) patch.startSituation = String(rs.start_situation)
|
||||||
|
if (rs.target_state) patch.targetState = String(rs.target_state)
|
||||||
|
if (rs.roadmap_notes) patch.roadmapNotes = String(rs.roadmap_notes)
|
||||||
|
return Object.keys(patch).length ? patch : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyResolvedStructuredToDraft(draft, progressionRoadmap, { onlyIfEmpty = false } = {}) {
|
||||||
|
const patch = resolvedStructuredFromRoadmap(progressionRoadmap)
|
||||||
|
if (!patch) return draft
|
||||||
|
const next = { ...draft }
|
||||||
|
if (patch.startSituation && (!onlyIfEmpty || !(draft.startSituation || '').trim())) {
|
||||||
|
next.startSituation = patch.startSituation
|
||||||
|
}
|
||||||
|
if (patch.targetState && (!onlyIfEmpty || !(draft.targetState || '').trim())) {
|
||||||
|
next.targetState = patch.targetState
|
||||||
|
}
|
||||||
|
if (patch.roadmapNotes && (!onlyIfEmpty || !(draft.roadmapNotes || '').trim())) {
|
||||||
|
next.roadmapNotes = patch.roadmapNotes
|
||||||
|
}
|
||||||
|
return { ...next, dirty: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function graphGoalQueryFromRow(graph) {
|
||||||
|
const art = graph?.planning_roadmap
|
||||||
|
if (!art || typeof art !== 'object') return ''
|
||||||
|
return (art.goal_query || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function graphSlotCountFromRow(graph) {
|
||||||
|
const art = graph?.planning_roadmap
|
||||||
|
if (!art || typeof art !== 'object') return null
|
||||||
|
const slots = art.slot_contents
|
||||||
|
if (Array.isArray(slots) && slots.length) return slots.length
|
||||||
|
const majors = art.progression_roadmap?.roadmap?.major_steps
|
||||||
|
if (Array.isArray(majors) && majors.length) return majors.length
|
||||||
|
const ms = Number(art.max_steps)
|
||||||
|
return Number.isFinite(ms) && ms > 0 ? ms : null
|
||||||
|
}
|
||||||
|
|
||||||
const OFFER_SOURCE_LABELS = {
|
const OFFER_SOURCE_LABELS = {
|
||||||
unfilled_gap: 'Lücke',
|
unfilled_gap: 'Lücke',
|
||||||
off_topic: 'Themenfremd',
|
off_topic: 'Themenfremd',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user