Files
alrahma_sunday_school/app/Views/payment/financial_report.php
T
2026-02-10 22:11:06 -05:00

593 lines
23 KiB
PHP

<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container-fluid mt-4">
<h2 class="text-center mt-4 mb-3">Detailed Financial Report</h2>
<style>
/* Keep table headers visible while scrolling the page */
#invoicesTable thead th {
position: sticky;
top: 0;
z-index: 2;
/* above table body cells */
background-color: #f8f9fa;
/* Bootstrap light header bg */
}
</style>
<!-- Top toolbar: put all filters on one line -->
<form id="financialFilterForm" method="get" class="d-flex align-items-end gap-3 flex-nowrap justify-content-center mb-3 w-100 overflow-auto" style="white-space: nowrap;">
<div class="d-flex align-items-center gap-2 flex-nowrap">
<label class="form-label mb-0 me-2">School year</label>
<select name="school_year" class="form-select form-select-sm rounded-pill px-3" style="min-width: 180px;">
<?php foreach (($schoolYears ?? []) as $sy): ?>
<option value="<?= esc($sy) ?>" <?= (string)($selectedYear ?? '') === (string)$sy ? 'selected' : '' ?>><?= esc($sy) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="d-flex align-items-end gap-2 flex-nowrap">
<div class="d-flex flex-column">
<label class="form-label">Date From:</label>
<input type="date" name="date_from" class="form-control" value="<?= esc($_GET['date_from'] ?? ($dateFrom ?? '')) ?>">
</div>
<div class="d-flex flex-column">
<label class="form-label">Date To:</label>
<input type="date" name="date_to" class="form-control" value="<?= esc($_GET['date_to'] ?? ($dateTo ?? '')) ?>">
</div>
<button type="submit" class="btn btn-secondary ms-2">Filter</button>
</div>
</form>
<!-- Loading & error placeholders -->
<div id="reportLoading" class="text-center my-4 d-none">
<div class="spinner-border text-primary" role="status"></div>
<div class="mt-2">Loading report…</div>
<div class="text-muted small" id="reportLastRef"></div>
</div>
<div id="reportError" class="alert alert-danger d-none">
Failed to load report. Please try again.
<a href="#" id="reportRetry" class="alert-link ms-2">Retry</a>
</div>
<div class="d-flex gap-2 mb-3 justify-content-end flex-nowrap w-100 overflow-auto" style="white-space: nowrap;">
<a id="downloadCsvLink" href="<?= base_url('payment/download_csv') ?>" class="btn btn-success">Download CSV</a>
<button class="btn btn-primary" onclick="window.print()">Print Report</button>
<a id="summaryReportLink" href="<?= base_url('financial-report/financialReportSummary') ?>" class="btn btn-info">Display Summary Report</a>
</div>
<div class="table-responsive">
<table id="invoicesTable" class="table table-bordered table-striped align-middle w-100">
<thead>
<tr>
<th>Invoice #</th>
<th>Parent</th>
<th>School Year</th>
<th>Total</th>
<th>Paid</th>
<th>Cash</th>
<th>Credit</th>
<th>Check</th>
<th>Balance</th>
<th>Refund</th>
<th>Discount</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<?php if (!empty($invoices ?? [])): ?>
<?php
$breakdowns = $paymentBreakdown ?? [];
foreach ($invoices as $inv):
$payMatches = array_filter($payments ?? [], fn($p) => isset($p['invoice_id']) && (int)$p['invoice_id'] === (int)$inv['id']);
$pay = $payMatches ? reset($payMatches) : null;
$paid = (float)($pay['paid_amount'] ?? 0);
$bd = $breakdowns[(int)$inv['id']] ?? [];
$cash = (float)($bd['cash'] ?? 0);
$cred = (float)($bd['credit'] ?? 0);
$chk = (float)($bd['check'] ?? 0);
$refMatches = array_filter($refunds ?? [], fn($r) => isset($r['invoice_id']) && (int)$r['invoice_id'] === (int)$inv['id']);
$ref = $refMatches ? reset($refMatches) : null;
$refunded = (float)($ref['total_refunded'] ?? 0);
$discMatches = array_filter($discounts ?? [], fn($d) => isset($d['invoice_id']) && (int)$d['invoice_id'] === (int)$inv['id']);
$disc = $discMatches ? reset($discMatches) : null;
$discounted = (float)($disc['discount_amount'] ?? 0);
$totalAmount = (float)($inv['total_amount'] ?? 0);
// Recompute balance for display: total - discount - paid - refunded
$balanceCalc = (float)$totalAmount - (float)$discounted - (float)$paid - (float)$refunded;
if ($balanceCalc < 0) $balanceCalc = 0.0;
$balance = $balanceCalc;
$status = ($balanceCalc === 0.0) ? 'Paid' : 'Unpaid';
$statusClass = ($status === 'Paid') ? 'bg-success' : 'bg-danger';
?>
<tr>
<td><?= esc($inv['invoice_number']) ?></td>
<td>
<?php $pid = (int)($inv['parent_id'] ?? 0); ?>
<?php if ($pid): ?>
<a href="<?= site_url('family') ?>?guardian_id=<?= (int)$pid ?>" class="text-decoration-none" data-family-guardian-id="<?= $pid ?>">
<?= esc($inv['parent_name']) ?>
</a>
<?php else: ?>
<?= esc($inv['parent_name']) ?>
<?php endif; ?>
</td>
<td><?= esc($inv['school_year']) ?></td>
<td data-order="<?= $totalAmount ?>">$<?= number_format($totalAmount, 2) ?></td>
<td data-order="<?= $paid ?>">$<?= number_format($paid, 2) ?></td>
<td data-order="<?= $cash ?>">$<?= number_format($cash, 2) ?></td>
<td data-order="<?= $cred ?>">$<?= number_format($cred, 2) ?></td>
<td data-order="<?= $chk ?>">$<?= number_format($chk, 2) ?></td>
<td data-order="<?= $balance ?>">$<?= number_format($balance, 2) ?></td>
<td data-order="<?= $refunded ?>">$<?= number_format($refunded, 2) ?></td>
<td data-order="<?= $discounted ?>">$<?= number_format($discounted, 2) ?></td>
<td data-order="<?= esc($status) ?>"><span class="badge <?= esc($statusClass) ?>"><?= esc($status) ?></span></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<h3 class="mt-5">Payments by Method (Filtered)</h3>
<div class="table-responsive">
<table id="summaryTable" class="table table-bordered align-middle w-100">
<thead>
<tr>
<th>Cash</th>
<th>Credit</th>
<th>Check</th>
<th>Grand Total (All Payments)</th>
</tr>
</thead>
<tbody>
<?php
$pt = $paymentTotals ?? [];
$totalCash = (float)($pt['total_cash'] ?? 0);
$totalCredit = (float)($pt['total_credit'] ?? 0);
$totalCheck = (float)($pt['total_check'] ?? 0);
$grandTotal = (float)($pt['total_all'] ?? ($totalCash + $totalCredit + $totalCheck));
?>
<tr>
<td class="sum-cash" data-order="<?= $totalCash ?>">$<?= number_format($totalCash, 2) ?></td>
<td class="sum-credit" data-order="<?= $totalCredit ?>">$<?= number_format($totalCredit, 2) ?></td>
<td class="sum-check" data-order="<?= $totalCheck ?>">$<?= number_format($totalCheck, 2) ?></td>
<td class="sum-all fw-bold" data-order="<?= $grandTotal ?>">$<?= number_format($grandTotal, 2) ?></td>
</tr>
</tbody>
</table>
</div>
<h3 class="mt-5">Expenses Summary</h3>
<div class="table-responsive">
<table id="expensesTable" class="table table-bordered align-middle w-100">
<thead>
<tr>
<th>Category</th>
<th>Total Amount</th>
</tr>
</thead>
<tbody>
<?php if (!empty($expenses ?? [])): foreach ($expenses as $exp): $expAmt = (float)($exp['total_amount'] ?? 0); ?>
<tr>
<td><?= esc($exp['category']) ?></td>
<td data-order="<?= $expAmt ?>">$<?= number_format($expAmt, 2) ?></td>
</tr>
<?php endforeach;
endif; ?>
</tbody>
</table>
</div>
<h3 class="mt-5">Reimbursements Summary</h3>
<div class="table-responsive">
<table id="reimbTable" class="table table-bordered align-middle w-100">
<thead>
<tr>
<th>Status</th>
<th>Total Amount</th>
</tr>
</thead>
<tbody>
<?php if (!empty($reimbursements ?? [])): foreach ($reimbursements as $r): $rAmt = (float)($r['total_amount'] ?? 0); ?>
<tr>
<td><?= esc($r['status']) ?></td>
<td data-order="<?= $rAmt ?>">$<?= number_format($rAmt, 2) ?></td>
</tr>
<?php endforeach;
endif; ?>
</tbody>
</table>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Fetch data from API and hydrate tables
function fmt(n) {
return '$' + Number(n || 0).toFixed(2);
}
function formatDateTime(value) {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.valueOf())) {
return '';
}
const pad = (num) => String(num).padStart(2, '0');
const datePart = `${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${date.getFullYear()}`;
const timePart = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
return `${datePart} ${timePart}`;
}
function qs(sel) {
return document.querySelector(sel);
}
function qsa(sel) {
return Array.from(document.querySelectorAll(sel));
}
// --- DataTables FixedHeader support (lazy-load assets if missing) ---
function loadScript(src, id) {
return new Promise((resolve, reject) => {
if (id && document.getElementById(id)) return resolve();
const s = document.createElement('script');
if (id) s.id = id;
s.src = src;
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
}
function loadCss(href, id) {
return new Promise((resolve) => {
if (id && document.getElementById(id)) return resolve();
const l = document.createElement('link');
if (id) l.id = id;
l.rel = 'stylesheet';
l.href = href;
l.onload = resolve;
document.head.appendChild(l);
});
}
function ensureDtFixedHeaderAssets() {
const tasks = [];
const needJQ = !window.jQuery;
if (needJQ) tasks.push(loadScript('https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js', 'jq-core'));
// Core DataTables + Bootstrap 5 integration
if (!window.jQuery || !jQuery.fn || !jQuery.fn.DataTable) {
tasks.push(loadScript('https://cdn.jsdelivr.net/npm/datatables.net@1.13.10/js/jquery.dataTables.min.js', 'dt-core'));
tasks.push(loadScript('https://cdn.jsdelivr.net/npm/datatables.net-bs5@1.13.10/js/dataTables.bootstrap5.min.js', 'dt-bs5'));
// base CSS if not already present (id optional)
tasks.push(loadCss('https://cdn.jsdelivr.net/npm/datatables.net-bs5@1.13.10/css/dataTables.bootstrap5.min.css', 'dt-bs5-css'));
}
// FixedHeader extension (JS + Bootstrap 5 CSS)
// Only load if plugin missing
const needFH = !(window.jQuery && jQuery.fn && jQuery.fn.dataTable && jQuery.fn.dataTable.FixedHeader);
if (needFH) {
tasks.push(loadScript('https://cdn.jsdelivr.net/npm/datatables.net-fixedheader@3.4.0/js/dataTables.fixedHeader.min.js', 'dt-fixedheader'));
tasks.push(loadCss('https://cdn.jsdelivr.net/npm/datatables.net-fixedheader-bs5@3.4.0/css/fixedHeader.bootstrap5.min.css', 'dt-fixedheader-css'));
}
return Promise.all(tasks).catch(() => {});
}
// Compute offset if a fixed/sticky top navbar exists to prevent overlap
function getFixedHeaderOffset() {
let total = 0;
const stack = [];
const header = document.querySelector('header.navbar.sticky-top, header.navbar.fixed-top');
if (header) stack.push(header);
const mgmt = document.getElementById('navbarManagement');
if (mgmt && (mgmt.classList.contains('sticky-top') || mgmt.classList.contains('fixed-top'))) stack.push(mgmt);
// Include any other sticky/fixed navbars at top (avoid duplicates)
document.querySelectorAll('.navbar.sticky-top, .navbar.fixed-top').forEach(el => { if (!stack.includes(el)) stack.push(el); });
stack.forEach(el => { const h = el.offsetHeight || el.getBoundingClientRect().height || 0; total += Math.max(0, Math.round(h)); });
return total;
}
function buildParams() {
const p = new URLSearchParams();
const f = document.getElementById('financialFilterForm');
if (f) {
new FormData(f).forEach((v, k) => p.set(k, v));
}
return p.toString();
}
function updateActionLinks() {
const params = buildParams();
const q = params ? ('?' + params) : '';
const csvBase = '<?= base_url('payment/download_csv') ?>';
const sumBase = '<?= base_url('financial-report/financialReportSummary') ?>';
const dl = document.getElementById('downloadCsvLink');
const sr = document.getElementById('summaryReportLink');
if (dl) dl.href = csvBase + q;
if (sr) sr.href = sumBase + q;
}
let __reqSeq = 0;
function hydrate() {
const mySeq = ++__reqSeq;
const baseUrl = '<?= site_url('payment/financial_report') ?>';
const params = buildParams();
const url = baseUrl + (params ? ('?' + params + '&format=json') : '?format=json');
// Show loading
const $loading = document.getElementById('reportLoading');
const $error = document.getElementById('reportError');
$error.classList.add('d-none');
$loading.classList.remove('d-none');
fetch(url, {
headers: {
'Accept': 'application/json'
}
})
.then(r => r.json())
.then(data => {
if (mySeq !== __reqSeq) return; // stale response; ignore
if (!data || data.ok !== true) throw new Error('bad response');
// Destroy DataTables BEFORE changing DOM to avoid conflicts
if (window.jQuery && jQuery.fn && jQuery.fn.DataTable) {
['#invoicesTable', '#summaryTable', '#expensesTable', '#reimbTable'].forEach(sel => {
if (jQuery.fn.DataTable.isDataTable(sel)) {
jQuery(sel).DataTable().clear().destroy();
}
});
}
const tbody = qs('#invoicesTable tbody');
tbody.innerHTML = '';
const paymentsMap = {};
(data.payments || []).forEach(p => {
paymentsMap[p.invoice_id] = Number(p.paid_amount || 0);
});
const refundsMap = {};
(data.refunds || []).forEach(r => {
refundsMap[r.invoice_id] = Number(r.total_refunded || 0);
});
const discountsMap = {};
(data.discounts || []).forEach(d => {
discountsMap[d.invoice_id] = Number(d.discount_amount || 0);
});
const breakdown = data.paymentBreakdown || {};
(data.invoices || []).forEach(inv => {
const paid = paymentsMap[inv.id] || 0;
const bd = breakdown[inv.id] || {};
const cash = Number(bd.cash || 0),
credit = Number(bd.credit || 0),
check = Number(bd.check || 0);
const refunded = refundsMap[inv.id] || 0;
const discounted = discountsMap[inv.id] || 0;
const total = Number(inv.total_amount || 0);
let balance = total - paid - discounted - refunded;
if (!Number.isFinite(balance) || balance < 0) balance = 0;
const status = balance === 0 ? 'Paid' : 'Unpaid';
const statusClass = balance === 0 ? 'bg-success' : 'bg-danger';
const tr = document.createElement('tr');
const parentHtml = (inv.parent_id && Number(inv.parent_id) > 0) ?
`<a href="<?= site_url('family') ?>?guardian_id=${encodeURIComponent(inv.parent_id)}" class="text-decoration-none" data-family-guardian-id="${inv.parent_id}">${inv.parent_name||''}</a>` :
(inv.parent_name || '');
tr.innerHTML = `
<td>${inv.invoice_number||''}</td>
<td>${parentHtml}</td>
<td>${inv.school_year||''}</td>
<td data-order="${total}">${fmt(total)}</td>
<td data-order="${paid}">${fmt(paid)}</td>
<td data-order="${cash}">${fmt(cash)}</td>
<td data-order="${credit}">${fmt(credit)}</td>
<td data-order="${check}">${fmt(check)}</td>
<td data-order="${balance}">${fmt(balance)}</td>
<td data-order="${refunded}">${fmt(refunded)}</td>
<td data-order="${discounted}">${fmt(discounted)}</td>
<td data-order="${status}"><span class="badge ${statusClass}">${status}</span></td>`;
tbody.appendChild(tr);
});
// Summary totals: rebuild row to be safe after DT destroy
const pt = data.paymentTotals || {};
const tCash = Number(pt.total_cash || 0);
const tCred = Number(pt.total_credit || 0);
const tChk = Number(pt.total_check || 0);
const tAll = Number(pt.total_all || (tCash + tCred + tChk));
const sBody = qs('#summaryTable tbody');
if (sBody) {
sBody.innerHTML = `<tr>
<td class="sum-cash" data-order="${tCash}">${fmt(tCash)}</td>
<td class="sum-credit" data-order="${tCred}">${fmt(tCred)}</td>
<td class="sum-check" data-order="${tChk}">${fmt(tChk)}</td>
<td class="sum-all fw-bold" data-order="${tAll}">${fmt(tAll)}</td>
</tr>`;
}
// Expenses
const expTbody = qs('#expensesTable tbody');
expTbody.innerHTML = '';
(data.expenses || []).forEach(exp => {
const amt = Number(exp.total_amount || 0);
const tr = document.createElement('tr');
tr.innerHTML = `<td>${exp.category||''}</td><td data-order="${amt}">${fmt(amt)}</td>`;
expTbody.appendChild(tr);
});
// Reimbursements
const rTbody = qs('#reimbTable tbody');
rTbody.innerHTML = '';
(data.reimbursements || []).forEach(r => {
const amt = Number(r.total_amount || 0);
const tr = document.createElement('tr');
tr.innerHTML = `<td>${r.status||''}</td><td data-order="${amt}">${fmt(amt)}</td>`;
rTbody.appendChild(tr);
});
// Re-init with options (FixedHeader enabled)
initDT('#invoicesTable', {
order: [
[2, 'asc'],
[0, 'asc']
],
columnDefs: [{
targets: 2,
visible: false
} // hide School Year column
],
fixedHeader: {
header: true,
headerOffset: getFixedHeaderOffset()
}
});
initDT('#summaryTable', {
paging: false,
searching: false,
info: false,
ordering: true
});
initDT('#expensesTable', {
order: [
[1, 'desc']
]
});
initDT('#reimbTable', {
order: [
[1, 'desc']
]
});
// Update last refreshed timestamp
const lastRef = document.getElementById('reportLastRef');
if (lastRef) lastRef.textContent = 'Last refreshed: ' + formatDateTime(new Date());
$loading.classList.add('d-none');
$error.classList.add('d-none');
// Keep action links synced with current filters
updateActionLinks();
})
.catch(() => {
if (mySeq !== __reqSeq) return; // stale error; ignore
document.getElementById('reportLoading').classList.add('d-none');
document.getElementById('reportError').classList.remove('d-none');
});
}
// Helper to (re)initialize safely
function initDT(selector, options) {
if (!window.jQuery || !jQuery.fn || !jQuery.fn.DataTable) return;
if (jQuery.fn.DataTable.isDataTable(selector)) {
jQuery(selector).DataTable().clear().destroy();
}
return jQuery(selector).DataTable(Object.assign({
// Defaults that prevent “stuck at 10/page 1”
stateSave: false, // don't restore old 10/page
paging: true,
ordering: true,
info: true,
searching: true,
pageLength: 100,
lengthMenu: [
[10, 25, 50, 100, -1],
[10, 25, 50, 100, 'All']
],
pagingType: 'full_numbers',
dom: 'lfrtip'
}, options || {}));
}
function initializeTablesAndHandlers() {
// Be noisy if DT errors happen
if (window.jQuery && jQuery.fn && jQuery.fn.dataTable) {
jQuery.fn.dataTable.ext.errMode = 'console';
}
// Main invoices table with FixedHeader
initDT('#invoicesTable', {
order: [
[2, 'asc'],
[0, 'asc']
], // School Year (hidden), then Invoice #
columnDefs: [{
targets: 2,
visible: false
} // hide School Year column
],
fixedHeader: {
header: true,
headerOffset: getFixedHeaderOffset()
}
});
// Payments summary (1 row; keep ordering for convenience)
initDT('#summaryTable', {
paging: false,
searching: false,
info: false,
ordering: true
});
// Expenses & Reimbursements
initDT('#expensesTable', {
order: [
[1, 'desc']
]
});
initDT('#reimbTable', {
order: [
[1, 'desc']
]
});
// Initial hydrate and on form submit
hydrate();
updateActionLinks();
const filterForm = document.getElementById('financialFilterForm');
if (filterForm) {
filterForm.addEventListener('submit', function(e) {
e.preventDefault();
hydrate();
updateActionLinks();
});
filterForm.addEventListener('change', function() {
updateActionLinks();
});
}
// Auto hydrate on year change
const yearSel = document.querySelector('#financialFilterForm select[name="school_year"]');
if (yearSel) yearSel.addEventListener('change', function() {
hydrate();
updateActionLinks();
});
// Retry handler on error banner
const retry = document.getElementById('reportRetry');
if (retry) retry.addEventListener('click', function(ev) {
ev.preventDefault();
hydrate();
});
}
// Ensure DT core + FixedHeader then init
ensureDtFixedHeaderAssets().then(initializeTablesAndHandlers, initializeTablesAndHandlers);
// OPTIONAL: one-time clear of any old saved state from previous versions
// Uncomment once, reload, then comment it back.
// if (window.localStorage) {
// Object.keys(localStorage)
// .filter(k => k.startsWith('DataTables_'))
// .forEach(k => localStorage.removeItem(k));
// }
});
</script>
<?= $this->endSection() ?>