import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react' import { GripVertical, Pencil } from 'lucide-react' import CombinationMethodProfileEditor from './CombinationMethodProfileEditor' import CombinationPlanBracket from './CombinationPlanBracket' import { comboPlanningProfileJsonForEditor, effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile' import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes' import { cloneJsonSerializablePlanningProfile, comboSlotsOutlineForProfileEditor, defaultSection, defaultPlanLocWholeGroup, defaultPlanLocParallel, maxPhaseOrderIndexFromSections, buildPlanTargetOptions, planLocKey, exerciseRow, noteRow, sectionPlannedMinutes, } from '../utils/trainingUnitSectionsForm' import api from '../utils/api' import { isCompactTagLegendMode } from '../config/planningModuleUx' import { useAuth } from '../context/AuthContext' function stripPlanLocFromSection(s) { if (!s || typeof s !== 'object') return s const { planLoc: _ignored, ...rest } = s return rest } function planSelectOptionsForSection(sections, sIdx, baseOpts) { const sec = sections[sIdx] const k = planLocKey(sec?.planLoc) if (k && !baseOpts.some((o) => o.key === k)) { const pl = sec.planLoc const label = pl.phaseKind === 'parallel' ? `Parallel · Phase ${pl.phaseOrderIndex ?? 0} · Stream ${pl.parallelStreamOrderIndex ?? 0}` : `Ganzgruppe · Phase ${pl.phaseOrderIndex ?? 0}` return [...baseOpts, { key: k, label, template: { ...pl } }].sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }) ) } return baseOpts } 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: nur Planungs‑Override vs. Katalog (Archetyp steht bereits links). */ function compactComboPlanningCaption(it) { const overridden = it.planning_method_profile != null && typeof it.planning_method_profile === 'object' && !Array.isArray(it.planning_method_profile) return overridden ? 'Planung angepasst' : 'wie Katalog' } /** 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, /** Trainingsplanung: Phasen/Streams anlegen und Abschnitte zuordnen */ enableParallelPhaseControls = false, }) { 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) => { const base = defaultSection(`Abschnitt ${prev.length + 1}`) const last = prev[prev.length - 1] const next = last?.planLoc ? { ...base, planLoc: { ...last.planLoc } } : base return [...prev, next] }) } const addWholeGroupPhase = () => { patch((prev) => { const nextPo = maxPhaseOrderIndexFromSections(prev) + 1 const pl = defaultPlanLocWholeGroup(nextPo) const base = defaultSection(`Abschnitt ${prev.length + 1}`) return [...prev, { ...base, planLoc: pl }] }) } const addParallelPhase = () => { patch((prev) => { const nextPo = maxPhaseOrderIndexFromSections(prev) + 1 const pl = defaultPlanLocParallel(nextPo, 0) const base = defaultSection(`Abschnitt ${prev.length + 1}`) return [...prev, { ...base, planLoc: pl }] }) } const addStreamToLastParallelPhase = () => { patch((prev) => { const par = (prev || []).filter((s) => s?.planLoc?.phaseKind === 'parallel') if (!par.length) return prev const maxP = Math.max(...par.map((s) => s.planLoc.phaseOrderIndex ?? 0)) const inPhase = par.filter((s) => (s.planLoc.phaseOrderIndex ?? 0) === maxP) const maxS = Math.max(...inPhase.map((s) => s.planLoc.parallelStreamOrderIndex ?? 0)) const newSo = maxS + 1 const tmpl = { ...inPhase[0].planLoc, parallelStreamOrderIndex: newSo, streamTitle: null, streamNotes: null, streamAssignedTrainerProfileIds: null, } const base = defaultSection(`Abschnitt ${prev.length + 1}`) return [...prev, { ...base, planLoc: tmpl }] }) } const applySectionPlanTarget = (sIdx, rawKey) => { patch((prev) => { if (!rawKey) { return prev.map((s, i) => (i === sIdx ? stripPlanLocFromSection(s) : s)) } const opts = planSelectOptionsForSection(prev, sIdx, buildPlanTargetOptions(prev)) const hit = opts.find((o) => o.key === rawKey) if (!hit) return prev const tpl = { ...hit.template } return prev.map((s, i) => (i === sIdx ? { ...s, planLoc: tpl } : s)) }) } 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 hasParallelPhase = useMemo( () => list.some((s) => s?.planLoc?.phaseKind === 'parallel'), [list] ) 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} {enableParallelPhaseControls ? (
Breakout: Phasen und parallele Streams

Legt fest, ob Abschnitte zur ganzen Gruppe oder zu parallelen Gruppen gehören. „Abschnitt hinzufügen“ unten übernimmt die Zuordnung des letzten Abschnitts. Speichern erzeugt bei Bedarf automatisch den{' '} phases-Payload fürs Backend.

) : 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)" />
{enableParallelPhaseControls && sec.planLoc ? (

{sec.planLoc.phaseKind === 'whole_group' ? `Ganzgruppen-Phase ${sec.planLoc.phaseOrderIndex ?? 0}` : `Parallel · Phase ${sec.planLoc.phaseOrderIndex ?? 0} · Stream ${sec.planLoc.parallelStreamOrderIndex ?? 0}`}

) : null}