feat: enhance training framework programs and planning features
Some checks failed
Deploy Development / deploy (push) Successful in 41s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 40s

- Added aggregation of training type names and target group names in the training framework programs API response for improved data presentation.
- Implemented origin framework slot tracking in the training planning module, allowing users to import training units from framework programs.
- Enhanced the TrainingFrameworkProgramsListPage to display aggregated training type and target group information, improving user experience.
- Introduced a modal for importing framework programs into training planning, streamlining the process of managing training units.
This commit is contained in:
Lars 2026-05-05 15:03:54 +02:00
parent 86192df508
commit 6dcbc8c610
4 changed files with 499 additions and 29 deletions

View File

@ -295,7 +295,19 @@ def list_training_framework_programs(session=Depends(require_auth)):
(SELECT COUNT(*)::int FROM training_framework_program_training_types t
WHERE t.framework_program_id = fp.id) AS training_types_count,
(SELECT COUNT(*)::int FROM training_framework_program_target_groups tg
WHERE tg.framework_program_id = fp.id) AS target_groups_count
WHERE tg.framework_program_id = fp.id) AS target_groups_count,
(
SELECT STRING_AGG(typ.name::text, ', ' ORDER BY typ.sort_order NULLS LAST, typ.name)
FROM training_framework_program_training_types j
JOIN training_types typ ON typ.id = j.training_type_id
WHERE j.framework_program_id = fp.id
) AS training_type_names_agg,
(
SELECT STRING_AGG(tg.name::text, ', ' ORDER BY tg.name)
FROM training_framework_program_target_groups j
JOIN target_groups tg ON tg.id = j.target_group_id
WHERE j.framework_program_id = fp.id
) AS target_group_names_agg
FROM training_framework_programs fp
LEFT JOIN focus_areas fa ON fa.id = fp.focus_area_id
LEFT JOIN style_directions sd ON sd.id = fp.style_direction_id

View File

