346 lines
15 KiB
PHP
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() ?>
|