All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 8s
Test Suite / playwright-tests (push) Successful in 23s
Test Suite / pytest-backend (pull_request) Successful in 23s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 6s
Test Suite / playwright-tests (pull_request) Successful in 30s
- Updated visibility logic for exercises, media assets, and training programs to ensure access is correctly managed based on active club memberships. - Refactored SQL queries to streamline visibility checks for platform admins and club members, ensuring only relevant content is displayed. - Improved user interface elements to reflect the status of club memberships, including visual indicators for inactive memberships. - Enhanced test cases to validate the new visibility logic and ensure proper access control across various components.
1483 lines
59 KiB
JavaScript
1483 lines
59 KiB
JavaScript
import React, { useState, useEffect, useMemo } from 'react'
|
|
import api from '../utils/api'
|
|
import { notifyOrgInboxChanged } from '../context/OrgInboxContext'
|
|
import { useAuth } from '../context/AuthContext'
|
|
import { activeClubMemberships } from '../utils/activeClub'
|
|
import PageSectionNav from '../components/PageSectionNav'
|
|
|
|
const CLUB_ROLE_OPTIONS = [
|
|
{ code: 'club_admin', label: 'Vereinsadmin' },
|
|
{ code: 'trainer', label: 'Trainer' },
|
|
{ code: 'division_lead', label: 'Spartenleitung' },
|
|
{ code: 'content_editor', label: 'Inhalte bearbeiten' },
|
|
]
|
|
|
|
function ClubsPage() {
|
|
const { user } = useAuth()
|
|
const [activeTab, setActiveTab] = useState('clubs')
|
|
const [clubs, setClubs] = useState([])
|
|
const [divisions, setDivisions] = useState([])
|
|
const [groups, setGroups] = useState([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [showModal, setShowModal] = useState(false)
|
|
const [editing, setEditing] = useState(null)
|
|
const [modalType, setModalType] = useState('club')
|
|
|
|
const [membersAdminClubId, setMembersAdminClubId] = useState(null)
|
|
const [clubMembersAdmin, setClubMembersAdmin] = useState([])
|
|
const [joinRequestsAdmin, setJoinRequestsAdmin] = useState([])
|
|
const [membersAdminLoading, setMembersAdminLoading] = useState(false)
|
|
const [groupMemberDirectory, setGroupMemberDirectory] = useState([])
|
|
|
|
const [showAddMemberModal, setShowAddMemberModal] = useState(false)
|
|
const [profilesAdminList, setProfilesAdminList] = useState([])
|
|
const [addMemberForm, setAddMemberForm] = useState({ profile_id: '', roles: ['trainer'] })
|
|
|
|
const [editMemberModal, setEditMemberModal] = useState(null)
|
|
const [acceptJoinModal, setAcceptJoinModal] = useState(null)
|
|
|
|
// Form state
|
|
const [formData, setFormData] = useState({})
|
|
|
|
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
|
const isSuperAdmin = user?.role === 'superadmin'
|
|
const clubAdminClubIds = new Set(
|
|
activeClubMemberships(user?.clubs)
|
|
.filter((c) => (c.roles || []).includes('club_admin'))
|
|
.map((c) => c.id)
|
|
)
|
|
const canManageClub = (clubId) => isPlatformAdmin || clubAdminClubIds.has(clubId)
|
|
const canCreateClub = isPlatformAdmin
|
|
const canManageOrgSomewhere = isPlatformAdmin || clubAdminClubIds.size > 0
|
|
const canCreateTrainingGroup =
|
|
isPlatformAdmin || activeClubMemberships(user?.clubs).length > 0
|
|
|
|
const canEditGroup = (g) =>
|
|
isPlatformAdmin ||
|
|
clubAdminClubIds.has(g.club_id) ||
|
|
g.trainer_id === user?.id ||
|
|
(Array.isArray(g.co_trainer_ids) && g.co_trainer_ids.includes(user?.id))
|
|
|
|
const canDeleteGroup = (g) => isPlatformAdmin || clubAdminClubIds.has(g.club_id)
|
|
useEffect(() => {
|
|
loadData()
|
|
}, [])
|
|
|
|
const loadData = async () => {
|
|
try {
|
|
const [clubsData, divisionsData, groupsData] = await Promise.all([
|
|
api.listClubs(),
|
|
api.listDivisions(),
|
|
api.listTrainingGroups()
|
|
])
|
|
setClubs(clubsData)
|
|
setDivisions(divisionsData)
|
|
setGroups(groupsData)
|
|
} catch (err) {
|
|
console.error('Failed to load data:', err)
|
|
alert('Fehler beim Laden: ' + err.message)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!clubs.length) {
|
|
setMembersAdminClubId(null)
|
|
return
|
|
}
|
|
const mcl = clubs.filter((c) => isPlatformAdmin || clubAdminClubIds.has(c.id))
|
|
if (!mcl.length) {
|
|
setMembersAdminClubId(null)
|
|
return
|
|
}
|
|
setMembersAdminClubId((prev) =>
|
|
prev != null && mcl.some((x) => x.id === prev) ? prev : mcl[0].id
|
|
)
|
|
}, [clubs, isPlatformAdmin, user?.clubs])
|
|
|
|
useEffect(() => {
|
|
if (activeTab !== 'members' || !membersAdminClubId || !canManageClub(membersAdminClubId)) return
|
|
let cancelled = false
|
|
setMembersAdminLoading(true)
|
|
Promise.all([
|
|
api.listClubMembers(membersAdminClubId, { includeInactive: true }),
|
|
api.listClubJoinRequests(membersAdminClubId),
|
|
])
|
|
.then(([m, j]) => {
|
|
if (!cancelled) {
|
|
setClubMembersAdmin(m)
|
|
setJoinRequestsAdmin(j)
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
if (!cancelled) alert('Mitglieder/Anträge: ' + err.message)
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setMembersAdminLoading(false)
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [activeTab, membersAdminClubId])
|
|
|
|
useEffect(() => {
|
|
if (!showModal || modalType !== 'group' || !formData.club_id) {
|
|
setGroupMemberDirectory([])
|
|
return
|
|
}
|
|
let cancelled = false
|
|
api
|
|
.clubMembersDirectory(formData.club_id)
|
|
.then((rows) => {
|
|
if (!cancelled) setGroupMemberDirectory(rows)
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setGroupMemberDirectory([])
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [showModal, modalType, formData.club_id])
|
|
|
|
useEffect(() => {
|
|
if (!showAddMemberModal || !isPlatformAdmin) return
|
|
api.listProfiles().then(setProfilesAdminList).catch(() => setProfilesAdminList([]))
|
|
}, [showAddMemberModal, isPlatformAdmin])
|
|
|
|
const handleCreate = (type) => {
|
|
setEditing(null)
|
|
setModalType(type)
|
|
|
|
if (type === 'club') {
|
|
setFormData({
|
|
name: '',
|
|
abbreviation: '',
|
|
description: '',
|
|
status: 'active',
|
|
primary_admin_profile_id: user?.id ?? '',
|
|
})
|
|
} else if (type === 'division') {
|
|
setFormData({ club_id: '', name: '', focus_area: '' })
|
|
} else if (type === 'group') {
|
|
setFormData({
|
|
club_id: '',
|
|
division_id: '',
|
|
name: '',
|
|
focus: '',
|
|
level: '',
|
|
age_group: '',
|
|
weekday: '',
|
|
time_start: '',
|
|
time_end: '',
|
|
location: '',
|
|
trainer_id: user?.id ?? '',
|
|
co_trainer_ids: [],
|
|
status: 'active'
|
|
})
|
|
}
|
|
|
|
setShowModal(true)
|
|
}
|
|
|
|
const manageableClubs = clubs.filter((c) => canManageClub(c.id))
|
|
|
|
const reloadMembersAdmin = async () => {
|
|
if (!membersAdminClubId || !canManageClub(membersAdminClubId)) return
|
|
try {
|
|
const [m, j] = await Promise.all([
|
|
api.listClubMembers(membersAdminClubId, { includeInactive: true }),
|
|
api.listClubJoinRequests(membersAdminClubId),
|
|
])
|
|
setClubMembersAdmin(m)
|
|
setJoinRequestsAdmin(j)
|
|
} catch (err) {
|
|
console.error(err)
|
|
}
|
|
}
|
|
|
|
const toggleMembersAdminClubAccess = async (m, activate) => {
|
|
if (!membersAdminClubId || !canManageClub(membersAdminClubId)) return
|
|
const st = activate ? 'active' : 'inactive'
|
|
if (
|
|
!activate &&
|
|
!confirm(
|
|
`Vereinszugang für „${m.name || m.email || '#' + m.profile_id}“ hier deaktivieren? Login bleibt möglich, Vereinsinhalte nicht — auch bei Super-Admins.`,
|
|
)
|
|
) {
|
|
return
|
|
}
|
|
try {
|
|
await api.updateClubMember(membersAdminClubId, m.profile_id, {
|
|
roles: [...(m.roles || [])],
|
|
status: st,
|
|
})
|
|
await reloadMembersAdmin()
|
|
} catch (err) {
|
|
alert(err.message || String(err))
|
|
}
|
|
}
|
|
|
|
const handleEdit = (item, type) => {
|
|
setEditing(item)
|
|
setModalType(type)
|
|
if (type === 'group') {
|
|
const co = item.co_trainer_ids
|
|
setFormData({
|
|
...item,
|
|
co_trainer_ids: Array.isArray(co) ? co : [],
|
|
})
|
|
} else {
|
|
setFormData({ ...item })
|
|
}
|
|
setShowModal(true)
|
|
}
|
|
|
|
const handleDelete = async (item, type) => {
|
|
const confirmMsg = {
|
|
club: `Verein "${item.name}" wirklich löschen? Alle Sparten und Gruppen werden auch gelöscht!`,
|
|
division: `Sparte "${item.name}" wirklich löschen?`,
|
|
group: `Trainingsgruppe "${item.name}" wirklich löschen?`
|
|
}
|
|
|
|
if (!confirm(confirmMsg[type])) return
|
|
|
|
try {
|
|
if (type === 'club') await api.deleteClub(item.id)
|
|
else if (type === 'division') await api.deleteDivision(item.id)
|
|
else if (type === 'group') await api.deleteTrainingGroup(item.id)
|
|
|
|
await loadData()
|
|
} catch (err) {
|
|
alert('Fehler beim Löschen: ' + err.message)
|
|
}
|
|
}
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault()
|
|
|
|
try {
|
|
if (modalType === 'club') {
|
|
if (editing) {
|
|
await api.updateClub(editing.id, formData)
|
|
} else {
|
|
const payload = {
|
|
...formData,
|
|
primary_admin_profile_id: Number(formData.primary_admin_profile_id),
|
|
}
|
|
if (!payload.primary_admin_profile_id) {
|
|
alert('Hauptverwalter (Profil-ID) ist Pflicht.')
|
|
return
|
|
}
|
|
await api.createClub(payload)
|
|
}
|
|
} else if (modalType === 'division') {
|
|
if (editing) {
|
|
await api.updateDivision(editing.id, formData)
|
|
} else {
|
|
await api.createDivision(formData)
|
|
}
|
|
} else if (modalType === 'group') {
|
|
const trainerRaw = formData.trainer_id
|
|
const trainer_id =
|
|
trainerRaw === '' || trainerRaw === undefined || trainerRaw === null
|
|
? null
|
|
: Number(trainerRaw)
|
|
const coRaw = formData.co_trainer_ids
|
|
const co_trainer_ids = Array.isArray(coRaw)
|
|
? coRaw.map((x) => Number(x)).filter((n) => Number.isFinite(n))
|
|
: []
|
|
const payload = {
|
|
...formData,
|
|
trainer_id: Number.isFinite(trainer_id) ? trainer_id : null,
|
|
co_trainer_ids,
|
|
}
|
|
if (editing) {
|
|
await api.updateTrainingGroup(editing.id, payload)
|
|
} else {
|
|
await api.createTrainingGroup(payload)
|
|
}
|
|
}
|
|
|
|
setShowModal(false)
|
|
await loadData()
|
|
} catch (err) {
|
|
alert('Fehler beim Speichern: ' + err.message)
|
|
}
|
|
}
|
|
|
|
const updateFormField = (field, value) => {
|
|
setFormData(prev => ({ ...prev, [field]: value }))
|
|
}
|
|
|
|
const clubTabItems = useMemo(() => {
|
|
const ids = canManageOrgSomewhere
|
|
? ['clubs', 'divisions', 'groups', 'members']
|
|
: ['clubs', 'divisions', 'groups']
|
|
const labels = {
|
|
clubs: 'Vereine',
|
|
divisions: 'Sparten',
|
|
groups: 'Trainingsgruppen',
|
|
members: 'Mitglieder',
|
|
}
|
|
return ids.map((id) => ({ id, label: labels[id] }))
|
|
}, [canManageOrgSomewhere])
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="skills-page__loading">
|
|
<div className="spinner"></div>
|
|
<p>Laden...</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="app-page clubs-page">
|
|
<h1 className="page-title">Vereinsverwaltung</h1>
|
|
<p className="clubs-page__intro muted">
|
|
Für die Trainingsplanung wird mindestens ein <strong>Verein</strong> und eine <strong>Trainingsgruppe</strong> gebraucht.
|
|
Sparten sind optional — typische Eckdaten einer Gruppe (Wochentag, Zeit, Ort) kannst du schrittweise eintragen.
|
|
</p>
|
|
|
|
<PageSectionNav
|
|
ariaLabel="Vereinsverwaltung"
|
|
value={activeTab}
|
|
onChange={setActiveTab}
|
|
items={clubTabItems}
|
|
/>
|
|
|
|
{/* Clubs Tab */}
|
|
{activeTab === 'clubs' && (
|
|
<>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
|
<h2>Vereine</h2>
|
|
{canCreateClub && (
|
|
<button className="btn btn-primary" onClick={() => handleCreate('club')}>
|
|
+ Neuer Verein
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{clubs.length === 0 ? (
|
|
<div className="card">
|
|
<p style={{ color: 'var(--text2)', textAlign: 'center', marginBottom: canCreateClub ? '1rem' : 0 }}>
|
|
Noch kein Verein angelegt.
|
|
{canCreateClub
|
|
? ' Nutze „+ Neuer Verein“ — ein Name reicht zum Start.'
|
|
: ' Bitte einen Administrator oder Support um Anlage.'}
|
|
</p>
|
|
{canCreateClub && (
|
|
<p style={{ color: 'var(--text2)', textAlign: 'center', fontSize: '0.9rem' }}>
|
|
Danach im Tab Trainingsgruppen eine Gruppe diesem Verein zuordnen; Details sind optional.
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
|
{clubs.map(club => (
|
|
<div key={club.id} className="card">
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
|
<div style={{ flex: 1 }}>
|
|
<h3 style={{ marginBottom: '0.5rem' }}>
|
|
{club.name}
|
|
{club.abbreviation && (
|
|
<span style={{ color: 'var(--text2)', fontSize: '0.875rem', marginLeft: '0.5rem' }}>
|
|
({club.abbreviation})
|
|
</span>
|
|
)}
|
|
</h3>
|
|
{club.description && (
|
|
<p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>
|
|
{club.description}
|
|
</p>
|
|
)}
|
|
<span style={{
|
|
display: 'inline-block',
|
|
marginTop: '0.5rem',
|
|
fontSize: '0.75rem',
|
|
padding: '0.25rem 0.5rem',
|
|
borderRadius: '4px',
|
|
background: club.status === 'active' ? '#2ea44f' : 'var(--surface2)',
|
|
color: club.status === 'active' ? 'white' : 'var(--text2)'
|
|
}}>
|
|
{club.status}
|
|
</span>
|
|
</div>
|
|
{canManageClub(club.id) && (
|
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
<button
|
|
className="btn btn-secondary"
|
|
onClick={() => handleEdit(club, 'club')}
|
|
>
|
|
Bearbeiten
|
|
</button>
|
|
{isSuperAdmin && (
|
|
<button
|
|
className="btn"
|
|
style={{
|
|
background: 'var(--danger)',
|
|
color: 'white',
|
|
border: 'none'
|
|
}}
|
|
onClick={() => handleDelete(club, 'club')}
|
|
>
|
|
Löschen
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Divisions Tab */}
|
|
{activeTab === 'divisions' && (
|
|
<>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
|
<h2>Sparten</h2>
|
|
{canManageOrgSomewhere && (
|
|
<button className="btn btn-primary" onClick={() => handleCreate('division')}>
|
|
+ Neue Sparte
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{divisions.length === 0 ? (
|
|
<div className="card">
|
|
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
|
Keine Sparten gefunden
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
|
{divisions.map(division => (
|
|
<div key={division.id} className="card">
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
|
<div style={{ flex: 1 }}>
|
|
<h3 style={{ marginBottom: '0.5rem' }}>{division.name}</h3>
|
|
<p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>
|
|
Verein: {division.club_name}
|
|
</p>
|
|
{division.focus_area && (
|
|
<span style={{
|
|
display: 'inline-block',
|
|
marginTop: '0.5rem',
|
|
fontSize: '0.75rem',
|
|
padding: '0.25rem 0.5rem',
|
|
borderRadius: '4px',
|
|
background: 'var(--surface2)',
|
|
color: 'var(--text2)'
|
|
}}>
|
|
{division.focus_area}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{canManageClub(division.club_id) && (
|
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
<button
|
|
className="btn btn-secondary"
|
|
onClick={() => handleEdit(division, 'division')}
|
|
>
|
|
Bearbeiten
|
|
</button>
|
|
<button
|
|
className="btn"
|
|
style={{
|
|
background: 'var(--danger)',
|
|
color: 'white',
|
|
border: 'none'
|
|
}}
|
|
onClick={() => handleDelete(division, 'division')}
|
|
>
|
|
Löschen
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Training Groups Tab */}
|
|
{activeTab === 'groups' && (
|
|
<>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
|
<h2>Trainingsgruppen</h2>
|
|
{canCreateTrainingGroup && (
|
|
<button className="btn btn-primary" onClick={() => handleCreate('group')}>
|
|
+ Neue Gruppe
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{groups.length === 0 ? (
|
|
<div className="card">
|
|
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
|
Keine Trainingsgruppen gefunden
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="card-grid clubs-groups-card-grid"
|
|
style={{
|
|
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
|
gap: '1rem'
|
|
}}
|
|
>
|
|
{groups.map(group => (
|
|
<div key={group.id} className="card">
|
|
<h3 style={{ marginBottom: '0.5rem' }}>{group.name}</h3>
|
|
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '0.5rem' }}>
|
|
{group.club_name}
|
|
{group.division_name && ` · ${group.division_name}`}
|
|
</p>
|
|
|
|
<div style={{ fontSize: '0.875rem', color: 'var(--text2)', marginBottom: '1rem' }}>
|
|
{group.weekday && group.time_start && (
|
|
<div>📅 {group.weekday}, {group.time_start.slice(0,5)} - {group.time_end?.slice(0,5)}</div>
|
|
)}
|
|
{group.location && <div>📍 {group.location}</div>}
|
|
{group.trainer_name && <div>👤 {group.trainer_name}</div>}
|
|
{group.level && <div>⭐ {group.level}</div>}
|
|
{group.age_group && <div>👶 {group.age_group}</div>}
|
|
</div>
|
|
|
|
{canEditGroup(group) && (
|
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
|
|
<button
|
|
className="btn btn-secondary"
|
|
style={{ flex: 1 }}
|
|
onClick={() => handleEdit(group, 'group')}
|
|
>
|
|
Bearbeiten
|
|
</button>
|
|
{canDeleteGroup(group) && (
|
|
<button
|
|
className="btn"
|
|
style={{
|
|
background: 'var(--danger)',
|
|
color: 'white',
|
|
border: 'none'
|
|
}}
|
|
onClick={() => handleDelete(group, 'group')}
|
|
>
|
|
Löschen
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{activeTab === 'members' && canManageOrgSomewhere && (
|
|
<>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem', flexWrap: 'wrap', gap: '0.75rem' }}>
|
|
<h2>Mitglieder & Beitrittsanträge</h2>
|
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
|
<label style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>Verein</label>
|
|
<select
|
|
className="form-input"
|
|
style={{ minWidth: '200px' }}
|
|
value={membersAdminClubId ?? ''}
|
|
onChange={(e) =>
|
|
setMembersAdminClubId(e.target.value ? parseInt(e.target.value, 10) : null)
|
|
}
|
|
>
|
|
{manageableClubs.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
onClick={() => {
|
|
setAddMemberForm({ profile_id: '', roles: ['trainer'] })
|
|
setShowAddMemberModal(true)
|
|
}}
|
|
>
|
|
Mitglied hinzufügen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{manageableClubs.length === 0 ? (
|
|
<div className="card">
|
|
<p style={{ color: 'var(--text2)' }}>
|
|
Keine Vereine, für die du Mitglieder verwalten darfst.
|
|
</p>
|
|
</div>
|
|
) : membersAdminLoading ? (
|
|
<p style={{ color: 'var(--text2)' }}>Laden…</p>
|
|
) : (
|
|
<>
|
|
{joinRequestsAdmin.length > 0 && (
|
|
<div className="card" style={{ marginBottom: '1rem' }}>
|
|
<h3 style={{ marginTop: 0 }}>Offene Beitrittsanträge</h3>
|
|
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
|
{joinRequestsAdmin.map((req) => (
|
|
<div
|
|
key={req.id}
|
|
style={{
|
|
padding: '0.75rem',
|
|
borderRadius: '8px',
|
|
border: '1px solid var(--border)',
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
flexWrap: 'wrap',
|
|
gap: '0.5rem',
|
|
}}
|
|
>
|
|
<div>
|
|
<strong>{req.applicant_name || req.applicant_email || 'Nutzer'}</strong>
|
|
<div style={{ fontSize: '0.85rem', color: 'var(--text2)' }}>
|
|
{req.applicant_email} · Profil #{req.profile_id}
|
|
</div>
|
|
{req.message ? (
|
|
<div style={{ marginTop: '0.35rem', fontSize: '0.875rem' }}>{req.message}</div>
|
|
) : null}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '0.35rem', flexWrap: 'wrap' }}>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
onClick={() =>
|
|
setAcceptJoinModal({
|
|
id: req.id,
|
|
label: req.applicant_name || req.applicant_email,
|
|
roles: ['trainer'],
|
|
})
|
|
}
|
|
>
|
|
Annehmen
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
onClick={async () => {
|
|
if (!confirm('Antrag ablehnen?')) return
|
|
try {
|
|
await api.rejectClubJoinRequest(membersAdminClubId, req.id)
|
|
notifyOrgInboxChanged()
|
|
await reloadMembersAdmin()
|
|
} catch (err) {
|
|
alert(err.message || String(err))
|
|
}
|
|
}}
|
|
>
|
|
Ablehnen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="card">
|
|
<h3 style={{ marginTop: 0 }}>Mitglieder</h3>
|
|
{isPlatformAdmin ? (
|
|
<p
|
|
className="muted"
|
|
style={{ fontSize: '0.85rem', marginTop: '-0.35rem', marginBottom: '0.85rem', lineHeight: 1.45 }}
|
|
>
|
|
Liste enthält <strong style={{ color: 'var(--text1)' }}>aktive und deaktivierte</strong> Vereinszugänge.
|
|
Deaktiviert gilt pro Verein (ohne Kontosperre) — auch für Super-Admins ohne aktive Mitgliedschaft in diesem
|
|
Verein kein Zugriff auf dessen Vereinsinhalte. Wiederherstellen über die Schaltflächen oder Mitglied
|
|
bearbeiten.
|
|
</p>
|
|
) : (
|
|
<p
|
|
className="muted"
|
|
style={{ fontSize: '0.85rem', marginTop: '-0.35rem', marginBottom: '0.85rem', lineHeight: 1.45 }}
|
|
>
|
|
Deaktivierte Vereinszugänge sind hervorgehoben —{' '}
|
|
<strong>Anmeldung</strong> bleibt möglich, <strong>Vereinsinhalte</strong> dieser Zuordnung nicht.
|
|
</p>
|
|
)}
|
|
{clubMembersAdmin.length === 0 ? (
|
|
<p style={{ color: 'var(--text2)' }}>Noch keine Mitglieder erfasst.</p>
|
|
) : (
|
|
<div style={{ display: 'grid', gap: '0.65rem' }}>
|
|
{clubMembersAdmin.map((m) => {
|
|
const memStatus = (m.status || 'active').toLowerCase()
|
|
const inactiveRow = memStatus === 'inactive'
|
|
const portalLabel = (m.portal_role || '').trim()
|
|
return (
|
|
<div
|
|
key={m.membership_id}
|
|
style={{
|
|
padding: '0.65rem',
|
|
borderRadius: '8px',
|
|
background: inactiveRow ? 'color-mix(in srgb, var(--warning, #884400) 12%, var(--surface2))' : 'var(--surface2)',
|
|
border: inactiveRow ? '1px solid color-mix(in srgb, var(--warning, #d4a012) 40%, transparent)' : undefined,
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
flexWrap: 'wrap',
|
|
gap: '0.5rem',
|
|
}}
|
|
>
|
|
<div>
|
|
<strong>{m.name || m.email}</strong>
|
|
<div style={{ fontSize: '0.8rem', color: 'var(--text2)' }}>
|
|
{m.email} · #{m.profile_id}
|
|
{' · '}
|
|
<span style={{ fontWeight: 600 }}>
|
|
Vereinszugang:{' '}
|
|
<span style={{ color: inactiveRow ? 'var(--warning, #d4a012)' : 'inherit' }}>
|
|
{inactiveRow ? 'deaktiviert' : 'aktiv'}
|
|
</span>
|
|
</span>
|
|
{portalLabel ? (
|
|
<span style={{ color: 'var(--text3)', marginLeft: '0.35rem' }}> · Portal: {portalLabel}</span>
|
|
) : null}
|
|
</div>
|
|
<div style={{ fontSize: '0.8rem', marginTop: '0.25rem' }}>
|
|
Rollen: {(m.roles || []).join(', ') || '—'}
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', alignItems: 'stretch' }}>
|
|
<button type="button" className="btn btn-secondary" onClick={() => setEditMemberModal(m)}>
|
|
Mitglied bearbeiten
|
|
</button>
|
|
{m.profile_id !== user?.id ? (
|
|
inactiveRow ? (
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
onClick={() => toggleMembersAdminClubAccess(m, true)}
|
|
>
|
|
Vereinszugang aktivieren
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
onClick={() => toggleMembersAdminClubAccess(m, false)}
|
|
>
|
|
Vereinszugang deaktivieren
|
|
</button>
|
|
)
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Modal */}
|
|
{showModal && (
|
|
<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: 1000,
|
|
padding: '1rem'
|
|
}}>
|
|
<div style={{
|
|
background: 'var(--surface)',
|
|
borderRadius: '12px',
|
|
padding: '2rem',
|
|
maxWidth: '600px',
|
|
width: '100%',
|
|
maxHeight: '90vh',
|
|
overflowY: 'auto'
|
|
}}>
|
|
<h2 style={{ marginBottom: '1.5rem' }}>
|
|
{editing
|
|
? (modalType === 'club' ? 'Verein bearbeiten' : modalType === 'division' ? 'Sparte bearbeiten' : 'Gruppe bearbeiten')
|
|
: (modalType === 'club' ? 'Neuer Verein' : modalType === 'division' ? 'Neue Sparte' : 'Neue Gruppe')
|
|
}
|
|
</h2>
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
{/* Club Form */}
|
|
{modalType === 'club' && (
|
|
<>
|
|
<div className="form-row">
|
|
<label className="form-label">Name *</label>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
value={formData.name || ''}
|
|
onChange={(e) => updateFormField('name', e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Kürzel</label>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
value={formData.abbreviation || ''}
|
|
onChange={(e) => updateFormField('abbreviation', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Beschreibung</label>
|
|
<textarea
|
|
className="form-input"
|
|
rows={3}
|
|
value={formData.description || ''}
|
|
onChange={(e) => updateFormField('description', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Status</label>
|
|
<select
|
|
className="form-input"
|
|
value={formData.status || 'active'}
|
|
onChange={(e) => updateFormField('status', e.target.value)}
|
|
>
|
|
<option value="active">Aktiv</option>
|
|
<option value="inactive">Inaktiv</option>
|
|
</select>
|
|
</div>
|
|
|
|
{!editing && canCreateClub && (
|
|
<div className="form-row">
|
|
<label className="form-label">Hauptverwalter (Profil-ID) *</label>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
className="form-input"
|
|
value={formData.primary_admin_profile_id ?? ''}
|
|
onChange={(e) =>
|
|
updateFormField(
|
|
'primary_admin_profile_id',
|
|
e.target.value === '' ? '' : parseInt(e.target.value, 10)
|
|
)
|
|
}
|
|
required
|
|
/>
|
|
<p style={{ fontSize: '0.8rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
|
|
Nur Plattform-Administratoren legen Vereine an. Standard ist deine eigene Profil-ID.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Division Form */}
|
|
{modalType === 'division' && (
|
|
<>
|
|
<div className="form-row">
|
|
<label className="form-label">Verein *</label>
|
|
<select
|
|
className="form-input"
|
|
value={formData.club_id || ''}
|
|
onChange={(e) => updateFormField('club_id', parseInt(e.target.value))}
|
|
required
|
|
disabled={editing}
|
|
>
|
|
<option value="">Bitte wählen</option>
|
|
{clubs.map(club => (
|
|
<option key={club.id} value={club.id}>{club.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Name *</label>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
value={formData.name || ''}
|
|
onChange={(e) => updateFormField('name', e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Fokusbereich</label>
|
|
<select
|
|
className="form-input"
|
|
value={formData.focus_area || ''}
|
|
onChange={(e) => updateFormField('focus_area', e.target.value)}
|
|
>
|
|
<option value="">Bitte wählen</option>
|
|
<option value="karate">Karate</option>
|
|
<option value="selbstverteidigung">Selbstverteidigung</option>
|
|
<option value="gewaltschutz">Gewaltschutz</option>
|
|
</select>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Group Form */}
|
|
{modalType === 'group' && (
|
|
<>
|
|
<div className="form-row">
|
|
<label className="form-label">Verein *</label>
|
|
<select
|
|
className="form-input"
|
|
value={formData.club_id || ''}
|
|
onChange={(e) => updateFormField('club_id', parseInt(e.target.value))}
|
|
required
|
|
disabled={editing}
|
|
>
|
|
<option value="">Bitte wählen</option>
|
|
{clubs.map(club => (
|
|
<option key={club.id} value={club.id}>{club.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Sparte (optional)</label>
|
|
<select
|
|
className="form-input"
|
|
value={formData.division_id || ''}
|
|
onChange={(e) => updateFormField('division_id', e.target.value ? parseInt(e.target.value) : null)}
|
|
>
|
|
<option value="">Keine Sparte</option>
|
|
{divisions.filter(d => d.club_id === formData.club_id).map(div => (
|
|
<option key={div.id} value={div.id}>{div.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Name *</label>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
value={formData.name || ''}
|
|
onChange={(e) => updateFormField('name', e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
|
<div className="form-row">
|
|
<label className="form-label">Level</label>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
value={formData.level || ''}
|
|
onChange={(e) => updateFormField('level', e.target.value)}
|
|
placeholder="z.B. Anfänger, Fortgeschritten"
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Altersgruppe</label>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
value={formData.age_group || ''}
|
|
onChange={(e) => updateFormField('age_group', e.target.value)}
|
|
placeholder="z.B. Kinder, Erwachsene"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '1rem' }}>
|
|
<div className="form-row">
|
|
<label className="form-label">Wochentag</label>
|
|
<select
|
|
className="form-input"
|
|
value={formData.weekday || ''}
|
|
onChange={(e) => updateFormField('weekday', e.target.value)}
|
|
>
|
|
<option value="">-</option>
|
|
<option value="Montag">Montag</option>
|
|
<option value="Dienstag">Dienstag</option>
|
|
<option value="Mittwoch">Mittwoch</option>
|
|
<option value="Donnerstag">Donnerstag</option>
|
|
<option value="Freitag">Freitag</option>
|
|
<option value="Samstag">Samstag</option>
|
|
<option value="Sonntag">Sonntag</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Von</label>
|
|
<input
|
|
type="time"
|
|
className="form-input"
|
|
value={formData.time_start || ''}
|
|
onChange={(e) => updateFormField('time_start', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Bis</label>
|
|
<input
|
|
type="time"
|
|
className="form-input"
|
|
value={formData.time_end || ''}
|
|
onChange={(e) => updateFormField('time_end', e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Ort</label>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
value={formData.location || ''}
|
|
onChange={(e) => updateFormField('location', e.target.value)}
|
|
placeholder="z.B. Sporthalle Musterstadt"
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Fokus</label>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
value={formData.focus || ''}
|
|
onChange={(e) => updateFormField('focus', e.target.value)}
|
|
placeholder="z.B. Kata, Kumite"
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Haupttrainer</label>
|
|
<select
|
|
className="form-input"
|
|
value={formData.trainer_id != null ? String(formData.trainer_id) : ''}
|
|
onChange={(e) =>
|
|
updateFormField(
|
|
'trainer_id',
|
|
e.target.value === '' ? '' : parseInt(e.target.value, 10)
|
|
)
|
|
}
|
|
>
|
|
<option value="">—</option>
|
|
{groupMemberDirectory.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{(p.name || p.email || '').trim()} (#{p.id})
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p style={{ fontSize: '0.75rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
|
|
Liste = aktive Vereinsmitglieder. Nach Zuweisungen ggf. Seite neu laden.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Co-Trainer (Mehrfachauswahl)</label>
|
|
<select
|
|
multiple
|
|
className="form-input"
|
|
size={Math.min(8, Math.max(4, groupMemberDirectory.length || 4))}
|
|
value={(formData.co_trainer_ids || []).map(String)}
|
|
onChange={(e) => {
|
|
const opts = [...e.target.selectedOptions].map((o) => parseInt(o.value, 10))
|
|
updateFormField('co_trainer_ids', opts)
|
|
}}
|
|
>
|
|
{groupMemberDirectory.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{(p.name || p.email || '').trim()} (#{p.id})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Status</label>
|
|
<select
|
|
className="form-input"
|
|
value={formData.status || 'active'}
|
|
onChange={(e) => updateFormField('status', e.target.value)}
|
|
>
|
|
<option value="active">Aktiv</option>
|
|
<option value="inactive">Inaktiv</option>
|
|
</select>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Buttons */}
|
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.5rem' }}>
|
|
<button type="submit" className="btn btn-primary" style={{ flex: 1 }}>
|
|
{editing ? 'Speichern' : 'Erstellen'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
onClick={() => setShowModal(false)}
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showAddMemberModal && membersAdminClubId && (
|
|
<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: 1100,
|
|
padding: '1rem',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
background: 'var(--surface)',
|
|
borderRadius: '12px',
|
|
padding: '1.5rem',
|
|
maxWidth: '480px',
|
|
width: '100%',
|
|
}}
|
|
>
|
|
<h2 style={{ marginTop: 0 }}>Mitglied hinzufügen</h2>
|
|
<p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>
|
|
{isPlatformAdmin
|
|
? 'Nutzer aus der Liste wählen oder Profil-ID eingeben.'
|
|
: 'Profil-ID des Nutzers (z. B. aus der Nutzerverwaltung). Offene Beitrittsanträge kannst du oben direkt annehmen.'}
|
|
</p>
|
|
{isPlatformAdmin && profilesAdminList.length > 0 ? (
|
|
<div className="form-row">
|
|
<label className="form-label">Profil</label>
|
|
<select
|
|
className="form-input"
|
|
value={addMemberForm.profile_id === '' ? '' : String(addMemberForm.profile_id)}
|
|
onChange={(e) =>
|
|
setAddMemberForm((prev) => ({ ...prev, profile_id: e.target.value }))
|
|
}
|
|
>
|
|
<option value="">Bitte wählen…</option>
|
|
{profilesAdminList.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
#{p.id} — {p.email || '—'} ({p.name || 'ohne Name'})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
) : (
|
|
<div className="form-row">
|
|
<label className="form-label">Profil-ID</label>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
className="form-input"
|
|
value={addMemberForm.profile_id}
|
|
onChange={(e) =>
|
|
setAddMemberForm((prev) => ({
|
|
...prev,
|
|
profile_id: e.target.value === '' ? '' : parseInt(e.target.value, 10),
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="form-row">
|
|
<span className="form-label">Rollen</span>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
|
{CLUB_ROLE_OPTIONS.map((opt) => (
|
|
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={addMemberForm.roles.includes(opt.code)}
|
|
onChange={() => {
|
|
setAddMemberForm((prev) => {
|
|
const set = new Set(prev.roles)
|
|
if (set.has(opt.code)) set.delete(opt.code)
|
|
else set.add(opt.code)
|
|
return { ...prev, roles: Array.from(set) }
|
|
})
|
|
}}
|
|
/>
|
|
{opt.label}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
style={{ flex: 1 }}
|
|
onClick={async () => {
|
|
const raw = addMemberForm.profile_id
|
|
const profile_id = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
|
|
if (!Number.isFinite(profile_id) || profile_id < 1) {
|
|
alert('Gültige Profil-ID wählen.')
|
|
return
|
|
}
|
|
if (!addMemberForm.roles.length) {
|
|
alert('Mindestens eine Rolle.')
|
|
return
|
|
}
|
|
try {
|
|
await api.addClubMember(membersAdminClubId, {
|
|
profile_id,
|
|
roles: addMemberForm.roles,
|
|
})
|
|
setShowAddMemberModal(false)
|
|
await reloadMembersAdmin()
|
|
await loadData()
|
|
} catch (err) {
|
|
alert(err.message || String(err))
|
|
}
|
|
}}
|
|
>
|
|
Hinzufügen
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
onClick={() => setShowAddMemberModal(false)}
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{acceptJoinModal && membersAdminClubId && (
|
|
<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: 1100,
|
|
padding: '1rem',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
background: 'var(--surface)',
|
|
borderRadius: '12px',
|
|
padding: '1.5rem',
|
|
maxWidth: '480px',
|
|
width: '100%',
|
|
}}
|
|
>
|
|
<h2 style={{ marginTop: 0 }}>Antrag annehmen</h2>
|
|
<p style={{ color: 'var(--text2)' }}>{acceptJoinModal.label}</p>
|
|
<div className="form-row">
|
|
<span className="form-label">Rollen bei Aufnahme</span>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
|
{CLUB_ROLE_OPTIONS.map((opt) => (
|
|
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={acceptJoinModal.roles.includes(opt.code)}
|
|
onChange={() => {
|
|
setAcceptJoinModal((prev) => {
|
|
if (!prev) return prev
|
|
const set = new Set(prev.roles)
|
|
if (set.has(opt.code)) set.delete(opt.code)
|
|
else set.add(opt.code)
|
|
const roles = Array.from(set)
|
|
return { ...prev, roles: roles.length ? roles : ['trainer'] }
|
|
})
|
|
}}
|
|
/>
|
|
{opt.label}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
style={{ flex: 1 }}
|
|
onClick={async () => {
|
|
try {
|
|
await api.acceptClubJoinRequest(
|
|
membersAdminClubId,
|
|
acceptJoinModal.id,
|
|
acceptJoinModal.roles.length ? acceptJoinModal.roles : ['trainer']
|
|
)
|
|
notifyOrgInboxChanged()
|
|
setAcceptJoinModal(null)
|
|
await reloadMembersAdmin()
|
|
await loadData()
|
|
} catch (err) {
|
|
alert(err.message || String(err))
|
|
}
|
|
}}
|
|
>
|
|
Aufnehmen
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
onClick={() => setAcceptJoinModal(null)}
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{editMemberModal && membersAdminClubId && (
|
|
<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: 1100,
|
|
padding: '1rem',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
background: 'var(--surface)',
|
|
borderRadius: '12px',
|
|
padding: '1.5rem',
|
|
maxWidth: '480px',
|
|
width: '100%',
|
|
}}
|
|
>
|
|
<h2 style={{ marginTop: 0 }}>Mitglied bearbeiten</h2>
|
|
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
|
|
{editMemberModal.name || editMemberModal.email} (#{editMemberModal.profile_id})
|
|
</p>
|
|
<div className="form-row">
|
|
<label className="form-label">Vereinszugang</label>
|
|
<select
|
|
className="form-input"
|
|
value={editMemberModal.status || 'active'}
|
|
onChange={(e) =>
|
|
setEditMemberModal((prev) => (prev ? { ...prev, status: e.target.value } : prev))
|
|
}
|
|
>
|
|
<option value="active">aktiv — sieht Vereinsinhalte</option>
|
|
<option value="inactive">deaktiviert — weiter anmeldbar, keine Vereinsinhalte</option>
|
|
</select>
|
|
</div>
|
|
<p className="muted" style={{ fontSize: '0.82rem', lineHeight: 1.45 }}>
|
|
Deaktivierung gilt nur für diesen Verein; der Login-Account bleibt aktiv.
|
|
</p>
|
|
<div className="form-row">
|
|
<span className="form-label">Rollen</span>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
|
{CLUB_ROLE_OPTIONS.map((opt) => (
|
|
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={(editMemberModal.roles || []).includes(opt.code)}
|
|
onChange={() => {
|
|
setEditMemberModal((prev) => {
|
|
if (!prev) return prev
|
|
const set = new Set(prev.roles || [])
|
|
if (set.has(opt.code)) set.delete(opt.code)
|
|
else set.add(opt.code)
|
|
let roles = Array.from(set)
|
|
if (!roles.length) roles = ['trainer']
|
|
return { ...prev, roles }
|
|
})
|
|
}}
|
|
/>
|
|
{opt.label}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '1rem' }}>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
onClick={async () => {
|
|
try {
|
|
await api.updateClubMember(membersAdminClubId, editMemberModal.profile_id, {
|
|
roles: editMemberModal.roles,
|
|
status: editMemberModal.status,
|
|
})
|
|
setEditMemberModal(null)
|
|
await reloadMembersAdmin()
|
|
await loadData()
|
|
} catch (err) {
|
|
alert(err.message || String(err))
|
|
}
|
|
}}
|
|
>
|
|
Speichern
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
style={{ background: 'var(--danger)', color: '#fff', border: 'none' }}
|
|
onClick={async () => {
|
|
if (
|
|
!confirm(
|
|
'Mitgliedschaft wirklich entfernen? (Nutzer verliert alle Rollen in diesem Verein.)'
|
|
)
|
|
)
|
|
return
|
|
try {
|
|
await api.removeClubMember(membersAdminClubId, editMemberModal.profile_id)
|
|
setEditMemberModal(null)
|
|
await reloadMembersAdmin()
|
|
await loadData()
|
|
} catch (err) {
|
|
alert(err.message || String(err))
|
|
}
|
|
}}
|
|
>
|
|
Aus Verein entfernen
|
|
</button>
|
|
<button type="button" className="btn btn-secondary" onClick={() => setEditMemberModal(null)}>
|
|
Schließen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default ClubsPage
|