generated from jric11/baseProject
127 lines
17 KiB
PHP
127 lines
17 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'),'notes_page'=>BoundedList::page('notes')]);
|
||
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>';
|
||
}
|
||
|
||
private function noteForm(int $club,int $membership,?array $note=null): string {
|
||
$id='club_note_'.($note?(int)$note['note_serial']:'new');
|
||
$form=$this->form($note?'note_edit':'note_add',$club,$membership);
|
||
if($note)$form.='<input type="hidden" name="note_serial" value="'.(int)$note['note_serial'].'"><input type="hidden" name="note_version" value="'.hash('sha256',$note['note_text']).'">';
|
||
return $form.'<label class="form-label" for="'.$id.'">Note</label><textarea class="form-control" id="'.$id.'" name="note_text" rows="6" maxlength="2000" required>'.self::h($note['note_text']??'').'</textarea><p>Up to 2,000 characters. Only administrators of this club and Super Administrators can see this note.</p><button class="btn btn-primary">Save note</button></form>';
|
||
}
|
||
|
||
private function deleteNoteForm(int $club,int $membership,array $note): string {
|
||
return $this->form('note_delete',$club,$membership).'<input type="hidden" name="note_serial" value="'.(int)$note['note_serial'].'"><input type="hidden" name="note_version" value="'.hash('sha256',$note['note_text']).'"><p>Delete this administrative note? This cannot be undone. A record of the deletion will remain in the audit trail.</p><blockquote style="white-space:pre-wrap;overflow-wrap:anywhere">'.self::h($note['note_text']).'</blockquote><button class="btn btn-danger">Confirm deletion</button></form>';
|
||
}
|
||
|
||
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;
|
||
case 'adjust':(new LedgerService())->adjust($club,MaintenanceService::id($_POST['due_serial']??null),$_POST['corrected_amount']??'',$_POST['reason']??'',$_POST['request_key']??'',$membership);break;
|
||
case 'reverse':(new LedgerService())->reverse($club,MaintenanceService::id($_POST['payment_serial']??null),$_POST['reason']??'',$_POST['request_key']??'',$membership);break;
|
||
case 'note_add':(new ClubMemberNotes())->save($club,$membership,0,$_POST['note_text']??'');break;
|
||
case 'note_edit':(new ClubMemberNotes())->save($club,$membership,MaintenanceService::id($_POST['note_serial']??null),$_POST['note_text']??'',$_POST['note_version']??'');break;
|
||
case 'note_delete':(new ClubMemberNotes())->delete($club,$membership,MaintenanceService::id($_POST['note_serial']??null),$_POST['note_version']??'');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'),'notes_page'=>(in_array($_POST['step']??'',['note_add','note_edit','note_delete'],true)?1:BoundedList::page('notes'))]);
|
||
if(in_array($_POST['step']??'',['note_add','note_edit','note_delete'],true))$redirect.='#admin-notes';
|
||
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>';
|
||
}
|
||
if($manage){
|
||
$notes=(new ClubMemberNotes())->listing($club,$membership,BoundedList::page('notes'));
|
||
$html.='<section class="mb-4 member-admin-notes" id="admin-notes"><h2 class="h4">Administrative notes</h2><p>Visible only to this club’s administrators and Super Administrators.</p>';
|
||
if($active)$html.=$this->noteForm($club,$membership);
|
||
$html.='<div class="table-responsive"><table class="table"><thead><tr><th>Note</th><th>Updated</th><th>By</th>'.($active?'<th>Actions</th>':'').'</tr></thead><tbody>';
|
||
foreach($notes as $note){
|
||
$html.='<tr><td style="white-space:pre-wrap;overflow-wrap:anywhere">'.self::h($note['note_text']).'</td><td>'.self::h($note['note_timestamp']).'</td><td>'.self::h($note['note_origin']).'</td>';
|
||
if($active)$html.='<td>'.$this->noteForm($club,$membership,$note).$this->deleteNoteForm($club,$membership,$note).'</td>';
|
||
$html.='</tr>';
|
||
}
|
||
if(!$notes)$html.='<tr><td colspan="'.($active?4:3).'">No administrative notes yet.</td></tr>';
|
||
$html.='</tbody></table></div>'.BoundedList::navigation('notes','Roster.memberSummary').'</section>';
|
||
}
|
||
$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.=$this->form('adjust',$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('corrected_amount','Corrected total (USD)','text',number_format((int)$due['amount_cents']/100,2,'.','')).self::field('reason','Reason','text','',500).'<p>This records an adjustment and preserves the original dues and payment history.</p><button class="btn btn-primary">Save correction</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>'.($manage&&$active?'<th>Actions</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>';
|
||
if($manage&&$active){
|
||
$html.='<td>';
|
||
if(!$payment['reversal_serial'])$html.=$this->form('reverse',$club,$membership).'<input type="hidden" name="payment_serial" value="'.(int)$payment['payment_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('reason','Reversal reason','text','',500).'<p>This preserves the original payment and restores the amount owed. It does not issue a refund.</p><button class="btn btn-outline-danger">Confirm reversal</button></form>';
|
||
$html.='</td>';
|
||
}
|
||
$html.='</tr>';
|
||
}
|
||
if(!$history['payments'])$html.='<tr><td colspan="'.($manage&&$active?6: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);
|
||
}
|
||
}
|