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:
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 ( +Laden …
++ + ← Zurück zu Vorlagen + +
+ ++ 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} + + + +- Mikrovorlagen für die Sektions-Gliederung einer Einheit (ohne Übungen). Neue Vorlagen - legst du beim Bearbeiten einer Trainingseinheit über „Vorlage aus Aufbau speichern“ an. +
+ Mikrovorlagen für die Sektions-Gliederung einer Einheit (ohne Übungen), inklusive{' '} + Split-Sessions. Neue Vorlagen legst du beim Bearbeiten einer Trainingseinheit über „Vorlage + aus Aufbau speichern“ an; hier kannst du sie prüfen und anpassen.
@@ -88,52 +91,95 @@ export default function TrainingPlanTemplatesListPage() {Noch keine Vorlagen gespeichert. Öffne unter Trainingsplanung eine Einheit, strukturiere - die Abschnitte und nutze dort „Vorlage aus Aufbau speichern“. + die Abschnitte (auch parallele Gruppen) und nutze dort „Vorlage aus Aufbau speichern“.
+ {desc} +
+ ) : null} +- Löschen nach Rolle: eigene private Vorlagen; Vereinsinhalte als Vereinsadmin; offizielle nur als - Plattform-Admin. + Bearbeiten: eigene private Vorlagen, Vereinsinhalte für Trainer im Verein, offizielle nur als Plattform-Admin. + Löschen nach Rolle: eigene private Vorlagen; Vereinsinhalte als Vereinsadmin; offizielle nur als Plattform-Admin.
> ) diff --git a/frontend/src/utils/libraryContentPermissions.js b/frontend/src/utils/libraryContentPermissions.js index 8f39f8a..60938c6 100644 --- a/frontend/src/utils/libraryContentPermissions.js +++ b/frontend/src/utils/libraryContentPermissions.js @@ -11,6 +11,25 @@ export function clubAdminInClub(user, clubId) { * Löschen von Bibliotheks-/Planungsinhalten (Vorlage, Modul, Rahmen, Graph) — grob wie Backend club_tenancy. * Vereins-Admins können fremde private Einträge im API löschen (gemeinsamer Verein); das blenden wir hier nicht ein. */ +/** Bearbeiten — grob wie Backend assert_library_content_editable (Ersteller, Plattform, Planung im Verein). */ +export function canEditLibraryContent(user, row) { + const grole = String(user?.role || '').toLowerCase() + if (grole === 'admin' || grole === 'superadmin') return true + const uid = Number(user?.id) + if (!Number.isFinite(uid)) return false + + const vis = String(row?.visibility ?? 'club').toLowerCase() + const createdBy = row?.created_by != null ? Number(row.created_by) : null + const clubId = row?.club_id != null ? Number(row.club_id) : null + + if (vis === 'official') return false + if (Number.isFinite(createdBy) && createdBy === uid) return true + if (vis === 'club' && Number.isFinite(clubId)) { + return activeClubMemberships(user?.clubs).some((c) => Number(c.id) === clubId) + } + return false +} + export function canDeleteLibraryContent(user, row) { const grole = String(user?.role || '').toLowerCase() if (grole === 'admin' || grole === 'superadmin') return true diff --git a/frontend/src/utils/trainingUnitSectionsForm.js b/frontend/src/utils/trainingUnitSectionsForm.js index 3f7050a..54423cd 100644 --- a/frontend/src/utils/trainingUnitSectionsForm.js +++ b/frontend/src/utils/trainingUnitSectionsForm.js @@ -1180,6 +1180,66 @@ export function templateSectionsPayloadFromFormSections(sections) { }) } +/** Kurzdarstellung der Vorlagen-Gliederung (Ganzgruppe + Split-Streams) für Listen/Übersicht. */ +export function formatPlanTemplateStructurePreview(templateSections) { + const rows = Array.isArray(templateSections) ? [...templateSections] : [] + if (!rows.length) { + return { lines: [], hasSplit: false, isEmpty: true } + } + rows.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) + const lines = [] + let hasSplit = false + let i = 0 + while (i < rows.length) { + const r0 = rows[i] + const pk0 = String(r0.phase_kind || 'whole_group').toLowerCase().trim() + const poi0 = Number(r0.phase_order_index) + const phaseOrder = Number.isFinite(poi0) ? poi0 : 0 + const run = [] + while (i < rows.length) { + const r = rows[i] + const pk = String(r.phase_kind || 'whole_group').toLowerCase().trim() + const poi = Number(r.phase_order_index) + const phaseOi = Number.isFinite(poi) ? poi : 0 + if (pk !== pk0 || phaseOi !== phaseOrder) break + run.push(r) + i += 1 + } + if (pk0 === 'parallel') { + hasSplit = true + const byStream = new Map() + for (const r of run) { + const soRaw = r.parallel_stream_order_index + const so = soRaw == null || soRaw === '' ? 0 : Number(soRaw) + const streamKey = Number.isFinite(so) ? so : 0 + if (!byStream.has(streamKey)) byStream.set(streamKey, []) + byStream.get(streamKey).push(r) + } + const streamParts = [...byStream.keys()] + .sort((a, b) => a - b) + .map((so) => { + const titles = byStream + .get(so) + .map((r) => (r.title || '').trim() || 'Abschnitt') + return `Gruppe ${so + 1}: ${titles.join(' · ')}` + }) + lines.push({ + kind: 'parallel', + label: phaseOrder > 0 ? `Split · Phase ${phaseOrder}` : 'Split-Session', + detail: streamParts.join(' │ '), + }) + } else { + const titles = run.map((r) => (r.title || '').trim() || 'Abschnitt') + lines.push({ + kind: 'whole_group', + label: phaseOrder > 0 ? `Ganzgruppe · Phase ${phaseOrder}` : 'Ganzgruppe', + detail: titles.join(' → '), + }) + } + } + return { lines, hasSplit, isEmpty: false } +} + /** GET-Vorlage → Editor-Abschnitte mit planLoc (Split-Sessions). */ export function formSectionsFromPlanTemplateRows(templateSections) { const rows = Array.isArray(templateSections) ? [...templateSections] : []