835 lines
43 KiB
PHP
835 lines
43 KiB
PHP
<?= $this->extend('layout/management_layout') ?>
|
|
<?= $this->section('content') ?>
|
|
<div class="container-fluid">
|
|
<div class="wrapper">
|
|
<div class="content"></div>
|
|
<h2 class="text-center mt-4 mb-3">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 & 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() ?>
|