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 (
Aus Modul: {lbl}
)
}
/** 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 (
Aus Modul
{modBandTitle}
{modOutline.exercises.length === 0 && modOutline.notes === 0 ? (
Ohne strukturierten Inhalt angezeigt.
) : (
{modOutline.exercises.slice(0, MODULE_OUTLINE_PREVIEW_MAX).map((tx, ox) => (
- {tx}
))}
)}
{modOutline.exercises.length > MODULE_OUTLINE_PREVIEW_MAX ? (
… und noch {modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX}{' '}
{modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX === 1 ? 'Übung' : 'Übungen'}
) : null}
{modOutline.notes > 0 ? (
sowie {modOutline.notes}{' '}
{modOutline.notes === 1 ? 'Zwischen-Hinweis' : 'Zwischen-Hinweise'}
) : null}
)
}
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 (
)
}
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 (
{(!hideHeading || headingAccessory) ? (
{!hideHeading ? (
{heading}
) : headingAccessory ? (
) : null}
{headingAccessory ? (
{headingAccessory}
) : null}
) : 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 (
{enableSectionDragReorder ? (
{
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}
{enableSectionDragReorder ? (
onSectionDragStart(e, sIdx)}
role="button"
tabIndex={0}
aria-label="Abschnitt ziehen"
title="Abschnitt ziehen"
>
) : null}
updateSectionField(sIdx, 'title', e.target.value)}
placeholder="Abschnittstitel (z. B. Aufwärmen)"
/>