fix is_active student flag logic and make moving with enrollment status
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use App\Services\EnrollmentStatusService;
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
|
||||
class SyncStudentActivity extends BaseCommand
|
||||
{
|
||||
protected $group = 'Enrollment';
|
||||
protected $name = 'students:sync-activity';
|
||||
protected $description = 'Synchronize students.is_active from current-year enrollment_status.';
|
||||
protected $usage = 'php spark students:sync-activity --school-year=YYYY-YYYY [--dry-run]';
|
||||
protected $options = [
|
||||
'--school-year' => '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 '';
|
||||
}
|
||||
}
|
||||
+11
-12
@@ -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']);
|
||||
|
||||
/*
|
||||
* --------------------------------------------------------------------
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -177,4 +177,4 @@ public function getEnrollmentStatus(int $studentId, string $schoolYear): ?string
|
||||
return $row['enrollment_status'] ?? null;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
final class EnrollmentStatusService
|
||||
{
|
||||
public const ACTIVE_STATUSES = [
|
||||
'admission under review',
|
||||
'review & decision',
|
||||
'payment pending',
|
||||
'enrolled',
|
||||
'withdraw under review',
|
||||
'refund pending',
|
||||
];
|
||||
|
||||
public const INACTIVE_STATUSES = [
|
||||
'denied',
|
||||
'withdrawn',
|
||||
'waitlist',
|
||||
];
|
||||
|
||||
public const VALID_STATUSES = [
|
||||
'admission under review',
|
||||
'review & decision',
|
||||
'payment pending',
|
||||
'enrolled',
|
||||
'withdraw under review',
|
||||
'refund pending',
|
||||
'withdrawn',
|
||||
'waitlist',
|
||||
'denied',
|
||||
];
|
||||
|
||||
public function __construct(private readonly BaseConnection $db)
|
||||
{
|
||||
}
|
||||
|
||||
public function isActiveStatus(string $status): bool
|
||||
{
|
||||
return in_array($this->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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 :
|
||||
<?php elseif ($flagTypeValue === 'PENDING_MAKE_UP_EXAM_PROMOTION'): ?>
|
||||
<form method="post" action="<?= site_url('administrator/enrollment-admin/flags/' . $flagId . '/makeup-promotion') ?>" class="mb-2">
|
||||
<?= csrf_field() ?>
|
||||
<div class="small fw-semibold mb-1">Record the makeup exam result</div>
|
||||
<div class="small fw-semibold mb-1">Record the makeup exam</div>
|
||||
<select class="form-select form-select-sm mb-2" name="exam_result" required>
|
||||
<option value="">Exam result</option>
|
||||
<option value="passed">Passed</option>
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
<?php
|
||||
$activeStudents = $active_students ?? [];
|
||||
$removedStudents = $removed_students ?? [];
|
||||
$schoolYear = $school_year ?? '';
|
||||
$activeSchoolYear = $active_school_year ?? '';
|
||||
?>
|
||||
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="wrapper">
|
||||
<div class="content">
|
||||
<h2 class="text-center mt-4 mb-2">Student Removal</h2>
|
||||
<p class="text-center text-muted mb-4">
|
||||
Active school year: <?= esc($schoolYear !== '' ? $schoolYear : $activeSchoolYear) ?>.
|
||||
Active students have a class assignment in the active school year. Removed students are inactive or have no active-year assignment.
|
||||
</p>
|
||||
<?= $this->include('partials/flash_messages') ?>
|
||||
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<span>Active Students</span>
|
||||
<span class="badge bg-primary"><?= count($activeStudents) ?></span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table id="activeStudentsTable" class="table table-striped table-hover align-middle">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>School ID</th>
|
||||
<th>First Name</th>
|
||||
<th>Last Name</th>
|
||||
<th>Class</th>
|
||||
<th style="min-width: 140px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (!empty($activeStudents)): ?>
|
||||
<?php foreach ($activeStudents as $student): ?>
|
||||
<tr>
|
||||
<td><?= esc($student['school_id'] ?? '') ?></td>
|
||||
<td><?= esc($student['firstname'] ?? '') ?></td>
|
||||
<td><?= esc($student['lastname'] ?? '') ?></td>
|
||||
<td><?= esc($student['class_section_name'] ?? 'No class assigned') ?></td>
|
||||
<td>
|
||||
<form method="post" action="<?= site_url('administrator/students/set-active') ?>" class="d-inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="student_id" value="<?= (int)($student['id'] ?? 0) ?>">
|
||||
<input type="hidden" name="is_active" value="0">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" onclick="return confirm('Remove this student from classes and attendance?');">
|
||||
Remove
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">No active students found.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<span>Removed Students</span>
|
||||
<span class="badge bg-secondary"><?= count($removedStudents) ?></span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table id="removedStudentsTable" class="table table-striped table-hover align-middle">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>School ID</th>
|
||||
<th>First Name</th>
|
||||
<th>Last Name</th>
|
||||
<th>Last Class</th>
|
||||
<th style="min-width: 140px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (!empty($removedStudents)): ?>
|
||||
<?php foreach ($removedStudents as $student): ?>
|
||||
<tr>
|
||||
<td><?= esc($student['school_id'] ?? '') ?></td>
|
||||
<td><?= esc($student['firstname'] ?? '') ?></td>
|
||||
<td><?= esc($student['lastname'] ?? '') ?></td>
|
||||
<td><?= esc($student['class_section_name'] ?? 'No class assigned') ?></td>
|
||||
<td>
|
||||
<form method="post" action="<?= site_url('administrator/students/set-active') ?>" class="d-inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="student_id" value="<?= (int)($student['id'] ?? 0) ?>">
|
||||
<input type="hidden" name="is_active" value="1">
|
||||
<button type="submit" class="btn btn-outline-success btn-sm" onclick="return confirm('Restore this student to classes and attendance?');">
|
||||
Restore
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">No removed students found.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('scripts') ?>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (window.jQuery && typeof jQuery.fn.DataTable === 'function') {
|
||||
if (document.getElementById('activeStudentsTable')) {
|
||||
jQuery('#activeStudentsTable').DataTable();
|
||||
}
|
||||
if (document.getElementById('removedStudentsTable')) {
|
||||
jQuery('#removedStudentsTable').DataTable();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
@@ -381,14 +381,12 @@ $selectedYear = trim((string)($selectedYear ?? ''));
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- is_active -->
|
||||
<div class="col-md-3">
|
||||
<label class="form-label d-block">Active Status</label>
|
||||
<input type="hidden" name="is_active" value="0">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" value="1" id="is_active_<?= (int)$student['id'] ?>" name="is_active" <?= $isActive ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="is_active_<?= (int)$student['id'] ?>">Show in classes/attendance</label>
|
||||
</div>
|
||||
<span class="badge <?= $isActive ? 'bg-success' : 'bg-secondary' ?>">
|
||||
<?= $isActive ? 'Visible' : 'Hidden' ?>
|
||||
</span>
|
||||
<div class="form-text">Controlled by current enrollment status.</div>
|
||||
</div>
|
||||
|
||||
<!-- photo_consent -->
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<?= $this->extend('layout/email_layout') ?>
|
||||
|
||||
<?= $this->section('content') ?>
|
||||
<div style="font-family:Arial, Helvetica, sans-serif; color:#222; font-size:16px; line-height:1.6;">
|
||||
<p style="margin:0 0 12px 0;">
|
||||
Dear <?= esc($parent_name ?? 'Parent/Guardian') ?>,
|
||||
</p>
|
||||
|
||||
<p style="margin:0 0 12px 0;">
|
||||
We are writing to inform you that <?= $student_name ? 'your child, <strong>' . esc($student_name) . '</strong>' : 'your child' ?> has been officially withdrawn from the school's enrollment list, effective immediately.
|
||||
</p>
|
||||
|
||||
<p style="margin:0 0 12px 0;">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<p style="margin:0 0 12px 0;">
|
||||
Thank you for your understanding. We look forward to speaking with you soon.
|
||||
</p>
|
||||
|
||||
<p style="margin:0;">
|
||||
Sincerely,<br>
|
||||
<?= esc($signature ?? 'AlRahma School Administration') ?>
|
||||
</p>
|
||||
</div>
|
||||
<?= $this->endSection() ?>
|
||||
+7
-3
@@ -525,7 +525,7 @@
|
||||
<!-- Statistics Start -->
|
||||
<div class="container-xxl py-2 content-section">
|
||||
<div class="container">
|
||||
<div class="home-stats" data-dashboard-endpoint="<?= site_url('api/administrator/dashboard') ?>">
|
||||
<div class="home-stats" data-dashboard-endpoint="/api/administrator/dashboard">
|
||||
<h1 class="d-flex justify-content-center p-md-1">Active Participants</h1>
|
||||
<br>
|
||||
<div class="stats-row">
|
||||
@@ -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) {
|
||||
|
||||
@@ -454,22 +454,17 @@ foreach (($students ?? []) as $student) {
|
||||
</div>
|
||||
|
||||
<div class="d-none" data-step-panel="2">
|
||||
<h6 class="fw-semibold">Review tuition, fees, and balance</h6>
|
||||
<h6 class="fw-semibold">Review tuition, fees and balance</h6>
|
||||
<div class="text-muted small mb-3">Review the family account information before submitting enrollment.</div>
|
||||
<?php if ($familyFinancialSummary !== []): ?>
|
||||
<div class="border rounded p-3 bg-light">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4"><span class="text-muted">Previous-year carry-over balance:</span> <strong data-financial-value="carry_over_balance"><?= esc($money($familyFinancialSummary['carry_over_balance'] ?? 0)) ?></strong></div>
|
||||
<div class="col-md-4"><span class="text-muted">Registration fee:</span> <strong data-financial-value="registration_fee"><?= esc($money($familyFinancialSummary['registration_fee'] ?? 0)) ?></strong></div>
|
||||
<div class="col-md-4"><span class="text-muted">Tuition due now:</span> <strong data-financial-value="tuition_due_at_registration"><?= esc($money($familyFinancialSummary['tuition_due_at_registration'] ?? 0)) ?></strong></div>
|
||||
<div class="col-md-4"><span class="text-muted">Mandatory fees:</span> <strong data-financial-value="mandatory_fees"><?= esc($money($familyFinancialSummary['mandatory_fees'] ?? 0)) ?></strong></div>
|
||||
<div class="col-md-4"><span class="text-muted">Current-year account balance:</span> <strong data-financial-value="current_balance"><?= esc($money($familyFinancialSummary['current_balance'] ?? 0)) ?></strong></div>
|
||||
<div class="col-md-4"><span class="text-muted">Total currently due:</span> <strong data-financial-value="amount_due"><?= esc($money($familyFinancialSummary['amount_due'] ?? 0)) ?></strong></div>
|
||||
<div class="col-md-4"><span class="text-muted">Total currently due:</span> <strong data-financial-value="amount_due"><?= esc($money($familyFinancialSummary['amount_due'] ?? 0)) ?></strong></div>
|
||||
</div>
|
||||
<?php if (!empty($familyFinancialSummary['policy_message'])): ?>
|
||||
<div class="small text-muted mt-2"><?= esc($familyFinancialSummary['policy_message']) ?></div>
|
||||
<?php endif; ?>
|
||||
<div class="small mt-2"><a href="<?= site_url('parent/financial-aid') ?>">Request financial aid</a></div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="alert alert-warning mb-0">Family account information is not available. Please contact the administration if you have questions about tuition or balance.</div>
|
||||
|
||||
@@ -34,19 +34,19 @@ switch ($userRole) {
|
||||
<ul class="list-unstyled mb-0 info-list">
|
||||
<li class="info-title text-center text-md-end"></li>
|
||||
<li>
|
||||
<a href="<?= base_url('privacy_policy.pdf') ?>" target="_blank" rel="noopener noreferrer"
|
||||
<a href="/privacy_policy.pdf" target="_blank" rel="noopener noreferrer"
|
||||
class="pdf-link" data-filename="privacy_policy.pdf">
|
||||
<i class="fas fa-file-pdf me-1"></i>Privacy Policy
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="<?= base_url('terms_of_service.pdf') ?>" target="_blank" rel="noopener noreferrer"
|
||||
<a href="/terms_of_service.pdf" target="_blank" rel="noopener noreferrer"
|
||||
class="pdf-link" data-filename="terms_of_service.pdf">
|
||||
<i class="fas fa-file-pdf me-1"></i>Terms of Service
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="<?= base_url('account_creation_guide.pdf') ?>" target="_blank" rel="noopener noreferrer"
|
||||
<a href="/account_creation_guide.pdf" target="_blank" rel="noopener noreferrer"
|
||||
class="pdf-link" data-filename="account_creation_guide.pdf">
|
||||
<i class="fas fa-file-pdf"></i>How To Create An Account
|
||||
</a>
|
||||
@@ -91,4 +91,4 @@ switch ($userRole) {
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Services;
|
||||
|
||||
use App\Services\EnrollmentStatusService;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class EnrollmentStatusServiceTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @dataProvider statusMappingProvider
|
||||
*/
|
||||
public function testEnrollmentStatusMapsToStudentActivity(string $status, int $expected): void
|
||||
{
|
||||
$service = new EnrollmentStatusService($this->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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user