generated from jric11/baseProject
Chat's updates for security, db and workflow
Security and workflow regressions / regression (push) Has been cancelled
Security and workflow regressions / regression (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
class Roster extends Base {
|
||||
private static function h(mixed $value): string{return htmlspecialchars((string)$value,ENT_QUOTES,'UTF-8');}
|
||||
private function form(string $step,int $club=0): string {
|
||||
return '<form method="post" action="main.php" class="row g-2 mb-3"><input type="hidden" name="action" value="Roster.workspace"><input type="hidden" name="csrf_token" value="'.self::h($this->csrfToken()).'"><input type="hidden" name="step" value="'.$step.'"><input type="hidden" name="club_serial" value="'.$club.'">';
|
||||
}
|
||||
private static function field(string $name,string $label,string $type='text',string $value='',int $max=128): string {
|
||||
$id=$name.'_'.bin2hex(random_bytes(3));
|
||||
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>');
|
||||
}
|
||||
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>';
|
||||
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();
|
||||
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.='<a href="main.php?Xaction=Roster.workspace">'.($service->administrator()?'Manage clubs and memberships':'View my clubs and dues').'</a>';
|
||||
}
|
||||
$this->displayContent($html.'</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>';
|
||||
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>';
|
||||
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>';
|
||||
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>';
|
||||
}
|
||||
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']??'');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.';header('Location: main.php?Xaction=Roster.workspace&club_serial='.$club,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>';
|
||||
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>';
|
||||
}
|
||||
$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>';
|
||||
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>';
|
||||
}
|
||||
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>';
|
||||
}
|
||||
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.='<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());
|
||||
$this->displayContent($html.'</div>');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user