fix enrollment, invoice, payment and financila aid
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Failing after 1m19s

This commit is contained in:
root
2026-08-24 21:20:58 -04:00
parent 576da3bd78
commit 608aca79b8
30 changed files with 3327 additions and 774 deletions
@@ -48,6 +48,18 @@
white-space: normal;
text-align: left;
}
.enrollment-admin .issue-badge.adult-student,
.enrollment-admin .adult-student-name {
background-color: #b02a37;
color: #fff;
}
.enrollment-admin .adult-student-name {
display: inline-block;
border-radius: .25rem;
font-weight: 600;
padding: .15rem .35rem;
margin: .1rem .15rem .1rem 0;
}
</style>
<?= $this->endSection() ?>
@@ -62,12 +74,11 @@ if (!function_exists('enrollment_admin_flag_label')) {
'AGE_EXCEPTION_REQUIRED' => 'Age exception',
'LATE_REGISTRATION_EXCEPTION' => 'Late registration',
'FINANCIAL_REVIEW_REQUIRED' => 'Finance review',
'CLASS_CAPACITY_EXCEPTION_REQUIRED' => 'Class capacity',
'DEFERRED_DELIBERATION' => 'Decision review',
'RESTRICTED_ADMINISTRATIVE_REVIEW' => 'Restricted review',
'WITHDRAWAL_REVIEW_REQUIRED' => 'Withdrawal review',
'COMPLETION_OR_EXIT_PROCESS_REQUIRED' => 'Exit / completion',
'ADULT_STUDENT_ACTION_REQUIRED' => 'Adult student',
'ADULT_STUDENT_ACTION_REQUIRED', 'ADULT_STUDENT_PARENT_BLOCKED' => 'Adult student',
'SIBLING_LAST_NAME_MISMATCH' => 'Sibling last names',
'NO_FINAL_DECISION' => 'No final decision',
'UNRECOGNIZED_DECISION' => 'Unrecognized decision',
@@ -110,8 +121,9 @@ if (!function_exists('enrollment_admin_issue_badge_class')) {
return match (strtoupper($code)) {
'NO_FINAL_DECISION', 'UNRECOGNIZED_DECISION', 'EXPELLED', 'DENIED', 'RESTRICTED_ADMINISTRATIVE_REVIEW' => 'bg-danger',
'DEFERRED_DECISION', 'DEFERRED_DELIBERATION', 'FINANCIAL_REVIEW_REQUIRED', 'OUTSTANDING_BALANCE_BLOCKED', 'FINANCE_APPROVAL_REQUIRED' => 'bg-warning text-dark',
'PENDING_MAKE_UP_EXAM_PROMOTION', 'MAKE_UP_EXAM', 'AGE_EXCEPTION_REQUIRED', 'ADULT_STUDENT_ACTION_REQUIRED' => 'bg-info text-dark',
'CLASS_REASSIGNMENT_REQUIRED', 'CLASS_CAPACITY_EXCEPTION_REQUIRED' => 'bg-primary',
'ADULT_STUDENT_ACTION_REQUIRED', 'ADULT_STUDENT_PARENT_BLOCKED' => 'adult-student',
'PENDING_MAKE_UP_EXAM_PROMOTION', 'MAKE_UP_EXAM', 'AGE_EXCEPTION_REQUIRED' => 'bg-info text-dark',
'CLASS_REASSIGNMENT_REQUIRED' => 'bg-primary',
'WITHDRAWN', 'WITHDRAWAL_REVIEW_REQUIRED', 'COMPLETION_OR_EXIT_PROCESS_REQUIRED' => 'bg-dark',
'SIBLING_LAST_NAME_MISMATCH', 'LATE_REGISTRATION_EXCEPTION' => 'bg-secondary',
default => 'bg-secondary',
@@ -147,7 +159,7 @@ if (!function_exists('enrollment_admin_rule_label')) {
'AGE_RULE_BLOCKED' => 'Age rule not met',
'REGISTRATION_CLOSED' => 'Registration is closed',
'REGISTRATION_NOT_OPEN' => 'Registration is not open yet',
'ADULT_STUDENT_PARENT_BLOCKED' => 'Adult student cannot be enrolled by a parent',
'ADULT_STUDENT_PARENT_BLOCKED' => 'Adult students cannot enroll or re-enroll at the school',
'EXIT_REQUIRED' => 'Exit / completion required',
'EXPELLED' => 'Expelled — administrative review',
'WITHDRAWN' => 'Withdrawn — review required',
@@ -405,7 +417,7 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes :
<input class="form-control form-control-sm mb-2" name="resolution_notes" placeholder="Resolution notes" required>
<button class="btn btn-sm btn-primary" type="submit">Save exam result</button>
</form>
<?php elseif (in_array($flagTypeValue, ['AGE_EXCEPTION_REQUIRED', 'LATE_REGISTRATION_EXCEPTION', 'FINANCIAL_REVIEW_REQUIRED', 'CLASS_CAPACITY_EXCEPTION_REQUIRED', 'SIBLING_LAST_NAME_MISMATCH', 'DEFERRED_DELIBERATION', 'WITHDRAWAL_REVIEW_REQUIRED', 'RESTRICTED_ADMINISTRATIVE_REVIEW'], true) && !empty($canManageEnrollmentExceptions)): ?>
<?php elseif (in_array($flagTypeValue, ['AGE_EXCEPTION_REQUIRED', 'LATE_REGISTRATION_EXCEPTION', 'FINANCIAL_REVIEW_REQUIRED', 'SIBLING_LAST_NAME_MISMATCH', 'DEFERRED_DELIBERATION', 'WITHDRAWAL_REVIEW_REQUIRED', 'RESTRICTED_ADMINISTRATIVE_REVIEW'], true) && !empty($canManageEnrollmentExceptions)): ?>
<form method="post" action="<?= site_url('administrator/enrollment-admin/flags/' . $flagId . '/approve-exception') ?>" class="mb-2">
<?= csrf_field() ?>
<div class="small fw-semibold mb-1">Approve an exception for this student</div>
@@ -528,7 +540,7 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes :
<div class="form-check">
<input class="form-check-input" type="checkbox" name="bypassed_rule_codes_by_student[<?= $studentId ?>][]" id="bypass_<?= $studentId ?>_<?= esc($code) ?>" value="<?= esc($code) ?>" <?= $isBypassable ? 'checked' : 'disabled' ?>>
<label class="form-check-label small" for="bypass_<?= $studentId ?>_<?= esc($code) ?>">
<?= esc(enrollment_admin_rule_label($code)) ?><?= $isBypassable ? '' : ' (cannot bypass)' ?>
<?= esc(enrollment_admin_rule_label($code)) ?><?= $isBypassable ? '' : ' ' ?>
</label>
</div>
<?php endforeach; ?>
@@ -562,9 +574,8 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes :
<?php endforeach; ?>
</select>
</div>
<div class="col-md-7">
<label class="form-label" for="expires_at">Expires</label>
<input class="form-control form-control-sm" id="expires_at" name="expires_at" type="datetime-local">
<div class="col-md-7 d-flex align-items-end">
<div class="form-text mb-2">Applies for the selected school year (<?= esc($schoolYear ?? '') ?>).</div>
</div>
<div class="col-12">
<label class="form-label" for="reason_note">Approval note</label>
@@ -601,7 +612,7 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes :
<th>Reason</th>
<th>Bypassed rules</th>
<th>Created by</th>
<th>Expires</th>
<th>School year</th>
<th class="no-sort">Action</th>
</tr>
</thead>
@@ -633,7 +644,7 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes :
</td>
<td><?= esc($bypassedLabels !== [] ? implode(', ', $bypassedLabels) : 'None') ?></td>
<td><?= esc($exception['created_by_name'] ?? '') ?></td>
<td><?= esc(!empty($exception['expires_at']) ? local_datetime($exception['expires_at'], 'm-d-Y H:i') : 'No expiry') ?></td>
<td><?= esc($exception['school_year'] ?? ($schoolYear ?? '')) ?></td>
<td>
<?php if ($exceptionStatus === 'active' && !empty($canManageEnrollmentExceptions)): ?>
<form method="post" action="<?= site_url('administrator/enrollment-admin/exceptions/' . $exceptionId . '/revoke') ?>">
@@ -751,7 +762,20 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes :
<tr>
<td><?= esc($example['parent_name'] ?? '') ?></td>
<td><?= esc(implode(', ', $example['recipients'] ?? [])) ?></td>
<td><?= esc(implode(', ', $example['student_names'] ?? [])) ?></td>
<td>
<?php if (!empty($example['students']) && is_array($example['students'])): ?>
<?php foreach ($example['students'] as $studentIndex => $student): ?>
<?= $studentIndex > 0 ? ', ' : '' ?>
<?php if (!empty($student['adult_student'])): ?>
<span class="adult-student-name"><?= esc($student['name'] ?? '') ?></span>
<?php else: ?>
<span><?= esc($student['name'] ?? '') ?></span>
<?php endif; ?>
<?php endforeach; ?>
<?php else: ?>
<?= esc(implode(', ', $example['student_names'] ?? [])) ?>
<?php endif; ?>
</td>
<td><?= esc($example['subject'] ?? '') ?></td>
<td>
<span class="badge <?= esc($badgeClass) ?>"><?= esc($deliveryLabel) ?></span>
+180 -16
View File
@@ -9,22 +9,51 @@
<?php endif; ?>
<div class="border rounded p-3 mb-3">
<div><strong>Parent:</strong> <?= esc(trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? ''))) ?> <?= esc($parent['email'] ?? '') ?></div>
<div><strong>School year:</strong> <?= esc($requestRow['school_year'] ?? '') ?></div>
<div><strong>Status:</strong> <?= esc($requestRow['status'] ?? '') ?></div>
<div><strong>Household size:</strong> <?= esc((string) ($requestRow['household_size'] ?? 'Not provided')) ?></div>
<div><strong>Requested amount:</strong> <?= $requestRow['requested_amount'] !== null && $requestRow['requested_amount'] !== '' ? '$' . number_format((float) $requestRow['requested_amount'], 2) : 'Not specified' ?></div>
<div class="mt-2"><strong>Need statement</strong></div>
<p><?= nl2br(esc($requestRow['need_statement'] ?? '')) ?></p>
<div><strong>Students</strong></div>
<ul>
<?php foreach (($students ?? []) as $student): ?>
<li>
<?= esc(trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? ''))) ?>
<?= student_enrollment_status_button($student, $schoolYear ?? null) ?>
</li>
<?php endforeach; ?>
</ul>
<div class="row g-4">
<div class="col-md-6">
<div><strong>Parent:</strong> <?= esc(trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? ''))) ?></div>
<div><strong>Household size:</strong> <?= esc((string) ($requestRow['household_size'] ?? 'Not provided')) ?></div>
<div><strong>Requested amount:</strong> <?= $requestRow['requested_amount'] !== null && $requestRow['requested_amount'] !== '' ? '$' . number_format((float) $requestRow['requested_amount'], 2) : 'Not specified' ?></div>
<div class="mt-2"><strong>Parent statement</strong></div>
<p class="mb-2"><?= nl2br(esc($requestRow['need_statement'] ?? '')) ?></p>
<div><strong>Students</strong></div>
<ul class="mb-0">
<?php foreach (($students ?? []) as $student): ?>
<li>
<?= esc(trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? ''))) ?>
<?= student_enrollment_status_button($student, $schoolYear ?? null) ?>
</li>
<?php endforeach; ?>
</ul>
</div>
<div class="col-md-6">
<h5 class="mb-3">Aid estimate calculator</h5>
<div class="mb-3">
<label class="form-label" for="calc_household_income">Household income</label>
<input class="form-control" type="number" min="0" step="0.01" id="calc_household_income"
value="<?= esc((string) ($requestRow['household_income'] ?? '0')) ?>">
</div>
<div class="mb-3">
<label class="form-label" for="calc_household_size">Household size</label>
<input class="form-control" type="number" min="1" id="calc_household_size"
value="<?= esc((string) max(1, (int) ($requestRow['household_size'] ?? 1))) ?>">
</div>
<div class="mb-3">
<label class="form-label" for="calc_invoice_balance">Invoice balance</label>
<input class="form-control" type="number" min="0" step="0.01" id="calc_invoice_balance"
value="<?= esc(number_format((float) ($invoiceBalance ?? 0), 2, '.', '')) ?>" readonly>
</div>
<button type="button" class="btn btn-outline-primary mb-3" id="calcFinancialAidBtn">Calculate / Recalculate</button>
<div id="calcFinancialAidError" class="alert alert-danger d-none py-2"></div>
<div id="calcFinancialAidSuccess" class="alert alert-success d-none py-2">Financial aid calculation completed.</div>
<div class="border rounded p-3 bg-light">
<div class="small text-muted">Estimated aid</div>
<div class="fs-4 fw-semibold" id="calcEstimatedAid">$<?= number_format((float) ($estimatedAid ?? 0), 2) ?></div>
<div class="small text-muted mt-2">Capped by request and balance</div>
<div class="fw-semibold" id="calcFinalRebate">$<?= number_format((float) ($finalRebate ?? 0), 2) ?></div>
</div>
</div>
</div>
</div>
<?php if (in_array((string) ($requestRow['status'] ?? ''), ['submitted', 'under_review'], true)): ?>
@@ -53,3 +82,138 @@
<?php endif; ?>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
(function () {
const btn = document.getElementById('calcFinancialAidBtn');
const errorBox = document.getElementById('calcFinancialAidError');
const successBox = document.getElementById('calcFinancialAidSuccess');
const estimatedEl = document.getElementById('calcEstimatedAid');
const finalEl = document.getElementById('calcFinalRebate');
const csrfName = <?= json_encode(csrf_token()) ?>;
const csrfHeaderName = <?= json_encode(csrf_header()) ?>;
let csrfHash = <?= json_encode(csrf_hash()) ?>;
const csrfCookieNames = <?= json_encode(array_values(array_unique(array_filter([
config('Security')->cookieName ?? null,
config('Security')->csrfCookieName ?? null,
'csrf_cookie_name',
])))) ?>;
function fmtUSD(value) {
return '$' + Number(value || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function getCookie(name) {
return document.cookie.split(';')
.map((value) => value.trim())
.find((value) => value.startsWith(name + '='))?.split('=')[1] || '';
}
function refreshCsrfFromCookie() {
for (const cookieName of csrfCookieNames) {
const value = decodeURIComponent(getCookie(cookieName) || '');
if (value) {
csrfHash = value;
syncCsrfFields();
return;
}
}
}
function syncCsrfFields() {
document.querySelectorAll('input[name="' + csrfName + '"]').forEach((input) => {
input.value = csrfHash;
});
}
function captureCsrf(resp, data) {
const headerValue = resp.headers.get(csrfHeaderName) || resp.headers.get('X-CSRF-HASH') || '';
if (headerValue) {
csrfHash = headerValue;
syncCsrfFields();
return;
}
if (data?.csrf_hash) {
csrfHash = data.csrf_hash;
syncCsrfFields();
return;
}
if (data?.csrfHash) {
csrfHash = data.csrfHash;
syncCsrfFields();
return;
}
refreshCsrfFromCookie();
}
document.querySelectorAll(
'form[action$="/approve"], form[action$="/deny"]'
).forEach((form) => {
form.addEventListener('submit', () => {
refreshCsrfFromCookie();
syncCsrfFields();
});
});
btn?.addEventListener('click', async function () {
if (errorBox) {
errorBox.classList.add('d-none');
errorBox.textContent = '';
}
if (successBox) {
successBox.classList.add('d-none');
}
refreshCsrfFromCookie();
const body = new URLSearchParams();
body.set('household_income', document.getElementById('calc_household_income')?.value ?? '');
body.set('household_size', document.getElementById('calc_household_size')?.value ?? '');
body.set(csrfName, csrfHash);
try {
const resp = await fetch(<?= json_encode(site_url('administrator/financial-aid/' . (int) ($requestRow['id'] ?? 0) . '/estimate')) ?>, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
[csrfHeaderName]: csrfHash,
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
body: body.toString(),
});
const text = await resp.text();
let data = {};
try {
data = JSON.parse(text);
} catch (parseErr) {
throw new Error('Unable to calculate estimated aid.');
}
captureCsrf(resp, data);
if (!resp.ok) {
throw new Error(data.error || data.message || 'Unable to calculate estimated aid.');
}
if (estimatedEl) estimatedEl.textContent = fmtUSD(data.estimated_aid);
if (finalEl) finalEl.textContent = fmtUSD(data.final_rebate);
const balanceInput = document.getElementById('calc_invoice_balance');
if (balanceInput && data.invoice_balance !== undefined) {
balanceInput.value = Number(data.invoice_balance).toFixed(2);
}
if (successBox) {
successBox.textContent = 'Financial aid calculation completed.';
successBox.classList.remove('d-none');
}
} catch (err) {
if (errorBox) {
errorBox.textContent = err.message || 'Unable to calculate estimated aid.';
errorBox.classList.remove('d-none');
}
}
});
})();
</script>
<?= $this->endSection() ?>
+55 -15
View File
@@ -54,12 +54,26 @@ $selectedClasses = array_values(array_intersect(range(1,13), $selectedClasses));
</div>
<div class="col-md-6">
<label class="form-label">Category</label>
<?php
$selectedCategoryId = (int) old('category_id', $item['category_id'] ?? 0);
$selectedCat = null;
if ($selectedCategoryId > 0) {
foreach ($categories as $c) {
if ((int)($c['id'] ?? 0) === $selectedCategoryId) {
$selectedCat = $c;
break;
}
}
}
$editGmin = old('grade_min', $selectedCat['grade_min'] ?? '');
$editGmax = old('grade_max', $selectedCat['grade_max'] ?? '');
?>
<select class="form-select" name="category_id" id="bookCategorySelect">
<option value="">— None —</option>
<?php foreach ($categories as $c): ?>
<?php
$cid = (int)($c['id'] ?? 0);
$sel = isset($item['category_id']) && (int)$item['category_id'] === $cid ? 'selected' : '';
$sel = $selectedCategoryId === $cid ? 'selected' : '';
$gmin = $c['grade_min'] ?? '';
$gmax = $c['grade_max'] ?? '';
?>
@@ -71,9 +85,20 @@ $selectedClasses = array_values(array_intersect(range(1,13), $selectedClasses));
</option>
<?php endforeach; ?>
</select>
<div class="row g-2 mt-2">
<div class="col-6">
<label class="form-label" for="bookGradeMin">Grade Min</label>
<input type="number" class="form-control" name="grade_min" id="bookGradeMin"
min="0" max="13" value="<?= esc((string)$editGmin) ?>">
</div>
<div class="col-6">
<label class="form-label" for="bookGradeMax">Grade Max</label>
<input type="number" class="form-control" name="grade_max" id="bookGradeMax"
min="0" max="13" value="<?= esc((string)$editGmax) ?>">
</div>
</div>
<div class="form-text">
Grade Range:
<span id="catGradeRange">—</span>
Grade range is saved on the selected category (shared by all books in that category).
</div>
</div>
@@ -141,20 +166,35 @@ $selectedClasses = array_values(array_intersect(range(1,13), $selectedClasses));
<script>
(function() {
// Category grade range helper
const sel = document.getElementById('bookCategorySelect');
const out = document.getElementById('catGradeRange');
function label(opt) {
if (!opt) { out.textContent = '—'; return; }
const gmin = opt.getAttribute('data-gmin');
const gmax = opt.getAttribute('data-gmax');
if ((gmin && gmin !== '') && (gmax && gmax !== '')) out.textContent = `G${gmin}G${gmax}`;
else if (gmin && gmin !== '') out.textContent = `G${gmin}+`;
else if (gmax && gmax !== '') out.textContent = `≤G${gmax}`;
else out.textContent = '';
const gminInput = document.getElementById('bookGradeMin');
const gmaxInput = document.getElementById('bookGradeMax');
function syncGradeInputsFromCategory() {
if (!sel || !gminInput || !gmaxInput) return;
const hasCategory = sel.value !== '';
if (!hasCategory) {
gminInput.value = '';
gmaxInput.value = '';
return;
}
const opt = sel.options[sel.selectedIndex];
gminInput.value = opt?.getAttribute('data-gmin') ?? '';
gmaxInput.value = opt?.getAttribute('data-gmax') ?? '';
}
sel?.addEventListener('change', () => label(sel.options[sel.selectedIndex]));
if (sel) label(sel.options[sel.selectedIndex]); // init on load
// Keep option dataset in sync so re-selecting the same category shows latest typed values
function mirrorInputsOntoSelectedOption() {
if (!sel || !sel.value) return;
const opt = sel.options[sel.selectedIndex];
if (!opt) return;
opt.setAttribute('data-gmin', gminInput?.value ?? '');
opt.setAttribute('data-gmax', gmaxInput?.value ?? '');
}
sel?.addEventListener('change', syncGradeInputsFromCategory);
gminInput?.addEventListener('change', mirrorInputsOntoSelectedOption);
gmaxInput?.addEventListener('change', mirrorInputsOntoSelectedOption);
// Classes select all / clear
const selectAllBtn = document.getElementById('btnSelectAllClasses');
+100 -3
View File
@@ -8,6 +8,7 @@
<a class="btn btn-outline-primary" href="<?= site_url('inventory/book-prices') ?>">Book Prices</a>
<a class="btn btn-primary" href="<?= site_url('inventory/create/book') ?>">Add Book</a>
<button class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#addCategoryModal">Add Category</button>
<button class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#editCategoryModal">Edit Category</button>
</div>
</div>
@@ -65,7 +66,19 @@
<td><?= esc($i['isbn']) ?></td>
<td><?= esc($i['edition']) ?></td>
<td><?= esc($catName) ?></td>
<td><?= esc($catGrades) ?></td> <!-- NEW -->
<td>
<?php if (!empty($cat['id'])): ?>
<button type="button"
class="btn btn-link p-0 text-decoration-none js-edit-category-grades"
data-bs-toggle="modal"
data-bs-target="#editCategoryModal"
data-category-id="<?= (int) $cat['id'] ?>">
<?= esc($catGrades) ?>
</button>
<?php else: ?>
<?= esc($catGrades) ?>
<?php endif; ?>
</td>
<td><?= esc($i['quantity']) ?></td>
<td><?= esc($i['unit']) ?></td>
<td><?= esc($userNames[(int)($i['updated_by'] ?? 0)] ?? '—') ?></td>
@@ -102,7 +115,6 @@
<input type="text" name="name" class="form-control" required>
</div>
<!-- NEW: Grade range -->
<div class="row g-2 mb-3">
<div class="col-6">
<label class="form-label">Grade Min</label>
@@ -127,6 +139,65 @@
</div>
</div>
<!-- Edit Category Modal -->
<div class="modal fade" id="editCategoryModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<form class="modal-content" method="post" action="<?= site_url('inventory/category/save') ?>" id="editCategoryForm">
<?= csrf_field() ?>
<input type="hidden" name="type" value="book">
<div class="modal-header">
<h5 class="modal-title">Edit Category (Books)</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Category</label>
<select class="form-select" name="id" id="editCategorySelect" required>
<option value="">— Select category —</option>
<?php foreach ($categories as $c): ?>
<option
value="<?= (int)($c['id'] ?? 0) ?>"
data-name="<?= esc($c['name'] ?? '', 'attr') ?>"
data-description="<?= esc($c['description'] ?? '', 'attr') ?>"
data-gmin="<?= esc((string)($c['grade_min'] ?? ''), 'attr') ?>"
data-gmax="<?= esc((string)($c['grade_max'] ?? ''), 'attr') ?>"
><?= esc(($c['name'] ?? '') . ($gradeLabel($c) !== '—' ? ' (' . $gradeLabel($c) . ')' : '')) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" name="name" id="editCategoryName" class="form-control" required>
</div>
<div class="row g-2 mb-3">
<div class="col-6">
<label class="form-label">Grade Min</label>
<input type="number" name="grade_min" id="editCategoryGradeMin" class="form-control" min="0" max="13" placeholder="e.g., 1">
</div>
<div class="col-6">
<label class="form-label">Grade Max</label>
<input type="number" name="grade_max" id="editCategoryGradeMax" class="form-control" min="0" max="13" placeholder="e.g., 3">
</div>
<div class="col-12">
<small class="text-muted">Optional. Changing grade range updates every book in this category.</small>
</div>
</div>
<div class="mb-0">
<label class="form-label">Description (optional)</label>
<textarea name="description" id="editCategoryDescription" class="form-control" rows="2"></textarea>
</div>
</div>
<div class="modal-footer"><button type="submit" class="btn btn-primary" id="editCategorySaveBtn">Update Category</button></div>
</form>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
document.getElementById('categoryFilter')?.addEventListener('change', function() {
const val = this.value;
@@ -134,6 +205,32 @@ document.getElementById('categoryFilter')?.addEventListener('change', function()
tr.style.display = (!val || tr.getAttribute('data-category') === val) ? '' : 'none';
});
});
</script>
(function() {
const select = document.getElementById('editCategorySelect');
const nameInput = document.getElementById('editCategoryName');
const descInput = document.getElementById('editCategoryDescription');
const gminInput = document.getElementById('editCategoryGradeMin');
const gmaxInput = document.getElementById('editCategoryGradeMax');
function fillEditCategory() {
const opt = select?.options[select.selectedIndex];
const has = !!(select && select.value);
if (nameInput) nameInput.value = has ? (opt.getAttribute('data-name') || '') : '';
if (descInput) descInput.value = has ? (opt.getAttribute('data-description') || '') : '';
if (gminInput) gminInput.value = has ? (opt.getAttribute('data-gmin') || '') : '';
if (gmaxInput) gmaxInput.value = has ? (opt.getAttribute('data-gmax') || '') : '';
}
select?.addEventListener('change', fillEditCategory);
document.getElementById('editCategoryModal')?.addEventListener('show.bs.modal', function (e) {
const trigger = e.relatedTarget;
const preselect = trigger && trigger.getAttribute ? trigger.getAttribute('data-category-id') : null;
if (preselect && select) {
select.value = preselect;
}
fillEditCategory();
});
})();
</script>
<?= $this->endSection() ?>
@@ -126,13 +126,22 @@
return name;
}
function renderInvoiceRow(r) {
const isCarryForward = !!r.is_carry_forward;
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>';
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>`;
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>` : '')
: renderStudents(r.enrolledKids || []);
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>`
: (isCarryForward ? '<span class=\"text-muted\">—</span>' : '<span class=\"text-muted\">No invoice yet</span>');
return [
renderParentCell(r),
renderStudents(r.enrolledKids || []),
invoiceLabel,
genBtn,
fmtMoney(r.invoice_amount),
renderRefundCell(r),
+372 -61
View File
@@ -254,17 +254,35 @@ $statusBadge = static function (?string $status): string {
default => '<span class="badge bg-light text-dark">unknown</span>',
};
};
$alreadyEnrolledMessage = static fn (?string $status): string => \App\Support\Enrollment\EnrollmentEligibility::alreadyEnrolledMessage($status);
$alreadyEnrolledTitle = static fn (?string $status): string => \App\Support\Enrollment\EnrollmentEligibility::alreadyEnrolledTitle($status);
$hasSettledEnrollmentStatus = static function (?string $status, ?string $admissionStatus = ''): bool {
if (strtolower(trim((string) $admissionStatus)) === 'accepted') {
return true;
}
return in_array(strtolower(trim((string) $status)), [
'admission under review',
'review & decision',
'payment pending',
'enrolled',
'waitlist',
'withdraw under review',
'refund pending',
], true);
};
$enrollableCount = 0;
$withdrawableCount = 0;
foreach (($students ?? []) as $student) {
$eligibilityMessage = is_array($student['enrollment_eligibility_message'] ?? null) ? $student['enrollment_eligibility_message'] : ['blocking' => false];
if (($student['enrollment_status'] ?? '') === 'not enrolled' && !$deadlinePassed && $isEditable && empty($eligibilityMessage['blocking'])) {
$evaluation = is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : [];
if (($evaluation['can_enroll'] ?? false) === true && !$deadlinePassed && $isEditable) {
$enrollableCount++;
}
if (($student['enrollment_status'] ?? '') === 'enrolled' && $isEditable) {
$withdrawableCount++;
}
}
$studentCount = count($students ?? []);
?>
<div class="container my-4">
@@ -311,15 +329,17 @@ foreach (($students ?? []) as $student) {
<button type="button"
class="btn btn-success btn-lg w-100"
id="startEnrollmentButton"
<?= (!$isEditable || $enrollableCount === 0 || $deadlinePassed) ? 'disabled' : '' ?>>
<?= (!$isEditable || $studentCount === 0 || $deadlinePassed) ? 'disabled' : '' ?>>
Start Enrollment
</button>
</div>
</div>
<?php if ($deadlinePassed): ?>
<div class="small text-danger mt-2">Enrollment closed on <?= esc($deadlineObj->format('m-d-Y')) ?>.</div>
<?php elseif ($studentCount === 0): ?>
<div class="small text-muted mt-2">No students are currently linked to this account.</div>
<?php elseif ($enrollableCount === 0): ?>
<div class="small text-muted mt-2">No students are currently available for new enrollment.</div>
<div class="small text-muted mt-2">No students are currently eligible for enrollment. You may still click Start Enrollment to review the reason.</div>
<?php endif; ?>
</div>
@@ -332,6 +352,7 @@ foreach (($students ?? []) as $student) {
<th>Decision</th>
<th>Required Action</th>
<th>Status</th>
<th>Eligibility</th>
<th>Withdraw</th>
</tr>
</thead>
@@ -352,8 +373,24 @@ foreach (($students ?? []) as $student) {
</td>
<td data-label="Grade"><?= esc($gradeLabel) ?></td>
<td data-label="Decision"><?= esc($student['transition_evaluation']['decision_label'] ?? 'Pending') ?></td>
<td data-label="Required Action"><?= esc($student['required_action_label'] ?? 'Contact administration') ?></td>
<td data-label="Required Action"><?= esc($student['parent_enrollment_state'] ?? $student['required_action_label'] ?? 'Contact administration') ?></td>
<td data-label="Status"><?= $statusBadge($student['enrollment_status'] ?? '') ?></td>
<td data-label="Eligibility">
<?php
$evaluation = is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : [];
if ($hasSettledEnrollmentStatus($student['enrollment_status'] ?? '', $student['admission_status'] ?? '')) {
echo '<span class="text-muted small">' . esc($alreadyEnrolledMessage((string) ($student['enrollment_status'] ?? ''))) . '</span>';
} elseif (($evaluation['can_enroll'] ?? false) === true) {
echo '<span class="text-success small">Eligible</span>';
} elseif (! empty($evaluation['primary_parent_message'])) {
echo '<span class="text-danger small">' . esc($evaluation['primary_parent_message']) . '</span>';
} elseif (! empty($student['enrollment_eligibility_message']['message'])) {
echo '<span class="text-danger small">' . esc($student['enrollment_eligibility_message']['message']) . '</span>';
} else {
echo '<span class="text-muted small">Not eligible</span>';
}
?>
</td>
<td data-label="Withdraw">
<?php if (($student['enrollment_status'] ?? '') === 'enrolled'): ?>
<div class="form-check form-switch m-0 enrollment-withdraw-control">
@@ -464,12 +501,19 @@ foreach (($students ?? []) as $student) {
<?php foreach ($students as $student): ?>
<?php
$eligibilityMessage = is_array($student['enrollment_eligibility_message'] ?? null) ? $student['enrollment_eligibility_message'] : ['message' => '', 'blocking' => false, 'level' => 'info'];
$evaluation = is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : [];
$studentName = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? ''));
$studentName = $studentName !== '' ? $studentName : 'Student';
$canEnroll = ($student['enrollment_status'] ?? '') === 'not enrolled'
$canEnroll = ($evaluation['can_enroll'] ?? false) === true
&& !$deadlinePassed
&& $isEditable
&& empty($eligibilityMessage['blocking']);
&& $isEditable;
$hasSettledEnrollment = $hasSettledEnrollmentStatus($student['enrollment_status'] ?? '', $student['admission_status'] ?? '');
$blockMessage = $hasSettledEnrollment
? $alreadyEnrolledMessage((string) ($student['enrollment_status'] ?? ''))
: (string) ($evaluation['primary_parent_message'] ?? $eligibilityMessage['message'] ?? '');
$blockTitle = $hasSettledEnrollment
? $alreadyEnrolledTitle((string) ($student['enrollment_status'] ?? ''))
: 'Enrollment Not Available';
$section = $student['class_section'] ?? null;
$gradeLabel = $section
? preg_replace_callback('/\byouth\b/i', fn($m) => ucfirst(strtolower($m[0])), (string) $section)
@@ -502,7 +546,10 @@ foreach (($students ?? []) as $student) {
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-selectable="<?= $canEnroll ? '1' : '0' ?>">
data-selectable="<?= $canEnroll ? '1' : '0' ?>"
data-already-enrolled="<?= $hasSettledEnrollment ? '1' : '0' ?>"
data-block-title="<?= esc($blockTitle) ?>"
data-block-message="<?= esc($blockMessage) ?>">
<div class="d-flex gap-3 align-items-start">
<span class="student-select-icon" aria-hidden="true"><i class="bi bi-check2"></i></span>
<div class="flex-grow-1">
@@ -622,7 +669,11 @@ foreach (($students ?? []) as $student) {
value="<?= esc($parentContact['cellphone'] ?? '') ?>"
maxlength="12"
inputmode="numeric"
autocomplete="tel"
title="Enter a valid 10-digit phone number (e.g., 123-456-7890)"
required>
<div class="form-text text-muted">10-digit US phone number (e.g., 123-456-7890).</div>
<div id="parent-contact-cellphone-error" class="invalid-feedback"></div>
</div>
<div class="col-md-8">
<label class="form-label small fw-semibold" for="parent-contact-street">Home street address</label>
@@ -631,8 +682,12 @@ foreach (($students ?? []) as $student) {
id="parent-contact-street"
name="parent_contact[address_street]"
value="<?= esc($parentContact['address_street'] ?? '') ?>"
maxlength="255"
maxlength="50"
autocomplete="address-line1"
title="250 characters. Letters, numbers, spaces, periods, and hyphens only."
required>
<div class="form-text text-muted">250 characters. Letters, numbers, spaces, periods, and hyphens only.</div>
<div id="parent-contact-street-error" class="invalid-feedback"></div>
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold" for="parent-contact-apt">Apt / Unit</label>
@@ -641,7 +696,11 @@ foreach (($students ?? []) as $student) {
id="parent-contact-apt"
name="parent_contact[apt]"
value="<?= esc($parentContact['apt'] ?? '') ?>"
maxlength="15">
maxlength="15"
autocomplete="address-line2"
title="Optional. Letters, numbers, spaces, periods, and hyphens only.">
<div class="form-text text-muted">Optional. Max 15 characters.</div>
<div id="parent-contact-apt-error" class="invalid-feedback"></div>
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold" for="parent-contact-city">City</label>
@@ -650,8 +709,12 @@ foreach (($students ?? []) as $student) {
id="parent-contact-city"
name="parent_contact[city]"
value="<?= esc($parentContact['city'] ?? '') ?>"
maxlength="100"
maxlength="30"
autocomplete="address-level2"
title="230 characters. Letters, spaces, periods, apostrophes, and hyphens only."
required>
<div class="form-text text-muted">230 characters. Letters and spaces allowed.</div>
<div id="parent-contact-city-error" class="invalid-feedback"></div>
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold" for="parent-contact-zip">ZIP code</label>
@@ -662,7 +725,12 @@ foreach (($students ?? []) as $student) {
value="<?= esc($parentContact['zip'] ?? '') ?>"
maxlength="5"
inputmode="numeric"
pattern="\d{5}"
autocomplete="postal-code"
title="Please enter a 5-digit ZIP code"
required>
<div class="form-text text-muted">5-digit US ZIP code only.</div>
<div id="parent-contact-zip-error" class="invalid-feedback"></div>
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold" for="parent-contact-state">State</label>
@@ -672,6 +740,7 @@ foreach (($students ?? []) as $student) {
<option value="<?= esc($abbr) ?>" <?= $currentParentState === $abbr ? 'selected' : '' ?>><?= esc($stateName) ?></option>
<?php endforeach; ?>
</select>
<div id="parent-contact-state-error" class="invalid-feedback"></div>
</div>
</div>
</div>
@@ -777,6 +846,22 @@ foreach (($students ?? []) as $student) {
<?php endif; ?>
</div>
<div class="modal fade" id="enrollmentBlockModal" tabindex="-1" aria-labelledby="enrollmentBlockModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="enrollmentBlockModalLabel">Enrollment Not Available</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="enrollmentBlockModalBody">
Enrollment cannot continue at this time. Please contact school administration.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="deadlineModal" tabindex="-1" aria-labelledby="deadlineModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
@@ -804,8 +889,15 @@ foreach (($students ?? []) as $student) {
const startButton = document.getElementById('startEnrollmentButton');
const flowModalEl = document.getElementById('enrollmentFlowModal');
const deadlineModalEl = document.getElementById('deadlineModal');
const blockModalEl = document.getElementById('enrollmentBlockModal');
const blockModalTitle = document.getElementById('enrollmentBlockModalLabel');
const blockModalHeader = blockModalEl ? blockModalEl.querySelector('.modal-header') : null;
const blockModalBody = document.getElementById('enrollmentBlockModalBody');
const flowModal = flowModalEl ? new bootstrap.Modal(flowModalEl) : null;
const deadlineModal = deadlineModalEl ? new bootstrap.Modal(deadlineModalEl) : null;
const blockModal = blockModalEl ? new bootstrap.Modal(blockModalEl) : null;
const eligibilityRefreshUrl = <?= json_encode(base_url('/parent/enrollment_eligibility_refresh'), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
let latestEligibility = {};
const policyAcceptedInput = document.getElementById('accept_school_policy_input');
const policyAcceptedCheckbox = document.getElementById('schoolPolicyAcceptedCheckbox');
const backButton = document.getElementById('enrollmentBackButton');
@@ -825,17 +917,12 @@ foreach (($students ?? []) as $student) {
'currency' => (string) ($enrollmentFeeSchedule['currency'] ?? '$'),
'firstStudentFee' => (float) ($enrollmentFeeSchedule['first_student_fee'] ?? 0),
'secondStudentFee' => (float) ($enrollmentFeeSchedule['second_student_fee'] ?? 0),
'registrationFee' => (float) ($enrollmentFeeSchedule['registration_fee'] ?? 0),
'tuitionDueAtRegistration' => (float) ($enrollmentFeeSchedule['tuition_due_at_registration'] ?? 0),
'mandatoryFees' => (float) ($enrollmentFeeSchedule['mandatory_fees'] ?? 0),
], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
const familyFinancial = <?= json_encode([
'carryOverBalance' => (float) ($familyFinancialSummary['carry_over_balance'] ?? 0),
'registrationFee' => (float) ($familyFinancialSummary['registration_fee'] ?? 0),
'tuitionDueAtRegistration' => (float) ($familyFinancialSummary['tuition_due_at_registration'] ?? 0),
'mandatoryFees' => (float) ($familyFinancialSummary['mandatory_fees'] ?? 0),
'currentBalance' => (float) ($familyFinancialSummary['current_balance'] ?? 0),
'amountDue' => (float) ($familyFinancialSummary['amount_due'] ?? 0),
'carryOverBalance' => (float) ($familyFinancialSummary['carry_forward_balance'] ?? $familyFinancialSummary['carry_over_balance'] ?? 0),
'currentBalance' => (float) ($familyFinancialSummary['current_year_balance'] ?? $familyFinancialSummary['current_balance'] ?? 0),
'amountDue' => (float) ($familyFinancialSummary['total_enrollment_due'] ?? $familyFinancialSummary['amount_due'] ?? 0),
], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
let currentStep = 0;
let hasAcceptedSchoolPolicy = <?= $hasAcceptedSchoolPolicy ? 'true' : 'false' ?>;
@@ -872,15 +959,11 @@ foreach (($students ?? []) as $student) {
const selectedCount = selectedEnrollInputs().length;
const tuitionDue = selectedCount > 0 ? calculateSelectedTuition() : 0;
const carryOver = Number(familyFinancial.carryOverBalance || 0);
const registrationFee = Number(familyFinancial.registrationFee || feeSchedule.registrationFee || 0);
const mandatoryFees = Number(familyFinancial.mandatoryFees || feeSchedule.mandatoryFees || 0);
const currentBalance = Number(familyFinancial.currentBalance || 0);
const amountDue = Math.max(0, carryOver) + Math.max(0, currentBalance) + registrationFee + tuitionDue + mandatoryFees;
const amountDue = Math.max(0, carryOver) + Math.max(0, currentBalance) + tuitionDue;
const values = {
carry_over_balance: carryOver,
registration_fee: registrationFee,
tuition_due_at_registration: tuitionDue,
mandatory_fees: mandatoryFees,
current_balance: currentBalance,
amount_due: amountDue,
};
@@ -982,6 +1065,35 @@ foreach (($students ?? []) as $student) {
return digits;
}
function titleCaseContact(value) {
return String(value || '')
.trim()
.replace(/\s+/g, ' ')
.toLowerCase()
.replace(/(^|[\s\-'])([a-z])/g, function(_, prefix, letter) {
return prefix + letter.toUpperCase();
});
}
const parentContactRules = {
phone: /^\(?\d{3}\)?[-\s]?\d{3}[-\s]?\d{4}$/,
street: /^[A-Za-z0-9.\s-]{2,50}$/,
apt: /^[A-Za-z0-9.\s-]{0,15}$/,
city: /^[A-Za-z\s.'-]{2,30}$/,
zip: /^\d{5}$/,
};
function setParentContactError(input, message) {
if (!input) {
return;
}
const error = document.getElementById(input.id + '-error');
input.classList.toggle('is-invalid', !!message);
if (error) {
error.textContent = message || '';
}
}
function parentContactAddress() {
const street = parentStreetInput?.value?.trim() || '';
const apt = parentAptInput?.value?.trim() || '';
@@ -1004,36 +1116,96 @@ foreach (($students ?? []) as $student) {
}
}
function validateParentContactField(input) {
if (!input) {
return true;
}
const value = input.value.trim();
let message = '';
switch (input.id) {
case 'parent-contact-cellphone':
if (!parentContactRules.phone.test(value) || digitsOnly(value).length !== 10) {
message = 'Please enter a valid 10-digit phone number.';
}
break;
case 'parent-contact-street':
if (!parentContactRules.street.test(value)) {
message = 'Street address must be 250 characters and may contain only letters, numbers, spaces, periods, and hyphens.';
}
break;
case 'parent-contact-apt':
if (value && !parentContactRules.apt.test(value)) {
message = 'Apartment or unit may contain only letters, numbers, spaces, periods, and hyphens.';
}
break;
case 'parent-contact-city':
if (!parentContactRules.city.test(value)) {
message = 'City must be 230 characters and may contain only letters, spaces, periods, apostrophes, and hyphens.';
}
break;
case 'parent-contact-zip':
if (!parentContactRules.zip.test(digitsOnly(value))) {
message = 'Please enter a valid 5-digit ZIP code.';
}
break;
case 'parent-contact-state':
if (!value) {
message = 'Please select your state.';
}
break;
}
setParentContactError(input, message);
return message === '';
}
function validateParentContact() {
const phone = digitsOnly(parentPhoneInput?.value || '');
if (phone.length !== 10) {
alert('Please enter a valid 10-digit phone number.');
parentPhoneInput?.focus();
return false;
}
if (!parentStreetInput || parentStreetInput.value.trim().length < 5) {
alert('Please enter your home street address.');
parentStreetInput?.focus();
return false;
}
if (!parentCityInput || parentCityInput.value.trim().length < 2) {
alert('Please enter your city.');
parentCityInput?.focus();
return false;
}
if (!parentStateInput || parentStateInput.value.trim() === '') {
alert('Please select your state.');
parentStateInput?.focus();
return false;
}
if (!parentZipInput || digitsOnly(parentZipInput.value).length !== 5) {
alert('Please enter a valid 5-digit ZIP code.');
parentZipInput?.focus();
const fields = [
parentPhoneInput,
parentStreetInput,
parentAptInput,
parentCityInput,
parentZipInput,
parentStateInput,
];
fields.forEach(shapeParentContactField);
renderParentContactReview();
let firstInvalid = null;
fields.forEach(field => {
if (!validateParentContactField(field) && !firstInvalid) {
firstInvalid = field;
}
});
if (firstInvalid) {
firstInvalid.focus();
return false;
}
return true;
}
function shapeParentContactField(input) {
if (!input) {
return;
}
if (input === parentPhoneInput) {
input.value = formatPhoneDisplay(input.value);
return;
}
if (input === parentZipInput) {
input.value = digitsOnly(input.value).substring(0, 5);
return;
}
if (input === parentStreetInput || input === parentCityInput) {
input.value = titleCaseContact(input.value);
return;
}
if (input === parentAptInput) {
input.value = input.value.trim().replace(/\s+/g, ' ').toUpperCase();
}
}
function selectStudentsForEnrollment(ids) {
const wanted = Array.isArray(ids) ? ids.map(Number).filter(id => id > 0) : [];
document.querySelectorAll('[data-student-card]').forEach(card => {
@@ -1192,24 +1364,143 @@ foreach (($students ?? []) as $student) {
});
});
function showBlockModal(message, options = {}) {
const alreadyEnrolled = options.alreadyEnrolled === true;
if (blockModalTitle) {
blockModalTitle.textContent = options.title || (alreadyEnrolled ? 'Already Enrolled' : 'Enrollment Not Available');
}
if (blockModalHeader) {
blockModalHeader.classList.toggle('bg-danger', !alreadyEnrolled);
blockModalHeader.classList.toggle('bg-success', alreadyEnrolled);
}
if (blockModalBody) {
blockModalBody.textContent = message || (alreadyEnrolled
? 'This student is already enrolled for the selected school year.'
: 'Enrollment cannot continue at this time. Please contact school administration.');
}
if (blockModal) {
blockModal.show();
}
}
function blockDetailsForCard(card) {
const studentId = String(card?.dataset?.studentId || '');
const row = latestEligibility[studentId] || {};
const alreadyEnrolled = row.decision === 'ALREADY_ENROLLED' || card.dataset.alreadyEnrolled === '1';
return {
alreadyEnrolled,
title: row.block_title || card.dataset.blockTitle || (alreadyEnrolled ? 'Already Enrolled' : 'Enrollment Not Available'),
message: row.primary_parent_message || card.dataset.blockMessage || '',
};
}
async function refreshEligibility() {
const response = await fetch(eligibilityRefreshUrl, {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin',
});
if (!response.ok) {
throw new Error('Unable to refresh eligibility.');
}
const payload = await response.json();
latestEligibility = {};
(payload.students || []).forEach(row => {
latestEligibility[String(row.student_id)] = row;
});
if (payload.financial_summary) {
familyFinancial.carryOverBalance = Number(payload.financial_summary.carry_forward_balance || 0);
familyFinancial.currentBalance = Number(payload.financial_summary.current_year_balance || 0);
familyFinancial.amountDue = Number(payload.financial_summary.total_enrollment_due || 0);
updateFinancialReview();
}
applyEligibilityToCards();
return latestEligibility;
}
function applyEligibilityToCards() {
let shouldReload = false;
document.querySelectorAll('[data-student-card]').forEach(card => {
const studentId = String(card.dataset.studentId || '');
const row = latestEligibility[studentId];
if (!row) {
return;
}
const wasBlocked = card.dataset.selectable !== '1';
const canEnroll = row.can_enroll === true && !deadlinePassed;
card.dataset.selectable = canEnroll ? '1' : '0';
card.classList.toggle('is-disabled', !canEnroll);
if (row.primary_parent_message) {
card.dataset.blockMessage = row.primary_parent_message;
}
if (row.decision === 'ALREADY_ENROLLED') {
card.dataset.alreadyEnrolled = '1';
card.dataset.blockTitle = row.block_title || 'Already Enrolled';
}
if (wasBlocked && canEnroll && !card.querySelector('[data-enroll-input]')) {
shouldReload = true;
}
});
if (shouldReload) {
window.location.reload();
}
}
if (startButton && flowModal) {
startButton.addEventListener('click', function() {
startButton.addEventListener('click', async function() {
if (deadlinePassed) {
if (deadlineModal) deadlineModal.show();
return;
}
try {
await refreshEligibility();
} catch (error) {
alert(error.message || 'Unable to refresh enrollment eligibility.');
return;
}
const eligibleCards = Array.from(document.querySelectorAll('[data-student-card]')).filter(card => {
const studentId = String(card.dataset.studentId || '');
return latestEligibility[studentId]?.can_enroll === true;
});
if (eligibleCards.length === 0) {
const firstBlocked = Object.values(latestEligibility).find(row => row.primary_parent_message || (row.blocking_rule_codes || []).length > 0);
const alreadyEnrolled = firstBlocked?.decision === 'ALREADY_ENROLLED';
const blockReason = firstBlocked?.primary_parent_message
|| (firstBlocked?.blocking_rule_codes || []).join(', ')
|| 'No students are currently eligible for enrollment.';
showBlockModal(blockReason, {
alreadyEnrolled,
title: firstBlocked?.block_title || (alreadyEnrolled ? 'Already Enrolled' : 'Enrollment Not Available'),
});
return;
}
setStep(0);
flowModal.show();
});
}
document.querySelectorAll('[data-student-card]').forEach(card => {
card.addEventListener('click', function(event) {
card.addEventListener('click', async function(event) {
if (event.target.closest('input, select, textarea, label')) {
return;
}
if (this.dataset.selectable !== '1') {
return;
try {
await refreshEligibility();
} catch (error) {
// Fall back to the last known server-rendered message.
}
if (this.dataset.selectable !== '1') {
const details = blockDetailsForCard(this);
showBlockModal(details.message, details);
return;
}
}
const input = this.querySelector('[data-enroll-input]');
if (!input) {
@@ -1320,21 +1611,41 @@ foreach (($students ?? []) as $student) {
});
}
if (parentPhoneInput) {
parentPhoneInput.addEventListener('input', function() {
this.value = formatPhoneDisplay(this.value);
[
parentPhoneInput,
parentStreetInput,
parentAptInput,
parentCityInput,
parentZipInput,
parentStateInput,
].forEach(input => {
if (!input) {
return;
}
input.addEventListener('input', function() {
if (input === parentPhoneInput) {
input.value = formatPhoneDisplay(input.value);
}
if (input === parentZipInput) {
input.value = digitsOnly(input.value).substring(0, 5);
}
validateParentContactField(input);
renderParentContactReview();
});
}
if (parentZipInput) {
parentZipInput.addEventListener('input', function() {
this.value = digitsOnly(this.value).substring(0, 5);
input.addEventListener('blur', function() {
shapeParentContactField(input);
validateParentContactField(input);
renderParentContactReview();
});
}
});
if (enrollmentStartStep >= 2 && startButton && !startButton.disabled && !<?= $flashError ? 'true' : 'false' ?>) {
openEnrollmentAtStep(enrollmentStartStep - 1);
}
refreshEligibility().catch(() => {
// Keep server-rendered eligibility if refresh fails.
});
});
</script>
<?= $this->endSection() ?>
+7 -14
View File
@@ -62,26 +62,19 @@
<form method="post" action="<?= site_url('parent/financial-aid') ?>" class="border rounded p-3 bg-light">
<?= csrf_field() ?>
<div class="mb-3">
<label class="form-label">Students</label>
<?php foreach (($students ?? []) as $student): ?>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="student_ids[]" value="<?= (int) $student['id'] ?>" id="fa_student_<?= (int) $student['id'] ?>">
<label class="form-check-label" for="fa_student_<?= (int) $student['id'] ?>">
<?= esc(trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? ''))) ?>
</label>
</div>
<?php endforeach; ?>
<label class="form-label" for="household_income">Household income <span class="text-danger">*</span></label>
<input class="form-control" type="number" min="0" step="0.01" name="household_income" id="household_income" required value="<?= esc(old('household_income')) ?>">
</div>
<div class="mb-3">
<label class="form-label" for="household_size">Household size</label>
<input class="form-control" type="number" min="1" name="household_size" id="household_size" value="<?= esc(old('household_size')) ?>">
<label class="form-label" for="household_size">Household size <span class="text-danger">*</span></label>
<input class="form-control" type="number" min="1" name="household_size" id="household_size" required value="<?= esc(old('household_size')) ?>">
</div>
<div class="mb-3">
<label class="form-label" for="requested_amount">Requested amount (optional)</label>
<input class="form-control" type="number" min="0" step="0.01" name="requested_amount" id="requested_amount" value="<?= esc(old('requested_amount')) ?>">
<label class="form-label" for="requested_amount">Requested amount <span class="text-danger">*</span></label>
<input class="form-control" type="number" min="0.01" step="0.01" name="requested_amount" id="requested_amount" required value="<?= esc(old('requested_amount')) ?>">
</div>
<div class="mb-3">
<label class="form-label" for="need_statement">Why are you requesting financial aid?</label>
<label class="form-label" for="need_statement">Reason for financial aid request <span class="text-danger">*</span></label>
<textarea class="form-control" name="need_statement" id="need_statement" rows="5" required><?= esc(old('need_statement')) ?></textarea>
</div>
<button class="btn btn-success" type="submit">Submit request</button>