'; } private static function field(string $name,string $label,string $type='text',string $value='',int $max=128): string { $id=$name.'_'.bin2hex(random_bytes(3)); return '
'; } public function help(): void { $this->displayContent('

Using your club workspace

Open Clubs and dues to view clubs assigned to your account. Club rosters and your season dues are available inside each club.

Your Super System Administrator creates clubs and assigns club administrators. Club administrators invite members, add seasons, record dues and payments, and answer tickets only in their clubs. Members can view club information and their own dues, and submit tickets. Accept invitations through Invitations after verifying your email.

'); } public function home(): void { $service=new RosterService();$html='

Club workspace

Account security and authenticator recovery

Club access requires a verified email. Verify your email or request another link.

'; if(!$service->ready()){ $html.=$service->administrator()?'

Club setup is ready to install. Run the database migrations described in PRODUCTION-FIXES.md.

':'

Your club workspace is being prepared. Please contact the site administrator.

'; }else{ $clubs=$service->clubs(BoundedList::page('clubs')); if((BoundedList::$pages['clubs']['count']??0)===1 && $clubs){ header('Location: main.php?Xaction=Roster.workspace&club_serial='.(int)$clubs[0]['club_serial'],true,303); return; } if(!$clubs)$html.='

You are not assigned to an active club yet. A site administrator can create clubs and assign memberships.

'; $html.='
'; foreach($clubs as $club)$html.='

'.self::h($club['club_name']).'

'.self::h(ucfirst($club['club_status'])).'

Open club
'; $html.='
'.BoundedList::navigation('clubs','displayHome'); $html.=''.($service->administrator()?'Manage clubs and memberships':'View my clubs and dues').''; } $this->displayContent($html.MfaSecurityNotifications::notice().'
'); } private function financialHistory(int $club,bool $active,bool $admin): string { $history=(new LedgerService())->history($club,BoundedList::page('payments'),BoundedList::page('adjustments'));$html='

Payment history

'; if($admin)$html.='
'; $html.='
'; foreach($history['payments'] as $p){ $html.=''; } $html.='
PaymentMemberDateAmountMethod / referenceRecorded byStatus
#'.(int)$p['payment_serial'].''.self::h($p['member_name']).''.self::h($p['paid_on']).'$'.number_format($p['amount_cents']/100,2).''.self::h($p['payment_method'].' '.$p['payment_reference']).''.self::h($p['actor']??'Historical record').''; if($p['reversal_serial'])$html.='Reversed: '.self::h($p['reversal_reason'].' by '.$p['reversal_actor'].' on '.$p['reversed_at']); elseif($admin&&$active)$html.='
Reverse incorrect payment

This preserves the original entry and restores the amount owed. It does not send a refund.

'.$this->form('reverse',$club).''.self::field('reason','Reversal reason','text','',500).'
'; else $html.='Received';$html.='
'.BoundedList::navigation('payments').'

Dues correction history

'.BoundedList::navigation('adjustments'); } public function createClub(): void { (new TenantAccess())->requireSuper();$service=new RosterService(); if(($_SERVER['REQUEST_METHOD']??'GET')==='POST'){ $this->requireCsrf();$club=$service->saveClub(0,$_POST['club_name']??''); $_SESSION['roster_notice']='Club created.';header('Location: main.php?Xaction=Roster.workspace&club_serial='.$club,true,303);return; } $form=str_replace('value="Roster.workspace"','value="Roster.createClub"',$this->form('club')); $this->displayContent('

Create club

Create a separate club, then assign its administrator from the club roster.

'.$form.self::field('club_name','Club name').'
Back to clubs
'); } public function workspace(): void { $service=new RosterService();if(!$service->ready()){$this->home();return;} $clubRaw=$_POST['club_serial']??$_GET['club_serial']??'';$club=($clubRaw===''||$clubRaw==='0')?0:MaintenanceService::id($clubRaw); if(($_SERVER['REQUEST_METHOD']??'GET')==='POST' && ($_POST['step']??'')!==''){ $service->requireAdministrator();$this->requireCsrf(); switch($_POST['step']){ case 'club':$club=$service->saveClub($club,$_POST['club_name']??'',$_POST['club_status']??'active');break; case 'member':$service->saveMember($club,empty($_POST['membership_serial'])?0:MaintenanceService::id($_POST['membership_serial']),$_POST['identifier']??'',$_POST['membership_status']??'active',$_POST['joined_on']??'',$_POST['membership_role']??'member');break; case 'invite':$result=(new ClubInvitations())->invite($club,$_POST['invite_email']??'',$_POST['membership_role']??'member');$_SESSION['club_notice']=$result['message'].' '.$result['warning'];break; case 'revokeInvitation':(new ClubInvitations())->revoke($club,MaintenanceService::id($_POST['invitation_serial']??null));$_SESSION['club_notice']='Invitation revoked.';break; case 'season':$service->addSeason($club,$_POST['season_name']??'',$_POST['starts_on']??'',$_POST['ends_on']??'');break; case 'charge':$service->charge($club,MaintenanceService::id($_POST['membership_serial']??null),MaintenanceService::id($_POST['season_serial']??null),$_POST['amount']??'');break; case 'payment':$service->payment($club,MaintenanceService::id($_POST['due_serial']??null),$_POST['amount']??'',$_POST['paid_on']??'',$_POST['request_key']??'',$_POST['payment_method']??'unspecified',$_POST['payment_reference']??'');break; case 'adjust':(new LedgerService())->adjust($club,MaintenanceService::id($_POST['due_serial']??null),$_POST['corrected_amount']??'',$_POST['reason']??'',$_POST['request_key']??'');break; case 'reverse':(new LedgerService())->reverse($club,MaintenanceService::id($_POST['payment_serial']??null),$_POST['reason']??'',$_POST['request_key']??'');break; default:throw new AppException('Unknown club action.'); } $_SESSION['roster_notice']='Changes saved.'; $redirect='main.php?Xaction=Roster.workspace&club_serial='.$club; if(($_SESSION[self::AJAX]??false)&&($_SERVER['HTTP_X_HCR_MODAL']??'')==='1'){ header('Content-Type: application/json');echo json_encode(['ok'=>true,'redirect'=>$redirect]);return; } header('Location: '.$redirect,true,303);exit; } $clubs=$service->clubs(BoundedList::page('clubs')); if(!$club && $clubs)$club=(int)$clubs[0]['club_serial']; if(!$club){$this->displayContent('

