feat: update version and enhance training unit management features
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 40s

- Bumped application version to 0.8.11 and updated database schema version.
- Added new API features for training units, including filtering by club and assigned trainer.
- Enhanced the TrainingPlanningPage with options to filter training units by club and assigned trainer, improving user experience.
- Implemented lead trainer assignment functionality, allowing users to take lead on training units.
- Updated changelog with new version details and changes.
This commit is contained in:
Lars 2026-05-05 15:40:29 +02:00
parent e69de82028
commit 0748990328
6 changed files with 511 additions and 26 deletions

View File

@ -0,0 +1,11 @@
-- Migration 038: Optionale verantwortliche Person pro Trainingstermin (Vertretung)
-- Für Vereins-/Trainerübersicht: COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) = wirksamer Leitungstrainer.
ALTER TABLE training_units
ADD COLUMN IF NOT EXISTS lead_trainer_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL;
COMMENT ON COLUMN training_units.lead_trainer_profile_id IS 'Vertretung / expliziter Leiter dieses Terms; NULL = Standard (Haupttrainer der Gruppe)';
CREATE INDEX IF NOT EXISTS idx_training_units_lead_trainer
ON training_units(lead_trainer_profile_id)
WHERE lead_trainer_profile_id IS NOT NULL;

View File

