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,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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user