fix pages and add distribution system
Tests / PHPUnit (push) Failing after 1m13s

This commit is contained in:
root
2026-07-15 23:03:51 -04:00
parent 7f3b24e47f
commit ba87598d3a
38 changed files with 1362 additions and 658 deletions
+27 -19
View File
@@ -73,30 +73,37 @@ class PrintRequests extends BaseController
public function admin_index()
{
$db = \Config\Database::connect();
$query = $db->query("
SELECT
pr.*,
u.firstname,
u.lastname,
$context = $this->resolveSchoolYearContext();
$schoolYear = $context->yearName();
$printRequestsQuery = $this->printRequestModel
->select('
print_requests.*,
u.firstname,
u.lastname,
cs.class_section_name,
admins.firstname AS admin_firstname,
admins.lastname AS admin_lastname
FROM print_requests pr
LEFT JOIN users u ON u.id = pr.teacher_id
LEFT JOIN classSection cs ON cs.class_section_id = pr.class_id
LEFT JOIN users admins ON admins.id = pr.admin_id
ORDER BY
')
->join('users u', 'u.id = print_requests.teacher_id', 'left')
->join('classSection cs', 'cs.class_section_id = print_requests.class_id', 'left')
->join('users admins', 'admins.id = print_requests.admin_id', 'left');
$this->applyPrintRequestSchoolYearScope($printRequestsQuery, $schoolYear);
$data['print_requests'] = $printRequestsQuery
->orderBy("
CASE
WHEN pr.status = 'not_assigned' THEN 1
WHEN pr.status = 'assigned' THEN 2
WHEN pr.status = 'done' THEN 3
WHEN pr.status = 'delivered' THEN 4
WHEN print_requests.status = 'not_assigned' THEN 1
WHEN print_requests.status = 'assigned' THEN 2
WHEN print_requests.status = 'done' THEN 3
WHEN print_requests.status = 'delivered' THEN 4
ELSE 5
END ASC,
pr.required_by ASC
");
$data['print_requests'] = $query->getResultArray();
END
", 'ASC', false)
->orderBy('print_requests.required_by', 'ASC')
->findAll();
$data['isSchoolYearReadonly'] = $context->isReadonly();
return view('print_requests/admin_index', $data);
}
@@ -156,6 +163,7 @@ class PrintRequests extends BaseController
// Case 1: Admin status update
if ($this->request->getPost('status')) {
$this->assertSchoolYearWritable($this->resolveSchoolYearContext());
$user_id = session()->get('user_id');
$current_status = $request['status'];
$new_status = $this->request->getPost('status');
+108 -73
View File
@@ -19,6 +19,7 @@ use App\Models\AdminNotificationSubjectModel;
use Doctrine\DBAL\Configuration;
use App\Services\FeeCalculationService;
use App\Models\StudentClassModel;
use App\Models\StudentSectionDistributionDraftModel;
use App\Controllers\View\EmailController;
use App\Controllers\View\InvoiceController;
use App\Models\StaffAttendanceModel;
@@ -2159,6 +2160,10 @@ class AdministratorController extends BaseController
public function parentProfiles()
{
if ($redirect = $this->redirectWithoutLegacyTermFilters('administrator/parent_profiles')) {
return $redirect;
}
// Fetch all users with their roles in one go
$allUsers = $this->userModel->findAll();
$parents = [];
@@ -2215,6 +2220,27 @@ class AdministratorController extends BaseController
return view('administrator/parent_profile', ['parents' => $parents]);
}
private function redirectWithoutLegacyTermFilters(string $route): ?\CodeIgniter\HTTP\RedirectResponse
{
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
$query = $this->request->getGet();
$hasLegacyTermFilter = false;
foreach ($legacyTermKeys as $key) {
if (array_key_exists($key, $query)) {
unset($query[$key]);
$hasLegacyTermFilter = true;
}
}
if (!$hasLegacyTermFilter) {
return null;
}
$target = site_url($route) . (!empty($query) ? '?' . http_build_query($query) : '');
return redirect()->to($target);
}
public function manageUsers()
{// Fetch all users
$users = $this->userModel->findAll();
@@ -2298,8 +2324,8 @@ class AdministratorController extends BaseController
public function showEnrollmentWithdrawalPage()
{
try {
$schoolYears = $this->availableSchoolYears();
$selectedYear = $this->selectedEnrollmentSchoolYear($schoolYears);
$schoolYearContext = $this->resolveSchoolYearContext();
$selectedYear = $schoolYearContext->yearName();
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
@@ -2399,11 +2425,10 @@ class AdministratorController extends BaseController
return view('enroll_withdraw/enrollment_withdrawal', [
'students' => $students,
'classes' => $classes, // <-- used by the modal <select>
'schoolYears' => $schoolYears,
'selectedYear' => $selectedYear,
'currentYear' => (string)$this->schoolYear,
'isCurrentYear' => ((string)$selectedYear === (string)$this->schoolYear),
'missingYear' => empty($this->schoolYear),
'currentYear' => $selectedYear,
'isCurrentYear' => ! $schoolYearContext->isReadonly(),
'missingYear' => $selectedYear === '',
]);
} catch (\Throwable $e) {
log_message('error', 'Enrollment/Withdrawal page error: {msg}', ['msg' => $e->getMessage()]);
@@ -2415,8 +2440,7 @@ class AdministratorController extends BaseController
public function enrollmentWithdrawalData()
{
try {
$schoolYears = $this->availableSchoolYears();
$selectedYear = $this->selectedEnrollmentSchoolYear($schoolYears);
$selectedYear = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
@@ -2502,7 +2526,6 @@ class AdministratorController extends BaseController
'csrfHash' => csrf_hash(),
'semester' => (string)$this->semester,
'school_year' => (string)$selectedYear,
'schoolYears' => $schoolYears,
]);
} catch (\Throwable $e) {
log_message('error', 'enrollmentWithdrawalData error: {msg}', ['msg' => $e->getMessage()]);
@@ -2510,70 +2533,6 @@ class AdministratorController extends BaseController
}
}
private function availableSchoolYears(): array
{
$years = [];
$addYear = static function (mixed $value) use (&$years): void {
$year = trim((string) $value);
if ($year !== '' && !in_array($year, $years, true)) {
$years[] = $year;
}
};
$addYear($this->schoolYear ?? null);
if ($this->db->tableExists('school_years')) {
$rows = $this->db->table('school_years')
->select('name')
->orderBy('name', 'DESC')
->get()
->getResultArray();
foreach ($rows as $row) {
$addYear($row['name'] ?? null);
}
}
if ($this->db->tableExists('enrollments')) {
$rows = $this->db->table('enrollments')
->distinct()
->select('school_year')
->where('school_year IS NOT NULL', null, false)
->orderBy('school_year', 'DESC')
->get()
->getResultArray();
foreach ($rows as $row) {
$addYear($row['school_year'] ?? null);
}
}
usort($years, static fn(string $a, string $b): int => strnatcasecmp($b, $a));
$activeYear = trim((string) ($this->schoolYear ?? ''));
if ($activeYear !== '') {
$years = array_values(array_filter($years, static fn(string $year): bool => $year !== $activeYear));
array_unshift($years, $activeYear);
}
return $years;
}
private function selectedEnrollmentSchoolYear(array $schoolYears): string
{
$requestedYear = trim((string) ($this->request->getGet('schoolYear') ?? ''));
if ($requestedYear !== '' && in_array($requestedYear, $schoolYears, true)) {
return $requestedYear;
}
$activeYear = trim((string) ($this->schoolYear ?? ''));
if ($activeYear !== '') {
return $activeYear;
}
return (string) ($schoolYears[0] ?? '');
}
private function enrollmentClassOptions(string $selectedYear): array
{
$select = ['id', 'class_section_id', 'class_section_name'];
@@ -2795,6 +2754,9 @@ class AdministratorController extends BaseController
}
log_message('info', "Created enrollment for student ID {$studentId} with status {$newEnrollmentStatus} and admission {$admissionStatus}.");
if (in_array($newEnrollmentStatus, ['payment pending', 'enrolled'], true)) {
$this->applyDistributionDraftToStudentClass((int)$studentId, (string)$this->schoolYear);
}
continue; // go to next student
}
@@ -2811,6 +2773,9 @@ class AdministratorController extends BaseController
// Skip if no actual change
if ($oldStatus === $newEnrollmentStatus) {
log_message('debug', "No status change for student {$studentId} ({$oldStatus}) — skipping.");
if (in_array($newEnrollmentStatus, ['payment pending', 'enrolled'], true)) {
$this->applyDistributionDraftToStudentClass((int)$studentId, (string)$this->schoolYear);
}
continue;
}
@@ -2830,6 +2795,9 @@ class AdministratorController extends BaseController
}
log_message('info', "Updated enrollment for student ID $studentId: {$oldStatus}{$newEnrollmentStatus} (admission: {$admissionStatus})");
if (in_array($newEnrollmentStatus, ['payment pending', 'enrolled'], true)) {
$this->applyDistributionDraftToStudentClass((int)$studentId, (string)$this->schoolYear);
}
// Student name
$studentRow = $this->studentModel->find($studentId);
@@ -3009,4 +2977,71 @@ class AdministratorController extends BaseController
->with('error', 'An unexpected error occurred while processing enrollments.');
}
}
private function applyDistributionDraftToStudentClass(int $studentId, string $year): void
{
try {
$draftModel = new StudentSectionDistributionDraftModel();
$draft = $draftModel->where('student_id', $studentId)
->where('school_year', $year)
->where('status', 'pending')
->first();
if (!$draft) {
return;
}
$targetSectionId = (int)($draft['class_section_id'] ?? 0);
if ($targetSectionId <= 0) {
return;
}
$studentClass = new StudentClassModel();
$exists = $studentClass->where('student_id', $studentId)
->where('school_year', $year)
->first();
$payload = [
'student_id' => $studentId,
'class_section_id' => $targetSectionId,
'school_year' => $year,
'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
'updated_at' => utc_now(),
];
if ($exists) {
$studentClass->update((int)$exists['id'], $payload);
} else {
$payload['created_at'] = utc_now();
$studentClass->insert($payload);
}
$this->db->table('enrollments')
->where('student_id', $studentId)
->where('school_year', $year)
->whereIn('enrollment_status', ['payment pending', 'enrolled'])
->update([
'class_section_id' => $targetSectionId,
'updated_at' => utc_now(),
]);
$this->db->table('promotion_queue')
->where('student_id', $studentId)
->where('school_year_to', $year)
->update([
'to_class_section_id' => $targetSectionId,
'status' => 'applied',
'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
'updated_at' => utc_now(),
]);
$draftModel->update((int)$draft['id'], [
'status' => 'applied',
'applied_at' => utc_now(),
'updated_at' => utc_now(),
]);
} catch (\Throwable $e) {
log_message('error', 'applyDistributionDraftToStudentClass failed: ' . $e->getMessage());
}
}
}
@@ -48,6 +48,8 @@ class AssignmentController extends BaseController
$selectedSemester = (string)($this->request->getGet('semester') ?? $this->semester ?? '');
$year = (string)($this->schoolYear ?? '');
$this->applyPendingDistributionDraftsForEnrolledStudents($year);
$tcQ = $this->teacherClassModel;
if ($year !== '') {
$tcQ = $tcQ->where('school_year', $year);
@@ -204,6 +206,94 @@ class AssignmentController extends BaseController
return view('administrator/class_assignment', $data);
}
private function applyPendingDistributionDraftsForEnrolledStudents(string $year): void
{
if ($year === '') {
return;
}
try {
$db = Database::connect();
if (! $db->tableExists('student_section_distribution_drafts')) {
return;
}
$rows = $db->table('student_section_distribution_drafts d')
->select('d.id, d.student_id, d.class_section_id')
->join(
'enrollments e',
'e.student_id = d.student_id AND e.school_year = d.school_year',
'inner'
)
->where('d.school_year', $year)
->where('d.status', 'pending')
->whereIn('e.enrollment_status', ['payment pending', 'enrolled'])
->groupBy('d.id, d.student_id, d.class_section_id')
->get()
->getResultArray();
if (empty($rows)) {
return;
}
$now = utc_now();
$updatedBy = (int)(session()->get('user_id') ?? 0) ?: null;
$db->transStart();
foreach ($rows as $row) {
$studentId = (int)($row['student_id'] ?? 0);
$sectionId = (int)($row['class_section_id'] ?? 0);
if ($studentId <= 0 || $sectionId <= 0) {
continue;
}
$existing = $db->table('student_class')
->select('id')
->where('student_id', $studentId)
->where('school_year', $year)
->get()
->getRowArray();
$payload = [
'student_id' => $studentId,
'class_section_id' => $sectionId,
'school_year' => $year,
'updated_by' => $updatedBy,
'updated_at' => $now,
];
if ($existing) {
$db->table('student_class')
->where('id', (int)$existing['id'])
->update($payload);
} else {
$payload['created_at'] = $now;
$db->table('student_class')->insert($payload);
}
$db->table('enrollments')
->where('student_id', $studentId)
->where('school_year', $year)
->whereIn('enrollment_status', ['payment pending', 'enrolled'])
->update([
'class_section_id' => $sectionId,
'updated_at' => $now,
]);
$db->table('student_section_distribution_drafts')
->where('id', (int)$row['id'])
->update([
'status' => 'applied',
'applied_at' => $now,
'updated_at' => $now,
]);
}
$db->transComplete();
} catch (\Throwable $e) {
log_message('error', 'applyPendingDistributionDraftsForEnrolledStudents failed: ' . $e->getMessage());
}
}
public function save()
+7 -7
View File
@@ -322,23 +322,23 @@ class EventController extends ResourceController
public function index()
{
$eventModel = new EventModel();
$today = local_date(utc_now(), 'Y-m-d');
$schoolYear = $this->currentSchoolYearName();
// Fetch all events
$events = $eventModel
$events = $this->eventModel
->where('school_year', $schoolYear)
->orderBy('created_at', 'DESC')
->findAll();
// Fetch active events (not expired)
$activeEventCount = $eventModel
$activeEventCount = $this->eventModel
->where('school_year', $schoolYear)
->where('expiration_date >=', $today)
->countAllResults();
return view('administrator/events/event_list', [
'events' => $events,
'activeEventCount' => $activeEventCount
'activeEventCount' => $activeEventCount,
'schoolYear' => $schoolYear,
]);
}
+31 -45
View File
@@ -54,14 +54,29 @@ class ExtraChargesController extends BaseController
/** Render HTML management page */
public function page()
{
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
$query = $this->request->getGet();
$hasLegacyTermFilter = false;
foreach ($legacyTermKeys as $key) {
if (array_key_exists($key, $query)) {
unset($query[$key]);
$hasLegacyTermFilter = true;
}
}
if ($hasLegacyTermFilter) {
$target = site_url('admin/charges') . (!empty($query) ? '?' . http_build_query($query) : '');
return redirect()->to($target);
}
$schoolYearContext = $this->resolveSchoolYearContext();
$schoolYear = $schoolYearContext->yearName();
$parentId = (int)($this->request->getGet('parent_id') ?? 0);
$status = $this->request->getGet('status') ?: null;
$yearSelect = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
$rows = [];
if ($parentId > 0) {
$rows = $this->additionalChargeModel
->byParentTerm($parentId, $this->schoolYear, $this->semester, $status);
->byParentTerm($parentId, $schoolYear, $this->semester, $status);
}
// Load ALL parents for current view
@@ -85,7 +100,7 @@ class ExtraChargesController extends BaseController
$parentIds = array_map(fn($r) => (int)$r['id'], $parents);
$invoicesByParent = [];
if (!empty($parentIds)) {
$all = $this->invoiceModel->getAllInvoicesByUserIds($parentIds, $yearSelect);
$all = $this->invoiceModel->getAllInvoicesByUserIds($parentIds, $schoolYear);
foreach ($all as $inv) {
$pid = (int)$inv['parent_id'];
$invoicesByParent[$pid][] = [
@@ -107,7 +122,7 @@ class ExtraChargesController extends BaseController
// ✅ Always pull all charges for the selected year & current semester (all parents)
$rows = $this->additionalChargeModel->listAllForTerm(
$yearSelect,
$schoolYear,
$this->semester,
$status,
$q,
@@ -115,38 +130,6 @@ class ExtraChargesController extends BaseController
);
$pager = $this->additionalChargeModel->pager;
// Build school year options from data (additional_charges + invoices)
$schoolYears = [];
try {
$q1 = $this->db->table('additional_charges')->select('DISTINCT school_year', false)
->where('school_year IS NOT NULL', null, false)
->orderBy('school_year', 'DESC')->get()->getResultArray();
foreach ($q1 as $r) {
$val = (string)($r['school_year'] ?? '');
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
}
} catch (\Throwable $e) {}
try {
$q2 = $this->db->table('invoices')->select('DISTINCT school_year', false)
->where('school_year IS NOT NULL', null, false)
->orderBy('school_year', 'DESC')->get()->getResultArray();
foreach ($q2 as $r) {
$val = (string)($r['school_year'] ?? '');
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
}
} catch (\Throwable $e) {}
if (empty($schoolYears) && is_string($this->schoolYear) && $this->schoolYear !== '') {
// fallback: generate recent years around configured schoolYear
$schoolYears[] = $this->schoolYear;
// Optionally add previous/next
[$start, $end] = explode('-', $this->schoolYear) + [0 => date('Y'), 1 => date('Y')+1];
$start = (int)$start;
for ($i = 1; $i <= 3; $i++) {
$schoolYears[] = ($start - $i) . '-' . (($start - $i) + 1);
}
}
rsort($schoolYears);
return view('payment/extra_charges', [
'q' => $q,
'pager' => $pager,
@@ -157,9 +140,9 @@ class ExtraChargesController extends BaseController
'parentId' => $parentId,
'selectedParentLabel' => $selectedParentLabel,
'status' => $status,
'schoolYear' => $yearSelect,
'schoolYears' => $schoolYears,
'schoolYear' => $schoolYear,
'semester' => $this->semester,
'isSchoolYearReadonly' => $schoolYearContext->isReadonly(),
]);
}
@@ -273,7 +256,7 @@ class ExtraChargesController extends BaseController
public function invoicesForParent()
{
$parentId = (int)($this->request->getGet('parent_id') ?? 0);
$schoolYear = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
$schoolYear = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
$rows = ($parentId > 0)
? ($this->invoiceModel->getInvoicesByUserId($parentId, $schoolYear) ?? [])
@@ -304,6 +287,9 @@ class ExtraChargesController extends BaseController
public function store()
{
$schoolYearContext = $this->resolveSchoolYearContext();
$this->assertSchoolYearWritable($schoolYearContext);
$schoolYear = $schoolYearContext->yearName();
$data = $this->request->getPost();
$rules = [
@@ -336,8 +322,8 @@ class ExtraChargesController extends BaseController
$payload = [
'parent_id' => (int)$data['parent_id'], // ← users.id of the parent
'invoice_id' => $invoiceId,
'school_year' => $data['school_year'] ?? $this->schoolYear,
'semester' => $data['semester'] ?? $this->semester,
'school_year' => $schoolYear,
'semester' => (string)$this->semester,
'charge_type' => $chargeType,
'title' => trim($data['title']),
'description' => trim($data['description'] ?? ''),
@@ -351,7 +337,7 @@ class ExtraChargesController extends BaseController
$this->db->transStart();
// BEFORE
$invoiceBefore = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $this->schoolYear);
$invoiceBefore = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $schoolYear);
// Insert charge
$this->additionalChargeModel->insert($payload);
@@ -363,7 +349,7 @@ class ExtraChargesController extends BaseController
}
// AFTER
$invoiceAfter = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $this->schoolYear);
$invoiceAfter = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $schoolYear);
// Parent USER (not parent table)
$parentUser = $this->userModel->getUserInfoById($data['parent_id']);
@@ -526,8 +512,8 @@ class ExtraChargesController extends BaseController
/** JSON: list charges for the current term (with optional filters). */
public function apiList()
{
$year = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
$sem = (string)($this->request->getGet('semester') ?? $this->semester);
$year = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
$sem = (string)$this->semester;
$status = $this->request->getGet('status') ?: null;
$q = trim((string)($this->request->getGet('q') ?? '')) ?: null;
$per = (int)($this->request->getGet('per_page') ?? 50);
@@ -90,6 +90,10 @@ class NotificationsController extends BaseController
{
helper('url');
if ($redirect = $this->redirectWithoutLegacyTermFilters('notifications/active')) {
return $redirect;
}
$targetGroup = $this->request->getGet('target_group');
return view('notifications/list_active', [
@@ -102,6 +106,10 @@ class NotificationsController extends BaseController
{
helper(['url', 'form']);
if ($redirect = $this->redirectWithoutLegacyTermFilters('notifications/deleted')) {
return $redirect;
}
return view('notifications/list_deleted', [
'deletedNotificationsEndpoint' => site_url('api/notifications/deleted'),
'restoreEndpoint' => site_url('notifications/restore'),
@@ -149,7 +157,7 @@ class NotificationsController extends BaseController
$targetGroup = null;
}
$schoolYear = (string) ((new \App\Models\ConfigurationModel())->getConfig('school_year') ?? '');
$schoolYear = $this->currentSchoolYearName();
if ($schoolYear !== '' && db_connect()->fieldExists('school_year', 'notifications')) {
$this->notificationModel->where('school_year', $schoolYear);
}
@@ -215,4 +223,25 @@ class NotificationsController extends BaseController
'notifications' => $notifications,
];
}
private function redirectWithoutLegacyTermFilters(string $route): ?\CodeIgniter\HTTP\RedirectResponse
{
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
$query = $this->request->getGet();
$hasLegacyTermFilter = false;
foreach ($legacyTermKeys as $key) {
if (array_key_exists($key, $query)) {
unset($query[$key]);
$hasLegacyTermFilter = true;
}
}
if (!$hasLegacyTermFilter) {
return null;
}
$target = site_url($route) . (!empty($query) ? '?' . http_build_query($query) : '');
return redirect()->to($target);
}
}
+40 -10
View File
@@ -7,6 +7,7 @@ use App\Models\UserModel;
use App\Models\StudentModel;
use App\Models\ClassSectionModel;
use App\Models\StudentClassModel;
use App\Models\StudentSectionDistributionDraftModel;
use App\Models\AuthorizedUserModel;
use App\Models\EmergencyContactModel;
use App\Models\ConfigurationModel;
@@ -444,6 +445,7 @@ class ParentController extends BaseController
$this->applyPromotionAssignment((int)$studentId, (string)$this->schoolYear);
} else {
log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) is already actively enrolled.");
$this->applyPromotionAssignment((int)$studentId, (string)$this->schoolYear);
}
} else {
$passedPreviousYear = $this->studentPassedPreviousYear((int)$studentId, (string)$this->schoolYear);
@@ -579,20 +581,29 @@ class ParentController extends BaseController
try {
$promo = new \App\Models\PromotionQueueModel();
$classSectionModel = new \App\Models\ClassSectionModel();
$draftModel = new StudentSectionDistributionDraftModel();
$studentClass = new \App\Models\StudentClassModel();
$draft = $draftModel->where('student_id', $studentId)
->where('school_year', $year)
->where('status', 'pending')
->first();
$row = $promo->where('student_id', $studentId)
->where('school_year_to', $year)
->first();
if (!$row) return; // nothing to do
if (!$row && !$draft) return; // nothing to do
$targetSectionId = (int)($row['to_class_section_id'] ?? 0);
$targetSectionId = (int)($draft['class_section_id'] ?? 0);
if ($targetSectionId <= 0) {
$targetSectionId = (int)($row['to_class_section_id'] ?? 0);
}
if ($targetSectionId <= 0) {
// Resolve base section for target class (e.g., '3')
$base = $classSectionModel->getBaseSectionByClassId((int)$row['to_class_id']);
$base = $classSectionModel->getBaseSectionByClassId((int)($row['to_class_id'] ?? 0));
if (!$base) {
log_message('warning', 'applyPromotionAssignment: base section not found for class_id=' . (int)$row['to_class_id']);
log_message('warning', 'applyPromotionAssignment: no draft or base section found for student_id=' . $studentId . ', year=' . $year);
return;
}
$targetSectionId = (int)($base['class_section_id'] ?? 0);
@@ -620,12 +631,31 @@ class ParentController extends BaseController
$studentClass->insert($payload);
}
// Mark promotion as applied
$promo->update((int)$row['id'], [
'status' => 'applied',
'updated_at' => utc_now(),
'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
]);
$this->db->table('enrollments')
->where('student_id', $studentId)
->where('school_year', $year)
->whereIn('enrollment_status', ['payment pending', 'enrolled'])
->update([
'class_section_id' => $targetSectionId,
'updated_at' => utc_now(),
]);
// Mark promotion as applied when promotion_queue is the source.
if ($row) {
$promo->update((int)$row['id'], [
'status' => 'applied',
'updated_at' => utc_now(),
'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
]);
}
if ($draft) {
$draftModel->update((int)$draft['id'], [
'status' => 'applied',
'applied_at' => utc_now(),
'updated_at' => utc_now(),
]);
}
} catch (\Throwable $e) {
log_message('error', 'applyPromotionAssignment failed: ' . $e->getMessage());
}
@@ -52,6 +52,11 @@ class RolePermissionController extends Controller
public function assignRole()
{
helper('url');
if ($redirect = $this->redirectWithoutLegacyTermFilters('rolepermission/assign_role')) {
return $redirect;
}
return view('rolepermission/assign_role', [
'assignRoleEndpoint' => site_url('api/rolepermission/users'),
'rolesEndpoint' => site_url('api/rolepermission/roles'),
@@ -196,6 +201,27 @@ class RolePermissionController extends Controller
return ['users' => $normalized];
}
private function redirectWithoutLegacyTermFilters(string $route): ?\CodeIgniter\HTTP\RedirectResponse
{
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
$query = $this->request->getGet();
$hasLegacyTermFilter = false;
foreach ($legacyTermKeys as $key) {
if (array_key_exists($key, $query)) {
unset($query[$key]);
$hasLegacyTermFilter = true;
}
}
if (!$hasLegacyTermFilter) {
return null;
}
$target = site_url($route) . (!empty($query) ? '?' . http_build_query($query) : '');
return redirect()->to($target);
}
private function updateStaffRecord(int $userId, array $newRoleNames): void
{
$excludedRoles = ['parent', 'student', 'guest'];
+29 -2
View File
@@ -32,6 +32,12 @@ class StaffController extends BaseController
public function index()
{
if ($redirect = $this->redirectWithoutLegacyTermFilters('staff/index')) {
return $redirect;
}
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
// roles we never show
$excludedRoles = ['student', 'parent', 'guest', 'inactive']; // ← add inactive here
@@ -45,7 +51,7 @@ class StaffController extends BaseController
$assignRows = $this->teacherClassModel
->select('teacher_class.teacher_id, teacher_class.position, classSection.class_section_name')
->join('classSection', 'classSection.class_section_id = teacher_class.class_section_id', 'left')
->where('teacher_class.school_year', (string)$this->schoolYear)
->where('teacher_class.school_year', $schoolYear)
->findAll();
$assignByTeacher = [];
@@ -88,10 +94,31 @@ class StaffController extends BaseController
'staff' => $staffList,
'issues_count' => $issuesCount,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'school_year' => $schoolYear,
]);
}
private function redirectWithoutLegacyTermFilters(string $route): ?\CodeIgniter\HTTP\RedirectResponse
{
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
$query = $this->request->getGet();
$hasLegacyTermFilter = false;
foreach ($legacyTermKeys as $key) {
if (array_key_exists($key, $query)) {
unset($query[$key]);
$hasLegacyTermFilter = true;
}
}
if (!$hasLegacyTermFilter) {
return null;
}
$target = site_url($route) . (!empty($query) ? '?' . http_build_query($query) : '');
return redirect()->to($target);
}
public function create()
{
return view('staff/create');
+417 -137
View File
@@ -10,6 +10,7 @@ use App\Models\ClassSectionModel;
use App\Models\EmergencyContactModel;
use App\Models\EnrollmentModel;
use App\Models\ConfigurationModel;
use App\Models\StudentSectionDistributionDraftModel;
use App\Models\StudentAllergyModel;
use App\Models\StudentMedicalConditionModel;
use CodeIgniter\Database\Exceptions\DataException;
@@ -866,10 +867,10 @@ class StudentController extends BaseController
}
/**
* POST admin endpoint: auto distribute students into lettered sections for a class
* Input: class_id (int), students_per_section (int), school_year (optional)
* Uses promotion_queue for the selected year (students who passed last year to this class).
* Balances male/female per section and respects capacity.
* POST admin endpoint: create draft balanced distribution rows for a class.
*
* Input: class_id/class_section_id, section_count, min_students_per_section,
* max_students_per_section, school_year.
*/
public function autoDistributeSections()
{
@@ -884,7 +885,10 @@ class StudentController extends BaseController
try {
$classId = (int) $this->request->getPost('class_id');
$classSectionId = (int) $this->request->getPost('class_section_id');
$perSec = (int) $this->request->getPost('students_per_section');
$sectionCount = (int) $this->request->getPost('section_count');
$minPerSection = (int) $this->request->getPost('min_students_per_section');
$maxRaw = trim((string) ($this->request->getPost('max_students_per_section') ?? ''));
$maxPerSection = $maxRaw === '' ? null : (int) $maxRaw;
$year = trim((string) ($this->request->getPost('school_year') ?? $this->schoolYear));
if ($classId <= 0 && $classSectionId > 0) {
@@ -892,51 +896,27 @@ class StudentController extends BaseController
$classId = (int) ($cid ?? 0);
}
if ($classId <= 0 || $perSec <= 0) {
$msg = 'Invalid class_id or students_per_section.';
if ($classId <= 0 || $sectionCount <= 0 || $minPerSection <= 0 || ($maxPerSection !== null && $maxPerSection <= 0)) {
$msg = 'Enter a valid class, section count, minimum size, and optional maximum size.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
$promo = new \App\Models\PromotionQueueModel();
// Candidates from promotion queue for this class/year and enrolled (or payment pending)
$cands = $promo->select('promotion_queue.*, students.gender')
->join('students', 'students.id = promotion_queue.student_id', 'left')
->where('promotion_queue.to_class_id', $classId)
->where('promotion_queue.school_year_to', $year)
->whereIn('promotion_queue.status', ['queued','assigned'])
->findAll();
$cands = $this->distributionCandidates($classId, $year);
if (empty($cands)) {
$msg = 'No students found in promotion queue for selected class/year.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 200) : redirect()->back()->with('info', $msg);
}
// Filter to those with enrollment for this year in acceptable statuses
$studentIds = array_map(static fn($r) => (int)$r['student_id'], $cands);
$enrolledIds = [];
if (!empty($studentIds)) {
$rows = $this->db->table('enrollments')
->select('student_id')
->whereIn('student_id', $studentIds)
->where('school_year', $year)
->whereIn('enrollment_status', ['payment pending', 'enrolled'])
->groupBy('student_id')
->get()->getResultArray();
$enrolledIds = array_map(static fn($r) => (int)$r['student_id'], $rows);
}
$cands = array_values(array_filter($cands, static function ($r) use ($enrolledIds) {
return in_array((int)$r['student_id'], $enrolledIds, true);
}));
if (empty($cands)) {
$msg = 'No eligible enrolled students found to distribute.';
$msg = 'No promoted students found to distribute for selected class/year.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 200) : redirect()->back()->with('info', $msg);
}
$total = count($cands);
$sectionsNeeded = (int) ceil($total / $perSec);
if ($sectionCount * $minPerSection > $total) {
$msg = 'Insufficient students: ' . $sectionCount . ' sections require at least ' . ($sectionCount * $minPerSection) . ' students, but only ' . $total . ' are available.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
if ($maxPerSection !== null && $total > $sectionCount * $maxPerSection) {
$msg = 'Capacity exceeded: ' . $sectionCount . ' sections can hold at most ' . ($sectionCount * $maxPerSection) . ' students, but ' . $total . ' must be assigned.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
// Fetch lettered sections for this class
$letters = $this->classSectionModel->getLetterSectionsByClassId($classId);
@@ -945,109 +925,66 @@ class StudentController extends BaseController
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
if (count($letters) < $sectionsNeeded) {
$msg = 'Not enough sections available. Needed: ' . $sectionsNeeded . ', available: ' . count($letters);
if (count($letters) < $sectionCount) {
$msg = 'Not enough sections available. Needed: ' . $sectionCount . ', available: ' . count($letters);
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
// Keep only required number of sections
$letters = array_slice($letters, 0, $sectionsNeeded);
$letters = array_slice($letters, 0, $sectionCount);
$buckets = $this->buildBalancedDistribution($cands, $letters, $minPerSection, $maxPerSection);
// Prepare buckets
$buckets = [];
foreach ($letters as $idx => $sec) {
$buckets[$idx] = [
'class_section_id' => (int)$sec['class_section_id'],
'assigned' => [],
'male' => 0,
'female' => 0,
];
}
// Split by gender
$males = [];
$females = [];
foreach ($cands as $r) {
$g = (string)($r['gender'] ?? '');
if (strcasecmp($g, 'Female') === 0) $females[] = $r; else $males[] = $r; // default non-female -> male bucket
}
// Helper to pick next bucket with available capacity and least of a gender
$pickBucket = function (string $gender) use (&$buckets, $perSec): ?int {
$bestIdx = null;
$bestCnt = PHP_INT_MAX;
foreach ($buckets as $i => $b) {
if (count($b['assigned']) >= $perSec) continue;
$cnt = ($gender === 'female') ? $b['female'] : $b['male'];
if ($cnt < $bestCnt) {
$bestCnt = $cnt;
$bestIdx = $i;
}
}
return $bestIdx;
};
// Assign males then females for balance
foreach ($males as $r) {
$bi = $pickBucket('male');
if ($bi === null) break;
$buckets[$bi]['assigned'][] = (int)$r['student_id'];
$buckets[$bi]['male']++;
}
foreach ($females as $r) {
$bi = $pickBucket('female');
if ($bi === null) break;
$buckets[$bi]['assigned'][] = (int)$r['student_id'];
$buckets[$bi]['female']++;
}
// Persist: set to_class_section_id on queue and upsert student_class
$promoIdsBySid = [];
foreach ($cands as $r) {
$promoIdsBySid[(int)$r['student_id']] = (int)$r['id'];
}
$studentClass = new StudentClassModel();
$draftModel = new StudentSectionDistributionDraftModel();
$promo = new \App\Models\PromotionQueueModel();
$updatedBy = (int)(session()->get('user_id') ?? 0) ?: null;
$now = utc_now();
$batchKey = sha1($year . ':' . $classId . ':' . microtime(true));
$this->db->transStart();
$studentIdsToReplace = array_values(array_unique(array_map(
static fn(array $student): int => (int)($student['student_id'] ?? 0),
$cands
)));
if (!empty($studentIdsToReplace)) {
$draftModel->where('school_year', $year)
->whereIn('student_id', $studentIdsToReplace)
->where('status', 'pending')
->delete();
}
foreach ($buckets as $b) {
$secId = (int)$b['class_section_id'];
foreach ($b['assigned'] as $sid) {
// Update promotion queue
if (isset($promoIdsBySid[$sid])) {
$promo->update($promoIdsBySid[$sid], [
foreach ($b['assigned'] as $student) {
$sid = (int)$student['student_id'];
$draftModel->insert([
'student_id' => $sid,
'class_id' => $classId,
'class_section_id' => $secId,
'school_year' => $year,
'previous_school_year' => (string)($student['school_year_from'] ?? ''),
'previous_final_score' => $student['previous_final_score'],
'score_group' => $student['score_group'],
'status' => 'pending',
'batch_key' => $batchKey,
'created_by' => $updatedBy,
'created_at' => $now,
'updated_at' => $now,
]);
if ((int)($student['promotion_queue_id'] ?? 0) > 0) {
$promo->update((int)$student['promotion_queue_id'], [
'to_class_section_id' => $secId,
'status' => 'assigned',
'updated_by' => $updatedBy,
'updated_at' => $now,
]);
}
// Upsert student_class
$exists = $studentClass->where('student_id', $sid)
->where('school_year', $year)
->where('semester', (string)$this->semester)
->first();
$payload = [
'student_id' => $sid,
'class_section_id' => $secId,
'school_year' => $year,
'semester' => (string)$this->semester,
'updated_by' => $updatedBy,
'updated_at' => $now,
];
if ($exists) {
$studentClass->update((int)$exists['id'], $payload);
} else {
$payload['created_at'] = $now;
$studentClass->insert($payload);
}
}
}
$this->db->transComplete();
if (!$this->db->transStatus()) {
$msg = 'Distribution could not be saved. No official student class rows were changed.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 500) : redirect()->back()->with('error', $msg);
}
// Build a map for section id -> name (for friendly headers)
$nameById = [];
foreach ($letters as $secRow) {
$nameById[(int)$secRow['class_section_id']] = (string)($secRow['class_section_name'] ?? '');
@@ -1056,27 +993,323 @@ class StudentController extends BaseController
$summary = [];
foreach ($buckets as $b) {
$secId = (int)$b['class_section_id'];
$scores = array_map(static fn($s): float => (float)$s['previous_final_score'], $b['assigned']);
$groups = ['90_100' => 0, '80_89' => 0, '70_79' => 0, '69_below' => 0];
$male = 0;
$female = 0;
$studentNames = [];
foreach ($b['assigned'] as $student) {
$groups[$student['score_group']] = ($groups[$student['score_group']] ?? 0) + 1;
$gender = strtolower((string)($student['gender'] ?? ''));
if ($gender === 'female') $female++; else $male++;
$studentName = trim((string)($student['student_name'] ?? ''));
if ($studentName === '') {
$studentName = 'Student #' . (int)($student['student_id'] ?? 0);
}
$studentNames[] = $studentName;
}
$summary[] = [
'class_section_id' => $secId,
'class_section_name' => $nameById[$secId] ?? (string)$secId,
'total' => count($b['assigned']),
'male' => $b['male'],
'female' => $b['female'],
'male' => $male,
'female' => $female,
'score_groups' => $groups,
'average_score' => count($scores) > 0 ? round(array_sum($scores) / count($scores), 2) : null,
'student_names' => $studentNames,
];
}
return $isAjax
? $json(['ok' => true, 'message' => 'Auto distribution completed.', 'sections' => $summary])
: redirect()->back()->with('success', 'Auto distribution completed.');
? $json(['ok' => true, 'message' => 'Draft distribution saved. Students will move to student_class when they enroll.', 'sections' => $summary])
: redirect()->back()->with('success', 'Draft distribution saved.');
} catch (\Throwable $e) {
$msg = 'Auto distribution failed: ' . $e->getMessage();
return $isAjax ? $json(['ok' => false, 'message' => $msg], 500) : redirect()->back()->with('error', $msg);
}
}
private function distributionCandidates(int $classId, string $year): array
{
$rows = $this->db->table('promotion_queue pq')
->select('pq.id AS promotion_queue_id, pq.student_id, pq.school_year_from, pq.to_class_id, students.firstname, students.lastname, students.gender, sd.year_score AS decision_score')
->join('students', 'students.id = pq.student_id', 'left')
->join('student_decisions sd', 'sd.student_id = pq.student_id AND sd.school_year = pq.school_year_from', 'left')
->where('pq.to_class_id', $classId)
->where('pq.school_year_to', $year)
->whereIn('pq.status', ['queued', 'assigned'])
->groupBy('pq.id')
->get()
->getResultArray();
if (empty($rows)) {
return $this->decisionDistributionCandidates($classId, $year);
}
$out = [];
foreach ($rows as $row) {
$score = is_numeric($row['decision_score'] ?? null)
? (float)$row['decision_score']
: $this->previousAverageScore((int)$row['student_id'], (string)($row['school_year_from'] ?? ''));
$score = $score === null ? 0.0 : max(0.0, min(100.0, $score));
$row['previous_final_score'] = $score;
$row['score_group'] = $this->scoreGroup($score);
$row['student_name'] = $this->formatStudentName($row);
$out[] = $row;
}
return $out;
}
private function decisionDistributionCandidates(int $classId, string $targetSchoolYear): array
{
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
if ($previousSchoolYear === null || ! $this->db->tableExists('student_decisions')) {
return [];
}
$rows = $this->db->table('student_decisions sd')
->select('sd.id AS decision_id, sd.student_id, sd.class_section_name, sd.year_score, sd.decision, students.firstname, students.lastname, students.gender')
->join('students', 'students.id = sd.student_id', 'left')
->where('sd.school_year', $previousSchoolYear)
->where('students.is_active', 1)
->orderBy('sd.updated_at', 'DESC')
->orderBy('sd.id', 'DESC')
->get()
->getResultArray();
$seen = [];
$out = [];
foreach ($rows as $row) {
$studentId = (int)($row['student_id'] ?? 0);
if ($studentId <= 0 || isset($seen[$studentId])) {
continue;
}
$seen[$studentId] = true;
$targetClassId = $this->targetClassIdFromDecision(
(string)($row['class_section_name'] ?? ''),
(string)($row['decision'] ?? '')
);
if ($targetClassId !== $classId) {
continue;
}
$score = is_numeric($row['year_score'] ?? null) ? (float)$row['year_score'] : 0.0;
$score = max(0.0, min(100.0, $score));
$out[] = [
'promotion_queue_id' => 0,
'student_id' => $studentId,
'school_year_from' => $previousSchoolYear,
'to_class_id' => $classId,
'student_name' => $this->formatStudentName($row),
'gender' => (string)($row['gender'] ?? ''),
'previous_final_score' => $score,
'score_group' => $this->scoreGroup($score),
];
}
return $out;
}
private function targetClassIdFromDecision(string $classSectionName, string $decision): ?int
{
$decision = strtolower(trim($decision));
$baseName = strtoupper(trim(preg_replace('/-.+$/', '', $classSectionName) ?? ''));
if ($baseName === '') {
return null;
}
$targetBaseName = $baseName;
if ($decision === 'pass') {
if ($baseName === 'KG') {
$targetBaseName = '1';
} elseif (ctype_digit($baseName)) {
$level = (int)$baseName;
$targetBaseName = $level >= 9 ? 'YOUTH' : (string)($level + 1);
} elseif ($baseName === 'YOUTH') {
$targetBaseName = 'YOUTH';
}
}
$row = $this->classSectionModel
->select('class_id')
->where('UPPER(class_section_name)', $targetBaseName)
->where("class_section_name NOT LIKE '%-%'", null, false)
->first();
return $row ? (int)$row['class_id'] : null;
}
private function formatStudentName(array $row): string
{
$name = trim(
trim((string)($row['firstname'] ?? '')) . ' ' .
trim((string)($row['lastname'] ?? ''))
);
return $name !== '' ? $name : 'Student #' . (int)($row['student_id'] ?? 0);
}
private function previousSchoolYearName(string $schoolYear): ?string
{
if (preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches) !== 1) {
return null;
}
return ((int)$matches[1] - 1) . '-' . ((int)$matches[2] - 1);
}
private function previousAverageScore(int $studentId, string $schoolYear): ?float
{
if ($studentId <= 0 || $schoolYear === '') {
return null;
}
$row = $this->db->table('semester_scores')
->select('AVG(semester_score) AS avg_score')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('semester_score IS NOT NULL', null, false)
->get()
->getRowArray();
return is_numeric($row['avg_score'] ?? null) ? (float)$row['avg_score'] : null;
}
private function scoreGroup(float $score): string
{
if ($score >= 90) return '90_100';
if ($score >= 80) return '80_89';
if ($score >= 70) return '70_79';
return '69_below';
}
private function buildBalancedDistribution(array $students, array $sections, int $minPerSection, ?int $maxPerSection): array
{
$sectionCount = count($sections);
$total = count($students);
$baseSize = intdiv($total, $sectionCount);
$remainder = $total % $sectionCount;
$targetSizes = [];
foreach ($sections as $idx => $section) {
$targetSizes[$idx] = $baseSize + ($idx < $remainder ? 1 : 0);
}
$buckets = [];
foreach ($sections as $idx => $section) {
$buckets[$idx] = [
'class_section_id' => (int)$section['class_section_id'],
'assigned' => [],
];
}
$groups = ['90_100' => [], '80_89' => [], '70_79' => [], '69_below' => []];
foreach ($students as $student) {
$groups[$student['score_group']][] = $student;
}
$currentCounts = array_fill(0, $sectionCount, 0);
$allocations = [];
$groupIndex = 0;
foreach ($groups as $groupName => $groupStudents) {
usort($groupStudents, static fn($a, $b): int => ((float)$b['previous_final_score']) <=> ((float)$a['previous_final_score']));
$groupTotal = count($groupStudents);
$base = intdiv($groupTotal, $sectionCount);
$extra = $groupTotal % $sectionCount;
$allocations[$groupName] = array_fill(0, $sectionCount, $base);
foreach ($currentCounts as $idx => $cnt) {
$currentCounts[$idx] += $base;
}
$order = range(0, $sectionCount - 1);
$offset = $groupIndex % $sectionCount;
$order = array_merge(array_slice($order, $offset), array_slice($order, 0, $offset));
usort($order, static function ($a, $b) use ($targetSizes, $currentCounts) {
$remainingA = $targetSizes[$a] - $currentCounts[$a];
$remainingB = $targetSizes[$b] - $currentCounts[$b];
return $remainingB <=> $remainingA;
});
foreach ($order as $sectionIdx) {
if ($extra <= 0) break;
if ($currentCounts[$sectionIdx] >= $targetSizes[$sectionIdx]) continue;
$allocations[$groupName][$sectionIdx]++;
$currentCounts[$sectionIdx]++;
$extra--;
}
$groups[$groupName] = $groupStudents;
$groupIndex++;
}
foreach ($groups as $groupName => $groupStudents) {
$quotas = $allocations[$groupName];
foreach ($groupStudents as $idx => $student) {
$round = intdiv($idx, max(1, $sectionCount));
$order = range(0, $sectionCount - 1);
if ($round % 2 === 1) {
$order = array_reverse($order);
}
foreach ($order as $sectionIdx) {
if (($quotas[$sectionIdx] ?? 0) <= 0) continue;
$buckets[$sectionIdx]['assigned'][] = $student;
$quotas[$sectionIdx]--;
break;
}
}
}
return $this->balanceDistributionAverages($buckets, $minPerSection, $maxPerSection);
}
private function balanceDistributionAverages(array $buckets, int $minPerSection, ?int $maxPerSection): array
{
for ($i = 0; $i < 50; $i++) {
$averages = array_map(function ($bucket): float {
if (empty($bucket['assigned'])) return 0.0;
$scores = array_map(static fn($student): float => (float)$student['previous_final_score'], $bucket['assigned']);
return array_sum($scores) / count($scores);
}, $buckets);
$highIdx = array_keys($averages, max($averages), true)[0];
$lowIdx = array_keys($averages, min($averages), true)[0];
if (($averages[$highIdx] - $averages[$lowIdx]) <= 1.0) {
break;
}
$best = null;
foreach ($buckets[$highIdx]['assigned'] as $hiPos => $hiStudent) {
foreach ($buckets[$lowIdx]['assigned'] as $loPos => $loStudent) {
if ($hiStudent['score_group'] !== $loStudent['score_group']) continue;
$trial = $buckets;
$trial[$highIdx]['assigned'][$hiPos] = $loStudent;
$trial[$lowIdx]['assigned'][$loPos] = $hiStudent;
$trialAvg = array_map(function ($bucket): float {
if (empty($bucket['assigned'])) return 0.0;
$scores = array_map(static fn($student): float => (float)$student['previous_final_score'], $bucket['assigned']);
return array_sum($scores) / count($scores);
}, $trial);
$newSpread = max($trialAvg) - min($trialAvg);
if ($newSpread < ($averages[$highIdx] - $averages[$lowIdx])) {
$best = [$hiPos, $loPos, $newSpread];
}
}
}
if ($best === null) {
break;
}
[$hiPos, $loPos] = $best;
$tmp = $buckets[$highIdx]['assigned'][$hiPos];
$buckets[$highIdx]['assigned'][$hiPos] = $buckets[$lowIdx]['assigned'][$loPos];
$buckets[$lowIdx]['assigned'][$loPos] = $tmp;
}
return $buckets;
}
/**
* API: Return totals per base class (KG, 1..9, Youth) for promotion_queue in selected year
* Only counts students with enrollment status 'payment pending' or 'enrolled'.
* API: Return promoted-student totals per base class for the selected year.
*/
public function promotionTotalsApi()
{
@@ -1111,22 +1344,24 @@ class StudentController extends BaseController
$out = [];
foreach ($wanted as $r) {
$classId = (int)$r['class_id'];
// candidates from promotion_queue for this base class in the target year
$cands = $this->db->table('promotion_queue pq')
->select('pq.student_id')
->join('enrollments e', 'e.student_id = pq.student_id AND e.school_year = ' . $this->db->escape($year), 'left')
->where('pq.to_class_id', $classId)
->where('pq.school_year_to', $year)
->whereIn('pq.status', ['queued','assigned','applied'])
->whereIn('e.enrollment_status', ['payment pending', 'enrolled'])
->whereIn('pq.status', ['queued','assigned'])
->groupBy('pq.student_id')
->get()->getResultArray();
$total = count($cands);
if ($total === 0) {
$total = count($this->decisionDistributionCandidates($classId, $year));
}
$out[] = [
'class_id' => $classId,
'class_section_id' => (int)($r['class_section_id'] ?? 0),
'class_section_name'=> (string)($r['class_section_name'] ?? ''),
'total' => count($cands),
'total' => $total,
'sections' => $this->savedDistributionSections($classId, $year),
];
}
@@ -1136,6 +1371,51 @@ class StudentController extends BaseController
}
}
private function savedDistributionSections(int $classId, string $year): array
{
if (! $this->db->tableExists('student_section_distribution_drafts')) {
return [];
}
$rows = $this->db->table('student_section_distribution_drafts d')
->select('d.class_section_id, cs.class_section_name, students.firstname, students.lastname, d.student_id')
->join('classSection cs', 'cs.class_section_id = d.class_section_id', 'left')
->join('students', 'students.id = d.student_id', 'left')
->where('d.class_id', $classId)
->where('d.school_year', $year)
->where('d.status', 'pending')
->orderBy('cs.class_section_name', 'ASC')
->orderBy('students.lastname', 'ASC')
->orderBy('students.firstname', 'ASC')
->get()
->getResultArray();
$sections = [];
foreach ($rows as $row) {
$sectionId = (int)($row['class_section_id'] ?? 0);
if ($sectionId <= 0) {
continue;
}
if (!isset($sections[$sectionId])) {
$sections[$sectionId] = [
'class_section_id' => $sectionId,
'class_section_name' => (string)($row['class_section_name'] ?? $sectionId),
'total' => 0,
'student_names' => [],
];
}
$name = trim(trim((string)($row['firstname'] ?? '')) . ' ' . trim((string)($row['lastname'] ?? '')));
if ($name === '') {
$name = 'Student #' . (int)($row['student_id'] ?? 0);
}
$sections[$sectionId]['student_names'][] = $name;
$sections[$sectionId]['total']++;
}
return array_values($sections);
}
/**
* POST /students/update/{id}
*/
+24 -20
View File
@@ -256,31 +256,14 @@ class TeacherController extends BaseController
public function teacherClassAssignment()
{
$selectedYear = trim((string)($this->request->getGet('schoolYear') ?? $this->request->getGet('school_year') ?? ''));
if ($selectedYear === '') {
$selectedYear = (string)($this->schoolYear ?? 'Not Set');
if ($redirect = $this->redirectWithoutLegacyTermFilters('administrator/teacher_class_assignment')) {
return $redirect;
}
// Build distinct school years from classSection (fallback to configured year)
try {
$yearsRows = $this->db->table('classSection')
->distinct()
->select('school_year')
->orderBy('school_year', 'DESC')
->get()->getResultArray();
$schoolYears = array_values(array_filter(array_map(static function ($r) {
return isset($r['school_year']) ? (string)$r['school_year'] : null;
}, $yearsRows)));
if (empty($schoolYears) && !empty($this->schoolYear)) {
$schoolYears = [(string)$this->schoolYear];
}
} catch (\Throwable $e) {
$schoolYears = !empty($this->schoolYear) ? [(string)$this->schoolYear] : [];
}
$selectedYear = (string)($this->schoolYear ?? 'Not Set');
return view('administrator/teacher_class_assignment', [
'schoolYear' => (string)$selectedYear,
'schoolYears' => $schoolYears,
'currentYear' => (string)($this->schoolYear ?? ''),
'isCurrentYear' => ((string)$selectedYear === (string)($this->schoolYear ?? '')),
'missingYear' => empty($this->schoolYear),
@@ -292,6 +275,27 @@ class TeacherController extends BaseController
]);
}
private function redirectWithoutLegacyTermFilters(string $route): ?\CodeIgniter\HTTP\RedirectResponse
{
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
$query = $this->request->getGet();
$hasLegacyTermFilter = false;
foreach ($legacyTermKeys as $key) {
if (array_key_exists($key, $query)) {
unset($query[$key]);
$hasLegacyTermFilter = true;
}
}
if (!$hasLegacyTermFilter) {
return null;
}
$target = site_url($route) . (!empty($query) ? '?' . http_build_query($query) : '');
return redirect()->to($target);
}
private function buildTeacherClassAssignmentPayload(?string $forYear = null): array
{
$classSectionModel = new ClassSectionModel();
+29
View File
@@ -81,6 +81,27 @@ class UserController extends BaseController
return hash('sha256', $token);
}
private function redirectWithoutLegacyTermFilters(string $route): ?\CodeIgniter\HTTP\RedirectResponse
{
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
$query = $this->request->getGet();
$hasLegacyTermFilter = false;
foreach ($legacyTermKeys as $key) {
if (array_key_exists($key, $query)) {
unset($query[$key]);
$hasLegacyTermFilter = true;
}
}
if (!$hasLegacyTermFilter) {
return null;
}
$target = site_url($route) . (!empty($query) ? '?' . http_build_query($query) : '');
return redirect()->to($target);
}
// Method to show the home page
public function home()
{
@@ -113,6 +134,10 @@ class UserController extends BaseController
helper('url');
if ($redirect = $this->redirectWithoutLegacyTermFilters('user/user_list')) {
return $redirect;
}
return view('user/user_list', [
'userListEndpoint' => site_url('api/users'),
]);
@@ -854,6 +879,10 @@ class UserController extends BaseController
helper('url');
if ($redirect = $this->redirectWithoutLegacyTermFilters('user/login_activity')) {
return $redirect;
}
$perPage = (int) ($this->request->getGet('per_page') ?? 25);
return view('user/login_activity', [
@@ -31,8 +31,8 @@ class CreateStudentSectionDistributionDrafts extends Migration
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['student_id', 'school_year'], 'unique_distribution_draft_student_year');
$this->forge->addKey(['class_id', 'school_year', 'status'], false, 'distribution_draft_class_year_status');
$this->forge->addKey(['class_section_id', 'school_year'], false, 'distribution_draft_section_year');
$this->forge->addKey(['class_id', 'school_year', 'status'], false, false, 'distribution_draft_class_year_status');
$this->forge->addKey(['class_section_id', 'school_year'], false, false, 'distribution_draft_section_year');
$this->forge->createTable('student_section_distribution_drafts');
}
@@ -0,0 +1,54 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class FixStudentSectionDistributionDraftIndexes extends Migration
{
public function up()
{
if (! $this->db->tableExists('student_section_distribution_drafts')) {
return;
}
$this->dropIndexIfExists('student_section_distribution_drafts', 'class_id_school_year_status');
$this->dropIndexIfExists('student_section_distribution_drafts', 'class_section_id_school_year');
$this->dropIndexIfExists('student_section_distribution_drafts', 'distribution_draft_class_year_status');
$this->dropIndexIfExists('student_section_distribution_drafts', 'distribution_draft_section_year');
$this->db->query(
'CREATE INDEX distribution_draft_class_year_status ' .
'ON student_section_distribution_drafts (class_id, school_year, status)'
);
$this->db->query(
'CREATE INDEX distribution_draft_section_year ' .
'ON student_section_distribution_drafts (class_section_id, school_year)'
);
}
public function down()
{
if (! $this->db->tableExists('student_section_distribution_drafts')) {
return;
}
$this->dropIndexIfExists('student_section_distribution_drafts', 'distribution_draft_class_year_status');
$this->dropIndexIfExists('student_section_distribution_drafts', 'distribution_draft_section_year');
}
private function dropIndexIfExists(string $table, string $indexName): void
{
$row = $this->db->query(
'SHOW INDEX FROM ' . $this->db->protectIdentifiers($table, true) .
' WHERE Key_name = ' . $this->db->escape($indexName)
)->getRowArray();
if ($row) {
$this->db->query(
'ALTER TABLE ' . $this->db->protectIdentifiers($table, true) .
' DROP INDEX ' . $this->db->protectIdentifiers($indexName)
);
}
}
}
+30 -4
View File
@@ -365,13 +365,33 @@ class StudentModel extends Model
$schoolYear = trim((string) $schoolYear);
$studentClassJoin = 'student_class.student_id = students.id';
$enrollmentJoin = 'enrollments.student_id = students.id';
$selectedYearFilter = '';
if ($schoolYear !== '') {
$studentClassJoin .= ' AND student_class.school_year = ' . $this->db->escape($schoolYear);
$enrollmentJoin .= ' AND enrollments.school_year = ' . $this->db->escape($schoolYear);
$escapedSchoolYear = $this->db->escape($schoolYear);
$studentClassJoin .= ' AND student_class.school_year = ' . $escapedSchoolYear;
$enrollmentJoin .= ' AND enrollments.school_year = ' . $escapedSchoolYear;
$selectedYearFilter = "
(
student_class.student_id IS NOT NULL
OR enrollments.student_id IS NOT NULL
OR (
NOT EXISTS (
SELECT 1
FROM student_class sc_history
WHERE sc_history.student_id = students.id
)
AND NOT EXISTS (
SELECT 1
FROM enrollments e_history
WHERE e_history.student_id = students.id
)
)
)
";
}
return $this->select('
$builder = $this->select('
students.*,
student_class.class_section_id,
enrollments.enrollment_status,
@@ -381,7 +401,13 @@ class StudentModel extends Model
')
->join('student_class', $studentClassJoin, 'left')
->join('enrollments', $enrollmentJoin, 'left')
->join('users u', 'u.id = students.parent_id', 'left')
->join('users u', 'u.id = students.parent_id', 'left');
if ($selectedYearFilter !== '') {
$builder->where($selectedYearFilter, null, false);
}
return $builder
// keep your original grouping as-is so behavior doesn't change
->groupBy('students.id, student_class.class_section_id, enrollments.enrollment_status, enrollments.admission_status')
->findAll();
-28
View File
@@ -17,34 +17,6 @@
</div>
<br>
<!-- School Year / Semester Filter Form -->
<form method="get" action="<?= base_url('administrator/calendar_view') ?>" class="mb-3 text-center d-flex gap-2 justify-content-center align-items-end flex-wrap">
<div>
<label for="school_year" class="form-label">School Year:</label>
<select name="school_year" id="school_year" class="form-select w-auto d-inline-block ms-2">
<?php foreach (range(date('Y') + 1, 2023) as $y):
$sy = ($y - 1) . '-' . $y; ?>
<option value="<?= $sy ?>" <?= $sy === ($schoolYear ?? '') ? 'selected' : '' ?>>
<?= $sy ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label for="semester" class="form-label">Semester:</label>
<?php $semVal = (string)($semester ?? ($_GET['semester'] ?? '')); ?>
<select name="semester" id="semester" class="form-select w-auto d-inline-block ms-2">
<option value="">—</option>
<option value="Fall" <?= (strcasecmp($semVal,'Fall')===0?'selected':'') ?>>Fall</option>
<option value="Spring" <?= (strcasecmp($semVal,'Spring')===0?'selected':'') ?>>Spring</option>
</select>
</div>
<div>
<button type="submit" class="btn btn-secondary">Apply</button>
<a href="<?= base_url('administrator/calendar_view') ?>" class="btn btn-outline-secondary">Reset</a>
</div>
</form>
<?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success">
<?= session()->getFlashdata('success') ?>
@@ -4,7 +4,6 @@
<div class="wrapper">
<div class="content">
<h2 class="text-center mt-4 mb-3">Emergency Contact Information</h2>
<?= $this->include('partials/academic_filter') ?>
<div class="table-responsive">
<table id="emergencyTable" class="display table table-bordered table-striped align-middle">
<thead class="table-dark">
@@ -4,7 +4,6 @@
<div class="wrapper">
<div class="content">
<h2 class="text-center mt-4 mb-3">Parent Profiles</h2>
<?= $this->include('partials/academic_filter') ?>
<table id="myTable" class="display table table-striped table-bordered align-middle" style="width:100%">
<thead>
<tr>
@@ -5,37 +5,26 @@
<div class="content">
<h2 class="text-center mt-4 mb-3">Auto-Distribute Students into Sections</h2>
<div class="row g-2 align-items-center justify-content-center mb-3">
<div class="col-auto"><label for="adYearSelect" class="col-form-label">School year</label></div>
<div class="col-auto">
<form method="get" action="<?= site_url('administrator/sections/auto-distribute') ?>" class="d-flex align-items-center gap-2">
<select id="adYearSelect" name="schoolYear" class="form-select form-select-sm" style="min-width: 180px;">
<?php
$years = isset($schoolYears) && is_array($schoolYears) ? $schoolYears : [];
if (empty($years) && !empty($selectedYear)) $years = [$selectedYear];
foreach ($years as $y): $val = is_array($y) && isset($y['school_year']) ? $y['school_year'] : (string)$y; ?>
<option value="<?= esc($val) ?>" <?= ((string)($selectedYear ?? '') === (string)$val) ? 'selected' : '' ?>>
<?= esc($val) ?>
</option>
<?php endforeach; ?>
</select>
<button type="submit" class="btn btn-secondary btn-sm">Apply</button>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="row g-3 align-items-end mb-2">
<div class="col-md-3">
<label for="globalPer" class="form-label">Students per section</label>
<input type="number" min="1" id="globalPer" class="form-control" placeholder="e.g. 20" />
<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-4 d-flex gap-2">
<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-7 text-end">
<div class="col-md-3 text-end">
<span id="pageMsg" class="small text-muted"></span>
</div>
</div>
@@ -46,7 +35,7 @@
<tr id="tblHeader">
<th>Class</th>
<th>Total</th>
<th>Sections Needed</th>
<th>Sections</th>
<th>Actions</th>
</tr>
</thead>
@@ -73,17 +62,18 @@
const tblBody = document.getElementById('tblBody');
const tblHeader = document.getElementById('tblHeader');
const perInput = document.getElementById('globalPer');
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');
let headerSections = []; // list of generated section names as columns
let rowIndexByClassId = {}; // mapping to locate rows
function calcNeeded(total) {
const per = parseInt(perInput.value || '0', 10);
if (!per || per <= 0) return '';
return Math.ceil(total / per);
function selectedSectionCount() {
const count = parseInt(sectionCountInput.value || '0', 10);
return count > 0 ? count : '';
}
function ensureSectionColumns(sectionNames) {
@@ -129,7 +119,7 @@
const tdNeed = document.createElement('td');
tdNeed.className = 'text-end need-cell';
tdNeed.textContent = calcNeeded(r.total);
tdNeed.textContent = selectedSectionCount();
tr.appendChild(tdNeed);
const tdAct = document.createElement('td');
@@ -143,24 +133,36 @@
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);
}
});
}
function updateNeeds() {
document.querySelectorAll('#tblBody tr').forEach(function(tr){
const total = parseInt(tr.children[1].textContent || '0', 10);
const needCell = tr.querySelector('.need-cell');
if (needCell) needCell.textContent = calcNeeded(total);
if (needCell) needCell.textContent = selectedSectionCount();
});
}
function runDistribution(baseSectionId, baseName) {
const per = parseInt(perInput.value || '0', 10);
if (!per || per <= 0) { msgEl.textContent = 'Enter students per section first.'; return; }
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('students_per_section', String(per));
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');
@@ -184,24 +186,39 @@
msgEl.textContent = res && res.message ? res.message : 'Completed.';
// Collect section names, then ensure header columns
const names = res.sections.map(s => s.class_section_name).filter(Boolean);
ensureSectionColumns(names);
// Fill row cells for this class
const tr = Array.from(document.querySelectorAll('#tblBody tr')).find(function(_tr){
return (_tr.children[0].textContent || '') === (baseName || '');
});
if (!tr) return;
res.sections.forEach(function(s){
const td = tr.querySelector('td[data-section="'+ s.class_section_name +'"]');
if (td) td.textContent = (s.total ?? 0) + ' (M'+ (s.male ?? 0) + '/F'+ (s.female ?? 0) +')';
});
renderSectionsForRow(baseName || '', 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);
const tr = Array.from(document.querySelectorAll('#tblBody tr')).find(function(_tr){
return (_tr.children[0].textContent || '') === (baseName || '');
});
if (!tr) return;
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 list = document.createElement('div');
list.className = 'small';
list.style.whiteSpace = 'normal';
list.textContent = studentNames.length ? studentNames.join(', ') : 'No students assigned';
td.appendChild(list);
});
}
function loadTotals() {
msgEl.textContent = 'Loading totals...';
fetch(totalsUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' }})
@@ -215,10 +232,14 @@
.catch(() => { msgEl.textContent = 'Failed to load totals.'; });
}
refreshBtn.addEventListener('click', function(){ updateNeeds(); });
refreshBtn.addEventListener('click', function(){ loadTotals(); });
document.getElementById('generateAllBtn').addEventListener('click', async function(){
const per = parseInt(perInput.value || '0', 10);
if (!per || per <= 0) { msgEl.textContent = 'Enter students per section first.'; return; }
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 }))
@@ -231,7 +252,7 @@
}
msgEl.textContent = 'All distributions completed.';
});
perInput.addEventListener('input', function(){ updateNeeds(); });
sectionCountInput.addEventListener('input', function(){ updateNeeds(); });
loadTotals();
})();
@@ -5,29 +5,6 @@
<div class="content">
<h2 class="text-center mt-4 mb-3">Student Class Assignment</h2>
<div class="row g-2 align-items-center justify-content-center mb-3">
<div class="col-auto"><label for="scaYearSelect" class="col-form-label">School year</label></div>
<div class="col-auto">
<form method="get" action="<?= site_url('administrator/student_class_assignment') ?>" class="d-flex align-items-center gap-2">
<select id="scaYearSelect" name="schoolYear" class="form-select form-select-sm" style="min-width: 180px;">
<?php
$years = isset($schoolYears) && is_array($schoolYears) ? $schoolYears : [];
if (empty($years) && !empty($selectedYear)) $years = [$selectedYear];
foreach ($years as $y): $val = is_array($y) && isset($y['school_year']) ? $y['school_year'] : (string)$y; ?>
<option value="<?= esc($val) ?>" <?= ((string)($selectedYear ?? '') === (string)$val) ? 'selected' : '' ?>>
<?= esc($val) ?>
</option>
<?php endforeach; ?>
</select>
<?php $semVal = (string)($semester ?? ($_GET['semester'] ?? '')); ?>
<label for="scaSemesterSelect" class="col-form-label">Semester</label>
<select id="scaSemesterSelect" 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($isCurrentYear) && !$isCurrentYear): ?>
<div class="col-auto"><span class="badge bg-secondary">Read-only (Past Year)</span></div>
<?php endif; ?>
@@ -59,10 +36,7 @@
<label for="autoDistPer" class="form-label">Students per section</label>
<input type="number" min="1" id="autoDistPer" name="students_per_section" class="form-control" required />
</div>
<div class="col-md-3">
<label for="autoDistYear" class="form-label">School year</label>
<input type="text" id="autoDistYear" name="school_year" value="<?= esc($selectedYear ?? '') ?>" class="form-control" />
</div>
<input type="hidden" id="autoDistYear" name="school_year" value="<?= esc($selectedYear ?? '') ?>" />
<div class="col-md-2 d-grid">
<button type="submit" class="btn btn-outline-primary">Auto-Distribute</button>
</div>
@@ -17,25 +17,6 @@
<a href="<?= site_url('configuration/configuration_view') ?>" class="alert-link">Add/Edit Configuration</a> (key: <code>school_year</code>).
</div>
<?php endif; ?>
<div class="d-flex justify-content-center align-items-center gap-2 mb-3">
<label for="tcaYearSelect" class="col-form-label">School year</label>
<form method="get" action="<?= site_url('administrator/teacher_class_assignment') ?>" class="d-flex align-items-center gap-2">
<select id="tcaYearSelect" name="schoolYear" class="form-select form-select-sm" style="min-width: 180px;">
<?php
$years = isset($schoolYears) && is_array($schoolYears) ? $schoolYears : [];
if (empty($years) && !empty($schoolYear)) $years = [$schoolYear];
foreach ($years as $y): $val = is_array($y) && isset($y['school_year']) ? $y['school_year'] : (string)$y; ?>
<option value="<?= esc($val) ?>" <?= ((string)($schoolYear ?? '') === (string)$val) ? 'selected' : '' ?>>
<?= esc($val) ?>
</option>
<?php endforeach; ?>
</select>
<button type="submit" class="btn btn-secondary btn-sm">Apply</button>
</form>
<?php if (isset($isCurrentYear) && !$isCurrentYear): ?>
<span class="badge bg-secondary">Read-only (Past Year)</span>
<?php endif; ?>
</div>
<?= view('partials/flash_messages') ?>
<div id="assignmentMessages"></div>
-1
View File
@@ -3,7 +3,6 @@
<div class="container-fluid mt-4">
<h2 class="text-center mt-4 mb-3">Discounts</h2>
<?= $this->include('partials/academic_filter') ?>
<div class="d-flex gap-2 mb-3 justify-content-end">
<div>
<a href="/discount/create" class="btn btn-primary">Create New Voucher</a>
@@ -12,34 +12,11 @@
</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>
<?php if (empty($isCurrentYear)): ?>
<div class="text-center mb-2">
<span class="badge bg-secondary">Read-only (Past Year)</span>
</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>
<?php endif; ?>
<!-- Display success and error messages -->
<?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success">
@@ -161,7 +138,7 @@
<!-- Update Enrollment -->
<td>
<select <?= (isset($selectedYear, $currentYear) && (string)$selectedYear !== (string)$currentYear) ? 'disabled' : '' ?>
<select <?= empty($isCurrentYear) ? 'disabled' : '' ?>
name="enrollment_status[<?= $sid ?>]"
class="form-control enrollment-status"
data-student-id="<?= $sid ?>"
@@ -182,7 +159,7 @@
<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' : '' ?>
<select class="form-select form-select-sm assign-class-select" <?= empty($isCurrentYear) ? 'disabled' : '' ?>
data-student-id="<?= $sid ?>"
data-parent-id="<?= $pid ?>">
<option value="">— Select class section —</option>
@@ -212,7 +189,7 @@
</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"' : '' ?> >
<button type="button" id="bulkSaveGenerateButton" class="btn btn-success btn-lg" <?= empty($isCurrentYear) ? 'disabled title="Editing disabled for non-current year"' : '' ?> >
Save &amp; Generate Invoice
</button>
<span id="bulkProgress" class="small text-muted d-none">Processing…</span>
@@ -9,7 +9,6 @@
<span class="badge bg-secondary ms-2"><?= (int)$total_new ?></span>
<?php endif; ?>
</h2>
<?= $this->include('partials/academic_filter') ?>
<!-- Flash messages -->
<?php if (session()->getFlashdata('success')): ?>
-1
View File
@@ -3,7 +3,6 @@
<div class="container-fluid mt-4">
<h2 class="text-center mt-4 mb-3">Expense / Purchase</h2>
<?= $this->include('partials/academic_filter') ?>
<div class="d-flex gap-2 mb-3 justify-content-end">
<a href="<?= base_url('expenses/create') ?>" class="btn btn-success mb-3">Add New</a>
</div>
-1
View File
@@ -2,7 +2,6 @@
<?= $this->section('content') ?>
<div class="container-fluid">
<h2 class="text-center mt-4 mb-3">Incidents Management</h2>
<?= $this->include('partials/academic_filter') ?>
<!-- Flash Success Message -->
<?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success">
+56 -99
View File
@@ -11,7 +11,6 @@
data-notifications-endpoint="<?= esc($endpoint) ?>"
data-default-target-group="<?= esc($defaultTarget) ?>">
<h2 class="text-center mt-4 mb-3">Active Notifications</h2>
<?= $this->include('partials/academic_filter') ?>
<?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
@@ -61,53 +60,6 @@
<?= $this->section('scripts') ?>
<script>
document.addEventListener('DOMContentLoaded', () => {
// --- FixedHeader helpers / assets ---
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;
}
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);
});
}
function ensureFixedHeaderAssets() {
const hasFH = !!(window.jQuery && window.jQuery.fn && window.jQuery.fn.dataTable && window.jQuery.fn.dataTable.FixedHeader);
if (hasFH) return Promise.resolve();
return 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')
]).catch(() => {});
}
// Start fetching FixedHeader in parallel
ensureFixedHeaderAssets();
const container = document.querySelector('[data-notifications-endpoint]');
if (!container) {
return;
@@ -123,6 +75,7 @@
const resetBtn = document.getElementById('resetFilterBtn');
let lastPayload = [];
let dataTable = null;
const showError = (message) => {
if (!alertBox) {
@@ -140,23 +93,16 @@
alertBox.textContent = '';
};
const destroyDataTable = () => {
if (!window.jQuery || !window.jQuery.fn || !window.jQuery.fn.DataTable) {
return;
}
if (window.jQuery.fn.DataTable.isDataTable(tableSelector)) {
window.jQuery(tableSelector).DataTable().clear().destroy();
}
};
const initDataTable = () => {
if (!window.jQuery || !window.jQuery.fn || !window.jQuery.fn.DataTable) {
return;
return null;
}
const opts = {
if (dataTable) {
return dataTable;
}
dataTable = window.jQuery(tableSelector).DataTable({
pageLength: 25,
responsive: true,
// Sort by Scheduled At desc, then Created At desc by default
order: [[5, 'desc'], [7, 'desc']],
columnDefs: [
{
@@ -164,22 +110,9 @@
orderable: false,
},
],
};
});
if (window.jQuery.fn.dataTable && window.jQuery.fn.dataTable.FixedHeader) {
opts.fixedHeader = { header: true, headerOffset: getFixedHeaderOffset() };
}
const dt = window.jQuery(tableSelector).DataTable(opts);
// Attach FixedHeader if it loads slightly later
if (!opts.fixedHeader) {
ensureFixedHeaderAssets().then(() => {
if (window.jQuery.fn.dataTable && window.jQuery.fn.dataTable.FixedHeader) {
try { new window.jQuery.fn.dataTable.FixedHeader(dt, { header: true, headerOffset: getFixedHeaderOffset() }); } catch (_) {}
}
});
}
return dataTable;
};
const formatDateTime = (value) => {
@@ -208,21 +141,32 @@
};
const renderTable = () => {
destroyDataTable();
tableBody.innerHTML = '';
if (!dataTable && window.jQuery && window.jQuery.fn && window.jQuery.fn.DataTable) {
tableBody.innerHTML = '';
}
const dt = initDataTable();
if (dt) {
dt.clear();
} else {
tableBody.innerHTML = '';
}
if (!Array.isArray(lastPayload) || lastPayload.length === 0) {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 8;
cell.classList.add('text-center', 'text-muted');
cell.textContent = 'No active notifications found.';
row.appendChild(cell);
tableBody.appendChild(row);
initDataTable();
if (dt) {
dt.draw();
} else {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 8;
cell.classList.add('text-center', 'text-muted');
cell.textContent = 'No active notifications found.';
row.appendChild(cell);
tableBody.appendChild(row);
}
return;
}
const rows = [];
lastPayload.forEach((entry, index) => {
const row = document.createElement('tr');
if (entry.isExpired) {
@@ -265,10 +209,15 @@
row.appendChild(cell);
});
tableBody.appendChild(row);
if (!dt) {
tableBody.appendChild(row);
}
rows.push(row);
});
initDataTable();
if (dt) {
dt.rows.add(rows).draw();
}
};
const buildUrl = (target) => {
@@ -290,11 +239,15 @@
clearError();
const url = buildUrl(targetGroup);
tableBody.innerHTML = `
<tr>
<td colspan="8" class="text-center text-muted">Loading notifications…</td>
</tr>
`;
if (dataTable) {
dataTable.clear().draw();
} else {
tableBody.innerHTML = `
<tr>
<td colspan="8" class="text-center text-muted">Loading notifications…</td>
</tr>
`;
}
return fetch(url, {
headers: {
@@ -314,12 +267,16 @@
})
.catch((error) => {
showError(error.message || 'Unable to load notifications.');
destroyDataTable();
tableBody.innerHTML = `
<tr>
<td colspan="8" class="text-center text-danger">Failed to load notifications.</td>
</tr>
`;
const dt = initDataTable();
if (dt) {
dt.clear().draw();
} else {
tableBody.innerHTML = `
<tr>
<td colspan="8" class="text-center text-danger">Failed to load notifications.</td>
</tr>
`;
}
});
};
-1
View File
@@ -15,7 +15,6 @@
data-csrf-name="<?= esc($csrfTokenName) ?>"
data-csrf-value="<?= esc($csrfTokenValue) ?>">
<h2 class="text-center mt-4 mb-3">Deleted Notifications</h2>
<?= $this->include('partials/academic_filter') ?>
<?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
+16 -1
View File
@@ -1,4 +1,19 @@
<?php if (!empty($schoolYearSelectorEnabled) && isset($schoolYearContext, $schoolYearOptions)): ?>
<?php
$currentPath = trim(service('request')->getUri()->getPath(), '/');
$hiddenOnPaths = [
'administrator/emergency_contact',
'flags/flags_management',
'administrator/calendar_view',
'administrator/student_class_assignment',
'administrator/sections/auto-distribute',
'admin/enrollment/new-students',
'payment/unpaid-parents',
'discounts/list',
'expenses/index',
'reimbursements/index',
];
?>
<?php if (!in_array($currentPath, $hiddenOnPaths, true) && !empty($schoolYearSelectorEnabled) && isset($schoolYearContext, $schoolYearOptions)): ?>
<?php
$query = service('request')->getUri()->getQuery();
$returnTo = current_url() . ($query !== '' ? '?' . $query : '');
+15 -43
View File
@@ -1,6 +1,7 @@
<!-- app/Views/payment/extra_charges.php -->
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php $isSchoolYearReadonly = (bool)($isSchoolYearReadonly ?? false); ?>
<div class="container-fluid py-4">
@@ -10,46 +11,23 @@
<?php if (session()->get('error')): ?>
<div class="alert alert-danger"><?= session()->get('error') ?></div>
<?php endif; ?>
<?php if ($isSchoolYearReadonly): ?>
<div class="alert alert-warning" role="alert">
This school year is read-only. Charges cannot be changed.
</div>
<?php endif; ?>
<!-- Centered School Year filter (match compact design) -->
<div class="d-flex justify-content-center mb-3">
<form id="chargesYearFilter" class="d-flex align-items-center gap-3" method="get" action="<?= site_url('admin/charges') ?>">
<div class="d-flex justify-content-between align-items-center mb-3 gap-3 flex-wrap">
<h3 class="mb-0">
Add &amp; Deduct Charges
<span class="text-muted ms-2">(<?= esc($schoolYear) ?> - <?= esc($semester) ?>)</span>
</h3>
<div class="d-flex justify-content-between align-items-center mb-3 gap-3 flex-wrap">
<h3 class="mb-0">
Add &amp; Deduct Charges
<span class="text-muted ms-2">(<?= esc($schoolYear) ?> - <?= esc($semester) ?>)</span>
</h3>
<div class="d-flex align-items-center gap-3 flex-wrap">
<label for="schoolYear" class="form-label mb-0">School year</label>
<select id="schoolYear"
name="school_year"
class="form-select form-select-sm rounded-pill px-3"
style="min-width: 180px; width: auto;">
<?php foreach (($schoolYears ?? []) as $sy): ?>
<option value="<?= esc($sy) ?>" <?= (string)$schoolYear === (string)$sy ? 'selected' : '' ?>>
<?= esc($sy) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="d-flex gap-2 mb-3 justify-content-end"></div>
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#chargeModal">
<i class="bi bi-plus-circle"></i> Add Charge
</button>
</div>
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#chargeModal" <?= $isSchoolYearReadonly ? 'disabled' : '' ?>>
<i class="bi bi-plus-circle"></i> Add Charge
</button>
</div>
<?php if (!empty($status)): ?>
<input type="hidden" name="status" value="<?= esc($status) ?>">
<?php endif; ?>
<?php if (!empty($parentId)): ?>
<input type="hidden" name="parent_id" value="<?= (int)$parentId ?>">
<?php endif; ?>
</form>
</div>
<div class="card shadow-sm">
<div class="card-body table-responsive">
<?php $pageTotal = 0.0; if (!empty($rows)) { foreach ($rows as $__r) { $pageTotal += (float)($__r['amount'] ?? 0); } } ?>
@@ -192,11 +170,6 @@
<?= $this->section('scripts') ?>
<script>
$(function() {
// Auto-apply School Year when selection changes (no Filter button)
$('#chargesYearFilter select[name="school_year"]').on('change', function() {
this.form.submit();
});
const $parent = $('#parentSelect');
const $invId = $('#invoiceIdHidden');
const $invNum = $('#invoiceNumberDisplay');
@@ -221,8 +194,7 @@
if (!pid) return;
$.getJSON('<?= site_url('admin/charges/invoices') ?>', {
parent_id: pid,
school_year: '<?= esc($schoolYear) ?>'
parent_id: pid
}).done(function(data) {
const items = (data && Array.isArray(data.results)) ? data.results : [];
const chosen = pickDefaultInvoice(items);
-2
View File
@@ -27,8 +27,6 @@
<div class="hint">Balance is summed across invoices for the selected school year.</div>
</div>
<?= $this->include('partials/academic_filter') ?>
<?php $flashStatus = session()->getFlashdata('status'); $flashError = session()->getFlashdata('error'); ?>
<?php if (!empty($flashStatus)): ?>
<div class="alert alert-success"><?= esc($flashStatus) ?></div>
+7 -1
View File
@@ -1,5 +1,6 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php $isSchoolYearReadonly = (bool) ($isSchoolYearReadonly ?? false); ?>
<div class="container-fluid py-5">
<div class="row">
@@ -10,6 +11,11 @@
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<?php endif; ?>
<?php if ($isSchoolYearReadonly): ?>
<div class="alert alert-warning" role="alert">
This school year is read-only. Print request statuses cannot be changed.
</div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<?= session()->getFlashdata('error') ?>
@@ -90,7 +96,7 @@
</span>
</td>
<td>
<?php if ($request['status'] != 'delivered'): ?>
<?php if (!$isSchoolYearReadonly && $request['status'] != 'delivered'): ?>
<form action="<?= site_url('print-requests/update/' . $request['id']) ?>" method="post" class="d-flex">
<?= csrf_field() ?>
<input type="hidden" name="admin_id" value="<?= session()->get('user_id') ?>">
-23
View File
@@ -5,16 +5,6 @@
<h2 class="text-center mt-4 mb-3">Reimbursed Expenses</h2>
<form method="get" action="<?= base_url('reimbursements') ?>" class="row g-3 mb-4">
<!-- Semester -->
<div class="col-md-2">
<label class="form-label">Semester</label>
<select name="semester" class="form-select">
<option value="">All</option>
<option value="Fall" <?= request()->getGet('semester') === 'Fall' ? 'selected' : '' ?>>Fall</option>
<option value="Spring" <?= request()->getGet('semester') === 'Spring' ? 'selected' : '' ?>>Spring</option>
</select>
</div>
<!-- Status (from reimbursements.status) -->
<div class="col-md-2">
<label class="form-label">Status</label>
@@ -28,19 +18,6 @@
</select>
</div>
<!-- School Year -->
<div class="col-md-2">
<label class="form-label">School Year</label>
<select name="school_year" class="form-select">
<option value="">All</option>
<?php foreach ($schoolYears as $sy): ?>
<option value="<?= esc($sy) ?>" <?= request()->getGet('school_year') == $sy ? 'selected' : '' ?>>
<?= esc($sy) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<!-- User -->
<div class="col-md-3">
<label class="form-label">Reimbursed To</label>
-1
View File
@@ -13,7 +13,6 @@
<div class="wrapper">
<div class="content"></div>
<h2 class="text-center mt-4 mb-3">Assign Role to Users</h2>
<?= $this->include('partials/academic_filter') ?>
<?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')); ?></div>
-1
View File
@@ -3,7 +3,6 @@
<div class="container-fluid mt-4">
<h2 class="text-center mt-4 mb-3">Staff List</h2>
<?= $this->include('partials/academic_filter') ?>
<?php if (!empty($issues_count) && (int)$issues_count > 0): ?>
<div class="alert alert-warning" role="alert">
-1
View File
@@ -13,7 +13,6 @@
<div class="wrapper">
<div class="content"></div>
<h2 class="text-center mt-4 mb-3">Login Activity</h2>
<?= $this->include('partials/academic_filter') ?>
<div id="loginActivityAlert" class="alert alert-danger d-none" role="alert"></div>
-1
View File
@@ -11,7 +11,6 @@
<div class="content"></div>
<h2 class="text-center mt-4 mb-3">User List</h2>
<?= $this->include('partials/academic_filter') ?>
<?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success alert-dismissible fade show" role="alert">
+241
View File
@@ -0,0 +1,241 @@
# Balanced Student Section Distribution Plan
## 1. Purpose
This plan describes how already-promoted students will be distributed into sections for the next academic year.
Promotion, deliberation, passing decisions, and student eligibility have already been completed and are outside the scope of this process.
The distribution process must ensure that:
* Students are divided as equally as possible among sections.
* Each section contains a balanced number of students from every score range.
* Section average scores are reasonably close.
* User-defined minimum and maximum section sizes are respected.
## 2. Required User Inputs
Before starting the distribution, the user must enter:
| Input | Description |
| ---------------------------- | --------------------------------------------- |
| Number of Sections | Number of sections to be created |
| Minimum Students per Section | Minimum permitted section size |
| Maximum Students per Section | Maximum permitted section size, if applicable |
The student list must already include each students final score from the previous academic year.
## 3. Score Range Classification
Students must be classified using their previous-year final scores:
| Score Group | Score Range |
| ----------- | -----------: |
| Group 1 | 90100 |
| Group 2 | 8089 |
| Group 3 | 7079 |
| Group 4 | 69 and below |
Each student must belong to exactly one score group.
## 4. Validate the Number of Sections
Let:
* **N** = Total number of students to distribute.
* **S** = Number of sections entered by the user.
* **M** = Minimum number of students per section.
* **X** = Maximum number of students per section.
The requested section count is valid only when:
**S × M ≤ N**
When a maximum section size is used, the following condition must also be satisfied:
**N ≤ S × X**
Therefore, the complete validation rule is:
**S × M ≤ N ≤ S × X**
If no maximum section size is used, only the minimum condition applies.
## 5. Validation Results
The system should return one of the following results:
### Insufficient Students
The requested number of sections cannot be created because the total number of students is below the required minimum.
### Capacity Exceeded
The requested sections cannot contain all students without exceeding the maximum section size.
### Valid Setup
The entered number of sections and section-size limits are valid, and distribution may proceed.
The system must not automatically change the number of sections entered by the user.
## 6. Determine the Target Section Sizes
Divide the total number of students by the number of sections.
Each section should receive:
**Base section size = N divided by S**
Any remaining students should be assigned one at a time to different sections.
The difference between the largest and smallest section should not exceed one student.
Example:
* Total students: 61
* Number of sections: 2
Result:
* Section 1: 31 students
* Section 2: 30 students
## 7. Calculate the Score-Group Allocation
For each score group:
1. Count the number of students in that group.
2. Divide the group total by the number of sections.
3. Assign the base number to every section.
4. Distribute any remaining students across different sections.
For a score group containing **G** students:
**Base allocation = G divided by S**
**Remaining students = G modulo S**
Example:
* Students scoring 90100: 11
* Sections: 2
Result:
* Section 1: 6 students
* Section 2: 5 students
The difference between sections within the same score group should not exceed one student.
## 8. Mathematical Limitation
Exact equality is not always possible.
For example, nine students from one score group cannot be divided equally between two sections. One section must receive five students and the other must receive four.
Therefore, the required rule is:
* Equal distribution when mathematically possible.
* A maximum difference of one student when exact equality is impossible.
## 9. Student Assignment Method
Within each score group:
1. Sort students from highest score to lowest score.
2. Assign them using a snake-distribution method.
3. Reverse the assignment direction after each round.
For two sections, use:
* First round: Section 1, Section 2
* Second round: Section 2, Section 1
* Third round: Section 1, Section 2
For three sections, use:
* First round: Section 1, Section 2, Section 3
* Second round: Section 3, Section 2, Section 1
This prevents one section from repeatedly receiving the highest-scoring students.
## 10. Rotate Remaining Students
Additional students caused by uneven division should not always be assigned to the first section.
The section receiving the extra student should rotate between score groups.
Example with two sections:
| Score Range | Section Receiving the Extra Student |
| ------------ | ----------------------------------- |
| 90100 | Section 1 |
| 8089 | Section 2 |
| 7079 | Section 1 |
| 69 and below | Section 2 |
This helps maintain equal total section sizes.
## 11. Balance the Average Scores
After the initial distribution, calculate the average previous-year score for every section.
The difference between the highest and lowest section averages should preferably not exceed one percentage point.
If the difference is too large, students may be exchanged between sections when:
* They belong to the same score range.
* The exchange improves the average-score balance.
* Section sizes remain valid.
* Minimum and maximum limits are still satisfied.
## 12. Additional Distribution Considerations
After academic balancing, the distribution may also be reviewed for:
* Gender balance, where applicable.
* Special educational needs.
* Language-support requirements.
* Behavioral considerations.
* Documented student-separation requirements.
* Medical or accessibility needs.
Any adjustment should preserve the score-range and section-size balance as much as possible.
## 13. Final Distribution Summary
The final result should include:
| Section | Total Students | 90100 | 8089 | 7079 | 69 and Below | Average Score |
| --------- | -------------: | -----: | ----: | ----: | -----------: | ------------: |
| Section 1 | 30 | 5 | 8 | 9 | 8 | 78.4 |
| Section 2 | 30 | 5 | 7 | 9 | 9 | 78.2 |
## 14. Final Validation Checklist
Before confirming the distribution, verify that:
* The number of sections was entered by the user.
* The minimum section size was entered by the user.
* The maximum section size was entered, when applicable.
* All students have been assigned exactly once.
* No section is below the minimum size.
* No section exceeds the maximum size.
* Total section sizes differ by no more than one student.
* Score-group counts differ by no more than one student.
* Average scores are reasonably balanced.
* No student is missing or duplicated.
## 15. Final Rule
The system distributes students only after promotion and deliberation are complete.
The user determines the number of sections and the section-size limits.
The system validates those values and creates the most balanced possible distribution based on:
* Total student count.
* Score-range counts.
* Section-size limits.
* Average section scores.