443 lines
16 KiB
React
443 lines
16 KiB
React
|
|
import React, { useCallback } from 'react'
|
||
|
|
import {
|
||
|
|
defaultSection,
|
||
|
|
exerciseRow,
|
||
|
|
noteRow,
|
||
|
|
sectionPlannedMinutes,
|
||
|
|
} from '../utils/trainingUnitSectionsForm'
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {(updater: (prev: Array) => Array) => void} props.onSectionsChange — wie React setState
|
||
|
|
*/
|
||
|
|
export default function TrainingUnitSectionsEditor({
|
||
|
|
sections,
|
||
|
|
onSectionsChange,
|
||
|
|
onRequestExercisePick,
|
||
|
|
onPeekExercise,
|
||
|
|
showExecutionExtras = false,
|
||
|
|
heading = 'Abschnitte & Übungen',
|
||
|
|
hideHeading = false,
|
||
|
|
}) {
|
||
|
|
const ensure = (prev) =>
|
||
|
|
prev && prev.length ? prev : [defaultSection()]
|
||
|
|
|
||
|
|
const patch = useCallback(
|
||
|
|
(updater) => {
|
||
|
|
onSectionsChange((prev) => updater(ensure(prev)))
|
||
|
|
},
|
||
|
|
[onSectionsChange]
|
||
|
|
)
|
||
|
|
|
||
|
|
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 addItem = (sIdx, kind) => {
|
||
|
|
patch((prev) =>
|
||
|
|
prev.map((s, i) =>
|
||
|
|
i !== sIdx
|
||
|
|
? s
|
||
|
|
: {
|
||
|
|
...s,
|
||
|
|
items: [...(s.items || []), kind === 'note' ? noteRow() : exerciseRow()],
|
||
|
|
}
|
||
|
|
)
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
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 list = ensure(sections)
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="training-unit-sections-editor">
|
||
|
|
{!hideHeading ? (
|
||
|
|
<h3 style={{ margin: '0 0 0.75rem', fontSize: '1rem' }}>{heading}</h3>
|
||
|
|
) : null}
|
||
|
|
{list.map((sec, sIdx) => {
|
||
|
|
const planMin = sectionPlannedMinutes(sec)
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
key={`sec-${sIdx}`}
|
||
|
|
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',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<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>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{(sec.items || []).map((it, iIdx) =>
|
||
|
|
it.item_type === 'note' ? (
|
||
|
|
<div key={`note-${sIdx}-${iIdx}`} style={{ marginTop: '0.65rem' }}>
|
||
|
|
<div style={{ fontSize: '0.75rem', color: 'var(--text2)', marginBottom: '4px' }}>
|
||
|
|
Zwischen-Anmerkung
|
||
|
|
</div>
|
||
|
|
<div style={{ display: 'flex', gap: '6px', alignItems: 'start' }}>
|
||
|
|
<div style={{ display: 'flex', flexDirection: 'column', paddingTop: '4px' }}>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => moveItem(sIdx, iIdx, -1)}
|
||
|
|
disabled={iIdx === 0}
|
||
|
|
style={{ padding: '2px', opacity: iIdx === 0 ? 0.3 : 1 }}
|
||
|
|
>
|
||
|
|
▲
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => moveItem(sIdx, iIdx, 1)}
|
||
|
|
disabled={iIdx === sec.items.length - 1}
|
||
|
|
style={{
|
||
|
|
padding: '2px',
|
||
|
|
opacity: iIdx === sec.items.length - 1 ? 0.3 : 1,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
▼
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<textarea
|
||
|
|
className="form-input"
|
||
|
|
rows={2}
|
||
|
|
style={{ flex: 1 }}
|
||
|
|
value={it.note_body}
|
||
|
|
onChange={(e) => updateItem(sIdx, iIdx, 'note_body', e.target.value)}
|
||
|
|
placeholder="Hinweise zwischen Übungen …"
|
||
|
|
/>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
style={{ background: 'var(--danger)', color: 'white', border: 'none' }}
|
||
|
|
onClick={() => removeItem(sIdx, iIdx)}
|
||
|
|
>
|
||
|
|
✗
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div
|
||
|
|
key={`ex-${sIdx}-${iIdx}`}
|
||
|
|
style={{
|
||
|
|
display: 'grid',
|
||
|
|
gridTemplateColumns: '28px minmax(0, 1fr) minmax(0, 64px) 36px',
|
||
|
|
gap: '6px',
|
||
|
|
alignItems: 'start',
|
||
|
|
marginTop: '0.65rem',
|
||
|
|
paddingTop: '0.45rem',
|
||
|
|
borderTop: '1px solid rgba(0,0,0,0.06)',
|
||
|
|
minWidth: 0,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<div style={{ display: 'flex', flexDirection: 'column', paddingTop: '6px' }}>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => moveItem(sIdx, iIdx, -1)}
|
||
|
|
disabled={iIdx === 0}
|
||
|
|
style={{ padding: '2px', opacity: iIdx === 0 ? 0.3 : 1 }}
|
||
|
|
>
|
||
|
|
▲
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => moveItem(sIdx, iIdx, 1)}
|
||
|
|
disabled={iIdx === sec.items.length - 1}
|
||
|
|
style={{
|
||
|
|
padding: '2px',
|
||
|
|
opacity: iIdx === sec.items.length - 1 ? 0.3 : 1,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
▼
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', minWidth: 0 }}>
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
display: 'flex',
|
||
|
|
gap: '6px',
|
||
|
|
flexWrap: 'wrap',
|
||
|
|
alignItems: 'center',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
|
|
style={{ margin: 0, whiteSpace: 'nowrap' }}
|
||
|
|
onClick={() =>
|
||
|
|
onRequestExercisePick?.({ sectionIndex: sIdx, itemIndex: iIdx })
|
||
|
|
}
|
||
|
|
>
|
||
|
|
Übung suchen…
|
||
|
|
</button>
|
||
|
|
{it.exercise_id && onPeekExercise ? (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
|
|
style={{ margin: 0 }}
|
||
|
|
onClick={() => onPeekExercise(Number(it.exercise_id))}
|
||
|
|
>
|
||
|
|
Vorschau
|
||
|
|
</button>
|
||
|
|
) : null}
|
||
|
|
{(it.exercise_title || it.exercise_id) && (
|
||
|
|
<span
|
||
|
|
style={{
|
||
|
|
fontSize: '0.82rem',
|
||
|
|
flex: 1,
|
||
|
|
minWidth: 0,
|
||
|
|
wordBreak: 'break-word',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{it.exercise_title ||
|
||
|
|
(it.exercise_id ? `Übung #${it.exercise_id}` : '')}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
{(() => {
|
||
|
|
const variantOpts = Array.isArray(it.variants) ? it.variants : []
|
||
|
|
return (
|
||
|
|
<select
|
||
|
|
className="form-input"
|
||
|
|
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)
|
||
|
|
)
|
||
|
|
}}
|
||
|
|
disabled={!it.exercise_id || variantOpts.length === 0}
|
||
|
|
style={{ margin: 0, fontSize: '0.82rem' }}
|
||
|
|
>
|
||
|
|
<option value="">
|
||
|
|
{variantOpts.length === 0 ? 'Keine Varianten' : 'Stammübung'}
|
||
|
|
</option>
|
||
|
|
{variantOpts.map((v) => (
|
||
|
|
<option key={v.id} value={v.id}>
|
||
|
|
{v.variant_name || `Variante #${v.id}`}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
)
|
||
|
|
})()}
|
||
|
|
<textarea
|
||
|
|
className="form-input"
|
||
|
|
rows={2}
|
||
|
|
value={it.notes || ''}
|
||
|
|
onChange={(e) => updateItem(sIdx, iIdx, 'notes', e.target.value)}
|
||
|
|
placeholder="Kurze Anmerkung zur Übung"
|
||
|
|
style={{ fontSize: '0.82rem' }}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<input
|
||
|
|
type="number"
|
||
|
|
className="form-input"
|
||
|
|
min={1}
|
||
|
|
value={it.planned_duration_min}
|
||
|
|
onChange={(e) =>
|
||
|
|
updateItem(sIdx, iIdx, 'planned_duration_min', e.target.value)
|
||
|
|
}
|
||
|
|
placeholder="min"
|
||
|
|
title="Geplante Dauer (Minuten)"
|
||
|
|
style={{ margin: 0 }}
|
||
|
|
/>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
style={{
|
||
|
|
padding: '0.45rem',
|
||
|
|
background: 'var(--danger)',
|
||
|
|
color: 'white',
|
||
|
|
border: 'none',
|
||
|
|
}}
|
||
|
|
onClick={() => removeItem(sIdx, iIdx)}
|
||
|
|
>
|
||
|
|
✗
|
||
|
|
</button>
|
||
|
|
{showExecutionExtras ? (
|
||
|
|
<label
|
||
|
|
className="form-label"
|
||
|
|
style={{
|
||
|
|
gridColumn: '2 / -1',
|
||
|
|
marginTop: 4,
|
||
|
|
display: 'block',
|
||
|
|
fontSize: '0.78rem',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
Ist-Dauer / Anpassungen
|
||
|
|
<input
|
||
|
|
type="number"
|
||
|
|
className="form-input"
|
||
|
|
min={1}
|
||
|
|
value={it.actual_duration_min}
|
||
|
|
onChange={(e) =>
|
||
|
|
updateItem(sIdx, iIdx, 'actual_duration_min', e.target.value)
|
||
|
|
}
|
||
|
|
placeholder="IST min"
|
||
|
|
style={{ maxWidth: '120px' }}
|
||
|
|
/>
|
||
|
|
<textarea
|
||
|
|
className="form-input"
|
||
|
|
rows={2}
|
||
|
|
value={it.modifications || ''}
|
||
|
|
onChange={(e) =>
|
||
|
|
updateItem(sIdx, iIdx, 'modifications', e.target.value)
|
||
|
|
}
|
||
|
|
placeholder="Abweichungen beim Durchführen"
|
||
|
|
style={{ marginTop: '6px', fontSize: '0.82rem' }}
|
||
|
|
/>
|
||
|
|
</label>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div style={{ marginTop: '0.65rem', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
|
|
onClick={() => addItem(sIdx, 'exercise')}
|
||
|
|
>
|
||
|
|
+ Übung
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
|
|
onClick={() => addItem(sIdx, 'note')}
|
||
|
|
>
|
||
|
|
+ Anmerkung
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
|
|
onClick={addSection}
|
||
|
|
>
|
||
|
|
+ Abschnitt hinzufügen
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|