@@ -919,7 +919,7 @@ class StudentController extends BaseController
|
||||
}
|
||||
|
||||
// Fetch lettered sections for this class
|
||||
$letters = $this->classSectionModel->getLetterSectionsByClassId($classId);
|
||||
$letters = $this->letterSectionsForDistribution($classId, $year);
|
||||
if (empty($letters)) {
|
||||
$msg = 'No lettered sections found for the selected class.';
|
||||
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
|
||||
@@ -1061,6 +1061,24 @@ class StudentController extends BaseController
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function letterSectionsForDistribution(int $classId, string $year): array
|
||||
{
|
||||
$query = $this->classSectionModel
|
||||
->where('class_id', $classId)
|
||||
->like('class_section_name', '-', 'both')
|
||||
->orderBy('class_section_name', 'ASC');
|
||||
if ($year !== '' && $this->db->fieldExists('school_year', 'classSection')) {
|
||||
$query->where('school_year', $year);
|
||||
}
|
||||
|
||||
$sections = $query->findAll();
|
||||
if (!empty($sections) || $year === '' || ! $this->db->fieldExists('school_year', 'classSection')) {
|
||||
return $sections;
|
||||
}
|
||||
|
||||
return $this->classSectionModel->getLetterSectionsByClassId($classId);
|
||||
}
|
||||
|
||||
private function decisionDistributionCandidates(int $classId, string $targetSchoolYear): array
|
||||
{
|
||||
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
||||
@@ -1315,12 +1333,22 @@ class StudentController extends BaseController
|
||||
{
|
||||
try {
|
||||
$year = trim((string)($this->request->getGet('school_year') ?? $this->schoolYear));
|
||||
$includeClassIds = $this->parseIncludedClassIds($this->request->getGet('include_class_ids'));
|
||||
|
||||
// Fetch base sections (no dash) and filter to KG, 1..9, youth
|
||||
$bases = $this->classSectionModel
|
||||
$baseQuery = $this->classSectionModel
|
||||
->where("class_section_name NOT LIKE '%-%'", null, false)
|
||||
->orderBy('class_id', 'ASC')
|
||||
->findAll();
|
||||
->orderBy('class_id', 'ASC');
|
||||
if ($year !== '' && $this->db->fieldExists('school_year', 'classSection')) {
|
||||
$baseQuery->where('school_year', $year);
|
||||
}
|
||||
$bases = $baseQuery->findAll();
|
||||
if (empty($bases) && $year !== '' && $this->db->fieldExists('school_year', 'classSection')) {
|
||||
$bases = $this->classSectionModel
|
||||
->where("class_section_name NOT LIKE '%-%'", null, false)
|
||||
->orderBy('class_id', 'ASC')
|
||||
->findAll();
|
||||
}
|
||||
|
||||
$wanted = [];
|
||||
foreach ($bases as $r) {
|
||||
@@ -1337,10 +1365,27 @@ class StudentController extends BaseController
|
||||
$num = (int)$name;
|
||||
if ($num >= 1 && $num <= 9) {
|
||||
$wanted[] = $r;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array((int)($r['class_id'] ?? 0), $includeClassIds, true)) {
|
||||
$wanted[] = $r;
|
||||
}
|
||||
}
|
||||
|
||||
$deduped = [];
|
||||
$seenClassIds = [];
|
||||
foreach ($wanted as $r) {
|
||||
$classId = (int)($r['class_id'] ?? 0);
|
||||
if ($classId <= 0 || isset($seenClassIds[$classId])) {
|
||||
continue;
|
||||
}
|
||||
$seenClassIds[$classId] = true;
|
||||
$deduped[] = $r;
|
||||
}
|
||||
$wanted = $deduped;
|
||||
|
||||
$out = [];
|
||||
foreach ($wanted as $r) {
|
||||
$classId = (int)$r['class_id'];
|
||||
@@ -1371,6 +1416,22 @@ class StudentController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
private function parseIncludedClassIds($raw): array
|
||||
{
|
||||
if ($raw === null || $raw === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!is_array($raw)) {
|
||||
$raw = explode(',', (string)$raw);
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter(
|
||||
array_map('intval', $raw),
|
||||
static fn(int $id): bool => $id > 0
|
||||
)));
|
||||
}
|
||||
|
||||
private function savedDistributionSections(int $classId, string $year): array
|
||||
{
|
||||
if (! $this->db->tableExists('student_section_distribution_drafts')) {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
use RuntimeException;
|
||||
|
||||
class BackfillAddedSchoolYearColumns extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$schoolYear = $this->configuredSchoolYear();
|
||||
|
||||
foreach ($this->tablesWithSchoolYearColumn() as $table) {
|
||||
$this->db->query(
|
||||
sprintf(
|
||||
'UPDATE %s SET `school_year` = ? WHERE `school_year` IS NULL OR TRIM(`school_year`) = \'\'',
|
||||
$this->quoteIdentifier($table)
|
||||
),
|
||||
[$schoolYear]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// This is a data backfill. Do not erase school_year values on rollback.
|
||||
}
|
||||
|
||||
private function configuredSchoolYear(): string
|
||||
{
|
||||
if (! $this->db->tableExists('configuration')) {
|
||||
throw new RuntimeException('Cannot backfill school_year columns: configuration table is missing.');
|
||||
}
|
||||
|
||||
$row = $this->db->table('configuration')
|
||||
->select('config_value')
|
||||
->where('config_key', 'school_year')
|
||||
->where('config_value IS NOT NULL', null, false)
|
||||
->where('TRIM(config_value) <>', '')
|
||||
->orderBy('id', 'DESC')
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
|
||||
$schoolYear = trim((string)($row['config_value'] ?? ''));
|
||||
if (preg_match('/^\d{4}-\d{4}$/', $schoolYear) !== 1) {
|
||||
throw new RuntimeException(
|
||||
'Cannot backfill school_year columns: configuration.school_year must be set as YYYY-YYYY.'
|
||||
);
|
||||
}
|
||||
|
||||
return $schoolYear;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function tablesWithSchoolYearColumn(): array
|
||||
{
|
||||
$rows = $this->db->query(
|
||||
"SELECT c.TABLE_NAME AS table_name
|
||||
FROM information_schema.COLUMNS c
|
||||
INNER JOIN information_schema.TABLES t
|
||||
ON t.TABLE_SCHEMA = c.TABLE_SCHEMA
|
||||
AND t.TABLE_NAME = c.TABLE_NAME
|
||||
WHERE c.TABLE_SCHEMA = DATABASE()
|
||||
AND c.COLUMN_NAME = 'school_year'
|
||||
AND t.TABLE_TYPE = 'BASE TABLE'
|
||||
ORDER BY c.TABLE_NAME"
|
||||
)->getResultArray();
|
||||
|
||||
$tables = [];
|
||||
foreach ($rows as $row) {
|
||||
$table = trim((string)($row['table_name'] ?? ''));
|
||||
if ($table === '' || $table === 'school_years') {
|
||||
continue;
|
||||
}
|
||||
$tables[] = $table;
|
||||
}
|
||||
|
||||
return $tables;
|
||||
}
|
||||
|
||||
private function quoteIdentifier(string $identifier): string
|
||||
{
|
||||
return '`' . str_replace('`', '``', $identifier) . '`';
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,69 @@
|
||||
<?= $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">
|
||||
@@ -28,6 +93,32 @@
|
||||
<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">
|
||||
@@ -37,6 +128,7 @@
|
||||
<th>Total</th>
|
||||
<th>Sections</th>
|
||||
<th>Actions</th>
|
||||
<th>Distribution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tblBody"></tbody>
|
||||
@@ -57,50 +149,37 @@
|
||||
<script>
|
||||
(function(){
|
||||
const selectedYear = '<?= esc($selectedYear ?? '') ?>';
|
||||
const totalsUrl = '<?= site_url('administrator/sections/promotion-totals') ?>?school_year=' + encodeURIComponent(selectedYear);
|
||||
const totalsBaseUrl = '<?= site_url('administrator/sections/promotion-totals') ?>';
|
||||
const distUrl = '<?= site_url('administrator/sections/auto-distribute') ?>';
|
||||
|
||||
const tblBody = document.getElementById('tblBody');
|
||||
const tblHeader = document.getElementById('tblHeader');
|
||||
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 headerSections = []; // list of generated section names as columns
|
||||
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 ensureSectionColumns(sectionNames) {
|
||||
// Add any new section name as a column if not already present
|
||||
sectionNames.forEach(function(name){
|
||||
if (!name) return;
|
||||
if (headerSections.indexOf(name) >= 0) return;
|
||||
headerSections.push(name);
|
||||
const th = document.createElement('th');
|
||||
th.textContent = name;
|
||||
tblHeader.appendChild(th);
|
||||
// Also add empty cell to each existing row
|
||||
document.querySelectorAll('#tblBody tr').forEach(function(tr){
|
||||
const td = document.createElement('td');
|
||||
td.dataset.section = name;
|
||||
td.textContent = '';
|
||||
tr.appendChild(td);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildInitialTable(rows) {
|
||||
tblBody.innerHTML = '';
|
||||
rowIndexByClassId = {};
|
||||
headerSections = [];
|
||||
// Remove dynamically-added section cols, keep the first four fixed
|
||||
while (tblHeader.children.length > 4) tblHeader.removeChild(tblHeader.lastElementChild);
|
||||
|
||||
rows.forEach(function(r){
|
||||
const tr = document.createElement('tr');
|
||||
@@ -130,13 +209,21 @@
|
||||
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_name || '', r.sections);
|
||||
renderSectionsForRow(r.class_section_id, r.sections);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -186,42 +273,84 @@
|
||||
|
||||
msgEl.textContent = res && res.message ? res.message : 'Completed.';
|
||||
|
||||
renderSectionsForRow(baseName || '', res.sections);
|
||||
renderSectionsForRow(baseSectionId, res.sections);
|
||||
})
|
||||
.catch(() => { msgEl.textContent = 'Failed to distribute. Please try again.'; });
|
||||
}
|
||||
|
||||
function renderSectionsForRow(baseName, sections) {
|
||||
const names = sections.map(s => s.class_section_name).filter(Boolean);
|
||||
ensureSectionColumns(names);
|
||||
|
||||
function renderSectionsForRow(baseSectionId, sections) {
|
||||
const tr = Array.from(document.querySelectorAll('#tblBody tr')).find(function(_tr){
|
||||
return (_tr.children[0].textContent || '') === (baseName || '');
|
||||
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 td = Array.from(tr.querySelectorAll('td[data-section]')).find(cell => cell.dataset.section === s.class_section_name);
|
||||
if (!td) return;
|
||||
|
||||
const studentNames = Array.isArray(s.student_names) ? s.student_names : [];
|
||||
td.innerHTML = '';
|
||||
const count = document.createElement('div');
|
||||
count.className = 'fw-semibold small mb-1';
|
||||
count.textContent = (s.total ?? studentNames.length) + ' students';
|
||||
td.appendChild(count);
|
||||
const block = document.createElement('div');
|
||||
block.className = 'distribution-section';
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'small';
|
||||
list.style.whiteSpace = 'normal';
|
||||
list.textContent = studentNames.length ? studentNames.join(', ') : 'No students assigned';
|
||||
td.appendChild(list);
|
||||
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' }})
|
||||
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; }
|
||||
@@ -232,6 +361,46 @@
|
||||
.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);
|
||||
|
||||
Reference in New Issue
Block a user