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

This commit is contained in:
root
2026-07-15 23:03:51 -04:00
parent 7f3b24e47f
commit ba87598d3a
38 changed files with 1362 additions and 658 deletions
+27 -19
View File
@@ -73,30 +73,37 @@ class PrintRequests extends BaseController
public function admin_index()
{
$db = \Config\Database::connect();
$query = $db->query("
SELECT
pr.*,
u.firstname,
u.lastname,
$context = $this->resolveSchoolYearContext();
$schoolYear = $context->yearName();
$printRequestsQuery = $this->printRequestModel
->select('
print_requests.*,
u.firstname,
u.lastname,
cs.class_section_name,
admins.firstname AS admin_firstname,
admins.lastname AS admin_lastname
FROM print_requests pr
LEFT JOIN users u ON u.id = pr.teacher_id
LEFT JOIN classSection cs ON cs.class_section_id = pr.class_id
LEFT JOIN users admins ON admins.id = pr.admin_id
ORDER BY
')
->join('users u', 'u.id = print_requests.teacher_id', 'left')
->join('classSection cs', 'cs.class_section_id = print_requests.class_id', 'left')
->join('users admins', 'admins.id = print_requests.admin_id', 'left');
$this->applyPrintRequestSchoolYearScope($printRequestsQuery, $schoolYear);
$data['print_requests'] = $printRequestsQuery
->orderBy("
CASE
WHEN pr.status = 'not_assigned' THEN 1
WHEN pr.status = 'assigned' THEN 2
WHEN pr.status = 'done' THEN 3
WHEN pr.status = 'delivered' THEN 4
WHEN print_requests.status = 'not_assigned' THEN 1
WHEN print_requests.status = 'assigned' THEN 2
WHEN print_requests.status = 'done' THEN 3
WHEN print_requests.status = 'delivered' THEN 4
ELSE 5
END ASC,
pr.required_by ASC
");
$data['print_requests'] = $query->getResultArray();
END
", 'ASC', false)
->orderBy('print_requests.required_by', 'ASC')
->findAll();
$data['isSchoolYearReadonly'] = $context->isReadonly();
return view('print_requests/admin_index', $data);
}
@@ -156,6 +163,7 @@ class PrintRequests extends BaseController
// Case 1: Admin status update
if ($this->request->getPost('status')) {
$this->assertSchoolYearWritable($this->resolveSchoolYearContext());
$user_id = session()->get('user_id');
$current_status = $request['status'];
$new_status = $this->request->getPost('status');
+108 -73
View File
@@ -19,6 +19,7 @@ use App\Models\AdminNotificationSubjectModel;
use Doctrine\DBAL\Configuration;
use App\Services\FeeCalculationService;
use App\Models\StudentClassModel;
use App\Models\StudentSectionDistributionDraftModel;
use App\Controllers\View\EmailController;
use App\Controllers\View\InvoiceController;
use App\Models\StaffAttendanceModel;
@@ -2159,6 +2160,10 @@ class AdministratorController extends BaseController
public function parentProfiles()
{
if ($redirect = $this->redirectWithoutLegacyTermFilters('administrator/parent_profiles')) {
return $redirect;
}
// Fetch all users with their roles in one go
$allUsers = $this->userModel->findAll();
$parents = [];
@@ -2215,6 +2220,27 @@ class AdministratorController extends BaseController
return view('administrator/parent_profile', ['parents' => $parents]);
}
private function redirectWithoutLegacyTermFilters(string $route): ?\CodeIgniter\HTTP\RedirectResponse
{
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
$query = $this->request->getGet();
$hasLegacyTermFilter = false;
foreach ($legacyTermKeys as $key) {
if (array_key_exists($key, $query)) {
unset($query[$key]);
$hasLegacyTermFilter = true;
}
}
if (!$hasLegacyTermFilter) {
return null;
}
$target = site_url($route) . (!empty($query) ? '?' . http_build_query($query) : '');
return redirect()->to($target);
}
public function manageUsers()
{// Fetch all users
$users = $this->userModel->findAll();
@@ -2298,8 +2324,8 @@ class AdministratorController extends BaseController
public function showEnrollmentWithdrawalPage()
{
try {
$schoolYears = $this->availableSchoolYears();
$selectedYear = $this->selectedEnrollmentSchoolYear($schoolYears);
$schoolYearContext = $this->resolveSchoolYearContext();
$selectedYear = $schoolYearContext->yearName();
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
@@ -2399,11 +2425,10 @@ class AdministratorController extends BaseController
return view('enroll_withdraw/enrollment_withdrawal', [
'students' => $students,
'classes' => $classes, // <-- used by the modal <select>
'schoolYears' => $schoolYears,
'selectedYear' => $selectedYear,
'currentYear' => (string)$this->schoolYear,
'isCurrentYear' => ((string)$selectedYear === (string)$this->schoolYear),
'missingYear' => empty($this->schoolYear),
'currentYear' => $selectedYear,
'isCurrentYear' => ! $schoolYearContext->isReadonly(),
'missingYear' => $selectedYear === '',
]);
} catch (\Throwable $e) {
log_message('error', 'Enrollment/Withdrawal page error: {msg}', ['msg' => $e->getMessage()]);
@@ -2415,8 +2440,7 @@ class AdministratorController extends BaseController
public function enrollmentWithdrawalData()
{
try {
$schoolYears = $this->availableSchoolYears();
$selectedYear = $this->selectedEnrollmentSchoolYear($schoolYears);
$selectedYear = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
@@ -2502,7 +2526,6 @@ class AdministratorController extends BaseController
'csrfHash' => csrf_hash(),
'semester' => (string)$this->semester,
'school_year' => (string)$selectedYear,
'schoolYears' => $schoolYears,
]);
} catch (\Throwable $e) {
log_message('error', 'enrollmentWithdrawalData error: {msg}', ['msg' => $e->getMessage()]);
@@ -2510,70 +2533,6 @@ class AdministratorController extends BaseController
}
}
private function availableSchoolYears(): array
{
$years = [];
$addYear = static function (mixed $value) use (&$years): void {
$year = trim((string) $value);
if ($year !== '' && !in_array($year, $years, true)) {
$years[] = $year;
}
};
$addYear($this->schoolYear ?? null);
if ($this->db->tableExists('school_years')) {
$rows = $this->db->table('school_years')
->select('name')
->orderBy('name', 'DESC')
->get()
->getResultArray();
foreach ($rows as $row) {
$addYear($row['name'] ?? null);
}
}
if ($this->db->tableExists('enrollments')) {
$rows = $this->db->table('enrollments')
->distinct()
->select('school_year')
->where('school_year IS NOT NULL', null, false)
->orderBy('school_year', 'DESC')
->get()
->getResultArray();
foreach ($rows as $row) {
$addYear($row['school_year'] ?? null);
}
}
usort($years, static fn(string $a, string $b): int => strnatcasecmp($b, $a));
$activeYear = trim((string) ($this->schoolYear ?? ''));
if ($activeYear !== '') {
$years = array_values(array_filter($years, static fn(string $year): bool => $year !== $activeYear));
array_unshift($years, $activeYear);
}
return $years;
}
private function selectedEnrollmentSchoolYear(array $schoolYears): string
{
$requestedYear = trim((string) ($this->request->getGet('schoolYear') ?? ''));
if ($requestedYear !== '' && in_array($requestedYear, $schoolYears, true)) {
return $requestedYear;
}
$activeYear = trim((string) ($this->schoolYear ?? ''));
if ($activeYear !== '') {
return $activeYear;
}
return (string) ($schoolYears[0] ?? '');
}
private function enrollmentClassOptions(string $selectedYear): array
{
$select = ['id', 'class_section_id', 'class_section_name'];
@@ -2795,6 +2754,9 @@ class AdministratorController extends BaseController
}
log_message('info', "Created enrollment for student ID {$studentId} with status {$newEnrollmentStatus} and admission {$admissionStatus}.");
if (in_array($newEnrollmentStatus, ['payment pending', 'enrolled'], true)) {
$this->applyDistributionDraftToStudentClass((int)$studentId, (string)$this->schoolYear);
}
continue; // go to next student
}
@@ -2811,6 +2773,9 @@ class AdministratorController extends BaseController
// Skip if no actual change
if ($oldStatus === $newEnrollmentStatus) {
log_message('debug', "No status change for student {$studentId} ({$oldStatus}) — skipping.");
if (in_array($newEnrollmentStatus, ['payment pending', 'enrolled'], true)) {
$this->applyDistributionDraftToStudentClass((int)$studentId, (string)$this->schoolYear);
}
continue;
}
@@ -2830,6 +2795,9 @@ class AdministratorController extends BaseController
}
log_message('info', "Updated enrollment for student ID $studentId: {$oldStatus}{$newEnrollmentStatus} (admission: {$admissionStatus})");
if (in_array($newEnrollmentStatus, ['payment pending', 'enrolled'], true)) {
$this->applyDistributionDraftToStudentClass((int)$studentId, (string)$this->schoolYear);
}
// Student name
$studentRow = $this->studentModel->find($studentId);
@@ -3009,4 +2977,71 @@ class AdministratorController extends BaseController
->with('error', 'An unexpected error occurred while processing enrollments.');
}
}
private function applyDistributionDraftToStudentClass(int $studentId, string $year): void
{
try {
$draftModel = new StudentSectionDistributionDraftModel();
$draft = $draftModel->where('student_id', $studentId)
->where('school_year', $year)
->where('status', 'pending')
->first();
if (!$draft) {
return;
}
$targetSectionId = (int)($draft['class_section_id'] ?? 0);
if ($targetSectionId <= 0) {
return;
}
$studentClass = new StudentClassModel();
$exists = $studentClass->where('student_id', $studentId)
->where('school_year', $year)
->first();
$payload = [
'student_id' => $studentId,
'class_section_id' => $targetSectionId,
'school_year' => $year,
'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
'updated_at' => utc_now(),
];
if ($exists) {
$studentClass->update((int)$exists['id'], $payload);
} else {
$payload['created_at'] = utc_now();
$studentClass->insert($payload);
}
$this->db->table('enrollments')
->where('student_id', $studentId)
->where('school_year', $year)
->whereIn('enrollment_status', ['payment pending', 'enrolled'])
->update([
'class_section_id' => $targetSectionId,
'updated_at' => utc_now(),
]);
$this->db->table('promotion_queue')
->where('student_id', $studentId)
->where('school_year_to', $year)
->update([
'to_class_section_id' => $targetSectionId,
'status' => 'applied',
'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
'updated_at' => utc_now(),
]);
$draftModel->update((int)$draft['id'], [
'status' => 'applied',
'applied_at' => utc_now(),
'updated_at' => utc_now(),
]);
} catch (\Throwable $e) {
log_message('error', 'applyDistributionDraftToStudentClass failed: ' . $e->getMessage());
}
}
}
@@ -48,6 +48,8 @@ class AssignmentController extends BaseController
$selectedSemester = (string)($this->request->getGet('semester') ?? $this->semester ?? '');
$year = (string)($this->schoolYear ?? '');
$this->applyPendingDistributionDraftsForEnrolledStudents($year);
$tcQ = $this->teacherClassModel;
if ($year !== '') {
$tcQ = $tcQ->where('school_year', $year);
@@ -204,6 +206,94 @@ class AssignmentController extends BaseController
return view('administrator/class_assignment', $data);
}
private function applyPendingDistributionDraftsForEnrolledStudents(string $year): void
{
if ($year === '') {
return;
}
try {
$db = Database::connect();
if (! $db->tableExists('student_section_distribution_drafts')) {
return;
}
$rows = $db->table('student_section_distribution_drafts d')
->select('d.id, d.student_id, d.class_section_id')
->join(
'enrollments e',
'e.student_id = d.student_id AND e.school_year = d.school_year',
'inner'
)
->where('d.school_year', $year)
->where('d.status', 'pending')
->whereIn('e.enrollment_status', ['payment pending', 'enrolled'])
->groupBy('d.id, d.student_id, d.class_section_id')
->get()
->getResultArray();
if (empty($rows)) {
return;
}
$now = utc_now();
$updatedBy = (int)(session()->get('user_id') ?? 0) ?: null;
$db->transStart();
foreach ($rows as $row) {
$studentId = (int)($row['student_id'] ?? 0);
$sectionId = (int)($row['class_section_id'] ?? 0);
if ($studentId <= 0 || $sectionId <= 0) {
continue;
}
$existing = $db->table('student_class')
->select('id')
->where('student_id', $studentId)
->where('school_year', $year)
->get()
->getRowArray();
$payload = [
'student_id' => $studentId,
'class_section_id' => $sectionId,
'school_year' => $year,
'updated_by' => $updatedBy,
'updated_at' => $now,
];
if ($existing) {
$db->table('student_class')
->where('id', (int)$existing['id'])
->update($payload);
} else {
$payload['created_at'] = $now;
$db->table('student_class')->insert($payload);
}
$db->table('enrollments')
->where('student_id', $studentId)
->where('school_year', $year)
->whereIn('enrollment_status', ['payment pending', 'enrolled'])
->update([
'class_section_id' => $sectionId,
'updated_at' => $now,
]);
$db->table('student_section_distribution_drafts')
->where('id', (int)$row['id'])
->update([
'status' => 'applied',
'applied_at' => $now,
'updated_at' => $now,
]);
}
$db->transComplete();
} catch (\Throwable $e) {
log_message('error', 'applyPendingDistributionDraftsForEnrolledStudents failed: ' . $e->getMessage());
}
}
public function save()
+7 -7
View File
@@ -322,23 +322,23 @@ class EventController extends ResourceController
public function index()
{
$eventModel = new EventModel();
$today = local_date(utc_now(), 'Y-m-d');
$schoolYear = $this->currentSchoolYearName();
// Fetch all events
$events = $eventModel
$events = $this->eventModel
->where('school_year', $schoolYear)
->orderBy('created_at', 'DESC')
->findAll();
// Fetch active events (not expired)
$activeEventCount = $eventModel
$activeEventCount = $this->eventModel
->where('school_year', $schoolYear)
->where('expiration_date >=', $today)
->countAllResults();
return view('administrator/events/event_list', [
'events' => $events,
'activeEventCount' => $activeEventCount
'activeEventCount' => $activeEventCount,
'schoolYear' => $schoolYear,
]);
}
+31 -45
View File
@@ -54,14 +54,29 @@ class ExtraChargesController extends BaseController
/** Render HTML management page */
public function page()
{
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
$query = $this->request->getGet();
$hasLegacyTermFilter = false;
foreach ($legacyTermKeys as $key) {
if (array_key_exists($key, $query)) {
unset($query[$key]);
$hasLegacyTermFilter = true;
}
}
if ($hasLegacyTermFilter) {
$target = site_url('admin/charges') . (!empty($query) ? '?' . http_build_query($query) : '');
return redirect()->to($target);
}
$schoolYearContext = $this->resolveSchoolYearContext();
$schoolYear = $schoolYearContext->yearName();
$parentId = (int)($this->request->getGet('parent_id') ?? 0);
$status = $this->request->getGet('status') ?: null;
$yearSelect = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
$rows = [];
if ($parentId > 0) {
$rows = $this->additionalChargeModel
->byParentTerm($parentId, $this->schoolYear, $this->semester, $status);
->byParentTerm($parentId, $schoolYear, $this->semester, $status);
}
// Load ALL parents for current view
@@ -85,7 +100,7 @@ class ExtraChargesController extends BaseController
$parentIds = array_map(fn($r) => (int)$r['id'], $parents);
$invoicesByParent = [];
if (!empty($parentIds)) {
$all = $this->invoiceModel->getAllInvoicesByUserIds($parentIds, $yearSelect);
$all = $this->invoiceModel->getAllInvoicesByUserIds($parentIds, $schoolYear);
foreach ($all as $inv) {
$pid = (int)$inv['parent_id'];
$invoicesByParent[$pid][] = [
@@ -107,7 +122,7 @@ class ExtraChargesController extends BaseController
// ✅ Always pull all charges for the selected year & current semester (all parents)
$rows = $this->additionalChargeModel->listAllForTerm(
$yearSelect,
$schoolYear,
$this->semester,
$status,
$q,
@@ -115,38 +130,6 @@ class ExtraChargesController extends BaseController
);
$pager = $this->additionalChargeModel->pager;
// Build school year options from data (additional_charges + invoices)
$schoolYears = [];
try {
$q1 = $this->db->table('additional_charges')->select('DISTINCT school_year', false)
->where('school_year IS NOT NULL', null, false)
->orderBy('school_year', 'DESC')->get()->getResultArray();
foreach ($q1 as $r) {
$val = (string)($r['school_year'] ?? '');
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
}
} catch (\Throwable $e) {}
try {
$q2 = $this->db->table('invoices')->select('DISTINCT school_year', false)
->where('school_year IS NOT NULL', null, false)
->orderBy('school_year', 'DESC')->get()->getResultArray();
foreach ($q2 as $r) {
$val = (string)($r['school_year'] ?? '');
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
}
} catch (\Throwable $e) {}
if (empty($schoolYears) && is_string($this->schoolYear) && $this->schoolYear !== '') {
// fallback: generate recent years around configured schoolYear
$schoolYears[] = $this->schoolYear;
// Optionally add previous/next
[$start, $end] = explode('-', $this->schoolYear) + [0 => date('Y'), 1 => date('Y')+1];
$start = (int)$start;
for ($i = 1; $i <= 3; $i++) {
$schoolYears[] = ($start - $i) . '-' . (($start - $i) + 1);
}
}
rsort($schoolYears);
return view('payment/extra_charges', [
'q' => $q,
'pager' => $pager,
@@ -157,9 +140,9 @@ class ExtraChargesController extends BaseController
'parentId' => $parentId,
'selectedParentLabel' => $selectedParentLabel,
'status' => $status,
'schoolYear' => $yearSelect,
'schoolYears' => $schoolYears,
'schoolYear' => $schoolYear,
'semester' => $this->semester,
'isSchoolYearReadonly' => $schoolYearContext->isReadonly(),
]);
}
@@ -273,7 +256,7 @@ class ExtraChargesController extends BaseController
public function invoicesForParent()
{
$parentId = (int)($this->request->getGet('parent_id') ?? 0);
$schoolYear = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
$schoolYear = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
$rows = ($parentId > 0)
? ($this->invoiceModel->getInvoicesByUserId($parentId, $schoolYear) ?? [])
@@ -304,6 +287,9 @@ class ExtraChargesController extends BaseController
public function store()
{
$schoolYearContext = $this->resolveSchoolYearContext();
$this->assertSchoolYearWritable($schoolYearContext);
$schoolYear = $schoolYearContext->yearName();
$data = $this->request->getPost();
$rules = [
@@ -336,8 +322,8 @@ class ExtraChargesController extends BaseController
$payload = [
'parent_id' => (int)$data['parent_id'], // ← users.id of the parent
'invoice_id' => $invoiceId,
'school_year' => $data['school_year'] ?? $this->schoolYear,
'semester' => $data['semester'] ?? $this->semester,
'school_year' => $schoolYear,
'semester' => (string)$this->semester,
'charge_type' => $chargeType,
'title' => trim($data['title']),
'description' => trim($data['description'] ?? ''),
@@ -351,7 +337,7 @@ class ExtraChargesController extends BaseController
$this->db->transStart();
// BEFORE
$invoiceBefore = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $this->schoolYear);
$invoiceBefore = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $schoolYear);
// Insert charge
$this->additionalChargeModel->insert($payload);
@@ -363,7 +349,7 @@ class ExtraChargesController extends BaseController
}
// AFTER
$invoiceAfter = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $this->schoolYear);
$invoiceAfter = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $schoolYear);
// Parent USER (not parent table)
$parentUser = $this->userModel->getUserInfoById($data['parent_id']);
@@ -526,8 +512,8 @@ class ExtraChargesController extends BaseController
/** JSON: list charges for the current term (with optional filters). */
public function apiList()
{
$year = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
$sem = (string)($this->request->getGet('semester') ?? $this->semester);
$year = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
$sem = (string)$this->semester;
$status = $this->request->getGet('status') ?: null;
$q = trim((string)($this->request->getGet('q') ?? '')) ?: null;
$per = (int)($this->request->getGet('per_page') ?? 50);
@@ -90,6 +90,10 @@ class NotificationsController extends BaseController
{
helper('url');
if ($redirect = $this->redirectWithoutLegacyTermFilters('notifications/active')) {
return $redirect;
}
$targetGroup = $this->request->getGet('target_group');
return view('notifications/list_active', [
@@ -102,6 +106,10 @@ class NotificationsController extends BaseController
{
helper(['url', 'form']);
if ($redirect = $this->redirectWithoutLegacyTermFilters('notifications/deleted')) {
return $redirect;
}
return view('notifications/list_deleted', [
'deletedNotificationsEndpoint' => site_url('api/notifications/deleted'),
'restoreEndpoint' => site_url('notifications/restore'),
@@ -149,7 +157,7 @@ class NotificationsController extends BaseController
$targetGroup = null;
}
$schoolYear = (string) ((new \App\Models\ConfigurationModel())->getConfig('school_year') ?? '');
$schoolYear = $this->currentSchoolYearName();
if ($schoolYear !== '' && db_connect()->fieldExists('school_year', 'notifications')) {
$this->notificationModel->where('school_year', $schoolYear);
}
@@ -215,4 +223,25 @@ class NotificationsController extends BaseController
'notifications' => $notifications,
];
}
private function redirectWithoutLegacyTermFilters(string $route): ?\CodeIgniter\HTTP\RedirectResponse
{
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
$query = $this->request->getGet();
$hasLegacyTermFilter = false;
foreach ($legacyTermKeys as $key) {
if (array_key_exists($key, $query)) {
unset($query[$key]);
$hasLegacyTermFilter = true;
}
}
if (!$hasLegacyTermFilter) {
return null;
}
$target = site_url($route) . (!empty($query) ? '?' . http_build_query($query) : '');
return redirect()->to($target);
}
}
+40 -10
View File
@@ -7,6 +7,7 @@ use App\Models\UserModel;
use App\Models\StudentModel;
use App\Models\ClassSectionModel;
use App\Models\StudentClassModel;
use App\Models\StudentSectionDistributionDraftModel;
use App\Models\AuthorizedUserModel;
use App\Models\EmergencyContactModel;
use App\Models\ConfigurationModel;
@@ -444,6 +445,7 @@ class ParentController extends BaseController
$this->applyPromotionAssignment((int)$studentId, (string)$this->schoolYear);
} else {
log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) is already actively enrolled.");
$this->applyPromotionAssignment((int)$studentId, (string)$this->schoolYear);
}
} else {
$passedPreviousYear = $this->studentPassedPreviousYear((int)$studentId, (string)$this->schoolYear);
@@ -579,20 +581,29 @@ class ParentController extends BaseController
try {
$promo = new \App\Models\PromotionQueueModel();
$classSectionModel = new \App\Models\ClassSectionModel();
$draftModel = new StudentSectionDistributionDraftModel();
$studentClass = new \App\Models\StudentClassModel();
$draft = $draftModel->where('student_id', $studentId)
->where('school_year', $year)
->where('status', 'pending')
->first();
$row = $promo->where('student_id', $studentId)
->where('school_year_to', $year)
->first();
if (!$row) return; // nothing to do
if (!$row && !$draft) return; // nothing to do
$targetSectionId = (int)($row['to_class_section_id'] ?? 0);
$targetSectionId = (int)($draft['class_section_id'] ?? 0);
if ($targetSectionId <= 0) {
$targetSectionId = (int)($row['to_class_section_id'] ?? 0);
}
if ($targetSectionId <= 0) {
// Resolve base section for target class (e.g., '3')
$base = $classSectionModel->getBaseSectionByClassId((int)$row['to_class_id']);
$base = $classSectionModel->getBaseSectionByClassId((int)($row['to_class_id'] ?? 0));
if (!$base) {
log_message('warning', 'applyPromotionAssignment: base section not found for class_id=' . (int)$row['to_class_id']);
log_message('warning', 'applyPromotionAssignment: no draft or base section found for student_id=' . $studentId . ', year=' . $year);
return;
}
$targetSectionId = (int)($base['class_section_id'] ?? 0);
@@ -620,12 +631,31 @@ class ParentController extends BaseController
$studentClass->insert($payload);
}
// Mark promotion as applied
$promo->update((int)$row['id'], [
'status' => 'applied',
'updated_at' => utc_now(),
'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
]);
$this->db->table('enrollments')
->where('student_id', $studentId)
->where('school_year', $year)
->whereIn('enrollment_status', ['payment pending', 'enrolled'])
->update([
'class_section_id' => $targetSectionId,
'updated_at' => utc_now(),
]);
// Mark promotion as applied when promotion_queue is the source.
if ($row) {
$promo->update((int)$row['id'], [
'status' => 'applied',
'updated_at' => utc_now(),
'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
]);
}
if ($draft) {
$draftModel->update((int)$draft['id'], [
'status' => 'applied',
'applied_at' => utc_now(),
'updated_at' => utc_now(),
]);
}
} catch (\Throwable $e) {
log_message('error', 'applyPromotionAssignment failed: ' . $e->getMessage());
}
@@ -52,6 +52,11 @@ class RolePermissionController extends Controller
public function assignRole()
{
helper('url');
if ($redirect = $this->redirectWithoutLegacyTermFilters('rolepermission/assign_role')) {
return $redirect;
}
return view('rolepermission/assign_role', [
'assignRoleEndpoint' => site_url('api/rolepermission/users'),
'rolesEndpoint' => site_url('api/rolepermission/roles'),
@@ -196,6 +201,27 @@ class RolePermissionController extends Controller
return ['users' => $normalized];
}
private function redirectWithoutLegacyTermFilters(string $route): ?\CodeIgniter\HTTP\RedirectResponse
{
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
$query = $this->request->getGet();
$hasLegacyTermFilter = false;
foreach ($legacyTermKeys as $key) {
if (array_key_exists($key, $query)) {
unset($query[$key]);
$hasLegacyTermFilter = true;
}
}
if (!$hasLegacyTermFilter) {
return null;
}
$target = site_url($route) . (!empty($query) ? '?' . http_build_query($query) : '');
return redirect()->to($target);
}
private function updateStaffRecord(int $userId, array $newRoleNames): void
{
$excludedRoles = ['parent', 'student', 'guest'];
+29 -2
View File
@@ -32,6 +32,12 @@ class StaffController extends BaseController
public function index()
{
if ($redirect = $this->redirectWithoutLegacyTermFilters('staff/index')) {
return $redirect;
}
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
// roles we never show
$excludedRoles = ['student', 'parent', 'guest', 'inactive']; // ← add inactive here
@@ -45,7 +51,7 @@ class StaffController extends BaseController
$assignRows = $this->teacherClassModel
->select('teacher_class.teacher_id, teacher_class.position, classSection.class_section_name')
->join('classSection', 'classSection.class_section_id = teacher_class.class_section_id', 'left')
->where('teacher_class.school_year', (string)$this->schoolYear)
->where('teacher_class.school_year', $schoolYear)
->findAll();
$assignByTeacher = [];
@@ -88,10 +94,31 @@ class StaffController extends BaseController
'staff' => $staffList,
'issues_count' => $issuesCount,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'school_year' => $schoolYear,
]);
}
private function redirectWithoutLegacyTermFilters(string $route): ?\CodeIgniter\HTTP\RedirectResponse
{
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
$query = $this->request->getGet();
$hasLegacyTermFilter = false;
foreach ($legacyTermKeys as $key) {
if (array_key_exists($key, $query)) {
unset($query[$key]);
$hasLegacyTermFilter = true;
}
}
if (!$hasLegacyTermFilter) {
return null;
}
$target = site_url($route) . (!empty($query) ? '?' . http_build_query($query) : '');
return redirect()->to($target);
}
public function create()
{
return view('staff/create');
+417 -137
View File
@@ -10,6 +10,7 @@ use App\Models\ClassSectionModel;
use App\Models\EmergencyContactModel;
use App\Models\EnrollmentModel;
use App\Models\ConfigurationModel;
use App\Models\StudentSectionDistributionDraftModel;
use App\Models\StudentAllergyModel;
use App\Models\StudentMedicalConditionModel;
use CodeIgniter\Database\Exceptions\DataException;
@@ -866,10 +867,10 @@ class StudentController extends BaseController
}
/**
* POST admin endpoint: auto distribute students into lettered sections for a class
* Input: class_id (int), students_per_section (int), school_year (optional)
* Uses promotion_queue for the selected year (students who passed last year to this class).
* Balances male/female per section and respects capacity.
* POST admin endpoint: create draft balanced distribution rows for a class.
*
* Input: class_id/class_section_id, section_count, min_students_per_section,
* max_students_per_section, school_year.
*/
public function autoDistributeSections()
{
@@ -884,7 +885,10 @@ class StudentController extends BaseController
try {
$classId = (int) $this->request->getPost('class_id');
$classSectionId = (int) $this->request->getPost('class_section_id');
$perSec = (int) $this->request->getPost('students_per_section');
$sectionCount = (int) $this->request->getPost('section_count');
$minPerSection = (int) $this->request->getPost('min_students_per_section');
$maxRaw = trim((string) ($this->request->getPost('max_students_per_section') ?? ''));
$maxPerSection = $maxRaw === '' ? null : (int) $maxRaw;
$year = trim((string) ($this->request->getPost('school_year') ?? $this->schoolYear));
if ($classId <= 0 && $classSectionId > 0) {
@@ -892,51 +896,27 @@ class StudentController extends BaseController
$classId = (int) ($cid ?? 0);
}
if ($classId <= 0 || $perSec <= 0) {
$msg = 'Invalid class_id or students_per_section.';
if ($classId <= 0 || $sectionCount <= 0 || $minPerSection <= 0 || ($maxPerSection !== null && $maxPerSection <= 0)) {
$msg = 'Enter a valid class, section count, minimum size, and optional maximum size.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
$promo = new \App\Models\PromotionQueueModel();
// Candidates from promotion queue for this class/year and enrolled (or payment pending)
$cands = $promo->select('promotion_queue.*, students.gender')
->join('students', 'students.id = promotion_queue.student_id', 'left')
->where('promotion_queue.to_class_id', $classId)
->where('promotion_queue.school_year_to', $year)
->whereIn('promotion_queue.status', ['queued','assigned'])
->findAll();
$cands = $this->distributionCandidates($classId, $year);
if (empty($cands)) {
$msg = 'No students found in promotion queue for selected class/year.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 200) : redirect()->back()->with('info', $msg);
}
// Filter to those with enrollment for this year in acceptable statuses
$studentIds = array_map(static fn($r) => (int)$r['student_id'], $cands);
$enrolledIds = [];
if (!empty($studentIds)) {
$rows = $this->db->table('enrollments')
->select('student_id')
->whereIn('student_id', $studentIds)
->where('school_year', $year)
->whereIn('enrollment_status', ['payment pending', 'enrolled'])
->groupBy('student_id')
->get()->getResultArray();
$enrolledIds = array_map(static fn($r) => (int)$r['student_id'], $rows);
}
$cands = array_values(array_filter($cands, static function ($r) use ($enrolledIds) {
return in_array((int)$r['student_id'], $enrolledIds, true);
}));
if (empty($cands)) {
$msg = 'No eligible enrolled students found to distribute.';
$msg = 'No promoted students found to distribute for selected class/year.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 200) : redirect()->back()->with('info', $msg);
}
$total = count($cands);
$sectionsNeeded = (int) ceil($total / $perSec);
if ($sectionCount * $minPerSection > $total) {
$msg = 'Insufficient students: ' . $sectionCount . ' sections require at least ' . ($sectionCount * $minPerSection) . ' students, but only ' . $total . ' are available.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
if ($maxPerSection !== null && $total > $sectionCount * $maxPerSection) {
$msg = 'Capacity exceeded: ' . $sectionCount . ' sections can hold at most ' . ($sectionCount * $maxPerSection) . ' students, but ' . $total . ' must be assigned.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
// Fetch lettered sections for this class
$letters = $this->classSectionModel->getLetterSectionsByClassId($classId);
@@ -945,109 +925,66 @@ class StudentController extends BaseController
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
if (count($letters) < $sectionsNeeded) {
$msg = 'Not enough sections available. Needed: ' . $sectionsNeeded . ', available: ' . count($letters);
if (count($letters) < $sectionCount) {
$msg = 'Not enough sections available. Needed: ' . $sectionCount . ', available: ' . count($letters);
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
// Keep only required number of sections
$letters = array_slice($letters, 0, $sectionsNeeded);
$letters = array_slice($letters, 0, $sectionCount);
$buckets = $this->buildBalancedDistribution($cands, $letters, $minPerSection, $maxPerSection);
// Prepare buckets
$buckets = [];
foreach ($letters as $idx => $sec) {
$buckets[$idx] = [
'class_section_id' => (int)$sec['class_section_id'],
'assigned' => [],
'male' => 0,
'female' => 0,
];
}
// Split by gender
$males = [];
$females = [];
foreach ($cands as $r) {
$g = (string)($r['gender'] ?? '');
if (strcasecmp($g, 'Female') === 0) $females[] = $r; else $males[] = $r; // default non-female -> male bucket
}
// Helper to pick next bucket with available capacity and least of a gender
$pickBucket = function (string $gender) use (&$buckets, $perSec): ?int {
$bestIdx = null;
$bestCnt = PHP_INT_MAX;
foreach ($buckets as $i => $b) {
if (count($b['assigned']) >= $perSec) continue;
$cnt = ($gender === 'female') ? $b['female'] : $b['male'];
if ($cnt < $bestCnt) {
$bestCnt = $cnt;
$bestIdx = $i;
}
}
return $bestIdx;
};
// Assign males then females for balance
foreach ($males as $r) {
$bi = $pickBucket('male');
if ($bi === null) break;
$buckets[$bi]['assigned'][] = (int)$r['student_id'];
$buckets[$bi]['male']++;
}
foreach ($females as $r) {
$bi = $pickBucket('female');
if ($bi === null) break;
$buckets[$bi]['assigned'][] = (int)$r['student_id'];
$buckets[$bi]['female']++;
}
// Persist: set to_class_section_id on queue and upsert student_class
$promoIdsBySid = [];
foreach ($cands as $r) {
$promoIdsBySid[(int)$r['student_id']] = (int)$r['id'];
}
$studentClass = new StudentClassModel();
$draftModel = new StudentSectionDistributionDraftModel();
$promo = new \App\Models\PromotionQueueModel();
$updatedBy = (int)(session()->get('user_id') ?? 0) ?: null;
$now = utc_now();
$batchKey = sha1($year . ':' . $classId . ':' . microtime(true));
$this->db->transStart();
$studentIdsToReplace = array_values(array_unique(array_map(
static fn(array $student): int => (int)($student['student_id'] ?? 0),
$cands
)));
if (!empty($studentIdsToReplace)) {
$draftModel->where('school_year', $year)
->whereIn('student_id', $studentIdsToReplace)
->where('status', 'pending')
->delete();
}
foreach ($buckets as $b) {
$secId = (int)$b['class_section_id'];
foreach ($b['assigned'] as $sid) {
// Update promotion queue
if (isset($promoIdsBySid[$sid])) {
$promo->update($promoIdsBySid[$sid], [
foreach ($b['assigned'] as $student) {
$sid = (int)$student['student_id'];
$draftModel->insert([
'student_id' => $sid,
'class_id' => $classId,
'class_section_id' => $secId,
'school_year' => $year,
'previous_school_year' => (string)($student['school_year_from'] ?? ''),
'previous_final_score' => $student['previous_final_score'],
'score_group' => $student['score_group'],
'status' => 'pending',
'batch_key' => $batchKey,
'created_by' => $updatedBy,
'created_at' => $now,
'updated_at' => $now,
]);
if ((int)($student['promotion_queue_id'] ?? 0) > 0) {
$promo->update((int)$student['promotion_queue_id'], [
'to_class_section_id' => $secId,
'status' => 'assigned',
'updated_by' => $updatedBy,
'updated_at' => $now,
]);
}
// Upsert student_class
$exists = $studentClass->where('student_id', $sid)
->where('school_year', $year)
->where('semester', (string)$this->semester)
->first();
$payload = [
'student_id' => $sid,
'class_section_id' => $secId,
'school_year' => $year,
'semester' => (string)$this->semester,
'updated_by' => $updatedBy,
'updated_at' => $now,
];
if ($exists) {
$studentClass->update((int)$exists['id'], $payload);
} else {
$payload['created_at'] = $now;
$studentClass->insert($payload);
}
}
}
$this->db->transComplete();
if (!$this->db->transStatus()) {
$msg = 'Distribution could not be saved. No official student class rows were changed.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 500) : redirect()->back()->with('error', $msg);
}
// Build a map for section id -> name (for friendly headers)
$nameById = [];
foreach ($letters as $secRow) {
$nameById[(int)$secRow['class_section_id']] = (string)($secRow['class_section_name'] ?? '');
@@ -1056,27 +993,323 @@ class StudentController extends BaseController
$summary = [];
foreach ($buckets as $b) {
$secId = (int)$b['class_section_id'];
$scores = array_map(static fn($s): float => (float)$s['previous_final_score'], $b['assigned']);
$groups = ['90_100' => 0, '80_89' => 0, '70_79' => 0, '69_below' => 0];
$male = 0;
$female = 0;
$studentNames = [];
foreach ($b['assigned'] as $student) {
$groups[$student['score_group']] = ($groups[$student['score_group']] ?? 0) + 1;
$gender = strtolower((string)($student['gender'] ?? ''));
if ($gender === 'female') $female++; else $male++;
$studentName = trim((string)($student['student_name'] ?? ''));
if ($studentName === '') {
$studentName = 'Student #' . (int)($student['student_id'] ?? 0);
}
$studentNames[] = $studentName;
}
$summary[] = [
'class_section_id' => $secId,
'class_section_name' => $nameById[$secId] ?? (string)$secId,
'total' => count($b['assigned']),
'male' => $b['male'],
'female' => $b['female'],
'male' => $male,
'female' => $female,
'score_groups' => $groups,
'average_score' => count($scores) > 0 ? round(array_sum($scores) / count($scores), 2) : null,
'student_names' => $studentNames,
];
}
return $isAjax
? $json(['ok' => true, 'message' => 'Auto distribution completed.', 'sections' => $summary])
: redirect()->back()->with('success', 'Auto distribution completed.');
? $json(['ok' => true, 'message' => 'Draft distribution saved. Students will move to student_class when they enroll.', 'sections' => $summary])
: redirect()->back()->with('success', 'Draft distribution saved.');
} catch (\Throwable $e) {
$msg = 'Auto distribution failed: ' . $e->getMessage();
return $isAjax ? $json(['ok' => false, 'message' => $msg], 500) : redirect()->back()->with('error', $msg);
}
}
private function distributionCandidates(int $classId, string $year): array
{
$rows = $this->db->table('promotion_queue pq')
->select('pq.id AS promotion_queue_id, pq.student_id, pq.school_year_from, pq.to_class_id, students.firstname, students.lastname, students.gender, sd.year_score AS decision_score')
->join('students', 'students.id = pq.student_id', 'left')
->join('student_decisions sd', 'sd.student_id = pq.student_id AND sd.school_year = pq.school_year_from', 'left')
->where('pq.to_class_id', $classId)
->where('pq.school_year_to', $year)
->whereIn('pq.status', ['queued', 'assigned'])
->groupBy('pq.id')
->get()
->getResultArray();
if (empty($rows)) {
return $this->decisionDistributionCandidates($classId, $year);
}
$out = [];
foreach ($rows as $row) {
$score = is_numeric($row['decision_score'] ?? null)
? (float)$row['decision_score']
: $this->previousAverageScore((int)$row['student_id'], (string)($row['school_year_from'] ?? ''));
$score = $score === null ? 0.0 : max(0.0, min(100.0, $score));
$row['previous_final_score'] = $score;
$row['score_group'] = $this->scoreGroup($score);
$row['student_name'] = $this->formatStudentName($row);
$out[] = $row;
}
return $out;
}
private function decisionDistributionCandidates(int $classId, string $targetSchoolYear): array
{
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
if ($previousSchoolYear === null || ! $this->db->tableExists('student_decisions')) {
return [];
}
$rows = $this->db->table('student_decisions sd')
->select('sd.id AS decision_id, sd.student_id, sd.class_section_name, sd.year_score, sd.decision, students.firstname, students.lastname, students.gender')
->join('students', 'students.id = sd.student_id', 'left')
->where('sd.school_year', $previousSchoolYear)
->where('students.is_active', 1)
->orderBy('sd.updated_at', 'DESC')
->orderBy('sd.id', 'DESC')
->get()
->getResultArray();
$seen = [];
$out = [];
foreach ($rows as $row) {
$studentId = (int)($row['student_id'] ?? 0);
if ($studentId <= 0 || isset($seen[$studentId])) {
continue;
}
$seen[$studentId] = true;
$targetClassId = $this->targetClassIdFromDecision(
(string)($row['class_section_name'] ?? ''),
(string)($row['decision'] ?? '')
);
if ($targetClassId !== $classId) {
continue;
}
$score = is_numeric($row['year_score'] ?? null) ? (float)$row['year_score'] : 0.0;
$score = max(0.0, min(100.0, $score));
$out[] = [
'promotion_queue_id' => 0,
'student_id' => $studentId,
'school_year_from' => $previousSchoolYear,
'to_class_id' => $classId,
'student_name' => $this->formatStudentName($row),
'gender' => (string)($row['gender'] ?? ''),
'previous_final_score' => $score,
'score_group' => $this->scoreGroup($score),
];
}
return $out;
}
private function targetClassIdFromDecision(string $classSectionName, string $decision): ?int
{
$decision = strtolower(trim($decision));
$baseName = strtoupper(trim(preg_replace('/-.+$/', '', $classSectionName) ?? ''));
if ($baseName === '') {
return null;
}
$targetBaseName = $baseName;
if ($decision === 'pass') {
if ($baseName === 'KG') {
$targetBaseName = '1';
} elseif (ctype_digit($baseName)) {
$level = (int)$baseName;
$targetBaseName = $level >= 9 ? 'YOUTH' : (string)($level + 1);
} elseif ($baseName === 'YOUTH') {
$targetBaseName = 'YOUTH';
}
}
$row = $this->classSectionModel
->select('class_id')
->where('UPPER(class_section_name)', $targetBaseName)
->where("class_section_name NOT LIKE '%-%'", null, false)
->first();
return $row ? (int)$row['class_id'] : null;
}
private function formatStudentName(array $row): string
{
$name = trim(
trim((string)($row['firstname'] ?? '')) . ' ' .
trim((string)($row['lastname'] ?? ''))
);
return $name !== '' ? $name : 'Student #' . (int)($row['student_id'] ?? 0);
}
private function previousSchoolYearName(string $schoolYear): ?string
{
if (preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches) !== 1) {
return null;
}
return ((int)$matches[1] - 1) . '-' . ((int)$matches[2] - 1);
}
private function previousAverageScore(int $studentId, string $schoolYear): ?float
{
if ($studentId <= 0 || $schoolYear === '') {
return null;
}
$row = $this->db->table('semester_scores')
->select('AVG(semester_score) AS avg_score')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('semester_score IS NOT NULL', null, false)
->get()
->getRowArray();
return is_numeric($row['avg_score'] ?? null) ? (float)$row['avg_score'] : null;
}
private function scoreGroup(float $score): string
{
if ($score >= 90) return '90_100';
if ($score >= 80) return '80_89';
if ($score >= 70) return '70_79';
return '69_below';
}
private function buildBalancedDistribution(array $students, array $sections, int $minPerSection, ?int $maxPerSection): array
{
$sectionCount = count($sections);
$total = count($students);
$baseSize = intdiv($total, $sectionCount);
$remainder = $total % $sectionCount;
$targetSizes = [];
foreach ($sections as $idx => $section) {
$targetSizes[$idx] = $baseSize + ($idx < $remainder ? 1 : 0);
}
$buckets = [];
foreach ($sections as $idx => $section) {
$buckets[$idx] = [
'class_section_id' => (int)$section['class_section_id'],
'assigned' => [],
];
}
$groups = ['90_100' => [], '80_89' => [], '70_79' => [], '69_below' => []];
foreach ($students as $student) {
$groups[$student['score_group']][] = $student;
}
$currentCounts = array_fill(0, $sectionCount, 0);
$allocations = [];
$groupIndex = 0;
foreach ($groups as $groupName => $groupStudents) {
usort($groupStudents, static fn($a, $b): int => ((float)$b['previous_final_score']) <=> ((float)$a['previous_final_score']));
$groupTotal = count($groupStudents);
$base = intdiv($groupTotal, $sectionCount);
$extra = $groupTotal % $sectionCount;
$allocations[$groupName] = array_fill(0, $sectionCount, $base);
foreach ($currentCounts as $idx => $cnt) {
$currentCounts[$idx] += $base;
}
$order = range(0, $sectionCount - 1);
$offset = $groupIndex % $sectionCount;
$order = array_merge(array_slice($order, $offset), array_slice($order, 0, $offset));
usort($order, static function ($a, $b) use ($targetSizes, $currentCounts) {
$remainingA = $targetSizes[$a] - $currentCounts[$a];
$remainingB = $targetSizes[$b] - $currentCounts[$b];
return $remainingB <=> $remainingA;
});
foreach ($order as $sectionIdx) {
if ($extra <= 0) break;
if ($currentCounts[$sectionIdx] >= $targetSizes[$sectionIdx]) continue;
$allocations[$groupName][$sectionIdx]++;
$currentCounts[$sectionIdx]++;
$extra--;
}
$groups[$groupName] = $groupStudents;
$groupIndex++;
}
foreach ($groups as $groupName => $groupStudents) {
$quotas = $allocations[$groupName];
foreach ($groupStudents as $idx => $student) {
$round = intdiv($idx, max(1, $sectionCount));
$order = range(0, $sectionCount - 1);
if ($round % 2 === 1) {
$order = array_reverse($order);
}
foreach ($order as $sectionIdx) {
if (($quotas[$sectionIdx] ?? 0) <= 0) continue;
$buckets[$sectionIdx]['assigned'][] = $student;
$quotas[$sectionIdx]--;
break;
}
}
}
return $this->balanceDistributionAverages($buckets, $minPerSection, $maxPerSection);
}
private function balanceDistributionAverages(array $buckets, int $minPerSection, ?int $maxPerSection): array
{
for ($i = 0; $i < 50; $i++) {
$averages = array_map(function ($bucket): float {
if (empty($bucket['assigned'])) return 0.0;
$scores = array_map(static fn($student): float => (float)$student['previous_final_score'], $bucket['assigned']);
return array_sum($scores) / count($scores);
}, $buckets);
$highIdx = array_keys($averages, max($averages), true)[0];
$lowIdx = array_keys($averages, min($averages), true)[0];
if (($averages[$highIdx] - $averages[$lowIdx]) <= 1.0) {
break;
}
$best = null;
foreach ($buckets[$highIdx]['assigned'] as $hiPos => $hiStudent) {
foreach ($buckets[$lowIdx]['assigned'] as $loPos => $loStudent) {
if ($hiStudent['score_group'] !== $loStudent['score_group']) continue;
$trial = $buckets;
$trial[$highIdx]['assigned'][$hiPos] = $loStudent;
$trial[$lowIdx]['assigned'][$loPos] = $hiStudent;
$trialAvg = array_map(function ($bucket): float {
if (empty($bucket['assigned'])) return 0.0;
$scores = array_map(static fn($student): float => (float)$student['previous_final_score'], $bucket['assigned']);
return array_sum($scores) / count($scores);
}, $trial);
$newSpread = max($trialAvg) - min($trialAvg);
if ($newSpread < ($averages[$highIdx] - $averages[$lowIdx])) {
$best = [$hiPos, $loPos, $newSpread];
}
}
}
if ($best === null) {
break;
}
[$hiPos, $loPos] = $best;
$tmp = $buckets[$highIdx]['assigned'][$hiPos];
$buckets[$highIdx]['assigned'][$hiPos] = $buckets[$lowIdx]['assigned'][$loPos];
$buckets[$lowIdx]['assigned'][$loPos] = $tmp;
}
return $buckets;
}
/**
* API: Return totals per base class (KG, 1..9, Youth) for promotion_queue in selected year
* Only counts students with enrollment status 'payment pending' or 'enrolled'.
* API: Return promoted-student totals per base class for the selected year.
*/
public function promotionTotalsApi()
{
@@ -1111,22 +1344,24 @@ class StudentController extends BaseController
$out = [];
foreach ($wanted as $r) {
$classId = (int)$r['class_id'];
// candidates from promotion_queue for this base class in the target year
$cands = $this->db->table('promotion_queue pq')
->select('pq.student_id')
->join('enrollments e', 'e.student_id = pq.student_id AND e.school_year = ' . $this->db->escape($year), 'left')
->where('pq.to_class_id', $classId)
->where('pq.school_year_to', $year)
->whereIn('pq.status', ['queued','assigned','applied'])
->whereIn('e.enrollment_status', ['payment pending', 'enrolled'])
->whereIn('pq.status', ['queued','assigned'])
->groupBy('pq.student_id')
->get()->getResultArray();
$total = count($cands);
if ($total === 0) {
$total = count($this->decisionDistributionCandidates($classId, $year));
}
$out[] = [
'class_id' => $classId,
'class_section_id' => (int)($r['class_section_id'] ?? 0),
'class_section_name'=> (string)($r['class_section_name'] ?? ''),
'total' => count($cands),
'total' => $total,
'sections' => $this->savedDistributionSections($classId, $year),
];
}
@@ -1136,6 +1371,51 @@ class StudentController extends BaseController
}
}
private function savedDistributionSections(int $classId, string $year): array
{
if (! $this->db->tableExists('student_section_distribution_drafts')) {
return [];
}
$rows = $this->db->table('student_section_distribution_drafts d')
->select('d.class_section_id, cs.class_section_name, students.firstname, students.lastname, d.student_id')
->join('classSection cs', 'cs.class_section_id = d.class_section_id', 'left')
->join('students', 'students.id = d.student_id', 'left')
->where('d.class_id', $classId)
->where('d.school_year', $year)
->where('d.status', 'pending')
->orderBy('cs.class_section_name', 'ASC')
->orderBy('students.lastname', 'ASC')
->orderBy('students.firstname', 'ASC')
->get()
->getResultArray();
$sections = [];
foreach ($rows as $row) {
$sectionId = (int)($row['class_section_id'] ?? 0);
if ($sectionId <= 0) {
continue;
}
if (!isset($sections[$sectionId])) {
$sections[$sectionId] = [
'class_section_id' => $sectionId,
'class_section_name' => (string)($row['class_section_name'] ?? $sectionId),
'total' => 0,
'student_names' => [],
];
}
$name = trim(trim((string)($row['firstname'] ?? '')) . ' ' . trim((string)($row['lastname'] ?? '')));
if ($name === '') {
$name = 'Student #' . (int)($row['student_id'] ?? 0);
}
$sections[$sectionId]['student_names'][] = $name;
$sections[$sectionId]['total']++;
}
return array_values($sections);
}
/**
* POST /students/update/{id}
*/
+24 -20
View File
@@ -256,31 +256,14 @@ class TeacherController extends BaseController
public function teacherClassAssignment()
{
$selectedYear = trim((string)($this->request->getGet('schoolYear') ?? $this->request->getGet('school_year') ?? ''));
if ($selectedYear === '') {
$selectedYear = (string)($this->schoolYear ?? 'Not Set');
if ($redirect = $this->redirectWithoutLegacyTermFilters('administrator/teacher_class_assignment')) {
return $redirect;
}
// Build distinct school years from classSection (fallback to configured year)
try {
$yearsRows = $this->db->table('classSection')
->distinct()
->select('school_year')
->orderBy('school_year', 'DESC')
->get()->getResultArray();
$schoolYears = array_values(array_filter(array_map(static function ($r) {
return isset($r['school_year']) ? (string)$r['school_year'] : null;
}, $yearsRows)));
if (empty($schoolYears) && !empty($this->schoolYear)) {
$schoolYears = [(string)$this->schoolYear];
}
} catch (\Throwable $e) {
$schoolYears = !empty($this->schoolYear) ? [(string)$this->schoolYear] : [];
}
$selectedYear = (string)($this->schoolYear ?? 'Not Set');
return view('administrator/teacher_class_assignment', [
'schoolYear' => (string)$selectedYear,
'schoolYears' => $schoolYears,
'currentYear' => (string)($this->schoolYear ?? ''),
'isCurrentYear' => ((string)$selectedYear === (string)($this->schoolYear ?? '')),
'missingYear' => empty($this->schoolYear),
@@ -292,6 +275,27 @@ class TeacherController extends BaseController
]);
}
private function redirectWithoutLegacyTermFilters(string $route): ?\CodeIgniter\HTTP\RedirectResponse
{
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
$query = $this->request->getGet();
$hasLegacyTermFilter = false;
foreach ($legacyTermKeys as $key) {
if (array_key_exists($key, $query)) {
unset($query[$key]);
$hasLegacyTermFilter = true;
}
}
if (!$hasLegacyTermFilter) {
return null;
}
$target = site_url($route) . (!empty($query) ? '?' . http_build_query($query) : '');
return redirect()->to($target);
}
private function buildTeacherClassAssignmentPayload(?string $forYear = null): array
{
$classSectionModel = new ClassSectionModel();
+29
View File
@@ -81,6 +81,27 @@ class UserController extends BaseController
return hash('sha256', $token);
}
private function redirectWithoutLegacyTermFilters(string $route): ?\CodeIgniter\HTTP\RedirectResponse
{
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
$query = $this->request->getGet();
$hasLegacyTermFilter = false;
foreach ($legacyTermKeys as $key) {
if (array_key_exists($key, $query)) {
unset($query[$key]);
$hasLegacyTermFilter = true;
}
}
if (!$hasLegacyTermFilter) {
return null;
}
$target = site_url($route) . (!empty($query) ? '?' . http_build_query($query) : '');
return redirect()->to($target);
}
// Method to show the home page
public function home()
{
@@ -113,6 +134,10 @@ class UserController extends BaseController
helper('url');
if ($redirect = $this->redirectWithoutLegacyTermFilters('user/user_list')) {
return $redirect;
}
return view('user/user_list', [
'userListEndpoint' => site_url('api/users'),
]);
@@ -854,6 +879,10 @@ class UserController extends BaseController
helper('url');
if ($redirect = $this->redirectWithoutLegacyTermFilters('user/login_activity')) {
return $redirect;
}
$perPage = (int) ($this->request->getGet('per_page') ?? 25);
return view('user/login_activity', [