Files
2026-05-16 13:44:12 -04:00

1148 lines
41 KiB
PHP
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?= $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 => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
} [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() ?>