shinkan-jinkendo/frontend/src/pages/TrainingModuleEditPage.jsx

524 lines
21 KiB
React
Raw Normal View History

import React, { useCallback, useEffect, useMemo, 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 { activeClubMemberships, getResolvedActiveClubIdForUi } from '../utils/activeClub'
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 { 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])
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 (clubChoices.length === 1) {
cid = clubChoices[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 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>
<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) : '')
}
}}
>
<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>
) : 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>
</>
)}
</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>
)
}