feat(combo-planning): enhance candidate interaction and UI for combination exercises
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 57s
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 57s
- Introduced new CSS styles for interactive candidate buttons and links in the `CombinationPlanBracket` and `CombinationCoachSlots` components, improving user engagement. - Updated `CombinationPlanBracket` to conditionally render candidates as buttons or links based on interaction type, enhancing navigation options. - Refactored candidate handling in `CombinationCoachSlots` to support new interaction methods, streamlining candidate exercise display. - Enhanced `ExercisePeekModal` and related components to support candidate peek functionality, allowing for a more seamless user experience. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
00edc7a93d
commit
502dddd3b3
|
|
@ -6398,6 +6398,68 @@ a.analysis-split__nav-item {
|
||||||
color: var(--text3);
|
color: var(--text3);
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
}
|
}
|
||||||
|
.combo-plan-bracket__station-exercises--interactive {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px 6px;
|
||||||
|
}
|
||||||
|
.combo-plan-bracket__cand-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.combo-plan-bracket__cand-sep {
|
||||||
|
color: var(--text3);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.combo-plan-bracket__cand-btn {
|
||||||
|
margin: 0;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-dark);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.combo-plan-bracket__cand-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--surface2);
|
||||||
|
}
|
||||||
|
.combo-plan-bracket__cand-link {
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-dark);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.combo-plan-bracket__cand-link:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.combo-coach-cand-link {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
button.combo-coach-cand-link:hover {
|
||||||
|
color: var(--accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
.training-run-combo-embed {
|
.training-run-combo-embed {
|
||||||
margin-top: 0.65rem;
|
margin-top: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,15 +35,26 @@ export default function CombinationCoachSlots({
|
||||||
methodProfile,
|
methodProfile,
|
||||||
compactPlanningView = false,
|
compactPlanningView = false,
|
||||||
omitGlobalKeyValueBlock = false,
|
omitGlobalKeyValueBlock = false,
|
||||||
|
/** Wenn gesetzt: Kandidaten als Button → Peek (kein Router-Wechsel, PWA-sicher) */
|
||||||
|
onOpenCandidatePeek,
|
||||||
}) {
|
}) {
|
||||||
const slots = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots), [combinationSlots])
|
const slots = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots), [combinationSlots])
|
||||||
|
|
||||||
const candidateIds = useMemo(() => {
|
const candidateIds = useMemo(() => {
|
||||||
const set = new Set()
|
const set = new Set()
|
||||||
for (const s of slots) {
|
for (const s of slots) {
|
||||||
for (const id of s.candidate_exercise_ids || []) {
|
if (Array.isArray(s.candidates) && s.candidates.length) {
|
||||||
const n = typeof id === 'number' ? id : parseInt(String(id), 10)
|
for (const c of s.candidates) {
|
||||||
if (Number.isFinite(n)) set.add(n)
|
const raw = c.exercise_id
|
||||||
|
if (raw == null) continue
|
||||||
|
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
|
||||||
|
if (Number.isFinite(n)) set.add(n)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const id of s.candidate_exercise_ids || []) {
|
||||||
|
const n = typeof id === 'number' ? id : parseInt(String(id), 10)
|
||||||
|
if (Number.isFinite(n)) set.add(n)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...set]
|
return [...set]
|
||||||
|
|
@ -282,9 +293,19 @@ export default function CombinationCoachSlots({
|
||||||
<>
|
<>
|
||||||
<p style={{ margin: '0 0 4px', fontSize: '0.96rem', fontWeight: 700 }}>{ex.title}</p>
|
<p style={{ margin: '0 0 4px', fontSize: '0.96rem', fontWeight: 700 }}>{ex.title}</p>
|
||||||
<p style={{ margin: 0 }}>
|
<p style={{ margin: 0 }}>
|
||||||
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.82rem', color: 'var(--accent)' }}>
|
{typeof onOpenCandidatePeek === 'function' ? (
|
||||||
Im Katalog öffnen
|
<button
|
||||||
</Link>
|
type="button"
|
||||||
|
className="combo-coach-cand-link"
|
||||||
|
onClick={() => onOpenCandidatePeek(cid)}
|
||||||
|
>
|
||||||
|
Details anzeigen
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.82rem', color: 'var(--accent)' }}>
|
||||||
|
Im Katalog öffnen
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -320,9 +341,19 @@ export default function CombinationCoachSlots({
|
||||||
</details>
|
</details>
|
||||||
) : null}
|
) : null}
|
||||||
<p style={{ marginTop: '8px', marginBottom: 0 }}>
|
<p style={{ marginTop: '8px', marginBottom: 0 }}>
|
||||||
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.84rem', color: 'var(--accent)' }}>
|
{typeof onOpenCandidatePeek === 'function' ? (
|
||||||
Volle Übungsseite
|
<button
|
||||||
</Link>
|
type="button"
|
||||||
|
className="combo-coach-cand-link"
|
||||||
|
onClick={() => onOpenCandidatePeek(cid)}
|
||||||
|
>
|
||||||
|
Volle Übungsansicht
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.84rem', color: 'var(--accent)' }}>
|
||||||
|
Volle Übungsseite
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
* Kombination: konsolidierte Darstellung globales Profil + Stationen mit Zeiten (Vorschau, Plan-Ansicht, Druck).
|
* Kombination: konsolidierte Darstellung globales Profil + Stationen mit Zeiten (Vorschau, Plan-Ansicht, Druck).
|
||||||
*/
|
*/
|
||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
archetypeCoachHint,
|
archetypeCoachHint,
|
||||||
combinationArchetypeLabel,
|
combinationArchetypeLabel,
|
||||||
|
|
@ -14,24 +15,22 @@ import {
|
||||||
stationPrimaryLoadLabel,
|
stationPrimaryLoadLabel,
|
||||||
} from '../utils/combinationMethodProfileUi'
|
} from '../utils/combinationMethodProfileUi'
|
||||||
|
|
||||||
function candidateLine(slot) {
|
/** @returns {{ exerciseId: number, label: string }[]} */
|
||||||
const cands = slot.candidates
|
export function normalizeCombinationSlotCandidates(slot) {
|
||||||
if (Array.isArray(cands) && cands.length > 0) {
|
const out = []
|
||||||
return cands
|
const cands =
|
||||||
.map((c) =>
|
slot.candidates && slot.candidates.length
|
||||||
((c.title || '').trim() || (c.exercise_id != null ? `Übung #${c.exercise_id}` : '')).trim(),
|
? slot.candidates
|
||||||
)
|
: (slot.candidate_exercise_ids || []).map((id) => ({ exercise_id: id, title: null }))
|
||||||
.filter(Boolean)
|
for (const c of cands) {
|
||||||
.join(' ↔ ')
|
const rawId = c.exercise_id
|
||||||
|
if (rawId == null) continue
|
||||||
|
const n = typeof rawId === 'number' ? rawId : parseInt(String(rawId), 10)
|
||||||
|
if (!Number.isFinite(n)) continue
|
||||||
|
const label = ((c.title || '').trim() || `Übung #${n}`).trim()
|
||||||
|
out.push({ exerciseId: n, label })
|
||||||
}
|
}
|
||||||
const ids = slot.candidate_exercise_ids || []
|
return out
|
||||||
return ids
|
|
||||||
.map((raw) => {
|
|
||||||
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
|
|
||||||
return Number.isFinite(n) ? `Übung #${n}` : ''
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ↔ ')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CombinationPlanBracket({
|
export default function CombinationPlanBracket({
|
||||||
|
|
@ -39,6 +38,9 @@ export default function CombinationPlanBracket({
|
||||||
methodProfile,
|
methodProfile,
|
||||||
combinationSlots,
|
combinationSlots,
|
||||||
planningAdjusted = false,
|
planningAdjusted = false,
|
||||||
|
/** 'none' | 'link' (Router) | 'button' (z. B. ExercisePeekModal / PWA-sicher) */
|
||||||
|
candidateInteraction = 'none',
|
||||||
|
onCandidatePeek,
|
||||||
}) {
|
}) {
|
||||||
const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
|
const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
|
||||||
const archLabel = arch ? combinationArchetypeLabel(arch) : null
|
const archLabel = arch ? combinationArchetypeLabel(arch) : null
|
||||||
|
|
@ -97,7 +99,8 @@ export default function CombinationPlanBracket({
|
||||||
const stationIx = Number.isFinite(ixParsed) ? ixParsed : si
|
const stationIx = Number.isFinite(ixParsed) ? ixParsed : si
|
||||||
const displayStep = si + 1
|
const displayStep = si + 1
|
||||||
const stationTitle = ((slot.title || '').trim() || `Station ${displayStep}`).trim()
|
const stationTitle = ((slot.title || '').trim() || `Station ${displayStep}`).trim()
|
||||||
const names = candidateLine(slot)
|
const candRows = normalizeCombinationSlotCandidates(slot)
|
||||||
|
const names = candRows.length ? candRows.map((r) => r.label).join(' ↔ ') : ''
|
||||||
const slotProfRow = timingByIx.get(stationIx)
|
const slotProfRow = timingByIx.get(stationIx)
|
||||||
const loadBadge = stationPrimaryLoadLabel(slotProfRow)
|
const loadBadge = stationPrimaryLoadLabel(slotProfRow)
|
||||||
const timing = effectiveStationTimingSummary(arch, methodProfile || {}, slotProfRow)
|
const timing = effectiveStationTimingSummary(arch, methodProfile || {}, slotProfRow)
|
||||||
|
|
@ -112,7 +115,35 @@ export default function CombinationPlanBracket({
|
||||||
</div>
|
</div>
|
||||||
<div className="combo-plan-bracket__station-main">
|
<div className="combo-plan-bracket__station-main">
|
||||||
<div className="combo-plan-bracket__station-title">{stationTitle}</div>
|
<div className="combo-plan-bracket__station-title">{stationTitle}</div>
|
||||||
<div className="combo-plan-bracket__station-exercises">{names || '(keine Einzelübung)'}</div>
|
{candidateInteraction === 'button' && typeof onCandidatePeek === 'function' && candRows.length > 0 ? (
|
||||||
|
<div className="combo-plan-bracket__station-exercises combo-plan-bracket__station-exercises--interactive">
|
||||||
|
{candRows.map((c, ci) => (
|
||||||
|
<span key={`${c.exerciseId}-${ci}`} className="combo-plan-bracket__cand-inline">
|
||||||
|
{ci > 0 ? <span className="combo-plan-bracket__cand-sep">↔</span> : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="combo-plan-bracket__cand-btn"
|
||||||
|
onClick={() => onCandidatePeek(c.exerciseId, c.label)}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : candidateInteraction === 'link' && candRows.length > 0 ? (
|
||||||
|
<div className="combo-plan-bracket__station-exercises combo-plan-bracket__station-exercises--interactive">
|
||||||
|
{candRows.map((c, ci) => (
|
||||||
|
<span key={`${c.exerciseId}-${ci}`} className="combo-plan-bracket__cand-inline">
|
||||||
|
{ci > 0 ? <span className="combo-plan-bracket__cand-sep">↔</span> : null}
|
||||||
|
<Link to={`/exercises/${c.exerciseId}`} className="combo-plan-bracket__cand-link">
|
||||||
|
{c.label}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="combo-plan-bracket__station-exercises">{names || '(keine Einzelübung)'}</div>
|
||||||
|
)}
|
||||||
{timing ? (
|
{timing ? (
|
||||||
<div className="combo-plan-bracket__station-timing">
|
<div className="combo-plan-bracket__station-timing">
|
||||||
<span className="combo-plan-bracket__timing-label">Zeit / Steuerung</span>
|
<span className="combo-plan-bracket__timing-label">Zeit / Steuerung</span>
|
||||||
|
|
|
||||||
|
|
@ -54,9 +54,18 @@ function metaParts(exercise) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{ exercise?: object|null, loading?: boolean, error?: string|null, exerciseId?: number, variantId?: number|string|null, planningComboMethodProfile?: object|null, catalogMethodProfileSnapshot?: object|null }} props
|
* @param {{ exercise?: object|null, loading?: boolean, error?: string|null, exerciseId?: number, variantId?: number|string|null, planningComboMethodProfile?: object|null, catalogMethodProfileSnapshot?: object|null, onCandidateExercisePeek?: (exerciseId: number) => void }} props
|
||||||
*/
|
*/
|
||||||
export default function ExerciseFullContent({ exercise, loading, error, exerciseId, variantId, planningComboMethodProfile, catalogMethodProfileSnapshot }) {
|
export default function ExerciseFullContent({
|
||||||
|
exercise,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
exerciseId,
|
||||||
|
variantId,
|
||||||
|
planningComboMethodProfile,
|
||||||
|
catalogMethodProfileSnapshot,
|
||||||
|
onCandidateExercisePeek,
|
||||||
|
}) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: 'center', padding: '1rem' }}>
|
<div style={{ textAlign: 'center', padding: '1rem' }}>
|
||||||
|
|
@ -129,6 +138,7 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
|
||||||
combinationSlots={exercise.combination_slots}
|
combinationSlots={exercise.combination_slots}
|
||||||
methodArchetype={exercise.method_archetype}
|
methodArchetype={exercise.method_archetype}
|
||||||
methodProfile={coachComboProfile}
|
methodProfile={coachComboProfile}
|
||||||
|
onOpenCandidatePeek={onCandidateExercisePeek}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<h2 style={{ margin: '0 0 8px', fontSize: '1.2rem', lineHeight: 1.35 }}>{exercise.title}</h2>
|
<h2 style={{ margin: '0 0 8px', fontSize: '1.2rem', lineHeight: 1.35 }}>{exercise.title}</h2>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
/**
|
/**
|
||||||
* Schnellansicht einer Übung aus dem Katalog (ohne die Planungsseite zu verlassen).
|
* Schnellansicht einer Übung aus dem Katalog (ohne die Planungsseite zu verlassen).
|
||||||
|
* Unterstützt Drill-down zu Kandidaten-Übungen bei Kombinationen inkl. „Zurück“ (PWA-sicher).
|
||||||
*/
|
*/
|
||||||
import React, { useEffect, useMemo, useState } from 'react'
|
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
|
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
|
||||||
|
|
@ -25,6 +26,8 @@ function TagMini({ exercise }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @typedef {{ exerciseId: number, variantId?: number | null, peekExtras?: object | null }} PeekStackEntry */
|
||||||
|
|
||||||
export default function ExercisePeekModal({
|
export default function ExercisePeekModal({
|
||||||
open,
|
open,
|
||||||
exerciseId,
|
exerciseId,
|
||||||
|
|
@ -37,36 +40,37 @@ export default function ExercisePeekModal({
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [err, setErr] = useState(null)
|
const [err, setErr] = useState(null)
|
||||||
const [exercise, setExercise] = useState(null)
|
const [exercise, setExercise] = useState(null)
|
||||||
|
/** @type {[PeekStackEntry[], React.Dispatch<React.SetStateAction<PeekStackEntry[]>>]} */
|
||||||
|
const [stack, setStack] = useState([])
|
||||||
|
|
||||||
const variant =
|
/** @type {React.MutableRefObject<boolean>} */
|
||||||
variantId != null && variantId !== '' && exercise?.variants?.length
|
const wasOpenRef = useRef(false)
|
||||||
? exercise.variants.find((v) => String(v.id) === String(variantId)) || null
|
|
||||||
: null
|
|
||||||
|
|
||||||
const isCombination =
|
|
||||||
exercise &&
|
|
||||||
String(exercise.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
|
|
||||||
|
|
||||||
const comboMethodProfileEffective = useMemo(() => {
|
|
||||||
if (!exercise || !isCombination) return {}
|
|
||||||
const fromPeek =
|
|
||||||
peekExtras?.catalog_method_profile &&
|
|
||||||
typeof peekExtras.catalog_method_profile === 'object' &&
|
|
||||||
!Array.isArray(peekExtras.catalog_method_profile) &&
|
|
||||||
Object.keys(peekExtras.catalog_method_profile).length > 0
|
|
||||||
? peekExtras.catalog_method_profile
|
|
||||||
: exercise.method_profile || {}
|
|
||||||
return effectiveComboMethodProfile(fromPeek, peekExtras?.planning_method_profile ?? null)
|
|
||||||
}, [exercise, isCombination, peekExtras])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setExercise(null)
|
setStack([])
|
||||||
setErr(null)
|
wasOpenRef.current = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!exerciseId) {
|
if (exerciseId == null || exerciseId === '') return
|
||||||
setErr('Keine Übung gewählt')
|
if (!wasOpenRef.current) {
|
||||||
|
wasOpenRef.current = true
|
||||||
|
setStack([
|
||||||
|
{
|
||||||
|
exerciseId: Number(exerciseId),
|
||||||
|
variantId: variantId ?? null,
|
||||||
|
peekExtras: peekExtras ?? null,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}, [open, exerciseId, variantId, peekExtras])
|
||||||
|
|
||||||
|
const top = stack.length ? stack[stack.length - 1] : null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !top?.exerciseId) {
|
||||||
|
setExercise(null)
|
||||||
|
setErr(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
@ -74,7 +78,7 @@ export default function ExercisePeekModal({
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setErr(null)
|
setErr(null)
|
||||||
try {
|
try {
|
||||||
const data = await api.getExercise(exerciseId)
|
const data = await api.getExercise(top.exerciseId)
|
||||||
if (!cancelled) setExercise(data)
|
if (!cancelled) setExercise(data)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!cancelled) setErr(e.message || 'Laden fehlgeschlagen')
|
if (!cancelled) setErr(e.message || 'Laden fehlgeschlagen')
|
||||||
|
|
@ -85,7 +89,40 @@ export default function ExercisePeekModal({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [open, exerciseId, variantId])
|
}, [open, top?.exerciseId])
|
||||||
|
|
||||||
|
const variant =
|
||||||
|
top?.variantId != null &&
|
||||||
|
top.variantId !== '' &&
|
||||||
|
exercise?.variants?.length
|
||||||
|
? exercise.variants.find((v) => String(v.id) === String(top.variantId)) || null
|
||||||
|
: null
|
||||||
|
|
||||||
|
const isCombination =
|
||||||
|
exercise && String(exercise.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
|
||||||
|
|
||||||
|
const comboMethodProfileEffective = useMemo(() => {
|
||||||
|
if (!exercise || !isCombination) return {}
|
||||||
|
const fromPeek =
|
||||||
|
top?.peekExtras?.catalog_method_profile &&
|
||||||
|
typeof top.peekExtras.catalog_method_profile === 'object' &&
|
||||||
|
!Array.isArray(top.peekExtras.catalog_method_profile) &&
|
||||||
|
Object.keys(top.peekExtras.catalog_method_profile).length > 0
|
||||||
|
? top.peekExtras.catalog_method_profile
|
||||||
|
: exercise.method_profile || {}
|
||||||
|
return effectiveComboMethodProfile(fromPeek, top?.peekExtras?.planning_method_profile ?? null)
|
||||||
|
}, [exercise, isCombination, top?.peekExtras])
|
||||||
|
|
||||||
|
const planningAdjustedBadge =
|
||||||
|
top?.peekExtras?.planning_method_profile != null &&
|
||||||
|
typeof top.peekExtras.planning_method_profile === 'object' &&
|
||||||
|
!Array.isArray(top.peekExtras.planning_method_profile)
|
||||||
|
|
||||||
|
const pushCandidatePeek = (id) => {
|
||||||
|
const n = Number(id)
|
||||||
|
if (!Number.isFinite(n)) return
|
||||||
|
setStack((s) => [...s, { exerciseId: n, variantId: null, peekExtras: null }])
|
||||||
|
}
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
|
|
@ -107,9 +144,19 @@ export default function ExercisePeekModal({
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="admin-modal-sheet__header">
|
<div className="admin-modal-sheet__header" style={{ gap: '8px', flexWrap: 'wrap' }}>
|
||||||
<h3 id="exercise-peek-title" className="admin-modal-sheet__title">
|
{stack.length > 1 ? (
|
||||||
{loading ? '…' : exercise?.title || titleFallback || `Übung #${exerciseId}`}
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ flexShrink: 0, fontWeight: 600 }}
|
||||||
|
onClick={() => setStack((s) => (s.length > 1 ? s.slice(0, -1) : s))}
|
||||||
|
>
|
||||||
|
← Zurück
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<h3 id="exercise-peek-title" className="admin-modal-sheet__title" style={{ flex: '1 1 200px', minWidth: 0 }}>
|
||||||
|
{loading ? '…' : exercise?.title || titleFallback || (top?.exerciseId != null ? `Übung #${top.exerciseId}` : 'Übung')}
|
||||||
</h3>
|
</h3>
|
||||||
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
|
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
|
||||||
Schließen
|
Schließen
|
||||||
|
|
@ -130,14 +177,10 @@ export default function ExercisePeekModal({
|
||||||
<CombinationPlanBracket
|
<CombinationPlanBracket
|
||||||
methodArchetype={exercise.method_archetype || ''}
|
methodArchetype={exercise.method_archetype || ''}
|
||||||
methodProfile={comboMethodProfileEffective}
|
methodProfile={comboMethodProfileEffective}
|
||||||
combinationSlots={
|
combinationSlots={Array.isArray(exercise.combination_slots) ? exercise.combination_slots : []}
|
||||||
Array.isArray(exercise.combination_slots) ? exercise.combination_slots : []
|
planningAdjusted={planningAdjustedBadge}
|
||||||
}
|
candidateInteraction="button"
|
||||||
planningAdjusted={
|
onCandidatePeek={pushCandidatePeek}
|
||||||
peekExtras?.planning_method_profile != null &&
|
|
||||||
typeof peekExtras.planning_method_profile === 'object' &&
|
|
||||||
!Array.isArray(peekExtras.planning_method_profile)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '1rem 0' }} />
|
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '1rem 0' }} />
|
||||||
</>
|
</>
|
||||||
|
|
@ -210,13 +253,17 @@ export default function ExercisePeekModal({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{exerciseId && (
|
{top?.exerciseId != null ? (
|
||||||
<div style={{ padding: '0 1rem 1rem', flexShrink: 0 }}>
|
<div style={{ padding: '0 1rem 1rem', flexShrink: 0 }}>
|
||||||
<Link to={`/exercises/${exerciseId}`} className="btn btn-secondary" style={{ width: '100%', textDecoration: 'none', textAlign: 'center', display: 'block' }}>
|
<Link
|
||||||
|
to={`/exercises/${top.exerciseId}`}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ width: '100%', textDecoration: 'none', textAlign: 'center', display: 'block' }}
|
||||||
|
>
|
||||||
Vollständige Übungsseite öffnen
|
Vollständige Übungsseite öffnen
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1596,6 +1596,12 @@ export default function TrainingUnitSectionsEditor({
|
||||||
typeof comboPlanningModalItem.planning_method_profile === 'object' &&
|
typeof comboPlanningModalItem.planning_method_profile === 'object' &&
|
||||||
!Array.isArray(comboPlanningModalItem.planning_method_profile)
|
!Array.isArray(comboPlanningModalItem.planning_method_profile)
|
||||||
}
|
}
|
||||||
|
candidateInteraction={onPeekExercise ? 'button' : 'none'}
|
||||||
|
onCandidatePeek={
|
||||||
|
onPeekExercise
|
||||||
|
? (exId) => onPeekExercise(Number(exId), null, undefined)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
|
||||||
import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip'
|
import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip'
|
||||||
import CombinationPlanBracket from '../components/CombinationPlanBracket'
|
import CombinationPlanBracket from '../components/CombinationPlanBracket'
|
||||||
import { formatSkillLevelSlug } from '../constants/skillLevels'
|
import { formatSkillLevelSlug } from '../constants/skillLevels'
|
||||||
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
|
|
||||||
|
|
||||||
function TagRow({ exercise }) {
|
function TagRow({ exercise }) {
|
||||||
const tags = []
|
const tags = []
|
||||||
|
|
@ -52,28 +51,6 @@ function metaParts(exercise) {
|
||||||
return parts
|
return parts
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Eindeutige Kandidaten-Übungen für Schnellnavigation unter der Klammerdarstellung */
|
|
||||||
function flattenCombinationCandidateLinks(slots) {
|
|
||||||
const rows = []
|
|
||||||
const seen = new Set()
|
|
||||||
sortCombinationSlotsForDisplay(slots || []).forEach((s) => {
|
|
||||||
const cands =
|
|
||||||
s.candidates && s.candidates.length
|
|
||||||
? s.candidates
|
|
||||||
: (s.candidate_exercise_ids || []).map((id) => ({
|
|
||||||
exercise_id: id,
|
|
||||||
title: null,
|
|
||||||
}))
|
|
||||||
cands.forEach((c) => {
|
|
||||||
const eid = c.exercise_id
|
|
||||||
if (eid == null || seen.has(eid)) return
|
|
||||||
seen.add(eid)
|
|
||||||
rows.push({ exercise_id: eid, title: (c.title || '').trim() || null })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExerciseDetailPage() {
|
function ExerciseDetailPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -135,9 +112,6 @@ function ExerciseDetailPage() {
|
||||||
(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' &&
|
(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' &&
|
||||||
Array.isArray(exercise.combination_slots) &&
|
Array.isArray(exercise.combination_slots) &&
|
||||||
exercise.combination_slots.length > 0
|
exercise.combination_slots.length > 0
|
||||||
const combinationCandidateLinks = isCombinationDetail
|
|
||||||
? flattenCombinationCandidateLinks(exercise.combination_slots)
|
|
||||||
: []
|
|
||||||
const catalogMethodProfileForBracket =
|
const catalogMethodProfileForBracket =
|
||||||
exercise.method_profile &&
|
exercise.method_profile &&
|
||||||
typeof exercise.method_profile === 'object' &&
|
typeof exercise.method_profile === 'object' &&
|
||||||
|
|
@ -186,20 +160,9 @@ function ExerciseDetailPage() {
|
||||||
methodProfile={catalogMethodProfileForBracket}
|
methodProfile={catalogMethodProfileForBracket}
|
||||||
combinationSlots={exercise.combination_slots}
|
combinationSlots={exercise.combination_slots}
|
||||||
planningAdjusted={false}
|
planningAdjusted={false}
|
||||||
|
candidateInteraction="link"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{combinationCandidateLinks.length > 0 ? (
|
|
||||||
<div style={{ marginTop: '14px', fontSize: '0.88rem' }}>
|
|
||||||
<div style={{ fontWeight: 600, marginBottom: '6px', color: 'var(--text2)' }}>Verknüpfte Einzelübungen</div>
|
|
||||||
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
|
|
||||||
{combinationCandidateLinks.map((c) => (
|
|
||||||
<li key={c.exercise_id}>
|
|
||||||
<Link to={`/exercises/${c.exercise_id}`}>{c.title || `Übung #${c.exercise_id}`}</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import ExerciseFullContent from '../components/ExerciseFullContent'
|
import ExerciseFullContent from '../components/ExerciseFullContent'
|
||||||
|
import ExercisePeekModal from '../components/ExercisePeekModal'
|
||||||
import {
|
import {
|
||||||
flattenPlanTimeline,
|
flattenPlanTimeline,
|
||||||
itemStableKey,
|
itemStableKey,
|
||||||
|
|
@ -178,6 +179,7 @@ export default function TrainingCoachPage() {
|
||||||
const [trainerAppend, setTrainerAppend] = useState('')
|
const [trainerAppend, setTrainerAppend] = useState('')
|
||||||
const [saveMarkDone, setSaveMarkDone] = useState(true)
|
const [saveMarkDone, setSaveMarkDone] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [candidatePeekId, setCandidatePeekId] = useState(null)
|
||||||
const [saveOk, setSaveOk] = useState(null)
|
const [saveOk, setSaveOk] = useState(null)
|
||||||
|
|
||||||
const reloadUnit = useCallback(async () => {
|
const reloadUnit = useCallback(async () => {
|
||||||
|
|
@ -460,6 +462,12 @@ export default function TrainingCoachPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="training-coach-page training-coach-layout">
|
<div className="training-coach-page training-coach-layout">
|
||||||
|
<ExercisePeekModal
|
||||||
|
key={candidatePeekId != null ? String(candidatePeekId) : 'coach-peek-closed'}
|
||||||
|
open={candidatePeekId != null}
|
||||||
|
exerciseId={candidatePeekId}
|
||||||
|
onClose={() => setCandidatePeekId(null)}
|
||||||
|
/>
|
||||||
<nav
|
<nav
|
||||||
className="no-print training-coach-meta-nav"
|
className="no-print training-coach-meta-nav"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -750,6 +758,7 @@ export default function TrainingCoachPage() {
|
||||||
? currentEntry?.item?.planning_method_profile ?? null
|
? currentEntry?.item?.planning_method_profile ?? null
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
onCandidateExercisePeek={(id) => setCandidatePeekId(id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1223,6 +1223,7 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ExercisePeekModal
|
<ExercisePeekModal
|
||||||
|
key={peekCtx != null ? String(peekCtx.exerciseId) : 'fw-peek-closed'}
|
||||||
open={peekCtx != null}
|
open={peekCtx != null}
|
||||||
exerciseId={peekCtx?.exerciseId || 0}
|
exerciseId={peekCtx?.exerciseId || 0}
|
||||||
variantId={peekCtx?.variantId ?? undefined}
|
variantId={peekCtx?.variantId ?? undefined}
|
||||||
|
|
|
||||||
|
|
@ -3087,6 +3087,7 @@ function TrainingPlanningPage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ExercisePeekModal
|
<ExercisePeekModal
|
||||||
|
key={planningPeekCtx != null ? String(planningPeekCtx.exerciseId) : 'plan-peek-closed'}
|
||||||
open={planningPeekCtx != null}
|
open={planningPeekCtx != null}
|
||||||
exerciseId={planningPeekCtx?.exerciseId}
|
exerciseId={planningPeekCtx?.exerciseId}
|
||||||
variantId={planningPeekCtx?.variantId ?? undefined}
|
variantId={planningPeekCtx?.variantId ?? undefined}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user