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
+25 -17
View File
@@ -73,30 +73,37 @@ class PrintRequests extends BaseController
public function admin_index() public function admin_index()
{ {
$db = \Config\Database::connect(); $context = $this->resolveSchoolYearContext();
$query = $db->query(" $schoolYear = $context->yearName();
SELECT
pr.*, $printRequestsQuery = $this->printRequestModel
->select('
print_requests.*,
u.firstname, u.firstname,
u.lastname, u.lastname,
cs.class_section_name, cs.class_section_name,
admins.firstname AS admin_firstname, admins.firstname AS admin_firstname,
admins.lastname AS admin_lastname admins.lastname AS admin_lastname
FROM print_requests pr ')
LEFT JOIN users u ON u.id = pr.teacher_id ->join('users u', 'u.id = print_requests.teacher_id', 'left')
LEFT JOIN classSection cs ON cs.class_section_id = pr.class_id ->join('classSection cs', 'cs.class_section_id = print_requests.class_id', 'left')
LEFT JOIN users admins ON admins.id = pr.admin_id ->join('users admins', 'admins.id = print_requests.admin_id', 'left');
ORDER BY
$this->applyPrintRequestSchoolYearScope($printRequestsQuery, $schoolYear);
$data['print_requests'] = $printRequestsQuery
->orderBy("
CASE CASE
WHEN pr.status = 'not_assigned' THEN 1 WHEN print_requests.status = 'not_assigned' THEN 1
WHEN pr.status = 'assigned' THEN 2 WHEN print_requests.status = 'assigned' THEN 2
WHEN pr.status = 'done' THEN 3 WHEN print_requests.status = 'done' THEN 3
WHEN pr.status = 'delivered' THEN 4 WHEN print_requests.status = 'delivered' THEN 4
ELSE 5 ELSE 5
END ASC, END
pr.required_by ASC ", 'ASC', false)
"); ->orderBy('print_requests.required_by', 'ASC')
$data['print_requests'] = $query->getResultArray(); ->findAll();
$data['isSchoolYearReadonly'] = $context->isReadonly();
return view('print_requests/admin_index', $data); return view('print_requests/admin_index', $data);
} }
@@ -156,6 +163,7 @@ class PrintRequests extends BaseController
// Case 1: Admin status update // Case 1: Admin status update
if ($this->request->getPost('status')) { if ($this->request->getPost('status')) {
$this->assertSchoolYearWritable($this->resolveSchoolYearContext());
$user_id = session()->get('user_id'); $user_id = session()->get('user_id');
$current_status = $request['status']; $current_status = $request['status'];
$new_status = $this->request->getPost('status'); $new_status = $this->request->getPost('status');
+108 -73
View File
@@ -19,6 +19,7 @@ use App\Models\AdminNotificationSubjectModel;
use Doctrine\DBAL\Configuration; use Doctrine\DBAL\Configuration;
use App\Services\FeeCalculationService; use App\Services\FeeCalculationService;
use App\Models\StudentClassModel; use App\Models\StudentClassModel;
use App\Models\StudentSectionDistributionDraftModel;
use App\Controllers\View\EmailController; use App\Controllers\View\EmailController;
use App\Controllers\View\InvoiceController; use App\Controllers\View\InvoiceController;
use App\Models\StaffAttendanceModel; use App\Models\StaffAttendanceModel;
@@ -2159,6 +2160,10 @@ class AdministratorController extends BaseController
public function parentProfiles() public function parentProfiles()
{ {
if ($redirect = $this->redirectWithoutLegacyTermFilters('administrator/parent_profiles')) {
return $redirect;
}
// Fetch all users with their roles in one go // Fetch all users with their roles in one go
$allUsers = $this->userModel->findAll(); $allUsers = $this->userModel->findAll();
$parents = []; $parents = [];
@@ -2215,6 +2220,27 @@ class AdministratorController extends BaseController
return view('administrator/parent_profile', ['parents' => $parents]); 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() public function manageUsers()
{// Fetch all users {// Fetch all users
$users = $this->userModel->findAll(); $users = $this->userModel->findAll();
@@ -2298,8 +2324,8 @@ class AdministratorController extends BaseController
public function showEnrollmentWithdrawalPage() public function showEnrollmentWithdrawalPage()
{ {
try { try {
$schoolYears = $this->availableSchoolYears(); $schoolYearContext = $this->resolveSchoolYearContext();
$selectedYear = $this->selectedEnrollmentSchoolYear($schoolYears); $selectedYear = $schoolYearContext->yearName();
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear); $students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
@@ -2399,11 +2425,10 @@ class AdministratorController extends BaseController
return view('enroll_withdraw/enrollment_withdrawal', [ return view('enroll_withdraw/enrollment_withdrawal', [
'students' => $students, 'students' => $students,
'classes' => $classes, // <-- used by the modal <select> 'classes' => $classes, // <-- used by the modal <select>
'schoolYears' => $schoolYears,
'selectedYear' => $selectedYear, 'selectedYear' => $selectedYear,
'currentYear' => (string)$this->schoolYear, 'currentYear' => $selectedYear,
'isCurrentYear' => ((string)$selectedYear === (string)$this->schoolYear), 'isCurrentYear' => ! $schoolYearContext->isReadonly(),
'missingYear' => empty($this->schoolYear), 'missingYear' => $selectedYear === '',
]); ]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
log_message('error', 'Enrollment/Withdrawal page error: {msg}', ['msg' => $e->getMessage()]); log_message('error', 'Enrollment/Withdrawal page error: {msg}', ['msg' => $e->getMessage()]);
@@ -2415,8 +2440,7 @@ class AdministratorController extends BaseController
public function enrollmentWithdrawalData() public function enrollmentWithdrawalData()
{ {
try { try {
$schoolYears = $this->availableSchoolYears(); $selectedYear = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
$selectedYear = $this->selectedEnrollmentSchoolYear($schoolYears);
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear); $students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
@@ -2502,7 +2526,6 @@ class AdministratorController extends BaseController
'csrfHash' => csrf_hash(), 'csrfHash' => csrf_hash(),
'semester' => (string)$this->semester, 'semester' => (string)$this->semester,
'school_year' => (string)$selectedYear, 'school_year' => (string)$selectedYear,
'schoolYears' => $schoolYears,
]); ]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
log_message('error', 'enrollmentWithdrawalData error: {msg}', ['msg' => $e->getMessage()]); 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 private function enrollmentClassOptions(string $selectedYear): array
{ {
$select = ['id', 'class_section_id', 'class_section_name']; $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}."); 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 continue; // go to next student
} }
@@ -2811,6 +2773,9 @@ class AdministratorController extends BaseController
// Skip if no actual change // Skip if no actual change
if ($oldStatus === $newEnrollmentStatus) { if ($oldStatus === $newEnrollmentStatus) {
log_message('debug', "No status change for student {$studentId} ({$oldStatus}) — skipping."); 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; continue;
} }
@@ -2830,6 +2795,9 @@ class AdministratorController extends BaseController
} }
log_message('info', "Updated enrollment for student ID $studentId: {$oldStatus}{$newEnrollmentStatus} (admission: {$admissionStatus})"); 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 // Student name
$studentRow = $this->studentModel->find($studentId); $studentRow = $this->studentModel->find($studentId);
@@ -3009,4 +2977,71 @@ class AdministratorController extends BaseController
->with('error', 'An unexpected error occurred while processing enrollments.'); ->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 ?? ''); $selectedSemester = (string)($this->request->getGet('semester') ?? $this->semester ?? '');
$year = (string)($this->schoolYear ?? ''); $year = (string)($this->schoolYear ?? '');
$this->applyPendingDistributionDraftsForEnrolledStudents($year);
$tcQ = $this->teacherClassModel; $tcQ = $this->teacherClassModel;
if ($year !== '') { if ($year !== '') {
$tcQ = $tcQ->where('school_year', $year); $tcQ = $tcQ->where('school_year', $year);
@@ -204,6 +206,94 @@ class AssignmentController extends BaseController
return view('administrator/class_assignment', $data); 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() public function save()
+7 -7
View File
@@ -322,23 +322,23 @@ class EventController extends ResourceController
public function index() public function index()
{ {
$eventModel = new EventModel();
$today = local_date(utc_now(), 'Y-m-d'); $today = local_date(utc_now(), 'Y-m-d');
$schoolYear = $this->currentSchoolYearName();
// Fetch all events $events = $this->eventModel
$events = $eventModel ->where('school_year', $schoolYear)
->orderBy('created_at', 'DESC') ->orderBy('created_at', 'DESC')
->findAll(); ->findAll();
// Fetch active events (not expired) $activeEventCount = $this->eventModel
$activeEventCount = $eventModel ->where('school_year', $schoolYear)
->where('expiration_date >=', $today) ->where('expiration_date >=', $today)
->countAllResults(); ->countAllResults();
return view('administrator/events/event_list', [ return view('administrator/events/event_list', [
'events' => $events, 'events' => $events,
'activeEventCount' => $activeEventCount 'activeEventCount' => $activeEventCount,
'schoolYear' => $schoolYear,
]); ]);
} }
+31 -45
View File
@@ -54,14 +54,29 @@ class ExtraChargesController extends BaseController
/** Render HTML management page */ /** Render HTML management page */
public function 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); $parentId = (int)($this->request->getGet('parent_id') ?? 0);
$status = $this->request->getGet('status') ?: null; $status = $this->request->getGet('status') ?: null;
$yearSelect = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
$rows = []; $rows = [];
if ($parentId > 0) { if ($parentId > 0) {
$rows = $this->additionalChargeModel $rows = $this->additionalChargeModel
->byParentTerm($parentId, $this->schoolYear, $this->semester, $status); ->byParentTerm($parentId, $schoolYear, $this->semester, $status);
} }
// Load ALL parents for current view // Load ALL parents for current view
@@ -85,7 +100,7 @@ class ExtraChargesController extends BaseController
$parentIds = array_map(fn($r) => (int)$r['id'], $parents); $parentIds = array_map(fn($r) => (int)$r['id'], $parents);
$invoicesByParent = []; $invoicesByParent = [];
if (!empty($parentIds)) { if (!empty($parentIds)) {
$all = $this->invoiceModel->getAllInvoicesByUserIds($parentIds, $yearSelect); $all = $this->invoiceModel->getAllInvoicesByUserIds($parentIds, $schoolYear);
foreach ($all as $inv) { foreach ($all as $inv) {
$pid = (int)$inv['parent_id']; $pid = (int)$inv['parent_id'];
$invoicesByParent[$pid][] = [ $invoicesByParent[$pid][] = [
@@ -107,7 +122,7 @@ class ExtraChargesController extends BaseController
// ✅ Always pull all charges for the selected year & current semester (all parents) // ✅ Always pull all charges for the selected year & current semester (all parents)
$rows = $this->additionalChargeModel->listAllForTerm( $rows = $this->additionalChargeModel->listAllForTerm(
$yearSelect, $schoolYear,
$this->semester, $this->semester,
$status, $status,
$q, $q,
@@ -115,38 +130,6 @@ class ExtraChargesController extends BaseController
); );
$pager = $this->additionalChargeModel->pager; $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', [ return view('payment/extra_charges', [
'q' => $q, 'q' => $q,
'pager' => $pager, 'pager' => $pager,
@@ -157,9 +140,9 @@ class ExtraChargesController extends BaseController
'parentId' => $parentId, 'parentId' => $parentId,
'selectedParentLabel' => $selectedParentLabel, 'selectedParentLabel' => $selectedParentLabel,
'status' => $status, 'status' => $status,
'schoolYear' => $yearSelect, 'schoolYear' => $schoolYear,
'schoolYears' => $schoolYears,
'semester' => $this->semester, 'semester' => $this->semester,
'isSchoolYearReadonly' => $schoolYearContext->isReadonly(),
]); ]);
} }
@@ -273,7 +256,7 @@ class ExtraChargesController extends BaseController
public function invoicesForParent() public function invoicesForParent()
{ {
$parentId = (int)($this->request->getGet('parent_id') ?? 0); $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) $rows = ($parentId > 0)
? ($this->invoiceModel->getInvoicesByUserId($parentId, $schoolYear) ?? []) ? ($this->invoiceModel->getInvoicesByUserId($parentId, $schoolYear) ?? [])
@@ -304,6 +287,9 @@ class ExtraChargesController extends BaseController
public function store() public function store()
{ {
$schoolYearContext = $this->resolveSchoolYearContext();
$this->assertSchoolYearWritable($schoolYearContext);
$schoolYear = $schoolYearContext->yearName();
$data = $this->request->getPost(); $data = $this->request->getPost();
$rules = [ $rules = [
@@ -336,8 +322,8 @@ class ExtraChargesController extends BaseController
$payload = [ $payload = [
'parent_id' => (int)$data['parent_id'], // ← users.id of the parent 'parent_id' => (int)$data['parent_id'], // ← users.id of the parent
'invoice_id' => $invoiceId, 'invoice_id' => $invoiceId,
'school_year' => $data['school_year'] ?? $this->schoolYear, 'school_year' => $schoolYear,
'semester' => $data['semester'] ?? $this->semester, 'semester' => (string)$this->semester,
'charge_type' => $chargeType, 'charge_type' => $chargeType,
'title' => trim($data['title']), 'title' => trim($data['title']),
'description' => trim($data['description'] ?? ''), 'description' => trim($data['description'] ?? ''),
@@ -351,7 +337,7 @@ class ExtraChargesController extends BaseController
$this->db->transStart(); $this->db->transStart();
// BEFORE // BEFORE
$invoiceBefore = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $this->schoolYear); $invoiceBefore = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $schoolYear);
// Insert charge // Insert charge
$this->additionalChargeModel->insert($payload); $this->additionalChargeModel->insert($payload);
@@ -363,7 +349,7 @@ class ExtraChargesController extends BaseController
} }
// AFTER // AFTER
$invoiceAfter = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $this->schoolYear); $invoiceAfter = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $schoolYear);
// Parent USER (not parent table) // Parent USER (not parent table)
$parentUser = $this->userModel->getUserInfoById($data['parent_id']); $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). */ /** JSON: list charges for the current term (with optional filters). */
public function apiList() public function apiList()
{ {
$year = (string)($this->request->getGet('school_year') ?? $this->schoolYear); $year = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
$sem = (string)($this->request->getGet('semester') ?? $this->semester); $sem = (string)$this->semester;
$status = $this->request->getGet('status') ?: null; $status = $this->request->getGet('status') ?: null;
$q = trim((string)($this->request->getGet('q') ?? '')) ?: null; $q = trim((string)($this->request->getGet('q') ?? '')) ?: null;
$per = (int)($this->request->getGet('per_page') ?? 50); $per = (int)($this->request->getGet('per_page') ?? 50);
@@ -90,6 +90,10 @@ class NotificationsController extends BaseController
{ {
helper('url'); helper('url');
if ($redirect = $this->redirectWithoutLegacyTermFilters('notifications/active')) {
return $redirect;
}
$targetGroup = $this->request->getGet('target_group'); $targetGroup = $this->request->getGet('target_group');
return view('notifications/list_active', [ return view('notifications/list_active', [
@@ -102,6 +106,10 @@ class NotificationsController extends BaseController
{ {
helper(['url', 'form']); helper(['url', 'form']);
if ($redirect = $this->redirectWithoutLegacyTermFilters('notifications/deleted')) {
return $redirect;
}
return view('notifications/list_deleted', [ return view('notifications/list_deleted', [
'deletedNotificationsEndpoint' => site_url('api/notifications/deleted'), 'deletedNotificationsEndpoint' => site_url('api/notifications/deleted'),
'restoreEndpoint' => site_url('notifications/restore'), 'restoreEndpoint' => site_url('notifications/restore'),
@@ -149,7 +157,7 @@ class NotificationsController extends BaseController
$targetGroup = null; $targetGroup = null;
} }
$schoolYear = (string) ((new \App\Models\ConfigurationModel())->getConfig('school_year') ?? ''); $schoolYear = $this->currentSchoolYearName();
if ($schoolYear !== '' && db_connect()->fieldExists('school_year', 'notifications')) { if ($schoolYear !== '' && db_connect()->fieldExists('school_year', 'notifications')) {
$this->notificationModel->where('school_year', $schoolYear); $this->notificationModel->where('school_year', $schoolYear);
} }
@@ -215,4 +223,25 @@ class NotificationsController extends BaseController
'notifications' => $notifications, '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\StudentModel;
use App\Models\ClassSectionModel; use App\Models\ClassSectionModel;
use App\Models\StudentClassModel; use App\Models\StudentClassModel;
use App\Models\StudentSectionDistributionDraftModel;
use App\Models\AuthorizedUserModel; use App\Models\AuthorizedUserModel;
use App\Models\EmergencyContactModel; use App\Models\EmergencyContactModel;
use App\Models\ConfigurationModel; use App\Models\ConfigurationModel;
@@ -444,6 +445,7 @@ class ParentController extends BaseController
$this->applyPromotionAssignment((int)$studentId, (string)$this->schoolYear); $this->applyPromotionAssignment((int)$studentId, (string)$this->schoolYear);
} else { } else {
log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) is already actively enrolled."); log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) is already actively enrolled.");
$this->applyPromotionAssignment((int)$studentId, (string)$this->schoolYear);
} }
} else { } else {
$passedPreviousYear = $this->studentPassedPreviousYear((int)$studentId, (string)$this->schoolYear); $passedPreviousYear = $this->studentPassedPreviousYear((int)$studentId, (string)$this->schoolYear);
@@ -579,20 +581,29 @@ class ParentController extends BaseController
try { try {
$promo = new \App\Models\PromotionQueueModel(); $promo = new \App\Models\PromotionQueueModel();
$classSectionModel = new \App\Models\ClassSectionModel(); $classSectionModel = new \App\Models\ClassSectionModel();
$draftModel = new StudentSectionDistributionDraftModel();
$studentClass = new \App\Models\StudentClassModel(); $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) $row = $promo->where('student_id', $studentId)
->where('school_year_to', $year) ->where('school_year_to', $year)
->first(); ->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) { if ($targetSectionId <= 0) {
// Resolve base section for target class (e.g., '3') // 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) { 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; return;
} }
$targetSectionId = (int)($base['class_section_id'] ?? 0); $targetSectionId = (int)($base['class_section_id'] ?? 0);
@@ -620,12 +631,31 @@ class ParentController extends BaseController
$studentClass->insert($payload); $studentClass->insert($payload);
} }
// Mark promotion as applied $this->db->table('enrollments')
$promo->update((int)$row['id'], [ ->where('student_id', $studentId)
'status' => 'applied', ->where('school_year', $year)
'updated_at' => utc_now(), ->whereIn('enrollment_status', ['payment pending', 'enrolled'])
'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null, ->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) { } catch (\Throwable $e) {
log_message('error', 'applyPromotionAssignment failed: ' . $e->getMessage()); log_message('error', 'applyPromotionAssignment failed: ' . $e->getMessage());
} }
@@ -52,6 +52,11 @@ class RolePermissionController extends Controller
public function assignRole() public function assignRole()
{ {
helper('url'); helper('url');
if ($redirect = $this->redirectWithoutLegacyTermFilters('rolepermission/assign_role')) {
return $redirect;
}
return view('rolepermission/assign_role', [ return view('rolepermission/assign_role', [
'assignRoleEndpoint' => site_url('api/rolepermission/users'), 'assignRoleEndpoint' => site_url('api/rolepermission/users'),
'rolesEndpoint' => site_url('api/rolepermission/roles'), 'rolesEndpoint' => site_url('api/rolepermission/roles'),
@@ -196,6 +201,27 @@ class RolePermissionController extends Controller
return ['users' => $normalized]; 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 private function updateStaffRecord(int $userId, array $newRoleNames): void
{ {
$excludedRoles = ['parent', 'student', 'guest']; $excludedRoles = ['parent', 'student', 'guest'];
+29 -2
View File
@@ -32,6 +32,12 @@ class StaffController extends BaseController
public function index() public function index()
{ {
if ($redirect = $this->redirectWithoutLegacyTermFilters('staff/index')) {
return $redirect;
}
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
// roles we never show // roles we never show
$excludedRoles = ['student', 'parent', 'guest', 'inactive']; // ← add inactive here $excludedRoles = ['student', 'parent', 'guest', 'inactive']; // ← add inactive here
@@ -45,7 +51,7 @@ class StaffController extends BaseController
$assignRows = $this->teacherClassModel $assignRows = $this->teacherClassModel
->select('teacher_class.teacher_id, teacher_class.position, classSection.class_section_name') ->select('teacher_class.teacher_id, teacher_class.position, classSection.class_section_name')
->join('classSection', 'classSection.class_section_id = teacher_class.class_section_id', 'left') ->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(); ->findAll();
$assignByTeacher = []; $assignByTeacher = [];
@@ -88,10 +94,31 @@ class StaffController extends BaseController
'staff' => $staffList, 'staff' => $staffList,
'issues_count' => $issuesCount, 'issues_count' => $issuesCount,
'semester' => $this->semester, '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() public function create()
{ {
return view('staff/create'); return view('staff/create');
+417 -137
View File
@@ -10,6 +10,7 @@ use App\Models\ClassSectionModel;
use App\Models\EmergencyContactModel; use App\Models\EmergencyContactModel;
use App\Models\EnrollmentModel; use App\Models\EnrollmentModel;
use App\Models\ConfigurationModel; use App\Models\ConfigurationModel;
use App\Models\StudentSectionDistributionDraftModel;
use App\Models\StudentAllergyModel; use App\Models\StudentAllergyModel;
use App\Models\StudentMedicalConditionModel; use App\Models\StudentMedicalConditionModel;
use CodeIgniter\Database\Exceptions\DataException; 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 * POST admin endpoint: create draft balanced distribution rows 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). * Input: class_id/class_section_id, section_count, min_students_per_section,
* Balances male/female per section and respects capacity. * max_students_per_section, school_year.
*/ */
public function autoDistributeSections() public function autoDistributeSections()
{ {
@@ -884,7 +885,10 @@ class StudentController extends BaseController
try { try {
$classId = (int) $this->request->getPost('class_id'); $classId = (int) $this->request->getPost('class_id');
$classSectionId = (int) $this->request->getPost('class_section_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)); $year = trim((string) ($this->request->getPost('school_year') ?? $this->schoolYear));
if ($classId <= 0 && $classSectionId > 0) { if ($classId <= 0 && $classSectionId > 0) {
@@ -892,51 +896,27 @@ class StudentController extends BaseController
$classId = (int) ($cid ?? 0); $classId = (int) ($cid ?? 0);
} }
if ($classId <= 0 || $perSec <= 0) { if ($classId <= 0 || $sectionCount <= 0 || $minPerSection <= 0 || ($maxPerSection !== null && $maxPerSection <= 0)) {
$msg = 'Invalid class_id or students_per_section.'; $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); return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
} }
$promo = new \App\Models\PromotionQueueModel(); $cands = $this->distributionCandidates($classId, $year);
// 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();
if (empty($cands)) { if (empty($cands)) {
$msg = 'No students found in promotion queue for selected class/year.'; $msg = 'No promoted students found to distribute 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.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 200) : redirect()->back()->with('info', $msg); return $isAjax ? $json(['ok' => false, 'message' => $msg], 200) : redirect()->back()->with('info', $msg);
} }
$total = count($cands); $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 // Fetch lettered sections for this class
$letters = $this->classSectionModel->getLetterSectionsByClassId($classId); $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); return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
} }
if (count($letters) < $sectionsNeeded) { if (count($letters) < $sectionCount) {
$msg = 'Not enough sections available. Needed: ' . $sectionsNeeded . ', available: ' . count($letters); $msg = 'Not enough sections available. Needed: ' . $sectionCount . ', available: ' . count($letters);
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg); return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
} }
// Keep only required number of sections $letters = array_slice($letters, 0, $sectionCount);
$letters = array_slice($letters, 0, $sectionsNeeded); $buckets = $this->buildBalancedDistribution($cands, $letters, $minPerSection, $maxPerSection);
// Prepare buckets $draftModel = new StudentSectionDistributionDraftModel();
$buckets = []; $promo = new \App\Models\PromotionQueueModel();
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();
$updatedBy = (int)(session()->get('user_id') ?? 0) ?: null; $updatedBy = (int)(session()->get('user_id') ?? 0) ?: null;
$now = utc_now(); $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) { foreach ($buckets as $b) {
$secId = (int)$b['class_section_id']; $secId = (int)$b['class_section_id'];
foreach ($b['assigned'] as $sid) { foreach ($b['assigned'] as $student) {
// Update promotion queue $sid = (int)$student['student_id'];
if (isset($promoIdsBySid[$sid])) { $draftModel->insert([
$promo->update($promoIdsBySid[$sid], [ '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, 'to_class_section_id' => $secId,
'status' => 'assigned', 'status' => 'assigned',
'updated_by' => $updatedBy, 'updated_by' => $updatedBy,
'updated_at' => $now, '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 = []; $nameById = [];
foreach ($letters as $secRow) { foreach ($letters as $secRow) {
$nameById[(int)$secRow['class_section_id']] = (string)($secRow['class_section_name'] ?? ''); $nameById[(int)$secRow['class_section_id']] = (string)($secRow['class_section_name'] ?? '');
@@ -1056,27 +993,323 @@ class StudentController extends BaseController
$summary = []; $summary = [];
foreach ($buckets as $b) { foreach ($buckets as $b) {
$secId = (int)$b['class_section_id']; $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[] = [ $summary[] = [
'class_section_id' => $secId, 'class_section_id' => $secId,
'class_section_name' => $nameById[$secId] ?? (string)$secId, 'class_section_name' => $nameById[$secId] ?? (string)$secId,
'total' => count($b['assigned']), 'total' => count($b['assigned']),
'male' => $b['male'], 'male' => $male,
'female' => $b['female'], 'female' => $female,
'score_groups' => $groups,
'average_score' => count($scores) > 0 ? round(array_sum($scores) / count($scores), 2) : null,
'student_names' => $studentNames,
]; ];
} }
return $isAjax return $isAjax
? $json(['ok' => true, 'message' => 'Auto distribution completed.', 'sections' => $summary]) ? $json(['ok' => true, 'message' => 'Draft distribution saved. Students will move to student_class when they enroll.', 'sections' => $summary])
: redirect()->back()->with('success', 'Auto distribution completed.'); : redirect()->back()->with('success', 'Draft distribution saved.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$msg = 'Auto distribution failed: ' . $e->getMessage(); $msg = 'Auto distribution failed: ' . $e->getMessage();
return $isAjax ? $json(['ok' => false, 'message' => $msg], 500) : redirect()->back()->with('error', $msg); 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 * API: Return promoted-student totals per base class for the selected year.
* Only counts students with enrollment status 'payment pending' or 'enrolled'.
*/ */
public function promotionTotalsApi() public function promotionTotalsApi()
{ {
@@ -1111,22 +1344,24 @@ class StudentController extends BaseController
$out = []; $out = [];
foreach ($wanted as $r) { foreach ($wanted as $r) {
$classId = (int)$r['class_id']; $classId = (int)$r['class_id'];
// candidates from promotion_queue for this base class in the target year
$cands = $this->db->table('promotion_queue pq') $cands = $this->db->table('promotion_queue pq')
->select('pq.student_id') ->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.to_class_id', $classId)
->where('pq.school_year_to', $year) ->where('pq.school_year_to', $year)
->whereIn('pq.status', ['queued','assigned','applied']) ->whereIn('pq.status', ['queued','assigned'])
->whereIn('e.enrollment_status', ['payment pending', 'enrolled'])
->groupBy('pq.student_id') ->groupBy('pq.student_id')
->get()->getResultArray(); ->get()->getResultArray();
$total = count($cands);
if ($total === 0) {
$total = count($this->decisionDistributionCandidates($classId, $year));
}
$out[] = [ $out[] = [
'class_id' => $classId, 'class_id' => $classId,
'class_section_id' => (int)($r['class_section_id'] ?? 0), 'class_section_id' => (int)($r['class_section_id'] ?? 0),
'class_section_name'=> (string)($r['class_section_name'] ?? ''), '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} * POST /students/update/{id}
*/ */
+24 -20
View File
@@ -256,31 +256,14 @@ class TeacherController extends BaseController
public function teacherClassAssignment() public function teacherClassAssignment()
{ {
$selectedYear = trim((string)($this->request->getGet('schoolYear') ?? $this->request->getGet('school_year') ?? '')); if ($redirect = $this->redirectWithoutLegacyTermFilters('administrator/teacher_class_assignment')) {
if ($selectedYear === '') { return $redirect;
$selectedYear = (string)($this->schoolYear ?? 'Not Set');
} }
// Build distinct school years from classSection (fallback to configured year) $selectedYear = (string)($this->schoolYear ?? 'Not Set');
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] : [];
}
return view('administrator/teacher_class_assignment', [ return view('administrator/teacher_class_assignment', [
'schoolYear' => (string)$selectedYear, 'schoolYear' => (string)$selectedYear,
'schoolYears' => $schoolYears,
'currentYear' => (string)($this->schoolYear ?? ''), 'currentYear' => (string)($this->schoolYear ?? ''),
'isCurrentYear' => ((string)$selectedYear === (string)($this->schoolYear ?? '')), 'isCurrentYear' => ((string)$selectedYear === (string)($this->schoolYear ?? '')),
'missingYear' => empty($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 private function buildTeacherClassAssignmentPayload(?string $forYear = null): array
{ {
$classSectionModel = new ClassSectionModel(); $classSectionModel = new ClassSectionModel();
+29
View File
@@ -81,6 +81,27 @@ class UserController extends BaseController
return hash('sha256', $token); 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 // Method to show the home page
public function home() public function home()
{ {
@@ -113,6 +134,10 @@ class UserController extends BaseController
helper('url'); helper('url');
if ($redirect = $this->redirectWithoutLegacyTermFilters('user/user_list')) {
return $redirect;
}
return view('user/user_list', [ return view('user/user_list', [
'userListEndpoint' => site_url('api/users'), 'userListEndpoint' => site_url('api/users'),
]); ]);
@@ -854,6 +879,10 @@ class UserController extends BaseController
helper('url'); helper('url');
if ($redirect = $this->redirectWithoutLegacyTermFilters('user/login_activity')) {
return $redirect;
}
$perPage = (int) ($this->request->getGet('per_page') ?? 25); $perPage = (int) ($this->request->getGet('per_page') ?? 25);
return view('user/login_activity', [ return view('user/login_activity', [
@@ -31,8 +31,8 @@ class CreateStudentSectionDistributionDrafts extends Migration
$this->forge->addKey('id', true); $this->forge->addKey('id', true);
$this->forge->addUniqueKey(['student_id', 'school_year'], 'unique_distribution_draft_student_year'); $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_id', 'school_year', 'status'], false, false, 'distribution_draft_class_year_status');
$this->forge->addKey(['class_section_id', 'school_year'], false, 'distribution_draft_section_year'); $this->forge->addKey(['class_section_id', 'school_year'], false, false, 'distribution_draft_section_year');
$this->forge->createTable('student_section_distribution_drafts'); $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); $schoolYear = trim((string) $schoolYear);
$studentClassJoin = 'student_class.student_id = students.id'; $studentClassJoin = 'student_class.student_id = students.id';
$enrollmentJoin = 'enrollments.student_id = students.id'; $enrollmentJoin = 'enrollments.student_id = students.id';
$selectedYearFilter = '';
if ($schoolYear !== '') { if ($schoolYear !== '') {
$studentClassJoin .= ' AND student_class.school_year = ' . $this->db->escape($schoolYear); $escapedSchoolYear = $this->db->escape($schoolYear);
$enrollmentJoin .= ' AND enrollments.school_year = ' . $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.*, students.*,
student_class.class_section_id, student_class.class_section_id,
enrollments.enrollment_status, enrollments.enrollment_status,
@@ -381,7 +401,13 @@ class StudentModel extends Model
') ')
->join('student_class', $studentClassJoin, 'left') ->join('student_class', $studentClassJoin, 'left')
->join('enrollments', $enrollmentJoin, '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 // 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') ->groupBy('students.id, student_class.class_section_id, enrollments.enrollment_status, enrollments.admission_status')
->findAll(); ->findAll();
-28
View File
@@ -17,34 +17,6 @@
</div> </div>
<br> <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')): ?> <?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success"> <div class="alert alert-success">
<?= session()->getFlashdata('success') ?> <?= session()->getFlashdata('success') ?>
@@ -4,7 +4,6 @@
<div class="wrapper"> <div class="wrapper">
<div class="content"> <div class="content">
<h2 class="text-center mt-4 mb-3">Emergency Contact Information</h2> <h2 class="text-center mt-4 mb-3">Emergency Contact Information</h2>
<?= $this->include('partials/academic_filter') ?>
<div class="table-responsive"> <div class="table-responsive">
<table id="emergencyTable" class="display table table-bordered table-striped align-middle"> <table id="emergencyTable" class="display table table-bordered table-striped align-middle">
<thead class="table-dark"> <thead class="table-dark">
@@ -4,7 +4,6 @@
<div class="wrapper"> <div class="wrapper">
<div class="content"> <div class="content">
<h2 class="text-center mt-4 mb-3">Parent Profiles</h2> <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%"> <table id="myTable" class="display table table-striped table-bordered align-middle" style="width:100%">
<thead> <thead>
<tr> <tr>
@@ -5,37 +5,26 @@
<div class="content"> <div class="content">
<h2 class="text-center mt-4 mb-3">Auto-Distribute Students into Sections</h2> <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">
<div class="card-body"> <div class="card-body">
<div class="row g-3 align-items-end mb-2"> <div class="row g-3 align-items-end mb-2">
<div class="col-md-3"> <div class="col-md-2">
<label for="globalPer" class="form-label">Students per section</label> <label for="sectionCount" class="form-label">Number of Sections</label>
<input type="number" min="1" id="globalPer" class="form-control" placeholder="e.g. 20" /> <input type="number" min="1" id="sectionCount" class="form-control" placeholder="e.g. 2" />
</div> </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="refreshTotalsBtn" class="btn btn-outline-secondary">Refresh Totals</button>
<button type="button" id="generateAllBtn" class="btn btn-primary">Generate All</button> <button type="button" id="generateAllBtn" class="btn btn-primary">Generate All</button>
</div> </div>
<div class="col-md-7 text-end"> <div class="col-md-3 text-end">
<span id="pageMsg" class="small text-muted"></span> <span id="pageMsg" class="small text-muted"></span>
</div> </div>
</div> </div>
@@ -46,7 +35,7 @@
<tr id="tblHeader"> <tr id="tblHeader">
<th>Class</th> <th>Class</th>
<th>Total</th> <th>Total</th>
<th>Sections Needed</th> <th>Sections</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -73,17 +62,18 @@
const tblBody = document.getElementById('tblBody'); const tblBody = document.getElementById('tblBody');
const tblHeader = document.getElementById('tblHeader'); 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 msgEl = document.getElementById('pageMsg');
const refreshBtn= document.getElementById('refreshTotalsBtn'); const refreshBtn= document.getElementById('refreshTotalsBtn');
let headerSections = []; // list of generated section names as columns let headerSections = []; // list of generated section names as columns
let rowIndexByClassId = {}; // mapping to locate rows let rowIndexByClassId = {}; // mapping to locate rows
function calcNeeded(total) { function selectedSectionCount() {
const per = parseInt(perInput.value || '0', 10); const count = parseInt(sectionCountInput.value || '0', 10);
if (!per || per <= 0) return ''; return count > 0 ? count : '';
return Math.ceil(total / per);
} }
function ensureSectionColumns(sectionNames) { function ensureSectionColumns(sectionNames) {
@@ -129,7 +119,7 @@
const tdNeed = document.createElement('td'); const tdNeed = document.createElement('td');
tdNeed.className = 'text-end need-cell'; tdNeed.className = 'text-end need-cell';
tdNeed.textContent = calcNeeded(r.total); tdNeed.textContent = selectedSectionCount();
tr.appendChild(tdNeed); tr.appendChild(tdNeed);
const tdAct = document.createElement('td'); const tdAct = document.createElement('td');
@@ -143,24 +133,36 @@
tblBody.appendChild(tr); tblBody.appendChild(tr);
rowIndexByClassId[r.class_id] = 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() { function updateNeeds() {
document.querySelectorAll('#tblBody tr').forEach(function(tr){ document.querySelectorAll('#tblBody tr').forEach(function(tr){
const total = parseInt(tr.children[1].textContent || '0', 10);
const needCell = tr.querySelector('.need-cell'); const needCell = tr.querySelector('.need-cell');
if (needCell) needCell.textContent = calcNeeded(total); if (needCell) needCell.textContent = selectedSectionCount();
}); });
} }
function runDistribution(baseSectionId, baseName) { function runDistribution(baseSectionId, baseName) {
const per = parseInt(perInput.value || '0', 10); const sectionCount = parseInt(sectionCountInput.value || '0', 10);
if (!per || per <= 0) { msgEl.textContent = 'Enter students per section first.'; return; } 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 || '') + '...'; msgEl.textContent = 'Distributing ' + (baseName || '') + '...';
const fd = new FormData(); const fd = new FormData();
fd.append('class_section_id', String(baseSectionId)); 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); fd.append('school_year', selectedYear);
// CSRF // CSRF
const csrfNameEl = document.getElementById('csrfName'); const csrfNameEl = document.getElementById('csrfName');
@@ -184,24 +186,39 @@
msgEl.textContent = res && res.message ? res.message : 'Completed.'; msgEl.textContent = res && res.message ? res.message : 'Completed.';
// Collect section names, then ensure header columns renderSectionsForRow(baseName || '', res.sections);
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) +')';
});
}) })
.catch(() => { msgEl.textContent = 'Failed to distribute. Please try again.'; }); .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() { function loadTotals() {
msgEl.textContent = 'Loading totals...'; msgEl.textContent = 'Loading totals...';
fetch(totalsUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' }}) fetch(totalsUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' }})
@@ -215,10 +232,14 @@
.catch(() => { msgEl.textContent = 'Failed to load totals.'; }); .catch(() => { msgEl.textContent = 'Failed to load totals.'; });
} }
refreshBtn.addEventListener('click', function(){ updateNeeds(); }); refreshBtn.addEventListener('click', function(){ loadTotals(); });
document.getElementById('generateAllBtn').addEventListener('click', async function(){ document.getElementById('generateAllBtn').addEventListener('click', async function(){
const per = parseInt(perInput.value || '0', 10); const sectionCount = parseInt(sectionCountInput.value || '0', 10);
if (!per || per <= 0) { msgEl.textContent = 'Enter students per section first.'; return; } 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 // Collect base sections from rows
const rows = Array.from(document.querySelectorAll('#tblBody tr')) const rows = Array.from(document.querySelectorAll('#tblBody tr'))
.map(tr => ({ id: tr.dataset.classSectionId, name: tr.dataset.className })) .map(tr => ({ id: tr.dataset.classSectionId, name: tr.dataset.className }))
@@ -231,7 +252,7 @@
} }
msgEl.textContent = 'All distributions completed.'; msgEl.textContent = 'All distributions completed.';
}); });
perInput.addEventListener('input', function(){ updateNeeds(); }); sectionCountInput.addEventListener('input', function(){ updateNeeds(); });
loadTotals(); loadTotals();
})(); })();
@@ -5,29 +5,6 @@
<div class="content"> <div class="content">
<h2 class="text-center mt-4 mb-3">Student Class Assignment</h2> <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="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): ?> <?php if (isset($isCurrentYear) && !$isCurrentYear): ?>
<div class="col-auto"><span class="badge bg-secondary">Read-only (Past Year)</span></div> <div class="col-auto"><span class="badge bg-secondary">Read-only (Past Year)</span></div>
<?php endif; ?> <?php endif; ?>
@@ -59,10 +36,7 @@
<label for="autoDistPer" class="form-label">Students per section</label> <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 /> <input type="number" min="1" id="autoDistPer" name="students_per_section" class="form-control" required />
</div> </div>
<div class="col-md-3"> <input type="hidden" id="autoDistYear" name="school_year" value="<?= esc($selectedYear ?? '') ?>" />
<label for="autoDistYear" class="form-label">School year</label>
<input type="text" id="autoDistYear" name="school_year" value="<?= esc($selectedYear ?? '') ?>" class="form-control" />
</div>
<div class="col-md-2 d-grid"> <div class="col-md-2 d-grid">
<button type="submit" class="btn btn-outline-primary">Auto-Distribute</button> <button type="submit" class="btn btn-outline-primary">Auto-Distribute</button>
</div> </div>
@@ -17,25 +17,6 @@
<a href="<?= site_url('configuration/configuration_view') ?>" class="alert-link">Add/Edit Configuration</a> (key: <code>school_year</code>). <a href="<?= site_url('configuration/configuration_view') ?>" class="alert-link">Add/Edit Configuration</a> (key: <code>school_year</code>).
</div> </div>
<?php endif; ?> <?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') ?> <?= view('partials/flash_messages') ?>
<div id="assignmentMessages"></div> <div id="assignmentMessages"></div>
-1
View File
@@ -3,7 +3,6 @@
<div class="container-fluid mt-4"> <div class="container-fluid mt-4">
<h2 class="text-center mt-4 mb-3">Discounts</h2> <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 class="d-flex gap-2 mb-3 justify-content-end">
<div> <div>
<a href="/discount/create" class="btn btn-primary">Create New Voucher</a> <a href="/discount/create" class="btn btn-primary">Create New Voucher</a>
@@ -12,34 +12,11 @@
</div> </div>
</div> </div>
<?php endif; ?> <?php endif; ?>
<div class="row g-2 align-items-center justify-content-center mb-2"> <?php if (empty($isCurrentYear)): ?>
<div class="col-auto"><label for="schoolYearSelect" class="col-form-label">School year</label></div> <div class="text-center mb-2">
<div class="col-auto"> <span class="badge bg-secondary">Read-only (Past Year)</span>
<form method="get" action="<?= site_url('enroll_withdraw/enrollment_withdrawal') ?>" class="d-flex align-items-center gap-2">
<select id="schoolYearSelect" name="schoolYear" class="form-select form-select-sm" style="min-width:180px;">
<?php if (!empty($schoolYears) && is_array($schoolYears)): ?>
<?php foreach ($schoolYears as $y): ?>
<?php $yval = is_array($y) && isset($y['school_year']) ? (string)$y['school_year'] : (string)$y; ?>
<option value="<?= esc($yval) ?>" <?= (isset($selectedYear) && (string)$selectedYear === $yval) ? 'selected' : '' ?>><?= esc($yval) ?></option>
<?php endforeach; ?>
<?php else: ?>
<option value="<?= esc($selectedYear ?? '') ?>" selected><?= esc($selectedYear ?? '') ?></option>
<?php endif; ?>
</select>
<?php $semVal = (string)($semester ?? ($_GET['semester'] ?? '')); ?>
<label for="ewSemester" class="col-form-label">Semester</label>
<select id="ewSemester" name="semester" class="form-select form-select-sm" style="min-width: 140px;">
<option value="">—</option>
<option value="Fall" <?= (strcasecmp($semVal,'Fall')===0?'selected':'') ?>>Fall</option>
<option value="Spring" <?= (strcasecmp($semVal,'Spring')===0?'selected':'') ?>>Spring</option>
</select>
<button type="submit" class="btn btn-secondary btn-sm">Apply</button>
</form>
</div> </div>
<?php if (isset($selectedYear, $currentYear) && (string)$selectedYear !== (string)$currentYear): ?> <?php endif; ?>
<div class="col-auto"><span class="badge bg-secondary">Read-only (Past Year)</span></div>
<?php endif; ?>
</div>
<!-- Display success and error messages --> <!-- Display success and error messages -->
<?php if (session()->getFlashdata('success')): ?> <?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success"> <div class="alert alert-success">
@@ -161,7 +138,7 @@
<!-- Update Enrollment --> <!-- Update Enrollment -->
<td> <td>
<select <?= (isset($selectedYear, $currentYear) && (string)$selectedYear !== (string)$currentYear) ? 'disabled' : '' ?> <select <?= empty($isCurrentYear) ? 'disabled' : '' ?>
name="enrollment_status[<?= $sid ?>]" name="enrollment_status[<?= $sid ?>]"
class="form-control enrollment-status" class="form-control enrollment-status"
data-student-id="<?= $sid ?>" data-student-id="<?= $sid ?>"
@@ -182,7 +159,7 @@
<td> <td>
<?php $isUnderReview = (strtolower($student['enrollment_status'] ?? '') === 'admission under review'); ?> <?php $isUnderReview = (strtolower($student['enrollment_status'] ?? '') === 'admission under review'); ?>
<?php if ($isUnderReview): ?> <?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-student-id="<?= $sid ?>"
data-parent-id="<?= $pid ?>"> data-parent-id="<?= $pid ?>">
<option value="">— Select class section —</option> <option value="">— Select class section —</option>
@@ -212,7 +189,7 @@
</div> </div>
<!-- Centered action buttons under the table --> <!-- Centered action buttons under the table -->
<div class="d-flex justify-content-center gap-2 my-3"> <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 Save &amp; Generate Invoice
</button> </button>
<span id="bulkProgress" class="small text-muted d-none">Processing…</span> <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> <span class="badge bg-secondary ms-2"><?= (int)$total_new ?></span>
<?php endif; ?> <?php endif; ?>
</h2> </h2>
<?= $this->include('partials/academic_filter') ?>
<!-- Flash messages --> <!-- Flash messages -->
<?php if (session()->getFlashdata('success')): ?> <?php if (session()->getFlashdata('success')): ?>
-1
View File
@@ -3,7 +3,6 @@
<div class="container-fluid mt-4"> <div class="container-fluid mt-4">
<h2 class="text-center mt-4 mb-3">Expense / Purchase</h2> <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"> <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> <a href="<?= base_url('expenses/create') ?>" class="btn btn-success mb-3">Add New</a>
</div> </div>
-1
View File
@@ -2,7 +2,6 @@
<?= $this->section('content') ?> <?= $this->section('content') ?>
<div class="container-fluid"> <div class="container-fluid">
<h2 class="text-center mt-4 mb-3">Incidents Management</h2> <h2 class="text-center mt-4 mb-3">Incidents Management</h2>
<?= $this->include('partials/academic_filter') ?>
<!-- Flash Success Message --> <!-- Flash Success Message -->
<?php if (session()->getFlashdata('success')): ?> <?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success"> <div class="alert alert-success">
+56 -99
View File
@@ -11,7 +11,6 @@
data-notifications-endpoint="<?= esc($endpoint) ?>" data-notifications-endpoint="<?= esc($endpoint) ?>"
data-default-target-group="<?= esc($defaultTarget) ?>"> data-default-target-group="<?= esc($defaultTarget) ?>">
<h2 class="text-center mt-4 mb-3">Active Notifications</h2> <h2 class="text-center mt-4 mb-3">Active Notifications</h2>
<?= $this->include('partials/academic_filter') ?>
<?php if (session()->getFlashdata('success')): ?> <?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div> <div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
@@ -61,53 +60,6 @@
<?= $this->section('scripts') ?> <?= $this->section('scripts') ?>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { 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]'); const container = document.querySelector('[data-notifications-endpoint]');
if (!container) { if (!container) {
return; return;
@@ -123,6 +75,7 @@
const resetBtn = document.getElementById('resetFilterBtn'); const resetBtn = document.getElementById('resetFilterBtn');
let lastPayload = []; let lastPayload = [];
let dataTable = null;
const showError = (message) => { const showError = (message) => {
if (!alertBox) { if (!alertBox) {
@@ -140,23 +93,16 @@
alertBox.textContent = ''; 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 = () => { const initDataTable = () => {
if (!window.jQuery || !window.jQuery.fn || !window.jQuery.fn.DataTable) { 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, pageLength: 25,
responsive: true,
// Sort by Scheduled At desc, then Created At desc by default
order: [[5, 'desc'], [7, 'desc']], order: [[5, 'desc'], [7, 'desc']],
columnDefs: [ columnDefs: [
{ {
@@ -164,22 +110,9 @@
orderable: false, orderable: false,
}, },
], ],
}; });
if (window.jQuery.fn.dataTable && window.jQuery.fn.dataTable.FixedHeader) { return dataTable;
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 (_) {}
}
});
}
}; };
const formatDateTime = (value) => { const formatDateTime = (value) => {
@@ -208,21 +141,32 @@
}; };
const renderTable = () => { const renderTable = () => {
destroyDataTable(); if (!dataTable && window.jQuery && window.jQuery.fn && window.jQuery.fn.DataTable) {
tableBody.innerHTML = ''; tableBody.innerHTML = '';
}
const dt = initDataTable();
if (dt) {
dt.clear();
} else {
tableBody.innerHTML = '';
}
if (!Array.isArray(lastPayload) || lastPayload.length === 0) { if (!Array.isArray(lastPayload) || lastPayload.length === 0) {
const row = document.createElement('tr'); if (dt) {
const cell = document.createElement('td'); dt.draw();
cell.colSpan = 8; } else {
cell.classList.add('text-center', 'text-muted'); const row = document.createElement('tr');
cell.textContent = 'No active notifications found.'; const cell = document.createElement('td');
row.appendChild(cell); cell.colSpan = 8;
tableBody.appendChild(row); cell.classList.add('text-center', 'text-muted');
initDataTable(); cell.textContent = 'No active notifications found.';
row.appendChild(cell);
tableBody.appendChild(row);
}
return; return;
} }
const rows = [];
lastPayload.forEach((entry, index) => { lastPayload.forEach((entry, index) => {
const row = document.createElement('tr'); const row = document.createElement('tr');
if (entry.isExpired) { if (entry.isExpired) {
@@ -265,10 +209,15 @@
row.appendChild(cell); 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) => { const buildUrl = (target) => {
@@ -290,11 +239,15 @@
clearError(); clearError();
const url = buildUrl(targetGroup); const url = buildUrl(targetGroup);
tableBody.innerHTML = ` if (dataTable) {
<tr> dataTable.clear().draw();
<td colspan="8" class="text-center text-muted">Loading notifications…</td> } else {
</tr> tableBody.innerHTML = `
`; <tr>
<td colspan="8" class="text-center text-muted">Loading notifications…</td>
</tr>
`;
}
return fetch(url, { return fetch(url, {
headers: { headers: {
@@ -314,12 +267,16 @@
}) })
.catch((error) => { .catch((error) => {
showError(error.message || 'Unable to load notifications.'); showError(error.message || 'Unable to load notifications.');
destroyDataTable(); const dt = initDataTable();
tableBody.innerHTML = ` if (dt) {
<tr> dt.clear().draw();
<td colspan="8" class="text-center text-danger">Failed to load notifications.</td> } else {
</tr> 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-name="<?= esc($csrfTokenName) ?>"
data-csrf-value="<?= esc($csrfTokenValue) ?>"> data-csrf-value="<?= esc($csrfTokenValue) ?>">
<h2 class="text-center mt-4 mb-3">Deleted Notifications</h2> <h2 class="text-center mt-4 mb-3">Deleted Notifications</h2>
<?= $this->include('partials/academic_filter') ?>
<?php if (session()->getFlashdata('success')): ?> <?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div> <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 <?php
$query = service('request')->getUri()->getQuery(); $query = service('request')->getUri()->getQuery();
$returnTo = current_url() . ($query !== '' ? '?' . $query : ''); $returnTo = current_url() . ($query !== '' ? '?' . $query : '');
+15 -43
View File
@@ -1,6 +1,7 @@
<!-- app/Views/payment/extra_charges.php --> <!-- app/Views/payment/extra_charges.php -->
<?= $this->extend('layout/management_layout') ?> <?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?> <?= $this->section('content') ?>
<?php $isSchoolYearReadonly = (bool)($isSchoolYearReadonly ?? false); ?>
<div class="container-fluid py-4"> <div class="container-fluid py-4">
@@ -10,46 +11,23 @@
<?php if (session()->get('error')): ?> <?php if (session()->get('error')): ?>
<div class="alert alert-danger"><?= session()->get('error') ?></div> <div class="alert alert-danger"><?= session()->get('error') ?></div>
<?php endif; ?> <?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-between align-items-center mb-3 gap-3 flex-wrap">
<div class="d-flex justify-content-center mb-3"> <h3 class="mb-0">
<form id="chargesYearFilter" class="d-flex align-items-center gap-3" method="get" action="<?= site_url('admin/charges') ?>"> Add &amp; Deduct Charges
<div class="d-flex justify-content-between align-items-center mb-3 gap-3 flex-wrap"> <span class="text-muted ms-2">(<?= esc($schoolYear) ?> - <?= esc($semester) ?>)</span>
<h3 class="mb-0"> </h3>
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"> <button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#chargeModal" <?= $isSchoolYearReadonly ? 'disabled' : '' ?>>
<label for="schoolYear" class="form-label mb-0">School year</label> <i class="bi bi-plus-circle"></i> Add Charge
<select id="schoolYear" </button>
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>
</div> </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 shadow-sm">
<div class="card-body table-responsive"> <div class="card-body table-responsive">
<?php $pageTotal = 0.0; if (!empty($rows)) { foreach ($rows as $__r) { $pageTotal += (float)($__r['amount'] ?? 0); } } ?> <?php $pageTotal = 0.0; if (!empty($rows)) { foreach ($rows as $__r) { $pageTotal += (float)($__r['amount'] ?? 0); } } ?>
@@ -192,11 +170,6 @@
<?= $this->section('scripts') ?> <?= $this->section('scripts') ?>
<script> <script>
$(function() { $(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 $parent = $('#parentSelect');
const $invId = $('#invoiceIdHidden'); const $invId = $('#invoiceIdHidden');
const $invNum = $('#invoiceNumberDisplay'); const $invNum = $('#invoiceNumberDisplay');
@@ -221,8 +194,7 @@
if (!pid) return; if (!pid) return;
$.getJSON('<?= site_url('admin/charges/invoices') ?>', { $.getJSON('<?= site_url('admin/charges/invoices') ?>', {
parent_id: pid, parent_id: pid
school_year: '<?= esc($schoolYear) ?>'
}).done(function(data) { }).done(function(data) {
const items = (data && Array.isArray(data.results)) ? data.results : []; const items = (data && Array.isArray(data.results)) ? data.results : [];
const chosen = pickDefaultInvoice(items); 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 class="hint">Balance is summed across invoices for the selected school year.</div>
</div> </div>
<?= $this->include('partials/academic_filter') ?>
<?php $flashStatus = session()->getFlashdata('status'); $flashError = session()->getFlashdata('error'); ?> <?php $flashStatus = session()->getFlashdata('status'); $flashError = session()->getFlashdata('error'); ?>
<?php if (!empty($flashStatus)): ?> <?php if (!empty($flashStatus)): ?>
<div class="alert alert-success"><?= esc($flashStatus) ?></div> <div class="alert alert-success"><?= esc($flashStatus) ?></div>
+7 -1
View File
@@ -1,5 +1,6 @@
<?= $this->extend('layout/management_layout') ?> <?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?> <?= $this->section('content') ?>
<?php $isSchoolYearReadonly = (bool) ($isSchoolYearReadonly ?? false); ?>
<div class="container-fluid py-5"> <div class="container-fluid py-5">
<div class="row"> <div class="row">
@@ -10,6 +11,11 @@
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div> </div>
<?php endif; ?> <?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')): ?> <?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger alert-dismissible fade show" role="alert"> <div class="alert alert-danger alert-dismissible fade show" role="alert">
<?= session()->getFlashdata('error') ?> <?= session()->getFlashdata('error') ?>
@@ -90,7 +96,7 @@
</span> </span>
</td> </td>
<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"> <form action="<?= site_url('print-requests/update/' . $request['id']) ?>" method="post" class="d-flex">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="admin_id" value="<?= session()->get('user_id') ?>"> <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> <h2 class="text-center mt-4 mb-3">Reimbursed Expenses</h2>
<form method="get" action="<?= base_url('reimbursements') ?>" class="row g-3 mb-4"> <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) --> <!-- Status (from reimbursements.status) -->
<div class="col-md-2"> <div class="col-md-2">
<label class="form-label">Status</label> <label class="form-label">Status</label>
@@ -28,19 +18,6 @@
</select> </select>
</div> </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 --> <!-- User -->
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">Reimbursed To</label> <label class="form-label">Reimbursed To</label>
-1
View File
@@ -13,7 +13,6 @@
<div class="wrapper"> <div class="wrapper">
<div class="content"></div> <div class="content"></div>
<h2 class="text-center mt-4 mb-3">Assign Role to Users</h2> <h2 class="text-center mt-4 mb-3">Assign Role to Users</h2>
<?= $this->include('partials/academic_filter') ?>
<?php if (session()->getFlashdata('success')): ?> <?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')); ?></div> <div class="alert alert-success"><?= esc(session()->getFlashdata('success')); ?></div>
-1
View File
@@ -3,7 +3,6 @@
<div class="container-fluid mt-4"> <div class="container-fluid mt-4">
<h2 class="text-center mt-4 mb-3">Staff List</h2> <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): ?> <?php if (!empty($issues_count) && (int)$issues_count > 0): ?>
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
-1
View File
@@ -13,7 +13,6 @@
<div class="wrapper"> <div class="wrapper">
<div class="content"></div> <div class="content"></div>
<h2 class="text-center mt-4 mb-3">Login Activity</h2> <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> <div id="loginActivityAlert" class="alert alert-danger d-none" role="alert"></div>
-1
View File
@@ -11,7 +11,6 @@
<div class="content"></div> <div class="content"></div>
<h2 class="text-center mt-4 mb-3">User List</h2> <h2 class="text-center mt-4 mb-3">User List</h2>
<?= $this->include('partials/academic_filter') ?>
<?php if (session()->getFlashdata('success')): ?> <?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success alert-dismissible fade show" role="alert"> <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.