All checks were successful
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Successful in 56s
- Introduced `summarizeSlotProfileBrief` utility for concise slot profile summaries, improving the display of combination exercises. - Updated `CombinationCoachSlots` and `ExercisePeekModal` components to utilize the new summary function for better user experience. - Enhanced `TrainingUnitSectionsEditor` to manage combination slots more effectively, including improved title handling and display options. - Adjusted `TrainingPlanningPage` to support additional peek context for combination exercises, streamlining the planning process. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1720 lines
66 KiB
JavaScript
1720 lines
66 KiB
JavaScript
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
|
||
import { GripVertical, Pencil } from 'lucide-react'
|
||
import CombinationMethodProfileEditor from './CombinationMethodProfileEditor'
|
||
import CombinationCoachSlots from './CombinationCoachSlots'
|
||
import { comboPlanningProfileJsonForEditor, effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
|
||
import { combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
|
||
import {
|
||
comboSlotsOutlineForProfileEditor,
|
||
defaultSection,
|
||
exerciseRow,
|
||
noteRow,
|
||
sectionPlannedMinutes,
|
||
} from '../utils/trainingUnitSectionsForm'
|
||
import api from '../utils/api'
|
||
import { readSlotProfilesV1, summarizeSlotProfileBrief } from '../utils/combinationMethodProfileUi'
|
||
import { isCompactTagLegendMode } from '../config/planningModuleUx'
|
||
import { useAuth } from '../context/AuthContext'
|
||
|
||
const DND_TU_ITEM = 'application/x-shinkan-training-unit-item'
|
||
const DND_TU_SECTION = 'application/x-shinkan-training-section-v1'
|
||
|
||
/** Optische Trennlinie: wird als normale Zwischen-Anmerkung gespeichert (Inhalt nur dieser Marker). */
|
||
const SECTION_INSERT_SEPARATOR_BODY = '---'
|
||
|
||
function normalizedPlanningModuleChainId(raw) {
|
||
if (raw == null || raw === '') return null
|
||
const n = typeof raw === 'number' ? raw : Number(raw)
|
||
return Number.isFinite(n) && n >= 1 ? n : null
|
||
}
|
||
|
||
function dtHasType(e, mime) {
|
||
const t = e?.dataTransfer?.types
|
||
if (!t || !mime) return false
|
||
if (typeof t.contains === 'function' && t.contains(mime)) return true
|
||
return Array.from(t).includes(mime)
|
||
}
|
||
|
||
function truncatePreview(text, max = 160) {
|
||
const t = (text || '').replace(/\s+/g, ' ').trim()
|
||
if (t.length <= max) return t
|
||
return `${t.slice(0, max - 1)}…`
|
||
}
|
||
|
||
/** Liest den zusammenhängenden Lauf eines Moduls im Abschnitt (ab erstem Item mit dieser Herkunfts-ID). */
|
||
function gatherPlanningModuleOutline(items, startIdx, moduleId) {
|
||
const exercises = []
|
||
let notes = 0
|
||
for (let j = startIdx; j < (items?.length ?? 0); j++) {
|
||
const row = items[j]
|
||
if (normalizedPlanningModuleChainId(row.source_training_module_id) !== moduleId) break
|
||
if (row.item_type === 'note') {
|
||
const bod = (row.note_body || '').trim()
|
||
if (bod === SECTION_INSERT_SEPARATOR_BODY) continue
|
||
notes += 1
|
||
continue
|
||
}
|
||
const t =
|
||
(row.exercise_title || '').trim() ||
|
||
(row.exercise_id ? `Übung #${row.exercise_id}` : 'Übung')
|
||
exercises.push(t)
|
||
}
|
||
return { exercises, notes }
|
||
}
|
||
|
||
const MODULE_OUTLINE_PREVIEW_MAX = 8
|
||
|
||
/** Statuszeile: Planungs‑Override vs. Katalog, inkl. Archetyp‑Label wenn bekannt. */
|
||
function compactComboPlanningCaption(it) {
|
||
const overridden =
|
||
it.planning_method_profile != null &&
|
||
typeof it.planning_method_profile === 'object' &&
|
||
!Array.isArray(it.planning_method_profile)
|
||
const archRaw = String(it.catalog_method_archetype || '').trim()
|
||
const archLbl = archRaw ? combinationArchetypeLabel(archRaw) : null
|
||
if (overridden) {
|
||
return archLbl ? `${archLbl} · Planung angepasst` : 'Planung angepasst'
|
||
}
|
||
return archLbl ? `${archLbl} · wie Katalog` : 'wie im Katalog'
|
||
}
|
||
|
||
/** Globale Eckdaten aus effective profile (optional unter Stationenliste). */
|
||
function comboRoughGlobalTimingHint(profileObj, archetypeKey) {
|
||
if (!profileObj || typeof profileObj !== 'object' || Array.isArray(profileObj)) return null
|
||
const bits = []
|
||
const rounds = profileObj.rounds
|
||
const ws = profileObj.work_seconds
|
||
const rb = profileObj.rest_between_rounds_sec
|
||
const hint = profileObj.hint_step_duration_sec
|
||
const globRest = profileObj.rest_between_sets_sec
|
||
if (rounds != null && rounds !== '') bits.push(`${rounds} Runden`)
|
||
if (ws != null && ws !== '') bits.push(`${ws}s Arbeit`)
|
||
if (rb != null && rb !== '') bits.push(`Pause ${rb}s`)
|
||
if (globRest != null && globRest !== '') bits.push(`Sets-Pause ${globRest}s`)
|
||
if (hint != null && hint !== '') bits.push(`Orientierung ~${hint}s`)
|
||
const arch = (archetypeKey || '').trim()
|
||
if (arch === 'time_domain_interval') {
|
||
const iw = profileObj.interval_work_sec
|
||
const ir = profileObj.interval_rest_sec
|
||
const ig = profileObj.interval_groups
|
||
if (iw != null && iw !== '') bits.push(`${iw}s Intervall`)
|
||
if (ir != null && ir !== '') bits.push(`${ir}s Erholung`)
|
||
if (ig != null && ig !== '') bits.push(`${ig} Gruppen`)
|
||
}
|
||
return bits.length ? bits.join(' · ') : null
|
||
}
|
||
|
||
/** Pro Station eine kompakte Textzeile für die Planungsliste. */
|
||
function comboPlanningStripBulletTexts(it) {
|
||
const slots = sortCombinationSlotsForDisplay(it.combination_slots || [])
|
||
if (!slots.length) return []
|
||
const mp = effectiveComboMethodProfile(it.catalog_method_profile || {}, it.planning_method_profile)
|
||
const byIx = new Map(readSlotProfilesV1(mp).map((r) => [Number(r.slot_index), r]))
|
||
const titles = it.combo_member_title_by_id || {}
|
||
return slots.map((slot, idx) => {
|
||
const siRaw = slot.slot_index
|
||
const siParsed =
|
||
siRaw === '' || siRaw == null ? idx : typeof siRaw === 'number' ? siRaw : parseInt(String(siRaw), 10)
|
||
const ix = Number.isFinite(siParsed) ? siParsed : idx
|
||
const stationLbl = ((slot.title || '').trim() || `Station ${ix}`)
|
||
const candIds = (slot.candidate_exercise_ids || [])
|
||
.map((raw) => (typeof raw === 'number' ? raw : parseInt(String(raw), 10)))
|
||
.filter((n) => Number.isFinite(n))
|
||
const namesJoined =
|
||
candIds.length === 0
|
||
? '(keine Übung)'
|
||
: candIds.map((id) => titles[String(id)] || `Übung ${id}`).join(' ↔ ')
|
||
const timing = summarizeSlotProfileBrief(byIx.get(ix))
|
||
let line = `${stationLbl}: ${namesJoined}`
|
||
if (timing) line += ` · ${timing}`
|
||
return line
|
||
})
|
||
}
|
||
|
||
/** Stabile Farbzurodnung aus Modul-ID (nur Darstellung). */
|
||
function planningModulePalette(moduleId) {
|
||
const id = normalizedPlanningModuleChainId(moduleId)
|
||
const n = id != null && id >= 1 ? Math.floor(Number(id)) : 1
|
||
const golden = ((n >>> 0) * 2654435761 + n * 73856093) >>> 0
|
||
const h = golden % 360
|
||
const border = `hsl(${h} 52% 36%)`
|
||
const soft = `hsl(${h} 42% 94%)`
|
||
return { border, soft, hue: h }
|
||
}
|
||
|
||
function PlanningModuleRowTag({ moduleId, title }) {
|
||
const p = planningModulePalette(moduleId)
|
||
const lbl = truncatePreview(title || `Modul #${moduleId}`, 34).trim()
|
||
const fullTitle = ((title || '').trim() || `Modul #${moduleId}`).trim()
|
||
return (
|
||
<span
|
||
className="tu-planning-mod-tag"
|
||
style={{ borderColor: p.border, backgroundColor: p.soft }}
|
||
title={`${fullTitle} (Bibliotheks-ID ${moduleId})`}
|
||
>
|
||
<span className="tu-planning-mod-tag__dot" style={{ background: p.border }} aria-hidden />
|
||
<span className="tu-planning-mod-tag__text">Aus Modul: {lbl}</span>
|
||
</span>
|
||
)
|
||
}
|
||
|
||
/** Eindeutige Module im Abschnitt mit Zählerständen für die Legende. */
|
||
function sectionModuleLegendModel(items) {
|
||
const map = new Map()
|
||
for (const row of items || []) {
|
||
const id = normalizedPlanningModuleChainId(row.source_training_module_id)
|
||
if (id == null) continue
|
||
if (!map.has(id)) {
|
||
map.set(id, {
|
||
id,
|
||
title: (((row.source_module_title || '').trim() || '') || `Modul #${id}`).trim(),
|
||
exercises: 0,
|
||
notes: 0,
|
||
})
|
||
}
|
||
const agg = map.get(id)
|
||
if ((row.item_type || '') === 'note') {
|
||
const bod = ((row.note_body || '').trim() || '').trim()
|
||
if (bod === SECTION_INSERT_SEPARATOR_BODY) continue
|
||
agg.notes += 1
|
||
} else {
|
||
agg.exercises += 1
|
||
}
|
||
}
|
||
return [...map.values()].sort((a, b) => a.id - b.id)
|
||
}
|
||
|
||
function renderModulePlanningHead(modBandTitle, modOutline, showModuleBand) {
|
||
if (!showModuleBand || !modOutline) return null
|
||
return (
|
||
<div className="tu-module-bundle-head" role="group" aria-label={`Modul ${modBandTitle}`}>
|
||
<div className="tu-module-bundle-head__stripe" aria-hidden />
|
||
<div className="tu-module-bundle-head__main">
|
||
<span className="tu-module-bundle-head__kicker">Aus Modul</span>
|
||
<strong className="tu-module-bundle-head__title">{modBandTitle}</strong>
|
||
{modOutline.exercises.length === 0 && modOutline.notes === 0 ? (
|
||
<p className="tu-module-bundle-head__empty">Ohne strukturierten Inhalt angezeigt.</p>
|
||
) : (
|
||
<ol className="tu-module-bundle-head__list" start={1}>
|
||
{modOutline.exercises.slice(0, MODULE_OUTLINE_PREVIEW_MAX).map((tx, ox) => (
|
||
<li key={`mo-li-${modBandTitle}-${ox}`}>{tx}</li>
|
||
))}
|
||
</ol>
|
||
)}
|
||
{modOutline.exercises.length > MODULE_OUTLINE_PREVIEW_MAX ? (
|
||
<p className="tu-module-bundle-head__more">
|
||
… und noch {modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX}{' '}
|
||
{modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX === 1 ? 'Übung' : 'Übungen'}
|
||
</p>
|
||
) : null}
|
||
{modOutline.notes > 0 ? (
|
||
<p className="tu-module-bundle-head__meta">
|
||
sowie {modOutline.notes}{' '}
|
||
{modOutline.notes === 1 ? 'Zwischen-Hinweis' : 'Zwischen-Hinweise'}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function reorderBlocksImmutable(blocks, fromI, toBeforeIdx) {
|
||
const b = [...blocks]
|
||
if (fromI < 0 || fromI >= b.length) return blocks
|
||
const [moved] = b.splice(fromI, 1)
|
||
let insertAt = toBeforeIdx
|
||
if (fromI < toBeforeIdx) insertAt = toBeforeIdx - 1
|
||
insertAt = Math.max(0, Math.min(insertAt, b.length))
|
||
b.splice(insertAt, 0, moved)
|
||
return b
|
||
}
|
||
|
||
/**
|
||
* @param {(updater: (prev: Array) => Array) => void} props.onSectionsChange — wie React setState
|
||
* @param {(p: { fromSlot: number, fromSectionIdx: number, toSlot: number, toSectionIdx: number }) => void} [props.onMoveSectionsAcrossSlots] — Rahmenprogramm: Abschnitt zwischen Slots verschieben
|
||
*/
|
||
export default function TrainingUnitSectionsEditor({
|
||
sections,
|
||
onSectionsChange,
|
||
onRequestExercisePick,
|
||
onRequestTrainingModulePick,
|
||
onPeekExercise,
|
||
showExecutionExtras = false,
|
||
heading = 'Abschnitte & Übungen',
|
||
hideHeading = false,
|
||
headingAccessory = null,
|
||
wideExerciseGrid = false,
|
||
enableItemDragReorder = true,
|
||
enableSectionDragReorder = true,
|
||
slotIndex = null,
|
||
onMoveSectionsAcrossSlots = null,
|
||
/** Dünnes „+“ zwischen Einträge: Popup für Typ (Übung, Modul, …) */
|
||
betweenInsertMenus = true,
|
||
}) {
|
||
const { user } = useAuth()
|
||
const planningCompactLegend = isCompactTagLegendMode(
|
||
user?.training_planning_prefs?.module_display_mode
|
||
)
|
||
|
||
const ensure = (prev) =>
|
||
prev && prev.length ? prev : [defaultSection()]
|
||
|
||
const patch = useCallback(
|
||
(updater) => {
|
||
onSectionsChange((prev) => updater(ensure(prev)))
|
||
},
|
||
[onSectionsChange]
|
||
)
|
||
|
||
const sectionToSlot =
|
||
slotIndex !== null && slotIndex !== undefined ? Number(slotIndex) : -1
|
||
|
||
const updateSectionField = (sIdx, field, val) => {
|
||
patch((prev) =>
|
||
prev.map((s, i) => (i === sIdx ? { ...s, [field]: val } : s))
|
||
)
|
||
}
|
||
|
||
const addSection = () => {
|
||
patch((prev) => [...prev, defaultSection(`Abschnitt ${prev.length + 1}`)])
|
||
}
|
||
|
||
const removeSection = (sIdx) => {
|
||
patch((prev) => {
|
||
const next = prev.filter((_, i) => i !== sIdx)
|
||
return next.length ? next : [defaultSection()]
|
||
})
|
||
}
|
||
|
||
const moveSection = (sIdx, dir) => {
|
||
patch((prev) => {
|
||
const p = [...prev]
|
||
const ta = sIdx + dir
|
||
if (ta < 0 || ta >= p.length) return p
|
||
;[p[sIdx], p[ta]] = [p[ta], p[sIdx]]
|
||
return p
|
||
})
|
||
}
|
||
|
||
const insertItemAt = useCallback(
|
||
(sIdx, beforeIx, row) => {
|
||
patch((prev) =>
|
||
prev.map((s, i) => {
|
||
if (i !== sIdx) return s
|
||
const items = [...(s.items || [])]
|
||
const ix = Math.max(
|
||
0,
|
||
Math.min(Number(beforeIx) || 0, items.length)
|
||
)
|
||
items.splice(ix, 0, row)
|
||
return { ...s, items }
|
||
})
|
||
)
|
||
},
|
||
[patch]
|
||
)
|
||
|
||
const addItem = (sIdx, kind) => {
|
||
patch((prev) =>
|
||
prev.map((s, i) => {
|
||
if (i !== sIdx) return s
|
||
const items = [...(s.items || [])]
|
||
items.push(kind === 'note' ? noteRow() : exerciseRow())
|
||
return { ...s, items }
|
||
})
|
||
)
|
||
}
|
||
|
||
const removeItem = (sIdx, iIdx) => {
|
||
patch((prev) =>
|
||
prev.map((s, si) =>
|
||
si !== sIdx ? s : { ...s, items: (s.items || []).filter((_, ii) => ii !== iIdx) }
|
||
)
|
||
)
|
||
}
|
||
|
||
const moveItem = (sIdx, iIdx, dir) => {
|
||
patch((prev) =>
|
||
prev.map((s, si) => {
|
||
if (si !== sIdx) return s
|
||
const items = [...(s.items || [])]
|
||
const ta = iIdx + dir
|
||
if (ta < 0 || ta >= items.length) return s
|
||
;[items[iIdx], items[ta]] = [items[ta], items[iIdx]]
|
||
return { ...s, items }
|
||
})
|
||
)
|
||
}
|
||
|
||
const updateItem = (sIdx, iIdx, field, val) => {
|
||
patch((prev) =>
|
||
prev.map((s, si) =>
|
||
si !== sIdx
|
||
? s
|
||
: {
|
||
...s,
|
||
items: (s.items || []).map((row, ii) =>
|
||
ii === iIdx ? { ...row, [field]: val } : row
|
||
),
|
||
}
|
||
)
|
||
)
|
||
}
|
||
|
||
const [textEdit, setTextEdit] = useState(null)
|
||
/** Kombi: Ablaufprofil in Modal statt einzuklappender Karte */
|
||
const [comboPlanningModal, setComboPlanningModal] = useState(null)
|
||
/** Katalog-Stationen, falls Zeile noch keine `combination_slots` (vor Enrich o. Ä.) */
|
||
const [modalComboSlotsFetched, setModalComboSlotsFetched] = useState(null)
|
||
/** { sIdx: number, beforeIx: number } – Einfüge-Popup („+“ zwischen Zeilen) */
|
||
const [insertChooser, setInsertChooser] = useState(null)
|
||
const [draggingPos, setDraggingPos] = useState(null)
|
||
const [dropTargetPos, setDropTargetPos] = useState(null)
|
||
|
||
const [dropSectionBand, setDropSectionBand] = useState(null)
|
||
/** { slot: number, beforeIdx: number } */
|
||
|
||
useEffect(() => {
|
||
if (!textEdit) return
|
||
const onKey = (e) => {
|
||
if (e.key === 'Escape') setTextEdit(null)
|
||
}
|
||
window.addEventListener('keydown', onKey)
|
||
return () => window.removeEventListener('keydown', onKey)
|
||
}, [textEdit])
|
||
|
||
useEffect(() => {
|
||
if (!insertChooser) return
|
||
const onKey = (e) => {
|
||
if (e.key === 'Escape') setInsertChooser(null)
|
||
}
|
||
window.addEventListener('keydown', onKey)
|
||
return () => window.removeEventListener('keydown', onKey)
|
||
}, [insertChooser])
|
||
|
||
useEffect(() => {
|
||
if (!comboPlanningModal) return
|
||
const onKey = (e) => {
|
||
if (e.key === 'Escape') setComboPlanningModal(null)
|
||
}
|
||
window.addEventListener('keydown', onKey)
|
||
return () => window.removeEventListener('keydown', onKey)
|
||
}, [comboPlanningModal])
|
||
|
||
useEffect(() => {
|
||
if (!comboPlanningModal) return
|
||
const L = ensure(sections)
|
||
const { sIdx, iIdx } = comboPlanningModal
|
||
const row = L[sIdx]?.items?.[iIdx]
|
||
const ok =
|
||
row &&
|
||
String(row.exercise_kind || '').toLowerCase().trim() === 'combination' &&
|
||
row.exercise_id
|
||
if (!ok) setComboPlanningModal(null)
|
||
}, [sections, comboPlanningModal])
|
||
|
||
useEffect(() => {
|
||
if (!comboPlanningModal) {
|
||
setModalComboSlotsFetched(null)
|
||
return
|
||
}
|
||
const L = ensure(sections)
|
||
const { sIdx, iIdx } = comboPlanningModal
|
||
const cand = L[sIdx]?.items?.[iIdx]
|
||
if (
|
||
!cand ||
|
||
String(cand.exercise_kind || '').toLowerCase().trim() !== 'combination' ||
|
||
!cand.exercise_id
|
||
) {
|
||
setModalComboSlotsFetched(null)
|
||
return
|
||
}
|
||
const cached = cand.combination_slots
|
||
if (Array.isArray(cached) && cached.length > 0) {
|
||
setModalComboSlotsFetched(cached)
|
||
return
|
||
}
|
||
let cancelled = false
|
||
setModalComboSlotsFetched([])
|
||
api.getExercise(cand.exercise_id).then((ex) => {
|
||
if (cancelled) return
|
||
setModalComboSlotsFetched(Array.isArray(ex?.combination_slots) ? ex.combination_slots : [])
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [sections, comboPlanningModal])
|
||
|
||
const closeInsertChooser = useCallback(() => setInsertChooser(null), [])
|
||
|
||
const insertSlotKeyPrefix =
|
||
slotIndex !== null && slotIndex !== undefined ? `sl${slotIndex}-` : ''
|
||
|
||
const clearSectionDnD = () => setDropSectionBand(null)
|
||
|
||
const onSectionDragStart = (e, sIdx) => {
|
||
if (!enableSectionDragReorder) return
|
||
e.stopPropagation()
|
||
try {
|
||
e.dataTransfer.effectAllowed = 'move'
|
||
e.dataTransfer.setData(
|
||
DND_TU_SECTION,
|
||
JSON.stringify({
|
||
fromSlot: sectionToSlot,
|
||
fromSectionIdx: sIdx,
|
||
})
|
||
)
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
setDropSectionBand(null)
|
||
}
|
||
|
||
const onSectionBandDragOver = (e, beforeIdx) => {
|
||
if (!enableSectionDragReorder) return
|
||
if (!dtHasType(e, DND_TU_SECTION)) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
try {
|
||
e.dataTransfer.dropEffect = 'move'
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
setDropSectionBand({ slot: sectionToSlot, beforeIdx })
|
||
}
|
||
|
||
const onSectionBandDrop = (e, insertBeforeIdx) => {
|
||
if (!enableSectionDragReorder) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
clearSectionDnD()
|
||
let raw = ''
|
||
try {
|
||
raw = e.dataTransfer.getData(DND_TU_SECTION)
|
||
} catch {
|
||
return
|
||
}
|
||
if (!raw) return
|
||
let data
|
||
try {
|
||
data = JSON.parse(raw)
|
||
} catch {
|
||
return
|
||
}
|
||
const fromSi = data.fromSectionIdx
|
||
const fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1
|
||
if (typeof fromSi !== 'number') return
|
||
|
||
if (
|
||
typeof onMoveSectionsAcrossSlots === 'function' &&
|
||
sectionToSlot >= 0 &&
|
||
fromSlot >= 0
|
||
) {
|
||
onMoveSectionsAcrossSlots({
|
||
fromSlot,
|
||
fromSectionIdx: fromSi,
|
||
toSlot: sectionToSlot,
|
||
toSectionIdx: insertBeforeIdx,
|
||
})
|
||
return
|
||
}
|
||
|
||
patch((prev) => reorderBlocksImmutable(prev, fromSi, insertBeforeIdx))
|
||
}
|
||
|
||
const onItemDragStart = (e, sIdx, iIdx) => {
|
||
if (!enableItemDragReorder) return
|
||
e.stopPropagation()
|
||
try {
|
||
e.dataTransfer.effectAllowed = 'move'
|
||
e.dataTransfer.setData(
|
||
DND_TU_ITEM,
|
||
JSON.stringify({ sectionIndex: sIdx, itemIndex: iIdx })
|
||
)
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
setDraggingPos({ sIdx, iIdx })
|
||
}
|
||
|
||
const clearDragChrome = () => {
|
||
setDraggingPos(null)
|
||
setDropTargetPos(null)
|
||
}
|
||
|
||
const onItemDragOverRow = (e, sIdx, iIdx) => {
|
||
if (!enableItemDragReorder) return
|
||
if (!dtHasType(e, DND_TU_ITEM)) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
try {
|
||
e.dataTransfer.dropEffect = 'move'
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
setDropTargetPos({ sIdx, iIdx })
|
||
}
|
||
|
||
const onItemDropRow = (e, toSIdx, toIdx) => {
|
||
if (!enableItemDragReorder) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
let raw = ''
|
||
try {
|
||
raw = e.dataTransfer.getData(DND_TU_ITEM)
|
||
} catch {
|
||
clearDragChrome()
|
||
return
|
||
}
|
||
if (!raw) {
|
||
clearDragChrome()
|
||
return
|
||
}
|
||
let data
|
||
try {
|
||
data = JSON.parse(raw)
|
||
} catch {
|
||
clearDragChrome()
|
||
return
|
||
}
|
||
const fromS = data.sectionIndex
|
||
const fromI = data.itemIndex
|
||
if (typeof fromS !== 'number' || typeof fromI !== 'number') {
|
||
clearDragChrome()
|
||
return
|
||
}
|
||
if (fromS === toSIdx && fromI === toIdx) {
|
||
clearDragChrome()
|
||
return
|
||
}
|
||
|
||
patch((prev) => {
|
||
const list = ensure(prev)
|
||
if (
|
||
fromS < 0 ||
|
||
fromS >= list.length ||
|
||
toSIdx < 0 ||
|
||
toSIdx >= list.length ||
|
||
typeof toIdx !== 'number'
|
||
) {
|
||
return prev
|
||
}
|
||
|
||
const fromItems = [...(list[fromS].items || [])]
|
||
if (fromI < 0 || fromI >= fromItems.length) return prev
|
||
|
||
const moved = fromItems[fromI]
|
||
fromItems.splice(fromI, 1)
|
||
|
||
if (fromS === toSIdx) {
|
||
let insertAt = toIdx
|
||
if (fromI < toIdx) insertAt = toIdx - 1
|
||
const bounded = Math.max(0, Math.min(insertAt, fromItems.length))
|
||
fromItems.splice(bounded, 0, moved)
|
||
return list.map((sec, i) => (i === fromS ? { ...sec, items: fromItems } : sec))
|
||
}
|
||
|
||
const toItems = [...(list[toSIdx].items || [])]
|
||
const insertAt = Math.max(0, Math.min(toIdx, toItems.length))
|
||
toItems.splice(insertAt, 0, moved)
|
||
return list.map((sec, i) => {
|
||
if (i === fromS) return { ...sec, items: fromItems }
|
||
if (i === toSIdx) return { ...sec, items: toItems }
|
||
return sec
|
||
})
|
||
})
|
||
clearDragChrome()
|
||
}
|
||
|
||
const applyTextEdit = () => {
|
||
if (!textEdit) return
|
||
const { kind, sIdx, iIdx, draft } = textEdit
|
||
if (kind === 'zwischen-note') {
|
||
updateItem(sIdx, iIdx, 'note_body', draft)
|
||
} else if (kind === 'exercise-notes') {
|
||
updateItem(sIdx, iIdx, 'notes', draft)
|
||
}
|
||
setTextEdit(null)
|
||
}
|
||
|
||
const renderBetweenInsertBand = (sIdx, beforeIx, itemCount) => {
|
||
const posLabel =
|
||
beforeIx === 0
|
||
? 'vor dem ersten Eintrag'
|
||
: beforeIx >= itemCount
|
||
? 'am Ende des Abschnitts'
|
||
: `vor Eintrag ${beforeIx + 1}`
|
||
return (
|
||
<div className="tu-insert-slot">
|
||
<button
|
||
type="button"
|
||
className="tu-insert-slot__btn"
|
||
aria-haspopup="dialog"
|
||
aria-label={`Inhalt einfügen (${posLabel})`}
|
||
title={`Hier einfügen (${posLabel})`}
|
||
onClick={() => setInsertChooser({ sIdx, beforeIx })}
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const list = ensure(sections)
|
||
|
||
const comboPlanningModalDerived = useMemo(() => {
|
||
if (!comboPlanningModal) {
|
||
return { item: null, sIdx: null, iIdx: null }
|
||
}
|
||
const { sIdx, iIdx } = comboPlanningModal
|
||
const cand = list[sIdx]?.items?.[iIdx]
|
||
if (
|
||
cand &&
|
||
String(cand.exercise_kind || '').toLowerCase().trim() === 'combination' &&
|
||
cand.exercise_id
|
||
) {
|
||
return { item: cand, sIdx, iIdx }
|
||
}
|
||
return { item: null, sIdx: null, iIdx: null }
|
||
}, [list, comboPlanningModal])
|
||
|
||
const comboPlanningModalItem = comboPlanningModalDerived.item
|
||
const comboPlanningModalSX = comboPlanningModalDerived.sIdx
|
||
const comboPlanningModalIX = comboPlanningModalDerived.iIdx
|
||
|
||
const comboPlanningResolvedSlots = useMemo(() => {
|
||
if (!comboPlanningModalItem) return []
|
||
const c = comboPlanningModalItem.combination_slots
|
||
if (Array.isArray(c) && c.length > 0) return c
|
||
return Array.isArray(modalComboSlotsFetched) ? modalComboSlotsFetched : []
|
||
}, [comboPlanningModalItem, modalComboSlotsFetched])
|
||
|
||
const comboPlanningSlotsOutline = useMemo(
|
||
() => comboSlotsOutlineForProfileEditor(comboPlanningResolvedSlots),
|
||
[comboPlanningResolvedSlots]
|
||
)
|
||
|
||
const comboPlanningEffectiveProfile = useMemo(() => {
|
||
if (!comboPlanningModalItem) return {}
|
||
return effectiveComboMethodProfile(
|
||
comboPlanningModalItem.catalog_method_profile || {},
|
||
comboPlanningModalItem.planning_method_profile
|
||
)
|
||
}, [comboPlanningModalItem])
|
||
|
||
return (
|
||
<div
|
||
className={
|
||
'training-unit-sections-editor' +
|
||
(wideExerciseGrid ? ' training-unit-sections-editor--wide' : '')
|
||
}
|
||
>
|
||
{(!hideHeading || headingAccessory) ? (
|
||
<div
|
||
className="tu-editor-heading-toolbar"
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
gap: '10px',
|
||
marginBottom: '0.75rem',
|
||
}}
|
||
>
|
||
{!hideHeading ? (
|
||
<h3 style={{ margin: 0, fontSize: '1rem', flex: '1 1 200px', minWidth: 0 }}>
|
||
{heading}
|
||
</h3>
|
||
) : headingAccessory ? (
|
||
<span style={{ flex: '1 1 auto', minWidth: 0 }} />
|
||
) : null}
|
||
{headingAccessory ? (
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: '8px',
|
||
justifyContent: 'flex-end',
|
||
alignItems: 'center',
|
||
}}
|
||
>
|
||
{headingAccessory}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
{list.map((sec, sIdx) => {
|
||
const planMin = sectionPlannedMinutes(sec)
|
||
const itemCount = sec.items?.length ?? 0
|
||
const moduleLegend = planningCompactLegend ? sectionModuleLegendModel(sec.items) : []
|
||
const bandActiveBefore = (bx) =>
|
||
enableSectionDragReorder &&
|
||
dropSectionBand &&
|
||
dropSectionBand.slot === sectionToSlot &&
|
||
dropSectionBand.beforeIdx === bx
|
||
|
||
return (
|
||
<Fragment key={`secFrag-${sIdx}`}>
|
||
{enableSectionDragReorder ? (
|
||
<div
|
||
className={'tu-section-dropband' + (bandActiveBefore(sIdx) ? ' tu-section-dropband--active' : '')}
|
||
title="Abschnitt hier einfügen"
|
||
onDragOver={(e) => {
|
||
if (!enableSectionDragReorder) return
|
||
if (!dtHasType(e, DND_TU_SECTION)) return
|
||
onSectionBandDragOver(e, sIdx)
|
||
}}
|
||
onDragLeave={(e) => {
|
||
if (e.currentTarget.contains(e.relatedTarget)) return
|
||
clearSectionDnD()
|
||
}}
|
||
onDrop={(e) => onSectionBandDrop(e, sIdx)}
|
||
/>
|
||
) : null}
|
||
<div
|
||
className="tu-section-shell"
|
||
style={{
|
||
marginBottom: '1rem',
|
||
padding: '0.75rem',
|
||
background: 'var(--surface2)',
|
||
borderRadius: '10px',
|
||
border: '1px solid var(--border, rgba(0,0,0,0.08))',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: '0.5rem',
|
||
marginBottom: '0.5rem',
|
||
alignItems: 'flex-start',
|
||
}}
|
||
>
|
||
{enableSectionDragReorder ? (
|
||
<span
|
||
className="tu-sec-drag-grip"
|
||
draggable
|
||
onDragStart={(e) => onSectionDragStart(e, sIdx)}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label="Abschnitt ziehen"
|
||
title="Abschnitt ziehen"
|
||
>
|
||
<GripVertical size={16} strokeWidth={2} aria-hidden />
|
||
</span>
|
||
) : null}
|
||
<input
|
||
className="form-input"
|
||
style={{ flex: '2 1 180px', marginBottom: 0 }}
|
||
value={sec.title}
|
||
onChange={(e) => updateSectionField(sIdx, 'title', e.target.value)}
|
||
placeholder="Abschnittstitel (z. B. Aufwärmen)"
|
||
/>
|
||
<div style={{ display: 'flex', gap: '4px', alignSelf: 'center' }}>
|
||
<button
|
||
type="button"
|
||
aria-label="Abschnitt hoch"
|
||
onClick={() => moveSection(sIdx, -1)}
|
||
disabled={sIdx === 0}
|
||
style={{ padding: '4px 10px', opacity: sIdx === 0 ? 0.35 : 1 }}
|
||
>
|
||
▲
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label="Abschnitt runter"
|
||
onClick={() => moveSection(sIdx, 1)}
|
||
disabled={sIdx === list.length - 1}
|
||
style={{
|
||
padding: '4px 10px',
|
||
opacity: sIdx === list.length - 1 ? 0.35 : 1,
|
||
}}
|
||
>
|
||
▼
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() => removeSection(sIdx)}
|
||
>
|
||
Abschnitt entfernen
|
||
</button>
|
||
</div>
|
||
<textarea
|
||
className="form-input"
|
||
rows={2}
|
||
value={sec.guidance_notes}
|
||
onChange={(e) =>
|
||
updateSectionField(sIdx, 'guidance_notes', e.target.value)
|
||
}
|
||
placeholder="Hinweise zum Abschnitt (Material, Zeit, Zielrichtung …)"
|
||
/>
|
||
{planMin > 0 && (
|
||
<p style={{ fontSize: '0.78rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
||
Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen)
|
||
</p>
|
||
)}
|
||
|
||
{betweenInsertMenus ? renderBetweenInsertBand(sIdx, 0, itemCount) : null}
|
||
|
||
{(sec.items || []).map((it, iIdx) => {
|
||
const dropHere =
|
||
enableItemDragReorder &&
|
||
dropTargetPos?.sIdx === sIdx &&
|
||
dropTargetPos?.iIdx === iIdx
|
||
const dragHere =
|
||
enableItemDragReorder &&
|
||
draggingPos?.sIdx === sIdx &&
|
||
draggingPos?.iIdx === iIdx
|
||
const rowCommon =
|
||
'tu-item-row' +
|
||
(dropHere ? ' tu-item-row--drop-target' : '') +
|
||
(dragHere ? ' tu-item-row--dragging' : '')
|
||
|
||
const dndRowProps = enableItemDragReorder
|
||
? {
|
||
onDragOverCapture: (ev) => onItemDragOverRow(ev, sIdx, iIdx),
|
||
onDrop: (ev) => onItemDropRow(ev, sIdx, iIdx),
|
||
}
|
||
: {}
|
||
|
||
const prevIt = iIdx > 0 ? sec.items[iIdx - 1] : null
|
||
const curMn = normalizedPlanningModuleChainId(it.source_training_module_id)
|
||
const showModuleBand =
|
||
curMn != null && curMn !== normalizedPlanningModuleChainId(prevIt?.source_training_module_id)
|
||
const modBandTitle =
|
||
(it.source_module_title || '').trim() ||
|
||
(curMn != null ? `Modul #${curMn}` : '')
|
||
|
||
const modOutline =
|
||
!planningCompactLegend &&
|
||
showModuleBand &&
|
||
curMn != null
|
||
? gatherPlanningModuleOutline(sec.items, iIdx, curMn)
|
||
: null
|
||
const fromModClass =
|
||
curMn != null
|
||
? planningCompactLegend
|
||
? ' tu-item-row--from-module-soft'
|
||
: ' tu-item-row--from-module'
|
||
: ''
|
||
const modBorderVarStyle =
|
||
planningCompactLegend && curMn != null
|
||
? { '--tu-mod-border': planningModulePalette(curMn).border }
|
||
: undefined
|
||
|
||
if (it.item_type === 'note') {
|
||
const isSepLine = (it.note_body || '').trim() === SECTION_INSERT_SEPARATOR_BODY
|
||
const notePv = truncatePreview(it.note_body || '', 260)
|
||
const noteHasText = Boolean((it.note_body || '').trim()) && !isSepLine
|
||
return (
|
||
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
|
||
{!planningCompactLegend &&
|
||
renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
|
||
<div
|
||
className={
|
||
`${rowCommon} tu-item-row--note` +
|
||
(isSepLine ? ' tu-item-row--separator-note' : '') +
|
||
fromModClass
|
||
}
|
||
{...dndRowProps}
|
||
style={modBorderVarStyle}
|
||
>
|
||
{enableItemDragReorder ? (
|
||
<span
|
||
className="tu-row-grip"
|
||
draggable
|
||
onDragStart={(e) => onItemDragStart(e, sIdx, iIdx)}
|
||
onDragEnd={clearDragChrome}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label="Eintrag ziehen"
|
||
>
|
||
<GripVertical size={15} strokeWidth={2} aria-hidden />
|
||
</span>
|
||
) : null}
|
||
<div className="tu-item-row__nudge">
|
||
<button
|
||
type="button"
|
||
aria-label="Eintrag nach oben"
|
||
onClick={() => moveItem(sIdx, iIdx, -1)}
|
||
disabled={iIdx === 0}
|
||
>
|
||
▲
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label="Eintrag nach unten"
|
||
onClick={() => moveItem(sIdx, iIdx, 1)}
|
||
disabled={iIdx === sec.items.length - 1}
|
||
>
|
||
▼
|
||
</button>
|
||
</div>
|
||
<div className="tu-item-row__body tu-item-row__body--note">
|
||
{!isSepLine && planningCompactLegend && curMn ? (
|
||
<PlanningModuleRowTag moduleId={curMn} title={modBandTitle} />
|
||
) : null}
|
||
<span className="tu-item-row__meta-label">
|
||
{isSepLine ? 'Trennung' : 'Zwischen-Anmerkung'}
|
||
</span>
|
||
{isSepLine ? (
|
||
<div
|
||
className="tu-item-row__separator-line"
|
||
role="separator"
|
||
aria-label="Trennlinie im Ablauf"
|
||
/>
|
||
) : (
|
||
<p
|
||
className={`tu-item-row__preview tu-item-row__preview--clamp${noteHasText ? '' : ' tu-item-row__preview--empty'}`}
|
||
title={noteHasText ? (it.note_body || '').trim() : undefined}
|
||
>
|
||
{noteHasText ? notePv : '—'}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="tu-icon-btn"
|
||
title={isSepLine ? 'Trennung bearbeiten' : 'Zwischen-Anmerkung bearbeiten'}
|
||
aria-label={isSepLine ? 'Trennung bearbeiten' : 'Zwischen-Anmerkung bearbeiten'}
|
||
onClick={() =>
|
||
setTextEdit({
|
||
kind: 'zwischen-note',
|
||
sIdx,
|
||
iIdx,
|
||
draft: it.note_body || '',
|
||
})
|
||
}
|
||
>
|
||
<Pencil size={15} strokeWidth={2} aria-hidden />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="tu-item-row__remove"
|
||
title="Entfernen"
|
||
aria-label={isSepLine ? 'Trennung entfernen' : 'Zwischen-Anmerkung entfernen'}
|
||
onClick={() => removeItem(sIdx, iIdx)}
|
||
>
|
||
✗
|
||
</button>
|
||
</div>
|
||
{betweenInsertMenus ? renderBetweenInsertBand(sIdx, iIdx + 1, itemCount) : null}
|
||
</Fragment>
|
||
)
|
||
}
|
||
|
||
const variantOpts = Array.isArray(it.variants) ? it.variants : []
|
||
const exTitle =
|
||
it.exercise_title || (it.exercise_id ? `Übung #${it.exercise_id}` : '')
|
||
const isCombination =
|
||
String(it.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
|
||
const annotPrev = truncatePreview(it.notes || '', 220)
|
||
const annotHasText = Boolean((it.notes || '').trim())
|
||
const hasVariants = !isCombination && variantOpts.length > 0 && it.exercise_id
|
||
const variantIdPeek =
|
||
it.exercise_variant_id === '' || it.exercise_variant_id == null
|
||
? undefined
|
||
: Number(it.exercise_variant_id)
|
||
|
||
const stripArchRaw =
|
||
isCombination && it.exercise_id ? String(it.catalog_method_archetype || '').trim() : ''
|
||
const stripArchLbl =
|
||
stripArchRaw && isCombination ? combinationArchetypeLabel(stripArchRaw) : null
|
||
const stripBullets =
|
||
isCombination && it.exercise_id ? comboPlanningStripBulletTexts(it) : []
|
||
const stripMpEff =
|
||
isCombination && it.exercise_id
|
||
? effectiveComboMethodProfile(
|
||
it.catalog_method_profile || {},
|
||
it.planning_method_profile,
|
||
)
|
||
: null
|
||
const stripGlobalRough =
|
||
isCombination && it.exercise_id && stripMpEff
|
||
? comboRoughGlobalTimingHint(stripMpEff, stripArchRaw)
|
||
: null
|
||
|
||
return (
|
||
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
|
||
{!planningCompactLegend &&
|
||
renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
|
||
<div
|
||
className={`${rowCommon} tu-item-row--exercise${fromModClass}`}
|
||
{...dndRowProps}
|
||
style={modBorderVarStyle}
|
||
>
|
||
<div className="tu-item-row__mainline">
|
||
{enableItemDragReorder ? (
|
||
<span
|
||
className="tu-row-grip"
|
||
draggable
|
||
onDragStart={(e) => onItemDragStart(e, sIdx, iIdx)}
|
||
onDragEnd={clearDragChrome}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label="Eintrag ziehen"
|
||
>
|
||
<GripVertical size={15} strokeWidth={2} aria-hidden />
|
||
</span>
|
||
) : null}
|
||
<div className="tu-item-row__nudge">
|
||
<button
|
||
type="button"
|
||
aria-label="Eintrag nach oben"
|
||
onClick={() => moveItem(sIdx, iIdx, -1)}
|
||
disabled={iIdx === 0}
|
||
>
|
||
▲
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label="Eintrag nach unten"
|
||
onClick={() => moveItem(sIdx, iIdx, 1)}
|
||
disabled={iIdx === sec.items.length - 1}
|
||
>
|
||
▼
|
||
</button>
|
||
</div>
|
||
<div className="tu-item-row__body tu-item-row__body--exercise">
|
||
<div className="tu-ex-title-line">
|
||
{exTitle ? (
|
||
<strong className="tu-ex-title">{exTitle}</strong>
|
||
) : (
|
||
<span className="tu-ex-title-placeholder">Keine Übung gewählt</span>
|
||
)}
|
||
{isCombination ? (
|
||
<span
|
||
className="exercise-tag"
|
||
style={{
|
||
marginLeft: 8,
|
||
fontSize: '11px',
|
||
alignSelf: 'center',
|
||
background: 'var(--accent-soft)',
|
||
color: 'var(--accent-dark)',
|
||
}}
|
||
>
|
||
Kombination
|
||
</span>
|
||
) : null}
|
||
{planningCompactLegend && curMn ? (
|
||
<PlanningModuleRowTag moduleId={curMn} title={modBandTitle} />
|
||
) : null}
|
||
<span className="tu-ex-inline-actions">
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() =>
|
||
onRequestExercisePick?.({
|
||
sectionIndex: sIdx,
|
||
itemIndex: iIdx,
|
||
})
|
||
}
|
||
>
|
||
{exTitle ? 'Wechseln' : 'Übung suchen…'}
|
||
</button>
|
||
{it.exercise_id && onPeekExercise ? (
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() =>
|
||
onPeekExercise(
|
||
Number(it.exercise_id),
|
||
variantIdPeek,
|
||
isCombination
|
||
? {
|
||
catalog_method_profile: it.catalog_method_profile,
|
||
planning_method_profile: it.planning_method_profile,
|
||
}
|
||
: undefined,
|
||
)
|
||
}
|
||
>
|
||
Vorschau
|
||
</button>
|
||
) : null}
|
||
</span>
|
||
</div>
|
||
<div className="tu-ex-meta-line">
|
||
{hasVariants ? (
|
||
<select
|
||
className={`form-input tu-ex-variant-select${
|
||
wideExerciseGrid ? ' tu-ex-variant-select--wide' : ''
|
||
}`}
|
||
value={
|
||
it.exercise_variant_id === '' ||
|
||
it.exercise_variant_id == null
|
||
? ''
|
||
: String(it.exercise_variant_id)
|
||
}
|
||
onChange={(e) => {
|
||
const raw = e.target.value
|
||
updateItem(
|
||
sIdx,
|
||
iIdx,
|
||
'exercise_variant_id',
|
||
raw === '' ? '' : parseInt(raw, 10)
|
||
)
|
||
}}
|
||
title="Übungsvariante"
|
||
>
|
||
<option value="">Stammübung</option>
|
||
{variantOpts.map((v) => (
|
||
<option key={v.id} value={v.id}>
|
||
{v.variant_name || `Variante #${v.id}`}
|
||
</option>
|
||
))}
|
||
</select>
|
||
) : null}
|
||
<div className="tu-ex-annot">
|
||
<span
|
||
className={`tu-item-row__preview tu-ex-annot__text${annotHasText ? '' : ' tu-item-row__preview--empty'}`}
|
||
title={annotHasText ? (it.notes || '').trim() : undefined}
|
||
>
|
||
{annotHasText ? annotPrev : '—'}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="tu-icon-btn"
|
||
title="Anmerkung zur Übung"
|
||
aria-label="Anmerkung zur Übung bearbeiten"
|
||
onClick={() =>
|
||
setTextEdit({
|
||
kind: 'exercise-notes',
|
||
sIdx,
|
||
iIdx,
|
||
draft: it.notes || '',
|
||
})
|
||
}
|
||
>
|
||
<Pencil size={15} strokeWidth={2} aria-hidden />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="tu-item-row__side">
|
||
<input
|
||
type="number"
|
||
className="form-input tu-ex-duration"
|
||
min={1}
|
||
value={it.planned_duration_min}
|
||
onChange={(e) =>
|
||
updateItem(sIdx, iIdx, 'planned_duration_min', e.target.value)
|
||
}
|
||
placeholder="Min"
|
||
title="Geplante Dauer (Minuten)"
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="tu-item-row__remove"
|
||
title="Übung entfernen"
|
||
aria-label="Übung entfernen"
|
||
onClick={() => removeItem(sIdx, iIdx)}
|
||
>
|
||
✗
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{isCombination && it.exercise_id ? (
|
||
<div
|
||
className="tu-combo-planning-strip"
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
alignItems: 'flex-start',
|
||
gap: '10px',
|
||
padding: '8px 12px 10px',
|
||
paddingLeft: enableItemDragReorder ? 44 : 12,
|
||
borderTop: '1px solid var(--border)',
|
||
background: 'var(--surface2)',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
flex: '1 1 200px',
|
||
minWidth: 0,
|
||
fontSize: '0.78rem',
|
||
color: 'var(--text2)',
|
||
lineHeight: 1.45,
|
||
}}
|
||
title="Stationen und grobe Zeiten aus Katalog bzw. Planungs-Anpassung — Details unter „Ablauf bearbeiten“ oder „Vorschau“"
|
||
>
|
||
<div style={{ marginBottom: stripBullets.length || stripGlobalRough ? 6 : 0 }}>
|
||
<strong style={{ color: 'var(--text1)', fontWeight: 600 }}>Archetyp: </strong>
|
||
<span style={{ color: 'var(--text1)' }}>
|
||
{stripArchLbl || stripArchRaw || '—'}
|
||
{stripArchRaw && stripArchLbl && stripArchLbl !== stripArchRaw ? (
|
||
<span style={{ marginLeft: 6, fontWeight: 400, color: 'var(--text3)', fontSize: '0.72rem' }}>
|
||
({stripArchRaw})
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
<span style={{ marginLeft: 10, fontWeight: 500 }}>{compactComboPlanningCaption(it)}</span>
|
||
</div>
|
||
{stripGlobalRough ? (
|
||
<div
|
||
style={{
|
||
marginBottom: stripBullets.length ? 6 : 0,
|
||
fontSize: '0.74rem',
|
||
color: 'var(--text3)',
|
||
}}
|
||
>
|
||
<strong style={{ color: 'var(--text2)', fontWeight: 600 }}>Block: </strong>
|
||
{stripGlobalRough}
|
||
</div>
|
||
) : null}
|
||
{stripBullets.length > 0 ? (
|
||
<ul
|
||
style={{
|
||
margin: 0,
|
||
paddingLeft: '1.05rem',
|
||
fontSize: '0.74rem',
|
||
color: 'var(--text2)',
|
||
}}
|
||
>
|
||
{stripBullets.map((line, bi) => (
|
||
<li key={`combo-strip-${sIdx}-${iIdx}-${bi}`} style={{ marginBottom: 2 }}>
|
||
{line}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', fontStyle: 'italic' }}>
|
||
Stationen laden oder noch keine Kombi-Stationen im Katalog …
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
style={{ flexShrink: 0 }}
|
||
aria-haspopup="dialog"
|
||
aria-label="Ablaufprofil Kombination für diese Planung bearbeiten"
|
||
onClick={() => setComboPlanningModal({ sIdx, iIdx })}
|
||
>
|
||
Ablauf bearbeiten…
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
|
||
{showExecutionExtras ? (
|
||
<div className="tu-ex-debrief">
|
||
<div className="tu-ex-debrief__grow">
|
||
<span className="tu-item-row__meta-label">Abweichungen beim Durchführen</span>
|
||
<textarea
|
||
className="form-input tu-ex-debrief__textarea"
|
||
rows={3}
|
||
value={it.modifications || ''}
|
||
onChange={(e) =>
|
||
updateItem(sIdx, iIdx, 'modifications', e.target.value)
|
||
}
|
||
placeholder="Was lief anders? Anpassungen für spätere Planung…"
|
||
/>
|
||
</div>
|
||
<div className="tu-ex-debrief__ist">
|
||
<span className="tu-item-row__meta-label">Ist (Min)</span>
|
||
<input
|
||
type="number"
|
||
className="form-input tu-ex-duration"
|
||
min={1}
|
||
value={it.actual_duration_min}
|
||
onChange={(e) =>
|
||
updateItem(sIdx, iIdx, 'actual_duration_min', e.target.value)
|
||
}
|
||
placeholder="IST"
|
||
title="Tatsächliche Dauer (Minuten); dieselbe Spaltenbreite wie „Min“ (Plan) oben"
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
{betweenInsertMenus ? renderBetweenInsertBand(sIdx, iIdx + 1, itemCount) : null}
|
||
</Fragment>
|
||
)
|
||
})}
|
||
|
||
{enableItemDragReorder ? (
|
||
<div
|
||
className={`tu-item-append-drop${
|
||
dropTargetPos?.sIdx === sIdx && dropTargetPos?.iIdx === itemCount
|
||
? ' tu-item-append-drop--active'
|
||
: ''
|
||
}`}
|
||
title="Hierhin ziehen, um nach unten einzufügen"
|
||
onDragOverCapture={(e) => onItemDragOverRow(e, sIdx, itemCount)}
|
||
onDrop={(e) => onItemDropRow(e, sIdx, itemCount)}
|
||
/>
|
||
) : null}
|
||
|
||
<div style={{ marginTop: '0.65rem' }}>
|
||
{betweenInsertMenus ? (
|
||
<p style={{ margin: 0, fontSize: '0.8rem', color: 'var(--text3)', lineHeight: 1.45, maxWidth: '42rem' }}>
|
||
Über die +-Zeilen zwischen den Einträgen fügst du an der gewünschten Stelle Inhalte ein. Reihenfolge
|
||
weiter per Ziehen oder den Pfeiltasten ändern.
|
||
</p>
|
||
) : (
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() => onRequestExercisePick?.({ sectionIndex: sIdx })}
|
||
>
|
||
+ Übung
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() => addItem(sIdx, 'note')}
|
||
>
|
||
+ Anmerkung
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{moduleLegend.length ? (
|
||
<div
|
||
className="tu-section-mod-legend"
|
||
aria-label="Liste der eingebundenen Trainingsmodule in diesem Abschnitt"
|
||
>
|
||
<div className="tu-section-mod-legend__caption">Übernommene Module im Abschnitt</div>
|
||
<ul className="tu-section-mod-legend__list">
|
||
{moduleLegend.map((e) => {
|
||
const pal = planningModulePalette(e.id)
|
||
return (
|
||
<li key={`mod-leg-${sIdx}-${e.id}`} className="tu-section-mod-legend__item">
|
||
<span
|
||
className="tu-section-mod-legend__swatch"
|
||
style={{ background: pal.border }}
|
||
title={`Farbe wie an den Zeilen (Modul #${e.id})`}
|
||
aria-hidden
|
||
/>
|
||
<span className="tu-section-mod-legend__text">
|
||
<span className="tu-section-mod-legend__title">
|
||
{(e.title || '').trim() || `Modul #${e.id}`}
|
||
</span>
|
||
<span className="tu-section-mod-legend__meta">
|
||
ID {e.id} · {e.exercises}{' '}
|
||
{e.exercises === 1 ? 'Übung' : 'Übungen'}
|
||
{e.notes > 0 ? (
|
||
<>
|
||
{' '}
|
||
· {e.notes} {e.notes === 1 ? 'Zwischen-Hinweis' : 'Zwischen-Hinweise'}
|
||
</>
|
||
) : null}
|
||
</span>
|
||
</span>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</Fragment>
|
||
)
|
||
})}
|
||
|
||
{enableSectionDragReorder ? (
|
||
<div
|
||
className={
|
||
'tu-section-dropband tu-section-dropband--end' +
|
||
(dropSectionBand &&
|
||
dropSectionBand.slot === sectionToSlot &&
|
||
dropSectionBand.beforeIdx === list.length
|
||
? ' tu-section-dropband--active'
|
||
: '')
|
||
}
|
||
title="Abschnitt am Ende einfügen"
|
||
onDragOver={(e) => {
|
||
if (!dtHasType(e, DND_TU_SECTION)) return
|
||
onSectionBandDragOver(e, list.length)
|
||
}}
|
||
onDragLeave={(e) => {
|
||
if (e.currentTarget.contains(e.relatedTarget)) return
|
||
clearSectionDnD()
|
||
}}
|
||
onDrop={(e) => onSectionBandDrop(e, list.length)}
|
||
/>
|
||
) : null}
|
||
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={addSection}
|
||
>
|
||
+ Abschnitt hinzufügen
|
||
</button>
|
||
|
||
{insertChooser ? (
|
||
<div
|
||
className="tu-textedit-backdrop"
|
||
role="presentation"
|
||
onMouseDown={(e) => {
|
||
if (e.target === e.currentTarget) closeInsertChooser()
|
||
}}
|
||
>
|
||
<div
|
||
className="tu-textedit-panel"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="tu-insert-chooser-title"
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
>
|
||
<h4 id="tu-insert-chooser-title" className="tu-textedit-title">
|
||
An dieser Stelle einfügen
|
||
</h4>
|
||
<p style={{ margin: '0 0 0.75rem', fontSize: '0.86rem', color: 'var(--text2)', lineHeight: 1.45 }}>
|
||
Die neue Zeile erscheint genau hier; Reihenfolge kannst du wie gewohnt per Ziehen oder Pfeilen
|
||
ändern.
|
||
</p>
|
||
<div className="tu-insert-chooser-actions">
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary tu-insert-chooser-actions__full"
|
||
onClick={() => {
|
||
const { sIdx, beforeIx } = insertChooser
|
||
closeInsertChooser()
|
||
onRequestExercisePick?.({
|
||
sectionIndex: sIdx,
|
||
insertBeforeIndex: beforeIx,
|
||
})
|
||
}}
|
||
>
|
||
Übung auswählen …
|
||
</button>
|
||
{onRequestTrainingModulePick ? (
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary tu-insert-chooser-actions__full"
|
||
onClick={() => {
|
||
const ctx = { ...insertChooser }
|
||
closeInsertChooser()
|
||
onRequestTrainingModulePick({
|
||
sectionIndex: ctx.sIdx,
|
||
insertBeforeIndex: ctx.beforeIx,
|
||
})
|
||
}}
|
||
>
|
||
Trainingsmodul …
|
||
</button>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary tu-insert-chooser-actions__full"
|
||
onClick={() => {
|
||
const { sIdx, beforeIx } = insertChooser
|
||
insertItemAt(sIdx, beforeIx, noteRow())
|
||
closeInsertChooser()
|
||
}}
|
||
>
|
||
Zwischen-Anmerkung
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary tu-insert-chooser-actions__full"
|
||
onClick={() => {
|
||
const { sIdx, beforeIx } = insertChooser
|
||
const r = noteRow()
|
||
r.note_body = SECTION_INSERT_SEPARATOR_BODY
|
||
insertItemAt(sIdx, beforeIx, r)
|
||
closeInsertChooser()
|
||
}}
|
||
>
|
||
Trennlinie
|
||
</button>
|
||
<button type="button" className="btn btn-secondary tu-insert-chooser-actions__full" onClick={closeInsertChooser}>
|
||
Abbrechen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{comboPlanningModalItem != null &&
|
||
comboPlanningModalSX != null &&
|
||
comboPlanningModalIX != null ? (
|
||
<div
|
||
className="tu-textedit-backdrop"
|
||
role="presentation"
|
||
onMouseDown={(e) => {
|
||
if (e.target === e.currentTarget) setComboPlanningModal(null)
|
||
}}
|
||
>
|
||
<div
|
||
className="tu-textedit-panel tu-textedit-panel--combo-planning"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="tu-combo-planning-title"
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
style={{
|
||
maxWidth: 'min(920px, 96vw)',
|
||
maxHeight: 'min(800px, 88vh)',
|
||
overflow: 'auto',
|
||
}}
|
||
>
|
||
<h4 id="tu-combo-planning-title" className="tu-textedit-title">
|
||
Ablaufprofil dieser Kombination für diese Planung
|
||
</h4>
|
||
<p
|
||
style={{
|
||
margin: '0 0 0.85rem',
|
||
fontSize: '0.82rem',
|
||
color: 'var(--text2)',
|
||
lineHeight: 1.45,
|
||
}}
|
||
>
|
||
<strong style={{ fontWeight: 600, color: 'var(--text1)' }}>
|
||
{(comboPlanningModalItem.exercise_title || '').trim() ||
|
||
`Kombination #${comboPlanningModalItem.exercise_id}`}
|
||
</strong>
|
||
<span style={{ marginLeft: 8 }}>
|
||
({compactComboPlanningCaption(comboPlanningModalItem)})
|
||
</span>
|
||
</p>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() =>
|
||
updateItem(comboPlanningModalSX, comboPlanningModalIX, 'planning_method_profile', null)
|
||
}
|
||
>
|
||
Planung wie Katalog
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
title="Bearbeitbare Kopie der Katalog-Vorgaben setzen"
|
||
onClick={() =>
|
||
updateItem(comboPlanningModalSX, comboPlanningModalIX, 'planning_method_profile', {
|
||
...(comboPlanningModalItem.catalog_method_profile || {}),
|
||
})
|
||
}
|
||
>
|
||
Aus Katalog kopieren …
|
||
</button>
|
||
</div>
|
||
<p
|
||
style={{
|
||
margin: '0 0 12px',
|
||
fontSize: '0.8rem',
|
||
color: 'var(--text2)',
|
||
lineHeight: 1.45,
|
||
}}
|
||
>
|
||
Stationen und Einzelübungen entsprechen der Kombination im Katalog. Einzelübungen hier auszutauschen ist
|
||
derzeit nicht vorgesehen (würde die Katalog-Übung ändern). Die Bereiche unten überschreiben nur diesen
|
||
Termin, sofern du von den Katalogvorgaben abweichst.
|
||
</p>
|
||
{comboPlanningResolvedSlots.length > 0 ? (
|
||
<div style={{ marginBottom: 16 }}>
|
||
<CombinationCoachSlots
|
||
combinationSlots={comboPlanningResolvedSlots}
|
||
methodArchetype={(comboPlanningModalItem.catalog_method_archetype || '').trim()}
|
||
methodProfile={comboPlanningEffectiveProfile}
|
||
compactPlanningView
|
||
omitGlobalKeyValueBlock
|
||
/>
|
||
</div>
|
||
) : (
|
||
<p style={{ margin: '0 0 14px', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
||
Stationen werden geladen … oder die Kombination hat im Katalog keine Stationsliste.
|
||
</p>
|
||
)}
|
||
<h5
|
||
style={{
|
||
margin: '0 0 10px',
|
||
fontSize: '0.95rem',
|
||
fontWeight: 700,
|
||
color: 'var(--text1)',
|
||
}}
|
||
>
|
||
Zeiten und Steuerung für diesen Termin
|
||
</h5>
|
||
<CombinationMethodProfileEditor
|
||
plannerMode
|
||
methodArchetype={(comboPlanningModalItem.catalog_method_archetype || '').trim()}
|
||
methodProfileJson={comboPlanningProfileJsonForEditor(
|
||
comboPlanningModalItem.catalog_method_profile || {},
|
||
comboPlanningModalItem.planning_method_profile
|
||
)}
|
||
onChangeMethodProfileJson={(json) => {
|
||
try {
|
||
const obj = JSON.parse(json || '{}')
|
||
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
||
updateItem(comboPlanningModalSX, comboPlanningModalIX, 'planning_method_profile', obj)
|
||
}
|
||
} catch {
|
||
/* Ungültiges JSON — Hinweis im Editor */
|
||
}
|
||
}}
|
||
comboSlotsOutline={comboPlanningSlotsOutline}
|
||
/>
|
||
<div className="tu-textedit-actions" style={{ marginTop: '0.95rem', paddingTop: '0.25rem' }}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
onClick={() => setComboPlanningModal(null)}
|
||
>
|
||
Schließen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{textEdit ? (
|
||
<div
|
||
className="tu-textedit-backdrop"
|
||
role="presentation"
|
||
onMouseDown={(e) => {
|
||
if (e.target === e.currentTarget) setTextEdit(null)
|
||
}}
|
||
>
|
||
<div
|
||
className="tu-textedit-panel"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="tu-textedit-title"
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
>
|
||
<h4 id="tu-textedit-title" className="tu-textedit-title">
|
||
{textEdit.kind === 'zwischen-note'
|
||
? 'Zwischen-Anmerkung'
|
||
: 'Anmerkung zur Übung'}
|
||
</h4>
|
||
<textarea
|
||
className="form-input tu-textedit-textarea"
|
||
rows={5}
|
||
value={textEdit.draft}
|
||
onChange={(e) =>
|
||
setTextEdit((prev) => (prev ? { ...prev, draft: e.target.value } : prev))
|
||
}
|
||
placeholder={
|
||
textEdit.kind === 'zwischen-note'
|
||
? 'Hinweise zwischen Übungen …'
|
||
: 'Kurze Anmerkung zur Übung'
|
||
}
|
||
/>
|
||
<div className="tu-textedit-actions">
|
||
<button type="button" className="btn btn-primary" onClick={applyTextEdit}>
|
||
Übernehmen
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
onClick={() => setTextEdit(null)}
|
||
>
|
||
Abbrechen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|