From 6dcbc8c610df4583432baed5b1d31620b871b21a Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 5 May 2026 15:03:54 +0200 Subject: [PATCH] feat: enhance training framework programs and planning features - 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. --- .../routers/training_framework_programs.py | 14 +- backend/routers/training_planning.py | 22 +- .../TrainingFrameworkProgramsListPage.jsx | 79 +++- frontend/src/pages/TrainingPlanningPage.jsx | 413 +++++++++++++++++- 4 files changed, 499 insertions(+), 29 deletions(-) diff --git a/backend/routers/training_framework_programs.py b/backend/routers/training_framework_programs.py index 2785ed0..459f7f1 100644 --- a/backend/routers/training_framework_programs.py +++ b/backend/routers/training_framework_programs.py @@ -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 diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index 9fd7e1d..fd31bdf 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -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,), diff --git a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx index 36f5550..cd356f3 100644 --- a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx +++ b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx @@ -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 ( +
+
+
Fokusbereich
+
{dashIfEmpty(focus)}
+
+ {styleDir ? ( +
+
Stilrichtung
+
{styleDir}
+
+ ) : null} +
+
Trainingsarten
+
{trainingTypes.length ? trainingTypes : '—'}
+
+
+
Zielgruppen
+
{targetGroups.length ? targetGroups : '—'}
+
+
+
Kurzbeschreibung
+
+ {(r.description && String(r.description).trim()) || '—'} +
+
+
+ ) +} + export default function TrainingFrameworkProgramsListPage() { const [rows, setRows] = useState([]) const [loading, setLoading] = useState(true) @@ -123,7 +160,7 @@ export default function TrainingFrameworkProgramsListPage() { gap: '0.75rem', }} > -
+
- {r.goals_count ?? '—'} Ziele · {r.slots_count ?? '—'} Slots + {(r.goals_count ?? '—') + ' Ziele · '} + {(r.slots_count ?? '—') + ' Slots'} - {contextTeaser(r) ? ( - - {contextTeaser(r)} - - ) : null}
- {r.description ? ( -

{r.description}

- ) : null} +
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
+
@@ -476,7 +633,9 @@ function TrainingPlanningPage() { ) : (
- {units.map((unit) => ( + {units.map((unit) => { + const lineage = unit.origin_framework_slot_id ? frameworkLineageText(unit) : null + return (
)} + {lineage ? ( +

+ Aus Rahmen: + {unit.origin_framework_program_id ? ( + + {lineage.fpTitle} + + ) : ( + {lineage.fpTitle} + )} + · {lineage.slotBit} +

+ ) : null}
)}
- ))} + ) + })} +
+ )} + + {frameworkImportOpen && ( +
+
+

Sessions aus Rahmen übernehmen

+

+ Wähle ein Trainingsrahmenprogramm und eine oder mehrere Sessions. Pro Session entsteht eine{' '} + eigene geplante Einheit in der aktuellen Gruppe (Kopie des Ablaufs). Die{' '} + Verknüpfung zum Rahmen-Slot wird gespeichert, damit die Herkunft sichtbar bleibt. +

+ +
+ + +
+ + {fwImportLoading ? ( +

Laden der Sessions…

+ ) : fwImportDetail?.slots?.length ? ( + <> +
+ + Sessions (mit Ablauf) + +
    + {[...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 ( +
  • + +
  • + ) + })} +
+
+ +
+
+ + setFwImportStartDate(e.target.value)} + disabled={fwImportSubmitting} + /> +
+
+ + setFwImportIntervalDays(parseInt(e.target.value, 10) || 0)} + disabled={fwImportSubmitting} + /> +
+
+ +
+
+ + ) : fwImportProgramId ? ( +

Keine Sessions in diesem Programm.

+ ) : null} + +
+ + +
+
)} @@ -625,6 +1001,39 @@ function TrainingPlanningPage() { {editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'} + {editingUnit?.origin_framework_slot_id ? (() => { + const L = frameworkLineageText(editingUnit) + return ( +
+ Herkunft:{' '} + {editingUnit.origin_framework_program_id ? ( + + {L.fpTitle} + + ) : ( + L.fpTitle + )} + · {L.slotBit} +

+ Inhalt stammt aus dem Session-Blueprint des Rahmenprogramms. Änderungen gelten nur für diese + geplante Einheit; die Zuordnung zum Rahmen bleibt zur Nachverfolgung erhalten. +

+
+ ) + })() : null} + {!editingUnit && (