shinkan-jinkendo/frontend/src/pages/TrainingModuleEditPage.jsx
Lars 2de4c0b7c9
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
Refactor Skill Scoring Functions and Enhance Corpus Handling
- 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.
2026-05-21 10:17:22 +02:00

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