shinkan-jinkendo/frontend/src/pages/TrainingModuleEditPage.jsx
Lars 49adb395dd
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
feat(version): bump to 0.8.110 and update project specifications
- 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>
2026-05-13 16:34:38 +02:00

672 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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