Files
2026-08-15 15:07:16 -04:00

1055 lines
39 KiB
PHP

<?= $this->extend('layout/management_layout') ?>
<?= $this->section('styles') ?>
<style>
#classesTable {
table-layout: auto;
}
#classesTable th:nth-child(1),
#classesTable td:nth-child(1) {
width: 1%;
white-space: nowrap;
}
#classesTable th:nth-child(2),
#classesTable td:nth-child(2) {
width: 1%;
white-space: nowrap;
}
#classesTable th:nth-child(3),
#classesTable td:nth-child(3) {
min-width: 34rem;
}
.distribution-cell {
min-width: 34rem;
}
.distribution-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: .5rem;
}
.distribution-class-roster {
border: 1px solid #ced4da;
border-radius: .375rem;
background: #f8f9fa;
padding: .5rem;
margin-bottom: .5rem;
}
.distribution-class-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: .5rem;
margin-bottom: .35rem;
}
.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: 0;
list-style: none;
counter-reset: distribution-student;
font-size: .8125rem;
line-height: 1.35;
}
.distribution-students li {
counter-increment: distribution-student;
}
.distribution-class-roster .distribution-students {
max-height: 14rem;
}
.distribution-students-wrap {
min-width: 0;
}
.distribution-students-header {
display: grid;
grid-template-columns: 2.25rem minmax(12rem, 1fr) 3.5rem 5rem minmax(8rem, 10rem) minmax(12rem, 16rem);
gap: .5rem;
padding: 0 .25rem .2rem 0;
font-size: .72rem;
font-weight: 700;
color: #6c757d;
text-transform: uppercase;
}
.distribution-students-header span:nth-child(3) {
text-align: end;
font-variant-numeric: tabular-nums;
}
.distribution-student-row {
display: grid;
grid-template-columns: 2.25rem minmax(12rem, 1fr) 3.5rem 5rem minmax(8rem, 10rem) minmax(12rem, 16rem);
gap: .5rem;
align-items: center;
margin-bottom: .25rem;
}
.student-row-number {
color: #6c757d;
text-align: end;
font-variant-numeric: tabular-nums;
}
.student-row-number::before {
content: counter(distribution-student) ". ";
}
.student-age-cell {
text-align: end;
color: #495057;
font-variant-numeric: tabular-nums;
}
.student-gender-cell {
color: #495057;
}
.student-last-year-cell {
color: #495057;
min-width: 0;
}
.student-assignment-cell {
min-width: 0;
}
.student-assignment-cell .form-select {
display: inline-block;
width: fit-content;
min-width: 5rem;
max-width: 100%;
}
.distribution-empty {
color: #6c757d;
font-size: .875rem;
}
.distribution-search-summary {
min-height: 1.25rem;
}
</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">Max 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">Min Students Per Section</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 <= 10));
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="row g-3 align-items-end mb-3">
<div class="col-md-4">
<label for="studentSearch" class="form-label">Search Students</label>
<input type="search" id="studentSearch" class="form-control" placeholder="Search by student name..." autocomplete="off" />
</div>
<div class="col-md-8">
<div id="studentSearchSummary" class="small text-muted distribution-search-summary"></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>Distribution</th>
</tr>
</thead>
<tbody id="tblBody"></tbody>
<tfoot class="table-light">
<tr>
<th>Total</th>
<th class="text-end" id="allStudentsTotal">0</th>
<th></th>
</tr>
</tfoot>
</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 updateDraftUrl = '<?= site_url('administrator/sections/distribution-draft/update') ?>';
const updateCandidateUrl = '<?= site_url('administrator/sections/distribution-candidate/update') ?>';
const classSections = <?= json_encode(array_values(array_map(static function ($c) {
return [
'class_id' => (int)($c['class_id'] ?? 0),
'class_section_id' => (int)($c['class_section_id'] ?? 0),
'class_section_name' => (string)($c['class_section_name'] ?? ''),
];
}, $classes ?? [])), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
const tblBody = document.getElementById('tblBody');
const allStudentsTotal = document.getElementById('allStudentsTotal');
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');
const studentSearchInput = document.getElementById('studentSearch');
const studentSearchSummary = document.getElementById('studentSearchSummary');
let rowIndexByClassId = {}; // mapping to locate rows
let includedClassIds = [];
const baseClasses = buildBaseClassOptions();
const sectionsByClassId = buildSectionsByClassId();
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 buildBaseClassOptions() {
const seen = {};
return classSections
.filter(row => row.class_id > 0 && row.class_section_name && row.class_section_name.indexOf('-') < 0)
.filter(function(row){
if (seen[row.class_id]) return false;
seen[row.class_id] = true;
return true;
})
.sort((a, b) => String(a.class_section_name).localeCompare(String(b.class_section_name), undefined, { numeric: true }));
}
function buildSectionsByClassId() {
const out = {};
classSections.forEach(function(row){
if (!row.class_id || !row.class_section_id || !row.class_section_name || row.class_section_name.indexOf('-') < 0) return;
if (!out[row.class_id]) out[row.class_id] = [];
out[row.class_id].push({
id: row.class_section_id,
name: row.class_section_name
});
});
Object.keys(out).forEach(function(classId){
out[classId].sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { numeric: true }));
});
return out;
}
function classNameById(classId) {
const row = baseClasses.find(c => c.class_id === parseInt(classId || '0', 10));
return row ? row.class_section_name : ('Class #' + classId);
}
function isStandardBaseClassName(name) {
const normalized = String(name || '').trim().toLowerCase();
if (normalized === 'kg' || normalized === 'youth') return true;
if (!/^\d+$/.test(normalized)) return false;
const n = parseInt(normalized, 10);
return n >= 1 && n <= 10;
}
function ensureIncludedClassVisible(classId) {
classId = parseInt(classId || '0', 10);
if (!classId) return;
const className = classNameById(classId);
if (isStandardBaseClassName(className)) return;
if (includedClassIds.indexOf(classId) < 0) {
includedClassIds.push(classId);
renderIncludedClasses();
}
}
function populateDestinationSelect(select, selectedSectionId, selectedClassId) {
select.innerHTML = '';
selectedClassId = parseInt(selectedClassId || '0', 10);
let resolvedClassId = 0;
baseClasses.forEach(function(baseClass){
const classOption = document.createElement('option');
classOption.value = String(baseClass.class_section_id);
classOption.dataset.classId = String(baseClass.class_id);
classOption.textContent = baseClass.class_section_name;
if (baseClass.class_section_id === parseInt(selectedSectionId || '0', 10) || (!parseInt(selectedSectionId || '0', 10) && selectedClassId === baseClass.class_id)) {
classOption.selected = true;
resolvedClassId = baseClass.class_id;
}
select.appendChild(classOption);
const sections = sectionsByClassId[String(baseClass.class_id)] || [];
sections.forEach(function(section){
const opt = document.createElement('option');
opt.value = String(section.id);
opt.dataset.classId = String(baseClass.class_id);
opt.textContent = section.name;
if (section.id === parseInt(selectedSectionId || '0', 10)) {
opt.selected = true;
resolvedClassId = baseClass.class_id;
}
select.appendChild(opt);
});
});
if (!resolvedClassId && selectedClassId > 0) {
const firstSection = (sectionsByClassId[String(selectedClassId)] || [])[0] || null;
if (firstSection) {
select.value = String(firstSection.id);
resolvedClassId = selectedClassId;
} else {
const baseClass = baseClasses.find(c => c.class_id === selectedClassId) || null;
if (baseClass) {
select.value = String(baseClass.class_section_id);
resolvedClassId = selectedClassId;
}
}
}
const selectedOption = select.options[select.selectedIndex] || null;
if (!resolvedClassId && selectedOption) {
resolvedClassId = parseInt(selectedOption.dataset.classId || '0', 10);
}
return {
classId: resolvedClassId,
sectionId: selectedOption ? parseInt(selectedOption.value || '0', 10) : 0
};
}
function selectedDestination(select) {
const selectedOption = select && select.options ? select.options[select.selectedIndex] : null;
return {
classId: selectedOption ? parseInt(selectedOption.dataset.classId || '0', 10) : 0,
sectionId: selectedOption ? parseInt(selectedOption.value || '0', 10) : 0
};
}
function assignmentLabel(assignment) {
if (assignment && assignment.class_section_name) return assignment.class_section_name;
const classId = parseInt((assignment && assignment.class_id) || '0', 10);
return classId > 0 ? classNameById(classId) : '-';
}
function buildInitialTable(rows) {
tblBody.innerHTML = '';
rowIndexByClassId = {};
let allTotal = 0;
rows.forEach(function(r){
allTotal += parseInt(r.total || '0', 10) || 0;
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 tdResults = document.createElement('td');
tdResults.className = 'distribution-cell';
if (Array.isArray(r.students) && r.students.length) {
tdResults.appendChild(renderClassRoster(tr, r.students));
} else {
const empty = document.createElement('span');
empty.className = 'distribution-empty';
empty.textContent = 'No students';
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, r.students || []);
}
});
if (allStudentsTotal) {
allStudentsTotal.textContent = String(allTotal);
}
applyStudentSearch();
}
function runDistribution(baseSectionId, baseName) {
const sectionCount = parseInt(sectionCountInput.value || '0', 10);
const minStudents = parseInt(minInput.value || '0', 10);
const maxStudents = parseInt((maxInput && 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, unassignedAssignments) {
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 unassigned = Array.isArray(unassignedAssignments) ? unassignedAssignments : (tr._unassignedAssignments || []);
tr._unassignedAssignments = unassigned;
const allSections = Array.isArray(sections) ? sections.slice() : [];
const baseSections = allSections.filter(function(section){
const name = String(section.class_section_name || '');
return name === String(tr.dataset.className || '') || name.indexOf('-') < 0;
});
const visibleSections = allSections.filter(function(section){
return baseSections.indexOf(section) < 0;
});
const baseAssignments = assignmentsFromSections(baseSections);
const rosterAssignments = unassigned.concat(baseAssignments);
if (rosterAssignments.length) {
td.appendChild(renderClassRoster(tr, rosterAssignments));
}
const grid = document.createElement('div');
grid.className = 'distribution-grid';
const allAssignments = assignmentsFromSections(visibleSections);
if (!visibleSections.length && !rosterAssignments.length) {
td.appendChild(renderClassRoster(tr, allAssignments));
}
visibleSections.forEach(function(s){
const studentAssignments = Array.isArray(s.student_assignments) && s.student_assignments.length
? s.student_assignments.map(function(assignment){
return {
...assignment,
class_id: assignment.class_id || s.class_id,
class_section_id: assignment.class_section_id || s.class_section_id,
class_section_name: assignment.class_section_name || s.class_section_name || ''
};
})
: (Array.isArray(s.student_names) ? s.student_names : []).map(function(studentName){
return {
draft_id: 0,
student_id: 0,
student_name: studentName,
age_at_reference: null,
gender: '',
last_year_class_section: '',
class_id: s.class_id,
class_section_id: s.class_section_id,
class_section_name: s.class_section_name || ''
};
});
const studentNames = studentAssignments.map(a => a.student_name).filter(Boolean);
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) {
block.appendChild(renderAssignmentList(studentAssignments, true));
} else {
const empty = document.createElement('div');
empty.className = 'distribution-empty';
empty.textContent = 'No students assigned';
block.appendChild(empty);
}
grid.appendChild(block);
});
td.appendChild(grid);
renderAddSectionButton(td, tr, baseSectionId, visibleSections);
applyStudentSearch();
}
function assignmentsFromSections(sections) {
const out = [];
const seenDrafts = {};
const seenFallback = {};
sections.forEach(function(section){
const assignments = Array.isArray(section.student_assignments) && section.student_assignments.length
? section.student_assignments
: (Array.isArray(section.student_names) ? section.student_names : []).map(function(studentName, idx){
return {
draft_id: 0,
student_id: 0,
student_name: studentName,
age_at_reference: null,
gender: '',
last_year_class_section: '',
class_id: section.class_id,
class_section_id: section.class_section_id,
class_section_name: section.class_section_name,
fallback_key: String(section.class_section_id || '') + ':' + idx + ':' + String(studentName || '')
};
});
assignments.forEach(function(assignment){
const draftId = parseInt(assignment.draft_id || '0', 10);
if (draftId > 0) {
if (seenDrafts[draftId]) return;
seenDrafts[draftId] = true;
} else {
const key = assignment.fallback_key || String(assignment.class_section_id || section.class_section_id || '') + ':' + String(assignment.student_name || '');
if (seenFallback[key]) return;
seenFallback[key] = true;
}
out.push({
draft_id: draftId,
student_id: parseInt(assignment.student_id || '0', 10),
student_name: assignment.student_name || 'Student',
age_at_reference: assignment.age_at_reference ?? null,
gender: assignment.gender || '',
previous_final_score: assignment.previous_final_score ?? null,
last_year_class_section: assignment.last_year_class_section || '',
class_id: parseInt(assignment.class_id || section.class_id || '0', 10),
class_section_id: parseInt(assignment.class_section_id || section.class_section_id || '0', 10),
class_section_name: assignment.class_section_name || section.class_section_name || ''
});
});
});
out.sort((a, b) => String(a.student_name).localeCompare(String(b.student_name), undefined, { numeric: true }));
return out;
}
function assignmentStats(assignments) {
const stats = { male: 0, female: 0, scoreTotal: 0, scoreCount: 0 };
(Array.isArray(assignments) ? assignments : []).forEach(function(assignment){
const gender = String(assignment.gender || '').trim().toLowerCase();
if (gender === 'female' || gender === 'f') {
stats.female++;
} else if (gender === 'male' || gender === 'm') {
stats.male++;
}
const score = Number(assignment.previous_final_score);
if (Number.isFinite(score)) {
stats.scoreTotal += score;
stats.scoreCount++;
}
});
stats.averageScore = stats.scoreCount ? stats.scoreTotal / stats.scoreCount : null;
return stats;
}
function renderClassRoster(tr, assignments) {
const wrap = document.createElement('div');
wrap.className = 'distribution-class-roster';
const title = document.createElement('div');
title.className = 'distribution-class-title';
const name = document.createElement('div');
name.className = 'fw-semibold';
name.textContent = 'Students in ' + (tr.dataset.className || 'class');
title.appendChild(name);
const count = document.createElement('div');
count.className = 'badge bg-secondary';
count.textContent = assignments.length + ' students';
title.appendChild(count);
wrap.appendChild(title);
const stats = assignmentStats(assignments);
const meta = document.createElement('div');
meta.className = 'distribution-meta';
if (stats.averageScore !== null) {
const avg = document.createElement('span');
avg.className = 'badge text-bg-light';
avg.textContent = 'Avg ' + stats.averageScore.toFixed(2);
meta.appendChild(avg);
}
if (stats.male || stats.female) {
const gender = document.createElement('span');
gender.className = 'badge text-bg-light';
gender.textContent = 'M ' + stats.male + ' / F ' + stats.female;
meta.appendChild(gender);
}
if (meta.children.length) wrap.appendChild(meta);
if (!assignments.length) {
const empty = document.createElement('div');
empty.className = 'distribution-empty';
empty.textContent = 'No students assigned';
wrap.appendChild(empty);
return wrap;
}
wrap.appendChild(renderAssignmentList(assignments, true));
return wrap;
}
function renderAssignmentList(assignments, editable) {
const wrap = document.createElement('div');
wrap.className = 'distribution-students-wrap';
const header = document.createElement('div');
header.className = 'distribution-students-header';
['', 'Student', 'Age', 'Gender', 'Last Year', 'Assignment'].forEach(function(label){
const cell = document.createElement('span');
cell.textContent = label;
header.appendChild(cell);
});
wrap.appendChild(header);
const list = document.createElement('ol');
list.className = 'distribution-students';
const sortedAssignments = Array.isArray(assignments)
? assignments.slice().sort(function(a, b){
return String(a.student_name || '').localeCompare(String(b.student_name || ''), undefined, { numeric: true });
})
: [];
sortedAssignments.forEach(function(assignment){
const item = document.createElement('li');
item.dataset.searchText = [
assignment.student_name || '',
assignment.age_at_reference ?? '',
assignment.gender || '',
assignment.last_year_class_section || '',
assignmentLabel(assignment)
].join(' ').toLowerCase();
const row = document.createElement('div');
row.className = 'distribution-student-row';
const rowNumber = document.createElement('span');
rowNumber.className = 'student-row-number';
row.appendChild(rowNumber);
const studentLabel = document.createElement('span');
studentLabel.textContent = assignment.student_name || 'Student';
row.appendChild(studentLabel);
const ageCell = document.createElement('span');
ageCell.className = 'student-age-cell';
ageCell.textContent = assignment.age_at_reference === null || assignment.age_at_reference === undefined || assignment.age_at_reference === ''
? '-'
: assignment.age_at_reference;
row.appendChild(ageCell);
const genderCell = document.createElement('span');
genderCell.className = 'student-gender-cell';
genderCell.textContent = assignment.gender || '-';
row.appendChild(genderCell);
const lastYearCell = document.createElement('span');
lastYearCell.className = 'student-last-year-cell';
lastYearCell.textContent = assignment.last_year_class_section || '-';
row.appendChild(lastYearCell);
const assignmentCell = document.createElement('span');
assignmentCell.className = 'student-assignment-cell';
const draftId = parseInt(assignment.draft_id || '0', 10);
const studentId = parseInt(assignment.student_id || '0', 10);
if (editable && (draftId > 0 || studentId > 0) && baseClasses.length) {
const destinationSelect = document.createElement('select');
destinationSelect.className = 'form-select form-select-sm';
destinationSelect.setAttribute('aria-label', 'Change class and section for ' + (assignment.student_name || 'student'));
populateDestinationSelect(
destinationSelect,
parseInt(assignment.class_section_id || '0', 10),
parseInt(assignment.class_id || '0', 10)
);
destinationSelect.dataset.previousValue = destinationSelect.value;
destinationSelect.addEventListener('change', function(){
const target = selectedDestination(destinationSelect);
if (draftId > 0) {
updateDraftAssignment(draftId, target.classId, target.sectionId, destinationSelect);
} else {
updateCandidateAssignment(studentId, target.classId, target.sectionId, destinationSelect);
}
});
assignmentCell.appendChild(destinationSelect);
} else {
assignmentCell.textContent = assignmentLabel(assignment);
}
row.appendChild(assignmentCell);
item.appendChild(row);
list.appendChild(item);
});
wrap.appendChild(list);
return wrap;
}
function applyStudentSearch() {
const query = String((studentSearchInput && studentSearchInput.value) || '').trim().toLowerCase();
let totalStudents = 0;
let visibleStudents = 0;
document.querySelectorAll('.distribution-students li').forEach(function(item){
totalStudents++;
const matches = !query || String(item.dataset.searchText || '').indexOf(query) >= 0;
item.hidden = !matches;
if (matches) visibleStudents++;
});
document.querySelectorAll('.distribution-section, .distribution-class-roster').forEach(function(block){
const items = Array.from(block.querySelectorAll('.distribution-students li'));
block.hidden = !!query && items.length > 0 && !items.some(item => !item.hidden);
});
document.querySelectorAll('#tblBody tr').forEach(function(tr){
const items = Array.from(tr.querySelectorAll('.distribution-students li'));
tr.hidden = !!query && items.length > 0 && !items.some(item => !item.hidden);
});
if (studentSearchSummary) {
studentSearchSummary.textContent = query
? visibleStudents + ' of ' + totalStudents + ' students shown'
: '';
}
}
function renderAddSectionButton(container, tr, baseSectionId, visibleSections) {
const baseClassId = parseInt(tr.dataset.classId || '0', 10);
const allSections = sectionsByClassId[String(baseClassId)] || [];
const visibleSectionIds = visibleSections.map(section => parseInt(section.class_section_id || '0', 10));
const nextSection = allSections.find(section => visibleSectionIds.indexOf(section.id) < 0);
if (!nextSection) return;
const addWrap = document.createElement('div');
addWrap.className = 'mt-2';
const addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'btn btn-sm btn-outline-secondary';
addBtn.textContent = 'Add Section';
addBtn.addEventListener('click', function(){
const nextVisibleSections = visibleSections.concat([{
class_id: baseClassId,
class_section_id: nextSection.id,
class_section_name: nextSection.name,
total: 0,
student_names: [],
student_assignments: []
}]);
renderSectionsForRow(baseSectionId, nextVisibleSections, tr._unassignedAssignments || []);
});
addWrap.appendChild(addBtn);
container.appendChild(addWrap);
}
function updateDraftAssignment(draftId, classId, classSectionId, selectEl) {
if (!draftId || !classSectionId) {
msgEl.textContent = 'Select a valid target section.';
return;
}
const previousValue = selectEl ? selectEl.dataset.previousValue || selectEl.defaultValue || '' : '';
if (selectEl) selectEl.disabled = true;
const fd = new FormData();
fd.append('draft_id', String(draftId));
fd.append('class_section_id', String(classSectionId));
const csrfNameEl = document.getElementById('csrfName');
const csrfValueEl= document.getElementById('csrfValue');
if (csrfNameEl && csrfValueEl) {
fd.append(csrfNameEl.value, csrfValueEl.value);
}
msgEl.textContent = 'Updating draft assignment...';
fetch(updateDraftUrl, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: fd })
.then(r => r.json())
.then(res => {
if (res && res.csrfTokenName && res.csrfHash) {
if (csrfNameEl) csrfNameEl.value = res.csrfTokenName;
if (csrfValueEl) csrfValueEl.value = res.csrfHash;
}
if (!res || !res.ok) {
if (selectEl && previousValue) selectEl.value = previousValue;
msgEl.textContent = res && res.message ? res.message : 'Draft assignment could not be updated.';
return;
}
msgEl.textContent = res.message || 'Draft assignment updated.';
if (selectEl) selectEl.dataset.previousValue = String(classSectionId);
ensureIncludedClassVisible(classId);
loadTotals();
})
.catch(() => {
if (selectEl && previousValue) selectEl.value = previousValue;
msgEl.textContent = 'Draft assignment could not be updated.';
})
.finally(() => {
if (selectEl) selectEl.disabled = false;
});
}
function updateCandidateAssignment(studentId, classId, classSectionId, selectEl) {
if (!studentId || !classSectionId) {
msgEl.textContent = 'Select a valid student and target assignment.';
return;
}
const previousValue = selectEl ? selectEl.dataset.previousValue || selectEl.defaultValue || '' : '';
if (selectEl) selectEl.disabled = true;
const fd = new FormData();
fd.append('student_id', String(studentId));
fd.append('class_section_id', String(classSectionId));
fd.append('school_year', selectedYear);
const csrfNameEl = document.getElementById('csrfName');
const csrfValueEl= document.getElementById('csrfValue');
if (csrfNameEl && csrfValueEl) {
fd.append(csrfNameEl.value, csrfValueEl.value);
}
msgEl.textContent = 'Saving candidate assignment...';
fetch(updateCandidateUrl, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: fd })
.then(r => r.json())
.then(res => {
if (res && res.csrfTokenName && res.csrfHash) {
if (csrfNameEl) csrfNameEl.value = res.csrfTokenName;
if (csrfValueEl) csrfValueEl.value = res.csrfHash;
}
if (!res || !res.ok) {
if (selectEl && previousValue) selectEl.value = previousValue;
msgEl.textContent = res && res.message ? res.message : 'Candidate assignment could not be saved.';
return;
}
msgEl.textContent = res.message || 'Candidate assignment saved.';
if (selectEl) selectEl.dataset.previousValue = String(classSectionId);
ensureIncludedClassVisible(classId);
loadTotals();
})
.catch(() => {
if (selectEl && previousValue) selectEl.value = previousValue;
msgEl.textContent = 'Candidate assignment could not be saved.';
})
.finally(() => {
if (selectEl) selectEl.disabled = false;
});
}
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 || []);
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(); });
if (studentSearchInput) {
studentSearchInput.addEventListener('input', applyStudentSearch);
}
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.';
});
loadTotals();
})();
</script>
<?= $this->endSection() ?>