shinkan-jinkendo/frontend/src/components/CombinationCoachSlots.jsx
Lars 502dddd3b3
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
feat(combo-planning): enhance candidate interaction and UI for combination exercises
- 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>
2026-05-13 21:51:52 +02:00

378 lines
15 KiB
JavaScript

/**
* Kombinationsübung im Coach: Archetyp-Hinweis + Katalog-Inhalt je Slot/Kandidat.
*/
import React, { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
import {
archetypeCoachHint,
combinationArchetypeLabel,
sortCombinationSlotsForDisplay,
} from '../constants/combinationArchetypes'
import {
describeGlobalComboProfile,
effectiveStationTimingSummary,
METHOD_PROFILE_GUI_FIELDS,
readSlotProfilesV1,
} from '../utils/combinationMethodProfileUi'
function formatInlineProfileValue(val) {
if (val === null || val === undefined) return '—'
if (typeof val === 'boolean') return val ? 'ja' : 'nein'
if (typeof val === 'number' && Number.isFinite(val)) return String(val)
if (typeof val === 'string') return val.trim() === '' ? '—' : val
try {
return JSON.stringify(val)
} catch {
return String(val)
}
}
export default function CombinationCoachSlots({
combinationSlots,
methodArchetype,
methodProfile,
compactPlanningView = false,
omitGlobalKeyValueBlock = false,
/** Wenn gesetzt: Kandidaten als Button → Peek (kein Router-Wechsel, PWA-sicher) */
onOpenCandidatePeek,
}) {
const slots = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots), [combinationSlots])
const candidateIds = useMemo(() => {
const set = new Set()
for (const s of slots) {
if (Array.isArray(s.candidates) && s.candidates.length) {
for (const c of s.candidates) {
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]
}, [slots])
const [byId, setById] = useState({})
const [errById, setErrById] = useState({})
const [loadingIds, setLoadingIds] = useState(false)
const sig = candidateIds.slice().sort((a, b) => a - b).join(',')
useEffect(() => {
let cancelled = false
setById({})
setErrById({})
if (candidateIds.length === 0) {
setLoadingIds(false)
return () => {
cancelled = true
}
}
setLoadingIds(true)
Promise.all(
candidateIds.map((id) =>
api.getExercise(id).then(
(ex) => ({ id, ok: true, ex }),
(e) => ({
id,
ok: false,
err: e?.message || String(e),
}),
),
),
).then((results) => {
if (cancelled) return
const map = {}
const emap = {}
for (const r of results) {
if (r.ok) map[r.id] = r.ex
else emap[r.id] = r.err
}
setById(map)
setErrById(emap)
setLoadingIds(false)
})
return () => {
cancelled = true
}
}, [sig])
const archeKey = methodArchetype != null ? String(methodArchetype).trim() : ''
const archDisplay = archeKey ? combinationArchetypeLabel(archeKey) : null
const slotTimingByIx = useMemo(() => {
if (!methodProfile || typeof methodProfile !== 'object' || Array.isArray(methodProfile)) return new Map()
const rows = readSlotProfilesV1(methodProfile)
const m = new Map()
for (const r of rows) {
m.set(Number(r.slot_index), r)
}
return m
}, [methodProfile])
const globalComboRows = useMemo(
() => describeGlobalComboProfile(archeKey, methodProfile || {}),
[archeKey, methodProfile],
)
const profileExtraEntries = useMemo(() => {
if (!methodProfile || typeof methodProfile !== 'object' || Array.isArray(methodProfile)) return []
const known = new Set(['slot_profiles_v1'])
for (const f of METHOD_PROFILE_GUI_FIELDS[archeKey] || []) {
known.add(f.key)
}
const out = []
for (const [k, val] of Object.entries(methodProfile)) {
if (known.has(k)) continue
if (val === null || val === undefined || val === '') continue
out.push([k, val])
}
return out.sort((a, b) => String(a[0]).localeCompare(String(b[0]), 'de'))
}, [methodProfile, archeKey])
return (
<section
className="card"
style={{
marginBottom: '14px',
padding: '12px 14px',
borderLeft: '3px solid var(--accent-dark)',
background: 'var(--surface2)',
}}
>
<h3
style={{
fontSize: '0.72rem',
textTransform: 'uppercase',
color: 'var(--text3)',
margin: '0 0 6px',
letterSpacing: '0.04em',
}}
>
{compactPlanningView ? 'Stationen & Einzelübungen (Katalog)' : 'Kombination · Stationen & Einzelübungen'}
</h3>
{archDisplay ? (
<p style={{ margin: '0 0 6px', fontSize: '0.95rem', fontWeight: 700 }}>{archDisplay}</p>
) : null}
{compactPlanningView ? null : (
<p style={{ margin: '0 0 14px', fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.48 }}>
{archetypeCoachHint(archeKey)}
</p>
)}
{methodProfile && typeof methodProfile === 'object' && !Array.isArray(methodProfile) && Object.keys(methodProfile).length && !omitGlobalKeyValueBlock ? (
<div
style={{
margin: '0 0 14px',
padding: '8px 10px',
borderRadius: '8px',
background: 'var(--surface)',
border: '1px solid var(--border)',
}}
>
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: 'var(--text3)', textTransform: 'uppercase', marginBottom: '6px' }}>
Globale Eckdaten (wie im Editor)
</div>
{globalComboRows.length === 0 && profileExtraEntries.length === 0 ? (
<p style={{ margin: 0, fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.42 }}>
Keine globalen Zahlenfelder gesetzt Zeiten und Steuerung siehe je Station unter Plan: (oder nur im Freitext der Kombination).
</p>
) : (
<dl style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.45 }}>
{globalComboRows.map((row) => (
<div
key={row.key}
style={{
marginBottom: '6px',
display: 'grid',
gridTemplateColumns: 'minmax(88px,1fr) minmax(0,1.4fr)',
gap: '6px 10px',
}}
>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>{row.detailLabel}</dt>
<dd style={{ margin: 0, color: 'var(--text1)', fontWeight: 600 }}>{row.value}</dd>
</div>
))}
{profileExtraEntries.map(([k, val]) => (
<div
key={k}
style={{
marginBottom: '4px',
display: 'grid',
gridTemplateColumns: 'minmax(88px,1fr) minmax(0,1.4fr)',
gap: '6px 10px',
}}
>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)', wordBreak: 'break-all' }}>{k}</dt>
<dd style={{ margin: 0, color: 'var(--text1)' }}>{formatInlineProfileValue(val)}</dd>
</div>
))}
</dl>
)}
</div>
) : null}
{!slots.length ? (
<p style={{ margin: 0, color: 'var(--text3)', fontSize: '0.88rem' }}>Keine Stationen hinterlegt.</p>
) : (
<ol style={{ margin: 0, paddingLeft: '1.1rem', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{slots.map((slot, si) => {
const candIdsRaw = slot.candidate_exercise_ids || []
const candIds = candIdsRaw
.map((id) => (typeof id === 'number' ? id : parseInt(String(id), 10)))
.filter((n) => Number.isFinite(n))
const slotTitle =
(slot.title && String(slot.title).trim()) ||
(candIds.length <= 1 && slot.candidates?.[0]?.title) ||
`Station ${si + 1}`
const ix = slot.slot_index != null ? Number(slot.slot_index) : si
const timingSummary = effectiveStationTimingSummary(archeKey, methodProfile || {}, slotTimingByIx.get(ix))
return (
<li key={`${slot.slot_index ?? si}-${slotTitle}`} style={{ lineHeight: 1.45 }}>
<div style={{ fontWeight: 700, fontSize: '0.92rem', marginBottom: timingSummary ? '4px' : candIds.length > 1 ? '6px' : '8px' }}>
{slotTitle}
</div>
{timingSummary ? (
<p style={{ margin: '0 0 8px', fontSize: '0.78rem', color: 'var(--text2)', lineHeight: 1.42 }}>
Plan: <span style={{ color: 'var(--text1)', fontWeight: 600 }}>{timingSummary}</span>
</p>
) : null}
{candIds.length === 0 ? (
<p style={{ margin: 0, color: 'var(--text3)', fontSize: '0.86rem' }}>Keine Übung zugeordnet.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', paddingLeft: 0 }}>
{candIds.map((cid, ci) => {
const ex = byId[cid]
const err = errById[cid]
const candTitleFallback =
slot.candidates?.find((c) => Number(c.exercise_id) === cid)?.title ||
slot.candidates?.[ci]?.title
const isAlt = candIds.length > 1
return (
<div
key={`${cid}-${ci}`}
style={{
padding: '10px 11px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface)',
}}
>
{isAlt ? (
<div style={{ fontSize: '0.72rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '4px' }}>
{candIds.length > 2 ? `Alternative ${ci + 1}` : ci === 0 ? 'Alternative A' : 'Alternative B'}
</div>
) : null}
{!ex && loadingIds ? (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', fontSize: '0.86rem', color: 'var(--text2)' }}>
<span className="spinner" style={{ transform: 'scale(0.7)' }} />
Übung #{cid} laden
</div>
) : err ? (
<p style={{ margin: 0, color: 'var(--danger)', fontSize: '0.88rem' }}>
Übung #{cid}: {err}
</p>
) : ex ? (
compactPlanningView ? (
<>
<p style={{ margin: '0 0 4px', fontSize: '0.96rem', fontWeight: 700 }}>{ex.title}</p>
<p style={{ margin: 0 }}>
{typeof onOpenCandidatePeek === 'function' ? (
<button
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 style={{ margin: '0 0 6px', fontSize: '0.96rem', fontWeight: 700 }}>{ex.title}</p>
{ex.summary ? (
<div style={{ marginBottom: ex.execution ? '8px' : 0 }}>
<ExerciseRichTextBlock html={ex.summary} exerciseId={ex.id} media={ex.media} />
</div>
) : (
<p style={{ margin: '0 0 6px', fontSize: '0.86rem', color: 'var(--text3)' }}>
Keine Kurzbeschreibung im Katalog.
</p>
)}
{ex.execution ? (
<details style={{ marginTop: '4px' }}>
<summary style={{ cursor: 'pointer', fontSize: '0.82rem', color: 'var(--accent-dark)', fontWeight: 600 }}>
Ablauf (Detail)
</summary>
<div style={{ marginTop: '8px' }}>
<ExerciseRichTextBlock html={ex.execution} exerciseId={ex.id} media={ex.media} />
</div>
</details>
) : null}
{ex.trainer_notes ? (
<details style={{ marginTop: '6px' }}>
<summary style={{ cursor: 'pointer', fontSize: '0.82rem', color: 'var(--accent-dark)', fontWeight: 600 }}>
Hinweise Trainer
</summary>
<div style={{ marginTop: '8px' }}>
<ExerciseRichTextBlock html={ex.trainer_notes} exerciseId={ex.id} media={ex.media} />
</div>
</details>
) : null}
<p style={{ marginTop: '8px', marginBottom: 0 }}>
{typeof onOpenCandidatePeek === 'function' ? (
<button
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 style={{ margin: 0, fontSize: '0.86rem', color: 'var(--text2)' }}>
{candTitleFallback || `Übung #${cid}`}
</p>
)}
</div>
)
})}
</div>
)}
</li>
)
})}
</ol>
)}
</section>
)
}