shinkan-jinkendo/frontend/src/components/planning/TrainingUnitFormShell.jsx
Lars 5a8a212f40
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m22s
Update version information and add training duration features
- Bumped application version to 0.8.150 and updated build date and database schema version.
- Introduced new SQL migration for planned duration fields in training units and sections.
- Added functions to handle focus areas and style directions in training framework programs.
- Enhanced training planning components to support planned duration input and display.
- Updated frontend components to manage and display planned duration for training units and sections.
2026-05-20 13:02:09 +02:00

530 lines
22 KiB
JavaScript

import React, { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel'
import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor'
import { activeClubMemberships } from '../../utils/activeClub'
import { frameworkLineageText } from '../../utils/trainingPlanningPageHelpers'
import {
formatDurationDisplay,
minutesToDurationFieldValue,
parseDurationInput,
sumExercisePlannedMinutes,
sumSectionPlannedMinutes,
} from '../../utils/trainingDurationUtils'
/**
* Vollseiten-Formular: Trainingseinheit planen / nachbereiten (ohne Modal-Overlay).
*/
export default function TrainingUnitFormShell({
editingUnit,
formData,
updateFormField,
setFormData,
onSaveOnly,
onSaveAndClose,
draftPlanTemplateId,
onDraftTemplateSelect,
planTemplates,
clubDirectory,
clubDirectoryForCo,
planningClubId,
user,
onMetaRefresh,
sectionsEditMode,
setSectionsEditMode,
onSaveAsTemplate,
onRequestPublishToFramework,
onRequestSaveAsModule,
onRequestTrainingModulePick,
onRequestExercisePick,
onPeekExercise,
formId = 'planning-unit-form',
}) {
const [newTplVisibility, setNewTplVisibility] = useState('private')
const [newTplClubId, setNewTplClubId] = useState('')
const memberClubs = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs])
const roleLc = String(user?.role || '').toLowerCase()
const isSuperadmin = roleLc === 'superadmin'
const plannedTotalParsed = parseDurationInput(formData.planned_duration_min)
const sumFromSections = sumSectionPlannedMinutes(formData.sections)
const sumFromExercises = sumExercisePlannedMinutes(formData.sections)
useEffect(() => {
if (planningClubId != null && planningClubId !== '') {
setNewTplClubId(String(planningClubId))
} else if (memberClubs.length === 1) {
setNewTplClubId(String(memberClubs[0].id))
}
}, [planningClubId, memberClubs])
return (
<form
id={formId}
className="card page-form-shell"
style={{ padding: 'clamp(14px, 3vw, 1.75rem)' }}
onSubmit={(e) => (onSaveAndClose ? onSaveAndClose(e) : onSaveOnly?.(e))}
>
<div className="page-form-shell__scroll">
{editingUnit?.origin_framework_slot_id
? (() => {
const L = frameworkLineageText(editingUnit)
return (
<div
className="card"
style={{
marginBottom: '1.1rem',
padding: '12px 14px',
background: 'var(--surface2)',
fontSize: '0.9rem',
lineHeight: 1.5,
}}
>
<strong style={{ color: 'var(--text1)' }}>Herkunft:</strong>{' '}
{editingUnit.origin_framework_program_id ? (
<Link
to={`/planning/framework-programs/${editingUnit.origin_framework_program_id}`}
style={{ color: 'var(--accent-dark)' }}
>
{L.fpTitle}
</Link>
) : (
L.fpTitle
)}
<span style={{ color: 'var(--text2)' }}> · {L.slotBit}</span>
<p style={{ margin: '0.5rem 0 0', fontSize: '0.82rem', color: 'var(--text2)' }}>
Inhalt stammt aus dem Session-Blueprint des Rahmenprogramms. Änderungen gelten nur für diese
geplante Einheit; die Zuordnung zum Rahmen bleibt zur Nachverfolgung erhalten.
</p>
</div>
)
})()
: null}
{!editingUnit && (
<div className="training-planning-template-panel" style={{ marginBottom: '1.35rem' }}>
<label className="form-label training-planning-template-panel__label" htmlFor="planning-draft-template">
Vorlage für den Ablauf
</label>
<select
id="planning-draft-template"
className="form-input training-planning-template-panel__select"
value={draftPlanTemplateId}
onChange={(e) => onDraftTemplateSelect(e.target.value)}
>
<option value="">Ohne Vorlage leere Gliederung (ein Abschnitt)</option>
{planTemplates.map((t) => {
const v = String(t.visibility || 'club').toLowerCase()
const vLabel = v === 'private' ? 'Privat' : v === 'official' ? 'Offiziell' : 'Verein'
return (
<option key={t.id} value={String(t.id)}>
{t.name}
{typeof t.sections_count === 'number' ? ` · ${t.sections_count} Abschn.` : ''} · {vLabel}
</option>
)
})}
</select>
<p className="training-planning-template-panel__help">
Übernimmt nur die <strong>Sektionsstruktur</strong> aus der Bibliothek; Übungen trägst du unten bei den
Abschnitten ein. Vorlagen verwaltest du unter{' '}
<Link to="/planning/plan-templates">Planung Vorlagen</Link>.
</p>
</div>
)}
<h3 style={{ marginBottom: '1rem' }}>Planung</h3>
<div className="responsive-grid-3" style={{ marginBottom: '1rem' }}>
<div className="form-row">
<label className="form-label">Datum *</label>
<input
type="date"
className="form-input"
value={formData.planned_date}
onChange={(e) => updateFormField('planned_date', e.target.value)}
required
/>
</div>
<div className="form-row">
<label className="form-label">Von</label>
<input
type="time"
className="form-input"
value={formData.planned_time_start}
onChange={(e) => updateFormField('planned_time_start', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Bis</label>
<input
type="time"
className="form-input"
value={formData.planned_time_end}
onChange={(e) => updateFormField('planned_time_end', e.target.value)}
/>
</div>
</div>
<div className="form-row" style={{ marginBottom: '1rem' }}>
<label className="form-label">Geplante Gesamtdauer</label>
<input
type="text"
className="form-input"
inputMode="decimal"
value={formData.planned_duration_min}
onChange={(e) => updateFormField('planned_duration_min', e.target.value)}
placeholder="z. B. 1,5 oder 90 (Minuten)"
/>
<p className="form-sub" style={{ marginTop: '0.35rem' }}>
{plannedTotalParsed != null ? (
<>
Eingabe: <strong>{formatDurationDisplay(plannedTotalParsed)}</strong>
</>
) : (
'Stunden mit Komma/Punkt (1,5 = 90 Min) oder Minuten ab 8.'
)}
{sumFromSections > 0 || sumFromExercises > 0 ? (
<>
{' '}
· Summe Abschnitte: <strong>{formatDurationDisplay(sumFromSections)}</strong>
{sumFromExercises > 0 && sumFromExercises !== sumFromSections ? (
<>
{' '}
· Übungen: <strong>{formatDurationDisplay(sumFromExercises)}</strong>
</>
) : null}
</>
) : null}
</p>
</div>
<div className="form-row">
<label className="form-label">Trainingsfokus</label>
<input
type="text"
className="form-input"
value={formData.planned_focus}
onChange={(e) => updateFormField('planned_focus', e.target.value)}
placeholder="z.B. Grundlagen, Kinder altersgerecht"
/>
</div>
<div
className="card"
style={{
marginTop: '1.25rem',
marginBottom: '0.25rem',
padding: '12px 14px',
background: 'var(--surface2)',
}}
>
<h3 style={{ margin: '0 0 10px', fontSize: '1rem' }}>Trainerzuordnung (diese Einheit)</h3>
<div className="form-row">
<label className="form-label">Leitung</label>
<select
className="form-input"
value={formData.lead_trainer_profile_id}
onChange={(e) => updateFormField('lead_trainer_profile_id', e.target.value)}
disabled={!editingUnit && !formData.group_id}
>
<option value="">Standard (Haupttrainer der Gruppe)</option>
{clubDirectory.map((m) => (
<option key={String(m.id)} value={String(m.id)}>
{(m.name || '').trim() || m.email || `Profil ${m.id}`}
</option>
))}
</select>
</div>
<div className="form-row" style={{ marginTop: '0.75rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.session_assistants_inherit}
onChange={(e) => updateFormField('session_assistants_inherit', e.target.checked)}
/>
<span style={{ fontSize: '0.9rem', color: 'var(--text1)' }}>
Co-Trainer wie in der Trainingsgruppe (Standard)
</span>
</label>
</div>
{!formData.session_assistants_inherit ? (
<div style={{ marginTop: '10px', maxHeight: '200px', overflowY: 'auto' }}>
{clubDirectoryForCo.map((m) => {
const mid = typeof m.id === 'number' ? m.id : parseInt(String(m.id), 10)
const isOn =
Number.isFinite(mid) && formData.session_assistant_profile_ids.includes(mid)
return (
<label
key={`co-${mid}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '0.875rem',
marginBottom: '6px',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={isOn}
onChange={() => {
setFormData((prev) => {
const was = prev.session_assistant_profile_ids.includes(mid)
const nextIds = was
? prev.session_assistant_profile_ids.filter((x) => x !== mid)
: [...prev.session_assistant_profile_ids, mid].sort((a, b) => a - b)
return { ...prev, session_assistant_profile_ids: nextIds }
})
}}
/>
<span>{(m.name || '').trim() || m.email || `Profil ${mid}`}</span>
</label>
)
})}
</div>
) : null}
</div>
<TrainingPlanExerciseVisibilityPanel
sections={formData.sections}
targetClubId={planningClubId}
user={user}
onMetaRefresh={onMetaRefresh}
/>
<div style={{ marginTop: '2rem' }}>
{editingUnit ? (
<div style={{ marginBottom: '1rem' }}>
<div
role="radiogroup"
aria-label="Modus für Abschnitte und Übungen"
style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '10px' }}
>
<span className="form-label" style={{ marginBottom: 0, fontSize: '0.82rem' }}>
Ablauf bearbeiten als
</span>
<div
style={{
display: 'inline-flex',
borderRadius: '10px',
border: '1.5px solid var(--border2)',
overflow: 'hidden',
background: 'var(--surface2)',
}}
>
{[
{ id: 'planning', label: 'Planung' },
{ id: 'debrief', label: 'Nachbereitung' },
].map((opt, i) => (
<button
key={opt.id}
type="button"
role="radio"
aria-checked={sectionsEditMode === opt.id}
onClick={() => setSectionsEditMode(opt.id)}
style={{
border: 'none',
padding: '8px 14px',
fontWeight: 600,
fontSize: '0.85rem',
cursor: 'pointer',
background: sectionsEditMode === opt.id ? 'var(--accent-dark)' : 'transparent',
color: sectionsEditMode === opt.id ? '#fff' : 'var(--text1)',
...(i > 0 ? { borderLeft: '1.5px solid var(--border2)' } : {}),
}}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
) : null}
<TrainingUnitSectionsEditor
heading="Abschnitte & Übungen"
headingAccessory={
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'flex-end',
gap: '10px',
marginBottom: '10px',
}}
>
<div className="form-row" style={{ marginBottom: 0, minWidth: 'min(160px, 100%)' }}>
<label className="form-label" style={{ fontSize: '0.82rem' }}>
Neue Vorlage: Sichtbarkeit
</label>
<select
className="form-input"
value={newTplVisibility}
onChange={(e) => {
const v = e.target.value
setNewTplVisibility(v)
if (v === 'club' && !newTplClubId && planningClubId != null) {
setNewTplClubId(String(planningClubId))
}
}}
>
<option value="private">Privat (nur du)</option>
<option value="club">Verein</option>
{isSuperadmin ? <option value="official">Offiziell (global)</option> : null}
</select>
</div>
{newTplVisibility === 'club' ? (
<div className="form-row" style={{ marginBottom: 0, flex: '1 1 200px' }}>
<label className="form-label" style={{ fontSize: '0.82rem' }}>
Verein
</label>
<select
className="form-input"
value={newTplClubId}
onChange={(e) => setNewTplClubId(e.target.value)}
>
<option value=""> Verein wählen </option>
{memberClubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || `Verein #${c.id}`}
</option>
))}
</select>
</div>
) : null}
<button
type="button"
className="btn btn-secondary"
onClick={() =>
onSaveAsTemplate?.({
visibility: newTplVisibility,
club_id:
newTplVisibility === 'club' && newTplClubId
? parseInt(newTplClubId, 10)
: null,
})
}
>
Vorlage aus Aufbau speichern
</button>
{editingUnit?.id && !editingUnit?.framework_slot_id ? (
<>
<button type="button" className="btn btn-secondary" onClick={() => onRequestPublishToFramework?.()}>
Als Rahmen-Session speichern
</button>
<button type="button" className="btn btn-secondary" onClick={() => onRequestSaveAsModule?.()}>
Übungen als Modul
</button>
</>
) : null}
</div>
}
sections={formData.sections}
wideExerciseGrid
onSectionsChange={(updater) =>
setFormData((prev) => ({ ...prev, sections: updater(prev.sections) }))
}
onRequestTrainingModulePick={onRequestTrainingModulePick}
onRequestExercisePick={onRequestExercisePick}
onPeekExercise={onPeekExercise}
showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'}
enableParallelPhaseControls
/>
</div>
{editingUnit ? (
<>
<h3 style={{ marginTop: '2rem', marginBottom: '1rem' }}>Durchführung</h3>
<div className="responsive-grid-4" style={{ marginBottom: '1rem' }}>
<div className="form-row">
<label className="form-label">Tatsächliches Datum</label>
<input
type="date"
className="form-input"
value={formData.actual_date}
onChange={(e) => updateFormField('actual_date', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Von</label>
<input
type="time"
className="form-input"
value={formData.actual_time_start}
onChange={(e) => updateFormField('actual_time_start', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Bis</label>
<input
type="time"
className="form-input"
value={formData.actual_time_end}
onChange={(e) => updateFormField('actual_time_end', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Teilnehmer</label>
<input
type="number"
className="form-input"
value={formData.attendance_count}
onChange={(e) => updateFormField('attendance_count', e.target.value)}
/>
</div>
</div>
<div className="form-row">
<label className="form-label">Status</label>
<select
className="form-input"
value={formData.status}
onChange={(e) => updateFormField('status', e.target.value)}
>
<option value="planned">Geplant</option>
<option value="completed">Durchgeführt</option>
<option value="cancelled">Abgesagt</option>
</select>
</div>
{formData.status === 'completed' ? (
<div className="form-row" style={{ marginTop: '0.75rem' }}>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '10px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!formData.debrief_completed}
onChange={(e) => updateFormField('debrief_completed', e.target.checked)}
style={{ marginTop: '3px' }}
/>
<span>
<strong>Rückschau erledigt</strong>
</span>
</label>
</div>
) : null}
</>
) : null}
<h3 style={{ marginTop: '2rem', marginBottom: '1rem' }}>Notizen</h3>
<div className="form-row">
<label className="form-label">Öffentliche Notizen</label>
<textarea
className="form-input"
rows={3}
value={formData.notes}
onChange={(e) => updateFormField('notes', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Trainernotizen</label>
<textarea
className="form-input"
rows={3}
value={formData.trainer_notes}
onChange={(e) => updateFormField('trainer_notes', e.target.value)}
/>
</div>
</div>
</form>
)
}