diff --git a/app/Commands/SyncStudentActivity.php b/app/Commands/SyncStudentActivity.php new file mode 100644 index 0000000..9368f7d --- /dev/null +++ b/app/Commands/SyncStudentActivity.php @@ -0,0 +1,171 @@ + 'School year to use as the controlling enrollment year.', + '--dry-run' => 'Report changes without updating students.', + ]; + + private BaseConnection $db; + private EnrollmentStatusService $statusService; + + public function run(array $params) + { + $this->db = \Config\Database::connect(); + $this->statusService = new EnrollmentStatusService($this->db); + + $schoolYear = $this->optionValue('school-year'); + if ($schoolYear === '') { + $schoolYear = $this->statusService->currentSchoolYear(); + } + + if ($schoolYear === '') { + CLI::error('Missing --school-year and no configured current school year was found.'); + return EXIT_ERROR; + } + + $dryRun = CLI::getOption('dry-run') !== null; + $duplicates = $this->duplicates($schoolYear); + $unknownStatuses = []; + $withoutEnrollment = []; + $mismatches = []; + $updated = 0; + + $students = $this->db->table('students') + ->select('id, school_id, firstname, lastname, is_active') + ->orderBy('id', 'ASC') + ->get() + ->getResultArray(); + + foreach ($students as $student) { + $studentId = (int) $student['id']; + $enrollment = $this->statusService->controllingEnrollment($studentId, $schoolYear); + if ($enrollment === null) { + $expected = 0; + $withoutEnrollment[] = $this->studentLabel($student); + } else { + try { + $status = $this->statusService->normalizeStatus((string) ($enrollment['enrollment_status'] ?? '')); + $expected = $this->statusService->activeFlagForStatus($status); + } catch (\InvalidArgumentException $e) { + $unknownStatuses[] = [ + 'student' => $this->studentLabel($student), + 'enrollment_id' => (int) ($enrollment['id'] ?? 0), + 'status' => (string) ($enrollment['enrollment_status'] ?? ''), + ]; + continue; + } + } + + $actual = (int) ($student['is_active'] ?? 1); + if ($actual !== $expected) { + $mismatches[] = [ + 'student' => $this->studentLabel($student), + 'current' => $actual, + 'expected' => $expected, + 'status' => (string) ($enrollment['enrollment_status'] ?? 'no enrollment'), + ]; + + if (! $dryRun) { + $this->db->table('students')->where('id', $studentId)->update(['is_active' => $expected]); + $updated++; + } + } + } + + CLI::write('School year: ' . $schoolYear); + CLI::write('Dry run: ' . ($dryRun ? 'yes' : 'no')); + CLI::write('Duplicate enrollment groups: ' . count($duplicates)); + foreach ($duplicates as $row) { + CLI::write(sprintf( + ' student_id=%d school_year=%s semester=%s count=%d', + (int) $row['student_id'], + (string) $row['school_year'], + (string) ($row['semester'] ?? ''), + (int) $row['row_count'] + )); + } + + CLI::write('Unknown statuses: ' . count($unknownStatuses)); + foreach ($unknownStatuses as $row) { + CLI::write(sprintf(' %s enrollment_id=%d status=%s', $row['student'], $row['enrollment_id'], $row['status'])); + } + + CLI::write('Students without current enrollment: ' . count($withoutEnrollment)); + foreach (array_slice($withoutEnrollment, 0, 50) as $label) { + CLI::write(' ' . $label); + } + if (count($withoutEnrollment) > 50) { + CLI::write(' ... ' . (count($withoutEnrollment) - 50) . ' more'); + } + + CLI::write('Mismatches: ' . count($mismatches)); + foreach ($mismatches as $row) { + CLI::write(sprintf( + ' %s current=%d expected=%d status=%s', + $row['student'], + $row['current'], + $row['expected'], + $row['status'] + )); + } + + if (! $dryRun) { + CLI::write('Updated students: ' . $updated); + } + + return EXIT_SUCCESS; + } + + private function duplicates(string $schoolYear): array + { + return $this->db->table('enrollments') + ->select('student_id, school_year, semester, COUNT(*) AS row_count') + ->where('school_year', $schoolYear) + ->groupBy('student_id, school_year, semester') + ->having('COUNT(*) >', 1) + ->orderBy('student_id', 'ASC') + ->get() + ->getResultArray(); + } + + private function studentLabel(array $student): string + { + $name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')); + return '#' . (int) ($student['id'] ?? 0) . ($name !== '' ? ' ' . $name : ''); + } + + private function optionValue(string $name): string + { + $value = CLI::getOption($name); + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + + $argv = $_SERVER['argv'] ?? []; + $prefix = '--' . $name . '='; + foreach ($argv as $index => $arg) { + if (is_string($arg) && str_starts_with($arg, $prefix)) { + return trim(substr($arg, strlen($prefix))); + } + + if ($arg === '--' . $name && isset($argv[$index + 1])) { + return trim((string) $argv[$index + 1]); + } + } + + return ''; + } +} diff --git a/app/Config/Routes.php b/app/Config/Routes.php index c456e56..5315538 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -107,16 +107,17 @@ $routes->post('student/score-card', 'View\StudentController::scoreCard'); $routes->get('student/score-card', 'View\StudentController::scoreCardIndex'); $routes->get('student/score-card/list', 'View\StudentController::scoreCardList'); $routes->get('administrator/student-score-card', 'View\StudentController::scoreCardAdmin'); -$routes->get('administrator/enrollment-admin', 'View\EnrollmentAdminController::dashboard', ['filter' => 'auth:admin']); -$routes->get('administrator/enrollment-admin/email-preview', 'View\EnrollmentAdminController::previewEmail', ['filter' => 'auth:admin']); -$routes->post('administrator/enrollment-admin/approve-launch', 'View\EnrollmentAdminController::approveLaunch', ['filter' => 'auth:admin']); -$routes->post('administrator/enrollment-admin/send-registration-emails', 'View\EnrollmentAdminController::sendRegistrationEmails', ['filter' => 'auth:admin']); -$routes->post('administrator/enrollment-admin/flags/(:num)/resolve', 'View\EnrollmentAdminController::resolveFlag/$1', ['filter' => 'auth:admin']); -$routes->post('administrator/enrollment-admin/flags/(:num)/assign-class', 'View\EnrollmentAdminController::assignClass/$1', ['filter' => 'auth:admin']); -$routes->post('administrator/enrollment-admin/flags/(:num)/makeup-promotion', 'View\EnrollmentAdminController::confirmMakeupPromotion/$1', ['filter' => 'auth:admin']); -$routes->post('administrator/enrollment-admin/flags/(:num)/approve-exception', 'View\EnrollmentAdminController::approveException/$1', ['filter' => 'auth:admin']); -$routes->post('administrator/enrollment-admin/exceptions/create', 'View\EnrollmentAdminController::createException', ['filter' => 'auth:admin']); -$routes->post('administrator/enrollment-admin/exceptions/(:num)/revoke', 'View\EnrollmentAdminController::revokeException/$1', ['filter' => 'auth:admin']); +$enrollmentAdminFilter = 'auth:admin|administrator|principal|vice_principal|vice principal'; +$routes->get('administrator/enrollment-admin', 'View\EnrollmentAdminController::dashboard', ['filter' => $enrollmentAdminFilter]); +$routes->get('administrator/enrollment-admin/email-preview', 'View\EnrollmentAdminController::previewEmail', ['filter' => $enrollmentAdminFilter]); +$routes->post('administrator/enrollment-admin/approve-launch', 'View\EnrollmentAdminController::approveLaunch', ['filter' => $enrollmentAdminFilter]); +$routes->post('administrator/enrollment-admin/send-registration-emails', 'View\EnrollmentAdminController::sendRegistrationEmails', ['filter' => $enrollmentAdminFilter]); +$routes->post('administrator/enrollment-admin/flags/(:num)/resolve', 'View\EnrollmentAdminController::resolveFlag/$1', ['filter' => $enrollmentAdminFilter]); +$routes->post('administrator/enrollment-admin/flags/(:num)/assign-class', 'View\EnrollmentAdminController::assignClass/$1', ['filter' => $enrollmentAdminFilter]); +$routes->post('administrator/enrollment-admin/flags/(:num)/makeup-promotion', 'View\EnrollmentAdminController::confirmMakeupPromotion/$1', ['filter' => $enrollmentAdminFilter]); +$routes->post('administrator/enrollment-admin/flags/(:num)/approve-exception', 'View\EnrollmentAdminController::approveException/$1', ['filter' => $enrollmentAdminFilter]); +$routes->post('administrator/enrollment-admin/exceptions/create', 'View\EnrollmentAdminController::createException', ['filter' => $enrollmentAdminFilter]); +$routes->post('administrator/enrollment-admin/exceptions/(:num)/revoke', 'View\EnrollmentAdminController::revokeException/$1', ['filter' => $enrollmentAdminFilter]); $routes->get('administrator/financial-aid', 'Administrator\FinancialAidController::index', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']); $routes->get('administrator/financial-aid/(:num)', 'Administrator\FinancialAidController::show/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']); $routes->post('administrator/financial-aid/(:num)/approve', 'Administrator\FinancialAidController::approve/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']); @@ -774,7 +775,6 @@ $routes->get('administrator/userSearch', 'View\AdministratorController::userSear $routes->get('/administrator/student_profiles', 'View\AdministratorController::studentProfiles'); $routes->get('/administrator/parent_profiles', 'View\AdministratorController::parentProfiles'); -$routes->get('/administrator/removed_students', 'View\StudentController::removedStudents'); $routes->get('/administrator/teacher-submissions', 'View\AdministratorController::teacherSubmissionsReport', ['filter' => 'auth:admin']); $routes->post('/administrator/teacher-submissions/notify', 'View\AdministratorController::sendTeacherSubmissionNotifications', ['filter' => 'auth:admin']); $routes->get('/administrator/exam-drafts', 'View\ExamDraftController::adminIndex', ['filter' => 'auth:admin']); @@ -1268,7 +1268,6 @@ $routes->get('/access_denied', 'ErrorController::accessDenied'); $routes->post('administrator/students/update', 'View\StudentController::editStudentData', ['filter' => 'auth:edit_student,update']); -$routes->post('administrator/students/set-active', 'View\StudentController::setStudentActive', ['filter' => 'auth:edit_student,update']); /* * -------------------------------------------------------------------- diff --git a/app/Config/Services.php b/app/Config/Services.php index 97ec497..1468871 100644 --- a/app/Config/Services.php +++ b/app/Config/Services.php @@ -293,6 +293,15 @@ class Services extends BaseService return new \App\Services\EnrollmentTransitionService(\Config\Database::connect()); } + public static function enrollmentStatus(bool $getShared = true): \App\Services\EnrollmentStatusService + { + if ($getShared) { + return static::getSharedInstance('enrollmentStatus'); + } + + return new \App\Services\EnrollmentStatusService(\Config\Database::connect()); + } + public static function enrollmentRegistrationEmail(bool $getShared = true): \App\Services\EnrollmentRegistrationEmailService { if ($getShared) { diff --git a/app/Controllers/View/AdministratorController.php b/app/Controllers/View/AdministratorController.php index 75420e9..8742bfd 100644 --- a/app/Controllers/View/AdministratorController.php +++ b/app/Controllers/View/AdministratorController.php @@ -2656,13 +2656,16 @@ class AdministratorController extends BaseController $payload = $this->filterEnrollmentPayloadByColumns($payload); if ($existing !== null) { - $this->db->table('enrollments') - ->where('id', (int) $existing['id']) - ->update($payload); + $payload['id'] = (int) $existing['id']; } else { $payload['created_at'] = $now; - $this->db->table('enrollments')->insert($this->filterEnrollmentPayloadByColumns($payload)); } + + \Config\Services::enrollmentStatus(false)->upsertStatus( + $this->filterEnrollmentPayloadByColumns($payload), + (int) (session()->get('user_id') ?? 0) ?: null, + 'admin_review_decision_enrollment' + ); } } @@ -2961,6 +2964,8 @@ class AdministratorController extends BaseController public function adminEnrollmentWithdrawalHandler() { $refundService = new FeeCalculationService(); + $enrollmentStatusService = \Config\Services::enrollmentStatus(false); + $performedBy = (int) (session()->get('user_id') ?? 0) ?: null; $this->db->transStart(); try { @@ -3023,7 +3028,7 @@ class AdministratorController extends BaseController $isWithdrawn = in_array($newEnrollmentStatus, ['withdrawn', 'refund pending', 'withdraw under review'], true) ? 1 : 0; - $ok = $this->db->table('enrollments')->insert([ + $result = $enrollmentStatusService->upsertStatus([ 'student_id' => (int)$studentId, 'parent_id' => $parentId, 'school_year' => (string)$this->schoolYear, @@ -3034,9 +3039,9 @@ class AdministratorController extends BaseController 'admission_status' => $admissionStatus, 'created_at' => utc_now(), 'updated_at' => utc_now(), - ]); + ], $performedBy, 'admin_enrollment_withdrawal_handler'); - if (!$ok) { + if ((int) ($result['id'] ?? 0) <= 0) { $errors[] = "Failed to create enrollment for student ID $studentId."; continue; } @@ -3080,26 +3085,36 @@ class AdministratorController extends BaseController // admissionStatus computed above - // Skip if no actual change if ($oldStatus === $newEnrollmentStatus) { - log_message('debug', "No status change for student {$studentId} ({$oldStatus}) — skipping."); + $enrollmentStatusService->upsertStatus([ + 'id' => (int) $enrollmentRow['id'], + 'student_id' => (int) $studentId, + 'parent_id' => (int) $parentId, + 'school_year' => (string) $this->schoolYear, + 'semester' => (string) ($enrollmentRow['semester'] ?? $this->semester), + 'enrollment_status' => $newEnrollmentStatus, + 'admission_status' => $admissionStatus, + 'updated_at' => utc_now(), + ], $performedBy, 'admin_enrollment_status_repair'); + log_message('debug', "No status change for student {$studentId} ({$oldStatus}); repaired activity flag."); if (in_array($newEnrollmentStatus, ['payment pending', 'enrolled'], true)) { $this->applyDistributionDraftToStudentClass((int)$studentId, (string)$this->schoolYear); } continue; } - // Update enrollment - $updated = $this->db->table('enrollments') - ->where('student_id', $studentId) - ->where('school_year', $this->schoolYear) - ->update([ - 'enrollment_status' => $newEnrollmentStatus, - 'admission_status' => $admissionStatus, - 'updated_at' => utc_now(), - ]); + $result = $enrollmentStatusService->upsertStatus([ + 'id' => (int) $enrollmentRow['id'], + 'student_id' => (int) $studentId, + 'parent_id' => (int) $parentId, + 'school_year' => (string) $this->schoolYear, + 'semester' => (string) ($enrollmentRow['semester'] ?? $this->semester), + 'enrollment_status' => $newEnrollmentStatus, + 'admission_status' => $admissionStatus, + 'updated_at' => utc_now(), + ], $performedBy, 'admin_enrollment_withdrawal_handler'); - if (!$updated) { + if ((int) ($result['id'] ?? 0) <= 0) { $errors[] = "Failed to update enrollment for student ID $studentId."; continue; } diff --git a/app/Controllers/View/DiscountController.php b/app/Controllers/View/DiscountController.php index 8a1c3cc..c119b93 100644 --- a/app/Controllers/View/DiscountController.php +++ b/app/Controllers/View/DiscountController.php @@ -491,19 +491,27 @@ class DiscountController extends BaseController return 0; } - // 5) Perform update with same filters - $builder = $db->table('enrollments'); - $builder->set('enrollment_status', 'enrolled') - // Use DB date if you prefer: ->set('enrollment_date', 'CURRENT_DATE()', false) - ->set('enrollment_date', local_date(utc_now(), 'Y-m-d')) - ->whereIn('id', $toUpdateIds); + $rowsToUpdate = $db->table('enrollments') + ->whereIn('id', $toUpdateIds) + ->get() + ->getResultArray(); + $statusService = \Config\Services::enrollmentStatus(false); - if ($builder->update() === false) { - $err = $db->error(); - throw new \RuntimeException('Enrollments update failed: ' . ($err['message'] ?? 'unknown DB error')); + foreach ($rowsToUpdate as $row) { + $statusService->upsertStatus([ + 'id' => (int) $row['id'], + 'student_id' => (int) $row['student_id'], + 'parent_id' => (int) $row['parent_id'], + 'school_year' => (string) $row['school_year'], + 'semester' => (string) ($row['semester'] ?? ''), + 'enrollment_date' => local_date(utc_now(), 'Y-m-d'), + 'enrollment_status' => 'enrolled', + 'admission_status' => 'accepted', + 'updated_at' => utc_now(), + ], (int) (session()->get('user_id') ?? 0) ?: null, 'discount_payment_completed'); } - $affected = $db->affectedRows(); + $affected = count($rowsToUpdate); $db->transCommit(); log_message( diff --git a/app/Controllers/View/ParentController.php b/app/Controllers/View/ParentController.php index d4d4c50..95a4572 100644 --- a/app/Controllers/View/ParentController.php +++ b/app/Controllers/View/ParentController.php @@ -509,6 +509,7 @@ class ParentController extends BaseController } $this->db->transStart(); + $enrollmentStatusService = \Config\Services::enrollmentStatus(false); foreach ($enroll as $studentId) { $studentId = (int) $studentId; if (! isset($evaluations[$studentId])) { @@ -548,7 +549,13 @@ class ParentController extends BaseController if ($existingEnrollment['is_withdrawn'] == 1) { // Reactivate the enrollment if the student was previously withdrawn - $this->enrollmentModel->update((int) $existingEnrollment['id'], $update); + $enrollmentStatusService->upsertStatus(array_merge($update, [ + 'id' => (int) $existingEnrollment['id'], + 'student_id' => $studentId, + 'parent_id' => $parentId, + 'school_year' => $selectedYear, + 'semester' => $this->semester, + ]), (int) $parentId, 'parent_re_enrollment_submitted'); log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) has been re-enrolled in enrollment ID {$existingEnrollment['id']}."); // Apply promotion-based class placement for the upcoming year $this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment); @@ -557,7 +564,13 @@ class ParentController extends BaseController if ($currentStatus === 'enrolled') { $update['admission_status'] = 'accepted'; } - $this->enrollmentModel->update((int) $existingEnrollment['id'], $update); + $enrollmentStatusService->upsertStatus(array_merge($update, [ + 'id' => (int) $existingEnrollment['id'], + 'student_id' => $studentId, + 'parent_id' => $parentId, + 'school_year' => $selectedYear, + 'semester' => $this->semester, + ]), (int) $parentId, 'parent_enrollment_submitted'); log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) is already actively enrolled."); $this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment); } @@ -592,9 +605,9 @@ class ParentController extends BaseController 'admission_status' => $targetAdmissionStatus, 'created_at' => utc_now() ]); - $result = $this->enrollmentModel->insert($payload, true); + $result = $enrollmentStatusService->upsertStatus($payload, (int) $parentId, 'parent_enrollment_submitted'); - if (!$result) { + if ((int) ($result['id'] ?? 0) <= 0) { $this->db->transRollback(); return redirect()->back()->withInput()->with('error', $studentName . ': Unable to save enrollment.'); } else { @@ -603,7 +616,7 @@ class ParentController extends BaseController $this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment); } - $enrollmentId = (int) $result; + $enrollmentId = (int) $result['id']; if (! empty($evaluation['admin_exception']['id'])) { $transitionService->markExceptionUsed((int) $evaluation['admin_exception']['id'], $enrollmentId); } @@ -642,11 +655,17 @@ class ParentController extends BaseController if ($enrollment !== null) { // Update enrollment as withdrawn - $this->enrollmentModel->update((int) $enrollment['id'], [ + $enrollmentStatusService = \Config\Services::enrollmentStatus(false); + $enrollmentStatusService->upsertStatus([ + 'id' => (int) $enrollment['id'], + 'student_id' => (int) $studentId, + 'parent_id' => (int) ($enrollment['parent_id'] ?? $parentId), + 'school_year' => (string) $this->schoolYear, + 'semester' => (string) ($enrollment['semester'] ?? $this->semester), 'withdrawal_date' => local_date(utc_now(), 'Y-m-d'), 'enrollment_status' => 'withdraw under review', // Withdrawal needs review 'updated_at' => utc_now() - ]); + ], (int) $parentId, 'parent_withdrawal_requested'); log_message('info', "Student ID $studentId has been withdrawn from enrollment ID {$enrollment['id']}."); // === Trigger refund process === diff --git a/app/Controllers/View/PaymentController.php b/app/Controllers/View/PaymentController.php index 1a00ff4..30405f1 100644 --- a/app/Controllers/View/PaymentController.php +++ b/app/Controllers/View/PaymentController.php @@ -609,17 +609,27 @@ class PaymentController extends ResourceController $db = $this->db; $db->transBegin(); try { - $builder = $db->table('enrollments'); - $builder->set('enrollment_status', 'enrolled') - ->set('enrollment_date', local_date(utc_now(), 'Y-m-d')) // or 'CURRENT_DATE()' with false flag - ->whereIn('id', $toUpdateIds); + $rowsToUpdate = $db->table('enrollments') + ->whereIn('id', $toUpdateIds) + ->get() + ->getResultArray(); + $statusService = \Config\Services::enrollmentStatus(false); - if ($builder->update() === false) { - $err = $db->error(); - throw new \RuntimeException('Enrollments update failed: ' . ($err['message'] ?? 'unknown DB error')); + foreach ($rowsToUpdate as $row) { + $statusService->upsertStatus([ + 'id' => (int) $row['id'], + 'student_id' => (int) $row['student_id'], + 'parent_id' => (int) $row['parent_id'], + 'school_year' => (string) $row['school_year'], + 'semester' => (string) ($row['semester'] ?? ''), + 'enrollment_date' => local_date(utc_now(), 'Y-m-d'), + 'enrollment_status' => 'enrolled', + 'admission_status' => 'accepted', + 'updated_at' => utc_now(), + ], (int) (session()->get('user_id') ?? 0) ?: null, 'payment_completed'); } - $affected = $db->affectedRows(); + $affected = count($rowsToUpdate); $db->transCommit(); log_message( diff --git a/app/Controllers/View/StudentController.php b/app/Controllers/View/StudentController.php index dd4c056..114e747 100644 --- a/app/Controllers/View/StudentController.php +++ b/app/Controllers/View/StudentController.php @@ -15,7 +15,6 @@ use App\Models\StudentAllergyModel; use App\Models\StudentMedicalConditionModel; use App\Support\Enrollment\DeliberationDecision; use CodeIgniter\Database\Exceptions\DataException; -use Config\Services; use Throwable; class StudentController extends BaseController @@ -225,14 +224,20 @@ class StudentController extends BaseController if ($enroll) { $enPk = $this->enrollmentModel->primaryKey ?? 'id'; - if (!$this->enrollmentModel->update($enroll[$enPk], [ + $result = \Config\Services::enrollmentStatus(false)->upsertStatus([ + 'id' => (int) $enroll[$enPk], + 'student_id' => $studentId, + 'parent_id' => (int) ($enroll['parent_id'] ?? 0), + 'school_year' => (string) $this->schoolYear, + 'semester' => (string) $this->semester, 'class_section_id' => $primarySectionId, 'enrollment_status' => 'payment pending', // Ensure admission is marked accepted once moved out of review 'admission_status' => 'accepted', 'updated_at' => $now, - ])) { - throw new \RuntimeException('Failed to update enrollment: ' . json_encode($this->enrollmentModel->errors())); + ], $userId ?: null, 'student_class_assignment'); + if ((int) ($result['id'] ?? 0) <= 0) { + throw new \RuntimeException('Failed to update enrollment.'); } } @@ -387,242 +392,6 @@ class StudentController extends BaseController } } - public function removedStudents() - { - $schoolYear = (string)($this->schoolYear ?? ''); - - $classMap = []; - $activeYearStudentIds = []; - $classQuery = $this->db->table('student_class sc') - ->select('sc.student_id, cs.class_section_name') - ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left'); - if ($schoolYear !== '') { - $classQuery->where('sc.school_year', $schoolYear); - } - $classRows = $classQuery->get()->getResultArray(); - - foreach ($classRows as $row) { - $sid = (int)($row['student_id'] ?? 0); - if ($sid > 0) { - $activeYearStudentIds[$sid] = true; - } - - $name = trim((string)($row['class_section_name'] ?? '')); - if ($sid <= 0 || $name === '') continue; - $classMap[$sid][] = $name; - } - - $students = $this->studentModel - ->select('id, school_id, firstname, lastname, gender, age, is_active') - ->orderBy('lastname', 'ASC') - ->orderBy('firstname', 'ASC') - ->findAll(); - - $activeStudents = []; - $removedStudents = []; - foreach ($students as $student) { - $studentId = (int)($student['id'] ?? 0); - $isGloballyActive = (int)($student['is_active'] ?? 0) === 1; - $hasSelectedYearClass = $studentId > 0 && isset($activeYearStudentIds[$studentId]); - - if ($isGloballyActive && $hasSelectedYearClass) { - $activeStudents[] = $student; - } else { - $removedStudents[] = $student; - } - } - - $attachClassNames = static function (array $students) use ($classMap): array { - foreach ($students as &$student) { - $sid = (int)($student['id'] ?? 0); - $names = $classMap[$sid] ?? []; - $names = array_values(array_unique(array_filter($names))); - $student['class_sections'] = $names; - $student['class_section_name'] = !empty($names) ? implode(', ', $names) : 'No class assigned'; - } - unset($student); - return $students; - }; - - return view('administrator/removed_students', [ - 'active_students' => $attachClassNames($activeStudents), - 'removed_students' => $attachClassNames($removedStudents), - 'school_year' => $schoolYear, - 'active_school_year' => (string)($this->schoolYear ?? ''), - ]); - } - - public function setStudentActive() - { - $studentId = (int) $this->request->getPost('student_id'); - $isActiveRaw = (string) $this->request->getPost('is_active'); - $isActive = $isActiveRaw === '1' ? 1 : 0; - $now = utc_now(); - $userId = (int) (session()->get('user_id') ?? 0); - - if ($studentId <= 0) { - return redirect()->back()->with('error', 'Invalid student ID.'); - } - - $student = $this->studentModel->find($studentId); - if (!$student) { - return redirect()->back()->with('error', 'Student not found.'); - } - - if (!$this->studentModel->update($studentId, ['is_active' => $isActive])) { - return redirect()->back()->with('error', 'Unable to update student status.'); - } - - $message = $isActive ? 'Student restored successfully.' : 'Student removed successfully.'; - - if ($isActive === 1) { - $hasCurrentClass = $this->studentClassModel - ->where('student_id', $studentId) - ->where('school_year', (string)$this->schoolYear) - ->where('semester', (string)$this->semester) - ->first(); - - if (!$hasCurrentClass) { - $lastClass = $this->studentClassModel - ->where('student_id', $studentId) - ->orderBy('updated_at', 'DESC') - ->orderBy('created_at', 'DESC') - ->orderBy('id', 'DESC') - ->first(); - - $restoreClassId = (int)($lastClass['class_section_id'] ?? 0); - if ($restoreClassId > 0) { - $inserted = $this->studentClassModel->insert([ - 'student_id' => $studentId, - 'class_section_id' => $restoreClassId, - 'semester' => (string)$this->semester, - 'school_year' => (string)$this->schoolYear, - 'description' => $lastClass['description'] ?? null, - 'updated_by' => $userId ?: null, - 'updated_at' => $now, - 'created_at' => $now, - ]); - - if ($inserted) { - $classLabel = (string)($this->classSectionModel->getClassSectionNameBySectionId($restoreClassId) ?? ''); - if ($classLabel !== '') { - $message .= ' Class assignment restored to ' . $classLabel . '.'; - } - } else { - $message .= ' No class assignment found for the current term.'; - return redirect()->to(base_url('administrator/removed_students'))->with('warning', $message); - } - } else { - $message .= ' No class assignment found for the current term.'; - return redirect()->to(base_url('administrator/removed_students'))->with('warning', $message); - } - } - } - - if ($isActive === 0) { - if (!$this->notifyParentOfStudentRemoval($studentId)) { - $message .= ' Parent notification email could not be sent.'; - } - } - - return redirect()->to(base_url('administrator/removed_students'))->with('success', $message); - } - - private function notifyParentOfStudentRemoval(int $studentId): bool - { - try { - $studentRow = $this->studentModel - ->select([ - 'students.firstname AS student_firstname', - 'students.lastname AS student_lastname', - 'students.school_id', - 'users.id AS parent_id', - 'users.firstname AS parent_firstname', - 'users.lastname AS parent_lastname', - 'users.email AS parent_email', - ]) - ->join('users', 'users.id = students.parent_id', 'left') - ->where('students.id', $studentId) - ->first(); - - if (empty($studentRow)) { - log_message('warning', "Student removal email skipped: student {$studentId} not found."); - return false; - } - - $parentEmail = trim((string)($studentRow['parent_email'] ?? '')); - if ($parentEmail === '') { - log_message('warning', "Student removal email skipped: missing parent email for student {$studentId}."); - return false; - } - - $studentName = trim(trim((string)($studentRow['student_firstname'] ?? '')) . ' ' . trim((string)($studentRow['student_lastname'] ?? ''))); - $parentName = trim(trim((string)($studentRow['parent_firstname'] ?? '')) . ' ' . trim((string)($studentRow['parent_lastname'] ?? ''))); - if ($parentName === '') { - $parentName = 'Parent/Guardian'; - } - - $subject = 'Withdrawal Notice for ' . ($studentName !== '' ? $studentName : 'Your Student'); - - $html = view('emails/student_removed', [ - 'student_name' => $studentName, - 'school_id' => $studentRow['school_id'] ?? '', - 'parent_name' => $parentName, - 'signature' => 'AlRahma School Administration', - ], ['saveData' => true]); - - $sent = $this->sendHtmlEmail($parentEmail, $subject, $html); - $logLevel = $sent ? 'info' : 'warning'; - log_message($logLevel, "Student removal email " . ($sent ? '' : 'not ') . "sent (studentId: {$studentId}, parent: {$parentEmail})."); - - return $sent; - } catch (\Throwable $e) { - log_message('error', 'Student removal notification failed: ' . $e->getMessage()); - return false; - } - } - - private function sendHtmlEmail(string $to, string $subject, string $html): bool - { - try { - $service = function_exists('service') ? service('emailService') : null; - if (!$service && method_exists(Services::class, 'emailService')) { - $service = Services::emailService(); - } - - if ($service && method_exists($service, 'send')) { - $result = $service->send($to, $subject, $html, 'student-removal'); - if ($result) { - return true; - } - log_message('debug', 'Custom emailService failed to send student removal notice.'); - } - - $email = Services::email(); - $cfg = config('Email'); - $fromEmail = $cfg->fromEmail ?? $cfg->SMTPUser ?? 'no-reply@example.com'; - $fromName = $cfg->fromName ?? 'Al Rahma Sunday School'; - - $email->setTo($to); - $email->setFrom($fromEmail, $fromName); - $email->setSubject($subject); - $email->setMessage($html); - $email->setMailType('html'); - - $ok = $email->send(); - if (!$ok) { - $debug = method_exists($email, 'printDebugger') ? $email->printDebugger(['headers', 'subject']) : 'no debugger'; - log_message('debug', 'CI Email send failed for student removal: ' . print_r($debug, true)); - } - - return $ok; - } catch (\Throwable $e) { - log_message('debug', 'sendHtmlEmail exception for student removal: ' . $e->getMessage()); - return false; - } - } - - private function updateStudentAttendanceSection( int $studentId, int $newClassSectionId, @@ -2742,7 +2511,6 @@ class StudentController extends BaseController 'rfid_tag' => 'permit_empty|max_length[100]', 'semester' => 'permit_empty|in_list[Fall,Spring,Summer]', 'is_new' => 'required|in_list[0,1]', - 'is_active' => 'permit_empty|in_list[0,1]', // Free-text lists; parsed later 'medical_conditions' => 'permit_empty', @@ -2777,7 +2545,6 @@ class StudentController extends BaseController 'rfid_tag' => trim((string) $request->getPost('rfid_tag')), 'semester' => trim((string) $request->getPost('semester')), 'is_new' => (string) $request->getPost('is_new', FILTER_SANITIZE_NUMBER_INT), - 'is_active' => (string) ($rawPost['is_active'] ?? ''), // raw lists (may be missing from POST entirely) 'medical_conditions' => (string) ($rawPost['medical_conditions'] ?? ''), @@ -2841,11 +2608,8 @@ class StudentController extends BaseController 'school_year' => $in['school_year'] ?: null, 'rfid_tag' => $in['rfid_tag'] ?: null, 'semester' => $in['semester'] ?: null, - 'is_new' => (int) ($in['is_new'] === '1'), - ]; - if (array_key_exists('is_active', $rawPost)) { - $studentData['is_active'] = (int) ($in['is_active'] === '1'); - } + 'is_new' => (int) ($in['is_new'] === '1'), + ]; // Normalize health lists (server-side safety) $normConditions = $this->normalizeHealthList($in['medical_conditions'], 100); // -> condition_name diff --git a/app/Models/EnrollmentModel.php b/app/Models/EnrollmentModel.php index 883b19b..a9b3da1 100644 --- a/app/Models/EnrollmentModel.php +++ b/app/Models/EnrollmentModel.php @@ -177,4 +177,4 @@ public function getEnrollmentStatus(int $studentId, string $schoolYear): ?string return $row['enrollment_status'] ?? null; } -} +} \ No newline at end of file diff --git a/app/Models/StudentModel.php b/app/Models/StudentModel.php index 9b2054c..873bf81 100644 --- a/app/Models/StudentModel.php +++ b/app/Models/StudentModel.php @@ -176,6 +176,7 @@ class StudentModel extends Model ->join('student_class', 'student_class.student_id = students.id', 'left') ->join('classSection', 'student_class.class_section_id = classSection.class_section_id', 'left') ->where('student_class.school_year', $schoolYear) + ->where('students.is_active', 1) ->get() ->getResultArray(); } @@ -200,6 +201,7 @@ class StudentModel extends Model ->join('teacher_class', 'student_class.class_section_id = teacher_class.class_section_id', 'left') ->join('classSection', 'student_class.class_section_id = classSection.class_section_id', 'left') ->where('teacher_class.teacher_id', $teacherId) + ->where('students.is_active', 1) ->get() ->getResultArray(); } @@ -215,6 +217,7 @@ class StudentModel extends Model return $this->db->table('students') ->join('student_class', 'students.id = student_class.student_id', 'left') ->where('student_class.class_section_id', $classSectionId) + ->where('students.is_active', 1) ->select('students.*, student_class.class_section_id') ->get() ->getResultArray(); diff --git a/app/Services/EnrollmentStatusService.php b/app/Services/EnrollmentStatusService.php new file mode 100644 index 0000000..7aee458 --- /dev/null +++ b/app/Services/EnrollmentStatusService.php @@ -0,0 +1,215 @@ +normalizeStatus($status), self::ACTIVE_STATUSES, true); + } + + public function activeFlagForStatus(string $status): int + { + return $this->isActiveStatus($status) ? 1 : 0; + } + + public function normalizeStatus(string $status): string + { + $status = strtolower(trim(str_replace("\xc2\xa0", ' ', $status))); + $status = preg_replace('/\s+/', ' ', $status) ?? $status; + + if (! in_array($status, self::VALID_STATUSES, true)) { + throw new InvalidArgumentException("Invalid enrollment status: {$status}"); + } + + return $status; + } + + public function upsertStatus(array $payload, ?int $performedBy = null, string $reason = 'enrollment_status_changed'): array + { + $studentId = (int) ($payload['student_id'] ?? 0); + $schoolYear = trim((string) ($payload['school_year'] ?? '')); + $status = $this->normalizeStatus((string) ($payload['enrollment_status'] ?? '')); + + if ($studentId <= 0 || $schoolYear === '') { + throw new InvalidArgumentException('student_id and school_year are required.'); + } + + $now = function_exists('utc_now') ? utc_now() : date('Y-m-d H:i:s'); + $payload['student_id'] = $studentId; + $payload['school_year'] = $schoolYear; + $payload['enrollment_status'] = $status; + $payload['updated_at'] = $payload['updated_at'] ?? $now; + + if (! isset($payload['admission_status'])) { + $payload['admission_status'] = $this->admissionStatusFor($status); + } + + if (! isset($payload['is_withdrawn']) && in_array($status, ['withdraw under review', 'refund pending', 'withdrawn'], true)) { + $payload['is_withdrawn'] = 1; + } elseif (! isset($payload['is_withdrawn']) && in_array($status, ['admission under review', 'review & decision', 'payment pending', 'enrolled', 'waitlist', 'denied'], true)) { + $payload['is_withdrawn'] = 0; + } + + $this->db->transStart(); + + $existing = $this->controllingEnrollment($studentId, $schoolYear); + $oldStatus = $existing['enrollment_status'] ?? null; + + if ($existing !== null) { + $this->db->table('enrollments') + ->where('id', (int) $existing['id']) + ->update($this->filterPayload('enrollments', $payload)); + $enrollmentId = (int) $existing['id']; + } else { + $payload['created_at'] = $payload['created_at'] ?? $now; + $insertPayload = $this->filterPayload('enrollments', $payload); + $this->db->table('enrollments')->insert($insertPayload); + $enrollmentId = (int) $this->db->insertID(); + } + + $currentSchoolYear = $this->currentSchoolYear(); + $updatedStudent = false; + if ($currentSchoolYear === '' || $schoolYear === $currentSchoolYear) { + $updatedStudent = $this->syncStudentActivityForEnrollment($studentId, $status); + } + + $newSnapshot = $payload; + $newSnapshot['id'] = $enrollmentId; + $newSnapshot['old_enrollment_status'] = $oldStatus; + $newSnapshot['new_enrollment_status'] = $status; + $newSnapshot['student_is_active'] = $this->activeFlagForStatus($status); + $newSnapshot['student_activity_synced'] = $updatedStudent; + + $this->audit($studentId, $schoolYear, $performedBy, $existing, $newSnapshot, $reason); + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + throw new RuntimeException('Unable to update enrollment status.'); + } + + return [ + 'id' => $enrollmentId, + 'old_status' => $oldStatus, + 'new_status' => $status, + 'student_is_active' => $this->activeFlagForStatus($status), + 'student_activity_synced' => $updatedStudent, + ]; + } + + public function syncStudentActivityForEnrollment(int $studentId, string $status): bool + { + if ($studentId <= 0 || ! $this->db->fieldExists('is_active', 'students')) { + return false; + } + + return (bool) $this->db->table('students') + ->where('id', $studentId) + ->update(['is_active' => $this->activeFlagForStatus($status)]); + } + + public function controllingEnrollment(int $studentId, string $schoolYear, ?string $semester = null): ?array + { + $builder = $this->db->table('enrollments') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear); + + if ($semester !== null && $semester !== '') { + $builder->where('semester', $semester); + } + + return $builder + ->orderBy('updated_at', 'DESC') + ->orderBy('enrollment_date', 'DESC') + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray() ?: null; + } + + public function currentSchoolYear(): string + { + if (! $this->db->tableExists('configuration')) { + return ''; + } + + $row = $this->db->table('configuration') + ->select('config_value') + ->where('config_key', 'school_year') + ->limit(1) + ->get() + ->getRowArray(); + + return trim((string) ($row['config_value'] ?? '')); + } + + private function admissionStatusFor(string $status): string + { + if ($status === 'denied') { + return 'denied'; + } + + return in_array($status, ['payment pending', 'enrolled'], true) ? 'accepted' : 'pending'; + } + + private function filterPayload(string $table, array $payload): array + { + $fields = $this->db->getFieldNames($table); + return array_intersect_key($payload, array_flip($fields)); + } + + private function audit(int $studentId, string $schoolYear, ?int $performedBy, ?array $original, array $new, string $reason): void + { + if (! $this->db->tableExists('enrollment_transition_audits')) { + return; + } + + $this->db->table('enrollment_transition_audits')->insert([ + 'student_id' => $studentId, + 'school_year' => $schoolYear, + 'source_school_year' => $original['source_school_year'] ?? $new['source_school_year'] ?? null, + 'action' => 'enrollment_status_sync', + 'performed_by' => $performedBy, + 'original_values_json' => $original !== null ? json_encode($original, JSON_UNESCAPED_SLASHES) : null, + 'new_values_json' => json_encode($new, JSON_UNESCAPED_SLASHES), + 'reason' => $reason, + 'created_at' => function_exists('utc_now') ? utc_now() : date('Y-m-d H:i:s'), + ]); + } +} diff --git a/app/Services/EnrollmentTransitionService.php b/app/Services/EnrollmentTransitionService.php index 615563b..7aa771a 100644 --- a/app/Services/EnrollmentTransitionService.php +++ b/app/Services/EnrollmentTransitionService.php @@ -285,12 +285,17 @@ final class EnrollmentTransitionService ]; if ($original !== null) { - $this->db->table('enrollments')->where('id', (int) $original['id'])->update($payload); + $payload['id'] = (int) $original['id']; } else { $payload['created_at'] = date('Y-m-d H:i:s'); - $this->db->table('enrollments')->insert($payload); } + (new EnrollmentStatusService($this->db))->upsertStatus( + $payload, + $performedBy, + 'initial_transition_applied' + ); + if ((int) ($evaluation['assigned_class_section_id'] ?? 0) > 0) { $this->upsertStudentClass($studentId, (int) $evaluation['assigned_class_section_id'], $targetSchoolYear, $performedBy); } diff --git a/app/Views/administrator/enrollment_admin_dashboard.php b/app/Views/administrator/enrollment_admin_dashboard.php index 359c05b..9924a4b 100644 --- a/app/Views/administrator/enrollment_admin_dashboard.php +++ b/app/Views/administrator/enrollment_admin_dashboard.php @@ -54,7 +54,7 @@ if (!function_exists('enrollment_admin_flag_label')) { { return match ($type) { 'CLASS_REASSIGNMENT_REQUIRED' => 'Assign a class', - 'PENDING_MAKE_UP_EXAM_PROMOTION' => 'Makeup exam result', + 'PENDING_MAKE_UP_EXAM_PROMOTION' => 'Makeup exam', 'AGE_EXCEPTION_REQUIRED' => 'Age exception', 'LATE_REGISTRATION_EXCEPTION' => 'Late registration', 'FINANCIAL_REVIEW_REQUIRED' => 'Finance review', @@ -359,7 +359,7 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes :
-
Record the makeup exam result
+
Record the makeup exam
- - -
- - - - - - No active students found. - - - - - - - - -
-
- Removed Students - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
School IDFirst NameLast NameLast ClassAction
-
- - - - -
-
No removed students found.
-
-
-
- - - - -endSection() ?> - -section('scripts') ?> - -endSection() ?> diff --git a/app/Views/administrator/student_profiles.php b/app/Views/administrator/student_profiles.php index 9b308e1..92a6974 100644 --- a/app/Views/administrator/student_profiles.php +++ b/app/Views/administrator/student_profiles.php @@ -381,14 +381,12 @@ $selectedYear = trim((string)($selectedYear ?? '')); -
- -
- > - -
+ + + +
Controlled by current enrollment status.
diff --git a/app/Views/emails/student_removed.php b/app/Views/emails/student_removed.php deleted file mode 100644 index 060ded6..0000000 --- a/app/Views/emails/student_removed.php +++ /dev/null @@ -1,26 +0,0 @@ -extend('layout/email_layout') ?> - -section('content') ?> -
-

- Dear , -

- -

- We are writing to inform you that ' . esc($student_name) . '' : 'your child' ?> has been officially withdrawn from the school's enrollment list, effective immediately. -

- -

- To ensure confidentiality and accuracy, we kindly request that you contact the school directly. This will allow us to provide complete information and discuss any necessary next steps. -

- -

- Thank you for your understanding. We look forward to speaking with you soon. -

- -

- Sincerely,
- -

-
-endSection() ?> diff --git a/app/Views/index.php b/app/Views/index.php index ecfab77..291f93d 100644 --- a/app/Views/index.php +++ b/app/Views/index.php @@ -525,7 +525,7 @@
-
+

Active Participants


@@ -734,11 +734,15 @@ window.addEventListener('load', function() { // Hide the spinner const spinner = document.getElementById('spinner'); - spinner.style.opacity = '0'; + if (spinner) { + spinner.style.opacity = '0'; + } // Remove spinner from DOM after fade out setTimeout(function() { - spinner.style.display = 'none'; + if (spinner) { + spinner.style.display = 'none'; + } // Force show all content document.querySelectorAll('.content-section, h1, h2, h3, h4, h5, h6, p').forEach(function(el) { diff --git a/app/Views/parent/enroll_classes.php b/app/Views/parent/enroll_classes.php index c7f5086..1b2f59d 100644 --- a/app/Views/parent/enroll_classes.php +++ b/app/Views/parent/enroll_classes.php @@ -454,22 +454,17 @@ foreach (($students ?? []) as $student) {
-
Review tuition, fees, and balance
+
Review tuition, fees and balance
Review the family account information before submitting enrollment.
Previous-year carry-over balance:
-
Registration fee:
-
Tuition due now:
-
Mandatory fees:
-
Current-year account balance:
-
Total currently due:
+
Total currently due:
-
Family account information is not available. Please contact the administration if you have questions about tuition or balance.
diff --git a/app/Views/partials/footer.php b/app/Views/partials/footer.php index 5f357ae..e18fad4 100644 --- a/app/Views/partials/footer.php +++ b/app/Views/partials/footer.php @@ -34,19 +34,19 @@ switch ($userRole) {
  • - Privacy Policy
  • - Terms of Service
  • - How To Create An Account @@ -91,4 +91,4 @@ switch ($userRole) { }); }); }); - \ No newline at end of file + diff --git a/tests/app/Services/EnrollmentStatusServiceTest.php b/tests/app/Services/EnrollmentStatusServiceTest.php new file mode 100644 index 0000000..a5508c3 --- /dev/null +++ b/tests/app/Services/EnrollmentStatusServiceTest.php @@ -0,0 +1,45 @@ +createMock(BaseConnection::class)); + + $this->assertSame($expected, $service->activeFlagForStatus($status)); + } + + public static function statusMappingProvider(): array + { + return [ + ['admission under review', 1], + ['review & decision', 1], + ['payment pending', 1], + ['enrolled', 1], + ['withdraw under review', 1], + ['refund pending', 1], + ['denied', 0], + ['withdrawn', 0], + ['waitlist', 0], + ]; + } + + public function testUnknownStatusIsRejected(): void + { + $service = new EnrollmentStatusService($this->createMock(BaseConnection::class)); + + $this->expectException(InvalidArgumentException::class); + + $service->activeFlagForStatus('accepted'); + } +}