Update version to 0.8.148 and enhance training plan template functionality
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m7s

- Incremented app version to 0.8.148 and updated changelog to reflect new features.
- Improved the training plan template structure by adding a preview of sections, including support for split sessions.
- Introduced a new editing page for training plan templates, allowing users to modify templates directly.
- Enhanced the TrainingPlanningPageRoot to include a description field when saving templates, improving user guidance.
- Updated permissions to allow editing of training plan templates based on user roles.
This commit is contained in:
Lars 2026-05-19 10:13:26 +02:00
parent 1684892bcb
commit f15aa7c415
10 changed files with 583 additions and 32 deletions

View File

@ -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

View File

@ -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",

View File

@ -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: <TrainingFrameworkProgramEditPage /> },
{ path: 'planning/training-modules/new', element: <TrainingModuleEditPage /> },
{ path: 'planning/training-modules/:id', element: <TrainingModuleEditPage /> },
{ path: 'planning/plan-templates/:id', element: <TrainingPlanTemplateEditPage /> },
{ path: 'planning/run/:unitId/coach', element: <TrainingCoachPage /> },
{ path: 'planning/run/:unitId', element: <TrainingUnitRunPage /> },
{ path: 'admin', element: <AdminHomeRedirect /> },

View File

@ -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({
</select>
</div>
) : null}
{planMin > 0 && (
{!structureOnly && planMin > 0 && (
<p style={{ fontSize: '0.78rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen)
</p>
)}
{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({
</ul>
</div>
) : null}
</>
) : null}
</div>
{useStreamTagDropUx && pl?.phaseKind === 'parallel' && parallelPhaseOrder != null
? (() => {

View File

@ -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 (
<p style={{ margin: 0, fontSize: compact ? '0.82rem' : '0.88rem', color: 'var(--text3)' }}>
Noch keine Abschnitte definiert.
</p>
)
}
return (
<ul
className="plan-template-structure-preview"
style={{
listStyle: 'none',
margin: compact ? '0.35rem 0 0' : '0.5rem 0 0',
padding: 0,
display: 'grid',
gap: compact ? '0.35rem' : '0.45rem',
}}
>
{preview.lines.map((line, idx) => (
<li
key={`${line.kind}-${line.label}-${idx}`}
style={{
fontSize: compact ? '0.82rem' : '0.88rem',
lineHeight: 1.45,
color: 'var(--text2)',
}}
>
<span
style={{
display: 'inline-block',
fontSize: '0.72rem',
fontWeight: 700,
letterSpacing: '0.02em',
textTransform: 'uppercase',
color: line.kind === 'parallel' ? 'var(--accent-dark)' : 'var(--text3)',
marginRight: '0.45rem',
}}
>
{line.label}
</span>
<span style={{ color: 'var(--text1)' }}>{line.detail}</span>
</li>
))}
</ul>
)
}

View File

@ -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),

View File

@ -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 (
<div className="app-page" style={{ padding: '2rem 0', textAlign: 'center' }}>
<div className="spinner" />
<p>Laden </p>
</div>
)
}
return (
<div className="app-page">
<p style={{ marginBottom: '0.75rem' }}>
<Link to="/planning/plan-templates" style={{ color: 'var(--accent-dark)', fontWeight: 600 }}>
Zurück zu Vorlagen
</Link>
</p>
<h1 className="page-title" style={{ marginBottom: '0.35rem' }}>
Trainingsvorlage bearbeiten
</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', marginBottom: '1.25rem', maxWidth: '42rem', lineHeight: 1.5 }}>
Nur die <strong>Abschnitts-Gliederung</strong> (inkl. Split-Sessions / parallele Gruppen) ohne Übungen.
Beim Anwenden auf eine Einheit wird der Ablauf als Struktur übernommen.
</p>
{error ? <p style={{ color: 'var(--danger)', marginBottom: '1rem' }}>{error}</p> : null}
<form
id="plan-template-form"
className="card page-form-shell"
style={{ padding: 'clamp(14px, 3vw, 1.75rem)', maxWidth: '920px' }}
onSubmit={handleSave}
>
<div className="page-form-shell__scroll">
<div className="form-row">
<label className="form-label">Name *</label>
<input className="form-input" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="form-row">
<label className="form-label">Kurzbeschreibung</label>
<textarea
className="form-input"
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Wofür eignet sich diese Gliederung? (optional)"
/>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 200px), 1fr))',
gap: '12px',
marginBottom: '1rem',
}}
>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
value={visibility}
onChange={(e) => {
const v = e.target.value
setVisibility(v)
if (v === 'club' && !(clubIdField || '').trim()) {
const fb = getDefaultClubIdForGovernanceForms(user)
if (fb != null) setClubIdField(String(fb))
}
}}
>
<option value="private">Privat (nur du)</option>
<option value="club">Verein</option>
{isPlatformAdmin ? <option value="official">Offiziell (global)</option> : null}
</select>
</div>
{visibility === 'club' ? (
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Verein</label>
<select
className="form-input"
value={clubIdField}
onChange={(e) => setClubIdField(e.target.value)}
>
<option value=""> Verein wählen </option>
{visibilityClubChoices.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || `Verein #${c.id}`}
</option>
))}
</select>
</div>
) : null}
</div>
<TrainingUnitSectionsEditor
heading="Abschnitts-Gliederung"
structureOnly
enableParallelPhaseControls
betweenInsertMenus={false}
enableItemDragReorder={false}
sections={sections}
onSectionsChange={(updater) => setSections((prev) => updater(prev))}
/>
</div>
<FormActionBar
placement="bottom"
variant="page"
formId="plan-template-form"
saving={saving}
onCancel={() => navigate('/planning/plan-templates')}
onSaveAndClose={handleSaveAndClose}
/>
</form>
<UnsavedChangesPrompt
blocker={blocker}
isBusy={saving}
onSave={handleUnsavedDialogSave}
onDiscardWithoutSave={() => setBypassDirty(true)}
/>
</div>
)
}

