430 lines
16 KiB
PHP
430 lines
16 KiB
PHP
<?= $this->extend('layout/management_layout') ?>
|
|
|
|
<?= $this->section('styles') ?>
|
|
<style>
|
|
#classesTable {
|
|
table-layout: fixed;
|
|
}
|
|
#classesTable th:nth-child(1),
|
|
#classesTable td:nth-child(1) {
|
|
width: 10rem;
|
|
}
|
|
#classesTable th:nth-child(2),
|
|
#classesTable td:nth-child(2),
|
|
#classesTable th:nth-child(3),
|
|
#classesTable td:nth-child(3) {
|
|
width: 6rem;
|
|
}
|
|
#classesTable th:nth-child(4),
|
|
#classesTable td:nth-child(4) {
|
|
width: 10rem;
|
|
}
|
|
.distribution-cell {
|
|
min-width: 28rem;
|
|
}
|
|
.distribution-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
|
gap: .5rem;
|
|
}
|
|
.distribution-section {
|
|
border: 1px solid #dee2e6;
|
|
border-radius: .375rem;
|
|
background: #fff;
|
|
padding: .5rem;
|
|
}
|
|
.distribution-section-title {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: .5rem;
|
|
margin-bottom: .25rem;
|
|
}
|
|
.distribution-meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: .25rem;
|
|
margin-bottom: .35rem;
|
|
}
|
|
.distribution-meta .badge {
|
|
font-weight: 500;
|
|
}
|
|
.distribution-students {
|
|
max-height: 8.5rem;
|
|
overflow: auto;
|
|
margin: 0;
|
|
padding-left: 1.1rem;
|
|
font-size: .8125rem;
|
|
line-height: 1.35;
|
|
}
|
|
.distribution-empty {
|
|
color: #6c757d;
|
|
font-size: .875rem;
|
|
}
|
|
</style>
|
|
<?= $this->endSection() ?>
|
|
|
|
<?= $this->section('content') ?>
|
|
<div class="container-fluid">
|
|
<div class="wrapper">
|
|
<div class="content">
|
|
<h2 class="text-center mt-4 mb-3">Auto-Distribute Students into Sections</h2>
|
|
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="row g-3 align-items-end mb-2">
|
|
<div class="col-md-2">
|
|
<label for="sectionCount" class="form-label">Number of Sections</label>
|
|
<input type="number" min="1" id="sectionCount" class="form-control" placeholder="e.g. 2" />
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label for="minStudents" class="form-label">Minimum Students</label>
|
|
<input type="number" min="1" id="minStudents" class="form-control" placeholder="e.g. 20" />
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label for="maxStudents" class="form-label">Maximum Students</label>
|
|
<input type="number" min="1" id="maxStudents" class="form-control" placeholder="Optional" />
|
|
</div>
|
|
<div class="col-md-3 d-flex gap-2">
|
|
<button type="button" id="refreshTotalsBtn" class="btn btn-outline-secondary">Refresh Totals</button>
|
|
<button type="button" id="generateAllBtn" class="btn btn-primary">Generate All</button>
|
|
</div>
|
|
<div class="col-md-3 text-end">
|
|
<span id="pageMsg" class="small text-muted"></span>
|
|
</div>
|
|
</div>
|
|
<div class="row g-3 align-items-end mb-3">
|
|
<div class="col-md-4">
|
|
<label for="additionalClassSelect" class="form-label">Additional Classes</label>
|
|
<select id="additionalClassSelect" class="form-select">
|
|
<option value="">Select class to include...</option>
|
|
<?php if (!empty($classes)): ?>
|
|
<?php foreach ($classes as $c): ?>
|
|
<?php
|
|
$name = trim((string)($c['class_section_name'] ?? ''));
|
|
$isBase = ($name !== '' && strpos($name, '-') === false);
|
|
$lowerName = strtolower($name);
|
|
$isStandard = ($lowerName === 'kg' || $lowerName === 'youth' || (ctype_digit($lowerName) && (int)$lowerName >= 1 && (int)$lowerName <= 9));
|
|
if (!$isBase || $isStandard) continue;
|
|
?>
|
|
<option value="<?= (int)($c['class_id'] ?? 0) ?>"><?= esc($name) ?></option>
|
|
<?php endforeach; ?>
|
|
<?php endif; ?>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2 d-grid">
|
|
<button type="button" id="addClassBtn" class="btn btn-outline-primary">Add Class</button>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div id="includedClasses" class="d-flex flex-wrap gap-2"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="table-responsive">
|
|
<table class="table table-sm table-striped align-middle no-mgmt-sticky" id="classesTable">
|
|
<thead class="table-light">
|
|
<tr id="tblHeader">
|
|
<th>Class</th>
|
|
<th>Total</th>
|
|
<th>Sections</th>
|
|
<th>Actions</th>
|
|
<th>Distribution</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="tblBody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
<?= $this->endSection() ?>
|
|
|
|
<?= $this->section('scripts') ?>
|
|
<input type="hidden" id="csrfName" value="<?= esc(csrf_token()) ?>" />
|
|
<input type="hidden" id="csrfValue" value="<?= esc(csrf_hash()) ?>" />
|
|
<script>
|
|
(function(){
|
|
const selectedYear = '<?= esc($selectedYear ?? '') ?>';
|
|
const totalsBaseUrl = '<?= site_url('administrator/sections/promotion-totals') ?>';
|
|
const distUrl = '<?= site_url('administrator/sections/auto-distribute') ?>';
|
|
|
|
const tblBody = document.getElementById('tblBody');
|
|
const sectionCountInput = document.getElementById('sectionCount');
|
|
const minInput = document.getElementById('minStudents');
|
|
const maxInput = document.getElementById('maxStudents');
|
|
const msgEl = document.getElementById('pageMsg');
|
|
const refreshBtn= document.getElementById('refreshTotalsBtn');
|
|
const additionalClassSelect = document.getElementById('additionalClassSelect');
|
|
const addClassBtn = document.getElementById('addClassBtn');
|
|
const includedClassesEl = document.getElementById('includedClasses');
|
|
|
|
let rowIndexByClassId = {}; // mapping to locate rows
|
|
let includedClassIds = [];
|
|
|
|
function totalsUrl() {
|
|
const params = new URLSearchParams();
|
|
params.set('school_year', selectedYear);
|
|
if (includedClassIds.length) params.set('include_class_ids', includedClassIds.join(','));
|
|
return totalsBaseUrl + '?' + params.toString();
|
|
}
|
|
|
|
function selectedSectionCount() {
|
|
const count = parseInt(sectionCountInput.value || '0', 10);
|
|
return count > 0 ? count : '';
|
|
}
|
|
|
|
function buildInitialTable(rows) {
|
|
tblBody.innerHTML = '';
|
|
rowIndexByClassId = {};
|
|
|
|
rows.forEach(function(r){
|
|
const tr = document.createElement('tr');
|
|
tr.dataset.classId = r.class_id;
|
|
tr.dataset.classSectionId = r.class_section_id;
|
|
tr.dataset.className = r.class_section_name || '';
|
|
|
|
const tdName = document.createElement('td');
|
|
tdName.textContent = r.class_section_name || '';
|
|
tr.appendChild(tdName);
|
|
|
|
const tdTotal = document.createElement('td');
|
|
tdTotal.className = 'text-end';
|
|
tdTotal.textContent = r.total;
|
|
tr.appendChild(tdTotal);
|
|
|
|
const tdNeed = document.createElement('td');
|
|
tdNeed.className = 'text-end need-cell';
|
|
tdNeed.textContent = selectedSectionCount();
|
|
tr.appendChild(tdNeed);
|
|
|
|
const tdAct = document.createElement('td');
|
|
const btn = document.createElement('button');
|
|
btn.className = 'btn btn-sm btn-primary';
|
|
btn.textContent = 'Generate Sections';
|
|
btn.addEventListener('click', function(){ runDistribution(r.class_section_id, r.class_section_name); });
|
|
tdAct.appendChild(btn);
|
|
tr.appendChild(tdAct);
|
|
|
|
const tdResults = document.createElement('td');
|
|
tdResults.className = 'distribution-cell';
|
|
const empty = document.createElement('span');
|
|
empty.className = 'distribution-empty';
|
|
empty.textContent = 'Not generated';
|
|
tdResults.appendChild(empty);
|
|
tr.appendChild(tdResults);
|
|
|
|
tblBody.appendChild(tr);
|
|
rowIndexByClassId[r.class_id] = tr;
|
|
});
|
|
|
|
rows.forEach(function(r){
|
|
if (Array.isArray(r.sections) && r.sections.length) {
|
|
renderSectionsForRow(r.class_section_id, r.sections);
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateNeeds() {
|
|
document.querySelectorAll('#tblBody tr').forEach(function(tr){
|
|
const needCell = tr.querySelector('.need-cell');
|
|
if (needCell) needCell.textContent = selectedSectionCount();
|
|
});
|
|
}
|
|
|
|
function runDistribution(baseSectionId, baseName) {
|
|
const sectionCount = parseInt(sectionCountInput.value || '0', 10);
|
|
const minStudents = parseInt(minInput.value || '0', 10);
|
|
const maxStudents = parseInt(maxInput.value || '0', 10);
|
|
if (!sectionCount || sectionCount <= 0 || !minStudents || minStudents <= 0) {
|
|
msgEl.textContent = 'Enter number of sections and minimum students first.';
|
|
return;
|
|
}
|
|
|
|
msgEl.textContent = 'Distributing ' + (baseName || '') + '...';
|
|
const fd = new FormData();
|
|
fd.append('class_section_id', String(baseSectionId));
|
|
fd.append('section_count', String(sectionCount));
|
|
fd.append('min_students_per_section', String(minStudents));
|
|
if (maxStudents > 0) fd.append('max_students_per_section', String(maxStudents));
|
|
fd.append('school_year', selectedYear);
|
|
// CSRF
|
|
const csrfNameEl = document.getElementById('csrfName');
|
|
const csrfValueEl= document.getElementById('csrfValue');
|
|
if (csrfNameEl && csrfValueEl) {
|
|
fd.append(csrfNameEl.value, csrfValueEl.value);
|
|
}
|
|
return fetch(distUrl, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: fd })
|
|
.then(r => r.json())
|
|
.then(res => {
|
|
// refresh CSRF if provided
|
|
if (res && res.csrfTokenName && res.csrfHash) {
|
|
if (csrfNameEl) csrfNameEl.value = res.csrfTokenName;
|
|
if (csrfValueEl) csrfValueEl.value = res.csrfHash;
|
|
}
|
|
|
|
if (!res || !res.ok || !Array.isArray(res.sections)) {
|
|
msgEl.textContent = res && res.message ? res.message : 'Distribution finished with no sections.';
|
|
return;
|
|
}
|
|
|
|
msgEl.textContent = res && res.message ? res.message : 'Completed.';
|
|
|
|
renderSectionsForRow(baseSectionId, res.sections);
|
|
})
|
|
.catch(() => { msgEl.textContent = 'Failed to distribute. Please try again.'; });
|
|
}
|
|
|
|
function renderSectionsForRow(baseSectionId, sections) {
|
|
const tr = Array.from(document.querySelectorAll('#tblBody tr')).find(function(_tr){
|
|
return String(_tr.dataset.classSectionId || '') === String(baseSectionId || '');
|
|
});
|
|
if (!tr) return;
|
|
|
|
const td = tr.querySelector('.distribution-cell');
|
|
if (!td) return;
|
|
td.innerHTML = '';
|
|
|
|
const grid = document.createElement('div');
|
|
grid.className = 'distribution-grid';
|
|
|
|
sections.forEach(function(s){
|
|
const studentNames = Array.isArray(s.student_names) ? s.student_names : [];
|
|
const block = document.createElement('div');
|
|
block.className = 'distribution-section';
|
|
|
|
const title = document.createElement('div');
|
|
title.className = 'distribution-section-title';
|
|
|
|
const name = document.createElement('div');
|
|
name.className = 'fw-semibold';
|
|
name.textContent = s.class_section_name || 'Section';
|
|
title.appendChild(name);
|
|
|
|
const count = document.createElement('div');
|
|
count.className = 'badge bg-primary';
|
|
count.textContent = (s.total ?? studentNames.length) + ' students';
|
|
title.appendChild(count);
|
|
block.appendChild(title);
|
|
|
|
const meta = document.createElement('div');
|
|
meta.className = 'distribution-meta';
|
|
if (Number.isFinite(Number(s.average_score))) {
|
|
const avg = document.createElement('span');
|
|
avg.className = 'badge text-bg-light';
|
|
avg.textContent = 'Avg ' + Number(s.average_score).toFixed(2);
|
|
meta.appendChild(avg);
|
|
}
|
|
if (Number.isFinite(Number(s.male)) || Number.isFinite(Number(s.female))) {
|
|
const gender = document.createElement('span');
|
|
gender.className = 'badge text-bg-light';
|
|
gender.textContent = 'M ' + (s.male ?? 0) + ' / F ' + (s.female ?? 0);
|
|
meta.appendChild(gender);
|
|
}
|
|
if (meta.children.length) block.appendChild(meta);
|
|
|
|
if (studentNames.length) {
|
|
const list = document.createElement('ol');
|
|
list.className = 'distribution-students';
|
|
studentNames.forEach(function(studentName){
|
|
const item = document.createElement('li');
|
|
item.textContent = studentName;
|
|
list.appendChild(item);
|
|
});
|
|
block.appendChild(list);
|
|
} else {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'distribution-empty';
|
|
empty.textContent = 'No students assigned';
|
|
block.appendChild(empty);
|
|
}
|
|
|
|
grid.appendChild(block);
|
|
});
|
|
|
|
td.appendChild(grid);
|
|
}
|
|
|
|
function loadTotals() {
|
|
msgEl.textContent = 'Loading totals...';
|
|
fetch(totalsUrl(), { headers: { 'X-Requested-With': 'XMLHttpRequest' }})
|
|
.then(r => r.json())
|
|
.then(res => {
|
|
if (!res || !res.ok) { msgEl.textContent = res && res.message ? res.message : 'Failed to load totals.'; return; }
|
|
buildInitialTable(res.rows || []);
|
|
updateNeeds();
|
|
msgEl.textContent = '';
|
|
})
|
|
.catch(() => { msgEl.textContent = 'Failed to load totals.'; });
|
|
}
|
|
|
|
function renderIncludedClasses() {
|
|
includedClassesEl.innerHTML = '';
|
|
includedClassIds.forEach(function(classId){
|
|
const option = additionalClassSelect.querySelector('option[value="' + String(classId) + '"]');
|
|
const badge = document.createElement('span');
|
|
badge.className = 'badge bg-secondary d-inline-flex align-items-center gap-2';
|
|
|
|
const label = document.createElement('span');
|
|
label.textContent = option ? option.textContent : ('Class #' + classId);
|
|
badge.appendChild(label);
|
|
|
|
const removeBtn = document.createElement('button');
|
|
removeBtn.type = 'button';
|
|
removeBtn.className = 'btn-close btn-close-white';
|
|
removeBtn.setAttribute('aria-label', 'Remove class');
|
|
removeBtn.addEventListener('click', function(){
|
|
includedClassIds = includedClassIds.filter(id => id !== classId);
|
|
renderIncludedClasses();
|
|
loadTotals();
|
|
});
|
|
badge.appendChild(removeBtn);
|
|
|
|
includedClassesEl.appendChild(badge);
|
|
});
|
|
}
|
|
|
|
addClassBtn.addEventListener('click', function(){
|
|
const classId = parseInt(additionalClassSelect.value || '0', 10);
|
|
if (!classId) {
|
|
msgEl.textContent = 'Select a class to include.';
|
|
return;
|
|
}
|
|
if (includedClassIds.indexOf(classId) < 0) {
|
|
includedClassIds.push(classId);
|
|
renderIncludedClasses();
|
|
}
|
|
additionalClassSelect.value = '';
|
|
loadTotals();
|
|
});
|
|
|
|
refreshBtn.addEventListener('click', function(){ loadTotals(); });
|
|
document.getElementById('generateAllBtn').addEventListener('click', async function(){
|
|
const sectionCount = parseInt(sectionCountInput.value || '0', 10);
|
|
const minStudents = parseInt(minInput.value || '0', 10);
|
|
if (!sectionCount || sectionCount <= 0 || !minStudents || minStudents <= 0) {
|
|
msgEl.textContent = 'Enter number of sections and minimum students first.';
|
|
return;
|
|
}
|
|
// Collect base sections from rows
|
|
const rows = Array.from(document.querySelectorAll('#tblBody tr'))
|
|
.map(tr => ({ id: tr.dataset.classSectionId, name: tr.dataset.className }))
|
|
.filter(r => r.id);
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const r = rows[i];
|
|
msgEl.textContent = 'Distributing ' + (r.name || r.id) + ' (' + (i+1) + '/' + rows.length + ')...';
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await runDistribution(r.id, r.name);
|
|
}
|
|
msgEl.textContent = 'All distributions completed.';
|
|
});
|
|
sectionCountInput.addEventListener('input', function(){ updateNeeds(); });
|
|
|
|
loadTotals();
|
|
})();
|
|
</script>
|
|
<?= $this->endSection() ?>
|