generated from jric11/baseProject
This commit is contained in:
+1
-1
@@ -15,7 +15,7 @@ class Base {
|
||||
|
||||
const APPLICATION_NAME = "My Hunting Club Roster";
|
||||
const APPLICATION_MNEMONIC = "HUNTINGCLUBROSTER";
|
||||
const VERSION = "1.04";
|
||||
const VERSION = "1.05";
|
||||
const TIMEZONE = "America/New_York";
|
||||
// =================
|
||||
// Session Variables
|
||||
|
||||
@@ -5,7 +5,8 @@ final class ClubFormModals {
|
||||
public static function render(string $html): string {
|
||||
$titles=['club'=>'Edit or archive club','member'=>'Add member','invite'=>'Invite members',
|
||||
'season'=>'Add season','charge'=>'Record season dues','payment'=>'Record payment',
|
||||
'adjust'=>'Correct dues','reverse'=>'Reverse incorrect payment','revokeInvitation'=>'Revoke invitation'];
|
||||
'adjust'=>'Correct dues','reverse'=>'Reverse incorrect payment','revokeInvitation'=>'Revoke invitation',
|
||||
'note_add'=>'Add note','note_edit'=>'Edit note','note_delete'=>'Delete note'];
|
||||
$doc=new DOMDocument('1.0','UTF-8');$previous=libxml_use_internal_errors(true);
|
||||
try{$loaded=$doc->loadHTML('<?xml encoding="UTF-8">'.$html,LIBXML_NONET);}
|
||||
finally{libxml_clear_errors();libxml_use_internal_errors($previous);}
|
||||
@@ -20,7 +21,7 @@ final class ClubFormModals {
|
||||
$title=$step==='member'&&!empty($values['membership_serial'])?'Edit membership':$titles[$step];
|
||||
$id='club-action-'.(++$index);$parent=$form->parentNode;
|
||||
$trigger=$doc->createElement('button');$trigger->appendChild($doc->createTextNode($title));
|
||||
foreach(['type'=>'button','class'=>'btn btn-outline-primary me-2 mb-2','data-bs-toggle'=>'modal','data-bs-target'=>'#'.$id,'data-club-step'=>$step] as $key=>$value)$trigger->setAttribute($key,$value);
|
||||
foreach(['type'=>'button','class'=>'btn '.($step==='note_delete'?'btn-outline-danger':'btn-outline-primary').' me-2 mb-2','data-bs-toggle'=>'modal','data-bs-target'=>'#'.$id,'data-club-step'=>$step] as $key=>$value)$trigger->setAttribute($key,$value);
|
||||
$dialog=$doc->createElement('div');
|
||||
foreach(['class'=>'modal fade club-action-modal','id'=>$id,'tabindex'=>'-1','aria-labelledby'=>$id.'-title','aria-hidden'=>'true'] as $key=>$value)$dialog->setAttribute($key,$value);
|
||||
$layout=$dialog->appendChild($doc->createElement('div'));$layout->setAttribute('class','modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable');
|
||||
@@ -31,7 +32,7 @@ final class ClubFormModals {
|
||||
$body=$content->appendChild($doc->createElement('div'));$body->setAttribute('class','modal-body');
|
||||
// Keep the selected row visible in the dialog so financial/member actions are unambiguous.
|
||||
$row=$parent;while($row&&$row!==$main&&!in_array($row->nodeName,['tr','li'],true))$row=$row->parentNode;
|
||||
if($row&&$row!==$main){
|
||||
if($row&&$row!==$main&&!in_array($step,['note_edit','note_delete'],true)){
|
||||
$text=$row->textContent;
|
||||
if($row->nodeName==='tr')$text=implode(' · ',array_map(static fn($cell)=>trim($cell->textContent),array_slice(iterator_to_array($row->getElementsByTagName('td')),0,-1)));
|
||||
$context=$body->appendChild($doc->createElement('p'));$context->setAttribute('class','text-secondary');$context->appendChild($doc->createTextNode(trim(preg_replace('/\s+/u',' ',$text))));
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
/** Private notes reference a club membership, never a global user account. */
|
||||
class ClubMemberNotes extends RosterService {
|
||||
public const TYPE='ClubMember';
|
||||
public const LIMIT=2000;
|
||||
|
||||
public function listing(int $club,int $membership,int $page): array {
|
||||
(new TenantAccess())->requireManager($club);$this->member($club,$membership);
|
||||
return BoundedList::rows($this->connect(),'SELECT note_serial,note_text,note_origin,note_timestamp FROM notes WHERE note_type=? AND note_reference=? ORDER BY note_timestamp DESC,note_serial DESC',[self::TYPE,$membership],$page,'notes');
|
||||
}
|
||||
|
||||
public function save(int $club,int $membership,int $id,string $text,string $version=''): int {
|
||||
(new TenantAccess())->requireManager($club);
|
||||
$this->requireTransactionalNotes();
|
||||
$text=trim($text);
|
||||
if(!mb_check_encoding($text,'UTF-8')||$text===''||mb_strlen($text,'UTF-8')>self::LIMIT)throw new AppException('Enter a note of up to 2,000 characters.');
|
||||
return DatabaseTransaction::run($this->connect(),function(PDO $db)use($club,$membership,$id,$text,$version){
|
||||
$this->club($club,$db,true);(new TenantAccess())->requireManager($club,$db);$this->member($club,$membership);
|
||||
if($id){
|
||||
$stmt=$db->prepare('SELECT note_text FROM notes WHERE note_serial=? AND note_type=? AND note_reference=? FOR UPDATE');$stmt->execute([$id,self::TYPE,$membership]);$old=$stmt->fetchColumn();
|
||||
if($old===false)throw new AppException('Note not found in this membership.',404);
|
||||
if(!hash_equals(hash('sha256',$old),$version))throw new AppException('This note changed since you opened it. Reload the summary before editing again.',409);
|
||||
$stmt=$db->prepare('UPDATE notes SET note_text=?,note_origin=? WHERE note_serial=? AND note_type=? AND note_reference=?');$stmt->execute([$text,$_SESSION[self::USER_NAME]??'',$id,self::TYPE,$membership]);
|
||||
}else{
|
||||
$stmt=$db->prepare('INSERT INTO notes(note_type,note_reference,note_text,note_origin) VALUES(?,?,?,?)');$stmt->execute([self::TYPE,$membership,$text,$_SESSION[self::USER_NAME]??'']);$id=(int)$db->lastInsertId();
|
||||
}
|
||||
Audit::required($db,(int)$_SESSION[self::USER_SERIAL],'saved club member note '.json_encode(['club'=>$club,'membership'=>$membership,'note'=>$id]));
|
||||
return $id;
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(int $club,int $membership,int $id,string $version): void {
|
||||
(new TenantAccess())->requireManager($club);$this->requireTransactionalNotes();
|
||||
DatabaseTransaction::run($this->connect(),function(PDO $db)use($club,$membership,$id,$version){
|
||||
$this->club($club,$db,true);(new TenantAccess())->requireManager($club,$db);$this->member($club,$membership);
|
||||
$stmt=$db->prepare('SELECT note_text FROM notes WHERE note_serial=? AND note_type=? AND note_reference=? FOR UPDATE');$stmt->execute([$id,self::TYPE,$membership]);$old=$stmt->fetchColumn();
|
||||
if($old===false)throw new AppException('Note not found in this membership.',404);
|
||||
if(!hash_equals(hash('sha256',$old),$version))throw new AppException('This note changed since you opened it. Reload the summary before deleting it.',409);
|
||||
$stmt=$db->prepare('DELETE FROM notes WHERE note_serial=? AND note_type=? AND note_reference=?');$stmt->execute([$id,self::TYPE,$membership]);
|
||||
Audit::required($db,(int)$_SESSION[self::USER_SERIAL],'deleted club member note '.json_encode(['club'=>$club,'membership'=>$membership,'note'=>$id]));
|
||||
});
|
||||
}
|
||||
|
||||
private function requireTransactionalNotes(): void {
|
||||
$engine=$this->connect()->query("SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='notes'")->fetchColumn();
|
||||
if(strtoupper((string)$engine)!=='INNODB')throw new AppException('Administrative notes require transactional note storage. Contact support.',503);
|
||||
}
|
||||
}
|
||||
@@ -38,10 +38,12 @@ class LedgerService extends RosterService {
|
||||
Audit::required($db,$this->actor(),'received payment '.json_encode(['payment'=>(int)$db->lastInsertId(),'club'=>$club,'due'=>$due,'amount_cents'=>$cents,'paid_on'=>$paid,'method'=>$method,'reference'=>$reference]));
|
||||
});
|
||||
}
|
||||
public function adjust(int $club,int $due,string $total,string $reason,string $key): void {
|
||||
public function adjust(int $club,int $due,string $total,string $reason,string $key,?int $membership=null): void {
|
||||
$cents=self::cents($total);$reason=$this->reason($reason);$this->key($key);
|
||||
$this->transaction(function(PDO $db)use($club,$due,$cents,$reason,$key){
|
||||
$row=$this->lockedDue($db,$club,$due);$stmt=$db->prepare('SELECT * FROM club_due_adjustments WHERE request_key=?');$stmt->execute([$key]);$existing=$stmt->fetch();
|
||||
$this->transaction(function(PDO $db)use($club,$due,$cents,$reason,$key,$membership){
|
||||
$row=$this->lockedDue($db,$club,$due);
|
||||
if($membership!==null&&(int)$row['membership_serial']!==$membership)throw new AppException('Dues record does not belong to this membership.',404);
|
||||
$stmt=$db->prepare('SELECT * FROM club_due_adjustments WHERE request_key=?');$stmt->execute([$key]);$existing=$stmt->fetch();
|
||||
if($existing){if((int)$existing['due_serial']!==$due||(int)$existing['corrected_amount_cents']!==$cents||$existing['reason']!==$reason)throw new AppException('This request key was already used for different correction details.',409);return;}
|
||||
if($cents<$row['paid'])throw new AppException('Corrected dues cannot be below received payments. Reverse an incorrect payment first.',409);
|
||||
if($cents===$row['effective'])throw new AppException('The corrected amount must differ from the current dues.',409);
|
||||
@@ -49,11 +51,12 @@ class LedgerService extends RosterService {
|
||||
Audit::required($db,$this->actor(),'corrected dues '.json_encode(['adjustment'=>(int)$db->lastInsertId(),'club'=>$club,'due'=>$due,'before_cents'=>$row['effective'],'after_cents'=>$cents,'reason'=>$reason]));
|
||||
});
|
||||
}
|
||||
public function reverse(int $club,int $payment,string $reason,string $key): void {
|
||||
public function reverse(int $club,int $payment,string $reason,string $key,?int $membership=null): void {
|
||||
$reason=$this->reason($reason);$this->key($key);
|
||||
$this->transaction(function(PDO $db)use($club,$payment,$reason,$key){
|
||||
$this->transaction(function(PDO $db)use($club,$payment,$reason,$key,$membership){
|
||||
$this->club($club,$db,true);$stmt=$db->prepare('SELECT p.* FROM club_payments p JOIN club_dues d ON d.due_serial=p.due_serial WHERE p.payment_serial=? AND d.club_serial=?');$stmt->execute([$payment,$club]);$row=$stmt->fetch();if(!$row)throw new AppException('Payment not found in this club.',404);
|
||||
$this->lockedDue($db,$club,(int)$row['due_serial']);
|
||||
$dueRow=$this->lockedDue($db,$club,(int)$row['due_serial']);
|
||||
if($membership!==null&&(int)$dueRow['membership_serial']!==$membership)throw new AppException('Payment does not belong to this membership.',404);
|
||||
$stmt=$db->prepare('SELECT * FROM club_payment_reversals WHERE request_key=? OR payment_serial=?');$stmt->execute([$key,$payment]);$existing=$stmt->fetch();
|
||||
if($existing){if($existing['request_key']===$key&&(int)$existing['payment_serial']===$payment&&$existing['reason']===$reason)return;throw new AppException('Payment is already reversed or this request key was already used.',409);}
|
||||
$stmt=$db->prepare('INSERT INTO club_payment_reversals(payment_serial,reason,recorded_by,request_key) VALUES(?,?,?,?)');$stmt->execute([$payment,$reason,$this->actor(),$key]);
|
||||
|
||||
@@ -6,7 +6,7 @@ class MemberSummary extends Base {
|
||||
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')]);
|
||||
$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 {
|
||||
@@ -14,6 +14,17 @@ class MemberSummary extends Base {
|
||||
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);
|
||||
@@ -28,10 +39,16 @@ class MemberSummary extends Base {
|
||||
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')]);
|
||||
$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;
|
||||
}
|
||||
@@ -59,6 +76,19 @@ class MemberSummary extends Base {
|
||||
}
|
||||
$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>';
|
||||
@@ -69,17 +99,24 @@ class MemberSummary extends Base {
|
||||
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></tr></thead><tbody>';
|
||||
$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></tr>';
|
||||
$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="5">No payments recorded for this membership.</td></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>';
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ class Notes extends Base {
|
||||
$this->displayContent($this->applyXSL($note,$this->getXSL('editNote')));
|
||||
}
|
||||
public function deleteNote(){
|
||||
$stmt=$this->connect()->prepare('DELETE FROM notes WHERE note_serial=?');$stmt->execute([MaintenanceService::id($_POST['note_serial']??null)]);if(!$stmt->rowCount())throw new AppException('Note not found.',404);
|
||||
$stmt=$this->connect()->prepare("DELETE FROM notes WHERE note_serial=? AND note_type='User'");$stmt->execute([MaintenanceService::id($_POST['note_serial']??null)]);if(!$stmt->rowCount())throw new AppException('Note not found.',404);
|
||||
}
|
||||
public function write(int $id,string $type,int $reference,string $text): int {
|
||||
$text=trim($text);if(!in_array($type,self::VALID_TYPES,true)||!$text||strlen($text)>65535)throw new AppException('Enter a valid user note (up to 65,535 bytes).');
|
||||
|
||||
Reference in New Issue
Block a user