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

346 lines
15 KiB
PHP

<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container-fluid">
<div class="wrapper">
<div class="content"></div>
<h2 class="text-center mt-4 mb-3">Invoice Management</h2>
<!-- Controls -->
<div class="row g-2 align-items-center justify-content-center mb-3">
<div class="col-auto">
<label for="schoolYearSelect" class="col-form-label">School year</label>
</div>
<div class="col-auto">
<select id="schoolYearSelect" class="form-select form-select-sm" style="min-width: 180px;"></select>
</div>
</div>
<!-- Table for displaying invoices (client-hydrated) -->
<table id="invoicesTable" class="table table-striped table-hover table-bordered w-100">
<thead class="thead-dark">
<tr>
<th scope="col">Parent Name</th>
<th scope="col">Enrolled Students</th>
<th scope="col">Invoice Generation</th>
<th scope="col">Invoice Amount</th>
<th scope="col">Refund Amount</th>
<th scope="col">Date</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
// Bootstrap 5 Toast utility
(function(){
function ensureContainer() {
let c = document.getElementById('toast-container');
if (!c) {
c = document.createElement('div');
c.id = 'toast-container';
c.className = 'toast-container position-fixed top-0 end-0 p-3';
c.style.zIndex = '1080';
document.body.appendChild(c);
}
return c;
}
window.showToast = function(message, ok = true, opts = {}) {
const c = ensureContainer();
const toast = document.createElement('div');
toast.className = 'toast align-items-center text-bg-' + (ok ? 'success' : 'danger') + ' border-0';
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'assertive');
toast.setAttribute('aria-atomic', 'true');
toast.innerHTML = '<div class="d-flex">'
+ '<div class="toast-body">' + (message || (ok ? 'Success' : 'Something went wrong')) + '</div>'
+ '<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>'
+ '</div>';
c.appendChild(toast);
try {
const t = new bootstrap.Toast(toast, { delay: opts.delay ?? 3000, autohide: true });
toast.addEventListener('hidden.bs.toast', () => toast.remove());
t.show();
} catch (e) {
// Fallback if bootstrap is not available
setTimeout(() => toast.remove(), 3000);
}
};
})();
// CSRF bootstrap
const CSRF_TOKEN_NAME = '<?= csrf_token() ?>';
let CSRF_HASH = '<?= csrf_hash() ?>';
const CSRF_COOKIE_NAME = <?= json_encode(config('Security')->csrfCookieName ?? (config('Security')->cookieName ?? 'csrf_cookie_name')) ?>;
const getCookie = (n) => document.cookie.split(';').map(v=>v.trim()).find(v=>v.startsWith(n+'='))?.split('=')[1] || '';
function updateCsrfFromJson(json) {
const name = (json && (json.csrfTokenName || json.tokenName)) || CSRF_TOKEN_NAME;
const hash = (json && (json.csrfHash || json.hash || json[name])) || decodeURIComponent(getCookie(CSRF_COOKIE_NAME));
if (name && hash) CSRF_HASH = hash;
}
const API_URL = <?= json_encode(site_url('api/invoices/management')) ?>;
const GENERATE_URL = <?= json_encode(site_url('api/invoices/generate')) ?>;
const fmtMoney = (v) => '$' + Number(v || 0).toFixed(2);
const esc = (s) => $('<div>').text(String(s ?? '')).html();
const pad2 = (num) => String(num).padStart(2, '0');
const formatDateTime = (ts) => {
const date = new Date(ts);
if (Number.isNaN(date.valueOf())) return '';
const datePart = `${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}-${date.getFullYear()}`;
const timePart = `${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
return `${datePart} ${timePart}`;
};
// Helper that opens family card or redirects if modal not available
window.__openFamilyByGuardian = function(gid) {
try {
if (window.openFamilyCard) return window.openFamilyCard({ guardian_id: gid });
} catch (_) {}
try {
window.location.href = '<?= site_url('family') ?>' + '?guardian_id=' + encodeURIComponent(gid);
} catch (_) {}
return false;
};
function renderStudents(list) {
if (!list || !list.length) return '<em>No students enrolled.</em>';
return list.map(k => `${esc(k.name)} (Grade: ${esc(k.grade)})`).join('<br>');
}
function renderParentCell(r) {
const pid = parseInt(r.parent_id || 0, 10);
const name = esc(r.parent_name || '');
if (pid > 0) {
const href = '<?= site_url('family') ?>' + '?guardian_id=' + encodeURIComponent(pid);
return `<a href="${href}" class="text-decoration-none parent-card-link" data-family-guardian-id="${pid}" onclick="return window.__openFamilyByGuardian(${pid});">${name}</a>`;
}
return name;
}
async function generateInvoice(parentId) {
const body = new URLSearchParams();
body.append('parent_id', parentId);
body.append(CSRF_TOKEN_NAME, CSRF_HASH);
const resp = await fetch(GENERATE_URL, { method: 'POST', headers: { 'Accept': 'application/json,text/html', 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin', body });
let json = null; let text = '';
const ct = resp.headers.get('content-type') || '';
try {
if (ct.includes('application/json')) json = await resp.json();
else text = await resp.text();
} catch {}
if (json) updateCsrfFromJson(json);
else updateCsrfFromJson({}); // fallback: refresh from cookie
if (!resp.ok) throw new Error((json && json.message) || text || 'Failed to generate invoice');
// Treat non-JSON 2xx (like redirect HTML) as success; refresh list next.
if (json && json.ok === false) throw new Error(json.message || 'Failed to generate invoice');
return json || { ok: true };
}
async function loadInvoices(year) {
const url = year ? `${API_URL}?schoolYear=${encodeURIComponent(year)}` : API_URL;
const res = await fetch(url, { credentials: 'same-origin' });
const data = await res.json();
if (!res.ok || data?.ok === false) throw new Error('Failed to load invoices');
return data;
}
document.addEventListener('DOMContentLoaded', async () => {
const $tbl = $('#invoicesTable');
let dt = null;
const $year = document.getElementById('schoolYearSelect');
// Global fallback handler for inline onclick
window.__genInvoice = async function(btn){
try {
btn.disabled = true;
await generateInvoice(btn.getAttribute('data-parent-id'));
const resp = await loadInvoices();
// Populate year select
const years = Array.isArray(resp.schoolYears) ? resp.schoolYears : [];
const selected = resp.schoolYear || (years[0] || '');
$year.innerHTML = years.map(y => `<option value="${esc(y)}" ${y===selected?'selected':''}>${esc(y)}</option>`).join('');
const data = (resp.invoices || []).map(r => {
const ts = Date.parse(r.invoice_date || new Date().toISOString());
const genBtn = `<button type=\"button\" class=\"btn btn-primary btn-sm gen-invoice\" data-parent-id=\"${esc(r.parent_id)}\" onclick=\"return window.__genInvoice && window.__genInvoice(this)\">Generate Invoice</button>`;
const pdf = r.invoice_id ? `<a href=\"<?= base_url('invoice/pdf') ?>/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF</a>` : '<span class=\"text-muted\">No invoice yet</span>';
return [
renderParentCell(r),
renderStudents(r.enrolledKids || []),
genBtn,
fmtMoney(r.invoice_amount),
fmtMoney(r.refund_amount),
`<span data-order=\"${ts}\">${formatDateTime(ts)}</span>`,
pdf,
];
});
if ($.fn.DataTable.isDataTable($tbl)) {
const dti = $tbl.DataTable();
dti.clear();
dti.rows.add(data).draw(false);
}
} catch (err) {
showToast(err?.message || 'Failed to generate invoice', false);
} finally {
btn.disabled = false;
}
return false;
}
try {
const resp = await loadInvoices();
// Populate year select on first load
const years = Array.isArray(resp.schoolYears) ? resp.schoolYears : [];
const selected = resp.schoolYear || (years[0] || '');
$year.innerHTML = years.map(y => `<option value="${esc(y)}" ${y===selected?'selected':''}>${esc(y)}</option>`).join('');
const data = (resp.invoices || []).map(r => {
const ts = Date.parse(r.invoice_date || new Date().toISOString());
const genBtn = `<button type=\"button\" class=\"btn btn-primary btn-sm gen-invoice\" data-parent-id=\"${esc(r.parent_id)}\" onclick=\"return window.__genInvoice && window.__genInvoice(this)\">Generate Invoice</button>`;
const pdf = r.invoice_id ? `<a href=\"<?= base_url('invoice/pdf') ?>/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF</a>` : '<span class=\"text-muted\">No invoice yet</span>';
return [
renderParentCell(r),
renderStudents(r.enrolledKids || []),
genBtn,
fmtMoney(r.invoice_amount),
fmtMoney(r.refund_amount),
`<span data-order=\"${ts}\">${formatDateTime(ts)}</span>`,
pdf,
];
});
dt = $tbl.DataTable({
data,
order: [[5, 'desc']],
pageLength: 100,
lengthMenu: [10, 25, 50, 100],
responsive: true,
columnDefs: [
{ targets: [1, 2, 6], orderable: false },
],
language: { search: 'Filter:', searchPlaceholder: 'Type to filter invoices…' }
});
} catch (e) {
console.error(e);
$tbl.find('tbody').html('<tr><td colspan="7" class="text-center">Failed to load invoices.</td></tr>');
showToast('Failed to load invoices', false);
}
// Delegate generate invoice
// Native delegation
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.gen-invoice');
if (!btn) return;
btn.disabled = true;
try {
await generateInvoice(btn.getAttribute('data-parent-id'));
// Refresh table minimally: reload data
const resp = await loadInvoices($year.value);
const data = (resp.invoices || []).map(r => {
const ts = Date.parse(r.invoice_date || new Date().toISOString());
const genBtn = `<button type=\"button\" class=\"btn btn-primary btn-sm gen-invoice\" data-parent-id=\"${esc(r.parent_id)}\" onclick=\"return window.__genInvoice && window.__genInvoice(this)\">Generate Invoice</button>`;
const pdf = r.invoice_id ? `<a href=\"<?= base_url('invoice/pdf') ?>/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF</a>` : '<span class=\"text-muted\">No invoice yet</span>';
return [
renderParentCell(r),
renderStudents(r.enrolledKids || []),
genBtn,
fmtMoney(r.invoice_amount),
fmtMoney(r.refund_amount),
`<span data-order=\"${ts}\">${formatDateTime(ts)}</span>`,
pdf,
];
});
if ($.fn.DataTable.isDataTable($tbl)) {
const dti = $tbl.DataTable();
dti.clear();
dti.rows.add(data).draw(false);
}
showToast('Invoice generated');
} catch (err) {
showToast(err?.message || 'Failed to generate invoice', false);
} finally {
btn.disabled = false;
}
});
// jQuery delegated fallback
$(document).on('click', '.gen-invoice', async function (e) {
try {
this.disabled = true;
await generateInvoice(this.getAttribute('data-parent-id'));
const resp = await loadInvoices($year.value);
const data = (resp.invoices || []).map(r => {
const ts = Date.parse(r.invoice_date || new Date().toISOString());
const genBtn = `<button type=\"button\" class=\"btn btn-primary btn-sm gen-invoice\" data-parent-id=\"${esc(r.parent_id)}\" onclick=\"return window.__genInvoice && window.__genInvoice(this)\">Generate Invoice</button>`;
const pdf = r.invoice_id ? `<a href=\"<?= base_url('invoice/pdf') ?>/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF</a>` : '<span class=\"text-muted\">No invoice yet</span>';
return [
esc(r.parent_name || ''),
renderStudents(r.enrolledKids || []),
genBtn,
fmtMoney(r.invoice_amount),
fmtMoney(r.refund_amount),
`<span data-order=\"${ts}\">${formatDateTime(ts)}</span>`,
pdf,
];
});
if ($.fn.DataTable.isDataTable($tbl)) {
const dti = $tbl.DataTable();
dti.clear();
dti.rows.add(data).draw(false);
}
showToast('Invoice generated');
} catch (err) {
showToast(err?.message || 'Failed to generate invoice', false);
} finally {
this.disabled = false;
}
});
// Explicit delegated handler as a safety net (in addition to inline onclick)
document.addEventListener('click', function(ev){
const el = ev.target.closest('.parent-card-link, [data-family-guardian-id]');
if (!el) return;
const gid = el.getAttribute('data-family-guardian-id');
if (!gid) return;
ev.preventDefault();
window.__openFamilyByGuardian(gid);
});
// Year change
$year.addEventListener('change', async () => {
try {
const resp = await loadInvoices($year.value);
const data = (resp.invoices || []).map(r => {
const ts = Date.parse(r.invoice_date || new Date().toISOString());
const genBtn = `<button type=\"button\" class=\"btn btn-primary btn-sm gen-invoice\" data-parent-id=\"${esc(r.parent_id)}\" onclick=\"return window.__genInvoice && window.__genInvoice(this)\">Generate Invoice</button>`;
const pdf = r.invoice_id ? `<a href=\"<?= base_url('invoice/pdf') ?>/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF</a>` : '<span class=\"text-muted\">No invoice yet</span>';
return [
esc(r.parent_name || ''),
renderStudents(r.enrolledKids || []),
genBtn,
fmtMoney(r.invoice_amount),
fmtMoney(r.refund_amount),
`<span data-order=\"${ts}\">${formatDateTime(ts)}</span>`,
pdf,
];
});
if ($.fn.DataTable.isDataTable($tbl)) {
const dti = $tbl.DataTable();
dti.clear();
dti.rows.add(data).draw(false);
}
showToast('Loaded ' + $year.value);
} catch (e) {
console.error(e);
showToast('Failed to load ' + $year.value, false);
}
});
});
</script>
<?= $this->endSection() ?>