Clubs and memberships

No clubs are available. Accept an invitation to join your club.

View invitations
');return;} $selected=$service->club($club);$manage=$service->manages($club);$super=(new TenantAccess())->isSuper();$members=$service->members($club,BoundedList::page('members'));$seasons=$service->seasons($club,BoundedList::page('seasons'));$dues=$service->dues($club,BoundedList::page('dues')); $html='

'.self::h($selected['club_name']).'

Club status: '.self::h(ucfirst($selected['club_status'])).' · '.($super?'Super System Administrator':($manage?'Club administrator':'Member')).'

'.($manage?'Manage your club roster, invitations, seasons and dues.':'View your membership, season dues and payment history.').'

'.MfaSecurityNotifications::notice(); if(!empty($_SESSION['roster_notice'])){$html.='

'.self::h($_SESSION['roster_notice']).'

';unset($_SESSION['roster_notice']);} if(isset($_SESSION['club_notice'])){$html.='

'.self::h($_SESSION['club_notice']).'

';unset($_SESSION['club_notice']);} if($manage){ $html.='
Edit or archive club'.$this->form('club',$club).self::field('club_name','Club name','text',$selected['club_name']).'
'; } $html.='

'.($manage?'Roster':'My membership').'

'.($manage?'':'').''; foreach($members as $member){ $html.=''; if($manage)$html.=''; $html.=''; } if(!$members)$html.=''; $html.='
NameEmailMembershipJoinedUpdate membership
'.self::h($member['user_first_name'].' '.$member['user_last_name']).''.self::h($member['user_email']).''.self::h($member['membership_status'].' / '.$member['membership_role']).''.self::h($member['joined_on']).''.(($super||$member['membership_role']!=='administrator')?$this->form('member',$club).''.($super?'':'').'':'Club administrator (managed by Super System Administrator)').'
No members assigned.
'.BoundedList::navigation('members'); if($super && $selected['club_status']==='active')$html.='

Assign an existing account

'.$this->form('member',$club).self::field('identifier','Username or email').self::field('joined_on','Joined on','date',date('Y-m-d')).'

Global account management is available through Users. To appoint an administrator, assign the account, then change its club role above.

'; if($manage && $selected['club_status']==='active'){ $html.='

Invitations

'.$this->form('invite',$club).self::field('invite_email','Invitation email','email','',100); if($super)$html.=''; $html.='

The recipient signs up or signs in using this email, verifies it, and accepts under Invitations. This does not change their login or any other club membership.

'.BoundedList::navigation('invitations'); } $html.='

Seasons

'.BoundedList::navigation('seasons'); if($manage && $selected['club_status']==='active')$html.=$this->form('season',$club).self::field('season_name','Season name','text','',100).self::field('starts_on','Starts on','date').self::field('ends_on','Ends on','date').'
'; $html.='

'.($manage?'Season dues':'My season dues').'

Amounts are in USD. Payments below are manually recorded receipts.

'.($manage?'':'').''; foreach($dues as $due){$balance=(int)$due['amount_cents']-(int)$due['paid_cents'];$html.=''; if($manage){$html.='';}$html.=''; } if(!$dues)$html.='';$html.='
MemberSeasonDuesPaidBalanceRecord received payment
'.self::h($due['user_first_name'].' '.$due['user_last_name']).''.self::h($due['season_name']).'$'.number_format($due['amount_cents']/100,2).'$'.number_format($due['paid_cents']/100,2).'$'.number_format($balance/100,2).'';if($balance>0 && $selected['club_status']==='active')$html.=$this->form('payment',$club).''.self::field('amount','Payment (USD)','text').self::field('paid_on','Received on','date',date('Y-m-d')).'';if($selected['club_status']==='active')$html.='
Correct dues'.$this->form('adjust',$club).''.self::field('corrected_amount','Corrected total (USD)').self::field('reason','Reason','text','',500).'
';$html.='
No dues recorded.
'.BoundedList::navigation('dues'); if($manage && $selected['club_status']==='active' && $members && $seasons){ $html.='