@ -112,6 +112,19 @@ def _assert_delete_training_unit(role: str, created_by: int, profile_id: int) ->
raise HTTPException(status_code=403, detail="Keine Berechtigung")
# Nachverfolgbarkeit: Übernahmen aus Rahmenprogramm über origin_framework_slot_id
_ORIGIN_LINEAGE_JOIN = """
LEFT JOIN training_framework_slots origin_slot ON origin_slot.id = tu.origin_framework_slot_id
LEFT JOIN training_framework_programs origin_fp ON origin_fp.id = origin_slot.framework_program_id
"""
_ORIGIN_LINEAGE_FIELDS = """
origin_fp.id AS origin_framework_program_id,
origin_fp.title AS origin_framework_program_title,
COALESCE(TRIM(origin_slot.title), '') AS origin_framework_slot_title,
origin_slot.sort_order AS origin_framework_slot_sort_order
"""
def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
cur.execute(
"""
@ -636,12 +649,15 @@ def list_training_units(
tg.name as group_name,
tg.weekday as group_weekday,
c.name as club_name,
p.name as trainer_name
p.name as trainer_name"""
query += "," + _ORIGIN_LINEAGE_FIELDS
query += """
FROM training_units tu
LEFT JOIN training_groups tg ON tu.group_id = tg.id
LEFT JOIN clubs c ON tg.club_id = c.id
LEFT JOIN profiles p ON tu.created_by = p.id
"""
query += _ORIGIN_LINEAGE_JOIN
where = []
params = []
@ -695,11 +711,13 @@ def get_training_unit(unit_id: int, session=Depends(require_auth)):
tg.time_end as group_time_end,
tg.location as group_location,
c.name as club_name,
p.name as trainer_name
p.name as trainer_name,
""" + _ORIGIN_LINEAGE_FIELDS.strip() + """
FROM training_units tu
LEFT JOIN training_groups tg ON tu.group_id = tg.id
LEFT JOIN clubs c ON tg.club_id = c.id
LEFT JOIN profiles p ON tu.created_by = p.id
""" + _ORIGIN_LINEAGE_JOIN.strip() + """
WHERE tu.id = %s
""",
(unit_id,),

View File

@ -2,21 +2,58 @@ import React, { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
const TYPE_COUNT = (r) =>
typeof r.training_types_count === 'number' ? r.training_types_count : null
const TG_COUNT = (r) =>
typeof r.target_groups_count === 'number' ? r.target_groups_count : null
function contextTeaser(r) {
const bits = []
if (r.focus_area_name) bits.push(r.focus_area_name)
if (r.style_direction_name) bits.push(r.style_direction_name)
const tn = TYPE_COUNT(r)
const gn = TG_COUNT(r)
if (tn != null && tn > 0) bits.push(`${tn} Trainingsart${tn === 1 ? '' : 'en'}`)
if (gn != null && gn > 0) bits.push(`${gn} Zielgruppe${gn === 1 ? '' : 'n'}`)
return bits.length ? bits.join(' · ') : null
function dashIfEmpty(val) {
const s = (val ?? '').toString().trim()
return s.length ? s : '—'
}
function FrameworkSummaryMeta({ r }) {
const trainingTypes =
typeof r.training_type_names_agg === 'string' ? r.training_type_names_agg.trim() : ''
const targetGroups =
typeof r.target_group_names_agg === 'string' ? r.target_group_names_agg.trim() : ''
const styleDir = typeof r.style_direction_name === 'string' ? r.style_direction_name.trim() : ''
const focus = typeof r.focus_area_name === 'string' ? r.focus_area_name.trim() : ''
const rowStyle = {
display: 'grid',
gridTemplateColumns: 'minmax(6.5rem, 32%) 1fr',
gap: '0.25rem 0.75rem',
alignItems: 'start',
marginTop: '0.35rem',
lineHeight: 1.45,
}
return (
<dl style={{ margin: '0.5rem 0 0', padding: 0, fontSize: '0.875rem', color: 'var(--text2)' }}>
<div style={rowStyle}>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Fokusbereich</dt>
<dd style={{ margin: 0 }}>{dashIfEmpty(focus)}</dd>
</div>
{styleDir ? (
<div style={rowStyle}>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Stilrichtung</dt>
<dd style={{ margin: 0 }}>{styleDir}</dd>
</div>
) : null}
<div style={rowStyle}>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Trainingsarten</dt>
<dd style={{ margin: 0 }}>{trainingTypes.length ? trainingTypes : '—'}</dd>
</div>
<div style={rowStyle}>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Zielgruppen</dt>
<dd style={{ margin: 0 }}>{targetGroups.length ? targetGroups : '—'}</dd>
</div>
<div style={{ ...rowStyle, marginTop: '0.5rem' }}>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Kurzbeschreibung</dt>
<dd style={{ margin: 0, whiteSpace: 'pre-wrap' }}>
{(r.description && String(r.description).trim()) || '—'}
</dd>
</div>
</dl>
)
}
export default function TrainingFrameworkProgramsListPage() {
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true)
@ -123,7 +160,7 @@ export default function TrainingFrameworkProgramsListPage() {
gap: '0.75rem',
}}
>
<div>
<div style={{ minWidth: 0, flex: '1 1 220px' }}>
<Link
to={`/planning/framework-programs/${r.id}`}
style={{ fontWeight: 600, fontSize: '1.05rem', color: 'var(--text1)' }}
@ -132,17 +169,11 @@ export default function TrainingFrameworkProgramsListPage() {
</Link>
<div style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
<span>
{r.goals_count ?? '—'} Ziele · {r.slots_count ?? '—'} Slots
{(r.goals_count ?? '—') + ' Ziele · '}
{(r.slots_count ?? '—') + ' Slots'}
</span>
{contextTeaser(r) ? (
<span style={{ display: 'block', marginTop: '0.25rem', color: 'var(--text3)' }}>
{contextTeaser(r)}
</span>
) : null}
</div>
{r.description ? (
<p style={{ marginTop: '0.5rem', fontSize: '0.9rem', color: 'var(--text2)' }}>{r.description}</p>
) : null}
<FrameworkSummaryMeta r={r} />
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
<Link

View File

@ -13,6 +13,12 @@ import {
hydrateExercisePlanningRow,
} from '../utils/trainingUnitSectionsForm'
function addDaysIsoDate(isoDay, daysDelta) {
const d = new Date(`${isoDay}T12:00:00`)
d.setDate(d.getDate() + daysDelta)
return d.toISOString().slice(0, 10)
}
function TrainingPlanningPage() {
const { user } = useAuth()
const [groups, setGroups] = useState([])
@ -31,6 +37,17 @@ function TrainingPlanningPage() {
const today = new Date().toISOString().split('T')[0]
const thirtyDaysLater = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
const [frameworkImportOpen, setFrameworkImportOpen] = useState(false)
const [frameworkProgramsList, setFrameworkProgramsList] = useState([])
const [fwImportProgramId, setFwImportProgramId] = useState('')
const [fwImportDetail, setFwImportDetail] = useState(null)
const [fwImportLoading, setFwImportLoading] = useState(false)
const [fwImportSelectedSlots, setFwImportSelectedSlots] = useState(() => new Set())
const [fwImportSlotDates, setFwImportSlotDates] = useState({})
const [fwImportStartDate, setFwImportStartDate] = useState(today)
const [fwImportIntervalDays, setFwImportIntervalDays] = useState(7)
const [fwImportSubmitting, setFwImportSubmitting] = useState(false)
const [startDate, setStartDate] = useState(today)
const [endDate, setEndDate] = useState(thirtyDaysLater)
@ -60,6 +77,137 @@ function TrainingPlanningPage() {
}
}, [selectedGroupId, startDate, endDate])
useEffect(() => {
if (!frameworkImportOpen) return
let cancelled = false
;(async () => {
try {
const list = await api.listTrainingFrameworkPrograms()
if (!cancelled) setFrameworkProgramsList(Array.isArray(list) ? list : [])
} catch (e) {
if (!cancelled) {
console.error('Rahmenprogramme laden:', e)
setFrameworkProgramsList([])
}
}
})()
return () => {
cancelled = true
}
}, [frameworkImportOpen])
const openFrameworkImportModal = useCallback(() => {
setFwImportProgramId('')
setFwImportDetail(null)
setFwImportSelectedSlots(new Set())
setFwImportSlotDates({})
setFwImportStartDate(new Date().toISOString().split('T')[0])
setFwImportIntervalDays(7)
setFrameworkImportOpen(true)
}, [])
const onFwImportProgramChange = async (idStr) => {
setFwImportProgramId(idStr)
if (!idStr) {
setFwImportDetail(null)
return
}
setFwImportLoading(true)
try {
const d = await api.getTrainingFrameworkProgram(parseInt(idStr, 10))
setFwImportDetail(d)
setFwImportSelectedSlots(new Set())
setFwImportSlotDates({})
} catch (e) {
alert(e.message || 'Rahmenprogramm laden fehlgeschlagen')
setFwImportDetail(null)
} finally {
setFwImportLoading(false)
}
}
const toggleFwImportSlot = (slot) => {
if (!slot?.blueprint_training_unit_id) return
const sid = slot.id
setFwImportSelectedSlots((prev) => {
const n = new Set(prev)
if (n.has(sid)) n.delete(sid)
else n.add(sid)
return n
})
}
const applyFwImportDateSuggestions = () => {
if (!fwImportDetail?.slots?.length) return
const sorted = [...fwImportDetail.slots].sort(
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)
)
let offset = 0
const iv = Math.max(0, Number(fwImportIntervalDays) || 0)
const next = {}
for (const s of sorted) {
if (!fwImportSelectedSlots.has(s.id)) continue
if (!s.blueprint_training_unit_id) continue
next[String(s.id)] = addDaysIsoDate(fwImportStartDate, offset)
offset += iv
}
setFwImportSlotDates((prev) => ({ ...prev, ...next }))
}
const submitFrameworkImport = async () => {
if (!selectedGroupId) {
alert('Bitte zuerst eine Trainingsgruppe wählen.')
return
}
const gid = parseInt(selectedGroupId, 10)
if (!fwImportDetail?.slots?.length) return
const sorted = [...fwImportDetail.slots].sort(
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)
)
const picks = sorted.filter(
(s) => fwImportSelectedSlots.has(s.id) && s.blueprint_training_unit_id
)
if (!picks.length) {
alert('Bitte mindestens eine Session mit Ablauf (Blueprint) auswählen.')
return
}
for (const s of picks) {
const key = String(s.id)
const date = fwImportSlotDates[key] || fwImportStartDate
if (!date) {
alert('Bitte für jede ausgewählte Session ein Datum setzen (oder Datumsvorschläge nutzen).')
return
}
}
setFwImportSubmitting(true)
try {
for (const s of picks) {
const key = String(s.id)
const date = fwImportSlotDates[key] || fwImportStartDate
await api.createTrainingUnitFromFrameworkSlot({
group_id: gid,
planned_date: date,
framework_slot_id: s.id,
})
}
setFrameworkImportOpen(false)
await loadUnits()
} catch (e) {
alert(e.message || 'Übernahme fehlgeschlagen')
} finally {
setFwImportSubmitting(false)
}
}
const frameworkLineageText = (unit) => {
const fpTitle = (unit.origin_framework_program_title || '').trim() || 'Rahmenprogramm'
const st = (unit.origin_framework_slot_title || '').trim()
const idx = unit.origin_framework_slot_sort_order
const slotBit =
st || (typeof idx === 'number' ? `Session ${idx + 1}` : 'Session')
return { fpTitle, slotBit, fpId: unit.origin_framework_program_id }
}
const loadPlanTemplates = useCallback(async () => {
try {
const tpl = await api.listTrainingPlanTemplates()
@ -457,6 +605,15 @@ function TrainingPlanningPage() {
Schnell erstellen
</button>
</div>
<button
type="button"
className="btn btn-secondary"
disabled={!selectedGroupId}
title={!selectedGroupId ? 'Zuerst eine Trainingsgruppe wählen' : undefined}
onClick={openFrameworkImportModal}
>
Aus Rahmen übernehmen
</button>
</div>
</div>
</div>
@ -476,7 +633,9 @@ function TrainingPlanningPage() {
</div>
) : (
<div style={{ display: 'grid', gap: '1rem' }}>
{units.map((unit) => (
{units.map((unit) => {
const lineage = unit.origin_framework_slot_id ? frameworkLineageText(unit) : null
return (
<div key={unit.id} className="card">
<div
style={{
@ -505,6 +664,29 @@ function TrainingPlanningPage() {
Fokus: {unit.planned_focus}
</p>
)}
{lineage ? (
<p
style={{
color: 'var(--text2)',
fontSize: '0.82rem',
marginBottom: '0.5rem',
lineHeight: 1.45,
}}
>
<span style={{ fontWeight: 600, color: 'var(--text3)' }}>Aus Rahmen: </span>
{unit.origin_framework_program_id ? (
<Link
to={`/planning/framework-programs/${unit.origin_framework_program_id}`}
style={{ color: 'var(--accent-dark)' }}
>
{lineage.fpTitle}
</Link>
) : (
<span>{lineage.fpTitle}</span>
)}
<span style={{ color: 'var(--text2)' }}> · {lineage.slotBit}</span>
</p>
) : null}
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<span
style={{
@ -586,7 +768,201 @@ function TrainingPlanningPage() {
</p>
)}
</div>
))}
)
})}
</div>
)}
{frameworkImportOpen && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1010,
padding: '1rem',
overflowY: 'auto',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: 'clamp(14px, 3vw, 1.75rem)',
maxWidth: 'min(620px, 100%)',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxSizing: 'border-box',
minWidth: 0,
}}
>
<h2 style={{ marginBottom: '0.65rem' }}>Sessions aus Rahmen übernehmen</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', marginBottom: '1rem', lineHeight: 1.5 }}>
Wähle ein Trainingsrahmenprogramm und eine oder mehrere Sessions. Pro Session entsteht eine{' '}
<strong>eigene geplante Einheit</strong> in der aktuellen Gruppe (Kopie des Ablaufs). Die{' '}
<strong>Verknüpfung zum Rahmen-Slot</strong> wird gespeichert, damit die Herkunft sichtbar bleibt.
</p>
<div className="form-row">
<label className="form-label">Rahmenprogramm</label>
<select
className="form-input"
value={fwImportProgramId}
onChange={(e) => onFwImportProgramChange(e.target.value)}
disabled={fwImportLoading || fwImportSubmitting}
>
<option value="">Bitte wählen</option>
{frameworkProgramsList.map((fp) => (
<option key={fp.id} value={String(fp.id)}>
{(fp.title || '').trim() || `Rahmen #${fp.id}`}
</option>
))}
</select>
</div>
{fwImportLoading ? (
<p style={{ color: 'var(--text2)', marginTop: '1rem' }}>Laden der Sessions</p>
) : fwImportDetail?.slots?.length ? (
<>
<fieldset style={{ border: 'none', margin: '1rem 0', padding: 0 }}>
<legend className="form-label" style={{ padding: 0, marginBottom: '0.5rem' }}>
Sessions (mit Ablauf)
</legend>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{[...fwImportDetail.slots]
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
.map((slot) => {
const hasBp = !!slot.blueprint_training_unit_id
const checked = fwImportSelectedSlots.has(slot.id)
const label =
(slot.title || '').trim() ||
`Session ${(slot.sort_order ?? 0) + 1}`
return (
<li key={slot.id} style={{ marginBottom: '10px' }}>
<label
style={{
display: 'flex',
gap: '10px',
alignItems: 'flex-start',
cursor: hasBp ? 'pointer' : 'not-allowed',
opacity: hasBp ? 1 : 0.55,
}}
>
<input
type="checkbox"
checked={checked}
disabled={!hasBp || fwImportSubmitting}
onChange={() => toggleFwImportSlot(slot)}
style={{ marginTop: '0.2rem', flexShrink: 0 }}
/>
<span style={{ flex: 1, minWidth: 0 }}>
<strong>{label}</strong>
{!hasBp ? (
<span style={{ display: 'block', fontSize: '0.82rem', color: 'var(--danger)' }}>
Ohne Session-Ablauf Übernahme nicht möglich.
</span>
) : null}
{hasBp && checked ? (
<span style={{ display: 'block', marginTop: '6px' }}>
<span className="form-label" style={{ fontSize: '0.78rem' }}>
Termin (Datum)
</span>
<input
type="date"
className="form-input"
style={{ maxWidth: '200px', marginTop: '4px' }}
value={fwImportSlotDates[String(slot.id)] || ''}
onChange={(e) =>
setFwImportSlotDates((prev) => ({
...prev,
[String(slot.id)]: e.target.value,
}))
}
disabled={fwImportSubmitting}
/>
</span>
) : null}
</span>
</label>
</li>
)
})}
</ul>
</fieldset>
<div
className="responsive-grid-3"
style={{
marginBottom: '0.75rem',
padding: '12px',
background: 'var(--surface2)',
borderRadius: '8px',
}}
>
<div className="form-row">
<label className="form-label">Startdatum (Vorschlag)</label>
<input
type="date"
className="form-input"
value={fwImportStartDate}
onChange={(e) => setFwImportStartDate(e.target.value)}
disabled={fwImportSubmitting}
/>
</div>
<div className="form-row">
<label className="form-label">Abstand (Tage)</label>
<input
type="number"
min={0}
className="form-input"
value={fwImportIntervalDays}
onChange={(e) => setFwImportIntervalDays(parseInt(e.target.value, 10) || 0)}
disabled={fwImportSubmitting}
/>
</div>
<div className="form-row" style={{ alignSelf: 'end' }}>
<button
type="button"
className="btn btn-secondary"
style={{ width: '100%' }}
disabled={fwImportSubmitting}
onClick={applyFwImportDateSuggestions}
>
Datumsvorschläge setzen
</button>
</div>
</div>
</>
) : fwImportProgramId ? (
<p style={{ color: 'var(--text2)', marginTop: '0.75rem' }}>Keine Sessions in diesem Programm.</p>
) : null}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginTop: '1.25rem' }}>
<button
type="button"
className="btn btn-primary"
disabled={fwImportSubmitting || !fwImportDetail}
onClick={submitFrameworkImport}
>
{fwImportSubmitting ? 'Übernehmen…' : 'In Planung übernehmen'}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={fwImportSubmitting}
onClick={() => setFrameworkImportOpen(false)}
>
Abbrechen
</button>
</div>
</div>
</div>
)}
@ -625,6 +1001,39 @@ function TrainingPlanningPage() {
{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
</h2>
{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="form-row" style={{ marginBottom: '1.25rem' }}>
<label className="form-label">Gliederungsvorlage (optional)</label>