Files
2026-05-16 13:44:12 -04:00

835 lines
43 KiB
PHP
Executable File

<?= $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">Manage Enrollment & Withdrawal</h2>
<?php if (!empty($missingYear)): ?>
<div class="alert alert-warning d-flex align-items-center" role="alert">
<div>
Current school year is not configured. This page is read-only until configured in
<a href="<?= site_url('configuration/configuration_view') ?>" class="alert-link">Add/Edit Configuration</a> (key: <code>school_year</code>).
</div>
</div>
<?php endif; ?>
<div class="row g-2 align-items-center justify-content-center mb-2">
<div class="col-auto"><label for="schoolYearSelect" class="col-form-label">School year</label></div>
<div class="col-auto">
<form method="get" action="<?= site_url('enroll_withdraw/enrollment_withdrawal') ?>" class="d-flex align-items-center gap-2">
<select id="schoolYearSelect" name="schoolYear" class="form-select form-select-sm" style="min-width:180px;">
<?php if (!empty($schoolYears) && is_array($schoolYears)): ?>
<?php foreach ($schoolYears as $y): ?>
<?php $yval = is_array($y) && isset($y['school_year']) ? (string)$y['school_year'] : (string)$y; ?>
<option value="<?= esc($yval) ?>" <?= (isset($selectedYear) && (string)$selectedYear === $yval) ? 'selected' : '' ?>><?= esc($yval) ?></option>
<?php endforeach; ?>
<?php else: ?>
<option value="<?= esc($selectedYear ?? '') ?>" selected><?= esc($selectedYear ?? '') ?></option>
<?php endif; ?>
</select>
<?php $semVal = (string)($semester ?? ($_GET['semester'] ?? '')); ?>
<label for="ewSemester" class="col-form-label">Semester</label>
<select id="ewSemester" name="semester" class="form-select form-select-sm" style="min-width: 140px;">
<option value="">—</option>
<option value="Fall" <?= (strcasecmp($semVal,'Fall')===0?'selected':'') ?>>Fall</option>
<option value="Spring" <?= (strcasecmp($semVal,'Spring')===0?'selected':'') ?>>Spring</option>
</select>
<button type="submit" class="btn btn-secondary btn-sm">Apply</button>
</form>
</div>
<?php if (isset($selectedYear, $currentYear) && (string)$selectedYear !== (string)$currentYear): ?>
<div class="col-auto"><span class="badge bg-secondary">Read-only (Past Year)</span></div>
<?php endif; ?>
</div>
<!-- Display success and error messages -->
<?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success">
<?= session()->getFlashdata('success') ?>
</div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger">
<?= session()->getFlashdata('error') ?>
</div>
<?php endif; ?>
<form action="<?= base_url('admin/enroll_withdrawal_handler') ?>" method="post">
<?= csrf_field() ?>
<div class="table-responsive">
<table id="enrollmentTable" class="table table-bordered table-striped mt-4 align-middle">
<thead>
<tr>
<th>Registration Date</th>
<th>Parent/Guardian</th>
<th>Student Name</th>
<th>New Student</th>
<th>Removed (Prior Years)</th>
<th>Current Class</th>
<th>Actual Status</th>
<th>Update Enrollment Status</th>
<th>Assign Class</th>
</tr>
</thead>
<tbody>
<?php if (!empty($students)): ?>
<?php foreach ($students as $student): ?>
<?php $sid = (int)($student['student_id'] ?? $student['id'] ?? 0); ?>
<?php $pid = (int)($student['parent_id'] ?? ($student['guardian_id'] ?? 0)); ?>
<tr data-student-id="<?= $sid ?>" data-parent-id="<?= $pid ?>">
<td data-order="<?= !empty($student['registration_date']) ? local_date($student['registration_date'], 'Y-m-d') : '' ?>">
<?= !empty($student['registration_date']) ? local_date($student['registration_date'], 'm-d-Y') : '-' ?>
</td>
<!-- Parent/Guardian (clickable for Family Card) -->
<td>
<?php if ($pid): ?>
<a href="#" class="text-decoration-none" data-family-guardian-id="<?= (int)$pid ?>">
<?= esc($student['parent_label'] ?? 'Unknown Parent') ?>
</a>
<?php else: ?>
<?= esc($student['parent_label'] ?? 'Unknown Parent') ?>
<?php endif; ?>
</td>
<!-- Student (clickable for Family Card) -->
<td>
<?php if ($sid): ?>
<a href="#" class="text-decoration-none" data-family-student-id="<?= (int)$sid ?>">
<?= esc($student['firstname']) ?> <?= esc($student['lastname']) ?>
</a>
<?php else: ?>
<?= esc($student['firstname']) ?> <?= esc($student['lastname']) ?>
<?php endif; ?>
</td>
<!-- New Student -->
<td>
<?php if (($student['new_student'] ?? 'Unknown') === 'Yes'): ?>
<span class="badge bg-warning text-dark">Yes</span>
<?php elseif (($student['new_student'] ?? 'Unknown') === 'No'): ?>
<span class="badge bg-success text-light">No</span>
<?php else: ?>
<span class="badge bg-light text-dark">Unknown</span>
<?php endif; ?>
</td>
<!-- Removed Previous Year -->
<td>
<?php if (($student['removed_previous_year'] ?? 'No') === 'Yes'): ?>
<span class="badge bg-danger text-light">Yes</span>
<?php else: ?>
<span class="badge bg-secondary">No</span>
<?php endif; ?>
</td>
<!-- Class -->
<td><?= esc($student['class_section'] ?? 'Class not Assigned') ?></td>
<!-- Enrollment Status -->
<td>
<?php
$status = $student['enrollment_status'] ?? 'not enrolled';
switch ($status) {
case 'admission under review':
echo '<span class="badge bg-primary">admission under review</span>';
break;
case 'payment pending':
echo '<span class="badge bg-warning text-dark">payment pending</span>';
break;
case 'enrolled':
echo '<span class="badge bg-success">enrolled</span>';
break;
case 'withdraw under review':
echo '<span class="badge bg-warning text-dark">withdraw under review</span>';
break;
case 'refund pending':
echo '<span class="badge bg-info text-dark">refund pending</span>';
break;
case 'withdrawn':
echo '<span class="badge bg-danger">withdrawn</span>';
break;
case 'waitlist':
echo '<span class="badge bg-secondary">waitlist</span>';
break;
case 'denied':
echo '<span class="badge bg-dark">denied</span>';
break;
default:
echo '<span class="badge bg-light text-dark">not enrolled</span>';
}
?>
</td>
<!-- Update Enrollment -->
<td>
<select <?= (isset($selectedYear, $currentYear) && (string)$selectedYear !== (string)$currentYear) ? 'disabled' : '' ?>
name="enrollment_status[<?= $sid ?>]"
class="form-control enrollment-status"
data-student-id="<?= $sid ?>"
data-parent-id="<?= $pid ?>"
data-prev="<?= esc($student['enrollment_status'] ?? '') ?>">
<option value="admission under review" <?= ($student['enrollment_status'] ?? '') === 'admission under review' ? 'selected' : '' ?>>admission under review</option>
<option value="payment pending" <?= ($student['enrollment_status'] ?? '') === 'payment pending' ? 'selected' : '' ?>>payment pending</option>
<option value="enrolled" <?= ($student['enrollment_status'] ?? '') === 'enrolled' ? 'selected' : '' ?>>enrolled</option>
<option value="withdraw under review" <?= ($student['enrollment_status'] ?? '') === 'withdraw under review' ? 'selected' : '' ?>>withdraw under review</option>
<option value="refund pending" <?= ($student['enrollment_status'] ?? '') === 'refund pending' ? 'selected' : '' ?>>refund pending</option>
<option value="withdrawn" <?= ($student['enrollment_status'] ?? '') === 'withdrawn' ? 'selected' : '' ?>>withdrawn</option>
<option value="waitlist" <?= ($student['enrollment_status'] ?? '') === 'waitlist' ? 'selected' : '' ?>>waitlist</option>
<option value="denied" <?= ($student['enrollment_status'] ?? '') === 'denied' ? 'selected' : '' ?>>denied</option>
</select>
</td>
<!-- Assign Class (only for 'admission under review') -->
<td>
<?php $isUnderReview = (strtolower($student['enrollment_status'] ?? '') === 'admission under review'); ?>
<?php if ($isUnderReview): ?>
<select class="form-select form-select-sm assign-class-select" <?= (isset($selectedYear, $currentYear) && (string)$selectedYear !== (string)$currentYear) ? 'disabled' : '' ?>
data-student-id="<?= $sid ?>"
data-parent-id="<?= $pid ?>">
<option value="">— Select class section —</option>
<?php if (!empty($classes) && is_array($classes)): ?>
<?php foreach ($classes as $class_): ?>
<?php
$val = $class_['class_section_id'] ?? $class_['id'];
$text = $class_['class_section_name'] ?? ($class_['name'] ?? 'Section ' . $val);
?>
<option value="<?= esc($val) ?>"><?= esc($text) ?></option>
<?php endforeach; ?>
<?php endif; ?>
</select>
<?php else: ?>
<span class="text-muted">—</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="9">No students available.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- Centered action buttons under the table -->
<div class="d-flex justify-content-center gap-2 my-3">
<button type="button" id="bulkSaveGenerateButton" class="btn btn-success btn-lg" <?= (isset($selectedYear, $currentYear) && (string)$selectedYear !== (string)$currentYear) ? 'disabled title="Editing disabled for non-current year"' : '' ?> >
Save &amp; Generate Invoice
</button>
<span id="bulkProgress" class="small text-muted d-none">Processing…</span>
</div>
</form>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<style>
/* === Modal Styling === */
.custom-modal {
border-radius: 12px;
overflow: hidden;
animation: fadeInUp 0.35s ease;
}
/* Gradient Header */
.custom-modal-header {
background: linear-gradient(135deg, #2d89ecff, #2161a7ff);
color: #fff;
border-bottom: none;
padding: 1rem 1.5rem;
}
.custom-modal-header .modal-title {
font-size: 1.1rem;
letter-spacing: 0.5px;
}
.custom-modal-footer {
background-color: #f8f9fa;
border-top: none;
padding: 1rem 1.5rem;
}
/* Buttons hover effect */
.custom-modal-footer .btn-primary {
transition: all 0.2s ease;
}
.custom-modal-footer .btn-primary:hover {
background-color: #2670c0ff;
transform: translateY(-1px);
}
.custom-modal-footer .btn-outline-secondary:hover {
background-color: #e9ecef;
}
/* Smooth show animation */
@keyframes fadeInUp {
from {
transform: translateY(40px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Toast container is provided by Bootstrap 5 classes when created */
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Do NOT auto-submit when status dropdown changes; just mark desired value
document.addEventListener('change', function(e) {
const el = e.target;
if (!(el instanceof HTMLSelectElement)) return;
if (!el.name || !el.name.startsWith('enrollment_status[')) return;
const prev = (el.getAttribute('data-prev') || '').trim().toLowerCase();
const next = (el.value || '').trim().toLowerCase();
if (next === prev) {
el.removeAttribute('data-desired');
el.classList.remove('pending-status-select');
} else {
el.setAttribute('data-desired', next);
el.classList.add('pending-status-select');
}
}, false);
});
</script>
<script>
// Bootstrap 5 Toast utility for this page
(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(msg, ok=true, opts={}){
const c = ensureContainer();
const el = document.createElement('div');
el.className = 'toast align-items-center text-bg-' + (ok ? 'success' : 'danger') + ' border-0';
el.setAttribute('role','alert'); el.setAttribute('aria-live','assertive'); el.setAttribute('aria-atomic','true');
el.innerHTML = '<div class="d-flex">'
+ '<div class="toast-body">' + (msg || (ok ? 'Saved' : 'Action failed')) + '</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(el);
try {
const t = new bootstrap.Toast(el, { delay: opts.delay ?? 3000, autohide: true });
el.addEventListener('hidden.bs.toast', () => el.remove());
t.show();
} catch (_){ setTimeout(() => el.remove(), 3000); }
}
})();
$(function() {
const $tbl = $('#enrollmentTable');
// Helper: compute offset if a fixed/sticky navbar exists to prevent overlap
function getFixedHeaderOffset() {
let total = 0;
const stack = [];
const header = document.querySelector('header.navbar.sticky-top, header.navbar.fixed-top');
if (header) stack.push(header);
const mgmt = document.getElementById('navbarManagement');
if (mgmt && (mgmt.classList.contains('sticky-top') || mgmt.classList.contains('fixed-top'))) stack.push(mgmt);
document.querySelectorAll('.navbar.sticky-top, .navbar.fixed-top').forEach(el => { if (!stack.includes(el)) stack.push(el); });
stack.forEach(el => { const h = el.offsetHeight || el.getBoundingClientRect().height || 0; total += Math.max(0, Math.round(h)); });
return total;
}
// Lightweight loaders for optional plugin assets
function loadScript(src, id) {
return new Promise((resolve, reject) => {
if (id && document.getElementById(id)) return resolve();
const s = document.createElement('script');
if (id) s.id = id;
s.src = src;
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
}
function loadCss(href, id) {
return new Promise((resolve) => {
if (id && document.getElementById(id)) return resolve();
const l = document.createElement('link');
if (id) l.id = id;
l.rel = 'stylesheet';
l.href = href;
l.onload = resolve;
document.head.appendChild(l);
});
}
// If already initialized, destroy cleanly (prevents duplicate headers)
if ($.fn.DataTable.isDataTable($tbl)) {
$tbl.DataTable().clear().destroy();
$tbl.find('thead th')
.removeClass('sorting sorting_asc sorting_desc sorting_asc_disabled sorting_desc_disabled');
}
// Helper: strip HTML (for badges) so search/sort use plain text
const stripHtml = (d) => {
if (d == null) return '';
const div = document.createElement('div');
div.innerHTML = d;
return (div.textContent || div.innerText || '').trim();
};
function initDT() {
$tbl.DataTable({
pageLength: 100,
lengthMenu: [10, 25, 50, 100],
// 🔽 Default: sort by Registration Date (col 0) newest first
order: [
[0, 'desc']
],
// Top row: length (left) + search (right)
dom: "<'row mb-2'<'col-sm-6'l><'col-sm-6'f>>" + "t" +
"<'row mt-2'<'col-sm-5'i><'col-sm-7'p>>",
// Disable sort/search on interactive columns (status select, assign select)
columnDefs: [
{ targets: [7, 8], orderable: false, searchable: false },
{ targets: [0, 1, 2, 3, 4, 5, 6], render: function(data, type) {
if (type === 'filter' || type === 'sort' || type === 'type') {
return stripHtml(data);
}
return data;
}}
],
fixedHeader: {
header: true,
headerOffset: getFixedHeaderOffset()
}
});
}
// Ensure FixedHeader plugin is present before init
const hasFH = $.fn.dataTable && $.fn.dataTable.FixedHeader;
if (hasFH) {
initDT();
} else {
// Load FixedHeader (and its Bootstrap CSS) then init
Promise.all([
loadScript('https://cdn.jsdelivr.net/npm/datatables.net-fixedheader@3.4.0/js/dataTables.fixedHeader.min.js', 'dt-fixedheader'),
loadCss('https://cdn.jsdelivr.net/npm/datatables.net-fixedheader-bs5@3.4.0/css/fixedHeader.bootstrap5.min.css', 'dt-fixedheader-css')
]).then(() => initDT()).catch(() => initDT()); // proceed even if CDN fails
}
// Prevent the surrounding <form> from submitting when pressing Enter in the DT search box
$(document).on('keydown keyup', '.dataTables_filter input', function(e) {
if (e.key === 'Enter') e.preventDefault();
});
// No API hydration here; server renders rows.
});
</script>
<script>
/* ===== 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')) ?>;
function getCookie(n) {
return document.cookie.split(';')
.map(v => v.trim())
.find(v => v.startsWith(n + '='))?.split('=')[1] || '';
}
/* ===== DataTable ref (if present) ===== */
const $tbl = $('#enrollmentTable');
const dt = $.fn.DataTable && $.fn.DataTable.isDataTable($tbl) ? $tbl.DataTable() : null;
/* Column index helpers by header text (robust to reordering) */
function findColIndexByHeader(text) {
let idx = -1;
$('#enrollmentTable thead th').each(function(i) {
if ($(this).text().trim() === text) idx = i;
});
return idx;
}
const CLASS_COL_INDEX = findColIndexByHeader('Current Class'); // was index 4
const STATUS_COL_INDEX = findColIndexByHeader('Actual Status'); // was index 5
const STATUS_SELECT_COL_INDEX = findColIndexByHeader('Update Enrollment Status');
const ASSIGN_COL_INDEX = findColIndexByHeader('Assign Class');
/* ===== Helpers ===== */
function norm(v) {
return (v || '').trim().toLowerCase().replace(/[_\s]+/g, ' ');
}
function showRowToast(tr, msg, ok = true) {
if (!tr) return;
const $tr = $(tr);
$tr.addClass(ok ? 'table-success' : 'table-danger');
$tr.attr('title', msg || '');
setTimeout(() => { $tr.removeClass('table-success table-danger'); $tr.removeAttr('title'); }, 1500);
}
function badgeForStatusJs(status) {
const s = norm(status);
switch (s) {
case 'admission under review': return '<span class="badge bg-primary">admission under review</span>';
case 'payment pending': return '<span class="badge bg-warning text-dark">payment pending</span>';
case 'enrolled': return '<span class="badge bg-success">enrolled</span>';
case 'withdraw under review': return '<span class="badge bg-warning text-dark">withdraw under review</span>';
case 'refund pending': return '<span class="badge bg-info text-dark">refund pending</span>';
case 'withdrawn': return '<span class="badge bg-danger">withdrawn</span>';
case 'waitlist': return '<span class="badge bg-secondary">waitlist</span>';
case 'denied': return '<span class="badge bg-dark">denied</span>';
default: return '<span class="badge bg-light text-dark">not enrolled</span>';
}
}
/* Update CSRF like your class-assignment view */
function updateCsrfFromResponse($form, res) {
if (!res) return;
let name = res.csrfTokenName || null;
let hash = res.csrfHash || null;
if (!hash) {
($form && $form.find ? $form.find('input[type="hidden"]') : $('input[type="hidden"]')).each(function() {
const nm = this.name;
if (nm && res[nm]) {
name = nm;
hash = res[nm];
return false;
}
});
}
// Fallback: cookie rotate after POST
if (!hash) {
const c = decodeURIComponent(getCookie(CSRF_COOKIE_NAME));
if (c) hash = c;
}
if (name && hash) {
CSRF_HASH = hash; // keep JS in sync
const $fld = $form && $form.find ? $form.find('input[name="' + name + '"]') : $('input[name="' + name + '"]');
if ($fld && $fld.length) $fld.val(hash);
else ($form && $form.prepend ? $form.prepend($('<input>', {
type: 'hidden',
name,
value: hash
})) : document.body.insertAdjacentHTML('beforeend', `<input type="hidden" name="${name}" value="${hash}">`));
}
}
/* Track the row and the select that triggered the change */
let triggerSelect = null;
let rowNode = null;
/** AJAX: batch update multiple students' statuses at once */
async function updateEnrollmentStatusesBatch(updates) {
if (!updates || !updates.length) return;
const url = '<?= site_url('admin/enroll_withdrawal_handler') ?>';
const body = new URLSearchParams();
updates.forEach(u => {
if (u && u.studentId && u.status) body.append(`enrollment_status[${u.studentId}]`, u.status);
});
body.append(CSRF_TOKEN_NAME, CSRF_HASH);
const resp = await fetch(url, {
method: 'POST',
headers: { 'Accept': 'application/json,text/html', 'X-Requested-With': 'XMLHttpRequest' },
body
});
const ct = resp.headers.get('content-type') || '';
if (ct.includes('application/json')) {
const json = await resp.json().catch(() => ({}));
updateCsrfFromResponse(null, json);
if (json?.ok === false) throw new Error(json?.message || 'Status update failed.');
} else if (!resp.ok) {
throw new Error('Status update failed.');
}
// Fallback: update CSRF from cookie after POST
updateCsrfFromResponse(null, {});
}
/* Intercept change on the status select (do NOT submit immediately) */
document.addEventListener('change', async (e) => {
const el = e.target;
if (!(el instanceof HTMLSelectElement)) return;
if (!el.name || !el.name.startsWith('enrollment_status[')) return;
const prev = norm(el.getAttribute('data-prev') || '');
const next = norm(el.value);
// Mark desired status but do not submit; will process on bulk Save
if (next === prev) {
el.removeAttribute('data-desired');
el.classList.remove('pending-status-select');
return;
}
el.setAttribute('data-desired', next);
el.classList.add('pending-status-select');
}, true); // capture to pre-empt stray handlers
/* Bulk Save & Generate button */
document.getElementById('bulkSaveGenerateButton')?.addEventListener('click', async () => {
const btn = document.getElementById('bulkSaveGenerateButton');
const prog = document.getElementById('bulkProgress');
if (!btn) return;
btn.disabled = true;
if (prog) prog.classList.remove('d-none');
// Collect tasks: rows where status is 'admission under review' and a class is selected
const assigns = Array.from(document.querySelectorAll('.assign-class-select'));
const tasks = [];
const parentSet = new Set();
assigns.forEach(sel => {
const tr = sel.closest('tr');
const statusSelect = tr?.querySelector('select.enrollment-status');
const prev = norm(statusSelect?.getAttribute('data-prev') || '');
const desired = norm(statusSelect?.getAttribute('data-desired') || statusSelect?.value || '');
const classId = sel.value;
// Only include rows explicitly set to move to payment pending with a class selected
if (prev === 'admission under review' && desired === 'payment pending' && classId) {
const studentId = sel.getAttribute('data-student-id');
const parentId = sel.getAttribute('data-parent-id');
const className = sel.options[sel.selectedIndex]?.text || '';
tasks.push({ tr, studentId, parentId, classId, className });
parentSet.add(parentId);
}
});
// Collect standalone status updates (not AUR->payment pending)
const statusTasks = [];
const shouldInvoice = new Set(['payment pending','enrolled','withdrawn','refund pending','withdraw under review']);
document.querySelectorAll('select.enrollment-status').forEach(se => {
const tr = se.closest('tr');
const prev = norm(se.getAttribute('data-prev') || '');
const desired = norm(se.getAttribute('data-desired') || '');
if (!desired || desired === prev) return;
if (prev === 'admission under review' && desired === 'payment pending') return; // handled by tasks
const parentId = se.getAttribute('data-parent-id') || tr?.getAttribute('data-parent-id') || '';
statusTasks.push({ tr, studentId: se.getAttribute('data-student-id'), desired, parentId });
if (parentId && shouldInvoice.has(desired)) parentSet.add(parentId);
});
if (tasks.length === 0 && statusTasks.length === 0) {
if (prog) prog.classList.add('d-none');
btn.disabled = false;
alert('Nothing to process. Set desired statuses, and for payment pending also select a class. Then click Save & Generate Invoice.');
return;
}
async function assignClass(t) {
const body = new URLSearchParams();
body.append('student_id', t.studentId);
body.append('parent_id', t.parentId);
body.append('class_section_id', t.classId);
body.append(CSRF_TOKEN_NAME, CSRF_HASH);
const resp = await fetch('<?= site_url('assign_class_student') ?>', {
method: 'POST', headers: { 'Accept': 'application/json,text/html', 'X-Requested-With': 'XMLHttpRequest' }, body
});
let data = null; try { data = await resp.json(); } catch {}
if (data) updateCsrfFromResponse(null, data);
if (!resp.ok || (data && data.ok === false)) throw new Error(data?.message || 'Assign failed');
}
function setPaymentPendingUI(t) {
if (dt) {
const r = dt.row(t.tr);
const rowData = r.data();
rowData[STATUS_COL_INDEX] = '<span class="badge bg-warning text-dark">payment pending</span>';
rowData[STATUS_SELECT_COL_INDEX] = buildStatusSelectHtml(t.studentId, t.parentId, 'payment pending');
rowData[ASSIGN_COL_INDEX] = '<span class="text-muted">—</span>';
r.data(rowData).draw(false);
} else {
const statusBadgeCell = $(t.tr).find('td').eq(STATUS_COL_INDEX);
statusBadgeCell.html('<span class="badge bg-warning text-dark">payment pending</span>');
const statusSelect = t.tr.querySelector('select.enrollment-status');
if (statusSelect) { statusSelect.value = 'payment pending'; statusSelect.setAttribute('data-prev', 'payment pending'); }
const cells = t.tr.querySelectorAll('td');
const assignCell = cells[ASSIGN_COL_INDEX];
if (assignCell) assignCell.innerHTML = '<span class="text-muted">—</span>';
}
}
async function updateClassCell(t) {
if (dt && CLASS_COL_INDEX >= 0) {
const r = dt.row(t.tr);
const rowData = r.data();
rowData[CLASS_COL_INDEX] = $('<div>').text(t.className).html();
r.data(rowData).draw(false);
} else {
$(t.tr).find('td').eq(CLASS_COL_INDEX).text(t.className);
}
}
const errors = [];
const changedIds = new Set();
const batchUpdates = [];
for (const t of tasks) {
try {
await assignClass(t);
await updateClassCell(t);
setPaymentPendingUI(t);
// clear desired on status select
const sel = t.tr.querySelector('select.enrollment-status');
if (sel) { sel.removeAttribute('data-desired'); sel.classList.remove('pending-status-select'); }
showRowToast(t.tr, 'Saved.');
changedIds.add(String(t.studentId));
batchUpdates.push({ studentId: t.studentId, status: 'payment pending' });
} catch (e) {
errors.push({ t, error: e });
showRowToast(t.tr, e?.message || 'Failed', false);
}
}
// Apply standalone status changes
for (const s of statusTasks) {
try {
if (dt) {
const r = dt.row(s.tr);
const rowData = r.data();
rowData[STATUS_COL_INDEX] = badgeForStatusJs(s.desired);
rowData[STATUS_SELECT_COL_INDEX] = buildStatusSelectHtml(s.studentId, s.tr.getAttribute('data-parent-id') || '', s.desired);
if (norm(s.desired) === 'admission under review') {
// leave current assign select as-is
} else {
rowData[ASSIGN_COL_INDEX] = '<span class="text-muted">—</span>';
}
r.data(rowData).draw(false);
} else {
const statusBadgeCell = $(s.tr).find('td').eq(STATUS_COL_INDEX);
statusBadgeCell.html(badgeForStatusJs(s.desired));
const statusSelect = s.tr.querySelector('select.enrollment-status');
if (statusSelect) {
statusSelect.value = s.desired;
statusSelect.setAttribute('data-prev', s.desired);
statusSelect.removeAttribute('data-desired');
statusSelect.classList.remove('pending-status-select');
}
if (norm(s.desired) !== 'admission under review') {
const cells = s.tr.querySelectorAll('td');
const assignCell = cells[ASSIGN_COL_INDEX];
if (assignCell) assignCell.innerHTML = '<span class="text-muted">—</span>';
}
}
showRowToast(s.tr, 'Status updated.');
changedIds.add(String(s.studentId));
batchUpdates.push({ studentId: s.studentId, status: s.desired });
} catch (e) {
errors.push({ s, error: e });
showRowToast(s.tr, e?.message || 'Failed', false);
}
}
// Perform one batch status update to trigger grouped emails per parent
try {
await updateEnrollmentStatusesBatch(batchUpdates);
} catch (e) {
errors.push({ error: e });
}
// Generate one invoice per parent AFTER statuses are updated (so admission_status is accepted)
for (const pid of parentSet) {
try {
const body = new URLSearchParams();
body.append('parent_id', pid);
body.append(CSRF_TOKEN_NAME, CSRF_HASH);
const resp2 = await fetch('<?= site_url('api/invoices/generate') ?>', { method: 'POST', headers: { 'Accept': 'application/json,text/html', 'X-Requested-With': 'XMLHttpRequest' }, body });
if (resp2.headers.get('content-type')?.includes('application/json')) {
const j = await resp2.json().catch(() => ({}));
updateCsrfFromResponse(null, j);
}
// Fallback: refresh CSRF from cookie in case token rotated
updateCsrfFromResponse(null, {});
} catch (_) { /* non-fatal */ }
}
if (prog) prog.classList.add('d-none');
btn.disabled = false;
if (errors.length) {
showToast(`${errors.length} row(s) failed. Hover red rows for details.`, false);
} else if (changedIds.size > 0) {
showToast(`${changedIds.size} row(s) updated successfully.`);
} else {
showToast('No changes applied.', false);
}
// Final refresh from API for changed rows to ensure full consistency
await refreshRowsByIds(Array.from(changedIds));
});
</script>
<script>
// API endpoint for refresh (not initial hydration)
const ENROLL_API_ENDPOINT = <?= json_encode(site_url('api/admin/enrollment-withdrawal')) ?>;
const SELECTED_YEAR = <?= json_encode((string)($selectedYear ?? $currentYear ?? '')) ?>;
const IS_CURRENT_YEAR = <?= json_encode((bool)($isCurrentYear ?? (isset($selectedYear,$currentYear) ? ((string)$selectedYear === (string)$currentYear) : true))) ?>;
const enrollApiUrl = () => SELECTED_YEAR ? (ENROLL_API_ENDPOINT + '?schoolYear=' + encodeURIComponent(SELECTED_YEAR)) : ENROLL_API_ENDPOINT;
function buildStatusSelectHtml(studentId, parentId, current) {
const opts = [
'admission under review','payment pending','enrolled','withdraw under review','refund pending','withdrawn','waitlist','denied'
];
let html = `<select name="enrollment_status[${studentId}]" class="form-control enrollment-status" ${IS_CURRENT_YEAR ? '' : 'disabled'} data-student-id="${studentId}" data-parent-id="${parentId}" data-prev="${esc(current)}">`;
for (const o of opts) {
const sel = (norm(current) === norm(o)) ? ' selected' : '';
html += `<option value="${esc(o)}"${sel}>${esc(o)}</option>`;
}
html += `</select>`;
return html;
}
function buildAssignSelectHtml(studentId, parentId, classes) {
let html = `<select class=\"form-select form-select-sm assign-class-select\" ${IS_CURRENT_YEAR ? '' : 'disabled'} data-student-id=\"${studentId}\" data-parent-id=\"${parentId}\">`;
html += `<option value=\"\">— Select class section —</option>`;
(classes || []).forEach(c => {
const val = c.class_section_id ?? c.id;
const text = c.class_section_name ?? (`Section ${val}`);
html += `<option value=\"${esc(String(val))}\">${esc(String(text))}</option>`;
});
html += `</select>`;
return html;
}
async function refreshRowsByIds(studentIds) {
if (!studentIds || !studentIds.length) return;
try {
const res = await fetch(enrollApiUrl(), { credentials: 'same-origin' });
const data = await res.json();
if (!res.ok || data?.error) return;
const byId = {};
(data.students || []).forEach(st => { byId[String(st.id || st.student_id)] = st; });
for (const id of studentIds) {
const st = byId[String(id)];
if (!st) continue;
const tr = document.querySelector(`tr[data-student-id=\"${id}\"]`);
if (!tr) continue;
if (dt) {
const r = dt.row(tr);
const rowData = r.data();
// Update class and status
rowData[CLASS_COL_INDEX] = esc(st.class_section || 'Class not Assigned');
rowData[STATUS_COL_INDEX] = badgeForStatusJs(st.enrollment_status || '');
rowData[STATUS_SELECT_COL_INDEX] = buildStatusSelectHtml(id, tr.getAttribute('data-parent-id') || '', st.enrollment_status || '');
if (norm(st.enrollment_status || '') === 'admission under review') {
rowData[ASSIGN_COL_INDEX] = buildAssignSelectHtml(id, tr.getAttribute('data-parent-id') || '', data.classes || []);
} else {
rowData[ASSIGN_COL_INDEX] = '<span class="text-muted">—</span>';
}
r.data(rowData).draw(false);
} else {
// Fallback DOM updates
$(tr).find('td').eq(CLASS_COL_INDEX).text(st.class_section || 'Class not Assigned');
$(tr).find('td').eq(STATUS_COL_INDEX).html(badgeForStatusJs(st.enrollment_status || ''));
}
}
} catch (_) { /* ignore */ }
}
</script>
<?= $this->endSection() ?>