generated from jric11/baseProject
90 lines
12 KiB
PHP
90 lines
12 KiB
PHP
<?php
|
|
|
|
/** A member summary always belongs to one authorized club membership. */
|
|
class MemberSummary extends Base {
|
|
private string $memberLabel='';
|
|
private static function h(mixed $value): string {return htmlspecialchars((string)$value,ENT_QUOTES,'UTF-8');}
|
|
private static function money(int $cents): string {return '$'.number_format($cents/100,2);}
|
|
private function form(string $step,int $club,int $membership): string {
|
|
$url='main.php?'.http_build_query(['Xaction'=>'Roster.memberSummary','club_serial'=>$club,'membership_serial'=>$membership,'members_page'=>BoundedList::page('members'),'dues_page'=>BoundedList::page('dues'),'payments_page'=>BoundedList::page('payments'),'adjustments_page'=>BoundedList::page('adjustments'),'seasons_page'=>BoundedList::page('seasons')]);
|
|
return '<form method="post" action="'.self::h($url).'" class="row g-2 mb-3"><p class="text-secondary">Member: '.self::h($this->memberLabel).'</p><input type="hidden" name="action" value="Roster.memberSummary"><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.'"><input type="hidden" name="membership_serial" value="'.$membership.'">';
|
|
}
|
|
private static function field(string $name,string $label,string $type='text',string $value='',int $max=128): string {
|
|
$id='member_'.$name.'_'.bin2hex(random_bytes(3));
|
|
return '<div class="col-md"><label class="form-label" for="'.$id.'">'.$label.'</label><input class="form-control" id="'.$id.'" name="'.$name.'" type="'.$type.'" value="'.self::h($value).'" maxlength="'.$max.'" required></div>';
|
|
}
|
|
|
|
public function show(): void {
|
|
$club=MaintenanceService::id($_GET['club_serial']??$_POST['club_serial']??null);
|
|
$membership=MaintenanceService::id($_GET['membership_serial']??$_POST['membership_serial']??null);
|
|
$service=new RosterService();$member=$service->member($club,$membership);$selected=$service->club($club);
|
|
$this->memberLabel=trim($member['user_first_name'].' '.$member['user_last_name']).' ('.$member['user_email'].')';
|
|
$manage=$service->manages($club);$super=(new TenantAccess())->isSuper();$active=$selected['club_status']==='active';
|
|
if(($_SERVER['REQUEST_METHOD']??'GET')==='POST'){
|
|
$this->requireCsrf();(new TenantAccess())->requireManager($club);
|
|
if(!$active)throw new AppException('Reactivate the club before changing records.',409);
|
|
if((int)($_POST['membership_serial']??0)!==$membership||(int)($_POST['club_serial']??0)!==$club)throw new AppException('The selected membership changed. Reload its summary.',409);
|
|
switch($_POST['step']??''){
|
|
case 'member':$service->saveMember($club,$membership,'',$_POST['membership_status']??'',$_POST['joined_on']??'',$_POST['membership_role']??'member');break;
|
|
case 'charge':$service->charge($club,$membership,MaintenanceService::id($_POST['season_serial']??null),$_POST['amount']??'');break;
|
|
case 'payment':(new LedgerService())->payment($club,MaintenanceService::id($_POST['due_serial']??null),$_POST['amount']??'',$_POST['paid_on']??'',$_POST['request_key']??'',$_POST['payment_method']??'unspecified',$_POST['payment_reference']??'',$membership);break;
|
|
default:throw new AppException('Unknown member action.');
|
|
}
|
|
$_SESSION['roster_notice']='Changes saved.';
|
|
$redirect='main.php?'.http_build_query(['Xaction'=>'Roster.memberSummary','club_serial'=>$club,'membership_serial'=>$membership,'members_page'=>BoundedList::page('members'),'dues_page'=>BoundedList::page('dues'),'payments_page'=>BoundedList::page('payments'),'adjustments_page'=>BoundedList::page('adjustments'),'seasons_page'=>BoundedList::page('seasons')]);
|
|
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);return;
|
|
}
|
|
$dues=$service->dues($club,BoundedList::page('dues'),$membership);
|
|
$history=(new LedgerService())->history($club,BoundedList::page('payments'),BoundedList::page('adjustments'),$membership);
|
|
// Totals include every season, independently of the visible history pages.
|
|
$stmt=$this->connect()->prepare('SELECT COALESCE(SUM(d.amount_cents+COALESCE((SELECT SUM(a.amount_cents) FROM club_due_adjustments a WHERE a.due_serial=d.due_serial),0)),0) AS dues_cents,COALESCE(SUM(COALESCE((SELECT SUM(p.amount_cents) FROM club_payments p WHERE p.due_serial=d.due_serial AND NOT EXISTS(SELECT 1 FROM club_payment_reversals r WHERE r.payment_serial=p.payment_serial)),0)),0) AS paid_cents FROM club_dues d WHERE d.club_serial=? AND d.membership_serial=?');
|
|
$stmt->execute([$club,$membership]);$totals=$stmt->fetch();
|
|
$back='main.php?'.http_build_query(['Xaction'=>'Roster.workspace','club_serial'=>$club,'members_page'=>BoundedList::page('members')]).'#roster';
|
|
$name=trim($member['user_first_name'].' '.$member['user_last_name']);
|
|
$html='<main class="container-fluid py-3 memberSummary"><header class="mb-4"><h1>'.self::h($name).'</h1><p>'.self::h($member['user_email']).'</p><p>Club: '.self::h($selected['club_name']).' · '.self::h(ucfirst($selected['club_status'])).'</p><p>Membership: '.self::h(ucfirst($member['membership_status'])).' · '.self::h(ucfirst($member['membership_role'])).'<br>Joined: '.self::h($member['joined_on']).'</p><a class="btn btn-outline-primary" href="'.self::h($back).'">Back to club roster</a></header>';
|
|
if(!empty($_SESSION['roster_notice'])){$html.='<p class="alert alert-success" role="status">'.self::h($_SESSION['roster_notice']).'</p>';unset($_SESSION['roster_notice']);}
|
|
if($manage&&$active){
|
|
$html.='<nav class="d-flex flex-wrap gap-2 mb-3" aria-label="Member actions">';
|
|
if($super||$member['membership_role']!=='administrator'){
|
|
$html.=$this->form('member',$club,$membership).self::field('joined_on','Joined on','date',$member['joined_on']).'<label class="form-label">Membership status<select class="form-select" name="membership_status"><option value="active"'.($member['membership_status']==='active'?' selected':'').'>Active</option><option value="inactive"'.($member['membership_status']==='inactive'?' selected':'').'>Inactive</option></select></label>';
|
|
if($super)$html.='<label class="form-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>';
|
|
else $html.='<input type="hidden" name="membership_role" value="member">';
|
|
$html.='<button class="btn btn-primary">Save membership</button></form>';
|
|
}
|
|
if($member['membership_status']==='active'){
|
|
$seasons=$service->seasons($club,BoundedList::page('seasons'));
|
|
if($seasons){$html.=$this->form('charge',$club,$membership).'<p>Add dues for '.self::h($name).'.</p><label class="form-label" for="member_dues_season">Season</label><select class="form-select" name="season_serial" id="member_dues_season">';foreach($seasons as $season)$html.='<option value="'.(int)$season['season_serial'].'">'.self::h($season['season_name']).'</option>';$html.='</select>'.self::field('amount','Dues (USD)').'<button class="btn btn-primary">Save dues</button>'.BoundedList::navigation('seasons','Roster.memberSummary').'</form>';}
|
|
else $html.='<p>Add a season from the club page before recording dues.</p>';
|
|
}
|
|
$html.='</nav>';
|
|
}
|
|
$html.='<div class="row g-3 mb-4">';
|
|
foreach(['Total dues'=>(int)$totals['dues_cents'],'Payments received'=>(int)$totals['paid_cents'],'Balance due'=>(int)$totals['dues_cents']-(int)$totals['paid_cents']] as $label=>$amount)
|
|
$html.='<div class="col-12 col-md-4"><section class="card h-100"><div class="card-body"><h2 class="h6">'.$label.'</h2><p class="h4 mb-0">'.self::money($amount).'</p></div></section></div>';
|
|
$html.='</div><p>Amounts are in USD. Totals include all seasons and exclude reversed payments.</p><h2 class="h4 mt-4">Season dues</h2><div class="table-responsive"><table class="table"><thead><tr><th>Season</th><th>Dues</th><th>Paid</th><th>Balance</th>'.($manage&&$active?'<th>Actions</th>':'').'</tr></thead><tbody>';
|
|
foreach($dues as $due){
|
|
$balance=(int)$due['amount_cents']-(int)$due['paid_cents'];
|
|
$html.='<tr><td>'.self::h($due['season_name']).'</td><td>'.self::money((int)$due['amount_cents']).'</td><td>'.self::money((int)$due['paid_cents']).'</td><td>'.self::money($balance).'</td>';
|
|
if($manage&&$active){
|
|
$html.='<td>';
|
|
if($balance>0)$html.=$this->form('payment',$club,$membership).'<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)').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-primary">Record payment</button></form>';
|
|
$html.='</td>';
|
|
}
|
|
$html.='</tr>';
|
|
}
|
|
if(!$dues)$html.='<tr><td colspan="'.($manage&&$active?5:4).'">No dues recorded for this membership.</td></tr>';
|
|
$html.='</tbody></table></div>'.BoundedList::navigation('dues','Roster.memberSummary').'<h2 class="h4 mt-4">Payment history</h2><div class="table-responsive"><table class="table"><thead><tr><th>Payment</th><th>Date</th><th>Amount</th><th>Method / reference</th><th>Status</th></tr></thead><tbody>';
|
|
foreach($history['payments'] as $payment){
|
|
$status=$payment['reversal_serial']?'Reversed: '.$payment['reversal_reason'].' ('.$payment['reversed_at'].' UTC)':'Received';
|
|
$html.='<tr><td>#'.(int)$payment['payment_serial'].' · Dues #'.(int)$payment['due_serial'].'</td><td>'.self::h($payment['paid_on']).'</td><td>'.self::money((int)$payment['amount_cents']).'</td><td>'.self::h($payment['payment_method'].' / '.$payment['payment_reference']).'</td><td>'.self::h($status).'</td></tr>';
|
|
}
|
|
if(!$history['payments'])$html.='<tr><td colspan="5">No payments recorded for this membership.</td></tr>';
|
|
$html.='</tbody></table></div>'.BoundedList::navigation('payments','Roster.memberSummary').'<h2 class="h4 mt-4">Dues correction history</h2><div class="table-responsive"><table class="table"><thead><tr><th>Date (UTC)</th><th>Adjustment</th><th>Corrected total</th><th>Reason</th></tr></thead><tbody>';
|
|
foreach($history['adjustments'] as $adjustment)$html.='<tr><td>'.self::h($adjustment['created_at']).'</td><td>'.self::money((int)$adjustment['amount_cents']).'</td><td>'.self::money((int)$adjustment['corrected_amount_cents']).'</td><td>'.self::h($adjustment['reason']).'</td></tr>';
|
|
if(!$history['adjustments'])$html.='<tr><td colspan="4">No dues corrections recorded for this membership.</td></tr>';
|
|
$html.='</tbody></table></div>'.BoundedList::navigation('adjustments','Roster.memberSummary').'</main>';
|
|
$this->displayContent($manage&&$active?ClubFormModals::render($html):$html);
|
|
}
|
|
}
|