shinkan-jinkendo/frontend/src/pages/ClubsPage.jsx
Lars 30c1c259d2
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
feat(access): enhance visibility handling for club-related content
- 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.
2026-05-09 10:55:58 +02:00

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 &amp; 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