feat: update application version to 0.8.38 and enhance training planning features
Some checks failed
Deploy Development / deploy (push) Failing after 14s
Test Suite / pytest-backend (push) Successful in 5s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Failing after 2s
Test Suite / playwright-tests (push) Successful in 29s

- Bumped application version to 0.8.38 in both backend and frontend files.
- Updated training planning API to improve permission checks for trainer assignments, allowing club admins to manage training units more effectively.
- Enhanced the TrainingPlanningPage with new modal functionality for assigning trainers and improved loading of club member directories.
- Updated changelog to reflect the new version and changes made in this release.
This commit is contained in:
Lars 2026-05-06 07:18:30 +02:00
parent c778d21b26
commit 9e759a28c6
4 changed files with 428 additions and 91 deletions

View File

@ -180,21 +180,36 @@ def _assert_training_unit_permission(
raise HTTPException(status_code=403, detail="Keine Berechtigung")
co_eff = _effective_co_trainer_ids_for_row(unit_row)
if role not in ["admin", "superadmin"]:
if (
unit_row["created_by"] != profile_id
and unit_row["trainer_id"] != profile_id
and profile_id not in co_eff
and unit_row.get("lead_trainer_profile_id") != profile_id
):
raise HTTPException(status_code=403, detail="Keine Berechtigung")
def _assert_delete_training_unit(role: str, created_by: int, profile_id: int) -> None:
if role not in ["admin", "superadmin"] and created_by != profile_id:
if role in ["admin", "superadmin"]:
return
gcid = unit_row.get("group_club_id")
if gcid is not None and can_manage_club_org(cur, profile_id, int(gcid), role):
return
if (
unit_row["created_by"] != profile_id
and unit_row["trainer_id"] != profile_id
and profile_id not in co_eff
and unit_row.get("lead_trainer_profile_id") != profile_id
):
raise HTTPException(status_code=403, detail="Keine Berechtigung")
def _assert_delete_training_unit(
cur,
role: str,
created_by: int,
profile_id: int,
group_club_id: Optional[int],
) -> None:
if role in ["admin", "superadmin"]:
return
if created_by == profile_id:
return
if group_club_id is not None and can_manage_club_org(cur, profile_id, int(group_club_id), role):
return
raise HTTPException(status_code=403, detail="Keine Berechtigung")
def _assert_club_visible_for_trainer(cur, club_id: int, profile_id: int, role: str) -> None:
"""Nicht-Admin: Vereinsbezug für Listen mit club_id (Mitglied genügt; Details filtert WHERE)."""
if role in ("admin", "superadmin"):
@ -1018,7 +1033,21 @@ def list_training_units(
where = []
params = []
if role not in ["admin", "superadmin"]:
skip_involvement_filter = role in ("admin", "superadmin")
if not skip_involvement_filter and cid is not None:
if can_manage_club_org(cur, profile_id, cid, role):
skip_involvement_filter = True
if not skip_involvement_filter and gid is not None:
cur.execute(
"SELECT club_id FROM training_groups WHERE id = %s AND status = 'active'",
(gid,),
)
gcx = cur.fetchone()
if gcx and gcx.get("club_id") is not None:
if can_manage_club_org(cur, profile_id, int(gcx["club_id"]), role):
skip_involvement_filter = True
if not skip_involvement_filter:
where.append(
"(tu.created_by = %s OR tg.trainer_id = %s OR tu.lead_trainer_profile_id = %s OR "
"COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) "
@ -1090,6 +1119,7 @@ def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_c
p.name as creator_name,
tg.trainer_id AS trainer_id,
tg.co_trainer_ids AS co_trainer_ids,
tg.club_id AS group_club_id,
COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id,
COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb)
AS effective_assistant_trainer_profile_ids,
@ -1429,7 +1459,12 @@ def delete_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenan
cur = get_cursor(conn)
cur.execute(
"SELECT created_by, framework_slot_id FROM training_units WHERE id = %s",
"""
SELECT tu.created_by, tu.framework_slot_id, tg.club_id AS group_club_id
FROM training_units tu
LEFT JOIN training_groups tg ON tu.group_id = tg.id
WHERE tu.id = %s
""",
(unit_id,),
)
@ -1444,7 +1479,13 @@ def delete_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenan
detail="Blueprint-Einheiten werden über das Rahmenprogramm verwaltet, nicht hier gelöscht.",
)
_assert_delete_training_unit(role, unit["created_by"], profile_id)
_assert_delete_training_unit(
cur,
role,
unit["created_by"],
profile_id,
unit.get("group_club_id"),
)
cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,))
conn.commit()

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.37"
BUILD_DATE = "2026-05-05"
APP_VERSION = "0.8.38"
BUILD_DATE = "2026-05-06"
DB_SCHEMA_VERSION = "20260505042"
MODULE_VERSIONS = {
@ -27,6 +27,14 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.38",
"date": "2026-05-06",
"changes": [
"Trainingsplanung: Vereinsadmins sehen alle Einheiten bei club_id-/Gruppenliste; GET/PUT Einheit & Löschen mit can_manage_club_org",
"Planung UI: „Trainer zuweisen“ in Vereins-Ansicht (Liste + Kalender) + eigener Modal; Mitgliederverzeichnis für Vereinsorganisation",
],
},
{
"version": "0.8.37",
"date": "2026-05-05",

View File

@ -123,6 +123,15 @@ function TrainingPlanningPage() {
const [planScope, setPlanScope] = useState('group')
const [assignedToMeOnly, setAssignedToMeOnly] = useState(false)
const [clubDirectory, setClubDirectory] = useState([])
const [meProfile, setMeProfile] = useState(null)
const [assignModalOpen, setAssignModalOpen] = useState(false)
const [assignDraft, setAssignDraft] = useState({
unit: null,
lead_trainer_profile_id: '',
session_assistants_inherit: true,
session_assistant_profile_ids: [],
})
const [assignSaving, setAssignSaving] = useState(false)
const [formData, setFormData] = useState({
group_id: '',
@ -223,26 +232,57 @@ function TrainingPlanningPage() {
}
}, [selectedGroupId, loadUnits])
const selectedGroupClubIdMemo = useMemo(() => {
const g = groups.find((gr) => gr.id === parseInt(selectedGroupId, 10))
return g?.club_id != null ? Number(g.club_id) : null
}, [groups, selectedGroupId])
const canClubOrgTraining = useMemo(() => {
const r = (user?.role || '').toLowerCase()
if (r === 'admin' || r === 'superadmin') return true
if (selectedGroupClubIdMemo == null || !Number.isFinite(selectedGroupClubIdMemo)) return false
const row = (meProfile?.clubs || []).find((c) => Number(c.id) === selectedGroupClubIdMemo)
return Array.isArray(row?.roles) && row.roles.includes('club_admin')
}, [user?.role, selectedGroupClubIdMemo, meProfile])
useEffect(() => {
if (!showModal) {
setClubDirectory([])
if (!user?.id) {
setMeProfile(null)
return undefined
}
let cancelled = false
api
.getCurrentProfile()
.then((p) => {
if (!cancelled) setMeProfile(p)
})
.catch(() => {
if (!cancelled) setMeProfile(null)
})
return () => {
cancelled = true
}
}, [user?.id])
useEffect(() => {
const gid = parseInt(formData.group_id || selectedGroupId || '0', 10)
if (!Number.isFinite(gid) || gid < 1) {
setClubDirectory([])
return undefined
}
const g = groups.find((x) => x.id === gid)
const cid = g?.club_id
if (!cid) {
const gModal = Number.isFinite(gid) && gid >= 1 ? groups.find((x) => x.id === gid) : null
const clubForModal = gModal?.club_id != null ? Number(gModal.club_id) : null
const loadClubId =
showModal && clubForModal != null && Number.isFinite(clubForModal)
? clubForModal
: planScope === 'club' && canClubOrgTraining && selectedGroupClubIdMemo != null
? selectedGroupClubIdMemo
: null
if (loadClubId == null || !Number.isFinite(loadClubId)) {
setClubDirectory([])
return undefined
}
let cancelled = false
;(async () => {
try {
const d = await api.clubMembersDirectory(cid)
const d = await api.clubMembersDirectory(loadClubId)
if (!cancelled) setClubDirectory(Array.isArray(d) ? d : [])
} catch (err) {
if (!cancelled) {
@ -254,7 +294,15 @@ function TrainingPlanningPage() {
return () => {
cancelled = true
}
}, [showModal, formData.group_id, selectedGroupId, groups])
}, [
showModal,
formData.group_id,
selectedGroupId,
groups,
planScope,
canClubOrgTraining,
selectedGroupClubIdMemo,
])
useEffect(() => {
if (!frameworkImportOpen) return
@ -543,6 +591,47 @@ function TrainingPlanningPage() {
}
}
const openTrainerAssignModal = (unit) => {
setAssignDraft({
unit,
lead_trainer_profile_id:
unit.lead_trainer_profile_id != null && unit.lead_trainer_profile_id !== ''
? String(unit.lead_trainer_profile_id)
: '',
session_assistants_inherit:
unit.assistant_trainer_profile_ids == null || unit.assistant_trainer_profile_ids === undefined,
session_assistant_profile_ids: toNumList(unit.assistant_trainer_profile_ids),
})
setAssignModalOpen(true)
}
const saveTrainerAssignModal = async () => {
if (!assignDraft.unit) return
setAssignSaving(true)
try {
const payload = {}
const leadStr = String(assignDraft.lead_trainer_profile_id || '').trim()
if (leadStr) payload.lead_trainer_profile_id = parseInt(leadStr, 10)
else payload.lead_trainer_profile_id = null
if (assignDraft.session_assistants_inherit) {
payload.assistant_trainer_profile_ids = null
} else {
payload.assistant_trainer_profile_ids = [...assignDraft.session_assistant_profile_ids].sort((a, b) => a - b)
}
await api.updateTrainingUnit(assignDraft.unit.id, payload)
setAssignModalOpen(false)
setAssignDraft({
unit: null,
...sessionAssignDefaults(),
})
await loadUnits()
} catch (err) {
alert(err.message || 'Zuweisung konnte nicht gespeichert werden')
} finally {
setAssignSaving(false)
}
}
const handleDelete = async (unit) => {
if (!confirm(`Trainingseinheit vom ${unit.planned_date} wirklich löschen?`)) return
try {
@ -646,6 +735,7 @@ function TrainingPlanningPage() {
}
const selectedGroup = groups.find((g) => g.id === parseInt(selectedGroupId, 10))
const showOrgTrainerAssignControls = planScope === 'club' && canClubOrgTraining
return (
<div className="app-page">
@ -943,8 +1033,14 @@ function TrainingPlanningPage() {
/>
Nur meine Zuordnung (Leitung / Co)
</label>
<span style={{ fontSize: '0.78rem', color: 'var(--text3)', lineHeight: 1.4, flex: '1 1 200px' }}>
<span style={{ fontSize: '0.78rem', color: 'var(--text3)', lineHeight: 1.4, flex: '1 1 240px' }}>
Ganzer Verein nutzt den Verein der gewählten Gruppe; neue Termine unten gelten weiter für die gewählte Gruppe.
{planScope === 'club' && canClubOrgTraining ? (
<span style={{ display: 'block', marginTop: '6px', color: 'var(--text2)' }}>
Vereinsorganisation: Über <strong>Trainer zuweisen</strong> kannst du pro Termin die Leitung und Co-Trainer
anpassen (auch in der Kalenderansicht).
</span>
) : null}
</span>
</div>
</div>
@ -1133,69 +1229,94 @@ function TrainingPlanningPage() {
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{dayUnits.slice(0, 3).map((unit) => (
<button
<div
key={unit.id}
type="button"
onClick={() => handleEdit(unit)}
title={[
planScope === 'club' && unit.group_name ? unit.group_name : '',
unit.planned_time_start?.slice(0, 5) || '',
unit.lead_trainer_name?.trim(),
unit.planned_focus?.trim(),
unit.status === 'completed'
? 'Durchgeführt'
: unit.status === 'cancelled'
? 'Abgesagt'
: 'Geplant',
]
.filter(Boolean)
.join(' · ')}
style={{
border: 'none',
cursor: 'pointer',
textAlign: 'left',
padding: '4px 5px',
borderRadius: '4px',
fontSize: '0.7rem',
lineHeight: 1.25,
width: '100%',
borderLeftWidth: '3px',
borderLeftStyle: 'solid',
borderLeftColor:
unit.status === 'completed'
? '#2ea44f'
: unit.status === 'cancelled'
? 'var(--danger)'
: 'var(--accent-dark)',
background: 'var(--surface2)',
color: 'var(--text1)',
}}
style={{ display: 'flex', flexDirection: 'column', gap: '4px', width: '100%' }}
>
<span style={{ fontWeight: 600 }}>
{unit.planned_time_start
? `${unit.planned_time_start.slice(0, 5)}`
: 'Ganztags'}
</span>
{planScope === 'club' && (unit.group_name || '').trim() ? (
<span style={{ display: 'block', fontWeight: 500, color: 'var(--text1)' }}>
{(unit.group_name || '').trim().length > 22
? `${(unit.group_name || '').trim().slice(0, 22)}`
: unit.group_name}
<button
type="button"
onClick={() => handleEdit(unit)}
title={[
planScope === 'club' && unit.group_name ? unit.group_name : '',
unit.planned_time_start?.slice(0, 5) || '',
unit.lead_trainer_name?.trim(),
unit.planned_focus?.trim(),
unit.status === 'completed'
? 'Durchgeführt'
: unit.status === 'cancelled'
? 'Abgesagt'
: 'Geplant',
]
.filter(Boolean)
.join(' · ')}
style={{
border: 'none',
cursor: 'pointer',
textAlign: 'left',
padding: '4px 5px',
borderRadius: '4px',
fontSize: '0.7rem',
lineHeight: 1.25,
width: '100%',
borderLeftWidth: '3px',
borderLeftStyle: 'solid',
borderLeftColor:
unit.status === 'completed'
? '#2ea44f'
: unit.status === 'cancelled'
? 'var(--danger)'
: 'var(--accent-dark)',
background: 'var(--surface2)',
color: 'var(--text1)',
}}
>
<span style={{ fontWeight: 600 }}>
{unit.planned_time_start
? `${unit.planned_time_start.slice(0, 5)}`
: 'Ganztags'}
</span>
{planScope === 'club' && (unit.group_name || '').trim() ? (
<span style={{ display: 'block', fontWeight: 500, color: 'var(--text1)' }}>
{(unit.group_name || '').trim().length > 22
? `${(unit.group_name || '').trim().slice(0, 22)}`
: unit.group_name}
</span>
) : null}
{unit.lead_trainer_name?.trim() ? (
<span
style={{
display: 'block',
color: 'var(--text3)',
fontWeight: 400,
fontSize: '0.62rem',
}}
>
{unit.lead_trainer_name.trim().split(/\s+/).slice(-1)[0] ||
unit.lead_trainer_name.trim()}
</span>
) : null}
{unit.planned_focus?.trim() ? (
<span style={{ display: 'block', color: 'var(--text2)', fontWeight: 400 }}>
{(unit.planned_focus || '').trim().length > 24
? `${(unit.planned_focus || '').trim().slice(0, 24)}`
: unit.planned_focus}
</span>
) : null}
</button>
{showOrgTrainerAssignControls ? (
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
style={{ fontSize: '0.62rem', padding: '2px 4px', alignSelf: 'stretch' }}
onClick={(ev) => {
ev.stopPropagation()
openTrainerAssignModal(unit)
}}
>
Trainer
</button>
) : null}
{unit.lead_trainer_name?.trim() ? (
<span style={{ display: 'block', color: 'var(--text3)', fontWeight: 400, fontSize: '0.62rem' }}>
{unit.lead_trainer_name.trim().split(/\s+/).slice(-1)[0] || unit.lead_trainer_name.trim()}
</span>
) : null}
{unit.planned_focus?.trim() ? (
<span style={{ display: 'block', color: 'var(--text2)', fontWeight: 400 }}>
{(unit.planned_focus || '').trim().length > 24
? `${(unit.planned_focus || '').trim().slice(0, 24)}`
: unit.planned_focus}
</span>
) : null}
</button>
</div>
))}
</div>
{dayUnits.length > 3 ? (
@ -1379,6 +1500,16 @@ function TrainingPlanningPage() {
<button className="btn btn-secondary" onClick={() => handleEdit(unit)}>
Bearbeiten
</button>
{showOrgTrainerAssignControls ? (
<button
type="button"
className="btn btn-secondary"
onClick={() => openTrainerAssignModal(unit)}
title="Nur organisatorisch: Leitung und Co für diese Einheit"
>
Trainer zuweisen
</button>
) : null}
{showTakeLead ? (
<button type="button" className="btn btn-secondary" onClick={() => handleTakeLead(unit)}>
Ich übernehme
@ -1405,6 +1536,163 @@ function TrainingPlanningPage() {
</div>
)}
{assignModalOpen && assignDraft.unit ? (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1020,
padding: '1rem',
overflowY: 'auto',
}}
role="presentation"
onClick={() => {
if (!assignSaving) setAssignModalOpen(false)
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="trainer-assign-modal-title"
onClick={(e) => e.stopPropagation()}
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: 'clamp(14px, 3vw, 1.75rem)',
maxWidth: 'min(460px, 100%)',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxSizing: 'border-box',
}}
>
<h2 id="trainer-assign-modal-title" style={{ marginBottom: '0.5rem', fontSize: '1.1rem' }}>
Trainer zuweisen (organisatorisch)
</h2>
<p style={{ fontSize: '0.86rem', color: 'var(--text2)', marginBottom: '1rem', lineHeight: 1.45 }}>
{(assignDraft.unit.planned_date || '').toString().slice(0, 10)}
{assignDraft.unit.planned_time_start
? ` · ${String(assignDraft.unit.planned_time_start).slice(0, 5)}`
: ''}
{(assignDraft.unit.group_name || '').trim()
? ` · ${(assignDraft.unit.group_name || '').trim()}`
: null}
</p>
<div className="form-row">
<label className="form-label">Leitung (diese Einheit)</label>
<select
className="form-input"
value={assignDraft.lead_trainer_profile_id}
onChange={(e) =>
setAssignDraft((prev) => ({ ...prev, lead_trainer_profile_id: e.target.value }))
}
disabled={assignSaving}
>
<option value="">Standard (Haupttrainer der Gruppe)</option>
{clubDirectory.map((m) => {
const idStr = String(m.id)
return (
<option key={idStr} value={idStr}>
{(m.name || '').trim() || m.email || `Profil ${m.id}`}
</option>
)
})}
</select>
</div>
<div className="form-row" style={{ marginTop: '0.85rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={assignDraft.session_assistants_inherit}
disabled={assignSaving}
onChange={(e) =>
setAssignDraft((prev) => ({
...prev,
session_assistants_inherit: e.target.checked,
}))
}
/>
<span style={{ fontSize: '0.9rem', color: 'var(--text1)' }}>
Co-Trainer wie in der Trainingsgruppe
</span>
</label>
</div>
{!assignDraft.session_assistants_inherit ? (
<div style={{ marginTop: '10px', maxHeight: '180px', overflowY: 'auto' }}>
{clubDirectory.map((m) => {
const mid = typeof m.id === 'number' ? m.id : parseInt(String(m.id), 10)
const labelText = `${(m.name || '').trim() || m.email || `Profil ${mid}`}`
const isOn = Number.isFinite(mid) && assignDraft.session_assistant_profile_ids.includes(mid)
return (
<label
key={`assign-co-${mid}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '0.875rem',
marginBottom: '6px',
cursor: assignSaving ? 'default' : 'pointer',
color: 'var(--text1)',
}}
>
<input
type="checkbox"
checked={isOn}
disabled={assignSaving}
onChange={() => {
setAssignDraft((prev) => {
const was = prev.session_assistant_profile_ids.includes(mid)
const nextIds = was
? prev.session_assistant_profile_ids.filter((x) => x !== mid)
: [...prev.session_assistant_profile_ids, mid].sort((a, b) => a - b)
return { ...prev, session_assistant_profile_ids: nextIds }
})
}}
/>
<span>{labelText}</span>
</label>
)
})}
</div>
) : null}
{!clubDirectory.length ? (
<p style={{ marginTop: '10px', fontSize: '0.82rem', color: 'var(--text3)' }}>
Mitgliederverzeichnis konnte nicht geladen werden.
</p>
) : null}
<div
style={{
display: 'flex',
gap: '0.65rem',
flexWrap: 'wrap',
justifyContent: 'flex-end',
marginTop: '1.25rem',
}}
>
<button
type="button"
className="btn btn-secondary"
disabled={assignSaving}
onClick={() => setAssignModalOpen(false)}
>
Abbrechen
</button>
<button type="button" className="btn btn-primary" disabled={assignSaving} onClick={saveTrainerAssignModal}>
{assignSaving ? 'Speichern …' : 'Speichern'}
</button>
</div>
</div>
</div>
) : null}
{frameworkImportOpen && (
<div
style={{

View File

@ -1,7 +1,7 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.37"
export const BUILD_DATE = "2026-05-05"
export const APP_VERSION = "0.8.38"
export const BUILD_DATE = "2026-05-06"
export const PAGE_VERSIONS = {
LoginPage: "1.0.0",
@ -10,7 +10,7 @@ export const PAGE_VERSIONS = {
ExercisesPage: "1.2.0", // Massenänderung Sichtbarkeit/Status auf der Liste
ClubsPage: "1.1.0",
SkillsPage: "1.0.0",
TrainingPlanningPage: "1.3.1",
TrainingPlanningPage: "1.4.0",
TrainingFrameworkProgramsListPage: "1.1.0",
TrainingFrameworkProgramEditPage: "1.5.0",
TrainingUnitRunPage: "1.1.0",