Record season dues

'.$this->form('charge',$club).'
'.self::field('amount','Dues (USD)','text').'
'; } $html.=$this->financialHistory($club,$selected['club_status']==='active',$manage); $this->displayContent(ClubFormModals::render($html.'
')); } public function invitations(): void { if(!(new RosterService())->ready()){$this->home();return;} $service=new ClubInvitations(); if(($_POST['step']??'')==='accept'){$service->accept(MaintenanceService::id($_POST['invitation_serial']??null));header('Location: main.php?Xaction=Roster.workspace',true,303);exit;} $html='

Club invitations

Sign in using the invited email and verify it before accepting. Club invitations never change your account password or other club memberships.

'; $pending=$service->pending(BoundedList::page('pending')); foreach($pending as $invite)$html.='

'.self::h($invite['club_name']).'

Club role: '.self::h($invite['membership_role']).'; expires '.self::h($invite['expires_at']).' UTC.

'.str_replace('Roster.workspace','Roster.invitations',$this->form('accept')).'
'; if(!$pending)$html.='

No pending invitations for your current email.

'; $html.=BoundedList::navigation('pending'); $this->displayContent($html.'
'); } public function tickets(): void { $service=new ClubTickets();if(!$service->ready()){$this->home();return;} $clubs=$service->clubs(BoundedList::page('clubs'));$raw=$_POST['club_serial']??$_GET['club_serial']??'';$club=$raw===''?($clubs?(int)$clubs[0]['club_serial']:0):MaintenanceService::id($raw); $html='

Club tickets

'.BoundedList::navigation('clubs'); if(!$club){$this->displayContent($html.'

Accept a club invitation to submit a ticket.

');return;} $selected=$service->club($club);$manage=$service->manages($club);$raw=$_POST['ticket_serial']??$_GET['ticket_serial']??'';$ticket=$raw===''?0:MaintenanceService::id($raw); $page=filter_var($_POST['ticket_page']??$_GET['ticket_page']??'1',FILTER_VALIDATE_INT,['options'=>['min_range'=>1]]); if($page===false)throw new AppException('Choose a valid ticket page.'); $search=$service->normalizeSearch($_POST['ticket_search']??$_GET['ticket_search']??''); if(($_POST['step']??'')==='openTicket')$ticket=$service->createTicket($club,$_POST['subject']??'',$_POST['message']??''); elseif(($_POST['step']??'')==='reply')$service->reply($club,$ticket,$_POST['message']??'',$_POST['ticket_status']??''); if(($_POST['step']??'')!==''){if(!in_array($_POST['step'],['openTicket','reply'],true))throw new AppException('Unknown ticket action.');header('Location: main.php?Xaction=Roster.tickets&club_serial='.$club.'&ticket_serial='.$ticket.($_POST['step']==='reply'?'&ticket_page='.$page.'&ticket_search='.rawurlencode($search):''),true,303);exit;} $listing=$service->page($club,$page,$search);$page=$listing['page']; $ticketUrl=static fn(int $target)=>'main.php?'.http_build_query(['Xaction'=>'Roster.tickets','club_serial'=>$club,'ticket_page'=>$target,'ticket_search'=>$search],'','&',PHP_QUERY_RFC3986); $html.='

'.self::h($selected['club_name']).'

'.($manage?'Tickets from this club.':'Only your own tickets are shown.').'

'; $html.='
Clear search
'; $html.='

Tickets '.$listing['start'].'-'.$listing['end'].' of '.$listing['count'].'. Page '.$page.' of '.$listing['pages'].'.

'; if($ticket){$row=$service->ticket($club,$ticket);$html.='

'.self::h($row['subject']).'

Status: '.self::h($row['ticket_status']).'

';foreach($service->messages($club,$ticket,BoundedList::page('messages')) as $message)$html.='

'.self::h($message['user_first_name'].' '.$message['user_last_name'].' — '.$message['created_at']).'

'.nl2br(self::h($message['message_text'])).'

'; $html.=BoundedList::navigation('messages'); if($selected['club_status']==='active'&&($manage||$row['ticket_status']==='open')){$html.=str_replace('Roster.workspace','Roster.tickets',$this->form('reply',$club)).'';if($manage)$html.='';$html.='';} } if($selected['club_status']==='active')$html.='

Submit a ticket

'.str_replace('Roster.workspace','Roster.tickets',$this->form('openTicket',$club)).str_replace('name="subject"','name="subject" data-character-limit="'.ClubTickets::SUBJECT_LIMIT.'"',self::field('subject','Subject','text','',ClubTickets::SUBJECT_LIMIT*2)).''; $html.=''; $this->displayContent($html.''); } }