diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index 0ccfb80..1eddfea 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -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() diff --git a/backend/version.py b/backend/version.py index ae0835e..ca0e8a0 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx index c4b2049..057b05d 100644 --- a/frontend/src/pages/TrainingPlanningPage.jsx +++ b/frontend/src/pages/TrainingPlanningPage.jsx @@ -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 (
+ {(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} +
++ Mitgliederverzeichnis konnte nicht geladen werden. +
+ ) : null} +