generated from jric11/baseProject
ChatGPT's update to harden software and run db updates.
This commit is contained in:
+108
-37
@@ -9,33 +9,48 @@ class Roster extends Base {
|
||||
return '<div class="col-md"><label class="form-label" for="'.$id.'">'.$label.'</label><input class="form-control" id="'.$id.'" type="'.$type.'" name="'.$name.'" value="'.self::h($value).'" maxlength="'.$max.'" required></div>';
|
||||
}
|
||||
public function help(): void {
|
||||
$this->displayContent('<main class="container py-3"><h1>Using your club workspace</h1><p>Open Clubs and dues to view clubs assigned to your account. Club rosters and your season dues are available inside each club.</p><p>Site administrators create clubs, assign existing accounts, add seasons, and record dues and received payments. Contact your club administrator for membership or account assistance.</p></main>');
|
||||
$this->displayContent('<main class="container py-3"><h1>Using your club workspace</h1><p>Open Clubs and dues to view clubs assigned to your account. Club rosters and your season dues are available inside each club.</p><p>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.</p></main>');
|
||||
}
|
||||
public function home(): void {
|
||||
$service=new RosterService();$html='<div class="container-fluid py-3"><h1>Club workspace</h1><p>Club access requires a verified email. <a href="main.php?Xaction=verifyemail">Verify your email or request another link</a>.</p>';
|
||||
$service=new RosterService();$html='<div class="container-fluid py-3"><h1>Club workspace</h1><p><a href="main.php?Xaction=security">Account security and authenticator recovery</a></p><p>Club access requires a verified email. <a href="main.php?Xaction=verifyemail">Verify your email or request another link</a>.</p>';
|
||||
if(!$service->ready()){
|
||||
$html.=$service->administrator()?'<p>Club setup is ready to install. Run the database migrations described in PRODUCTION-FIXES.md.</p>':'<p>Your club workspace is being prepared. Please contact the site administrator.</p>';
|
||||
}else{
|
||||
$clubs=$service->clubs();
|
||||
$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.='<p>You are not assigned to an active club yet. A site administrator can create clubs and assign memberships.</p>';
|
||||
foreach($clubs as $club)$html.='<section class="card mb-3"><div class="card-body"><h2 class="h4">'.self::h($club['club_name']).'</h2><p>'.self::h(ucfirst($club['club_status'])).'</p><a class="btn btn-primary" href="main.php?Xaction=Roster.workspace&club_serial='.(int)$club['club_serial'].'">Open club</a></div></section>';
|
||||
$html.='<div class="row">';
|
||||
foreach($clubs as $club)$html.='<div class="col-12 col-md-6 col-xl-4 mb-3"><section class="card h-100"><div class="card-body d-flex flex-column"><h2 class="h4">'.self::h($club['club_name']).'</h2><p>'.self::h(ucfirst($club['club_status'])).'</p><a class="btn btn-primary mt-auto align-self-start" href="main.php?Xaction=Roster.workspace&club_serial='.(int)$club['club_serial'].'">Open club</a></div></section></div>';
|
||||
$html.='</div>'.BoundedList::navigation('clubs','displayHome');
|
||||
$html.='<a href="main.php?Xaction=Roster.workspace">'.($service->administrator()?'Manage clubs and memberships':'View my clubs and dues').'</a>';
|
||||
}
|
||||
$this->displayContent($html.'</div>');
|
||||
$this->displayContent($html.MfaSecurityNotifications::notice().'</div>');
|
||||
}
|
||||
private function financialHistory(int $club,bool $active,bool $admin): string {
|
||||
$history=(new LedgerService())->history($club);$html='<h3 class="h4 mt-4">Payment history</h3>';
|
||||
$history=(new LedgerService())->history($club,BoundedList::page('payments'),BoundedList::page('adjustments'));$html='<h2 class="h4 mt-4">Payment history</h2>';
|
||||
if($admin)$html.='<form method="post" action="main.php"><input type="hidden" name="action" value="RosterReports.ledger"><input type="hidden" name="csrf_token" value="'.self::h($this->csrfToken()).'"><input type="hidden" name="club_serial" value="'.$club.'"><button class="btn btn-outline-primary">Export financial history (CSV)</button></form>';
|
||||
$html.='<div class="table-responsive"><table class="table"><thead><tr><th>Payment</th><th>Member</th><th>Date</th><th>Amount</th><th>Method / reference</th><th>Recorded by</th><th>Status</th></tr></thead><tbody>';
|
||||
$html.='<div class="table-responsive position-relative"><table class="table"><thead><tr><th>Payment</th><th>Member</th><th>Date</th><th>Amount</th><th>Method / reference</th><th>Recorded by</th><th>Status</th></tr></thead><tbody>';
|
||||
foreach($history['payments'] as $p){
|
||||
$html.='<tr><td>#'.(int)$p['payment_serial'].'</td><td>'.self::h($p['member_name']).'</td><td>'.self::h($p['paid_on']).'</td><td>$'.number_format($p['amount_cents']/100,2).'</td><td>'.self::h($p['payment_method'].' '.$p['payment_reference']).'</td><td>'.self::h($p['actor']??'Historical record').'</td><td>';
|
||||
if($p['reversal_serial'])$html.='Reversed: '.self::h($p['reversal_reason'].' by '.$p['reversal_actor'].' on '.$p['reversed_at']);
|
||||
elseif($admin&&$active)$html.='<details><summary>Reverse incorrect payment</summary><p>This preserves the original entry and restores the amount owed. It does not send a refund.</p>'.$this->form('reverse',$club).'<input type="hidden" name="payment_serial" value="'.(int)$p['payment_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('reason','Reversal reason','text','',500).'<button class="btn btn-outline-danger">Confirm reversal</button></form></details>';
|
||||
else $html.='Received';$html.='</td></tr>';
|
||||
}
|
||||
$html.='</tbody></table></div><h3 class="h4">Dues correction history</h3><ul>';
|
||||
$html.='</tbody></table></div>'.BoundedList::navigation('payments').'<h2 class="h4 mt-4">Dues correction history</h2><ul>';
|
||||
foreach($history['adjustments'] as $a)$html.='<li>'.self::h($a['member_name']).': $'.number_format($a['amount_cents']/100,2).' adjustment; corrected total $'.number_format($a['corrected_amount_cents']/100,2).' — '.self::h($a['reason'].' ('.$a['actor'].', '.$a['created_at'].')').'</li>';
|
||||
return $html.'</ul>';
|
||||
return $html.'</ul>'.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('<main class="container-fluid py-3"><h1>Create club</h1><p>Create a separate club, then assign its administrator from the club roster.</p>'.$form.self::field('club_name','Club name').'<div class="col-auto align-self-end"><button class="btn btn-primary">Create club</button></div></form><a class="btn btn-outline-primary" href="main.php">Back to clubs</a></main>');
|
||||
}
|
||||
public function workspace(): void {
|
||||
$service=new RosterService();if(!$service->ready()){$this->home();return;}
|
||||
@@ -44,7 +59,9 @@ class Roster extends Base {
|
||||
$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']??'');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;
|
||||
@@ -52,45 +69,99 @@ class Roster extends Base {
|
||||
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.';header('Location: main.php?Xaction=Roster.workspace&club_serial='.$club,true,303);exit;
|
||||
$_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;
|
||||
}
|
||||
$html='<div class="container-fluid py-3"><h1>Clubs and memberships</h1>';
|
||||
if(!empty($_SESSION['roster_notice'])){$html.='<p class="alert alert-success" role="status">'.self::h($_SESSION['roster_notice']).'</p>';unset($_SESSION['roster_notice']);}
|
||||
$clubs=$service->clubs();$html.='<nav aria-label="Clubs" class="mb-3">';
|
||||
foreach($clubs as $row)$html.='<a class="btn btn-outline-primary me-2 mb-2" href="main.php?Xaction=Roster.workspace&club_serial='.(int)$row['club_serial'].'">'.self::h($row['club_name']).'</a>';
|
||||
$html.='</nav>';
|
||||
if($service->administrator())$html.='<section><h2 class="h4">Create club</h2>'.$this->form('club').self::field('club_name','Club name').' <div class="col-auto align-self-end"><button class="btn btn-primary">Create club</button></div></form></section>';
|
||||
$clubs=$service->clubs(BoundedList::page('clubs'));
|
||||
if(!$club && $clubs)$club=(int)$clubs[0]['club_serial'];
|
||||
if(!$club){$this->displayContent($html.'<p>No clubs are available.</p></div>');return;}
|
||||
$selected=$service->club($club);$members=$service->members($club);$seasons=$service->seasons($club);$dues=$service->dues($club);
|
||||
$html.='<hr><h2>'.self::h($selected['club_name']).'</h2>';
|
||||
if($service->administrator()){
|
||||
$html.='<details class="mb-3"><summary>Edit or archive club</summary>'.$this->form('club',$club).self::field('club_name','Club name','text',$selected['club_name']).'<div class="col-md"><label class="form-label">Status<select class="form-select" name="club_status"><option value="active"'.($selected['club_status']==='active'?' selected':'').'>Active</option><option value="archived"'.($selected['club_status']==='archived'?' selected':'').'>Archived</option></select></label></div><div class="col-auto align-self-end"><button class="btn btn-primary">Save club</button></div></form></details>';
|
||||
if(!$club){$this->displayContent('<main class="container-fluid py-3"><h1>Clubs and memberships</h1><p>No clubs are available. Accept an invitation to join your club.</p><a class="btn btn-primary" href="main.php?Xaction=Roster.invitations">View invitations</a></main>');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='<main class="container-fluid py-3 clubSummary"><header class="mb-4"><h1>'.self::h($selected['club_name']).'</h1><p>Club status: '.self::h(ucfirst($selected['club_status'])).' · '.($super?'Super System Administrator':($manage?'Club administrator':'Member')).'</p><p>'.($manage?'Manage your club roster, invitations, seasons and dues.':'View your membership, season dues and payment history.').'</p><nav class="d-flex flex-wrap gap-2" aria-label="Club actions"><a class="btn btn-primary" href="main.php?Xaction=Roster.tickets&club_serial='.$club.'">'.($manage?'Club tickets':'My tickets').'</a><a class="btn btn-outline-primary" href="#roster">'.($manage?'View roster':'My membership').'</a><a class="btn btn-outline-primary" href="#dues">'.($manage?'Season dues':'My dues').'</a>';
|
||||
if($manage && $selected['club_status']==='active')$html.='<a class="btn btn-outline-primary" href="#club-invitations">Invite members</a>';
|
||||
if((BoundedList::$pages['clubs']['count']??0)>1){
|
||||
$html.='<a class="btn btn-outline-primary" href="main.php?Xaction=displayHome">Switch club</a>';
|
||||
}
|
||||
$html.='<h3 class="h4">Roster</h3><div class="table-responsive"><table class="table"><thead><tr><th>Name</th><th>Email</th><th>Membership</th><th>Joined</th>'.($service->administrator()?'<th>Update membership</th>':'').'</tr></thead><tbody>';
|
||||
$html.='</nav></header>'.MfaSecurityNotifications::notice();
|
||||
if(!empty($_SESSION['roster_notice'])){$html.='<p class="alert alert-success" role="status">'.self::h($_SESSION['roster_notice']).'</p>';unset($_SESSION['roster_notice']);}
|
||||
if(isset($_SESSION['club_notice'])){$html.='<p role="status">'.self::h($_SESSION['club_notice']).'</p>';unset($_SESSION['club_notice']);}
|
||||
if($manage){
|
||||
$html.='<details class="mb-3"><summary class="btn btn-outline-primary">Edit or archive club</summary>'.$this->form('club',$club).self::field('club_name','Club name','text',$selected['club_name']).'<div class="col-md"><label class="form-label">Status<select class="form-select" name="club_status"><option value="active"'.($selected['club_status']==='active'?' selected':'').'>Active</option><option value="archived"'.($selected['club_status']==='archived'?' selected':'').'>Archived</option></select></label></div><div class="col-auto align-self-end"><button class="btn btn-primary">Save club</button></div></form></details>';
|
||||
}
|
||||
$html.='<h2 class="h4 mt-4" id="roster">'.($manage?'Roster':'My membership').'</h2><div class="table-responsive position-relative"><table class="table"><thead><tr><th>Name</th><th>Email</th><th>Membership</th><th>Joined</th>'.($manage?'<th>Update membership</th>':'').'</tr></thead><tbody>';
|
||||
foreach($members as $member){
|
||||
$html.='<tr><td>'.self::h($member['user_first_name'].' '.$member['user_last_name']).'</td><td>'.self::h($member['user_email']).'</td><td>'.self::h($member['membership_status']).'</td><td>'.self::h($member['joined_on']).'</td>';
|
||||
if($service->administrator())$html.='<td>'.$this->form('member',$club).'<input type="hidden" name="membership_serial" value="'.(int)$member['membership_serial'].'"><input type="hidden" name="joined_on" value="'.self::h($member['joined_on']).'"><label class="visually-hidden" for="membership_'.(int)$member['membership_serial'].'">Membership status for '.self::h($member['user_first_name'].' '.$member['user_last_name']).'</label><select class="form-select" name="membership_status" id="membership_'.(int)$member['membership_serial'].'"><option value="active"'.($member['membership_status']==='active'?' selected':'').'>Active</option><option value="inactive"'.($member['membership_status']==='inactive'?' selected':'').'>Inactive</option></select><button class="btn btn-outline-primary">Save membership</button></form></td>';
|
||||
$html.='<tr><td><a href="main.php?Xaction=Roster.memberSummary&club_serial='.$club.'&membership_serial='.(int)$member['membership_serial'].'&members_page='.(int)BoundedList::$pages['members']['page'].'">'.self::h($member['user_first_name'].' '.$member['user_last_name']).'</a></td><td>'.self::h($member['user_email']).'</td><td>'.self::h($member['membership_status'].' / '.$member['membership_role']).'</td><td>'.self::h($member['joined_on']).'</td>';
|
||||
if($manage)$html.='<td>'.(($super||$member['membership_role']!=='administrator')?$this->form('member',$club).'<input type="hidden" name="membership_serial" value="'.(int)$member['membership_serial'].'"><input type="hidden" name="joined_on" value="'.self::h($member['joined_on']).'">'.($super?'<label>Club role<select class="form-select" name="membership_role"><option value="member"'.($member['membership_role']==='member'?' selected':'').'>Member</option><option value="administrator"'.($member['membership_role']==='administrator'?' selected':'').'>Administrator</option></select></label>':'<input type="hidden" name="membership_role" value="member">').'<label class="visually-hidden" for="membership_'.(int)$member['membership_serial'].'">Membership status for '.self::h($member['user_first_name'].' '.$member['user_last_name']).'</label><select class="form-select" name="membership_status" id="membership_'.(int)$member['membership_serial'].'"><option value="active"'.($member['membership_status']==='active'?' selected':'').'>Active</option><option value="inactive"'.($member['membership_status']==='inactive'?' selected':'').'>Inactive</option></select><button class="btn btn-outline-primary">Save membership</button></form>':'Club administrator (managed by Super System Administrator)').'</td>';
|
||||
$html.='</tr>';
|
||||
}
|
||||
if(!$members)$html.='<tr><td colspan="5">No members assigned.</td></tr>';
|
||||
$html.='</tbody></table></div>';
|
||||
if($service->administrator() && $selected['club_status']==='active')$html.='<h4 class="h5">Assign an existing account</h4>'.$this->form('member',$club).self::field('identifier','Username or email').self::field('joined_on','Joined on','date',date('Y-m-d')).'<input type="hidden" name="membership_status" value="active"><div class="col-auto align-self-end"><button class="btn btn-primary">Add member</button></div></form><p>Create or invite accounts through Users before assigning them to a club.</p>';
|
||||
$html.='<h3 class="h4">Seasons</h3><ul>';
|
||||
foreach($seasons as $season)$html.='<li>'.self::h($season['season_name'].' ('.$season['starts_on'].' to '.$season['ends_on'].')').'</li>';
|
||||
$html.='</ul>';
|
||||
if($service->administrator() && $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').'<div class="col-auto align-self-end"><button class="btn btn-primary">Add season</button></div></form>';
|
||||
$html.='<h3 class="h4" id="dues">'.($service->administrator()?'Season dues':'My season dues').'</h3><p>Amounts are in USD. Payments below are manually recorded receipts.</p><div class="table-responsive"><table class="table"><thead><tr><th>Member</th><th>Season</th><th>Dues</th><th>Paid</th><th>Balance</th>'.($service->administrator()?'<th>Record received payment</th>':'').'</tr></thead><tbody>';
|
||||
foreach($dues as $due){$balance=(int)$due['amount_cents']-(int)$due['paid_cents'];$html.='<tr><td>'.self::h($due['user_first_name'].' '.$due['user_last_name']).'</td><td>'.self::h($due['season_name']).'</td><td>$'.number_format($due['amount_cents']/100,2).'</td><td>$'.number_format($due['paid_cents']/100,2).'</td><td>$'.number_format($balance/100,2).'</td>';
|
||||
if($service->administrator()){$html.='<td>';if($balance>0 && $selected['club_status']==='active')$html.=$this->form('payment',$club).'<input type="hidden" name="due_serial" value="'.(int)$due['due_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('amount','Payment (USD)','text').self::field('paid_on','Received on','date',date('Y-m-d')).'<label class="form-label">Method<select class="form-select" name="payment_method"><option value="unspecified">Unspecified</option><option value="cash">Cash</option><option value="check">Check</option><option value="transfer">Transfer</option><option value="other">Other</option></select></label><label class="form-label">Reference (optional)<input class="form-control" name="payment_reference" maxlength="100"></label><button class="btn btn-outline-primary">Record payment</button></form>';if($selected['club_status']==='active')$html.='<details class="mt-2"><summary>Correct dues</summary>'.$this->form('adjust',$club).'<input type="hidden" name="due_serial" value="'.(int)$due['due_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('corrected_amount','Corrected total (USD)').self::field('reason','Reason','text','',500).'<button class="btn btn-outline-primary">Save correction</button></form></details>';$html.='</td>';}$html.='</tr>';
|
||||
$html.='</tbody></table></div>'.BoundedList::navigation('members');
|
||||
if($super && $selected['club_status']==='active')$html.='<h4 class="h5">Assign an existing account</h4>'.$this->form('member',$club).self::field('identifier','Username or email').self::field('joined_on','Joined on','date',date('Y-m-d')).'<input type="hidden" name="membership_status" value="active"><div class="col-auto align-self-end"><button class="btn btn-primary">Add member</button></div></form><p>Global account management is available through Users. To appoint an administrator, assign the account, then change its club role above.</p>';
|
||||
if($manage && $selected['club_status']==='active'){
|
||||
$html.='<h2 class="h4 mt-4" id="club-invitations">Invitations</h2>'.$this->form('invite',$club).self::field('invite_email','Invitation email','email','',100);
|
||||
if($super)$html.='<label>Club role<select class="form-select" name="membership_role"><option value="member">Member</option><option value="administrator">Administrator</option></select></label>';
|
||||
$html.='<button class="btn btn-primary">Send club invitation</button></form><p>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.</p><ul>';
|
||||
foreach((new ClubInvitations())->forClub($club,BoundedList::page('invitations')) as $invite){$html.='<li>'.self::h($invite['invite_email'].' — '.$invite['membership_role'].' — '.$invite['invitation_status'].' (expires '.$invite['expires_at'].' UTC)');if($invite['invitation_status']==='pending'&&($super||$invite['membership_role']==='member'))$html.=$this->form('revokeInvitation',$club).'<input type="hidden" name="invitation_serial" value="'.(int)$invite['invitation_serial'].'"><button class="btn btn-outline-danger">Revoke invitation</button></form>';$html.='</li>';}$html.='</ul>'.BoundedList::navigation('invitations');
|
||||
}
|
||||
if(!$dues)$html.='<tr><td colspan="6">No dues recorded.</td></tr>';$html.='</tbody></table></div>';
|
||||
if($service->administrator() && $selected['club_status']==='active' && $members && $seasons){
|
||||
$html.='<h2 class="h4 mt-4">Seasons</h2><ul>';
|
||||
foreach($seasons as $season)$html.='<li>'.self::h($season['season_name'].' ('.$season['starts_on'].' to '.$season['ends_on'].')').'</li>';
|
||||
$html.='</ul>'.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').'<div class="col-auto align-self-end"><button class="btn btn-primary">Add season</button></div></form>';
|
||||
$html.='<h2 class="h4 mt-4" id="dues">'.($manage?'Season dues':'My season dues').'</h2><p>Amounts are in USD. Payments below are manually recorded receipts.</p><div class="table-responsive position-relative"><table class="table"><thead><tr><th>Member</th><th>Season</th><th>Dues</th><th>Paid</th><th>Balance</th>'.($manage?'<th>Record received payment</th>':'').'</tr></thead><tbody>';
|
||||
foreach($dues as $due){$balance=(int)$due['amount_cents']-(int)$due['paid_cents'];$html.='<tr><td>'.self::h($due['user_first_name'].' '.$due['user_last_name']).'</td><td>'.self::h($due['season_name']).'</td><td>$'.number_format($due['amount_cents']/100,2).'</td><td>$'.number_format($due['paid_cents']/100,2).'</td><td>$'.number_format($balance/100,2).'</td>';
|
||||
if($manage){$html.='<td>';if($balance>0 && $selected['club_status']==='active')$html.=$this->form('payment',$club).'<input type="hidden" name="due_serial" value="'.(int)$due['due_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('amount','Payment (USD)','text').self::field('paid_on','Received on','date',date('Y-m-d')).'<label class="form-label">Method<select class="form-select" name="payment_method"><option value="unspecified">Unspecified</option><option value="cash">Cash</option><option value="check">Check</option><option value="transfer">Transfer</option><option value="other">Other</option></select></label><label class="form-label">Reference (optional)<input class="form-control" name="payment_reference" maxlength="100"></label><button class="btn btn-outline-primary">Record payment</button></form>';if($selected['club_status']==='active')$html.='<details class="mt-2"><summary>Correct dues</summary>'.$this->form('adjust',$club).'<input type="hidden" name="due_serial" value="'.(int)$due['due_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('corrected_amount','Corrected total (USD)').self::field('reason','Reason','text','',500).'<button class="btn btn-outline-primary">Save correction</button></form></details>';$html.='</td>';}$html.='</tr>';
|
||||
}
|
||||
if(!$dues)$html.='<tr><td colspan="6">No dues recorded.</td></tr>';$html.='</tbody></table></div>'.BoundedList::navigation('dues');
|
||||
if($manage && $selected['club_status']==='active' && $members && $seasons){
|
||||
$html.='<h4 class="h5">Record season dues</h4>'.$this->form('charge',$club).'<div class="col-md"><label class="form-label">Member<select class="form-select" name="membership_serial">';foreach($members as $member)if($member['membership_status']==='active')$html.='<option value="'.(int)$member['membership_serial'].'">'.self::h($member['user_first_name'].' '.$member['user_last_name']).'</option>';
|
||||
$html.='</select></label></div><div class="col-md"><label class="form-label">Season<select class="form-select" name="season_serial">';foreach($seasons as $season)$html.='<option value="'.(int)$season['season_serial'].'">'.self::h($season['season_name']).'</option>';
|
||||
$html.='</select></label></div>'.self::field('amount','Dues (USD)','text').'<div class="col-auto align-self-end"><button class="btn btn-primary">Save dues</button></div></form>';
|
||||
}
|
||||
$html.=$this->financialHistory($club,$selected['club_status']==='active',$service->administrator());
|
||||
$html.=$this->financialHistory($club,$selected['club_status']==='active',$manage);
|
||||
$this->displayContent(ClubFormModals::render($html.'</main>'));
|
||||
}
|
||||
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='<div class="container py-3"><h1>Club invitations</h1><p>Sign in using the invited email and verify it before accepting. Club invitations never change your account password or other club memberships.</p>';
|
||||
$pending=$service->pending(BoundedList::page('pending'));
|
||||
foreach($pending as $invite)$html.='<section><h2 class="h4">'.self::h($invite['club_name']).'</h2><p>Club role: '.self::h($invite['membership_role']).'; expires '.self::h($invite['expires_at']).' UTC.</p>'.str_replace('Roster.workspace','Roster.invitations',$this->form('accept')).'<input type="hidden" name="invitation_serial" value="'.(int)$invite['invitation_serial'].'"><button class="btn btn-primary">Accept invitation</button></form></section>';
|
||||
if(!$pending)$html.='<p>No pending invitations for your current email.</p>';
|
||||
$html.=BoundedList::navigation('pending');
|
||||
$this->displayContent($html.'</div>');
|
||||
}
|
||||
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='<div class="container py-3"><h1>Club tickets</h1><nav>';
|
||||
foreach($clubs as $option)$html.='<a class="btn btn-outline-primary me-2" href="main.php?Xaction=Roster.tickets&club_serial='.(int)$option['club_serial'].'">'.self::h($option['club_name']).'</a>';$html.='</nav>'.BoundedList::navigation('clubs');
|
||||
if(!$club){$this->displayContent($html.'<p>Accept a club invitation to submit a ticket.</p></div>');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.='<h2>'.self::h($selected['club_name']).'</h2><p>'.($manage?'Tickets from this club.':'Only your own tickets are shown.').'</p>';
|
||||
$html.='<form method="get" action="main.php" class="row g-2 mb-3"><input type="hidden" name="Xaction" value="Roster.tickets"><input type="hidden" name="club_serial" value="'.$club.'"><label for="ticket_search">Search ticket subjects</label><input class="form-control" id="ticket_search" name="ticket_search" value="'.self::h($search).'" maxlength="200" data-character-limit="100"><button class="btn btn-outline-primary">Search tickets</button><a href="'.self::h('main.php?Xaction=Roster.tickets&club_serial='.$club).'">Clear search</a></form>';
|
||||
$html.='<p role="status" class="ticket-page-status">Tickets '.$listing['start'].'-'.$listing['end'].' of '.$listing['count'].'. Page '.$page.' of '.$listing['pages'].'.</p><ul class="ticket-list">';
|
||||
foreach($listing['rows'] as $row)$html.='<li><a href="'.self::h($ticketUrl($page).'&ticket_serial='.(int)$row['ticket_serial']).'">#'.(int)$row['ticket_serial'].' '.self::h($row['subject']).'</a> — '.self::h($row['ticket_status']).'</li>';
|
||||
if(!$listing['rows'])$html.='<li>No matching tickets.</li>';$html.='</ul><nav aria-label="Ticket pages" class="d-flex gap-3 mb-3">';
|
||||
if($page>1)$html.='<a href="'.self::h($ticketUrl(1)).'">First page</a><a href="'.self::h($ticketUrl($page-1)).'">Previous page</a>';
|
||||
if($page<$listing['pages'])$html.='<a href="'.self::h($ticketUrl($page+1)).'">Next page</a><a href="'.self::h($ticketUrl($listing['pages'])).'">Last page</a>';$html.='</nav>';
|
||||
if($ticket){$row=$service->ticket($club,$ticket);$html.='<h3>'.self::h($row['subject']).'</h3><p>Status: '.self::h($row['ticket_status']).'</p>';foreach($service->messages($club,$ticket,BoundedList::page('messages')) as $message)$html.='<article class="card p-3 mb-2"><p>'.self::h($message['user_first_name'].' '.$message['user_last_name'].' — '.$message['created_at']).'</p><p class="text-break">'.nl2br(self::h($message['message_text'])).'</p></article>';
|
||||
$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)).'<input type="hidden" name="ticket_page" value="'.$page.'"><input type="hidden" name="ticket_search" value="'.self::h($search).'"><input type="hidden" name="ticket_serial" value="'.$ticket.'"><label>Reply<textarea class="form-control" name="message" maxlength="'.(ClubTickets::MESSAGE_LIMIT*2).'" data-character-limit="'.ClubTickets::MESSAGE_LIMIT.'" required></textarea></label>';if($manage)$html.='<label for="ticket_status">Status</label><select id="ticket_status" class="form-select" name="ticket_status"><option value="open">Open</option><option value="closed">Closed</option></select>';$html.='<button class="btn btn-primary">Send reply</button></form>';}
|
||||
}
|
||||
if($selected['club_status']==='active')$html.='<h3 class="h4">Submit a ticket</h3>'.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)).'<label>Message<textarea class="form-control" name="message" maxlength="'.(ClubTickets::MESSAGE_LIMIT*2).'" data-character-limit="'.ClubTickets::MESSAGE_LIMIT.'" required></textarea></label><button class="btn btn-primary">Submit ticket</button></form>';
|
||||
$html.='<script src="js/tickets.js?version='.self::VERSION.'"></script>';
|
||||
$this->displayContent($html.'</div>');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user