View File

@ -1,9 +1,11 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { useToast } from '../context/ToastContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
import { canDeleteLibraryContent } from '../utils/libraryContentPermissions'
import { canDeleteLibraryContent, canEditLibraryContent } from '../utils/libraryContentPermissions'
import PlanTemplateStructurePreview from '../components/planning/PlanTemplateStructurePreview'
function visibilityLabel(v) {
const x = String(v || 'club').toLowerCase()
@ -73,9 +75,10 @@ export default function TrainingPlanTemplatesListPage() {
<h1 className="page-title" style={{ marginBottom: '0.35rem' }}>
Trainingsvorlagen
</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '40rem', margin: 0, lineHeight: 1.5 }}>
Mikrovorlagen für die <strong>Sektions-Gliederung</strong> einer Einheit (ohne Übungen). Neue Vorlagen
legst du beim Bearbeiten einer Trainingseinheit über Vorlage aus Aufbau speichern an.
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '42rem', margin: 0, lineHeight: 1.5 }}>
Mikrovorlagen für die <strong>Sektions-Gliederung</strong> einer Einheit (ohne Übungen), inklusive{' '}
<strong>Split-Sessions</strong>. Neue Vorlagen legst du beim Bearbeiten einer Trainingseinheit über Vorlage
aus Aufbau speichern an; hier kannst du sie prüfen und anpassen.
</p>
</div>
</div>
@ -88,52 +91,95 @@ export default function TrainingPlanTemplatesListPage() {
<div className="card" style={{ padding: '1.25rem' }}>
<p style={{ margin: 0, color: 'var(--text2)', lineHeight: 1.5 }}>
Noch keine Vorlagen gespeichert. Öffne unter <strong>Trainingsplanung</strong> eine Einheit, strukturiere
die Abschnitte und nutze dort Vorlage aus Aufbau speichern.
die Abschnitte (auch parallele Gruppen) und nutze dort Vorlage aus Aufbau speichern.
</p>
</div>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: '0.75rem' }}>
{rows.map((t) => {
const canEdit = user && canEditLibraryContent(user, t)
const canDel = user && canDeleteLibraryContent(user, t)
const desc = (t.description || '').trim()
return (
<li key={t.id} className="card" style={{ padding: '14px 16px' }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: '12px',
}}
>
<div style={{ minWidth: 0, flex: '1 1 200px' }}>
<strong style={{ color: 'var(--text1)', fontSize: '1rem' }}>
{(t.name || '').trim() || `Vorlage #${t.id}`}
</strong>
<div style={{ minWidth: 0, flex: '1 1 240px' }}>
{canEdit ? (
<Link
to={`/planning/plan-templates/${t.id}`}
style={{
fontWeight: 700,
fontSize: '1rem',
color: 'var(--accent-dark)',
textDecoration: 'none',
wordBreak: 'break-word',
}}
>
{(t.name || '').trim() || `Vorlage #${t.id}`}
</Link>
) : (
<strong style={{ color: 'var(--text1)', fontSize: '1rem', wordBreak: 'break-word' }}>
{(t.name || '').trim() || `Vorlage #${t.id}`}
</strong>
)}
<div style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '4px' }}>
{visibilityLabel(t.visibility)}
{typeof t.sections_count === 'number' ? ` · ${t.sections_count} Abschn.` : ''}
{t.updated_at ? (
<span style={{ color: 'var(--text3)' }}>
{' '}
· aktualisiert{' '}
{String(t.updated_at).slice(0, 10)}
· aktualisiert {String(t.updated_at).slice(0, 10)}
</span>
) : null}
</div>
{desc ? (
<p
style={{
margin: '0.45rem 0 0',
fontSize: '0.88rem',
color: 'var(--text2)',
lineHeight: 1.45,
maxWidth: '42rem',
}}
>
{desc}
</p>
) : null}
<PlanTemplateStructurePreview sections={t.sections} compact />
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', flexShrink: 0 }}>
{canEdit ? (
<Link
className="btn btn-secondary"
style={{ textDecoration: 'none' }}
to={`/planning/plan-templates/${t.id}`}
>
Bearbeiten
</Link>
) : (
<span style={{ fontSize: '0.82rem', color: 'var(--text3)', alignSelf: 'center' }}>
nur Lesen
</span>
)}
{canDel ? (
<button
type="button"
className="btn btn-secondary"
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => handleDelete(t)}
>
Löschen
</button>
) : null}
</div>
{canDel ? (
<button
type="button"
className="btn btn-secondary"
style={{ flexShrink: 0, color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => handleDelete(t)}
>
Löschen
</button>
) : (
<span style={{ fontSize: '0.82rem', color: 'var(--text3)', flexShrink: 0 }}>nur Lesen</span>
)}
</div>
</li>
)
@ -142,8 +188,8 @@ export default function TrainingPlanTemplatesListPage() {
)}
<p style={{ marginTop: '1.25rem', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45, maxWidth: '40rem' }}>
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.
</p>
</>
)

View File

@ -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

View File

@ -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] : []