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
+
+ Aus Rahmen übernehmen…
+
@@ -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.
+
+
+
+ Rahmenprogramm
+ onFwImportProgramChange(e.target.value)}
+ disabled={fwImportLoading || fwImportSubmitting}
+ >
+ Bitte wählen…
+ {frameworkProgramsList.map((fp) => (
+
+ {(fp.title || '').trim() || `Rahmen #${fp.id}`}
+
+ ))}
+
+
+
+ {fwImportLoading ? (
+
Laden der Sessions…
+ ) : fwImportDetail?.slots?.length ? (
+ <>
+
+
+ Sessions (mit Ablauf)
+
+
+
+
+
+
+ Startdatum (Vorschlag)
+ setFwImportStartDate(e.target.value)}
+ disabled={fwImportSubmitting}
+ />
+
+
+ Abstand (Tage)
+ setFwImportIntervalDays(parseInt(e.target.value, 10) || 0)}
+ disabled={fwImportSubmitting}
+ />
+
+
+
+ Datumsvorschläge setzen
+
+
+
+ >
+ ) : fwImportProgramId ? (
+
Keine Sessions in diesem Programm.
+ ) : null}
+
+
+
+ {fwImportSubmitting ? 'Übernehmen…' : 'In Planung übernehmen'}
+
+ setFrameworkImportOpen(false)}
+ >
+ Abbrechen
+
+
+
)}
@@ -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 && (
Gliederungsvorlage (optional)