shinkan-jinkendo/frontend/src/components/TrainingUnitSectionsEditor.jsx

443 lines
16 KiB
React
Raw Normal View History

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>
)
}