@ -68,6 +68,7 @@ def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]:
cur.execute(
"""
SELECT tu.id, tu.created_by, tu.group_id, tu.plan_template_id, tu.framework_slot_id,
tu.lead_trainer_profile_id,
tg.trainer_id, tg.co_trainer_ids,
fwp.created_by AS framework_created_by
FROM training_units tu
@ -103,6 +104,7 @@ def _assert_training_unit_permission(
unit_row["created_by"] != profile_id
and unit_row["trainer_id"] != profile_id
and profile_id not in co_trainers
and unit_row.get("lead_trainer_profile_id") != profile_id
):
raise HTTPException(status_code=403, detail="Keine Berechtigung")
@ -112,6 +114,69 @@ def _assert_delete_training_unit(role: str, created_by: int, profile_id: int) ->
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: mindestens eine aktive Gruppe im Verein als Trainer/Co-Trainer."""
if role in ("admin", "superadmin"):
return
cur.execute(
"""
SELECT 1 FROM training_groups g
WHERE g.club_id = %s AND g.status = 'active'
AND (
g.trainer_id = %s
OR (g.co_trainer_ids IS NOT NULL AND g.co_trainer_ids @> jsonb_build_array(%s::int))
)
LIMIT 1
""",
(club_id, profile_id, profile_id),
)
if not cur.fetchone():
raise HTTPException(status_code=403, detail="Kein Zugriff auf diesen Verein")
def _normalize_lead_trainer_profile_id(
cur,
group_id: int,
raw_lead: Any,
profile_id: int,
role: str,
) -> Optional[int]:
"""NULL = Vertretung aufheben; sonst Profil-ID mit Profil-Check und Gruppenkontext."""
if raw_lead is None:
return None
if raw_lead in ("", []):
return None
try:
nid = int(raw_lead)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="lead_trainer_profile_id ungültig")
if nid < 1:
raise HTTPException(status_code=400, detail="lead_trainer_profile_id ungültig")
cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,))
if not cur.fetchone():
raise HTTPException(status_code=400, detail="Profil für Leitung nicht gefunden")
if role in ("admin", "superadmin"):
return nid
if nid == profile_id:
return nid
cur.execute(
"SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s",
(group_id,),
)
gr = cur.fetchone()
if not gr:
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
eligible = {gr["trainer_id"]} if gr.get("trainer_id") else set()
for x in gr.get("co_trainer_ids") or []:
eligible.add(x)
if nid in eligible:
return nid
raise HTTPException(
status_code=403,
detail="Lead-Trainer kann nur eigene Person, Haupttrainer oder Co-Trainer der Gruppe sein",
)
# Nachverfolgbarkeit: Übernahmen aus Rahmenprogramm über origin_framework_slot_id
_ORIGIN_LINEAGE_JOIN = """
LEFT JOIN training_framework_slots origin_slot ON origin_slot.id = tu.origin_framework_slot_id
@ -633,29 +698,70 @@ def delete_training_plan_template(template_id: int, session=Depends(require_auth
@router.get("/training-units")
def list_training_units(
group_id: Optional[int] = Query(default=None),
club_id: Optional[int] = Query(default=None),
start_date: Optional[str] = Query(default=None),
end_date: Optional[str] = Query(default=None),
status: Optional[str] = Query(default=None),
assigned_to_me: bool = Query(default=False),
sort: str = Query(default="desc"),
limit: Optional[int] = Query(default=None),
session=Depends(require_auth),
):
profile_id = session["profile_id"]
role = session.get("role")
gid = _optional_positive_int(group_id, "group_id") if group_id else None
cid = _optional_positive_int(club_id, "club_id") if club_id else None
if gid and cid:
raise HTTPException(status_code=400, detail="Nur eines der Parameter group_id oder club_id angeben")
with get_db() as conn:
cur = get_cursor(conn)
if cid and role not in ["admin", "superadmin"]:
_assert_club_visible_for_trainer(cur, cid, profile_id, role)
if gid and role not in ["admin", "superadmin"]:
cur.execute(
"SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s AND status = 'active'",
(gid,),
)
gr = cur.fetchone()
if not gr:
raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden")
cob = gr["co_trainer_ids"] or []
if gr["trainer_id"] != profile_id and profile_id not in cob:
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe")
order_dir = "ASC" if (sort or "").strip().lower() == "asc" else "DESC"
lim: Optional[int] = None
if limit is not None:
try:
lim = int(limit)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="limit ungültig")
if lim < 1:
raise HTTPException(status_code=400, detail="limit ungültig")
lim = min(lim, 250)
query = """
SELECT tu.*,
tg.name as group_name,
tg.weekday as group_weekday,
tg.club_id AS group_club_id,
c.name as club_name,
p.name as trainer_name"""
p.name as trainer_name,
p.name as creator_name,
COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id,
leadp.name AS lead_trainer_name
"""
query += "," + _ORIGIN_LINEAGE_FIELDS
query += """
FROM training_units tu
LEFT JOIN training_groups tg ON tu.group_id = tg.id
LEFT JOIN clubs c ON tg.club_id = c.id
LEFT JOIN profiles p ON tu.created_by = p.id
LEFT JOIN profiles leadp ON leadp.id = COALESCE(tu.lead_trainer_profile_id, tg.trainer_id)
"""
query += _ORIGIN_LINEAGE_JOIN
@ -663,14 +769,28 @@ def list_training_units(
params = []
if role not in ["admin", "superadmin"]:
where.append("(tu.created_by = %s OR tg.trainer_id = %s)")
params.extend([profile_id, profile_id])
where.append(
"(tu.created_by = %s OR tg.trainer_id = %s OR "
"(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> jsonb_build_array(%s::int)))"
)
params.extend([profile_id, profile_id, profile_id])
where.append("tu.framework_slot_id IS NULL")
if group_id:
if gid:
where.append("tu.group_id = %s")
params.append(group_id)
params.append(gid)
if cid:
where.append("tg.club_id = %s")
params.append(cid)
if assigned_to_me:
where.append(
"(COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) = %s OR "
"(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> jsonb_build_array(%s::int)))"
)
params.extend([profile_id, profile_id])
if start_date:
where.append("tu.planned_date >= %s")
@ -687,7 +807,10 @@ def list_training_units(
if where:
query += " WHERE " + " AND ".join(where)
query += " ORDER BY tu.planned_date DESC, tu.planned_time_start DESC"
query += f" ORDER BY tu.planned_date {order_dir}, tu.planned_time_start {order_dir} NULLS LAST"
if lim is not None:
query += " LIMIT %s"
params.append(lim)
cur.execute(query, params)
rows = cur.fetchall()
@ -712,11 +835,17 @@ def get_training_unit(unit_id: int, session=Depends(require_auth)):
tg.location as group_location,
c.name as club_name,
p.name as trainer_name,
p.name as creator_name,
tg.trainer_id AS trainer_id,
tg.co_trainer_ids AS co_trainer_ids,
COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id,
leadp.name AS lead_trainer_name,
""" + _ORIGIN_LINEAGE_FIELDS.strip() + """
FROM training_units tu
LEFT JOIN training_groups tg ON tu.group_id = tg.id
LEFT JOIN clubs c ON tg.club_id = c.id
LEFT JOIN profiles p ON tu.created_by = p.id
LEFT JOIN profiles leadp ON leadp.id = COALESCE(tu.lead_trainer_profile_id, tg.trainer_id)
""" + _ORIGIN_LINEAGE_JOIN.strip() + """
WHERE tu.id = %s
""",
@ -744,16 +873,9 @@ def get_training_unit(unit_id: int, session=Depends(require_auth)):
if unit["created_by"] != profile_id and cb != profile_id:
raise HTTPException(status_code=403, detail="Keine Berechtigung")
else:
gid = unit.get("group_id")
if not gid:
if not unit.get("group_id"):
raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden")
cur.execute("SELECT trainer_id FROM training_groups WHERE id = %s", (gid,))
group = cur.fetchone()
if role not in ["admin", "superadmin"]:
if unit["created_by"] != profile_id and (not group or group["trainer_id"] != profile_id):
raise HTTPException(status_code=403, detail="Keine Berechtigung")
_assert_training_unit_permission(cur, unit, profile_id, role)
_hydrate_training_unit_payload(cur, unit)
return unit
@ -892,8 +1014,21 @@ def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth)
tuple(blueprint_params),
)
else:
lead_sql = ""
lead_params: List[Any] = []
if "lead_trainer_profile_id" in data:
nl = _normalize_lead_trainer_profile_id(
cur,
unit_row["group_id"],
data.get("lead_trainer_profile_id"),
profile_id,
role,
)
lead_sql = ", lead_trainer_profile_id = %s"
lead_params.append(nl)
cur.execute(
"""
f"""
UPDATE training_units SET
planned_date = COALESCE(%s, planned_date),
planned_time_start = %s,
@ -908,6 +1043,7 @@ def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth)
trainer_notes = %s,
plan_template_id = COALESCE(%s, plan_template_id),
updated_at = NOW()
{lead_sql}
WHERE id = %s
""",
(
@ -923,8 +1059,9 @@ def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth)
data.get("notes"),
trainer_notes_val,
tpl_id_val,
unit_id,
),
)
+ tuple(lead_params)
+ (unit_id,),
)
content_handled = False

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.10"
APP_VERSION = "0.8.11"
BUILD_DATE = "2026-05-05"
DB_SCHEMA_VERSION = "20260505037"
DB_SCHEMA_VERSION = "20260505038"
MODULE_VERSIONS = {
"auth": "1.0.0",
@ -14,7 +14,7 @@ MODULE_VERSIONS = {
"exercises": "2.3.0", # Progressionsgraph: Sequenz-API, Varianten-Knoten, UX Ketten (Migration 034)
"training_units": "0.1.0",
"training_programs": "0.1.0",
"planning": "0.5.0",
"planning": "0.6.0",
"import_wiki": "1.0.0",
"admin": "1.0.0",
"membership": "1.0.0",
@ -23,6 +23,15 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.11",
"date": "2026-05-05",
"changes": [
"DB 038: training_units.lead_trainer_profile_id (Vertretung / Leitung pro Termin)",
"API GET /api/training-units: club_id, assigned_to_me, sort, limit; Co-Trainer in Sichtbarkeit; lead_trainer_name / effective_lead_trainer_profile_id",
"API PUT /api/training-units/{id}: lead_trainer_profile_id (Validierung über Gruppe)",
],
},
{
"version": "0.8.10",
"date": "2026-05-05",

View File

@ -1,18 +1,86 @@
import React, { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import EmailVerificationBanner from '../components/EmailVerificationBanner'
function unitWhenLabel(u) {
const d = u.planned_date ? String(u.planned_date).slice(0, 10) : ''
const t = u.planned_time_start ? String(u.planned_time_start).slice(0, 5) : ''
const bits = [d, t].filter(Boolean)
return bits.length ? bits.join(' · ') : 'Termin'
}
function Dashboard() {
const [version, setVersion] = useState(null)
const [profile, setProfile] = useState(null)
const [loading, setLoading] = useState(true)
const [trainingHome, setTrainingHome] = useState(null)
const [trainingHomeErr, setTrainingHomeErr] = useState(null)
const { user } = useAuth()
useEffect(() => {
loadData()
}, [])
useEffect(() => {
if (!user?.id) {
setTrainingHome(null)
setTrainingHomeErr(null)
return undefined
}
let cancelled = false
;(async () => {
setTrainingHomeErr(null)
try {
const today = new Date().toISOString().slice(0, 10)
const [upcomingRaw, recentRaw, plannedPool] = await Promise.all([
api.listTrainingUnits({
assigned_to_me: true,
status: 'planned',
start_date: today,
sort: 'asc',
limit: 8
}),
api.listTrainingUnits({
assigned_to_me: true,
status: 'completed',
sort: 'desc',
limit: 6
}),
api.listTrainingUnits({
assigned_to_me: true,
status: 'planned',
start_date: today,
sort: 'asc',
limit: 40
})
])
const noteHits = (plannedPool || []).filter((u) => {
const tn = (u.trainer_notes || '').trim()
const n = (u.notes || '').trim()
return Boolean(tn || n)
}).slice(0, 5)
if (!cancelled) {
setTrainingHome({
upcoming: Array.isArray(upcomingRaw) ? upcomingRaw : [],
recent: Array.isArray(recentRaw) ? recentRaw : [],
plannedWithNotes: noteHits
})
}
} catch (e) {
if (!cancelled) {
console.error('Dashboard Trainingsübersicht:', e)
setTrainingHomeErr(e.message || 'Konnte Trainingsdaten nicht laden')
setTrainingHome(null)
}
}
})()
return () => {
cancelled = true
}
}, [user?.id])
const loadData = async () => {
try {
const [versionData, profileData] = await Promise.all([
@ -52,6 +120,101 @@ function Dashboard() {
</p>
</div>
{user?.id && (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 280px), 1fr))',
gap: '1rem',
marginBottom: '1.5rem'
}}
>
<div className="card">
<h3 style={{ fontSize: '1.05rem', marginBottom: '0.65rem' }}>Deine nächsten Trainings</h3>
{trainingHomeErr ? (
<p style={{ color: 'var(--danger)', fontSize: '0.9rem' }}>{trainingHomeErr}</p>
) : trainingHome?.upcoming?.length ? (
<ul style={{ margin: 0, paddingLeft: '1.15rem', color: 'var(--text2)', fontSize: '0.9rem', lineHeight: 1.55 }}>
{trainingHome.upcoming.map((u) => (
<li key={u.id} style={{ marginBottom: '0.35rem' }}>
<Link to={`/planning/run/${u.id}`} style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
{unitWhenLabel(u)}
</Link>
{u.group_name ? (
<span style={{ color: 'var(--text3)' }}>{`${u.group_name}`}</span>
) : null}
{u.lead_trainer_name ? (
<span style={{ display: 'block', fontSize: '0.82rem', color: 'var(--text3)', marginTop: '2px' }}>
Leitung: {u.lead_trainer_name}
</span>
) : null}
</li>
))}
</ul>
) : (
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', margin: 0 }}>
Keine anstehenden Termine mit dir als Leitung oder CoTrainer. Unter{' '}
<Link to="/planning" style={{ color: 'var(--accent-dark)' }}>
Trainingsplanung
</Link>{' '}
kannst du den Vereins oder GruppenZeitraum einblenden.
</p>
)}
</div>
<div className="card">
<h3 style={{ fontSize: '1.05rem', marginBottom: '0.65rem' }}>Vermerk / Hinweise (anstehend)</h3>
{trainingHomeErr ? (
<p style={{ color: 'var(--danger)', fontSize: '0.9rem' }}>{trainingHomeErr}</p>
) : trainingHome?.plannedWithNotes?.length ? (
<ul style={{ margin: 0, paddingLeft: '1.15rem', color: 'var(--text2)', fontSize: '0.88rem', lineHeight: 1.5 }}>
{trainingHome.plannedWithNotes.map((u) => {
const snippet = (u.trainer_notes || u.notes || '').trim().slice(0, 120)
return (
<li key={`n-${u.id}`} style={{ marginBottom: '0.5rem' }}>
<Link to={`/planning/run/${u.id}`} style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
{unitWhenLabel(u)}
</Link>
{u.group_name ? <span style={{ color: 'var(--text3)' }}>{` · ${u.group_name}`}</span> : null}
<div style={{ color: 'var(--text2)', marginTop: '4px' }}>
{snippet}
{(u.trainer_notes || u.notes || '').trim().length > 120 ? '…' : ''}
</div>
</li>
)
})}
</ul>
) : (
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', margin: 0 }}>
Keine Einträge mit Allgemein oder TrainerNotizen in deinen nächsten geplanten Terminen.
</p>
)}
</div>
<div className="card">
<h3 style={{ fontSize: '1.05rem', marginBottom: '0.65rem' }}>Rückschau (durchgeführt)</h3>
{trainingHomeErr ? (
<p style={{ color: 'var(--danger)', fontSize: '0.9rem' }}>{trainingHomeErr}</p>
) : trainingHome?.recent?.length ? (
<ul style={{ margin: 0, paddingLeft: '1.15rem', color: 'var(--text2)', fontSize: '0.9rem', lineHeight: 1.55 }}>
{trainingHome.recent.map((u) => (
<li key={`r-${u.id}`} style={{ marginBottom: '0.35rem' }}>
<Link to={`/planning/run/${u.id}`} style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
{(u.actual_date || u.planned_date || '').toString().slice(0, 10) || 'Datum'}
</Link>
{u.group_name ? (
<span style={{ color: 'var(--text3)' }}>{`${u.group_name}`}</span>
) : null}
</li>
))}
</ul>
) : (
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', margin: 0 }}>Noch keine abgeschlossenen Einheiten in der Kurzliste.</p>
)}
</div>
</div>
)}
{/* Status Grid */}
<div style={{
display: 'grid',

View File

@ -105,6 +105,8 @@ function TrainingPlanningPage() {
const [endDate, setEndDate] = useState(thirtyDaysLater)
const [planView, setPlanView] = useState('list')
const [calendarMonthStr, setCalendarMonthStr] = useState(() => today.slice(0, 7))
const [planScope, setPlanScope] = useState('group')
const [assignedToMeOnly, setAssignedToMeOnly] = useState(false)
const [formData, setFormData] = useState({
group_id: '',
@ -162,17 +164,37 @@ function TrainingPlanningPage() {
start = r.gridStart
end = r.gridEnd
}
const gid = parseInt(selectedGroupId, 10)
const groupRow = groups.find((g) => g.id === gid)
const clubId = groupRow?.club_id
try {
const unitsData = await api.listTrainingUnits({
group_id: selectedGroupId,
const filters = {
start_date: start,
end_date: end
})
}
if (assignedToMeOnly) {
filters.assigned_to_me = true
}
if (planScope === 'club' && clubId) {
filters.club_id = clubId
} else {
filters.group_id = gid
}
const unitsData = await api.listTrainingUnits(filters)
setUnits(unitsData)
} catch (err) {
console.error('Failed to load units:', err)
}
}, [selectedGroupId, startDate, endDate, planView, calendarMonthStr])
}, [
selectedGroupId,
groups,
startDate,
endDate,
planView,
calendarMonthStr,
planScope,
assignedToMeOnly
])
useEffect(() => {
loadData()
@ -455,6 +477,16 @@ function TrainingPlanningPage() {
}
}
const handleTakeLead = async (unit) => {
if (!user?.id) return
try {
await api.updateTrainingUnit(unit.id, { lead_trainer_profile_id: user.id })
await loadUnits()
} catch (err) {
alert(err.message || 'Leitung konnte nicht übernommen werden')
}
}
const handleDelete = async (unit) => {
if (!confirm(`Trainingseinheit vom ${unit.planned_date} wirklich löschen?`)) return
try {
@ -759,6 +791,93 @@ function TrainingPlanningPage() {
</button>
</div>
)}
<div
style={{
gridColumn: '1 / -1',
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '12px',
}}
>
<span className="form-label" style={{ marginBottom: 0, alignSelf: 'center' }}>
Einblenden
</span>
<div
role="group"
aria-label="Gruppe oder ganzer Verein"
style={{
display: 'inline-flex',
borderRadius: '10px',
border: '1.5px solid var(--border2)',
overflow: 'hidden',
background: 'var(--surface2)',
}}
>
<button
type="button"
aria-pressed={planScope === 'group'}
disabled={!selectedGroupId}
onClick={() => setPlanScope('group')}
style={{
border: 'none',
padding: '8px 14px',
fontWeight: 600,
fontSize: '0.85rem',
cursor: selectedGroupId ? 'pointer' : 'not-allowed',
opacity: selectedGroupId ? 1 : 0.55,
background: planScope === 'group' ? 'var(--accent-dark)' : 'transparent',
color: planScope === 'group' ? '#fff' : 'var(--text1)',
whiteSpace: 'nowrap',
}}
>
Nur diese Gruppe
</button>
<button
type="button"
aria-pressed={planScope === 'club'}
disabled={!selectedGroupId}
onClick={() => setPlanScope('club')}
style={{
border: 'none',
borderLeft: '1.5px solid var(--border2)',
padding: '8px 14px',
fontWeight: 600,
fontSize: '0.85rem',
cursor: selectedGroupId ? 'pointer' : 'not-allowed',
opacity: selectedGroupId ? 1 : 0.55,
background: planScope === 'club' ? 'var(--accent-dark)' : 'transparent',
color: planScope === 'club' ? '#fff' : 'var(--text1)',
whiteSpace: 'nowrap',
}}
>
Ganzer Verein
</button>
</div>
<label
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
fontSize: '0.88rem',
color: 'var(--text2)',
cursor: selectedGroupId ? 'pointer' : 'default',
opacity: selectedGroupId ? 1 : 0.65,
}}
>
<input
type="checkbox"
checked={assignedToMeOnly}
disabled={!selectedGroupId}
onChange={(e) => setAssignedToMeOnly(e.target.checked)}
/>
Nur meine Zuordnung (Leitung / Co)
</label>
<span style={{ fontSize: '0.78rem', color: 'var(--text3)', lineHeight: 1.4, flex: '1 1 200px' }}>
Ganzer Verein nutzt den Verein der gewählten Gruppe; neue Termine unten gelten weiter für die gewählte Gruppe.
</span>
</div>
</div>
{selectedGroup && (
@ -950,7 +1069,9 @@ function TrainingPlanningPage() {
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'
@ -986,6 +1107,18 @@ function TrainingPlanningPage() {
? `${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
@ -1017,6 +1150,13 @@ function TrainingPlanningPage() {
<div style={{ display: 'grid', gap: '1rem' }}>
{units.map((unit) => {
const lineage = unit.origin_framework_slot_id ? frameworkLineageText(unit) : null
const uid = user?.id != null ? Number(user.id) : null
const effLead =
unit.effective_lead_trainer_profile_id != null
? Number(unit.effective_lead_trainer_profile_id)
: null
const showTakeLead =
unit.status === 'planned' && uid != null && effLead != null && effLead !== uid
return (
<div key={unit.id} className="card">
<div
@ -1035,6 +1175,20 @@ function TrainingPlanningPage() {
{unit.planned_time_start &&
` · ${unit.planned_time_start.slice(0, 5)} - ${unit.planned_time_end?.slice(0, 5)}`}
</h3>
{planScope === 'club' && (unit.group_name || '').trim() ? (
<p
style={{
fontSize: '0.85rem',
color: 'var(--text3)',
margin: '0 0 0.35rem',
}}
>
{unit.group_name}
</p>
) : null}
<p style={{ fontSize: '0.82rem', color: 'var(--text2)', margin: '0 0 0.5rem' }}>
Leitung: {(unit.lead_trainer_name || '').trim() || '—'}
</p>
{unit.planned_focus && (
<p
style={{
@ -1134,6 +1288,11 @@ function TrainingPlanningPage() {
<button className="btn btn-secondary" onClick={() => handleEdit(unit)}>
Bearbeiten
</button>
{showTakeLead ? (
<button type="button" className="btn btn-secondary" onClick={() => handleTakeLead(unit)}>
Ich übernehme
</button>
) : null}
<button
className="btn"
style={{ background: 'var(--danger)', color: 'white', border: 'none' }}

View File

@ -882,15 +882,21 @@ export async function deleteTrainerContext(id) {
// Training Planning
// ============================================================================
/** Query-Parameter wie GET /api/training-units (group_id, start_date, end_date, status). */
/** Query-Parameter wie GET /api/training-units. */
export async function listTrainingUnits(filters = {}) {
const q = new URLSearchParams()
if (filters.group_id != null && filters.group_id !== '') {
q.set('group_id', String(filters.group_id))
}
if (filters.club_id != null && filters.club_id !== '') {
q.set('club_id', String(filters.club_id))
}
if (filters.start_date) q.set('start_date', filters.start_date)
if (filters.end_date) q.set('end_date', filters.end_date)
if (filters.status) q.set('status', filters.status)
if (filters.assigned_to_me === true) q.set('assigned_to_me', 'true')
if (filters.sort) q.set('sort', String(filters.sort))
if (filters.limit != null && filters.limit !== '') q.set('limit', String(filters.limit))
const qs = q.toString()
return request(`/api/training-units${qs ? `?${qs}` : ''}`)
}