All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 1m15s
- Updated app version to 0.8.110 and database schema version to 20260512057, reflecting recent enhancements. - Revised project status documentation to include new versioning and next steps for development. - Enhanced the functional specification for training modules and combination exercises, detailing upcoming features and improvements. - Improved technical specifications to align with the latest code changes, ensuring consistency across documentation. - Introduced new UI elements for toast notifications and unsaved changes prompts to enhance user experience. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
672 lines
25 KiB
JavaScript
672 lines
25 KiB
JavaScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||
import api from '../utils/api'
|
||
import ExercisePickerModal from '../components/ExercisePickerModal'
|
||
import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm'
|
||
import { useAuth } from '../context/AuthContext'
|
||
import { useToast } from '../context/ToastContext'
|
||
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
|
||
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
|
||
import { activeClubMemberships, getDefaultClubIdForGovernanceForms, getTenantClubDependencyKey } from '../utils/activeClub'
|
||
|
||
function moduleFormSnapshot({
|
||
title,
|
||
summary,
|
||
goal,
|
||
recommendedDurationMin,
|
||
targetGroupNotes,
|
||
deploymentContextNotes,
|
||
visibility,
|
||
clubIdField,
|
||
primaryMethodId,
|
||
items,
|
||
}) {
|
||
const itemRows = items.map((it) => {
|
||
if (it.item_type === 'note') {
|
||
return { k: 'n', b: it.note_body ?? '' }
|
||
}
|
||
return {
|
||
k: 'e',
|
||
id: it.exercise_id,
|
||
v: it.exercise_variant_id,
|
||
d: it.planned_duration_min,
|
||
n: it.notes ?? '',
|
||
}
|
||
})
|
||
return JSON.stringify({
|
||
title: (title || '').trim(),
|
||
summary: (summary || '').trim(),
|
||
goal: goal || '',
|
||
recommendedDurationMin: recommendedDurationMin || '',
|
||
targetGroupNotes: targetGroupNotes || '',
|
||
deploymentContextNotes: deploymentContextNotes || '',
|
||
visibility: visibility || '',
|
||
clubIdField: (clubIdField || '').trim(),
|
||
primaryMethodId: (primaryMethodId || '').trim(),
|
||
items: itemRows,
|
||
})
|
||
}
|
||
|
||
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([])
|
||
|
||
const toast = useToast()
|
||
const baselineRef = useRef(null)
|
||
const latestFormRef = useRef({})
|
||
const [baselineReady, setBaselineReady] = useState(false)
|
||
const [bypassDirty, setBypassDirty] = useState(false)
|
||
|
||
latestFormRef.current = {
|
||
title,
|
||
summary,
|
||
goal,
|
||
recommendedDurationMin,
|
||
targetGroupNotes,
|
||
deploymentContextNotes,
|
||
visibility,
|
||
clubIdField,
|
||
primaryMethodId,
|
||
items,
|
||
}
|
||
|
||
const dirtySignature = moduleFormSnapshot(latestFormRef.current)
|
||
|
||
useEffect(() => {
|
||
baselineRef.current = null
|
||
setBaselineReady(false)
|
||
setBypassDirty(false)
|
||
}, [isNew, moduleId])
|
||
|
||
useEffect(() => {
|
||
if (loading) return
|
||
const handle = window.setTimeout(() => {
|
||
baselineRef.current = moduleFormSnapshot(latestFormRef.current)
|
||
setBaselineReady(true)
|
||
}, 120)
|
||
return () => clearTimeout(handle)
|
||
}, [loading, isNew, moduleId])
|
||
|
||
const formDirtyEffective =
|
||
baselineReady && baselineRef.current != null && !bypassDirty && !loading && dirtySignature !== baselineRef.current
|
||
|
||
const blocker = useUnsavedChangesBlocker(Boolean(formDirtyEffective && !saving))
|
||
useBeforeUnloadWhen(Boolean(formDirtyEffective && !saving))
|
||
|
||
const { user } = useAuth()
|
||
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
||
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
|
||
const [clubsForGovernanceForms, setClubsForGovernanceForms] = useState([])
|
||
|
||
useEffect(() => {
|
||
if (!isPlatformAdmin) {
|
||
setClubsForGovernanceForms([])
|
||
return undefined
|
||
}
|
||
let cancelled = false
|
||
;(async () => {
|
||
try {
|
||
const list = await api.listClubs()
|
||
if (!cancelled) setClubsForGovernanceForms(Array.isArray(list) ? list : [])
|
||
} catch {
|
||
if (!cancelled) setClubsForGovernanceForms([])
|
||
}
|
||
})()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [isPlatformAdmin, tenantClubDepKey])
|
||
|
||
const membershipClubRows = useMemo(() => activeClubMemberships(user?.clubs ?? []), [user?.clubs])
|
||
|
||
const visibilityClubChoices = useMemo(() => {
|
||
if (isPlatformAdmin && clubsForGovernanceForms.length > 0) {
|
||
return [...clubsForGovernanceForms].sort((a, b) =>
|
||
String(a.name || '').localeCompare(String(b.name || ''), 'de'),
|
||
)
|
||
}
|
||
return [...membershipClubRows].sort((a, b) =>
|
||
String(a.name || '').localeCompare(String(b.name || ''), 'de'),
|
||
)
|
||
}, [isPlatformAdmin, clubsForGovernanceForms, membershipClubRows])
|
||
|
||
useEffect(() => {
|
||
if (!isNew || visibility !== 'club') return
|
||
if ((clubIdField || '').trim() !== '') return
|
||
const xs = visibilityClubChoices
|
||
if (xs.length === 1) setClubIdField(String(xs[0].id))
|
||
else {
|
||
const r = getDefaultClubIdForGovernanceForms(user)
|
||
if (r != null && xs.some((c) => Number(c.id) === Number(r))) setClubIdField(String(r))
|
||
}
|
||
}, [isNew, visibility, clubIdField, visibilityClubChoices, user])
|
||
|
||
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 = () => {
|
||
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 (visibilityClubChoices.length === 1) {
|
||
cid = visibilityClubChoices[0].id
|
||
}
|
||
}
|
||
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 performModuleSave = async ({ fromUnsavedDialog = false } = {}) => {
|
||
if (!title.trim()) {
|
||
toast.error('Titel ist Pflicht.')
|
||
return false
|
||
}
|
||
setSaving(true)
|
||
setError('')
|
||
try {
|
||
const body = buildBody()
|
||
if (isNew) {
|
||
const created = await api.createTrainingModule(body)
|
||
toast.success('Trainingsmodul angelegt.')
|
||
if (!fromUnsavedDialog) {
|
||
navigate(`/planning/training-modules/${created.id}`, { replace: true })
|
||
}
|
||
return true
|
||
}
|
||
await api.updateTrainingModule(moduleId, body)
|
||
baselineRef.current = moduleFormSnapshot(latestFormRef.current)
|
||
setBypassDirty(false)
|
||
toast.success('Gespeichert.')
|
||
return true
|
||
} catch (err) {
|
||
const msg = err.message || 'Speichern fehlgeschlagen'
|
||
setError(msg)
|
||
toast.error(msg)
|
||
return false
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const handleSave = async (e) => {
|
||
e.preventDefault()
|
||
await performModuleSave({ fromUnsavedDialog: false })
|
||
}
|
||
|
||
const handleUnsavedDialogSave = async () => {
|
||
const ok = await performModuleSave({ fromUnsavedDialog: true })
|
||
if (ok) blocker.proceed()
|
||
}
|
||
|
||
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>
|
||
<select
|
||
className="form-input"
|
||
value={visibility}
|
||
onChange={(e) => {
|
||
const v = e.target.value
|
||
setVisibility(v)
|
||
if (v !== 'club') {
|
||
setClubIdField('')
|
||
return
|
||
}
|
||
const xs = visibilityClubChoices
|
||
if (xs.length === 1) setClubIdField(String(xs[0].id))
|
||
else if (xs.length === 0) setClubIdField('')
|
||
else {
|
||
const resolved = getDefaultClubIdForGovernanceForms(user)
|
||
setClubIdField(
|
||
resolved != null && xs.some((c) => Number(c.id) === Number(resolved))
|
||
? String(resolved)
|
||
: '',
|
||
)
|
||
}
|
||
}}
|
||
>
|
||
<option value="private">Privat</option>
|
||
<option value="club">Vereinsintern</option>
|
||
<option value="official">Offiziell</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<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>
|
||
) : visibilityClubChoices.length === 0 ? (
|
||
<p style={{ margin: '0.25rem 0 0', fontSize: '0.85rem', color: 'var(--danger)', lineHeight: 1.45 }}>
|
||
Kein Verein zur Auswahl — bitte aktiven Verein im Profil wählen oder (Plattform-Admin) Vereinsliste
|
||
laden.
|
||
</p>
|
||
) : visibilityClubChoices.length === 1 ? (
|
||
<>
|
||
<input
|
||
className="form-input"
|
||
disabled
|
||
readOnly
|
||
value={
|
||
(visibilityClubChoices[0].short_name || visibilityClubChoices[0].name || '').trim() ||
|
||
`Verein #${visibilityClubChoices[0].id}`
|
||
}
|
||
/>
|
||
<p style={{ margin: '0.35rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
||
Fixiert durch deine Mitgliedschaft. Verein-ID {visibilityClubChoices[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>
|
||
{visibilityClubChoices.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>
|
||
</>
|
||
)}
|
||
</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} />
|
||
<UnsavedChangesPrompt
|
||
blocker={blocker}
|
||
isBusy={saving}
|
||
onSave={handleUnsavedDialogSave}
|
||
onDiscardWithoutSave={() => setBypassDirty(true)}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|