diff --git a/backend/migrations/038_training_unit_lead_trainer.sql b/backend/migrations/038_training_unit_lead_trainer.sql new file mode 100644 index 0000000..1f76c2d --- /dev/null +++ b/backend/migrations/038_training_unit_lead_trainer.sql @@ -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; diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index fd31bdf..2cd7f03 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -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 diff --git a/backend/version.py b/backend/version.py index 3bf97bb..00b7e14 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 5431f5e..f941894 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -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() {

+ {user?.id && ( +
+
+

Deine nächsten Trainings

+ {trainingHomeErr ? ( +

{trainingHomeErr}

+ ) : trainingHome?.upcoming?.length ? ( + + ) : ( +

+ Keine anstehenden Termine mit dir als Leitung oder Co‑Trainer. Unter{' '} + + Trainingsplanung + {' '} + kannst du den Vereins‑ oder Gruppen‑Zeitraum einblenden. +

+ )} +
+ +
+

Vermerk / Hinweise (anstehend)

+ {trainingHomeErr ? ( +

{trainingHomeErr}

+ ) : trainingHome?.plannedWithNotes?.length ? ( + + ) : ( +

+ Keine Einträge mit Allgemein‑ oder Trainer‑Notizen in deinen nächsten geplanten Terminen. +

+ )} +
+ +
+

Rückschau (durchgeführt)

+ {trainingHomeErr ? ( +

{trainingHomeErr}

+ ) : trainingHome?.recent?.length ? ( + + ) : ( +

Noch keine abgeschlossenen Einheiten in der Kurzliste.

+ )} +
+
+ )} + {/* Status Grid */}
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() {
)} + +
+ + Einblenden + +
+ + +
+ + + „Ganzer Verein“ nutzt den Verein der gewählten Gruppe; neue Termine unten gelten weiter für die gewählte Gruppe. + +
{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'} + {planScope === 'club' && (unit.group_name || '').trim() ? ( + + {(unit.group_name || '').trim().length > 22 + ? `${(unit.group_name || '').trim().slice(0, 22)}…` + : unit.group_name} + + ) : null} + {unit.lead_trainer_name?.trim() ? ( + + {unit.lead_trainer_name.trim().split(/\s+/).slice(-1)[0] || unit.lead_trainer_name.trim()} + + ) : null} {unit.planned_focus?.trim() ? ( {(unit.planned_focus || '').trim().length > 24 @@ -1017,6 +1150,13 @@ function TrainingPlanningPage() {
{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 (
+ {planScope === 'club' && (unit.group_name || '').trim() ? ( +

+ {unit.group_name} +

+ ) : null} +

+ Leitung: {(unit.lead_trainer_name || '').trim() || '—'} +

{unit.planned_focus && (

handleEdit(unit)}> Bearbeiten + {showTakeLead ? ( + + ) : null}