diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index ddc05f7..d08757d 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -1857,7 +1857,26 @@ def list_training_plan_templates(tenant: TenantContext = Depends(get_tenant_cont f""" SELECT t.*, (SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id) - AS sections_count + AS sections_count, + COALESCE( + ( + SELECT json_agg( + json_build_object( + 'id', s.id, + 'order_index', s.order_index, + 'title', s.title, + 'guidance_text', s.guidance_text, + 'phase_kind', s.phase_kind, + 'phase_order_index', s.phase_order_index, + 'parallel_stream_order_index', s.parallel_stream_order_index + ) + ORDER BY s.order_index + ) + FROM training_plan_template_sections s + WHERE s.template_id = t.id + ), + '[]'::json + ) AS sections FROM training_plan_templates t WHERE ({vis_clause}) ORDER BY t.updated_at DESC NULLS LAST, t.name diff --git a/backend/version.py b/backend/version.py index 0855d21..e9bf313 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.147" +APP_VERSION = "0.8.148" BUILD_DATE = "2026-05-19" DB_SCHEMA_VERSION = "20260516065" @@ -24,7 +24,7 @@ MODULE_VERSIONS = { "exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_programs": "0.1.0", - "planning": "0.14.0", # publish-to-framework; UI Rahmen-Session aus Planung + "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung "dashboard": "1.1.0", # GET /api/dashboard/kpis inkl. training_home (ein Client-Roundtrip für KPIs + nächste Termine) "training_modules": "1.1.0", # PUT/DELETE: assert_library_content_* (Vereinsadmin löscht Vereins-Inhalt, Trainer bearbeitet club wie Übungen) "import_wiki": "1.0.3", # Default-Kategorie Fähigkeiten: Fähigkeitsbeschreibung; cmtitle-Normalisierung; UI Preview/Execute Defaults je Typ @@ -36,6 +36,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.148", + "date": "2026-05-19", + "changes": [ + "Planung Vorlagen: Strukturvorschau (Split-Sessions), Kurzbeschreibung, Bearbeitungsseite; Liste liefert sections[] mit", + ], + }, { "version": "0.8.147", "date": "2026-05-19", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f529dd7..fbabf06 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -31,6 +31,7 @@ const InboxPage = lazy(() => import('./pages/InboxPage')) const SkillsPage = lazy(() => import('./pages/SkillsPage')) const TrainingPlanningPage = lazy(() => import('./pages/TrainingPlanningPage')) const TrainingPlanTemplatesListPage = lazy(() => import('./pages/TrainingPlanTemplatesListPage')) +const TrainingPlanTemplateEditPage = lazy(() => import('./pages/TrainingPlanTemplateEditPage')) const PlanningLayout = lazy(() => import('./layouts/PlanningLayout')) const TrainingFrameworkProgramsListPage = lazy(() => import('./pages/TrainingFrameworkProgramsListPage'), @@ -239,6 +240,7 @@ const appRouter = createBrowserRouter([ { path: 'planning/framework-programs/:id', element: }, { path: 'planning/training-modules/new', element: }, { path: 'planning/training-modules/:id', element: }, + { path: 'planning/plan-templates/:id', element: }, { path: 'planning/run/:unitId/coach', element: }, { path: 'planning/run/:unitId', element: }, { path: 'admin', element: }, diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx index a58fe7f..f2f148a 100644 --- a/frontend/src/components/TrainingUnitSectionsEditor.jsx +++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx @@ -266,6 +266,8 @@ export default function TrainingUnitSectionsEditor({ betweenInsertMenus = true, /** Trainingsplanung: Phasen/Streams anlegen und Abschnitte zuordnen */ enableParallelPhaseControls = false, + /** Nur Abschnitts-Gliederung (Vorlagen): keine Übungen/Anmerkungen */ + structureOnly = false, }) { const { user } = useAuth() const planningCompactLegend = isCompactTagLegendMode( @@ -2021,15 +2023,17 @@ export default function TrainingUnitSectionsEditor({ ) : null} - {planMin > 0 && ( + {!structureOnly && planMin > 0 && (

Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen)

)} - {betweenInsertMenus ? renderBetweenInsertBand(sIdx, 0, itemCount) : null} + {!structureOnly ? ( + <> + {betweenInsertMenus ? renderBetweenInsertBand(sIdx, 0, itemCount) : null} - {(sec.items || []).map((it, iIdx) => { + {(sec.items || []).map((it, iIdx) => { const dropHere = enableItemDragReorder && dropTargetPos?.sIdx === sIdx && @@ -2541,6 +2545,8 @@ export default function TrainingUnitSectionsEditor({ ) : null} + + ) : null} {useStreamTagDropUx && pl?.phaseKind === 'parallel' && parallelPhaseOrder != null ? (() => { diff --git a/frontend/src/components/planning/PlanTemplateStructurePreview.jsx b/frontend/src/components/planning/PlanTemplateStructurePreview.jsx new file mode 100644 index 0000000..d8ad88c --- /dev/null +++ b/frontend/src/components/planning/PlanTemplateStructurePreview.jsx @@ -0,0 +1,53 @@ +import React, { useMemo } from 'react' +import { formatPlanTemplateStructurePreview } from '../../utils/trainingUnitSectionsForm' + +export default function PlanTemplateStructurePreview({ sections, compact = false }) { + const preview = useMemo(() => formatPlanTemplateStructurePreview(sections), [sections]) + + if (preview.isEmpty) { + return ( +

+ Noch keine Abschnitte definiert. +

+ ) + } + + return ( +
    + {preview.lines.map((line, idx) => ( +
  • + + {line.label} + + {line.detail} +
  • + ))} +
+ ) +} diff --git a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx index 3a72640..22c25dd 100644 --- a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx +++ b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx @@ -653,6 +653,7 @@ function TrainingPlanningPageRoot() { const handleSaveAsTemplate = async (opts = {}) => { const name = window.prompt('Name für die neue Trainingsvorlage (nur Abschnitts-Gliederung):') if (!name?.trim()) return + const descRaw = window.prompt('Kurzbeschreibung (optional, leer lassen zum Überspringen):') const visibility = typeof opts.visibility === 'string' && opts.visibility.trim() ? String(opts.visibility).trim().toLowerCase() @@ -673,6 +674,7 @@ function TrainingPlanningPageRoot() { try { await api.createTrainingPlanTemplate({ name: name.trim(), + description: descRaw?.trim() ? descRaw.trim() : null, visibility, club_id: visibility === 'club' ? club_id : null, sections: templateSectionsPayloadFromFormSections(formData.sections), diff --git a/frontend/src/pages/TrainingPlanTemplateEditPage.jsx b/frontend/src/pages/TrainingPlanTemplateEditPage.jsx new file mode 100644 index 0000000..11d95d4 --- /dev/null +++ b/frontend/src/pages/TrainingPlanTemplateEditPage.jsx @@ -0,0 +1,337 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import api from '../utils/api' +import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor' +import FormActionBar from '../components/FormActionBar' +import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt' +import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker' +import { useAuth } from '../context/AuthContext' +import { useToast } from '../context/ToastContext' +import { + defaultSection, + formSectionsFromPlanTemplateRows, + templateSectionsPayloadFromFormSections, +} from '../utils/trainingUnitSectionsForm' +import { + activeClubMemberships, + getDefaultClubIdForGovernanceForms, + getTenantClubDependencyKey, +} from '../utils/activeClub' + +function templateFormSnapshot({ name, description, visibility, clubIdField, sections }) { + return JSON.stringify({ + name: (name || '').trim(), + description: (description || '').trim(), + visibility: visibility || '', + clubIdField: (clubIdField || '').trim(), + sections: templateSectionsPayloadFromFormSections(sections), + }) +} + +export default function TrainingPlanTemplateEditPage() { + const { id: routeId } = useParams() + const navigate = useNavigate() + const templateId = parseInt(routeId, 10) + const toast = useToast() + const { user } = useAuth() + const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' + const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) + + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [visibility, setVisibility] = useState('club') + const [clubIdField, setClubIdField] = useState('') + const [sections, setSections] = useState([defaultSection()]) + const [clubsForGovernanceForms, setClubsForGovernanceForms] = useState([]) + + const baselineRef = useRef(null) + const latestFormRef = useRef({}) + const [baselineReady, setBaselineReady] = useState(false) + const [bypassDirty, setBypassDirty] = useState(false) + + latestFormRef.current = { name, description, visibility, clubIdField, sections } + + const dirtySignature = templateFormSnapshot(latestFormRef.current) + const formDirtyEffective = + baselineReady && baselineRef.current != null && !bypassDirty && !loading && dirtySignature !== baselineRef.current + + const blocker = useUnsavedChangesBlocker(Boolean(formDirtyEffective && !saving)) + useBeforeUnloadWhen(Boolean(formDirtyEffective && !saving)) + + const membershipClubRows = useMemo(() => activeClubMemberships(user?.clubs ?? []), [user?.clubs]) + + const visibilityClubChoices = useMemo(() => { + if (isPlatformAdmin && clubsForGovernanceForms.length > 0) { + return [...clubsForGovernanceForms].sort((a, b) => + String(a.name || '').localeCompare(String(b.name || ''), 'de') + ) + } + return [...membershipClubRows].sort((a, b) => + String(a.name || '').localeCompare(String(b.name || ''), 'de') + ) + }, [isPlatformAdmin, clubsForGovernanceForms, membershipClubRows]) + + useEffect(() => { + if (!isPlatformAdmin) { + setClubsForGovernanceForms([]) + return undefined + } + let cancelled = false + ;(async () => { + try { + const list = await api.listClubs() + if (!cancelled) setClubsForGovernanceForms(Array.isArray(list) ? list : []) + } catch { + if (!cancelled) setClubsForGovernanceForms([]) + } + })() + return () => { + cancelled = true + } + }, [isPlatformAdmin, tenantClubDepKey]) + + useEffect(() => { + baselineRef.current = null + setBaselineReady(false) + setBypassDirty(false) + }, [templateId]) + + useEffect(() => { + if (loading) return + const handle = window.setTimeout(() => { + baselineRef.current = templateFormSnapshot(latestFormRef.current) + setBaselineReady(true) + }, 120) + return () => clearTimeout(handle) + }, [loading, templateId]) + + useEffect(() => { + if (!Number.isFinite(templateId) || templateId < 1) { + setError('Ungültige Vorlagen-ID') + setLoading(false) + return undefined + } + let cancelled = false + async function load() { + setLoading(true) + setError('') + try { + const tpl = await api.getTrainingPlanTemplate(templateId) + if (cancelled) return + setName((tpl.name || '').trim()) + setDescription((tpl.description || '').trim()) + setVisibility((tpl.visibility || 'club').trim()) + setClubIdField(tpl.club_id != null ? String(tpl.club_id) : '') + const nextSections = formSectionsFromPlanTemplateRows(tpl.sections) + setSections(nextSections.length ? nextSections : [defaultSection()]) + } catch (e) { + if (!cancelled) setError(e.message || 'Laden fehlgeschlagen') + } finally { + if (!cancelled) setLoading(false) + } + } + load() + return () => { + cancelled = true + } + }, [templateId]) + + const buildBody = useCallback(() => { + 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 (visibilityClubChoices.length === 1) { + cid = visibilityClubChoices[0].id + } + } + return { + name: name.trim(), + description: description.trim() || null, + visibility, + club_id: + cid != null && Number.isFinite(cid) && cid >= 1 + ? cid + : visibility === 'club' + ? undefined + : null, + sections: templateSectionsPayloadFromFormSections(sections), + } + }, [name, description, visibility, clubIdField, visibilityClubChoices, sections]) + + const performSave = async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => { + if (!name.trim()) { + toast.error('Name ist Pflicht.') + return false + } + if (visibility === 'club') { + const bodyPreview = buildBody() + if (bodyPreview.club_id === undefined) { + toast.error('Bitte einen Verein wählen (Sichtbarkeit „Verein“).') + return false + } + } + setSaving(true) + setError('') + try { + await api.updateTrainingPlanTemplate(templateId, buildBody()) + baselineRef.current = templateFormSnapshot(latestFormRef.current) + setBypassDirty(false) + toast.success('Vorlage gespeichert.') + if (closeAfter) navigate('/planning/plan-templates') + return true + } catch (err) { + const msg = err.message || 'Speichern fehlgeschlagen' + setError(msg) + toast.error(msg) + return false + } finally { + setSaving(false) + } + } + + const handleSave = async (e) => { + e?.preventDefault?.() + await performSave({ closeAfter: false }) + } + + const handleSaveAndClose = async () => { + await performSave({ closeAfter: true }) + } + + const handleUnsavedDialogSave = async () => { + const ok = await performSave({ fromUnsavedDialog: true }) + if (ok) blocker.proceed() + } + + if (loading) { + return ( +
+
+

Laden …

+
+ ) + } + + return ( +
+

+ + ← Zurück zu Vorlagen + +

+ +

+ Trainingsvorlage bearbeiten +

+

+ Nur die Abschnitts-Gliederung (inkl. Split-Sessions / parallele Gruppen) — ohne Übungen. + Beim Anwenden auf eine Einheit wird der Ablauf als Struktur übernommen. +

+ + {error ?

{error}

: null} + +
+
+
+ + setName(e.target.value)} /> +
+ +
+ +