fix invoice and enrollment fees
This commit is contained in:
@@ -24,6 +24,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="generateInvoiceConfirmModal" tabindex="-1" aria-labelledby="generateInvoiceConfirmModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="generateInvoiceConfirmModalLabel">Generate Invoice?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-2">Generate Invoice will recalculate this invoice using the current tuition settings and current student enrollment.</p>
|
||||
<p class="mb-0 text-muted small">This will update the saved invoice student list.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" id="generateInvoiceNoButton" data-bs-dismiss="modal">No</button>
|
||||
<button type="button" class="btn btn-primary" id="generateInvoiceYesButton">Yes, Generate Invoice</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('scripts') ?>
|
||||
@@ -80,6 +99,7 @@
|
||||
|
||||
const fmtMoney = (v) => '$' + Number(v || 0).toFixed(2);
|
||||
const esc = (s) => $('<div>').text(String(s ?? '')).html();
|
||||
const escAttr = (s) => esc(s).replace(/`/g, '`');
|
||||
const pad2 = (num) => String(num).padStart(2, '0');
|
||||
const formatDateTime = (ts) => {
|
||||
const date = new Date(ts);
|
||||
@@ -130,7 +150,7 @@
|
||||
const ts = Date.parse(r.invoice_date || new Date().toISOString());
|
||||
const genBtn = isCarryForward
|
||||
? '<span class="text-muted small">Audit only</span>'
|
||||
: `<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>`;
|
||||
: `<button type=\"button\" class=\"btn btn-primary btn-sm gen-invoice\" data-parent-id=\"${escAttr(r.parent_id)}\" data-parent-name=\"${escAttr(r.parent_name || '')}\">Generate Invoice</button>`;
|
||||
const invoiceLabel = isCarryForward
|
||||
? `<div><span class="badge bg-secondary me-1">Carry-over</span>${esc(r.invoice_description || 'Carry over balance')}</div>`
|
||||
+ (r.invoice_number ? `<div class="text-muted small">${esc(r.invoice_number)}</div>` : '')
|
||||
@@ -169,6 +189,47 @@
|
||||
return json || { ok: true };
|
||||
}
|
||||
|
||||
function confirmGenerateInvoice(parentName) {
|
||||
return new Promise((resolve) => {
|
||||
const modalEl = document.getElementById('generateInvoiceConfirmModal');
|
||||
const yesButton = document.getElementById('generateInvoiceYesButton');
|
||||
if (!modalEl || !yesButton || typeof bootstrap === 'undefined') {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = modalEl.querySelector('.modal-body');
|
||||
if (body) {
|
||||
const name = parentName ? `<strong>${esc(parentName)}</strong>` : 'this parent';
|
||||
body.innerHTML = '<p class="mb-2">Generate Invoice for ' + name + '?</p>'
|
||||
+ '<p class="mb-2">This will recalculate the invoice using the current tuition settings and current student enrollment.</p>'
|
||||
+ '<p class="mb-0 text-muted small">This will update the saved invoice student list.</p>';
|
||||
}
|
||||
|
||||
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
yesButton.removeEventListener('click', onYes);
|
||||
modalEl.removeEventListener('hidden.bs.modal', onHidden);
|
||||
};
|
||||
const finish = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(value);
|
||||
};
|
||||
const onYes = () => {
|
||||
modal.hide();
|
||||
finish(true);
|
||||
};
|
||||
const onHidden = () => finish(false);
|
||||
|
||||
yesButton.addEventListener('click', onYes);
|
||||
modalEl.addEventListener('hidden.bs.modal', onHidden, { once: true });
|
||||
modal.show();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadInvoices(year) {
|
||||
const url = year ? `${API_URL}?schoolYear=${encodeURIComponent(year)}` : API_URL;
|
||||
const res = await fetch(url, { credentials: 'same-origin' });
|
||||
@@ -188,27 +249,33 @@
|
||||
const selected = resp.schoolYear || (years[0] || '');
|
||||
$year.innerHTML = years.map(y => `<option value="${esc(y)}" ${y===selected?'selected':''}>${esc(y)}</option>`).join('');
|
||||
};
|
||||
// Global fallback handler for inline onclick
|
||||
window.__genInvoice = async function(btn){
|
||||
|
||||
const runGenerateInvoice = async function(btn) {
|
||||
const confirmed = await confirmGenerateInvoice(btn.getAttribute('data-parent-name') || '');
|
||||
if (!confirmed) return false;
|
||||
|
||||
try {
|
||||
btn.disabled = true;
|
||||
await generateInvoice(btn.getAttribute('data-parent-id'));
|
||||
const resp = await loadInvoices(selectedSchoolYear());
|
||||
syncSchoolYearSelect(resp);
|
||||
const resp = await loadInvoices(selectedSchoolYear());
|
||||
syncSchoolYearSelect(resp);
|
||||
|
||||
const data = (resp.invoices || []).map(renderInvoiceRow);
|
||||
const data = (resp.invoices || []).map(renderInvoiceRow);
|
||||
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;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
window.__genInvoice = runGenerateInvoice;
|
||||
|
||||
try {
|
||||
const resp = await loadInvoices();
|
||||
syncSchoolYearSelect(resp);
|
||||
@@ -233,47 +300,11 @@
|
||||
}
|
||||
|
||||
// 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(selectedSchoolYear());
|
||||
const data = (resp.invoices || []).map(renderInvoiceRow);
|
||||
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(selectedSchoolYear());
|
||||
const data = (resp.invoices || []).map(renderInvoiceRow);
|
||||
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;
|
||||
}
|
||||
e.preventDefault();
|
||||
await runGenerateInvoice(btn);
|
||||
});
|
||||
|
||||
// Explicit delegated handler as a safety net (in addition to inline onclick)
|
||||
|
||||
@@ -74,11 +74,70 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.enrollment-policy-ack {
|
||||
align-items: flex-start;
|
||||
background: #fff8e1;
|
||||
border: 2px solid #ffc107;
|
||||
border-radius: 8px;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -.35rem 1rem rgba(33, 37, 41, .08);
|
||||
display: flex;
|
||||
gap: .85rem;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.enrollment-policy-ack.is-accepted {
|
||||
background: #eef8f0;
|
||||
border-color: #198754;
|
||||
}
|
||||
|
||||
.enrollment-policy-ack .form-check-input {
|
||||
border: 2px solid #212529;
|
||||
flex: 0 0 auto;
|
||||
height: 1.6rem;
|
||||
margin: .1rem 0 0;
|
||||
width: 1.6rem;
|
||||
}
|
||||
|
||||
.enrollment-policy-ack .form-check-input:focus {
|
||||
box-shadow: 0 0 0 .25rem rgba(255, 193, 7, .35);
|
||||
}
|
||||
|
||||
.enrollment-policy-ack .form-check-input:checked {
|
||||
background-color: #198754;
|
||||
border-color: #198754;
|
||||
}
|
||||
|
||||
.enrollment-policy-ack-label {
|
||||
color: #212529;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.enrollment-policy-ack-label strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.enrollment-policy-ack-label span {
|
||||
color: #6c757d;
|
||||
display: block;
|
||||
font-size: .875rem;
|
||||
margin-top: .15rem;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.enrollment-policy-frame {
|
||||
height: 72vh;
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.enrollment-policy-ack {
|
||||
padding: .85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.enrollment-withdraw-control {
|
||||
@@ -578,6 +637,7 @@ $studentCount = count($students ?? []);
|
||||
data-required-action="<?= esc($student['required_action_label'] ?? 'Contact administration') ?>"
|
||||
data-expected-placement="<?= esc($student['expected_placement_label'] ?? $gradeLabel) ?>"
|
||||
data-is-new="<?= (string) ($student['is_new'] ?? '1') === '1' ? '1' : '0' ?>"
|
||||
data-counts-tuition="<?= $hasSettledEnrollment ? '1' : '0' ?>"
|
||||
data-selectable="<?= $canEnroll ? '1' : '0' ?>"
|
||||
data-already-enrolled="<?= $hasSettledEnrollment ? '1' : '0' ?>"
|
||||
data-block-title="<?= esc($blockTitle) ?>"
|
||||
@@ -781,10 +841,11 @@ $studentCount = count($students ?? []);
|
||||
<h6 class="fw-semibold">Acknowledge school policies</h6>
|
||||
<div class="text-muted small mb-3">Review the current school policies before submitting enrollment.</div>
|
||||
<iframe src="<?= base_url('policy/school_policy') ?>" class="enrollment-policy-frame" frameborder="0" title="School Policies"></iframe>
|
||||
<div class="form-check mt-3">
|
||||
<div class="enrollment-policy-ack <?= $hasAcceptedSchoolPolicy ? 'is-accepted' : '' ?>" id="schoolPolicyAcceptedPanel">
|
||||
<input class="form-check-input" type="checkbox" value="1" id="schoolPolicyAcceptedCheckbox" <?= $hasAcceptedSchoolPolicy ? 'checked disabled' : '' ?>>
|
||||
<label class="form-check-label" for="schoolPolicyAcceptedCheckbox">
|
||||
I have read and accept all school policies.
|
||||
<label class="enrollment-policy-ack-label" for="schoolPolicyAcceptedCheckbox">
|
||||
<strong>I have read and accept all school policies.</strong>
|
||||
<span>This acknowledgement is required before enrollment can continue.</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -932,6 +993,7 @@ $studentCount = count($students ?? []);
|
||||
let latestEligibility = {};
|
||||
const policyAcceptedInput = document.getElementById('accept_school_policy_input');
|
||||
const policyAcceptedCheckbox = document.getElementById('schoolPolicyAcceptedCheckbox');
|
||||
const policyAcceptedPanel = document.getElementById('schoolPolicyAcceptedPanel');
|
||||
const backButton = document.getElementById('enrollmentBackButton');
|
||||
const nextButton = document.getElementById('enrollmentNextButton');
|
||||
const submitButton = document.getElementById('enrollmentSubmitButton');
|
||||
@@ -987,10 +1049,62 @@ $studentCount = count($students ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
function parseGradeRank(value) {
|
||||
let text = String(value || '').trim().toUpperCase().replace(/\s+/g, ' ');
|
||||
text = text.replace(/[._-]/g, ' ');
|
||||
if (['PK', 'P K', 'PREK', 'PRE K', 'PRE KINDER', 'PREKINDER'].includes(text)) return -1;
|
||||
if (['K', 'KG', 'K G', 'KINDER', 'KINDERGARTEN'].includes(text)) return 1;
|
||||
const youth = text.match(/^Y(?:OUTH)?\s*(\d+)?$/);
|
||||
if (youth) return 9 + (youth[1] ? Math.max(1, Number(youth[1])) : 1);
|
||||
const grade = text.match(/^(?:GR?ADE\s*)?(\d{1,2})\s*([A-Z]*)$/);
|
||||
if (grade) return Number(grade[1]);
|
||||
const match = text.match(/\d+/);
|
||||
return match ? Number(match[0]) : 999;
|
||||
}
|
||||
|
||||
function cardFeeGrade(card) {
|
||||
return card?.dataset.expectedPlacement || card?.dataset.currentGrade || '';
|
||||
}
|
||||
|
||||
function billableTuitionCards() {
|
||||
const cardsByStudentId = new Map();
|
||||
|
||||
studentCards().forEach(card => {
|
||||
if (card.dataset.countsTuition === '1') {
|
||||
cardsByStudentId.set(String(card.dataset.studentId || ''), card);
|
||||
}
|
||||
});
|
||||
|
||||
selectedEnrollInputs().forEach(input => {
|
||||
const card = input.closest('[data-student-card]');
|
||||
if (card) {
|
||||
cardsByStudentId.set(String(card.dataset.studentId || ''), card);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(cardsByStudentId.values()).sort((left, right) => {
|
||||
const rankDiff = parseGradeRank(cardFeeGrade(left)) - parseGradeRank(cardFeeGrade(right));
|
||||
if (rankDiff !== 0) return rankDiff;
|
||||
return String(left.dataset.studentName || '').localeCompare(String(right.dataset.studentName || ''));
|
||||
});
|
||||
}
|
||||
|
||||
function tuitionFeeByStudentId() {
|
||||
const fees = new Map();
|
||||
billableTuitionCards().forEach((card, index) => {
|
||||
fees.set(
|
||||
String(card.dataset.studentId || ''),
|
||||
index === 0 ? Number(feeSchedule.firstStudentFee || 0) : Number(feeSchedule.secondStudentFee || 0)
|
||||
);
|
||||
});
|
||||
return fees;
|
||||
}
|
||||
|
||||
function calculateSelectedTuition() {
|
||||
return selectedEnrollInputs().reduce((total, input, index) => {
|
||||
const familyPosition = Number(familyFinancial.currentYearTuitionStudentCount || 0) + index;
|
||||
return total + (familyPosition === 0 ? Number(feeSchedule.firstStudentFee || 0) : Number(feeSchedule.secondStudentFee || 0));
|
||||
const fees = tuitionFeeByStudentId();
|
||||
return selectedEnrollInputs().reduce((total, input) => {
|
||||
const card = input.closest('[data-student-card]');
|
||||
return total + Number(fees.get(String(card?.dataset.studentId || '')) || 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -1030,6 +1144,7 @@ $studentCount = count($students ?? []);
|
||||
const isNewStudent = card?.dataset.isNew === '1';
|
||||
const name = (firstName + ' ' + lastName).trim() || card?.querySelector('.fw-semibold')?.textContent?.trim() || 'Student';
|
||||
return {
|
||||
studentId: card?.dataset.studentId || '',
|
||||
name,
|
||||
schoolId,
|
||||
dob,
|
||||
@@ -1051,9 +1166,9 @@ $studentCount = count($students ?? []);
|
||||
return;
|
||||
}
|
||||
|
||||
feeReviewList.innerHTML = cards.map((student, index) => {
|
||||
const familyPosition = Number(familyFinancial.currentYearTuitionStudentCount || 0) + index;
|
||||
const tuitionFee = familyPosition === 0 ? feeSchedule.firstStudentFee : feeSchedule.secondStudentFee;
|
||||
const fees = tuitionFeeByStudentId();
|
||||
feeReviewList.innerHTML = cards.map((student) => {
|
||||
const tuitionFee = fees.get(String(student.studentId || '')) || 0;
|
||||
const lines = [
|
||||
['Student', student.name],
|
||||
['School ID', student.schoolId || 'N/A'],
|
||||
@@ -1286,6 +1401,9 @@ $studentCount = count($students ?? []);
|
||||
if (policyAcceptedInput) {
|
||||
policyAcceptedInput.value = hasAcceptedSchoolPolicy ? '1' : '0';
|
||||
}
|
||||
if (policyAcceptedPanel) {
|
||||
policyAcceptedPanel.classList.toggle('is-accepted', hasAcceptedSchoolPolicy);
|
||||
}
|
||||
}
|
||||
|
||||
function setStudentFieldsEnabled(card, enabled) {
|
||||
|
||||
@@ -80,13 +80,14 @@
|
||||
<?php
|
||||
$isEditable = (bool) ($isEditable ?? true);
|
||||
$disabledAttr = $isEditable ? '' : ' disabled';
|
||||
$reportRows = $reportRows ?? [];
|
||||
?>
|
||||
<div class="container my-5">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
|
||||
<div>
|
||||
<h3 class="text-success mb-1">Report Cards</h3>
|
||||
<div class="text-muted small">
|
||||
<?= esc($schoolYear ?: 'N/A') ?> <?= $semester ? '• ' . esc($semester) . ' Semester' : '' ?>
|
||||
<?= esc($schoolYear ?: 'N/A') ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,7 +104,7 @@
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (empty($students)): ?>
|
||||
<?php if (empty($reportRows)): ?>
|
||||
<div class="alert alert-info">No students available for report cards.</div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
@@ -112,24 +113,29 @@
|
||||
<tr>
|
||||
<th>Student</th>
|
||||
<th>Class Section</th>
|
||||
<th>Semester</th>
|
||||
<th>Viewed</th>
|
||||
<th>Signature</th>
|
||||
<th class="text-end">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($students as $student): ?>
|
||||
<?php foreach ($reportRows as $reportRow): ?>
|
||||
<?php
|
||||
$student = $reportRow['student'] ?? [];
|
||||
$sid = (int) ($student['id'] ?? 0);
|
||||
$ack = $ackMap[$sid] ?? null;
|
||||
$rowSemester = (string) ($reportRow['semester'] ?? '');
|
||||
$rowKey = (string) ($reportRow['key'] ?? ($sid . '|' . $rowSemester));
|
||||
$ack = $ackMap[$rowKey] ?? null;
|
||||
$viewedAt = $ack['viewed_at'] ?? '';
|
||||
$signedAt = $ack['signed_at'] ?? '';
|
||||
$signedName = $ack['signed_name'] ?? '';
|
||||
$hasReport = !empty(($reportAvailableMap ?? [])[$sid]);
|
||||
$hasReport = !empty(($reportAvailableMap ?? [])[$rowKey]);
|
||||
?>
|
||||
<tr>
|
||||
<td data-label="Student"><?= esc(trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? ''))) ?></td>
|
||||
<td data-label="Class Section"><?= esc($student['class_section_name'] ?? 'N/A') ?></td>
|
||||
<td data-label="Semester"><?= esc($rowSemester) ?></td>
|
||||
<td data-label="Viewed"><?= $viewedAt ? esc(local_datetime($viewedAt, 'm-d-Y H:i')) : 'Not viewed' ?></td>
|
||||
<td data-label="Signature">
|
||||
<?php if ($signedAt): ?>
|
||||
@@ -141,13 +147,14 @@
|
||||
</td>
|
||||
<td class="text-end" data-label="Action">
|
||||
<?php if ($hasReport): ?>
|
||||
<a class="btn btn-sm btn-outline-primary" target="_blank" href="<?= base_url('parent/report-cards/view/' . $sid) ?>">View Report</a>
|
||||
<a class="btn btn-sm btn-outline-primary" target="_blank" href="<?= site_url('parent/report-cards/view/' . $sid) . '?' . http_build_query(['semester' => $rowSemester]) ?>">View Report</a>
|
||||
<?php else: ?>
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" disabled>No report available</button>
|
||||
<?php endif; ?>
|
||||
<?php if ($hasReport && ! $signedAt): ?>
|
||||
<form class="d-inline-flex align-items-center gap-2 ms-2" method="post" action="<?= base_url('parent/report-cards/sign/' . $sid) ?>">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="semester" value="<?= esc($rowSemester) ?>">
|
||||
<input type="text" name="signed_name" class="form-control form-control-sm" placeholder="Full name" required<?= $disabledAttr ?>>
|
||||
<button class="btn btn-sm btn-success" type="submit"<?= $disabledAttr ?>>Sign</button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user