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
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:
parent
1684892bcb
commit
f15aa7c415
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 /> },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? (() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
337
frontend/src/pages/TrainingPlanTemplateEditPage.jsx
Normal file
337
frontend/src/pages/TrainingPlanTemplateEditPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] : []
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user