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
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:
parent
c778d21b26
commit
9e759a28c6
|
|
@ -180,7 +180,11 @@ def _assert_training_unit_permission(
|
||||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||||
|
|
||||||
co_eff = _effective_co_trainer_ids_for_row(unit_row)
|
co_eff = _effective_co_trainer_ids_for_row(unit_row)
|
||||||
if role not in ["admin", "superadmin"]:
|
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 (
|
if (
|
||||||
unit_row["created_by"] != profile_id
|
unit_row["created_by"] != profile_id
|
||||||
and unit_row["trainer_id"] != profile_id
|
and unit_row["trainer_id"] != profile_id
|
||||||
|
|
@ -190,8 +194,19 @@ def _assert_training_unit_permission(
|
||||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||||
|
|
||||||
|
|
||||||
def _assert_delete_training_unit(role: str, created_by: int, profile_id: int) -> None:
|
def _assert_delete_training_unit(
|
||||||
if role not in ["admin", "superadmin"] and created_by != profile_id:
|
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")
|
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1018,7 +1033,21 @@ def list_training_units(
|
||||||
where = []
|
where = []
|
||||||
params = []
|
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(
|
where.append(
|
||||||
"(tu.created_by = %s OR tg.trainer_id = %s OR tu.lead_trainer_profile_id = %s OR "
|
"(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) "
|
"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,
|
p.name as creator_name,
|
||||||
tg.trainer_id AS trainer_id,
|
tg.trainer_id AS trainer_id,
|
||||||
tg.co_trainer_ids AS co_trainer_ids,
|
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.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id,
|
||||||
COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb)
|
COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb)
|
||||||
AS effective_assistant_trainer_profile_ids,
|
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 = get_cursor(conn)
|
||||||
|
|
||||||
cur.execute(
|
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,),
|
(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.",
|
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,))
|
cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.37"
|
APP_VERSION = "0.8.38"
|
||||||
BUILD_DATE = "2026-05-05"
|
BUILD_DATE = "2026-05-06"
|
||||||
DB_SCHEMA_VERSION = "20260505042"
|
DB_SCHEMA_VERSION = "20260505042"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
|
|
@ -27,6 +27,14 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.37",
|
||||||
"date": "2026-05-05",
|
"date": "2026-05-05",
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,15 @@ function TrainingPlanningPage() {
|
||||||
const [planScope, setPlanScope] = useState('group')
|
const [planScope, setPlanScope] = useState('group')
|
||||||
const [assignedToMeOnly, setAssignedToMeOnly] = useState(false)
|
const [assignedToMeOnly, setAssignedToMeOnly] = useState(false)
|
||||||
const [clubDirectory, setClubDirectory] = useState([])
|
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({
|
const [formData, setFormData] = useState({
|
||||||
group_id: '',
|
group_id: '',
|
||||||
|
|
@ -223,26 +232,57 @@ function TrainingPlanningPage() {
|
||||||
}
|
}
|
||||||
}, [selectedGroupId, loadUnits])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!showModal) {
|
if (!user?.id) {
|
||||||
setClubDirectory([])
|
setMeProfile(null)
|
||||||
return undefined
|
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)
|
const gid = parseInt(formData.group_id || selectedGroupId || '0', 10)
|
||||||
if (!Number.isFinite(gid) || gid < 1) {
|
const gModal = Number.isFinite(gid) && gid >= 1 ? groups.find((x) => x.id === gid) : null
|
||||||
setClubDirectory([])
|
const clubForModal = gModal?.club_id != null ? Number(gModal.club_id) : null
|
||||||
return undefined
|
const loadClubId =
|
||||||
}
|
showModal && clubForModal != null && Number.isFinite(clubForModal)
|
||||||
const g = groups.find((x) => x.id === gid)
|
? clubForModal
|
||||||
const cid = g?.club_id
|
: planScope === 'club' && canClubOrgTraining && selectedGroupClubIdMemo != null
|
||||||
if (!cid) {
|
? selectedGroupClubIdMemo
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (loadClubId == null || !Number.isFinite(loadClubId)) {
|
||||||
setClubDirectory([])
|
setClubDirectory([])
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const d = await api.clubMembersDirectory(cid)
|
const d = await api.clubMembersDirectory(loadClubId)
|
||||||
if (!cancelled) setClubDirectory(Array.isArray(d) ? d : [])
|
if (!cancelled) setClubDirectory(Array.isArray(d) ? d : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
|
@ -254,7 +294,15 @@ function TrainingPlanningPage() {
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [showModal, formData.group_id, selectedGroupId, groups])
|
}, [
|
||||||
|
showModal,
|
||||||
|
formData.group_id,
|
||||||
|
selectedGroupId,
|
||||||
|
groups,
|
||||||
|
planScope,
|
||||||
|
canClubOrgTraining,
|
||||||
|
selectedGroupClubIdMemo,
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!frameworkImportOpen) return
|
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) => {
|
const handleDelete = async (unit) => {
|
||||||
if (!confirm(`Trainingseinheit vom ${unit.planned_date} wirklich löschen?`)) return
|
if (!confirm(`Trainingseinheit vom ${unit.planned_date} wirklich löschen?`)) return
|
||||||
try {
|
try {
|
||||||
|
|
@ -646,6 +735,7 @@ function TrainingPlanningPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedGroup = groups.find((g) => g.id === parseInt(selectedGroupId, 10))
|
const selectedGroup = groups.find((g) => g.id === parseInt(selectedGroupId, 10))
|
||||||
|
const showOrgTrainerAssignControls = planScope === 'club' && canClubOrgTraining
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page">
|
||||||
|
|
@ -943,8 +1033,14 @@ function TrainingPlanningPage() {
|
||||||
/>
|
/>
|
||||||
Nur meine Zuordnung (Leitung / Co)
|
Nur meine Zuordnung (Leitung / Co)
|
||||||
</label>
|
</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.
|
„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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1133,8 +1229,11 @@ function TrainingPlanningPage() {
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
{dayUnits.slice(0, 3).map((unit) => (
|
{dayUnits.slice(0, 3).map((unit) => (
|
||||||
<button
|
<div
|
||||||
key={unit.id}
|
key={unit.id}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '4px', width: '100%' }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleEdit(unit)}
|
onClick={() => handleEdit(unit)}
|
||||||
title={[
|
title={[
|
||||||
|
|
@ -1184,8 +1283,16 @@ function TrainingPlanningPage() {
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{unit.lead_trainer_name?.trim() ? (
|
{unit.lead_trainer_name?.trim() ? (
|
||||||
<span style={{ display: 'block', color: 'var(--text3)', fontWeight: 400, fontSize: '0.62rem' }}>
|
<span
|
||||||
{unit.lead_trainer_name.trim().split(/\s+/).slice(-1)[0] || unit.lead_trainer_name.trim()}
|
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>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{unit.planned_focus?.trim() ? (
|
{unit.planned_focus?.trim() ? (
|
||||||
|
|
@ -1196,6 +1303,20 @@ function TrainingPlanningPage() {
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</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}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{dayUnits.length > 3 ? (
|
{dayUnits.length > 3 ? (
|
||||||
|
|
@ -1379,6 +1500,16 @@ function TrainingPlanningPage() {
|
||||||
<button className="btn btn-secondary" onClick={() => handleEdit(unit)}>
|
<button className="btn btn-secondary" onClick={() => handleEdit(unit)}>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</button>
|
</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 ? (
|
{showTakeLead ? (
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => handleTakeLead(unit)}>
|
<button type="button" className="btn btn-secondary" onClick={() => handleTakeLead(unit)}>
|
||||||
Ich übernehme
|
Ich übernehme
|
||||||
|
|
@ -1405,6 +1536,163 @@ function TrainingPlanningPage() {
|
||||||
</div>
|
</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 && (
|
{frameworkImportOpen && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Shinkan Jinkendo Frontend Version
|
// Shinkan Jinkendo Frontend Version
|
||||||
|
|
||||||
export const APP_VERSION = "0.8.37"
|
export const APP_VERSION = "0.8.38"
|
||||||
export const BUILD_DATE = "2026-05-05"
|
export const BUILD_DATE = "2026-05-06"
|
||||||
|
|
||||||
export const PAGE_VERSIONS = {
|
export const PAGE_VERSIONS = {
|
||||||
LoginPage: "1.0.0",
|
LoginPage: "1.0.0",
|
||||||
|
|
@ -10,7 +10,7 @@ export const PAGE_VERSIONS = {
|
||||||
ExercisesPage: "1.2.0", // Massenänderung Sichtbarkeit/Status auf der Liste
|
ExercisesPage: "1.2.0", // Massenänderung Sichtbarkeit/Status auf der Liste
|
||||||
ClubsPage: "1.1.0",
|
ClubsPage: "1.1.0",
|
||||||
SkillsPage: "1.0.0",
|
SkillsPage: "1.0.0",
|
||||||
TrainingPlanningPage: "1.3.1",
|
TrainingPlanningPage: "1.4.0",
|
||||||
TrainingFrameworkProgramsListPage: "1.1.0",
|
TrainingFrameworkProgramsListPage: "1.1.0",
|
||||||
TrainingFrameworkProgramEditPage: "1.5.0",
|
TrainingFrameworkProgramEditPage: "1.5.0",
|
||||||
TrainingUnitRunPage: "1.1.0",
|
TrainingUnitRunPage: "1.1.0",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user