All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m17s
- Introduced new helper functions for managing artifact type corpus, improving code organization and readability. - Updated the `compute_club_corpus_reference` function to utilize the new corpus handling methods, enhancing clarity and maintainability. - Refactored skill profile functions to leverage the new corpus structure, ensuring consistent data retrieval across different artifact types. - Improved the handling of visibility clauses for library content, streamlining database queries for skill profiles. - Enhanced the batch skill profile summary function to aggregate reference data by artifact type, improving performance and accuracy.
739 lines
27 KiB
JavaScript
739 lines
27 KiB
JavaScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
|
import api from '../utils/api'
|
|
import ExercisePickerModal from '../components/ExercisePickerModal'
|
|
import FormActionBar from '../components/FormActionBar'
|
|
import SkillProfilePanel from '../components/skills/SkillProfilePanel'
|
|
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'
|
|
import {
|
|
buildTrainingModulesListReturnContext,
|
|
goNavReturn,
|
|
preserveAppReturnOnNavigate,
|
|
} from '../utils/navReturnContext'
|
|
|
|
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 location = useLocation()
|
|
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 [skillProfileData, setSkillProfileData] = useState(null)
|
|
const [skillProfileLoading, setSkillProfileLoading] = useState(false)
|
|
const [skillProfileError, setSkillProfileError] = useState('')
|
|
const [skillProfileTick, setSkillProfileTick] = useState(0)
|
|
|
|
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))
|
|
|
|
useEffect(() => {
|
|
if (isNew || !Number.isFinite(moduleId)) {
|
|
setSkillProfileData(null)
|
|
return undefined
|
|
}
|
|
let cancelled = false
|
|
;(async () => {
|
|
setSkillProfileLoading(true)
|
|
setSkillProfileError('')
|
|
try {
|
|
const data = await api.getTrainingModuleSkillProfile(moduleId)
|
|
if (!cancelled) setSkillProfileData(data)
|
|
} catch (e) {
|
|
if (!cancelled) {
|
|
setSkillProfileData(null)
|
|
setSkillProfileError(e.message || 'Fähigkeiten-Profil nicht geladen')
|
|
}
|
|
} finally {
|
|
if (!cancelled) setSkillProfileLoading(false)
|
|
}
|
|
})()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [isNew, moduleId, skillProfileTick])
|
|
|
|
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 moduleListReturn = useMemo(() => buildTrainingModulesListReturnContext(), [])
|
|
|
|
const goBack = useCallback(() => {
|
|
goNavReturn(navigate, location, moduleListReturn)
|
|
}, [navigate, location, moduleListReturn])
|
|
|
|
const performModuleSave = async ({ fromUnsavedDialog = false, closeAfter = 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 (closeAfter) {
|
|
goBack()
|
|
} else if (!fromUnsavedDialog) {
|
|
preserveAppReturnOnNavigate(navigate, location, `/planning/training-modules/${created.id}`, {
|
|
replace: true,
|
|
})
|
|
}
|
|
return true
|
|
}
|
|
await api.updateTrainingModule(moduleId, body)
|
|
baselineRef.current = moduleFormSnapshot(latestFormRef.current)
|
|
setBypassDirty(false)
|
|
toast.success('Gespeichert.')
|
|
setSkillProfileTick((t) => t + 1)
|
|
if (closeAfter) goBack()
|
|
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, closeAfter: false })
|
|
}
|
|
|
|
const handleSaveAndClose = async () => {
|
|
await performModuleSave({ fromUnsavedDialog: false, closeAfter: true })
|
|
}
|
|
|
|
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">
|
|
<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
|
|
id="training-module-form"
|
|
className="card page-form-shell"
|
|
style={{ padding: 'clamp(14px, 3vw, 1.75rem)', maxWidth: '720px' }}
|
|
onSubmit={handleSave}
|
|
>
|
|
<div className="page-form-shell__scroll">
|
|
{!isNew ? (
|
|
<SkillProfilePanel
|
|
title="Fähigkeiten im Modul"
|
|
profile={skillProfileData?.overall}
|
|
loading={skillProfileLoading}
|
|
error={skillProfileError}
|
|
artifactType="training_module"
|
|
/>
|
|
) : null}
|
|
<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>
|
|
|
|
<FormActionBar
|
|
formId="training-module-form"
|
|
isNew={isNew}
|
|
saving={saving}
|
|
onSave={() => handleSave()}
|
|
onSaveAndClose={handleSaveAndClose}
|
|
onCancel={goBack}
|
|
cancelLabel="Abbrechen"
|
|
/>
|
|
</form>
|
|
)}
|
|
|
|
<ExercisePickerModal open={pickerOpen} onClose={() => setPickerOpen(false)} onSelectExercise={pickExercise} />
|
|
<UnsavedChangesPrompt
|
|
blocker={blocker}
|
|
isBusy={saving}
|
|
onSave={handleUnsavedDialogSave}
|
|
onDiscardWithoutSave={() => setBypassDirty(true)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|