2026-05-12 21:53:41 +02:00
|
|
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
2026-05-12 21:35:07 +02:00
|
|
|
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
|
|
|
|
|
import api from '../utils/api'
|
|
|
|
|
|
import ExercisePickerModal from '../components/ExercisePickerModal'
|
|
|
|
|
|
import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm'
|
2026-05-12 21:53:41 +02:00
|
|
|
|
import { useAuth } from '../context/AuthContext'
|
|
|
|
|
|
import { activeClubMemberships, getResolvedActiveClubIdForUi } from '../utils/activeClub'
|
2026-05-12 21:35:07 +02:00
|
|
|
|
|
|
|
|
|
|
function nextLocalKey() {
|
|
|
|
|
|
return `m-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function swapItems(arr, i, j) {
|
|
|
|
|
|
if (i === j || i < 0 || j < 0 || i >= arr.length || j >= arr.length) return [...arr]
|
|
|
|
|
|
const n = [...arr]
|
|
|
|
|
|
;[n[i], n[j]] = [n[j], n[i]]
|
|
|
|
|
|
return n
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function TrainingModuleEditPage() {
|
|
|
|
|
|
const { id: routeId } = useParams()
|
|
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
|
const isNew = !routeId || routeId === 'new'
|
|
|
|
|
|
const moduleId = !isNew ? parseInt(routeId, 10) : NaN
|
|
|
|
|
|
|
|
|
|
|
|
const [loading, setLoading] = useState(!isNew)
|
|
|
|
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
|
|
const [methods, setMethods] = useState([])
|
|
|
|
|
|
const [pickerOpen, setPickerOpen] = useState(false)
|
|
|
|
|
|
const [error, setError] = useState('')
|
|
|
|
|
|
|
|
|
|
|
|
const [title, setTitle] = useState('')
|
|
|
|
|
|
const [summary, setSummary] = useState('')
|
|
|
|
|
|
const [goal, setGoal] = useState('')
|
|
|
|
|
|
const [recommendedDurationMin, setRecommendedDurationMin] = useState('')
|
|
|
|
|
|
const [targetGroupNotes, setTargetGroupNotes] = useState('')
|
|
|
|
|
|
const [deploymentContextNotes, setDeploymentContextNotes] = useState('')
|
|
|
|
|
|
const [visibility, setVisibility] = useState('club')
|
|
|
|
|
|
const [clubIdField, setClubIdField] = useState('')
|
|
|
|
|
|
const [primaryMethodId, setPrimaryMethodId] = useState('')
|
|
|
|
|
|
const [items, setItems] = useState([])
|
|
|
|
|
|
|
2026-05-12 21:53:41 +02:00
|
|
|
|
const { user } = useAuth()
|
|
|
|
|
|
const clubChoices = useMemo(() => activeClubMemberships(user?.clubs ?? []), [user?.clubs])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!isNew || visibility !== 'club') return
|
|
|
|
|
|
if ((clubIdField || '').trim() !== '') return
|
|
|
|
|
|
if (clubChoices.length === 1) setClubIdField(String(clubChoices[0].id))
|
|
|
|
|
|
else {
|
|
|
|
|
|
const r = getResolvedActiveClubIdForUi(user)
|
|
|
|
|
|
if (r) setClubIdField(String(r))
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [isNew, visibility, clubIdField, clubChoices, user])
|
|
|
|
|
|
|
2026-05-12 21:35:07 +02:00
|
|
|
|
const itemsPayload = items.map((it, i) => {
|
|
|
|
|
|
if (it.item_type === 'note') {
|
|
|
|
|
|
return { item_type: 'note', order_index: i, note_body: it.note_body ?? '' }
|
|
|
|
|
|
}
|
|
|
|
|
|
const vid =
|
|
|
|
|
|
it.exercise_variant_id !== '' && it.exercise_variant_id != null
|
|
|
|
|
|
? parseInt(it.exercise_variant_id, 10)
|
|
|
|
|
|
: null
|
|
|
|
|
|
return {
|
|
|
|
|
|
item_type: 'exercise',
|
|
|
|
|
|
order_index: i,
|
|
|
|
|
|
exercise_id: parseInt(it.exercise_id, 10),
|
|
|
|
|
|
exercise_variant_id: Number.isFinite(vid) ? vid : null,
|
|
|
|
|
|
planned_duration_min:
|
|
|
|
|
|
it.planned_duration_min !== '' && it.planned_duration_min != null
|
|
|
|
|
|
? parseInt(String(it.planned_duration_min), 10)
|
|
|
|
|
|
: null,
|
|
|
|
|
|
notes: it.notes?.trim() ? it.notes.trim() : null,
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const loadCatalogs = useCallback(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const m = await api.listMethods({})
|
|
|
|
|
|
setMethods(Array.isArray(m) ? m : [])
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setMethods([])
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadCatalogs()
|
|
|
|
|
|
}, [loadCatalogs])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (isNew || !Number.isFinite(moduleId)) {
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
async function load() {
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
setError('')
|
|
|
|
|
|
try {
|
|
|
|
|
|
const m = await api.getTrainingModule(moduleId)
|
|
|
|
|
|
if (cancelled) return
|
|
|
|
|
|
setTitle((m.title || '').trim())
|
|
|
|
|
|
setSummary((m.summary || '').trim())
|
|
|
|
|
|
setGoal(m.goal || '')
|
|
|
|
|
|
setRecommendedDurationMin(
|
|
|
|
|
|
m.recommended_duration_min != null && m.recommended_duration_min !== ''
|
|
|
|
|
|
? String(m.recommended_duration_min)
|
|
|
|
|
|
: ''
|
|
|
|
|
|
)
|
|
|
|
|
|
setTargetGroupNotes(m.target_group_notes || '')
|
|
|
|
|
|
setDeploymentContextNotes(m.deployment_context_notes || '')
|
|
|
|
|
|
setVisibility((m.visibility || 'club').trim())
|
|
|
|
|
|
setClubIdField(m.club_id != null ? String(m.club_id) : '')
|
|
|
|
|
|
setPrimaryMethodId(m.primary_method_id != null ? String(m.primary_method_id) : '')
|
|
|
|
|
|
const nextItems = []
|
|
|
|
|
|
for (const row of Array.isArray(m.items) ? m.items : []) {
|
|
|
|
|
|
if (row.item_type === 'note') {
|
|
|
|
|
|
nextItems.push({ localKey: nextLocalKey(), item_type: 'note', note_body: row.note_body || '' })
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
const ex = await hydrateExercisePlanningRow({
|
|
|
|
|
|
id: row.exercise_id,
|
|
|
|
|
|
title: '',
|
|
|
|
|
|
variants: [],
|
|
|
|
|
|
})
|
|
|
|
|
|
if (ex) {
|
|
|
|
|
|
ex.localKey = nextLocalKey()
|
|
|
|
|
|
if (row.exercise_variant_id) ex.exercise_variant_id = String(row.exercise_variant_id)
|
|
|
|
|
|
ex.planned_duration_min =
|
|
|
|
|
|
row.planned_duration_min != null && row.planned_duration_min !== ''
|
|
|
|
|
|
? String(row.planned_duration_min)
|
|
|
|
|
|
: ''
|
|
|
|
|
|
ex.notes = row.notes || ''
|
|
|
|
|
|
nextItems.push(ex)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
setItems(nextItems)
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (!cancelled) setError(e.message || 'Laden fehlgeschlagen')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (!cancelled) setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
load()
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [isNew, moduleId])
|
|
|
|
|
|
|
|
|
|
|
|
const buildBody = () => {
|
2026-05-12 21:53:41 +02:00
|
|
|
|
let cid = null
|
|
|
|
|
|
if (visibility === 'club') {
|
|
|
|
|
|
const raw = (clubIdField || '').trim()
|
|
|
|
|
|
if (raw !== '') {
|
|
|
|
|
|
const p = parseInt(raw, 10)
|
|
|
|
|
|
if (Number.isFinite(p) && p >= 1) cid = p
|
|
|
|
|
|
} else if (clubChoices.length === 1) {
|
|
|
|
|
|
cid = clubChoices[0].id
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-12 21:35:07 +02:00
|
|
|
|
const pm =
|
|
|
|
|
|
primaryMethodId !== '' && primaryMethodId != null ? parseInt(primaryMethodId, 10) : null
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: title.trim(),
|
|
|
|
|
|
summary: summary.trim() || null,
|
|
|
|
|
|
goal: goal.trim() || null,
|
|
|
|
|
|
recommended_duration_min:
|
|
|
|
|
|
recommendedDurationMin !== '' ? parseInt(recommendedDurationMin, 10) : null,
|
|
|
|
|
|
target_group_notes: targetGroupNotes.trim() || null,
|
|
|
|
|
|
deployment_context_notes: deploymentContextNotes.trim() || null,
|
|
|
|
|
|
visibility,
|
|
|
|
|
|
club_id:
|
|
|
|
|
|
cid != null && Number.isFinite(cid) && cid >= 1
|
|
|
|
|
|
? cid
|
|
|
|
|
|
: visibility === 'club'
|
|
|
|
|
|
? undefined
|
|
|
|
|
|
: null,
|
|
|
|
|
|
primary_method_id:
|
|
|
|
|
|
pm != null && Number.isFinite(pm) && pm >= 1 ? pm : null,
|
|
|
|
|
|
items: itemsPayload.filter((row) =>
|
|
|
|
|
|
row.item_type === 'note' ? true : Number.isFinite(row.exercise_id) && row.exercise_id >= 1
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleSave = async (e) => {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
if (!title.trim()) {
|
|
|
|
|
|
alert('Titel ist Pflicht.')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
setSaving(true)
|
|
|
|
|
|
setError('')
|
|
|
|
|
|
try {
|
|
|
|
|
|
const body = buildBody()
|
|
|
|
|
|
if (isNew) {
|
|
|
|
|
|
const created = await api.createTrainingModule(body)
|
|
|
|
|
|
navigate(`/planning/training-modules/${created.id}`, { replace: true })
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await api.updateTrainingModule(moduleId, body)
|
|
|
|
|
|
alert('Trainingsmodul gespeichert.')
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err.message || 'Speichern fehlgeschlagen')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSaving(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const pickExercise = async (ex) => {
|
|
|
|
|
|
if (!ex?.id) return
|
|
|
|
|
|
const row = await hydrateExercisePlanningRow(ex)
|
|
|
|
|
|
if (row) row.localKey = nextLocalKey()
|
|
|
|
|
|
if (row) setItems((prev) => [...prev, row])
|
|
|
|
|
|
setPickerOpen(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="app-page">
|
|
|
|
|
|
<p style={{ marginBottom: '0.75rem' }}>
|
|
|
|
|
|
<Link to="/planning/training-modules" style={{ color: 'var(--accent-dark)', fontWeight: 600 }}>
|
|
|
|
|
|
← Zurück zur Modul‑Bibliothek
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<h1 className="page-title">{isNew ? 'Neues Trainingsmodul' : 'Trainingsmodul bearbeiten'}</h1>
|
|
|
|
|
|
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', marginBottom: '1.25rem', maxWidth: '40rem' }}>
|
|
|
|
|
|
Reihenfolge der Positionen entspricht der späteren Übernahme in einen Abschnitt der Einheit (Kopie).
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
{error ? <p style={{ color: 'var(--danger)', marginBottom: '1rem' }}>{error}</p> : null}
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
|
<p style={{ color: 'var(--text2)' }}>Laden …</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<form className="card" style={{ padding: 'clamp(14px, 3vw, 1.75rem)', maxWidth: '720px' }} onSubmit={handleSave}>
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">Titel *</label>
|
|
|
|
|
|
<input className="form-input" value={title} onChange={(e) => setTitle(e.target.value)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">Kurzbeschreibung</label>
|
|
|
|
|
|
<textarea className="form-input" rows={2} value={summary} onChange={(e) => setSummary(e.target.value)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">Ziel</label>
|
|
|
|
|
|
<textarea className="form-input" rows={3} value={goal} onChange={(e) => setGoal(e.target.value)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="form-row" style={{ display: 'grid', gap: '1rem', gridTemplateColumns: '1fr 1fr' }}>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="form-label">Empfohlene Dauer (Min.)</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min={0}
|
|
|
|
|
|
value={recommendedDurationMin}
|
|
|
|
|
|
onChange={(e) => setRecommendedDurationMin(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="form-label">Primäre Trainingsmethode</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={primaryMethodId}
|
|
|
|
|
|
onChange={(e) => setPrimaryMethodId(e.target.value)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">—</option>
|
|
|
|
|
|
{methods.map((m) => (
|
|
|
|
|
|
<option key={m.id} value={String(m.id)}>
|
|
|
|
|
|
{(m.name || '').trim() || `Methode #${m.id}`}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">Empfohlene Zielgruppe (Freitext)</label>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
rows={2}
|
|
|
|
|
|
value={targetGroupNotes}
|
|
|
|
|
|
onChange={(e) => setTargetGroupNotes(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">Einsatz / Kontext</label>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
rows={2}
|
|
|
|
|
|
value={deploymentContextNotes}
|
|
|
|
|
|
onChange={(e) => setDeploymentContextNotes(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="form-row" style={{ display: 'grid', gap: '1rem', gridTemplateColumns: '1fr 1fr' }}>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="form-label">Sichtbarkeit</label>
|
2026-05-12 21:53:41 +02:00
|
|
|
|
<select
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={visibility}
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
const v = e.target.value
|
|
|
|
|
|
setVisibility(v)
|
|
|
|
|
|
if (v !== 'club') {
|
|
|
|
|
|
setClubIdField('')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const xs = clubChoices
|
|
|
|
|
|
if (xs.length === 1) setClubIdField(String(xs[0].id))
|
|
|
|
|
|
else if (xs.length === 0) setClubIdField('')
|
|
|
|
|
|
else {
|
|
|
|
|
|
const resolved = getResolvedActiveClubIdForUi(user)
|
|
|
|
|
|
setClubIdField(resolved != null ? String(resolved) : '')
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-05-12 21:35:07 +02:00
|
|
|
|
<option value="private">Privat</option>
|
|
|
|
|
|
<option value="club">Vereinsintern</option>
|
|
|
|
|
|
<option value="official">Offiziell</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
2026-05-12 21:53:41 +02:00
|
|
|
|
<label className="form-label">Verein (bei „Vereinsintern“)</label>
|
|
|
|
|
|
{visibility !== 'club' ? (
|
|
|
|
|
|
<p style={{ margin: '0.25rem 0 0', fontSize: '0.85rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
|
|
|
|
|
Bei privaten oder offiziellen Modulen ist keine Vereinszuordnung nötig (Server legt keine
|
|
|
|
|
|
Vereinsbindung fest).
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : clubChoices.length === 0 ? (
|
|
|
|
|
|
<p style={{ margin: '0.25rem 0 0', fontSize: '0.85rem', color: 'var(--danger)', lineHeight: 1.45 }}>
|
|
|
|
|
|
Kein aktiver Verein im Profil — bitte zuerst einem Verein beitreten.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : clubChoices.length === 1 ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
disabled
|
|
|
|
|
|
readOnly
|
|
|
|
|
|
value={
|
|
|
|
|
|
(clubChoices[0].short_name || clubChoices[0].name || '').trim() ||
|
|
|
|
|
|
`Verein #${clubChoices[0].id}`
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p style={{ margin: '0.35rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
|
|
|
|
|
Fixiert durch deine Mitgliedschaft. Verein-ID {clubChoices[0].id} wird beim Speichern verwendet.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<select
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={clubIdField}
|
|
|
|
|
|
onChange={(e) => setClubIdField(e.target.value)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">Automatisch (aktueller Verein im Profil)</option>
|
|
|
|
|
|
{clubChoices.map((c) => {
|
|
|
|
|
|
const ln = `${((c.short_name || c.name || '').trim() || '') || `Verein #${c.id}`}`
|
|
|
|
|
|
return (
|
|
|
|
|
|
<option key={c.id} value={String(c.id)}>
|
|
|
|
|
|
{ln}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<p style={{ margin: '0.35rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
|
|
|
|
|
Bei „Automatisch“ entscheidet der aktiv gewählte Verein beim Speichern (wie bei anderen
|
|
|
|
|
|
Bibliotheksinhalten).
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-05-12 21:35:07 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '1.25rem 0' }} />
|
|
|
|
|
|
|
|
|
|
|
|
<h3 style={{ fontSize: '1rem', marginBottom: '0.75rem' }}>Positionen</h3>
|
|
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px' }}>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => setPickerOpen(true)}>
|
|
|
|
|
|
Übung hinzufügen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
onClick={() =>
|
|
|
|
|
|
setItems((prev) => [...prev, { localKey: nextLocalKey(), item_type: 'note', note_body: '' }])
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
Notiz hinzufügen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
|
|
|
|
|
{items.map((it, idx) => (
|
|
|
|
|
|
<li key={it.localKey || idx} style={{ padding: '10px', background: 'var(--surface2)', borderRadius: '8px' }}>
|
|
|
|
|
|
{it.item_type === 'note' ? (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text3)' }}>Notiz</span>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
style={{ marginTop: '8px' }}
|
|
|
|
|
|
rows={2}
|
|
|
|
|
|
value={it.note_body}
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
const v = e.target.value
|
|
|
|
|
|
setItems((prev) =>
|
|
|
|
|
|
prev.map((x, j) =>
|
|
|
|
|
|
j === idx && x.item_type === 'note' ? { ...x, note_body: v } : x
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div style={{ fontWeight: 700, wordBreak: 'break-word', marginBottom: '8px' }}>
|
|
|
|
|
|
{(it.exercise_title || '').trim() || `Übung #${it.exercise_id}`}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ display: 'grid', gap: '8px', gridTemplateColumns: '1fr 1fr' }}>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="form-label" style={{ fontSize: '0.78rem' }}>
|
|
|
|
|
|
Variante
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={String(it.exercise_variant_id ?? '')}
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
const v = e.target.value
|
|
|
|
|
|
setItems((prev) =>
|
|
|
|
|
|
prev.map((row, j) =>
|
|
|
|
|
|
j === idx && row.item_type === 'exercise' ? { ...row, exercise_variant_id: v } : row
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">—</option>
|
|
|
|
|
|
{(it.variants || []).map((v) => (
|
|
|
|
|
|
<option key={v.id} value={String(v.id)}>
|
|
|
|
|
|
{(v.name || '').trim() || `Variante #${v.id}`}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="form-label" style={{ fontSize: '0.78rem' }}>
|
|
|
|
|
|
Minuten (plan)
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min={0}
|
|
|
|
|
|
value={it.planned_duration_min}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setItems((prev) =>
|
|
|
|
|
|
prev.map((row, j) =>
|
|
|
|
|
|
j === idx && row.item_type === 'exercise'
|
|
|
|
|
|
? { ...row, planned_duration_min: e.target.value }
|
|
|
|
|
|
: row
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ marginTop: '8px' }}>
|
|
|
|
|
|
<label className="form-label" style={{ fontSize: '0.78rem' }}>
|
|
|
|
|
|
Hinweis zur Position
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={it.notes ?? ''}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setItems((prev) =>
|
|
|
|
|
|
prev.map((row, j) =>
|
|
|
|
|
|
j === idx && row.item_type === 'exercise' ? { ...row, notes: e.target.value } : row
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div style={{ display: 'flex', gap: '8px', marginTop: '10px', flexWrap: 'wrap' }}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
disabled={idx < 1}
|
|
|
|
|
|
onClick={() => setItems((prev) => swapItems(prev, idx, idx - 1))}
|
|
|
|
|
|
>
|
|
|
|
|
|
Nach oben
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
disabled={idx >= items.length - 1}
|
|
|
|
|
|
onClick={() => setItems((prev) => swapItems(prev, idx, idx + 1))}
|
|
|
|
|
|
>
|
|
|
|
|
|
Nach unten
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
onClick={() => setItems((prev) => prev.filter((_, j) => j !== idx))}
|
|
|
|
|
|
>
|
|
|
|
|
|
Entfernen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ display: 'flex', gap: '10px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
|
|
|
|
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
|
|
|
|
|
{saving ? 'Speichern …' : isNew ? 'Anlegen' : 'Speichern'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<Link to="/planning/training-modules" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
|
|
|
|
|
|
Abbrechen
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<ExercisePickerModal open={pickerOpen} onClose={() => setPickerOpen(false)} onSelectExercise={pickExercise} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|