1148 lines
41 KiB
PHP
1148 lines
41 KiB
PHP
<?= $this->extend('layout/management_layout') ?>
|
||
<?= $this->section('content') ?>
|
||
|
||
<!-- ===== Styled Confirmation Overlay CSS ===== -->
|
||
<style>
|
||
#payConfirmOverlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
display: none;
|
||
background: rgba(0, 0, 0, .5);
|
||
z-index: 1085;
|
||
}
|
||
|
||
#payConfirmCard {
|
||
width: min(560px, 92vw);
|
||
margin: 6vh auto;
|
||
background: #fff;
|
||
border-radius: 14px;
|
||
box-shadow: 0 20px 60px rgba(0, 0, 0, .25);
|
||
overflow: hidden;
|
||
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
||
}
|
||
|
||
#payConfirmHeader {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 14px 18px;
|
||
color: #fff;
|
||
background: linear-gradient(135deg, #198754, #20c997);
|
||
}
|
||
|
||
#payConfirmHeader .title {
|
||
font-size: 1.05rem;
|
||
font-weight: 700;
|
||
margin: 0;
|
||
}
|
||
|
||
#payConfirmBadge {
|
||
margin-left: auto;
|
||
font-size: .8rem;
|
||
font-weight: 600;
|
||
background: rgba(255, 255, 255, .2);
|
||
padding: 3px 10px;
|
||
border-radius: 999px;
|
||
border: 1px solid rgba(255, 255, 255, .4);
|
||
}
|
||
|
||
#payConfirmBody {
|
||
padding: 16px 18px;
|
||
}
|
||
|
||
#payConfirmList {
|
||
margin: 10px 0 0;
|
||
padding-left: 1.1rem;
|
||
}
|
||
|
||
#payConfirmList li {
|
||
margin: 4px 0;
|
||
}
|
||
|
||
.amountRow {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: baseline;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.amountRow .label {
|
||
opacity: .8;
|
||
}
|
||
|
||
.amountRow .pill {
|
||
font-weight: 700;
|
||
letter-spacing: .2px;
|
||
background: #f1f3f5;
|
||
border-radius: 8px;
|
||
padding: 4px 10px;
|
||
}
|
||
|
||
#pcOverpay {
|
||
display: none;
|
||
margin-top: 12px;
|
||
padding: 10px 12px;
|
||
border-radius: 10px;
|
||
background: #fff3cd;
|
||
border: 1px solid #ffe69c;
|
||
color: #664d03;
|
||
}
|
||
|
||
#payConfirmFooter {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
padding: 12px 18px;
|
||
background: #f8f9fa;
|
||
border-top: 1px solid #eee;
|
||
}
|
||
|
||
.payBtn {
|
||
border: 0;
|
||
border-radius: 10px;
|
||
padding: 10px 14px;
|
||
font-weight: 700;
|
||
transition: transform .06s ease;
|
||
}
|
||
|
||
.payBtn:active {
|
||
transform: translateY(1px);
|
||
}
|
||
|
||
.payBtn.cancel {
|
||
background: #6c757d;
|
||
color: #fff;
|
||
}
|
||
|
||
.payBtn.confirm {
|
||
background: #198754;
|
||
color: #fff;
|
||
}
|
||
|
||
@media (prefers-color-scheme: dark) {
|
||
#payConfirmCard {
|
||
background: #1f2225;
|
||
}
|
||
|
||
#payConfirmBody {
|
||
color: #f1f3f5;
|
||
}
|
||
|
||
.amountRow .pill {
|
||
background: #2a2e31;
|
||
}
|
||
|
||
#payConfirmFooter {
|
||
background: #191c1f;
|
||
border-top-color: #2a2e31;
|
||
}
|
||
}
|
||
|
||
.manual-pay-suggest {
|
||
position: static;
|
||
max-height: 320px;
|
||
overflow-y: auto;
|
||
margin-top: 6px;
|
||
background: var(--bs-body-bg, #fff);
|
||
border: 1px solid rgba(0, 0, 0, .12);
|
||
border-radius: 8px;
|
||
box-shadow: 0 10px 24px rgba(0, 0, 0, .12);
|
||
}
|
||
</style>
|
||
|
||
<div class="container mt-4">
|
||
<h2 class="text-center mt-4 mb-3">Manual Payment</h2>
|
||
|
||
<?php if (session()->getFlashdata('success')): ?>
|
||
<div class="alert alert-success"><?= session()->getFlashdata('success') ?></div>
|
||
<?php endif; ?>
|
||
<?php if (session()->getFlashdata('error')): ?>
|
||
<div class="alert alert-danger"><?= session()->getFlashdata('error') ?></div>
|
||
<?php endif; ?>
|
||
|
||
<!-- Search form -->
|
||
<form method="get" action="<?= base_url('payment/manual_pay') ?>" class="mb-3">
|
||
<div>
|
||
<div class="input-group">
|
||
<input
|
||
type="text"
|
||
id="manualPaySearchInput"
|
||
name="search_term"
|
||
class="form-control"
|
||
placeholder="Enter Parent Email or Phone"
|
||
required
|
||
autocomplete="off"
|
||
aria-autocomplete="list"
|
||
aria-haspopup="listbox"
|
||
value="<?= esc($searchTermUsedInSearch ?? '') ?>">
|
||
<button class="btn btn-primary" type="submit">Search</button>
|
||
</div>
|
||
<div id="manualPaySuggest" class="list-group manual-pay-suggest d-none" role="listbox"></div>
|
||
</div>
|
||
</form>
|
||
|
||
<?php if ($parent): ?>
|
||
<!-- Parent + Student info -->
|
||
<div class="row mb-4">
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header">Parent Information</div>
|
||
<div class="card-body">
|
||
<p><strong>Name:</strong> <?= esc($parent['firstname']) ?> <?= esc($parent['lastname']) ?></p>
|
||
<p><strong>Phone:</strong> <?= esc($parent['cellphone']) ?></p>
|
||
<p><strong>Email:</strong> <?= esc($parent['email']) ?></p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header">Students</div>
|
||
<div class="card-body">
|
||
<?php if (!empty($students)): ?>
|
||
<ul class="list-group">
|
||
<?php foreach ($students as $student): ?>
|
||
<li class="list-group-item"><?= esc($student['firstname']) ?> <?= esc($student['lastname']) ?></li>
|
||
<?php endforeach; ?>
|
||
</ul>
|
||
<?php else: ?>
|
||
<p>No students found.</p>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Payments table -->
|
||
<h5>Payments</h5>
|
||
<table class="table table-bordered no-mgmt-sticky">
|
||
<thead>
|
||
<tr>
|
||
<th>Paid Date</th>
|
||
<th>Paid Amount</th>
|
||
<th>Balance</th>
|
||
<th>Payment Method</th>
|
||
<th>Check Number</th>
|
||
<th>Installments</th>
|
||
<th>Status</th>
|
||
<th>Check/Card File</th>
|
||
<th>Action</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($payments as $payment): ?>
|
||
<tr>
|
||
<?php
|
||
// Use updated_at if present; else update_at; else payment_date
|
||
$rawTs = $payment['updated_at'] ?? $payment['update_at'] ?? $payment['payment_date'] ?? null;
|
||
if (!empty($rawTs)) {
|
||
try { $paidAt = local_datetime($rawTs, 'm-d-Y h:i A'); }
|
||
catch (\Throwable $e) { $paidAt = '-'; }
|
||
} else { $paidAt = '-'; }
|
||
$method = strtolower(trim((string)($payment['payment_method'] ?? '')));
|
||
?>
|
||
<td><?= esc($paidAt) ?></td>
|
||
<td>$<?= number_format((float)($payment['paid_amount'] ?? 0), 2) ?></td>
|
||
<td>$<?= number_format((float)($payment['balance'] ?? 0), 2) ?></td>
|
||
<td>
|
||
<?php if ($method === 'cash'): ?>
|
||
<span class="badge" style="background-color:#28a745;color:#fff;">Cash</span>
|
||
<?php elseif ($method === 'check'): ?>
|
||
<span class="badge" style="background-color:#1ba2b9;color:#fff;">Check</span>
|
||
<?php elseif ($method === 'card' || $method === 'debit/credit card'): ?>
|
||
<span class="badge" style="background-color:#007bff;color:#fff;">Card</span>
|
||
<?php else: ?>
|
||
<span class="badge bg-secondary"><?= esc($payment['payment_method'] ?? '') ?></span>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td><?= !empty($payment['check_number']) ? esc($payment['check_number']) : '-' ?></td>
|
||
<td><?= esc($payment['number_of_installments'] ?? '') ?></td>
|
||
<td><?= esc($payment['status'] ?? '') ?></td>
|
||
<td>
|
||
<?php if (!empty($payment['check_file'])): ?>
|
||
<a href="#" data-bs-toggle="modal" data-bs-target="#checkModal<?= (int)$payment['id'] ?>">View</a> /
|
||
<a href="<?= base_url('payment/serveCheckFile/' . esc($payment['check_file']) . '/download') ?>">Download</a>
|
||
|
||
<!-- File Preview Modal -->
|
||
<div class="modal fade" id="checkModal<?= (int)$payment['id'] ?>" tabindex="-1" aria-hidden="true">
|
||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">Check/Card File</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body text-center">
|
||
<iframe src="<?= base_url('payment/serveCheckFile/' . esc($payment['check_file']) . '/inline') ?>" width="100%" height="600" style="border:none;"></iframe>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<?php else: ?>
|
||
-
|
||
<?php endif; ?>
|
||
</td>
|
||
<td>
|
||
<button class="btn btn-sm btn-secondary" data-bs-toggle="modal" data-bs-target="#editPaymentModal<?= (int)$payment['id'] ?>">Edit</button>
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- Edit Payment Modal -->
|
||
<div class="modal fade" id="editPaymentModal<?= (int)$payment['id'] ?>" tabindex="-1" aria-labelledby="editPaymentModalLabel<?= (int)$payment['id'] ?>" aria-hidden="true">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<form method="post" action="<?= base_url('payment/manual_pay_edit') ?>" enctype="multipart/form-data">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="payment_id" value="<?= (int)$payment['id'] ?>">
|
||
<input type="hidden" name="search_term" value="<?= esc($searchTermUsedInSearch ?? '') ?>">
|
||
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="editPaymentModalLabel<?= (int)$payment['id'] ?>">Edit Payment ID <?= (int)$payment['id'] ?></h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="mb-3">
|
||
<label>Paid Amount</label>
|
||
<input type="number" step="0.01" name="paid_amount" class="form-control" value="<?= esc($payment['paid_amount']) ?>" required>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label>Payment Method</label>
|
||
<select name="payment_method" class="form-select edit-payment-method" data-payment-id="<?= (int)$payment['id'] ?>" onchange="ManualPayUI.toggleEditFields(<?= (int)$payment['id'] ?>)" required>
|
||
<option value="Cash" <?= strtolower($payment['payment_method']) === 'cash' ? 'selected' : '' ?>>Cash</option>
|
||
<option value="Check" <?= strtolower($payment['payment_method']) === 'check' ? 'selected' : '' ?>>Check</option>
|
||
<option value="Card" <?= (strtolower($payment['payment_method']) === 'card' || strtolower($payment['payment_method']) === 'debit/credit card') ? 'selected' : '' ?>>Debit/Credit Card</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="mb-3 check-number-edit" id="check-number-edit-<?= (int)$payment['id'] ?>" style="display:none;">
|
||
<label>Check Number</label>
|
||
<input type="text" name="check_number" class="form-control" value="<?= !empty($payment['check_number']) ? esc($payment['check_number']) : '' ?>">
|
||
</div>
|
||
|
||
<div class="mb-3" id="payment-file-edit-<?= (int)$payment['id'] ?>" style="display:none;">
|
||
<label>Replace Payment File (optional)</label>
|
||
<input type="file" name="payment_file" class="form-control" accept=".jpg,.jpeg,.png,.pdf">
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="submit" class="btn btn-success">Save Changes</button>
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
|
||
|
||
<?php if (isset($pager)): ?>
|
||
<div class="d-flex justify-content-center"><?= $pager->links() ?></div>
|
||
<?php endif; ?>
|
||
|
||
<!-- Invoices -->
|
||
<?php if (!empty($invoices)): ?>
|
||
<h5>Invoices</h5>
|
||
<table class="table table-bordered no-mgmt-sticky">
|
||
<thead>
|
||
<tr>
|
||
<th>Invoice #</th>
|
||
<th>Total</th>
|
||
<th>Paid</th>
|
||
<th>Discount</th>
|
||
<th>Refunds</th>
|
||
<th>Balance</th>
|
||
<th>Status</th>
|
||
<th>Due Date</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($invoices as $invoice): ?>
|
||
<tr>
|
||
<td><?= esc($invoice['invoice_number']) ?></td>
|
||
<td>$<?= number_format((float)$invoice['total_amount'], 2) ?></td>
|
||
<td>$<?= number_format((float)$invoice['paid_amount'], 2) ?></td>
|
||
<td>$<?= number_format((float)($invoice['discount'] ?? 0), 2) ?></td>
|
||
<td>$<?= number_format((float)($invoice['refund_paid'] ?? 0), 2) ?></td>
|
||
<?php $tblBal = max(0, (float)($invoice['total_amount'] ?? 0) - (float)($invoice['paid_amount'] ?? 0) - (float)($invoice['discount'] ?? 0) - (float)($invoice['refund_paid'] ?? 0)); ?>
|
||
<td>$<?= number_format($tblBal, 2) ?></td>
|
||
<td><?= esc($invoice['status']) ?></td>
|
||
<td><?= esc(!empty($invoice['due_date']) ? local_date($invoice['due_date'], 'm-d-Y') : '') ?></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
<?php endif; ?>
|
||
|
||
<div class="text-end mb-3">
|
||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addPaymentModal">Add Payment</button>
|
||
</div>
|
||
|
||
<?php elseif (isset($parent)): ?>
|
||
<p class="text-danger">Parent found but no payment records.</p>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<!-- ===== Add Payment Modal (Fixed) ===== -->
|
||
<div class="modal fade" id="addPaymentModal" tabindex="-1" aria-labelledby="addPaymentModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<form method="post" action="<?= base_url('payment/manual_pay_update') ?>" id="addPaymentForm" enctype="multipart/form-data">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="search_term" value="<?= esc($searchTermUsedInSearch ?? '') ?>">
|
||
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="addPaymentModalLabel">Add Manual Payment</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||
</div>
|
||
|
||
<div class="modal-body">
|
||
<!-- Invoice select -->
|
||
<div class="mb-3">
|
||
<label for="invoice_id" class="form-label">Select Invoice</label>
|
||
<select
|
||
name="invoice_id"
|
||
id="invoice_id"
|
||
class="form-select"
|
||
required
|
||
data-end-date="<?= esc($installmentEndYmd ?? '') ?>">
|
||
<?php if (!empty($invoices)): ?>
|
||
<?php foreach ($invoices as $invoice): ?>
|
||
<option
|
||
value="<?= esc($invoice['id']) ?>"
|
||
data-balance="<?= esc($invoice['balance']) ?>"
|
||
data-total="<?= esc($invoice['total_amount'] ?? $invoice['total'] ?? '') ?>"
|
||
data-paid="<?= esc($invoice['paid_amount'] ?? 0) ?>"
|
||
data-discount="<?= esc($invoice['discount'] ?? 0) ?>"
|
||
data-refund="<?= esc($invoice['refund_paid'] ?? 0) ?>"
|
||
data-next-installment="<?= (int)($invoice['next_installment'] ?? 1) ?>">
|
||
<?php
|
||
$uiTotal = (float)($invoice['total_amount'] ?? 0);
|
||
$uiPaid = (float)($invoice['paid_amount'] ?? 0);
|
||
$uiDisc = (float)($invoice['discount'] ?? 0);
|
||
$uiRef = (float)($invoice['refund_paid'] ?? 0);
|
||
$uiBal = max(0, $uiTotal - $uiPaid - $uiDisc - $uiRef);
|
||
?>
|
||
Invoice <?= esc($invoice['invoice_number']) ?> |
|
||
Balance: $<?= number_format($uiBal, 2) ?> |
|
||
Paid: $<?= number_format($uiPaid, 2) ?>
|
||
</option>
|
||
<?php endforeach; ?>
|
||
<?php else: ?>
|
||
<option value="">No invoices available</option>
|
||
<?php endif; ?>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Method -->
|
||
<div class="mb-3">
|
||
<label for="payment_method" class="form-label">Payment Method</label>
|
||
<select name="payment_method" id="payment_method" class="form-select" required>
|
||
<option value="">Select Method</option>
|
||
<option value="cash">Cash</option>
|
||
<option value="check">Check</option>
|
||
<option value="card">Debit/Credit Card</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Check number (only for checks) -->
|
||
<div class="mb-3" id="check-number-field" style="display:none;">
|
||
<label for="check_number" class="form-label">Check Number</label>
|
||
<input type="text" name="check_number" id="check_number" class="form-control" inputmode="numeric" pattern="[0-9\- ]*">
|
||
</div>
|
||
|
||
<!-- Receipt upload (check/card) -->
|
||
<div class="mb-3" id="payment-upload" style="display:none;">
|
||
<label for="payment_file" class="form-label">Upload File (optional)</label>
|
||
<input type="file" name="payment_file" id="payment_file" class="form-control" accept=".jpg,.jpeg,.png,.pdf">
|
||
<div class="form-text">For checks and cards, you can attach a receipt or image.</div>
|
||
</div>
|
||
|
||
<!-- Payment type -->
|
||
<div class="mb-3">
|
||
<label for="payment_type" class="form-label">Payment Type</label>
|
||
<select name="payment_type" id="payment_type" class="form-select" required>
|
||
<option value="full">Full Payment</option>
|
||
<option value="installment">Installment Payment</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Installment section (non-card only) -->
|
||
<div id="installment-section" style="display:none;">
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label for="installment_count" class="form-label">Number of Installments</label>
|
||
<select name="installment_count" id="installment_count" class="form-select">
|
||
<!-- populated by JS -->
|
||
</select>
|
||
<div class="form-text">Maximum installments available: <span id="max-installments">0</span></div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label for="installment_amount" class="form-label">Amount per Installment</label>
|
||
<input type="number" step="0.01" name="installment_amount" id="installment_amount" class="form-control" readonly>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="installment-seq-row" class="text-muted small" style="display:none;">
|
||
Next installment sequence: <strong id="installment-seq-label">1</strong>
|
||
<input type="hidden" id="installment_seq" name="installment_seq" value="1">
|
||
</div>
|
||
<div class="text-muted small mt-2">
|
||
Choose the number of installments (up to the maximum available). The first payment is due today.
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Amount -->
|
||
<div class="mb-3">
|
||
<label for="amount" class="form-label">Amount</label>
|
||
<input type="number" step="0.01" name="amount" id="amount" class="form-control" required>
|
||
<div id="amount-hint" class="form-text"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button type="submit" class="btn btn-success">Submit Payment</button>
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== Styled Confirmation Overlay (HTML) ===== -->
|
||
<div id="payConfirmOverlay" aria-modal="true" role="dialog">
|
||
<div id="payConfirmCard">
|
||
<div id="payConfirmHeader">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||
<path fill="currentColor" d="M12 2a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2m1 15h-2v-2h2zm0-4h-2V7h2z" />
|
||
</svg>
|
||
<h6 class="title">Confirm Payment</h6>
|
||
</div>
|
||
<div id="payConfirmBody">
|
||
<div class="amountRow">
|
||
<div class="label">Amount:</div>
|
||
<div class="pill" id="pcAmount">$0.00</div>
|
||
</div>
|
||
<ul id="payConfirmList">
|
||
<li id="pcInvoice">Invoice —</li>
|
||
<li id="pcMethod">Method —</li>
|
||
<li id="pcType">Type —</li>
|
||
<li id="pcInst" style="display:none;">Installments —</li>
|
||
<li id="pcCheck" style="display:none;">Check # —</li>
|
||
<li id="pcAttach" style="display:none;">Attachment —</li>
|
||
<li id="pcCurrent" style="display:none;">Current Balance —</li>
|
||
<li id="pcAfter" style="display:none;">Balance After —</li>
|
||
</ul>
|
||
<div id="pcOverpay">The amount exceeds the current balance. Please confirm this is intended.</div>
|
||
</div>
|
||
<div id="payConfirmFooter">
|
||
<button type="button" class="payBtn cancel" id="pcBack">Back & Edit</button>
|
||
<button type="button" class="payBtn confirm" id="pcConfirm">Yes, submit</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<?= $this->endSection() ?>
|
||
|
||
<?= $this->section('scripts') ?>
|
||
<script>
|
||
/* Minimal helper */
|
||
function _el(id) {
|
||
return document.getElementById(id);
|
||
}
|
||
|
||
/* Namespaced UI to avoid collisions */
|
||
const ManualPayUI = (() => {
|
||
// Cached elements (Add modal)
|
||
const $invoice = () => _el('invoice_id');
|
||
const $method = () => _el('payment_method');
|
||
const $type = () => _el('payment_type');
|
||
const $amount = () => _el('amount');
|
||
const $amountHint = () => _el('amount-hint');
|
||
const $checkRow = () => _el('check-number-field');
|
||
const $uploadRow = () => _el('payment-upload');
|
||
const $instSec = () => _el('installment-section');
|
||
const $instCount = () => _el('installment_count');
|
||
const $instAmt = () => _el('installment_amount');
|
||
const $instSeqRow = () => _el('installment-seq-row');
|
||
const $instSeqLab = () => _el('installment-seq-label');
|
||
const $instSeq = () => _el('installment_seq');
|
||
const $maxInstLab = () => _el('max-installments');
|
||
const $form = () => _el('addPaymentForm');
|
||
|
||
// Confirm overlay elements
|
||
const $overlay = () => _el('payConfirmOverlay');
|
||
const $pcAmount = () => _el('pcAmount');
|
||
const $pcInvoice = () => _el('pcInvoice');
|
||
const $pcMethod = () => _el('pcMethod');
|
||
const $pcType = () => _el('pcType');
|
||
const $pcInst = () => _el('pcInst');
|
||
const $pcCheck = () => _el('pcCheck');
|
||
const $pcAttach = () => _el('pcAttach');
|
||
const $pcCurrent = () => _el('pcCurrent');
|
||
const $pcAfter = () => _el('pcAfter');
|
||
const $pcOverpay = () => _el('pcOverpay');
|
||
const $pcBack = () => _el('pcBack');
|
||
const $pcConfirm = () => _el('pcConfirm');
|
||
|
||
let currentInvoiceBalance = 0;
|
||
let confirming = false;
|
||
|
||
// Utils
|
||
function currentOpt() {
|
||
const inv = $invoice();
|
||
return inv && inv.options[inv.selectedIndex] ? inv.options[inv.selectedIndex] : null;
|
||
}
|
||
|
||
function getBalance() {
|
||
const opt = currentOpt();
|
||
if (!opt) return 0;
|
||
// Compute from total - paid FIRST (more reliable if DB balance is stale)
|
||
const totRaw = opt.getAttribute('data-total');
|
||
const paidRaw = opt.getAttribute('data-paid');
|
||
const discRaw = opt.getAttribute('data-discount');
|
||
const refRaw = opt.getAttribute('data-refund');
|
||
const tot = (totRaw !== null && totRaw !== '') ? parseFloat(totRaw) : NaN;
|
||
const paid = (paidRaw !== null && paidRaw !== '') ? parseFloat(paidRaw) : NaN;
|
||
const disc = (discRaw !== null && discRaw !== '') ? parseFloat(discRaw) : 0;
|
||
const ref = (refRaw !== null && refRaw !== '') ? parseFloat(refRaw) : 0;
|
||
if (isFinite(tot) && isFinite(paid)) {
|
||
const computed = tot - paid - (isFinite(disc) ? disc : 0) - (isFinite(ref) ? ref : 0);
|
||
if (isFinite(computed)) return Math.max(0, computed);
|
||
}
|
||
// Fallback to provided balance
|
||
const raw = opt.getAttribute('data-balance');
|
||
const bal = (raw !== null && raw !== '') ? parseFloat(raw) : NaN;
|
||
return isFinite(bal) ? Math.max(0, bal) : 0;
|
||
}
|
||
|
||
function getTotal() {
|
||
const opt = currentOpt();
|
||
const v = opt ? parseFloat(opt.getAttribute('data-total') || '0') : 0;
|
||
return isFinite(v) ? v : 0;
|
||
}
|
||
|
||
function getNextInstallmentSeq() {
|
||
const opt = currentOpt();
|
||
const v = opt ? parseInt(opt.getAttribute('data-next-installment') || '1', 10) : 1;
|
||
return isFinite(v) ? v : 1;
|
||
}
|
||
|
||
function monthsUntil(endYmd) {
|
||
if (!endYmd) return 0;
|
||
const today = new Date();
|
||
const end = new Date(endYmd + 'T00:00:00');
|
||
if (end <= today) return 0;
|
||
let months = (end.getFullYear() - today.getFullYear()) * 12 + (end.getMonth() - today.getMonth());
|
||
if (end.getDate() > today.getDate()) months += 1;
|
||
return Math.max(0, months);
|
||
}
|
||
|
||
function fmtUSD(n) {
|
||
const v = Number(n);
|
||
if (!isFinite(v)) return '—';
|
||
try {
|
||
return new Intl.NumberFormat(undefined, {
|
||
style: 'currency',
|
||
currency: 'USD'
|
||
}).format(v);
|
||
} catch {
|
||
return '$' + v.toFixed(2);
|
||
}
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s).replace(/[&<>"']/g, m => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
} [m]));
|
||
}
|
||
|
||
function debounce(fn, wait = 200) {
|
||
let t;
|
||
return (...args) => {
|
||
clearTimeout(t);
|
||
t = setTimeout(() => fn(...args), wait);
|
||
};
|
||
}
|
||
|
||
// Core behaviors
|
||
function updateAmountHint() {
|
||
const bal = getBalance();
|
||
const tot = getTotal();
|
||
if (($method().value || '').toLowerCase() === 'card') {
|
||
$amountHint().textContent = 'Card payments must cover the remaining balance in full.';
|
||
} else {
|
||
$amountHint().textContent = `Remaining balance: ${fmtUSD(bal)} (invoice total: ${fmtUSD(tot)}).`;
|
||
}
|
||
}
|
||
|
||
function forceCardRules() {
|
||
// payment type -> full & disabled
|
||
$type().value = 'full';
|
||
$type().setAttribute('disabled', 'disabled');
|
||
// hide installments
|
||
$instSec().style.display = 'none';
|
||
$instSeqRow().style.display = 'none';
|
||
// amount = balance & readonly
|
||
const bal = getBalance();
|
||
$amount().value = bal.toFixed(2);
|
||
$amount().setAttribute('readonly', 'readonly');
|
||
if ($instAmt()) $instAmt().value = '';
|
||
$amountHint().textContent = 'Card payments must cover the remaining balance in full.';
|
||
}
|
||
|
||
function setupInstallments() {
|
||
const endDate = $invoice().getAttribute('data-end-date') || '';
|
||
const months = monthsUntil(endDate);
|
||
const bal = getBalance();
|
||
const maxInst = Math.max((months || 0), bal > 0 ? 2 : 0);
|
||
|
||
$maxInstLab().textContent = String(maxInst);
|
||
|
||
const sel = $instCount();
|
||
sel.innerHTML = '';
|
||
if (maxInst <= 0) {
|
||
const opt = document.createElement('option');
|
||
opt.value = '1';
|
||
opt.textContent = '1';
|
||
sel.appendChild(opt);
|
||
} else {
|
||
for (let i = 2; i <= Math.max(2, maxInst); i++) {
|
||
const opt = document.createElement('option');
|
||
opt.value = String(i);
|
||
opt.textContent = String(i);
|
||
sel.appendChild(opt);
|
||
}
|
||
sel.value = '2';
|
||
}
|
||
|
||
// sequence
|
||
const seq = getNextInstallmentSeq();
|
||
$instSeqRow().style.display = '';
|
||
$instSeqLab().textContent = String(seq);
|
||
$instSeq().value = String(seq);
|
||
}
|
||
|
||
function recalcInstallments() {
|
||
const bal = getBalance();
|
||
const n = parseInt($instCount().value || '2', 10);
|
||
if (!isFinite(bal) || bal <= 0 || !isFinite(n) || n <= 1) {
|
||
$instAmt().value = '';
|
||
return;
|
||
}
|
||
const per = Math.round((bal / n) * 100) / 100;
|
||
$instAmt().value = per.toFixed(2);
|
||
$amount().removeAttribute('readonly');
|
||
$amount().value = per.toFixed(2);
|
||
$amountHint().textContent = `Paying ${n} installments. First payment is ${fmtUSD(per)} (total balance ${fmtUSD(bal)}).`;
|
||
}
|
||
|
||
function applyMethodUI() {
|
||
const m = ($method().value || '').toLowerCase();
|
||
|
||
// Toggle check/file rows
|
||
if (m === 'check') {
|
||
$checkRow().style.display = '';
|
||
$uploadRow().style.display = '';
|
||
_el('check_number').required = true;
|
||
} else if (m === 'card') {
|
||
$checkRow().style.display = 'none';
|
||
$uploadRow().style.display = '';
|
||
const chk = _el('check_number');
|
||
if (chk) chk.required = false;
|
||
} else { // cash / empty
|
||
$checkRow().style.display = 'none';
|
||
$uploadRow().style.display = 'none';
|
||
const chk = _el('check_number');
|
||
if (chk) chk.required = false;
|
||
}
|
||
|
||
if (m === 'card') {
|
||
forceCardRules();
|
||
} else {
|
||
// enable full/installment select
|
||
$type().removeAttribute('disabled');
|
||
$type().value = 'full';
|
||
$instSec().style.display = 'none';
|
||
$instSeqRow().style.display = 'none';
|
||
$amount().removeAttribute('readonly');
|
||
$amount().value = getBalance().toFixed(2);
|
||
updateAmountHint();
|
||
}
|
||
}
|
||
|
||
function applyTypeUI() {
|
||
const m = ($method().value || '').toLowerCase();
|
||
const t = $type().value;
|
||
|
||
if (m === 'card') {
|
||
forceCardRules();
|
||
return;
|
||
}
|
||
if (t === 'installment') {
|
||
$instSec().style.display = '';
|
||
setupInstallments();
|
||
recalcInstallments();
|
||
} else {
|
||
$instSec().style.display = 'none';
|
||
$instSeqRow().style.display = 'none';
|
||
$amount().removeAttribute('readonly');
|
||
$amount().value = getBalance().toFixed(2);
|
||
updateAmountHint();
|
||
}
|
||
}
|
||
|
||
function onInvoiceChange() {
|
||
const seq = getNextInstallmentSeq();
|
||
$instSeqLab().textContent = String(seq);
|
||
$instSeq().value = String(seq);
|
||
|
||
if (($method().value || '').toLowerCase() === 'card') {
|
||
forceCardRules();
|
||
} else {
|
||
updateAmountHint();
|
||
if ($type().value === 'installment') {
|
||
setupInstallments();
|
||
recalcInstallments();
|
||
} else {
|
||
$amount().removeAttribute('readonly');
|
||
$amount().value = getBalance().toFixed(2);
|
||
updateAmountHint();
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== Confirmation overlay =====
|
||
function showOverlay() {
|
||
$overlay().style.display = 'block';
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
|
||
function hideOverlay() {
|
||
$overlay().style.display = 'none';
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
function bindConfirmation() {
|
||
const form = $form();
|
||
if (!form) return;
|
||
|
||
// click-off and ESC to close
|
||
$overlay().addEventListener('click', (e) => {
|
||
if (e.target === $overlay()) hideOverlay();
|
||
});
|
||
document.addEventListener('keydown', (e) => {
|
||
if ($overlay().style.display === 'block' && e.key === 'Escape') hideOverlay();
|
||
});
|
||
$pcBack().addEventListener('click', hideOverlay);
|
||
|
||
$pcConfirm().addEventListener('click', function() {
|
||
confirming = true;
|
||
$pcConfirm().disabled = true;
|
||
hideOverlay();
|
||
// slight delay for UX
|
||
setTimeout(() => form.submit(), 80);
|
||
});
|
||
|
||
form.addEventListener('submit', function(e) {
|
||
if (confirming) return; // already confirmed once
|
||
e.preventDefault();
|
||
e.stopImmediatePropagation();
|
||
|
||
const invSel = $invoice();
|
||
const invOpt = invSel && invSel.options && invSel.selectedIndex >= 0 ? invSel.options[invSel.selectedIndex] : null;
|
||
const method = ($method().value || '').trim();
|
||
const type = ($type().value || '').trim();
|
||
const amount = parseFloat(($amount().value || '0'));
|
||
|
||
if (!invOpt || !invSel.value) {
|
||
alert('Please select an invoice.');
|
||
return;
|
||
}
|
||
if (!method) {
|
||
alert('Please select a payment method.');
|
||
return;
|
||
}
|
||
if (!type) {
|
||
alert('Please select a payment type.');
|
||
return;
|
||
}
|
||
if (!(amount > 0)) {
|
||
alert('Please enter a valid amount > 0.');
|
||
return;
|
||
}
|
||
|
||
const invoiceText = invOpt.text || ('Invoice #' + invOpt.value);
|
||
// Derive balance: prefer total - paid - discount - refunds, else fallback to data-balance
|
||
let balance = NaN;
|
||
if (invOpt) {
|
||
const rt = invOpt.getAttribute('data-total');
|
||
const rp = invOpt.getAttribute('data-paid');
|
||
const rd = invOpt.getAttribute('data-discount');
|
||
const rr = invOpt.getAttribute('data-refund');
|
||
const t = (rt !== null && rt !== '') ? parseFloat(rt) : NaN;
|
||
const p = (rp !== null && rp !== '') ? parseFloat(rp) : NaN;
|
||
const d = (rd !== null && rd !== '') ? parseFloat(rd) : 0;
|
||
const r = (rr !== null && rr !== '') ? parseFloat(rr) : 0;
|
||
if (isFinite(t) && isFinite(p)) {
|
||
balance = t - p - (isFinite(d) ? d : 0) - (isFinite(r) ? r : 0);
|
||
}
|
||
if (!isFinite(balance)) {
|
||
const rb = invOpt.getAttribute('data-balance');
|
||
balance = (rb !== null && rb !== '') ? parseFloat(rb) : NaN;
|
||
}
|
||
}
|
||
const nextInst = invOpt.dataset?.nextInstallment || '';
|
||
const instCount = $instCount() ? ($instCount().value || '') : '';
|
||
const instAmt = $instAmt() ? ($instAmt().value || '') : '';
|
||
const checkNo = _el('check_number')?.value?.trim() || '';
|
||
const fileEl = _el('payment_file');
|
||
const file = (fileEl && fileEl.files && fileEl.files[0]) ? fileEl.files[0] : null;
|
||
|
||
const newBal = (isFinite(balance) && isFinite(amount)) ? (balance - amount) : NaN;
|
||
const overpay = (isFinite(newBal) && newBal < -0.005);
|
||
|
||
$pcAmount().textContent = fmtUSD(amount);
|
||
$pcInvoice().innerHTML = '<strong>Invoice:</strong> ' + esc(invoiceText);
|
||
$pcMethod().innerHTML = '<strong>Payment Method:</strong> ' + esc(method);
|
||
$pcType().innerHTML = '<strong>Payment Type:</strong> ' + (type === 'installment' ? 'Installment' : 'Full');
|
||
|
||
if (type === 'installment' && instCount) {
|
||
$pcInst().style.display = '';
|
||
$pcInst().innerHTML = '<strong>Installments:</strong> ' + esc(instCount) + (instAmt ? (' × ' + fmtUSD(instAmt)) : '') + (nextInst ? (' (next #' + esc(nextInst) + ')') : '');
|
||
} else {
|
||
$pcInst().style.display = 'none';
|
||
}
|
||
|
||
if (method.toLowerCase() === 'check' && checkNo) {
|
||
$pcCheck().style.display = '';
|
||
$pcCheck().innerHTML = '<strong>Check #:</strong> ' + esc(checkNo);
|
||
} else {
|
||
$pcCheck().style.display = 'none';
|
||
}
|
||
|
||
if (file) {
|
||
$pcAttach().style.display = '';
|
||
$pcAttach().innerHTML = '<strong>Attachment:</strong> ' + esc(file.name);
|
||
} else {
|
||
$pcAttach().style.display = 'none';
|
||
}
|
||
|
||
if (isFinite(balance)) {
|
||
$pcCurrent().style.display = '';
|
||
$pcCurrent().innerHTML = '<strong>Current Balance:</strong> ' + fmtUSD(balance);
|
||
} else {
|
||
$pcCurrent().style.display = 'none';
|
||
}
|
||
if (isFinite(newBal)) {
|
||
$pcAfter().style.display = '';
|
||
$pcAfter().innerHTML = '<strong>Balance After:</strong> ' + fmtUSD(newBal);
|
||
} else {
|
||
$pcAfter().style.display = 'none';
|
||
}
|
||
|
||
$pcOverpay().style.display = overpay ? 'block' : 'none';
|
||
|
||
showOverlay();
|
||
});
|
||
}
|
||
|
||
// Public: used by edit modals
|
||
function toggleEditFields(paymentId) {
|
||
const select = document.querySelector(`select[data-payment-id='${paymentId}']`);
|
||
const checkField = _el(`check-number-edit-${paymentId}`);
|
||
const fileField = _el(`payment-file-edit-${paymentId}`);
|
||
if (!select || !checkField || !fileField) return;
|
||
const input = checkField.querySelector('input');
|
||
|
||
const v = (select.value || '').toLowerCase();
|
||
if (v === 'check') {
|
||
checkField.style.display = 'block';
|
||
fileField.style.display = 'block';
|
||
if (input) input.required = true;
|
||
} else if (v === 'card') {
|
||
checkField.style.display = 'none';
|
||
fileField.style.display = 'block';
|
||
if (input) input.required = false;
|
||
} else {
|
||
checkField.style.display = 'none';
|
||
fileField.style.display = 'none';
|
||
if (input) input.required = false;
|
||
}
|
||
}
|
||
|
||
function initSearchSuggest() {
|
||
const input = _el('manualPaySearchInput');
|
||
const suggest = _el('manualPaySuggest');
|
||
if (!input || !suggest) return;
|
||
|
||
const suggestUrl = <?= json_encode(site_url('payment/manual_pay_suggest')) ?>;
|
||
let lastRequest = 0;
|
||
|
||
function hideSuggest() {
|
||
suggest.classList.add('d-none');
|
||
suggest.innerHTML = '';
|
||
}
|
||
|
||
function renderSuggest(items) {
|
||
suggest.innerHTML = '';
|
||
if (!items.length) {
|
||
hideSuggest();
|
||
return;
|
||
}
|
||
items.forEach(item => {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'list-group-item list-group-item-action';
|
||
|
||
const row = document.createElement('div');
|
||
row.className = 'd-flex justify-content-between gap-2';
|
||
|
||
const label = document.createElement('span');
|
||
label.textContent = item.label || '';
|
||
|
||
const sub = document.createElement('span');
|
||
sub.className = 'text-muted small';
|
||
sub.textContent = item.sub || '';
|
||
|
||
row.appendChild(label);
|
||
row.appendChild(sub);
|
||
btn.appendChild(row);
|
||
|
||
btn.addEventListener('click', () => {
|
||
input.value = item.value || item.label || '';
|
||
hideSuggest();
|
||
if (input.form) {
|
||
if (typeof input.form.requestSubmit === 'function') {
|
||
input.form.requestSubmit();
|
||
} else {
|
||
input.form.submit();
|
||
}
|
||
} else {
|
||
input.focus();
|
||
}
|
||
});
|
||
|
||
suggest.appendChild(btn);
|
||
});
|
||
suggest.classList.remove('d-none');
|
||
}
|
||
|
||
async function loadSuggest(q) {
|
||
const reqId = ++lastRequest;
|
||
try {
|
||
const resp = await fetch(suggestUrl + '?q=' + encodeURIComponent(q), {
|
||
headers: { 'Accept': 'application/json' }
|
||
});
|
||
if (!resp.ok) {
|
||
hideSuggest();
|
||
return;
|
||
}
|
||
const data = await resp.json();
|
||
if (reqId !== lastRequest) return;
|
||
const items = Array.isArray(data.items) ? data.items : [];
|
||
renderSuggest(items);
|
||
} catch (_) {
|
||
hideSuggest();
|
||
}
|
||
}
|
||
|
||
const debouncedLoad = debounce(loadSuggest, 200);
|
||
|
||
input.addEventListener('input', () => {
|
||
const q = input.value.trim();
|
||
if (q.length < 2) {
|
||
hideSuggest();
|
||
return;
|
||
}
|
||
debouncedLoad(q);
|
||
});
|
||
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') hideSuggest();
|
||
});
|
||
|
||
document.addEventListener('click', (e) => {
|
||
if (e.target === input || suggest.contains(e.target)) return;
|
||
hideSuggest();
|
||
});
|
||
}
|
||
|
||
// Init
|
||
function init() {
|
||
// Normalize initial state
|
||
const inv = $invoice();
|
||
if (inv && inv.options.length > 0) {
|
||
// Prefer the first invoice whose (total - paid) > 0
|
||
let chosen = inv.selectedIndex;
|
||
for (let i = 0; i < inv.options.length; i++) {
|
||
const o = inv.options[i];
|
||
const rt = o.getAttribute('data-total');
|
||
const rp = o.getAttribute('data-paid');
|
||
const rd = o.getAttribute('data-discount');
|
||
const rr = o.getAttribute('data-refund');
|
||
const t = (rt !== null && rt !== '') ? parseFloat(rt) : NaN;
|
||
const p = (rp !== null && rp !== '') ? parseFloat(rp) : NaN;
|
||
const d = (rd !== null && rd !== '') ? parseFloat(rd) : 0;
|
||
const r = (rr !== null && rr !== '') ? parseFloat(rr) : 0;
|
||
let b = (isFinite(t) && isFinite(p)) ? (t - p - (isFinite(d) ? d : 0) - (isFinite(r) ? r : 0)) : NaN;
|
||
if (!isFinite(b)) {
|
||
const rb = o.getAttribute('data-balance');
|
||
b = (rb !== null && rb !== '') ? parseFloat(rb) : NaN;
|
||
}
|
||
if (isFinite(b) && b > 0.005) { chosen = i; break; }
|
||
}
|
||
if (typeof chosen === 'number' && chosen >= 0) inv.selectedIndex = chosen;
|
||
|
||
// set balance cache and apply UI
|
||
currentInvoiceBalance = getBalance();
|
||
onInvoiceChange();
|
||
}
|
||
|
||
// Listeners (Add modal)
|
||
if (inv) inv.addEventListener('change', onInvoiceChange);
|
||
if ($method()) $method().addEventListener('change', () => {
|
||
applyMethodUI();
|
||
});
|
||
if ($type()) $type().addEventListener('change', () => {
|
||
applyTypeUI();
|
||
});
|
||
|
||
const instCountSel = $instCount();
|
||
if (instCountSel) instCountSel.addEventListener('change', recalcInstallments);
|
||
|
||
// Setup initial UI per default method/type
|
||
applyMethodUI();
|
||
applyTypeUI();
|
||
|
||
// Edit modals
|
||
document.querySelectorAll('.edit-payment-method').forEach(select => {
|
||
const id = select.getAttribute('data-payment-id');
|
||
toggleEditFields(id);
|
||
select.addEventListener('change', () => toggleEditFields(id));
|
||
});
|
||
|
||
// Confirm overlay
|
||
bindConfirmation();
|
||
|
||
// Search suggestions
|
||
initSearchSuggest();
|
||
}
|
||
|
||
return {
|
||
init,
|
||
toggleEditFields
|
||
};
|
||
})();
|
||
|
||
// Boot after DOM ready
|
||
document.addEventListener('DOMContentLoaded', ManualPayUI.init);
|
||
</script>
|
||
<?= $this->endSection() ?>
|