2026-05-13 06:36:50 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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'
|
2026-05-13 14:11:53 +02:00
|
|
|
|
import { readSlotProfilesV1, summarizeSlotProfileBrief } from '../utils/combinationMethodProfileUi'
|
2026-05-13 06:36:50 +02:00
|
|
|
|
|
2026-05-13 09:58:59 +02:00
|
|
|
|
export default function CombinationCoachSlots({
|
|
|
|
|
|
combinationSlots,
|
|
|
|
|
|
methodArchetype,
|
|
|
|
|
|
methodProfile,
|
|
|
|
|
|
compactPlanningView = false,
|
|
|
|
|
|
omitGlobalKeyValueBlock = false,
|
|
|
|
|
|
}) {
|
2026-05-13 08:58:41 +02:00
|
|
|
|
const slots = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots), [combinationSlots])
|
2026-05-13 06:36:50 +02:00
|
|
|
|
|
|
|
|
|
|
const candidateIds = useMemo(() => {
|
|
|
|
|
|
const set = new Set()
|
|
|
|
|
|
for (const s of slots) {
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-13 08:19:46 +02:00
|
|
|
|
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 methodProfileKvSansSlots = useMemo(() => {
|
|
|
|
|
|
if (!methodProfile || typeof methodProfile !== 'object' || Array.isArray(methodProfile)) return []
|
|
|
|
|
|
return Object.entries(methodProfile)
|
|
|
|
|
|
.filter(([k]) => k !== 'slot_profiles_v1')
|
|
|
|
|
|
.sort(([a], [b]) => a.localeCompare(b, 'de'))
|
|
|
|
|
|
}, [methodProfile])
|
|
|
|
|
|
|
2026-05-13 06:36:50 +02:00
|
|
|
|
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',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-05-13 09:58:59 +02:00
|
|
|
|
{compactPlanningView ? 'Stationen & Einzelübungen (Katalog)' : 'Kombination · Stationen & Einzelübungen'}
|
2026-05-13 06:36:50 +02:00
|
|
|
|
</h3>
|
|
|
|
|
|
{archDisplay ? (
|
|
|
|
|
|
<p style={{ margin: '0 0 6px', fontSize: '0.95rem', fontWeight: 700 }}>
|
|
|
|
|
|
{archDisplay}
|
|
|
|
|
|
{archeKey && archDisplay !== archeKey ? (
|
|
|
|
|
|
<span style={{ marginLeft: 8, fontSize: '0.78rem', fontWeight: 500, color: 'var(--text3)' }}>
|
|
|
|
|
|
({archeKey})
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
2026-05-13 09:58:59 +02:00
|
|
|
|
{compactPlanningView ? null : (
|
2026-05-13 06:36:50 +02:00
|
|
|
|
<p style={{ margin: '0 0 14px', fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.48 }}>
|
|
|
|
|
|
{archetypeCoachHint(archeKey)}
|
|
|
|
|
|
</p>
|
2026-05-13 09:58:59 +02:00
|
|
|
|
)}
|
2026-05-13 06:36:50 +02:00
|
|
|
|
|
2026-05-13 09:58:59 +02:00
|
|
|
|
{methodProfile && typeof methodProfile === 'object' && !Array.isArray(methodProfile) && Object.keys(methodProfile).length && !omitGlobalKeyValueBlock ? (
|
2026-05-13 07:16:58 +02:00
|
|
|
|
<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' }}>
|
|
|
|
|
|
Geplantes Ablaufprofil (Katalog)
|
|
|
|
|
|
</div>
|
2026-05-13 08:19:46 +02:00
|
|
|
|
{methodProfileKvSansSlots.length === 0 ? (
|
|
|
|
|
|
<p style={{ margin: 0, fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.42 }}>
|
|
|
|
|
|
Nur stationsbezogene Daten (Zeiten/Zähl‑Steuerung) — siehe je Station unter der Überschrift „Plan:“.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<dl style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.45 }}>
|
|
|
|
|
|
{methodProfileKvSansSlots.map(([k, val]) => (
|
2026-05-13 07:16:58 +02:00
|
|
|
|
<div key={k} style={{ marginBottom: '4px', display: 'grid', gridTemplateColumns: 'minmax(72px,1fr) minmax(0,2fr)', gap: '6px 10px' }}>
|
|
|
|
|
|
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)', wordBreak: 'break-all' }}>{k}</dt>
|
|
|
|
|
|
<dd style={{ margin: 0, color: 'var(--text1)' }}>
|
|
|
|
|
|
{typeof val === 'boolean'
|
|
|
|
|
|
? val
|
|
|
|
|
|
? 'ja'
|
|
|
|
|
|
: 'nein'
|
|
|
|
|
|
: typeof val === 'number'
|
|
|
|
|
|
? String(val)
|
|
|
|
|
|
: typeof val === 'string'
|
|
|
|
|
|
? val
|
|
|
|
|
|
: JSON.stringify(val)}
|
|
|
|
|
|
</dd>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
2026-05-13 08:19:46 +02:00
|
|
|
|
</dl>
|
|
|
|
|
|
)}
|
2026-05-13 07:16:58 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
2026-05-13 06:36:50 +02:00
|
|
|
|
{!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 ${slot.slot_index != null ? Number(slot.slot_index) + 1 : si + 1}`
|
|
|
|
|
|
|
2026-05-13 08:19:46 +02:00
|
|
|
|
const ix = slot.slot_index != null ? Number(slot.slot_index) : si
|
2026-05-13 14:11:53 +02:00
|
|
|
|
const timingSummary = summarizeSlotProfileBrief(slotTimingByIx.get(ix))
|
2026-05-13 08:19:46 +02:00
|
|
|
|
|
2026-05-13 06:36:50 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<li key={`${slot.slot_index ?? si}-${slotTitle}`} style={{ lineHeight: 1.45 }}>
|
2026-05-13 08:19:46 +02:00
|
|
|
|
<div style={{ fontWeight: 700, fontSize: '0.92rem', marginBottom: timingSummary ? '4px' : candIds.length > 1 ? '6px' : '8px' }}>
|
2026-05-13 06:36:50 +02:00
|
|
|
|
{slotTitle}
|
|
|
|
|
|
</div>
|
2026-05-13 08:19:46 +02:00
|
|
|
|
{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}
|
2026-05-13 06:36:50 +02:00
|
|
|
|
{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 ? (
|
2026-05-13 09:58:59 +02:00
|
|
|
|
compactPlanningView ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<p style={{ margin: '0 0 4px', fontSize: '0.96rem', fontWeight: 700 }}>{ex.title}</p>
|
|
|
|
|
|
<p style={{ margin: 0 }}>
|
|
|
|
|
|
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.82rem', color: 'var(--accent)' }}>
|
|
|
|
|
|
Im Katalog öffnen
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
2026-05-13 06:36:50 +02:00
|
|
|
|
<>
|
|
|
|
|
|
<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 }}>
|
|
|
|
|
|
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.84rem', color: 'var(--accent)' }}>
|
|
|
|
|
|
Volle Übungsseite
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</>
|
2026-05-13 09:58:59 +02:00
|
|
|
|
)
|
2026-05-13 06:36:50 +02:00
|
|
|
|
) : (
|
|
|
|
|
|
<p style={{ margin: 0, fontSize: '0.86rem', color: 'var(--text2)' }}>
|
|
|
|
|
|
{candTitleFallback || `Übung #${cid}`}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</ol>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|