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) => (
  1. {tx}
  2. ))}
)} {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)" />