fix is_new issue with enrollment fixes
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -492,4 +492,16 @@ class Services extends BaseService
|
||||
(string) getSemester()
|
||||
);
|
||||
}
|
||||
|
||||
public static function studentYearStatus(bool $getShared = true): \App\Services\StudentYearStatusService
|
||||
{
|
||||
if ($getShared) {
|
||||
return static::getSharedInstance('studentYearStatus');
|
||||
}
|
||||
|
||||
return new \App\Services\StudentYearStatusService(
|
||||
\Config\Database::connect(),
|
||||
model(\App\Models\StudentYearStatusModel::class)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,9 @@ class ClassPrepController extends PrintablesBaseController
|
||||
$isGrade5 = static fn(string $g) => (bool) preg_match('/^5(\-|$)/', trim($g));
|
||||
|
||||
$b = $this->db->table('student_class sc')
|
||||
->select('sc.student_id, s.firstname, s.lastname, s.is_new, cs.class_section_name AS grade_label')
|
||||
->select('sc.student_id, s.firstname, s.lastname, COALESCE(sys.is_new, s.is_new, 1) AS is_new, cs.class_section_name AS grade_label', false)
|
||||
->join('students s', 's.id = sc.student_id', 'inner')
|
||||
->join('student_year_status sys', 'sys.student_id = s.id AND sys.school_year = sc.school_year', 'left')
|
||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'inner')
|
||||
->join('classes c', 'c.id = cs.class_id', 'inner')
|
||||
->where('sc.school_year', $schoolYear);
|
||||
@@ -122,14 +123,9 @@ class ClassPrepController extends PrintablesBaseController
|
||||
|
||||
// --- Pull roster: student_class -> classSection -> classes -> students ---
|
||||
$builder = $this->db->table('student_class sc')
|
||||
->select([
|
||||
'sc.student_id',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
's.is_new',
|
||||
'cs.class_section_name AS grade_label',
|
||||
])
|
||||
->select('sc.student_id, s.firstname, s.lastname, COALESCE(sys.is_new, s.is_new, 1) AS is_new, cs.class_section_name AS grade_label', false)
|
||||
->join('students s', 's.id = sc.student_id')
|
||||
->join('student_year_status sys', 'sys.student_id = s.id AND sys.school_year = sc.school_year', 'left')
|
||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
|
||||
->join('classes c', 'c.id = cs.class_id')
|
||||
->where('sc.school_year', $schoolYear);
|
||||
@@ -217,8 +213,9 @@ class ClassPrepController extends PrintablesBaseController
|
||||
$isGrade5 = static fn($g) => (bool) preg_match('/^5(\-|$)/', trim($g));
|
||||
|
||||
$b = $this->db->table('student_class sc')
|
||||
->select('sc.student_id, s.firstname, s.lastname, s.is_new, cs.class_section_name AS grade_label')
|
||||
->select('sc.student_id, s.firstname, s.lastname, COALESCE(sys.is_new, s.is_new, 1) AS is_new, cs.class_section_name AS grade_label', false)
|
||||
->join('students s', 's.id = sc.student_id')
|
||||
->join('student_year_status sys', 'sys.student_id = s.id AND sys.school_year = sc.school_year', 'left')
|
||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
|
||||
->join('classes c', 'c.id = cs.class_id')
|
||||
->where('sc.school_year', $schoolYear)
|
||||
|
||||
@@ -83,7 +83,8 @@ class ParentController extends BaseController
|
||||
|
||||
$this->ageDateRefernce = $this->configModel->getConfig('date_age_reference');
|
||||
$this->lastDayOfRegistration = $this->configModel->getConfig('enrollment_deadline');
|
||||
$this->schoolStartDate = $this->configModel->getConfig('fall_semester_start');
|
||||
$this->schoolStartDate = $this->configModel->getConfig('first_day_of_school')
|
||||
?: $this->configModel->getConfig('fall_semester_start');
|
||||
$this->withdrawalDeadline = $this->configModel->getConfig('refund_deadline');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = getSemester();
|
||||
@@ -263,7 +264,7 @@ class ParentController extends BaseController
|
||||
|
||||
// Verify user type is "parent" from the `users` table
|
||||
$userData = $this->db->table('users')
|
||||
->select('user_type, accept_school_policy')
|
||||
->select('user_type, accept_school_policy, firstname, lastname, cellphone, address_street, apt, city, state, zip')
|
||||
->where('id', $parentId)
|
||||
->get()
|
||||
->getRowArray();
|
||||
@@ -385,6 +386,30 @@ class ParentController extends BaseController
|
||||
$student['parent_enrollment_state'] = $this->parentEnrollmentState($student);
|
||||
}
|
||||
}
|
||||
unset($student);
|
||||
|
||||
service('studentYearStatus')->attachToStudents($students, $selectedYear);
|
||||
foreach ($students as &$student) {
|
||||
$studentId = (int) ($student['id'] ?? 0);
|
||||
if ($studentId > 0 && $this->isReturningReEnrollmentStudent($studentId, $selectedYear)) {
|
||||
$student['is_new'] = 0;
|
||||
}
|
||||
}
|
||||
unset($student);
|
||||
|
||||
$enrollmentStartStep = (int) $this->request->getGet('start');
|
||||
if ($enrollmentStartStep < 1 || $enrollmentStartStep > 5) {
|
||||
$enrollmentStartStep = 0;
|
||||
}
|
||||
|
||||
$preselectedStudentIds = [];
|
||||
$studentsParam = trim((string) $this->request->getGet('students'));
|
||||
if ($studentsParam !== '') {
|
||||
$preselectedStudentIds = array_values(array_unique(array_filter(
|
||||
array_map('intval', explode(',', $studentsParam)),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
}
|
||||
|
||||
// Render view
|
||||
return view('/parent/enroll_classes', [
|
||||
@@ -399,6 +424,9 @@ class ParentController extends BaseController
|
||||
'hasAcceptedSchoolPolicy' => $this->hasAcceptedPolicyForYear((int) $parentId, $selectedYear),
|
||||
'familyFinancialSummary' => $this->familyFinancialSummary((int) $parentId, $previousSchoolYear, $selectedYear, $students),
|
||||
'enrollmentFeeSchedule' => $this->enrollmentFeeSchedule($selectedYear),
|
||||
'parentContact' => $this->parentContactForEnrollment($userData),
|
||||
'enrollmentStartStep' => $enrollmentStartStep,
|
||||
'preselectedStudentIds' => $preselectedStudentIds,
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
log_message('error', 'An error occurred in enrollClasses: ' . $e->getMessage());
|
||||
@@ -452,6 +480,7 @@ class ParentController extends BaseController
|
||||
|
||||
// Handle enrollments
|
||||
$studentData = [];
|
||||
$enrollmentResultMessages = [];
|
||||
|
||||
if (!empty($enroll)) {
|
||||
$selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||
@@ -476,6 +505,11 @@ class ParentController extends BaseController
|
||||
return redirect()->back()->withInput()->with('error', implode(' ', $studentInfoErrors));
|
||||
}
|
||||
|
||||
$parentContactErrors = $this->updateEnrollmentParentContact((int) $parentId);
|
||||
if ($parentContactErrors !== []) {
|
||||
return redirect()->back()->withInput()->with('error', implode(' ', $parentContactErrors));
|
||||
}
|
||||
|
||||
foreach ($submittedStudentIds as $studentId) {
|
||||
try {
|
||||
$evaluation = $transitionService->evaluateForParent((int) $parentId, $studentId, $previousSchoolYear, $selectedYear, 'parent');
|
||||
@@ -539,12 +573,13 @@ class ParentController extends BaseController
|
||||
|
||||
$studentName = trim((string) ($studentData[$studentId]['firstname'] ?? '') . ' ' . (string) ($studentData[$studentId]['lastname'] ?? '')) ?: 'Student ID ' . $studentId;
|
||||
$isReturningReEnrollment = $this->isReturningReEnrollmentStudent((int)$studentId, $selectedYear);
|
||||
$submittedEnrollmentContexts[$studentId] = $isReturningReEnrollment ? 're_enrollment' : 'first_enrollment';
|
||||
$isNewForSelectedYear = ! $isReturningReEnrollment
|
||||
&& service('studentYearStatus')->isNew($studentId, $selectedYear);
|
||||
$submittedEnrollmentContexts[$studentId] = $isNewForSelectedYear ? 'first_enrollment' : 're_enrollment';
|
||||
$targetEnrollmentStatus = $isNewForSelectedYear ? 'admission under review' : 'payment pending';
|
||||
$targetAdmissionStatus = $isNewForSelectedYear ? 'pending' : 'accepted';
|
||||
|
||||
if ($existingEnrollment) {
|
||||
$targetEnrollmentStatus = $isReturningReEnrollment ? 'payment pending' : 'admission under review';
|
||||
$targetAdmissionStatus = $isReturningReEnrollment ? 'accepted' : 'pending';
|
||||
|
||||
$update = $this->enrollmentPayloadFromEvaluation($evaluation, [
|
||||
'is_withdrawn' => 0,
|
||||
'withdrawal_date' => null,
|
||||
@@ -595,10 +630,8 @@ class ParentController extends BaseController
|
||||
$this->enrollmentAuditPayload($update, $evaluation),
|
||||
implode(' ', array_map('strval', $evaluation['rule_codes'] ?? []))
|
||||
);
|
||||
$enrollmentResultMessages[] = $studentName . ': ' . $targetEnrollmentStatus;
|
||||
} else {
|
||||
$targetEnrollmentStatus = $isReturningReEnrollment ? 'payment pending' : 'admission under review';
|
||||
$targetAdmissionStatus = $isReturningReEnrollment ? 'accepted' : 'pending';
|
||||
|
||||
// If no enrollment record exists, insert a new enrollment record
|
||||
$payload = $this->enrollmentPayloadFromEvaluation($evaluation, [
|
||||
'student_id' => $studentId,
|
||||
@@ -636,6 +669,7 @@ class ParentController extends BaseController
|
||||
$this->enrollmentAuditPayload($payload, $evaluation),
|
||||
implode(' ', array_map('strval', $evaluation['rule_codes'] ?? []))
|
||||
);
|
||||
$enrollmentResultMessages[] = $studentName . ': ' . $targetEnrollmentStatus;
|
||||
}
|
||||
}
|
||||
$this->db->transComplete();
|
||||
@@ -649,8 +683,11 @@ class ParentController extends BaseController
|
||||
// $studentData now holds info for all students processed
|
||||
|
||||
// Handle withdrawals
|
||||
$withdrawalResultMessages = [];
|
||||
$withdrawalErrors = [];
|
||||
if (!empty($withdraw)) {
|
||||
foreach ($withdraw as $studentId) {
|
||||
$studentId = (int) $studentId;
|
||||
$enrollment = $this->enrollmentModel
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
@@ -673,6 +710,7 @@ class ParentController extends BaseController
|
||||
'updated_at' => utc_now()
|
||||
], (int) $parentId, 'parent_withdrawal_requested');
|
||||
log_message('info', "Student ID $studentId has been withdrawn from enrollment ID {$enrollment['id']}.");
|
||||
$withdrawalResultMessages[] = 'Student ID ' . $studentId . ': withdraw under review';
|
||||
|
||||
// === Trigger refund process ===
|
||||
// Find the related invoice (you may need to adjust this based on your DB structure)
|
||||
@@ -752,6 +790,7 @@ class ParentController extends BaseController
|
||||
}
|
||||
} else {
|
||||
log_message('error', "No active enrollment found for student ID $studentId.");
|
||||
$withdrawalErrors[] = 'Student ID ' . $studentId . ': no active enrollment was found.';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -761,13 +800,32 @@ class ParentController extends BaseController
|
||||
// Redirect to the success page after processing enrollment and withdrawal
|
||||
if (!empty($withdraw)) {
|
||||
// Redirect to withdrawal success page if there are withdrawals //parent/enroll_classes
|
||||
return redirect()->to('/parent/enroll_classes');
|
||||
$redirect = redirect()->to('/parent/enroll_classes');
|
||||
if ($withdrawalResultMessages !== []) {
|
||||
$redirect = $redirect->with('success', 'Withdrawal request submitted. ' . implode(' ', $withdrawalResultMessages));
|
||||
}
|
||||
if ($withdrawalErrors !== []) {
|
||||
$redirect = $redirect->with('error', 'Some withdrawal requests failed. ' . implode(' ', $withdrawalErrors));
|
||||
}
|
||||
|
||||
return $redirect;
|
||||
} else {
|
||||
// Redirect to enrollment success page if there are enrollments
|
||||
$contextValues = array_values(array_unique($submittedEnrollmentContexts ?? []));
|
||||
$parentData['enrollment_context'] = count($contextValues) === 1 ? $contextValues[0] : 'mixed';
|
||||
Events::trigger('admissionUnderReview', $parentData, $studentData); //send notification for enrolled student
|
||||
return redirect()->to('/parent/enroll_classes');
|
||||
try {
|
||||
Events::trigger('admissionUnderReview', $parentData, $studentData);
|
||||
} catch (Throwable $e) {
|
||||
log_message('error', 'Enrollment notification failed after successful submission: {message}', [
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
$successMessage = 'Enrollment submitted successfully.';
|
||||
if ($enrollmentResultMessages !== []) {
|
||||
$successMessage .= ' ' . implode(' ', $enrollmentResultMessages);
|
||||
}
|
||||
|
||||
return redirect()->to('/parent/enroll_success')->with('success', $successMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -887,6 +945,107 @@ class ParentController extends BaseController
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $user
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function parentContactForEnrollment(array $user): array
|
||||
{
|
||||
$posted = old('parent_contact');
|
||||
$posted = is_array($posted) ? $posted : [];
|
||||
$phoneDigits = preg_replace('/\D/', '', (string) ($posted['cellphone'] ?? $user['cellphone'] ?? '')) ?? '';
|
||||
$phoneDisplay = strlen($phoneDigits) === 10
|
||||
? substr($phoneDigits, 0, 3) . '-' . substr($phoneDigits, 3, 3) . '-' . substr($phoneDigits, 6)
|
||||
: trim((string) ($posted['cellphone'] ?? $user['cellphone'] ?? ''));
|
||||
|
||||
return [
|
||||
'firstname' => trim((string) ($user['firstname'] ?? '')),
|
||||
'lastname' => trim((string) ($user['lastname'] ?? '')),
|
||||
'cellphone' => $phoneDisplay,
|
||||
'address_street' => trim((string) ($posted['address_street'] ?? $user['address_street'] ?? '')),
|
||||
'apt' => trim((string) ($posted['apt'] ?? $user['apt'] ?? '')),
|
||||
'city' => trim((string) ($posted['city'] ?? $user['city'] ?? '')),
|
||||
'state' => strtoupper(trim((string) ($posted['state'] ?? $user['state'] ?? ''))),
|
||||
'zip' => trim((string) ($posted['zip'] ?? $user['zip'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function updateEnrollmentParentContact(int $parentId): array
|
||||
{
|
||||
$fields = $this->request->getPost('parent_contact');
|
||||
$normalized = $this->normalizeEnrollmentParentContact(is_array($fields) ? $fields : []);
|
||||
if ($normalized['errors'] !== []) {
|
||||
return $normalized['errors'];
|
||||
}
|
||||
|
||||
if (! $this->userModel->update($parentId, $normalized['data'])) {
|
||||
return ['Parent contact information could not be updated.'];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $fields
|
||||
* @return array{errors: list<string>, data: array<string, string>}
|
||||
*/
|
||||
private function normalizeEnrollmentParentContact(array $fields): array
|
||||
{
|
||||
$phoneDigits = preg_replace('/\D/', '', (string) ($fields['cellphone'] ?? '')) ?? '';
|
||||
$street = trim((string) ($fields['address_street'] ?? ''));
|
||||
$apt = trim((string) ($fields['apt'] ?? ''));
|
||||
$city = trim((string) ($fields['city'] ?? ''));
|
||||
$state = strtoupper(trim((string) ($fields['state'] ?? '')));
|
||||
$zip = trim((string) ($fields['zip'] ?? ''));
|
||||
$errors = [];
|
||||
|
||||
if (strlen($phoneDigits) !== 10) {
|
||||
$errors[] = 'A valid 10-digit home/cell phone number is required.';
|
||||
}
|
||||
|
||||
if (strlen($street) < 5 || strlen($street) > 255) {
|
||||
$errors[] = 'Home street address is required.';
|
||||
}
|
||||
|
||||
if ($apt !== '' && strlen($apt) > 15) {
|
||||
$errors[] = 'Apartment or unit must be 15 characters or fewer.';
|
||||
}
|
||||
|
||||
if (strlen($city) < 2 || strlen($city) > 100) {
|
||||
$errors[] = 'City is required.';
|
||||
}
|
||||
|
||||
if (! preg_match('/^[A-Z]{2}$/', $state)) {
|
||||
$errors[] = 'State is required.';
|
||||
}
|
||||
|
||||
if (! preg_match('/^\d{5}$/', $zip)) {
|
||||
$errors[] = 'A valid 5-digit ZIP code is required.';
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
return ['errors' => $errors, 'data' => []];
|
||||
}
|
||||
|
||||
$formattedPhone = (new PhoneFormatterService())->formatPhoneNumber($phoneDigits);
|
||||
|
||||
return [
|
||||
'errors' => [],
|
||||
'data' => [
|
||||
'cellphone' => $formattedPhone ?: $phoneDigits,
|
||||
'address_street' => $street,
|
||||
'apt' => $apt,
|
||||
'city' => ucfirst(strtolower($city)),
|
||||
'state' => $state,
|
||||
'zip' => $zip,
|
||||
'updated_at' => utc_now(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $selected
|
||||
* @return list<string>
|
||||
@@ -1968,6 +2127,11 @@ class ParentController extends BaseController
|
||||
return ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1);
|
||||
}
|
||||
|
||||
public function enrollSuccess()
|
||||
{
|
||||
return view('/parent/enroll_success');
|
||||
}
|
||||
|
||||
public function enrollFailure()
|
||||
{
|
||||
echo view('/parent/enroll_failure');
|
||||
@@ -2446,6 +2610,7 @@ class ParentController extends BaseController
|
||||
$this->db->transStart();
|
||||
|
||||
$studentAdded = false;
|
||||
$registeredStudentIds = [];
|
||||
|
||||
// Gather all POST data for last year flags
|
||||
$rawPost = $this->request->getPost();
|
||||
@@ -2472,6 +2637,9 @@ class ParentController extends BaseController
|
||||
|
||||
if ($result) {
|
||||
$studentAdded = true;
|
||||
if (is_numeric($result) && (int) $result > 0) {
|
||||
$registeredStudentIds[] = (int) $result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2492,6 +2660,16 @@ class ParentController extends BaseController
|
||||
throw new \Exception('DB transaction failed.');
|
||||
}
|
||||
|
||||
if ($studentAdded) {
|
||||
$enrollmentQuery = ['start' => 2];
|
||||
if ($registeredStudentIds !== []) {
|
||||
$enrollmentQuery['students'] = implode(',', array_values(array_unique($registeredStudentIds)));
|
||||
}
|
||||
|
||||
return redirect()->to(base_url('/parent/enroll_classes?' . http_build_query($enrollmentQuery)))
|
||||
->with('success', 'Registration successful! Continue enrollment by confirming your home address and phone number.');
|
||||
}
|
||||
|
||||
return redirect()->to(base_url('/parent/child_register'))->with('success', 'Registration successful!');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', $e->getMessage());
|
||||
@@ -2564,8 +2742,15 @@ class ParentController extends BaseController
|
||||
->findColumn('condition_name') ?? [];
|
||||
|
||||
$kid['enrollment'] = isset($enrollmentMap[$studentId]['id']) && !empty($enrollmentMap[$studentId]['id']) ? 1 : 0;
|
||||
}
|
||||
unset($kid);
|
||||
|
||||
service('studentYearStatus')->attachToStudents($kids, $selectedSchoolYear);
|
||||
|
||||
foreach ($kids as &$kid) {
|
||||
$kid['can_delete'] = $this->canParentDeleteStudent($kid, $parentId);
|
||||
}
|
||||
unset($kid);
|
||||
|
||||
$emergencies = $this->emergencyContactModel->where('parent_id', $parentId)->findAll();
|
||||
|
||||
@@ -2693,6 +2878,10 @@ $existing = $this->studentModel
|
||||
}
|
||||
}
|
||||
|
||||
if (! is_null($isNew) && (int) $studentId > 0) {
|
||||
service('studentYearStatus')->upsert((int) $studentId, (string) $schoolYear, (bool) $isNew);
|
||||
}
|
||||
|
||||
// ---------- SAVE MEDICAL CONDITIONS ----------
|
||||
$this->medicalConditionModel->where('student_id', $studentId)->delete();
|
||||
foreach ((array) $conditions as $c) {
|
||||
@@ -2710,14 +2899,14 @@ $existing = $this->studentModel
|
||||
foreach ((array) $allergies as $a) {
|
||||
$a = trim($a);
|
||||
if ($a !== '') {
|
||||
$this->allergyModel->insert([
|
||||
$this->allergyModel->insert([
|
||||
'student_id' => $studentId,
|
||||
'allergy' => $a,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
return (int) $studentId > 0 ? (int) $studentId : true;
|
||||
}
|
||||
|
||||
|
||||
@@ -3099,7 +3288,15 @@ $existing = $this->studentModel
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((string) ($student['is_new'] ?? '1') === '0') {
|
||||
$statusYear = trim((string) ($student['school_year'] ?? ''));
|
||||
if ($statusYear === '') {
|
||||
$statusYear = (string) (service('studentYearStatus')->activeSchoolYear() ?? '');
|
||||
}
|
||||
$isNew = $statusYear !== ''
|
||||
? service('studentYearStatus')->isNew($studentId, $statusYear)
|
||||
: ((string) ($student['is_new'] ?? '1') === '1');
|
||||
|
||||
if (! $isNew) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -542,7 +542,7 @@ class StudentController extends BaseController
|
||||
|
||||
// Retrieve students with an enrollment in the selected year (any status)
|
||||
$students = $this->studentModel
|
||||
->select('students.id, students.firstname, students.lastname, students.registration_date, students.is_new, students.age, students.parent_id, students.registration_grade')
|
||||
->select('students.id, students.firstname, students.lastname, students.registration_date, students.age, students.parent_id, students.registration_grade')
|
||||
->join('enrollments e', 'e.student_id = students.id', 'inner')
|
||||
->where('e.school_year', $selectedYear)
|
||||
->groupBy('students.id')
|
||||
@@ -552,11 +552,13 @@ class StudentController extends BaseController
|
||||
// Fallback: if none found (data inconsistency), include students even without an enrollment row
|
||||
if (empty($students)) {
|
||||
$students = $this->studentModel
|
||||
->select('students.id, students.firstname, students.lastname, students.registration_date, students.is_new, students.age, students.parent_id, students.registration_grade')
|
||||
->select('students.id, students.firstname, students.lastname, students.registration_date, students.age, students.parent_id, students.registration_grade')
|
||||
->orderBy('students.lastname', 'ASC')
|
||||
->findAll();
|
||||
}
|
||||
|
||||
service('studentYearStatus')->attachToStudents($students, $selectedYear);
|
||||
|
||||
$studentData = [];
|
||||
foreach ($students as $student) {
|
||||
$sectionNames = $this->studentClassModel->getClassSectionsByStudentIdWithFlags((int)$student['id'], $selectedYear, true);
|
||||
@@ -2608,7 +2610,6 @@ 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'),
|
||||
];
|
||||
|
||||
// Normalize health lists (server-side safety)
|
||||
@@ -2631,6 +2632,13 @@ class StudentController extends BaseController
|
||||
->with('errors', $this->studentModel->errors() ?: []);
|
||||
}
|
||||
|
||||
$statusYear = $in['school_year'] !== ''
|
||||
? $in['school_year']
|
||||
: (string) ($this->currentSchoolYearName((string) ($this->schoolYear ?? '')) ?: '');
|
||||
if ($statusYear !== '') {
|
||||
service('studentYearStatus')->upsert($id, $statusYear, $in['is_new'] === '1');
|
||||
}
|
||||
|
||||
// Only sync health lists when user actually changed them (touched=1)
|
||||
// Fallback: if no touch flag but a non-empty value was explicitly posted, also sync.
|
||||
$medTouched = ($in['medical_touched'] === '1');
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateStudentYearStatus extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if ($this->db->tableExists('student_year_status')) {
|
||||
$this->backfillExistingStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forge->addField([
|
||||
'id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'unsigned' => true,
|
||||
'auto_increment' => true,
|
||||
],
|
||||
'student_id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'unsigned' => true,
|
||||
'null' => false,
|
||||
],
|
||||
'school_year' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 9,
|
||||
'null' => false,
|
||||
],
|
||||
'is_new' => [
|
||||
'type' => 'TINYINT',
|
||||
'constraint' => 1,
|
||||
'null' => false,
|
||||
'default' => 1,
|
||||
'comment' => '1 = new student for this school year, 0 = returning',
|
||||
],
|
||||
'created_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
'updated_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addUniqueKey(['student_id', 'school_year'], 'uq_student_year_status');
|
||||
$this->forge->addKey('school_year');
|
||||
$this->forge->addKey('student_id');
|
||||
$this->forge->createTable('student_year_status', true);
|
||||
|
||||
$this->backfillExistingStatus();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$this->forge->dropTable('student_year_status', true);
|
||||
}
|
||||
|
||||
private function backfillExistingStatus(): void
|
||||
{
|
||||
if (! $this->db->tableExists('student_year_status') || ! $this->db->tableExists('students')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$hasStudentSchoolYear = $this->db->fieldExists('school_year', 'students');
|
||||
$select = ['id', 'is_new'];
|
||||
if ($hasStudentSchoolYear) {
|
||||
$select[] = 'school_year';
|
||||
}
|
||||
|
||||
$students = $this->db->table('students')
|
||||
->select($select)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
if ($hasStudentSchoolYear) {
|
||||
foreach ($students as $student) {
|
||||
$studentId = (int) ($student['id'] ?? 0);
|
||||
if ($studentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$schoolYear = trim((string) ($student['school_year'] ?? ''));
|
||||
if (preg_match('/^\d{4}-\d{4}$/', $schoolYear)) {
|
||||
$this->upsertStatus(
|
||||
$studentId,
|
||||
$schoolYear,
|
||||
(int) ($student['is_new'] ?? 1) === 1 ? 1 : 0,
|
||||
$now
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$activeYear = $this->configuredSchoolYear();
|
||||
if ($activeYear === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$activeStart = $this->schoolYearStartYear($activeYear);
|
||||
$returningIds = $activeStart === null ? [] : $this->priorYearStudentIds($activeStart);
|
||||
|
||||
foreach ($students as $student) {
|
||||
$studentId = (int) ($student['id'] ?? 0);
|
||||
if ($studentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isNew = isset($returningIds[$studentId]) ? 0 : ((int) ($student['is_new'] ?? 1) === 1 ? 1 : 0);
|
||||
$this->upsertStatus($studentId, $activeYear, $isNew, $now);
|
||||
}
|
||||
}
|
||||
|
||||
private function upsertStatus(int $studentId, string $schoolYear, int $isNew, string $now): void
|
||||
{
|
||||
$existing = $this->db->table('student_year_status')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
|
||||
if ($existing !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->table('student_year_status')->insert([
|
||||
'student_id' => $studentId,
|
||||
'school_year' => $schoolYear,
|
||||
'is_new' => $isNew,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, true>
|
||||
*/
|
||||
private function priorYearStudentIds(int $selectedStartYear): array
|
||||
{
|
||||
$studentIds = [];
|
||||
foreach (['enrollments', 'student_class'] as $table) {
|
||||
if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows = $this->db->table($table)
|
||||
->select('student_id, school_year')
|
||||
->where('student_id IS NOT NULL', null, false)
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$rowStartYear = $this->schoolYearStartYear((string) ($row['school_year'] ?? ''));
|
||||
$studentId = (int) ($row['student_id'] ?? 0);
|
||||
if ($studentId > 0 && $rowStartYear !== null && $rowStartYear < $selectedStartYear) {
|
||||
$studentIds[$studentId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $studentIds;
|
||||
}
|
||||
|
||||
private function schoolYearStartYear(string $schoolYear): ?int
|
||||
{
|
||||
if (! preg_match('/^(\d{4})-\d{4}$/', trim($schoolYear), $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
private function configuredSchoolYear(): ?string
|
||||
{
|
||||
if ($this->db->tableExists('school_years')) {
|
||||
$row = $this->db->table('school_years')
|
||||
->select('name')
|
||||
->where('status', 'active')
|
||||
->orderBy('id', 'DESC')
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
|
||||
$name = trim((string) ($row['name'] ?? ''));
|
||||
if (preg_match('/^\d{4}-\d{4}$/', $name)) {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->db->tableExists('configuration')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $this->db->table('configuration')
|
||||
->select('config_value')
|
||||
->where('config_key', 'school_year')
|
||||
->orderBy('id', 'DESC')
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
|
||||
$name = trim((string) ($row['config_value'] ?? ''));
|
||||
|
||||
return preg_match('/^\d{4}-\d{4}$/', $name) ? $name : null;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ if (!function_exists('student_normalized_enrollment_status')) {
|
||||
$status = 'withdrawn';
|
||||
}
|
||||
|
||||
if ($status === '' && $studentId > 0) {
|
||||
if ($studentId > 0) {
|
||||
$schoolYear = trim((string)($schoolYear ?? ''));
|
||||
if ($schoolYear === '') {
|
||||
try {
|
||||
@@ -44,7 +44,9 @@ if (!function_exists('student_normalized_enrollment_status')) {
|
||||
}
|
||||
}
|
||||
}
|
||||
$status = $statusCache[$cacheKey];
|
||||
if (($statusCache[$cacheKey] ?? '') !== '') {
|
||||
$status = $statusCache[$cacheKey];
|
||||
}
|
||||
}
|
||||
|
||||
return $status;
|
||||
|
||||
@@ -78,6 +78,23 @@ class StudentClassModel extends Model
|
||||
return $builder->groupEnd();
|
||||
}
|
||||
|
||||
private function latestEnrollmentJoinCondition(string $schoolYear): string
|
||||
{
|
||||
$schoolYear = trim($schoolYear);
|
||||
$yearCondition = $schoolYear !== ''
|
||||
? 'e_latest.school_year = ' . $this->db->escape($schoolYear)
|
||||
: 'e_latest.school_year = student_class.school_year';
|
||||
|
||||
return 'enrollments.id = (
|
||||
SELECT e_latest.id
|
||||
FROM enrollments e_latest
|
||||
WHERE e_latest.student_id = student_class.student_id
|
||||
AND ' . $yearCondition . '
|
||||
ORDER BY e_latest.updated_at DESC, e_latest.enrollment_date DESC, e_latest.id DESC
|
||||
LIMIT 1
|
||||
)';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh builder scoped to active students.
|
||||
*/
|
||||
@@ -174,10 +191,9 @@ class StudentClassModel extends Model
|
||||
)
|
||||
->join(
|
||||
'enrollments',
|
||||
$schoolYear !== ''
|
||||
? 'enrollments.student_id = student_class.student_id AND enrollments.school_year = ' . $this->db->escape($schoolYear)
|
||||
: 'enrollments.student_id = student_class.student_id',
|
||||
'left'
|
||||
$this->latestEnrollmentJoinCondition($schoolYear),
|
||||
'left',
|
||||
false
|
||||
)
|
||||
->where(
|
||||
'student_class.class_section_id',
|
||||
@@ -249,8 +265,9 @@ class StudentClassModel extends Model
|
||||
)
|
||||
->join(
|
||||
'enrollments',
|
||||
'enrollments.student_id = student_class.student_id AND enrollments.school_year = ' . $this->db->escape($schoolYear),
|
||||
'left'
|
||||
$this->latestEnrollmentJoinCondition($schoolYear),
|
||||
'left',
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
@@ -436,7 +453,7 @@ class StudentClassModel extends Model
|
||||
'students.lastname',
|
||||
'students.school_id',
|
||||
'students.is_active',
|
||||
'students.is_new',
|
||||
'COALESCE(sys.is_new, 1) AS is_new',
|
||||
'students.photo_consent',
|
||||
'students.age',
|
||||
'student_class.school_year',
|
||||
@@ -451,12 +468,18 @@ class StudentClassModel extends Model
|
||||
'inner'
|
||||
)
|
||||
->join(
|
||||
'enrollments',
|
||||
'student_year_status sys',
|
||||
$schoolYear !== ''
|
||||
? 'enrollments.student_id = student_class.student_id AND enrollments.school_year = ' . $this->db->escape($schoolYear)
|
||||
: 'enrollments.student_id = student_class.student_id',
|
||||
? 'sys.student_id = students.id AND sys.school_year = ' . $this->db->escape($schoolYear)
|
||||
: 'sys.student_id = students.id AND sys.school_year = student_class.school_year',
|
||||
'left'
|
||||
)
|
||||
->join(
|
||||
'enrollments',
|
||||
$this->latestEnrollmentJoinCondition($schoolYear),
|
||||
'left',
|
||||
false
|
||||
)
|
||||
->whereIn(
|
||||
'student_class.class_section_id',
|
||||
$classSectionIds
|
||||
@@ -580,8 +603,9 @@ class StudentClassModel extends Model
|
||||
)
|
||||
->join(
|
||||
'enrollments',
|
||||
'enrollments.student_id = student_class.student_id AND enrollments.school_year = ' . $this->db->escape($schoolYear),
|
||||
'left'
|
||||
$this->latestEnrollmentJoinCondition($schoolYear),
|
||||
'left',
|
||||
false
|
||||
);
|
||||
} else {
|
||||
$schoolYear = '';
|
||||
|
||||
+139
-39
@@ -17,7 +17,6 @@ class StudentModel extends Model
|
||||
'gender',
|
||||
'registration_grade',
|
||||
'photo_consent',
|
||||
'is_new',
|
||||
'parent_id',
|
||||
'registration_date',
|
||||
'tuition_paid',
|
||||
@@ -27,24 +26,38 @@ class StudentModel extends Model
|
||||
];
|
||||
protected $useTimestamps = false;
|
||||
|
||||
// File: app/Models/StudentModel.php
|
||||
/**
|
||||
* Get all students marked as new (is_new = 1).
|
||||
*
|
||||
* @return array
|
||||
* Get students marked as new for a school year.
|
||||
*/
|
||||
public function getNewStudents(): array
|
||||
public function getNewStudents(string $schoolYear): array
|
||||
{
|
||||
return $this->where('is_new', 1)
|
||||
->orderBy('lastname', 'ASC')
|
||||
->orderBy('firstname', 'ASC')
|
||||
$schoolYear = trim($schoolYear);
|
||||
if (! preg_match('/^\d{4}-\d{4}$/', $schoolYear) || ! $this->db->tableExists('student_year_status')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->select('students.*, COALESCE(sys.is_new, 1) AS is_new')
|
||||
->join(
|
||||
'student_year_status sys',
|
||||
'sys.student_id = students.id AND sys.school_year = ' . $this->db->escape($schoolYear),
|
||||
'inner'
|
||||
)
|
||||
->where('COALESCE(sys.is_new, 1)', 1, false)
|
||||
->orderBy('students.lastname', 'ASC')
|
||||
->orderBy('students.firstname', 'ASC')
|
||||
->findAll();
|
||||
}
|
||||
|
||||
public function getNewStudentsWithParents(): array
|
||||
public function getNewStudentsWithParents(string $schoolYear): array
|
||||
{
|
||||
$schoolYear = trim($schoolYear);
|
||||
if (! preg_match('/^\d{4}-\d{4}$/', $schoolYear) || ! $this->db->tableExists('student_year_status')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->select([
|
||||
'students.*',
|
||||
'COALESCE(sys.is_new, 1) AS is_new',
|
||||
'u.id AS parent_id',
|
||||
'u.firstname AS parent_firstname',
|
||||
'u.lastname AS parent_lastname',
|
||||
@@ -52,55 +65,91 @@ class StudentModel extends Model
|
||||
'u.cellphone AS parent_phone',
|
||||
])
|
||||
->join('users u', 'u.id = students.parent_id', 'left')
|
||||
->where('students.is_new', 1)
|
||||
->join(
|
||||
'student_year_status sys',
|
||||
'sys.student_id = students.id AND sys.school_year = ' . $this->db->escape($schoolYear),
|
||||
'inner'
|
||||
)
|
||||
->where('COALESCE(sys.is_new, 1)', 1, false)
|
||||
->orderBy('students.lastname', 'ASC')
|
||||
->orderBy('students.firstname', 'ASC')
|
||||
->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch only new students (is_new=1) with parent info and a single emergency contact.
|
||||
* - Parent fields: parent_firstname, parent_lastname, parent_email, parent_phone
|
||||
* - Emergency fields: emergency_name, emergency_relationship, emergency_phone
|
||||
* Fetch only new students for a school year with parent info and a single emergency contact.
|
||||
*/
|
||||
public function getNewStudentsWithParentsAndEmergency(): array
|
||||
public function getNewStudentsWithParentsAndEmergency(string $schoolYear): array
|
||||
{
|
||||
$schoolYear = trim($schoolYear);
|
||||
if (! preg_match('/^\d{4}-\d{4}$/', $schoolYear) || ! $this->db->tableExists('student_year_status')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->select([
|
||||
'students.*',
|
||||
'COALESCE(sys.is_new, 1) AS is_new',
|
||||
'u.firstname AS parent_firstname',
|
||||
'u.lastname AS parent_lastname',
|
||||
'u.email AS parent_email',
|
||||
'u.cellphone AS parent_phone',
|
||||
|
||||
// Pick one emergency contact (MIN() works fine if you group by student)
|
||||
'MIN(ec.emergency_contact_name) AS emergency_name',
|
||||
'MIN(ec.relation) AS emergency_relationship',
|
||||
'MIN(ec.cellphone) AS emergency_phone',
|
||||
])
|
||||
->join('users u', 'u.id = students.parent_id', 'left')
|
||||
->join('emergency_contacts ec', 'ec.parent_id = students.parent_id', 'left')
|
||||
->where('students.is_new', 1) // strictly 1; change to 'Yes' if your data uses Yes/No
|
||||
->join(
|
||||
'student_year_status sys',
|
||||
'sys.student_id = students.id AND sys.school_year = ' . $this->db->escape($schoolYear),
|
||||
'inner'
|
||||
)
|
||||
->where('COALESCE(sys.is_new, 1)', 1, false)
|
||||
->groupBy('students.id')
|
||||
->orderBy('students.lastname', 'ASC')
|
||||
->orderBy('students.firstname', 'ASC')
|
||||
->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch students with parent + one emergency contact.
|
||||
* @param null|int $isNew Pass 1 for only new, 0 for only not-new, null for both.
|
||||
*/
|
||||
/**
|
||||
* Fetch students with parent + one emergency contact.
|
||||
*
|
||||
* @param null|int $isNew 1 = only new, 0 = only not-new, null = both
|
||||
* @param null|string $schoolYear e.g. "2025-2026"; pass null or "all" for no filter
|
||||
* @param null|int $isNew 1 = only new, 0 = only not-new, null for both
|
||||
* @param null|string $schoolYear e.g. "2025-2026"; pass null or "all" for no year filter
|
||||
* @return array
|
||||
*/
|
||||
public function getStudentsWithParentsAndEmergency(?string $schoolYear = null, ?int $isNew = null): array
|
||||
{
|
||||
$builder = $this->select([
|
||||
'students.*',
|
||||
$filterByYear = $schoolYear !== null && strtolower((string) $schoolYear) !== 'all';
|
||||
$statusYear = $filterByYear ? trim((string) $schoolYear) : '';
|
||||
if ($statusYear === '' && ($isNew === 0 || $isNew === 1)) {
|
||||
$statusYear = (string) (service('studentYearStatus')->activeSchoolYear() ?? '');
|
||||
}
|
||||
$useYearScopedIsNew = $statusYear !== ''
|
||||
&& preg_match('/^\d{4}-\d{4}$/', $statusYear)
|
||||
&& $this->db->tableExists('student_year_status');
|
||||
$schoolYearSelect = $statusYear !== ''
|
||||
? $this->db->escape($statusYear) . ' AS school_year'
|
||||
: 'NULL AS school_year';
|
||||
|
||||
$select = [
|
||||
'students.id',
|
||||
'students.school_id',
|
||||
'students.firstname',
|
||||
'students.lastname',
|
||||
'students.dob',
|
||||
'students.age',
|
||||
'students.gender',
|
||||
'students.registration_grade',
|
||||
'students.photo_consent',
|
||||
'students.parent_id',
|
||||
'students.registration_date',
|
||||
'students.tuition_paid',
|
||||
'students.year_of_registration',
|
||||
$schoolYearSelect,
|
||||
'students.rfid_tag',
|
||||
'NULL AS semester',
|
||||
'students.is_active',
|
||||
'u.firstname AS parent_firstname',
|
||||
'u.lastname AS parent_lastname',
|
||||
'u.email AS parent_email',
|
||||
@@ -108,30 +157,47 @@ class StudentModel extends Model
|
||||
'MIN(ec.emergency_contact_name) AS emergency_name',
|
||||
'MIN(ec.relation) AS emergency_relationship',
|
||||
'MIN(ec.cellphone) AS emergency_phone',
|
||||
])
|
||||
];
|
||||
|
||||
if ($useYearScopedIsNew) {
|
||||
$select[] = 'COALESCE(sys.is_new, 1) AS is_new';
|
||||
}
|
||||
|
||||
$builder = $this->select($select)
|
||||
->join('users u', 'u.id = students.parent_id', 'left')
|
||||
->join('emergency_contacts ec', 'ec.parent_id = students.parent_id', 'left');
|
||||
|
||||
// is_new filter
|
||||
if ($isNew === 0 || $isNew === 1) {
|
||||
$builder->where('students.is_new', $isNew);
|
||||
} else {
|
||||
$builder->whereIn('students.is_new', [0, 1]); // both
|
||||
if ($useYearScopedIsNew) {
|
||||
$builder->join(
|
||||
'student_year_status sys',
|
||||
'sys.student_id = students.id AND sys.school_year = ' . $this->db->escape($statusYear),
|
||||
'left'
|
||||
);
|
||||
}
|
||||
|
||||
// school_year filter (skip if null or "all")
|
||||
if ($schoolYear !== null && strtolower($schoolYear) !== 'all') {
|
||||
if ($isNew === 0 || $isNew === 1) {
|
||||
if (! $useYearScopedIsNew) {
|
||||
return [];
|
||||
}
|
||||
$builder->where('COALESCE(sys.is_new, 1)', $isNew, false);
|
||||
}
|
||||
|
||||
if ($filterByYear) {
|
||||
$builder
|
||||
->join('student_class sc_filter', 'sc_filter.student_id = students.id', 'inner')
|
||||
->where('sc_filter.school_year', $schoolYear);
|
||||
}
|
||||
|
||||
return $builder
|
||||
$builder = $builder
|
||||
->groupBy('students.id')
|
||||
->orderBy('students.is_new', 'DESC') // new first
|
||||
->orderBy('students.lastname', 'ASC')
|
||||
->orderBy('students.firstname', 'ASC')
|
||||
->findAll();
|
||||
->orderBy('students.firstname', 'ASC');
|
||||
|
||||
if ($useYearScopedIsNew) {
|
||||
$builder->orderBy('COALESCE(sys.is_new, 1)', 'DESC', false);
|
||||
}
|
||||
|
||||
return $builder->findAll();
|
||||
}
|
||||
|
||||
|
||||
@@ -407,6 +473,15 @@ class StudentModel extends Model
|
||||
$studentClassJoin = 'student_class.student_id = students.id';
|
||||
$enrollmentJoin = 'enrollments.student_id = students.id';
|
||||
$selectedYearFilter = '';
|
||||
$useYearScopedIsNew = $schoolYear !== ''
|
||||
&& preg_match('/^\d{4}-\d{4}$/', $schoolYear)
|
||||
&& $this->db->tableExists('student_year_status');
|
||||
$schoolYearSelect = $schoolYear !== ''
|
||||
? $this->db->escape($schoolYear) . ' AS school_year'
|
||||
: 'NULL AS school_year';
|
||||
$isNewSelect = $useYearScopedIsNew
|
||||
? 'COALESCE(sys.is_new, 1) AS is_new'
|
||||
: '1 AS is_new';
|
||||
|
||||
if ($schoolYear !== '') {
|
||||
$escapedSchoolYear = $this->db->escape($schoolYear);
|
||||
@@ -433,7 +508,24 @@ class StudentModel extends Model
|
||||
}
|
||||
|
||||
$builder = $this->select('
|
||||
students.*,
|
||||
students.id,
|
||||
students.school_id,
|
||||
students.firstname,
|
||||
students.lastname,
|
||||
students.dob,
|
||||
students.age,
|
||||
students.gender,
|
||||
students.registration_grade,
|
||||
students.photo_consent,
|
||||
students.parent_id,
|
||||
students.registration_date,
|
||||
students.tuition_paid,
|
||||
students.year_of_registration,
|
||||
' . $schoolYearSelect . ',
|
||||
students.rfid_tag,
|
||||
NULL AS semester,
|
||||
students.is_active,
|
||||
' . $isNewSelect . ',
|
||||
student_class.class_section_id,
|
||||
enrollments.enrollment_status,
|
||||
enrollments.admission_status,
|
||||
@@ -444,6 +536,14 @@ class StudentModel extends Model
|
||||
->join('enrollments', $enrollmentJoin, 'left')
|
||||
->join('users u', 'u.id = students.parent_id', 'left');
|
||||
|
||||
if ($useYearScopedIsNew) {
|
||||
$builder->join(
|
||||
'student_year_status sys',
|
||||
'sys.student_id = students.id AND sys.school_year = ' . $this->db->escape($schoolYear),
|
||||
'left'
|
||||
);
|
||||
}
|
||||
|
||||
if ($selectedYearFilter !== '') {
|
||||
$builder->where($selectedYearFilter, null, false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\SchoolYearAutoFillTrait;
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class StudentYearStatusModel extends Model
|
||||
{
|
||||
use SchoolYearAutoFillTrait;
|
||||
|
||||
protected $table = 'student_year_status';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
protected $allowedFields = [
|
||||
'student_id',
|
||||
'school_year',
|
||||
'is_new',
|
||||
];
|
||||
|
||||
protected $validationRules = [
|
||||
'student_id' => 'required|is_natural_no_zero',
|
||||
'school_year' => 'required|regex_match[/^\d{4}-\d{4}$/]',
|
||||
'is_new' => 'required|in_list[0,1]',
|
||||
];
|
||||
}
|
||||
@@ -110,6 +110,10 @@ public function studentProfiles(string $selectedYear): array
|
||||
->getResultArray();
|
||||
}
|
||||
|
||||
if ($selectedYear !== '') {
|
||||
service('studentYearStatus')->attachToStudents($students, $selectedYear);
|
||||
}
|
||||
|
||||
$enrollmentStatusByStudentId = [];
|
||||
$studentIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): int => (int) ($row['id'] ?? 0),
|
||||
|
||||
@@ -58,7 +58,7 @@ public function buildRoster(string $selectedYear, string $semester): array
|
||||
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
|
||||
|
||||
$removedPriorStatuses = $this->removedPriorYearStudentStatuses($selectedYear);
|
||||
$returningStudentIds = $this->priorYearStudentIds($selectedYear);
|
||||
service('studentYearStatus')->attachToStudents($students, $selectedYear);
|
||||
|
||||
foreach ($students as &$s) {
|
||||
// ===== Ensure IDs needed by the modal =====
|
||||
@@ -93,11 +93,8 @@ public function buildRoster(string $selectedYear, string $semester): array
|
||||
$s['parent_sort'] = 'ZZZ Unknown Parent';
|
||||
}
|
||||
|
||||
// ===== New-student flags =====
|
||||
$s['is_new'] = (int) ($s['is_new'] ?? 0);
|
||||
if (isset($returningStudentIds[$s['student_id']])) {
|
||||
$s['is_new'] = 0;
|
||||
}
|
||||
// ===== New-student flags (year-scoped) =====
|
||||
$s['is_new'] = (int) ($s['is_new'] ?? 1) === 1 ? 1 : 0;
|
||||
$s['new_student'] = $s['is_new'] === 1 ? 'Yes' : 'No';
|
||||
|
||||
// ===== Admission override =====
|
||||
@@ -158,7 +155,7 @@ public function buildRoster(string $selectedYear, string $semester): array
|
||||
*/
|
||||
public function newStudents(string $schoolYear): array
|
||||
{
|
||||
$rows = $this->studentModel->getStudentsWithParentsAndEmergency($schoolYear);
|
||||
$rows = $this->studentModel->getStudentsWithParentsAndEmergency($schoolYear, 1);
|
||||
|
||||
$newStudents = [];
|
||||
foreach ($rows as $r) {
|
||||
@@ -170,7 +167,7 @@ public function newStudents(string $schoolYear): array
|
||||
? $classSection
|
||||
: 'Class not Assigned';
|
||||
|
||||
$r['is_new'] = (int) $r['is_new'];
|
||||
$r['is_new'] = (int) ($r['is_new'] ?? 1) === 1 ? 1 : 0;
|
||||
$r['new_student'] = $r['is_new'] === 1 ? "Yes" : "No";
|
||||
$r['modalIdContact'] = 'contact_' . (int)($r['id'] ?? 0);
|
||||
$r['enrollment_status'] = $enrollmentstatus;
|
||||
|
||||
@@ -603,14 +603,6 @@ final class SchoolYearClosingService
|
||||
->where('sc.school_year', $schoolYear)
|
||||
->where('sc.class_section_id IS NOT NULL', null, false);
|
||||
|
||||
if ($hasEnrollments && ($hasEnrollmentStatus || $hasEnrollmentWithdrawn)) {
|
||||
$builder->join(
|
||||
'enrollments e',
|
||||
'e.student_id = sc.student_id AND e.school_year = ' . $this->db->escape($schoolYear),
|
||||
'left'
|
||||
);
|
||||
}
|
||||
|
||||
if ($hasDob) {
|
||||
$builder->select('s.dob');
|
||||
}
|
||||
@@ -630,18 +622,12 @@ final class SchoolYearClosingService
|
||||
$builder->where('s.is_active', 1);
|
||||
}
|
||||
|
||||
if ($hasEnrollmentStatus) {
|
||||
$inactiveStatuses = implode(',', array_map([$this->db, 'escape'], EnrollmentStatusService::INACTIVE_STATUSES));
|
||||
$builder->where(
|
||||
'(e.enrollment_status IS NULL OR LOWER(TRIM(e.enrollment_status)) NOT IN (' . $inactiveStatuses . '))',
|
||||
null,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
if ($hasEnrollmentWithdrawn) {
|
||||
$builder->where('(e.is_withdrawn IS NULL OR e.is_withdrawn != 1)', null, false);
|
||||
}
|
||||
$this->excludeWithdrawnStudentsFromClosingBlockers(
|
||||
$builder,
|
||||
$schoolYear,
|
||||
$hasEnrollmentStatus,
|
||||
$hasEnrollmentWithdrawn
|
||||
);
|
||||
|
||||
$assignmentRows = $builder
|
||||
->orderBy('sc.student_id', 'ASC')
|
||||
@@ -783,6 +769,39 @@ final class SchoolYearClosingService
|
||||
];
|
||||
}
|
||||
|
||||
private function excludeWithdrawnStudentsFromClosingBlockers(
|
||||
$builder,
|
||||
string $schoolYear,
|
||||
bool $hasEnrollmentStatus,
|
||||
bool $hasEnrollmentWithdrawn
|
||||
): void {
|
||||
if (! $hasEnrollmentStatus && ! $hasEnrollmentWithdrawn) {
|
||||
return;
|
||||
}
|
||||
|
||||
$conditions = [];
|
||||
if ($hasEnrollmentStatus) {
|
||||
$inactiveStatuses = implode(',', array_map([$this->db, 'escape'], EnrollmentStatusService::INACTIVE_STATUSES));
|
||||
$conditions[] = 'LOWER(TRIM(e.enrollment_status)) IN (' . $inactiveStatuses . ')';
|
||||
}
|
||||
|
||||
if ($hasEnrollmentWithdrawn) {
|
||||
$conditions[] = 'e.is_withdrawn = 1';
|
||||
}
|
||||
|
||||
$builder->where(
|
||||
'NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM enrollments e
|
||||
WHERE e.student_id = sc.student_id
|
||||
AND e.school_year = ' . $this->db->escape($schoolYear) . '
|
||||
AND (' . implode(' OR ', $conditions) . ')
|
||||
)',
|
||||
null,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
private function isKgStudent(array $student): bool
|
||||
{
|
||||
foreach (['class_name', 'class_section_name'] as $field) {
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\StudentYearStatusModel;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
|
||||
class StudentYearStatusService
|
||||
{
|
||||
public function __construct(
|
||||
private BaseConnection $db,
|
||||
private StudentYearStatusModel $yearStatusModel,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isNew(int $studentId, string $schoolYear): bool
|
||||
{
|
||||
if ($studentId <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$schoolYear = trim($schoolYear);
|
||||
if (! preg_match('/^\d{4}-\d{4}$/', $schoolYear)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $this->db->tableExists('student_year_status')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$row = $this->yearStatusModel
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->first();
|
||||
|
||||
if (! is_array($row)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (int) ($row['is_new'] ?? 1) === 1;
|
||||
}
|
||||
|
||||
public function upsert(int $studentId, string $schoolYear, bool $isNew): void
|
||||
{
|
||||
if ($studentId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schoolYear = trim($schoolYear);
|
||||
if (! preg_match('/^\d{4}-\d{4}$/', $schoolYear) || ! $this->db->tableExists('student_year_status')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$flag = $isNew ? 1 : 0;
|
||||
|
||||
$existing = $this->yearStatusModel
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->first();
|
||||
|
||||
if (is_array($existing) && isset($existing['id'])) {
|
||||
$this->yearStatusModel->update((int) $existing['id'], [
|
||||
'is_new' => $flag,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->yearStatusModel->insert([
|
||||
'student_id' => $studentId,
|
||||
'school_year' => $schoolYear,
|
||||
'is_new' => $flag,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $students
|
||||
*/
|
||||
public function attachToStudents(array &$students, string $schoolYear): void
|
||||
{
|
||||
$schoolYear = trim($schoolYear);
|
||||
if ($students === [] || ! preg_match('/^\d{4}-\d{4}$/', $schoolYear)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
foreach ($students as $student) {
|
||||
$id = (int) ($student['id'] ?? $student['student_id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$ids[$id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$flags = $this->flagsForStudents(array_keys($ids), $schoolYear);
|
||||
|
||||
foreach ($students as &$student) {
|
||||
$id = (int) ($student['id'] ?? $student['student_id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$student['is_new'] = $flags[$id] ?? 1;
|
||||
}
|
||||
unset($student);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $studentIds
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function flagsForStudents(array $studentIds, string $schoolYear): array
|
||||
{
|
||||
$studentIds = array_values(array_unique(array_filter(
|
||||
array_map('intval', $studentIds),
|
||||
static fn(int $id): bool => $id > 0
|
||||
)));
|
||||
|
||||
$schoolYear = trim($schoolYear);
|
||||
if ($studentIds === [] || ! preg_match('/^\d{4}-\d{4}$/', $schoolYear)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$flags = [];
|
||||
foreach ($studentIds as $studentId) {
|
||||
$flags[$studentId] = 1;
|
||||
}
|
||||
|
||||
if (! $this->db->tableExists('student_year_status')) {
|
||||
return $flags;
|
||||
}
|
||||
|
||||
$rows = $this->yearStatusModel
|
||||
->select('student_id, is_new')
|
||||
->whereIn('student_id', $studentIds)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$id = (int) ($row['student_id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$flags[$id] = (int) ($row['is_new'] ?? 1) === 1 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
return $flags;
|
||||
}
|
||||
|
||||
public function activeSchoolYear(): ?string
|
||||
{
|
||||
if ($this->db->tableExists('school_years')) {
|
||||
$row = $this->db->table('school_years')
|
||||
->select('name')
|
||||
->where('status', 'active')
|
||||
->orderBy('id', 'DESC')
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
|
||||
$name = trim((string) ($row['name'] ?? ''));
|
||||
if (preg_match('/^\d{4}-\d{4}$/', $name)) {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->db->tableExists('configuration')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $this->db->table('configuration')
|
||||
->select('config_value')
|
||||
->where('config_key', 'school_year')
|
||||
->orderBy('id', 'DESC')
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
|
||||
$name = trim((string) ($row['config_value'] ?? ''));
|
||||
|
||||
return preg_match('/^\d{4}-\d{4}$/', $name) ? $name : null;
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ final class SchoolYearTableRegistry
|
||||
'staff_attendance',
|
||||
'student_class',
|
||||
'student_decisions',
|
||||
'student_year_status',
|
||||
'teacher_attendance_data',
|
||||
'teacher_class',
|
||||
'teacher_submission_notification_history',
|
||||
|
||||
@@ -38,9 +38,9 @@
|
||||
<th>Registration Date</th>
|
||||
<th>Parent/Guardian</th>
|
||||
<th>Student Name</th>
|
||||
<th>School ID</th>
|
||||
<th>Age</th>
|
||||
<th>New Student</th>
|
||||
<th>Removed (Prior Years)</th>
|
||||
<th>Current Class</th>
|
||||
<th>Actual Status</th>
|
||||
<th>Update Enrollment Status</th>
|
||||
@@ -79,6 +79,11 @@
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
|
||||
<!-- School ID -->
|
||||
<td>
|
||||
<?= esc($student['school_id'] ?? '-') ?>
|
||||
</td>
|
||||
|
||||
<!-- Age -->
|
||||
<td class="text-center">
|
||||
<?= isset($student['age']) && $student['age'] !== '' && $student['age'] !== null ? esc((string)(int)$student['age']) : '-' ?>
|
||||
@@ -95,15 +100,6 @@
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
|
||||
<!-- Removed Previous Year -->
|
||||
<td>
|
||||
<?php if (($student['removed_previous_year'] ?? 'No') === 'Yes'): ?>
|
||||
<span class="badge bg-danger text-light">Yes</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-secondary">No</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
|
||||
<!-- Class -->
|
||||
<td><?= esc($student['class_section'] ?? 'Class not Assigned') ?></td>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
.enrollment-stepper {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
border-bottom: 3px solid #dee2e6;
|
||||
color: #6c757d;
|
||||
font-size: .85rem;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
padding: .35rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -92,6 +94,38 @@
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.enrollment-summary-card {
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.enrollment-summary-lines {
|
||||
display: grid;
|
||||
gap: .35rem;
|
||||
}
|
||||
|
||||
.enrollment-summary-line {
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.enrollment-summary-label {
|
||||
color: #6c757d;
|
||||
flex: 0 0 auto;
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
.enrollment-summary-value {
|
||||
font-size: .875rem;
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.enrollment-stepper {
|
||||
gap: .25rem;
|
||||
@@ -177,6 +211,16 @@
|
||||
.enrollment-status-table td[data-label="Withdraw"] .enrollment-withdraw-control {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.enrollment-summary-line {
|
||||
align-items: flex-start;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.enrollment-summary-value {
|
||||
display: block;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<?= $this->endSection() ?>
|
||||
@@ -231,15 +275,21 @@ foreach (($students ?? []) as $student) {
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (session()->getFlashdata('error')): ?>
|
||||
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
|
||||
<?php
|
||||
$flashError = session()->getFlashdata('error');
|
||||
$flashSuccess = session()->getFlashdata('success');
|
||||
?>
|
||||
<?php if ($flashError): ?>
|
||||
<div class="alert alert-danger"><?= esc($flashError) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($flashSuccess): ?>
|
||||
<div class="alert alert-success"><?= esc($flashSuccess) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="alert alert-info mb-3">
|
||||
<p>Enrollment is the process of officially signing up your child for the upcoming school year.</p>
|
||||
<ul class="mb-0">
|
||||
<li>Last Day for Enrollment is <strong><?= esc(local_date($lastDayOfRegistration, 'm-d-Y')) ?></strong>.</li>
|
||||
<li>After submit, enrollment status changes to <strong>admission under review</strong>.</li>
|
||||
<li>Payments are processed on the first day of school: <strong><?= esc(local_date($schoolStartDate, 'm-d-Y')) ?></strong>.</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -254,7 +304,7 @@ foreach (($students ?? []) as $student) {
|
||||
<div class="col-lg">
|
||||
<div class="fw-semibold">Enrollment process</div>
|
||||
<div class="text-muted small">
|
||||
Review student information, acknowledge policies, review tuition and balances, then submit enrollment.
|
||||
Review student information, confirm your home address and phone, acknowledge policies, review tuition and balances, then submit enrollment.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-auto">
|
||||
@@ -338,9 +388,10 @@ foreach (($students ?? []) as $student) {
|
||||
<div class="modal-body">
|
||||
<div class="enrollment-stepper mb-3" aria-label="Enrollment steps">
|
||||
<div class="enrollment-step is-active" data-step-indicator="0">Student Info</div>
|
||||
<div class="enrollment-step" data-step-indicator="1">Policies</div>
|
||||
<div class="enrollment-step" data-step-indicator="2">Tuition</div>
|
||||
<div class="enrollment-step" data-step-indicator="3">Submit</div>
|
||||
<div class="enrollment-step" data-step-indicator="1">Contact</div>
|
||||
<div class="enrollment-step" data-step-indicator="2">Policies</div>
|
||||
<div class="enrollment-step" data-step-indicator="3">Tuition</div>
|
||||
<div class="enrollment-step" data-step-indicator="4">Submit</div>
|
||||
</div>
|
||||
|
||||
<div data-step-panel="0">
|
||||
@@ -450,6 +501,7 @@ foreach (($students ?? []) as $student) {
|
||||
data-current-grade="<?= esc($gradeLabel) ?>"
|
||||
data-required-action="<?= esc($student['required_action_label'] ?? 'Contact administration') ?>"
|
||||
data-expected-placement="<?= esc($student['expected_placement_label'] ?? $gradeLabel) ?>"
|
||||
data-is-new="<?= (string) ($student['is_new'] ?? '1') === '1' ? '1' : '0' ?>"
|
||||
data-selectable="<?= $canEnroll ? '1' : '0' ?>">
|
||||
<div class="d-flex gap-3 align-items-start">
|
||||
<span class="student-select-icon" aria-hidden="true"><i class="bi bi-check2"></i></span>
|
||||
@@ -463,10 +515,13 @@ foreach (($students ?? []) as $student) {
|
||||
<div>School ID: <?= esc($student['school_id'] ?? 'N/A') ?></div>
|
||||
<div>Date of birth: <?= esc($dobDisplay) ?><?php if (isset($student['age'])): ?> · Age: <?= esc($student['age']) ?><?php endif; ?></div>
|
||||
<div>Gender: <?= esc($student['gender'] ?? 'N/A') ?></div>
|
||||
<div>Registration grade: <?= esc($student['registration_grade'] ?? 'N/A') ?></div>
|
||||
<div>Current grade: <?= esc($gradeLabel) ?></div>
|
||||
<div>Expected placement: <?= esc($student['expected_placement_label'] ?? $gradeLabel) ?></div>
|
||||
<div>Required action: <?= esc($student['required_action_label'] ?? 'Contact administration') ?></div>
|
||||
<?php if ((string) ($student['is_new'] ?? '1') === '1'): ?>
|
||||
<div>Registration grade: <?= esc($student['registration_grade'] ?? 'N/A') ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ((string) ($student['is_new'] ?? '0') === '0'): ?>
|
||||
<div>Expected placement: <?= esc($student['expected_placement_label'] ?? $gradeLabel) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
|
||||
<?php if ($canEnroll): ?>
|
||||
@@ -478,7 +533,7 @@ foreach (($students ?? []) as $student) {
|
||||
|
||||
<div class="row g-2 mt-3" data-student-edit-fields>
|
||||
<div class="col-12">
|
||||
<div class="fw-semibold small mb-1">Editable health information</div>
|
||||
<div class="fw-semibold small mb-1">Editable information</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold" for="student-photo-consent-<?= esc($student['id']) ?>">Photo consent</label>
|
||||
@@ -533,6 +588,95 @@ foreach (($students ?? []) as $student) {
|
||||
</div>
|
||||
|
||||
<div class="d-none" data-step-panel="1">
|
||||
<?php
|
||||
$parentContact = is_array($parentContact ?? null) ? $parentContact : [];
|
||||
$parentContactStates = [
|
||||
'CT' => 'Connecticut',
|
||||
'ME' => 'Maine',
|
||||
'MA' => 'Massachusetts',
|
||||
'NH' => 'New Hampshire',
|
||||
'NY' => 'New York',
|
||||
'RI' => 'Rhode Island',
|
||||
'VT' => 'Vermont',
|
||||
];
|
||||
$currentParentState = strtoupper(trim((string) ($parentContact['state'] ?? '')));
|
||||
if ($currentParentState !== '' && ! isset($parentContactStates[$currentParentState])) {
|
||||
$parentContactStates[$currentParentState] = $currentParentState;
|
||||
}
|
||||
$parentDisplayName = trim((string) ($parentContact['firstname'] ?? '') . ' ' . (string) ($parentContact['lastname'] ?? ''));
|
||||
?>
|
||||
<h6 class="fw-semibold">Confirm home address and phone</h6>
|
||||
<div class="text-muted small mb-3">
|
||||
Please review and update your contact information before continuing enrollment.
|
||||
<?php if ($parentDisplayName !== ''): ?>
|
||||
This account is for <?= esc($parentDisplayName) ?>.
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold" for="parent-contact-cellphone">Phone</label>
|
||||
<input type="tel"
|
||||
class="form-control"
|
||||
id="parent-contact-cellphone"
|
||||
name="parent_contact[cellphone]"
|
||||
value="<?= esc($parentContact['cellphone'] ?? '') ?>"
|
||||
maxlength="12"
|
||||
inputmode="numeric"
|
||||
required>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label small fw-semibold" for="parent-contact-street">Home street address</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="parent-contact-street"
|
||||
name="parent_contact[address_street]"
|
||||
value="<?= esc($parentContact['address_street'] ?? '') ?>"
|
||||
maxlength="255"
|
||||
required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold" for="parent-contact-apt">Apt / Unit</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="parent-contact-apt"
|
||||
name="parent_contact[apt]"
|
||||
value="<?= esc($parentContact['apt'] ?? '') ?>"
|
||||
maxlength="15">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold" for="parent-contact-city">City</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="parent-contact-city"
|
||||
name="parent_contact[city]"
|
||||
value="<?= esc($parentContact['city'] ?? '') ?>"
|
||||
maxlength="100"
|
||||
required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold" for="parent-contact-zip">ZIP code</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="parent-contact-zip"
|
||||
name="parent_contact[zip]"
|
||||
value="<?= esc($parentContact['zip'] ?? '') ?>"
|
||||
maxlength="5"
|
||||
inputmode="numeric"
|
||||
required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold" for="parent-contact-state">State</label>
|
||||
<select class="form-select" id="parent-contact-state" name="parent_contact[state]" required>
|
||||
<option value="">Select</option>
|
||||
<?php foreach ($parentContactStates as $abbr => $stateName): ?>
|
||||
<option value="<?= esc($abbr) ?>" <?= $currentParentState === $abbr ? 'selected' : '' ?>><?= esc($stateName) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-none" data-step-panel="2">
|
||||
<h6 class="fw-semibold">Acknowledge school policies</h6>
|
||||
<div class="text-muted small mb-3">Review the current school policies before submitting enrollment.</div>
|
||||
<iframe src="<?= base_url('policy/school_policy') ?>" class="enrollment-policy-frame" frameborder="0" title="School Policies"></iframe>
|
||||
@@ -544,7 +688,7 @@ foreach (($students ?? []) as $student) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-none" data-step-panel="2">
|
||||
<div class="d-none" data-step-panel="3">
|
||||
<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 !== []): ?>
|
||||
@@ -559,7 +703,7 @@ foreach (($students ?? []) as $student) {
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="d-none" data-step-panel="3">
|
||||
<div class="d-none" data-step-panel="4">
|
||||
<h6 class="fw-semibold">Submit enrollment</h6>
|
||||
<div class="text-muted small mb-3">Review the enrollment summary before submitting.</div>
|
||||
|
||||
@@ -568,6 +712,20 @@ foreach (($students ?? []) as $student) {
|
||||
<div id="enrollmentFeeReviewList" class="d-grid gap-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="enrollment-summary-card mb-3" id="parentContactReviewCard">
|
||||
<div class="fw-semibold mb-2">Parent contact</div>
|
||||
<div class="enrollment-summary-lines">
|
||||
<div class="enrollment-summary-line">
|
||||
<span class="enrollment-summary-label">Phone:</span>
|
||||
<span class="enrollment-summary-value" data-parent-review="cellphone"></span>
|
||||
</div>
|
||||
<div class="enrollment-summary-line">
|
||||
<span class="enrollment-summary-label">Home address:</span>
|
||||
<span class="enrollment-summary-value" data-parent-review="address"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="border rounded p-3 h-100">
|
||||
@@ -582,10 +740,7 @@ foreach (($students ?? []) as $student) {
|
||||
<div class="fw-semibold mb-2">Financial information</div>
|
||||
<?php if ($familyFinancialSummary !== []): ?>
|
||||
<div class="small"><span class="text-muted">Carry-over balance:</span> <strong data-financial-value="carry_over_balance"><?= esc($money($familyFinancialSummary['carry_over_balance'] ?? 0)) ?></strong></div>
|
||||
<div class="small"><span class="text-muted">Registration fee:</span> <strong data-financial-value="registration_fee"><?= esc($money($familyFinancialSummary['registration_fee'] ?? 0)) ?></strong></div>
|
||||
<div class="small"><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="small"><span class="text-muted">Mandatory fees:</span> <strong data-financial-value="mandatory_fees"><?= esc($money($familyFinancialSummary['mandatory_fees'] ?? 0)) ?></strong></div>
|
||||
<div class="small"><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="small"><span class="text-muted">Total currently due:</span> <strong data-financial-value="amount_due"><?= esc($money($familyFinancialSummary['amount_due'] ?? 0)) ?></strong></div>
|
||||
<?php else: ?>
|
||||
<div class="small text-muted">Financial information is not available.</div>
|
||||
@@ -597,7 +752,8 @@ foreach (($students ?? []) as $student) {
|
||||
<div class="alert alert-warning mb-0 small">
|
||||
<div class="fw-semibold mb-1">Important information</div>
|
||||
<ul class="mb-0 ps-3">
|
||||
<li>For newly registered students, enrollment status changes to admission under review after submitting the request.</li>
|
||||
<li>Submitting sends the selected enrollment(s) for newly registered students to Admissions for review.</li>
|
||||
<li>Returning students who passed last year grade are automatically granted admission.</li>
|
||||
<li>Payments are processed on the first day of school.</li>
|
||||
<?php if (!empty($fallMakeupExamOn)): ?>
|
||||
<li>Students with a make-up exam decision may initially remain in the same grade until the make-up exam result is confirmed.</li>
|
||||
@@ -656,7 +812,15 @@ foreach (($students ?? []) as $student) {
|
||||
const nextButton = document.getElementById('enrollmentNextButton');
|
||||
const submitButton = document.getElementById('enrollmentSubmitButton');
|
||||
const feeReviewList = document.getElementById('enrollmentFeeReviewList');
|
||||
const finalStep = 3;
|
||||
const parentPhoneInput = document.getElementById('parent-contact-cellphone');
|
||||
const parentStreetInput = document.getElementById('parent-contact-street');
|
||||
const parentAptInput = document.getElementById('parent-contact-apt');
|
||||
const parentCityInput = document.getElementById('parent-contact-city');
|
||||
const parentStateInput = document.getElementById('parent-contact-state');
|
||||
const parentZipInput = document.getElementById('parent-contact-zip');
|
||||
const finalStep = 4;
|
||||
const enrollmentStartStep = <?= (int) ($enrollmentStartStep ?? 0) ?>;
|
||||
const preselectedStudentIds = <?= json_encode(array_values(array_map('intval', $preselectedStudentIds ?? [])), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
|
||||
const feeSchedule = <?= json_encode([
|
||||
'currency' => (string) ($enrollmentFeeSchedule['currency'] ?? '$'),
|
||||
'firstStudentFee' => (float) ($enrollmentFeeSchedule['first_student_fee'] ?? 0),
|
||||
@@ -690,7 +854,7 @@ foreach (($students ?? []) as $student) {
|
||||
backButton.classList.toggle('d-none', currentStep === 0);
|
||||
nextButton.classList.toggle('d-none', currentStep === finalStep);
|
||||
submitButton.classList.toggle('d-none', currentStep !== finalStep);
|
||||
if (currentStep >= 2) {
|
||||
if (currentStep >= 3) {
|
||||
updateFinancialReview();
|
||||
}
|
||||
if (currentStep === finalStep) {
|
||||
@@ -736,20 +900,19 @@ foreach (($students ?? []) as $student) {
|
||||
const lastName = card ? card.querySelector("input[name$='[lastname]']")?.value?.trim() : '';
|
||||
const dob = card ? card.querySelector("input[name$='[dob]']")?.value?.trim() : '';
|
||||
const grade = card ? card.querySelector("input[name$='[registration_grade]']")?.value?.trim() : '';
|
||||
const gender = card ? card.querySelector("select[name$='[gender]']")?.value?.trim() : '';
|
||||
const gender = card ? card.querySelector("input[name$='[gender]']")?.value?.trim() : '';
|
||||
const schoolId = card?.dataset.schoolId || 'N/A';
|
||||
const currentGrade = card?.dataset.currentGrade || '';
|
||||
const expectedPlacement = card?.dataset.expectedPlacement || '';
|
||||
const requiredAction = card?.dataset.requiredAction || '';
|
||||
const isNewStudent = card?.dataset.isNew === '1';
|
||||
const name = (firstName + ' ' + lastName).trim() || card?.querySelector('.fw-semibold')?.textContent?.trim() || 'Student';
|
||||
const metaParts = [];
|
||||
if (grade) metaParts.push('Registration grade: ' + grade);
|
||||
if (dob) metaParts.push('DOB: ' + dob);
|
||||
if (gender) metaParts.push('Gender: ' + gender);
|
||||
if (schoolId) metaParts.push('School ID: ' + schoolId);
|
||||
return {
|
||||
name,
|
||||
meta: metaParts.join(' - '),
|
||||
schoolId,
|
||||
dob,
|
||||
gender,
|
||||
registrationGrade: isNewStudent ? grade : '',
|
||||
currentGrade,
|
||||
expectedPlacement,
|
||||
requiredAction,
|
||||
@@ -762,26 +925,35 @@ foreach (($students ?? []) as $student) {
|
||||
|
||||
if (!cards.length) {
|
||||
feeReviewList.innerHTML = '<div class="alert alert-warning mb-0">No students selected.</div>';
|
||||
renderParentContactReview();
|
||||
return;
|
||||
}
|
||||
|
||||
feeReviewList.innerHTML = cards.map((student, index) => {
|
||||
const tuitionFee = index === 0 ? feeSchedule.firstStudentFee : feeSchedule.secondStudentFee;
|
||||
const tier = index === 0 ? 'First student tuition tier' : 'Additional student tuition tier';
|
||||
return '<div class="border rounded p-3">' +
|
||||
'<div class="d-flex flex-wrap justify-content-between gap-2">' +
|
||||
'<div><div class="fw-semibold">' + escapeHtml(student.name) + '</div>' +
|
||||
'<div class="small text-muted">' + escapeHtml(student.meta) + '</div></div>' +
|
||||
'<div class="text-md-end"><div class="fw-semibold">' + escapeHtml(formatMoney(tuitionFee)) + '</div>' +
|
||||
'<div class="small text-muted">' + escapeHtml(tier) + '</div></div>' +
|
||||
'</div>' +
|
||||
'<div class="row g-2 small mt-2">' +
|
||||
'<div class="col-md-4"><span class="text-muted">Current grade:</span> ' + escapeHtml(student.currentGrade || 'N/A') + '</div>' +
|
||||
'<div class="col-md-4"><span class="text-muted">Expected placement:</span> ' + escapeHtml(student.expectedPlacement || 'Pending') + '</div>' +
|
||||
'<div class="col-md-4"><span class="text-muted">Required action:</span> ' + escapeHtml(student.requiredAction || 'Contact administration') + '</div>' +
|
||||
const lines = [
|
||||
['Student', student.name],
|
||||
['School ID', student.schoolId || 'N/A'],
|
||||
['Date of birth', student.dob || 'N/A'],
|
||||
['Gender', student.gender || 'N/A'],
|
||||
['Tuition', formatMoney(tuitionFee)],
|
||||
['Current grade', student.currentGrade || 'N/A'],
|
||||
['Expected placement', student.expectedPlacement || 'Pending'],
|
||||
];
|
||||
|
||||
return '<div class="enrollment-summary-card">' +
|
||||
'<div class="enrollment-summary-lines">' +
|
||||
lines.map(([label, value]) => {
|
||||
return '<div class="enrollment-summary-line">' +
|
||||
'<span class="enrollment-summary-label">' + escapeHtml(label) + ':</span>' +
|
||||
'<span class="enrollment-summary-value">' + escapeHtml(value) + '</span>' +
|
||||
'</div>';
|
||||
}).join('') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
renderParentContactReview();
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
@@ -795,6 +967,108 @@ foreach (($students ?? []) as $student) {
|
||||
return String(feeSchedule.currency || '$') + numericAmount.toFixed(2);
|
||||
}
|
||||
|
||||
function digitsOnly(value) {
|
||||
return String(value || '').replace(/\D/g, '');
|
||||
}
|
||||
|
||||
function formatPhoneDisplay(value) {
|
||||
const digits = digitsOnly(value).substring(0, 10);
|
||||
if (digits.length > 6) {
|
||||
return digits.substring(0, 3) + '-' + digits.substring(3, 6) + '-' + digits.substring(6);
|
||||
}
|
||||
if (digits.length > 3) {
|
||||
return digits.substring(0, 3) + '-' + digits.substring(3);
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
|
||||
function parentContactAddress() {
|
||||
const street = parentStreetInput?.value?.trim() || '';
|
||||
const apt = parentAptInput?.value?.trim() || '';
|
||||
const city = parentCityInput?.value?.trim() || '';
|
||||
const state = parentStateInput?.value?.trim() || '';
|
||||
const zip = parentZipInput?.value?.trim() || '';
|
||||
const line = [street, apt].filter(Boolean).join(', ');
|
||||
const cityLine = [city, state, zip].filter(Boolean).join(' ');
|
||||
return [line, cityLine].filter(Boolean).join(', ') || 'N/A';
|
||||
}
|
||||
|
||||
function renderParentContactReview() {
|
||||
const phoneNode = document.querySelector('[data-parent-review="cellphone"]');
|
||||
const addressNode = document.querySelector('[data-parent-review="address"]');
|
||||
if (phoneNode) {
|
||||
phoneNode.textContent = formatPhoneDisplay(parentPhoneInput?.value || '') || 'N/A';
|
||||
}
|
||||
if (addressNode) {
|
||||
addressNode.textContent = parentContactAddress();
|
||||
}
|
||||
}
|
||||
|
||||
function validateParentContact() {
|
||||
const phone = digitsOnly(parentPhoneInput?.value || '');
|
||||
if (phone.length !== 10) {
|
||||
alert('Please enter a valid 10-digit phone number.');
|
||||
parentPhoneInput?.focus();
|
||||
return false;
|
||||
}
|
||||
if (!parentStreetInput || parentStreetInput.value.trim().length < 5) {
|
||||
alert('Please enter your home street address.');
|
||||
parentStreetInput?.focus();
|
||||
return false;
|
||||
}
|
||||
if (!parentCityInput || parentCityInput.value.trim().length < 2) {
|
||||
alert('Please enter your city.');
|
||||
parentCityInput?.focus();
|
||||
return false;
|
||||
}
|
||||
if (!parentStateInput || parentStateInput.value.trim() === '') {
|
||||
alert('Please select your state.');
|
||||
parentStateInput?.focus();
|
||||
return false;
|
||||
}
|
||||
if (!parentZipInput || digitsOnly(parentZipInput.value).length !== 5) {
|
||||
alert('Please enter a valid 5-digit ZIP code.');
|
||||
parentZipInput?.focus();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function selectStudentsForEnrollment(ids) {
|
||||
const wanted = Array.isArray(ids) ? ids.map(Number).filter(id => id > 0) : [];
|
||||
document.querySelectorAll('[data-student-card]').forEach(card => {
|
||||
if (card.dataset.selectable !== '1') {
|
||||
return;
|
||||
}
|
||||
const studentId = Number(card.dataset.studentId || 0);
|
||||
if (wanted.length && !wanted.includes(studentId)) {
|
||||
return;
|
||||
}
|
||||
const input = card.querySelector('[data-enroll-input]');
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
input.checked = true;
|
||||
card.classList.add('is-selected');
|
||||
setStudentFieldsEnabled(card, true);
|
||||
});
|
||||
}
|
||||
|
||||
function openEnrollmentAtStep(step) {
|
||||
if (!flowModal || deadlinePassed) {
|
||||
return false;
|
||||
}
|
||||
if (selectedEnrollInputs().length === 0) {
|
||||
selectStudentsForEnrollment(preselectedStudentIds);
|
||||
}
|
||||
if (selectedEnrollInputs().length === 0) {
|
||||
return false;
|
||||
}
|
||||
setStep(step);
|
||||
flowModal.show();
|
||||
return true;
|
||||
}
|
||||
|
||||
function syncPolicyAccepted() {
|
||||
hasAcceptedSchoolPolicy = !!(policyAcceptedCheckbox && policyAcceptedCheckbox.checked);
|
||||
if (policyAcceptedInput) {
|
||||
@@ -966,7 +1240,10 @@ foreach (($students ?? []) as $student) {
|
||||
if (currentStep === 0 && !validateSelectedStudentHealth()) {
|
||||
return;
|
||||
}
|
||||
if (currentStep === 1) {
|
||||
if (currentStep === 1 && !validateParentContact()) {
|
||||
return;
|
||||
}
|
||||
if (currentStep === 2) {
|
||||
syncPolicyAccepted();
|
||||
if (!hasAcceptedSchoolPolicy) {
|
||||
alert('Please read and accept the school policies before continuing.');
|
||||
@@ -1013,13 +1290,27 @@ foreach (($students ?? []) as $student) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (anyEnroll && !hasAcceptedSchoolPolicy) {
|
||||
if (anyEnroll && !validateSelectedStudentHealth()) {
|
||||
e.preventDefault();
|
||||
setStep(0);
|
||||
if (flowModal) flowModal.show();
|
||||
return;
|
||||
}
|
||||
|
||||
if (anyEnroll && !validateParentContact()) {
|
||||
e.preventDefault();
|
||||
setStep(1);
|
||||
if (flowModal) flowModal.show();
|
||||
return;
|
||||
}
|
||||
|
||||
if (anyEnroll && !hasAcceptedSchoolPolicy) {
|
||||
e.preventDefault();
|
||||
setStep(2);
|
||||
if (flowModal) flowModal.show();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!anyEnroll && anyWithdraw) {
|
||||
const ok = confirm('Confirm withdrawal request for the selected student(s)?');
|
||||
if (!ok) {
|
||||
@@ -1028,6 +1319,22 @@ foreach (($students ?? []) as $student) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (parentPhoneInput) {
|
||||
parentPhoneInput.addEventListener('input', function() {
|
||||
this.value = formatPhoneDisplay(this.value);
|
||||
});
|
||||
}
|
||||
|
||||
if (parentZipInput) {
|
||||
parentZipInput.addEventListener('input', function() {
|
||||
this.value = digitsOnly(this.value).substring(0, 5);
|
||||
});
|
||||
}
|
||||
|
||||
if (enrollmentStartStep >= 2 && startButton && !startButton.disabled && !<?= $flashError ? 'true' : 'false' ?>) {
|
||||
openEnrollmentAtStep(enrollmentStartStep - 1);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<?= $this->extend('layout/main_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<main class="col-md-9 ml-sm-auto col-lg-10 px-md-4">
|
||||
<div class="alert alert-success mt-4">
|
||||
<h4 class="alert-heading">Success!</h4>
|
||||
<p>The student(s) has/have been successfully enrolled.</p>
|
||||
<p>You will be redirected to the enroll page shortly...</p>
|
||||
</div>
|
||||
</main>
|
||||
<?php $successMessage = session()->getFlashdata('success'); ?>
|
||||
<div class="container my-4">
|
||||
<div class="alert alert-success">
|
||||
<h4 class="alert-heading">Success!</h4>
|
||||
<p><?= esc($successMessage ?: 'The student(s) has/have been successfully enrolled.') ?></p>
|
||||
<p class="mb-0">You will be redirected to the enroll page shortly...</p>
|
||||
</div>
|
||||
<a href="<?= base_url('/parent/enroll_classes') ?>" class="btn btn-success">Return to Enrollment</a>
|
||||
</div>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
@@ -17,6 +15,6 @@
|
||||
<script>
|
||||
setTimeout(function() {
|
||||
window.location.href = "<?= base_url('/parent/enroll_classes'); ?>";
|
||||
}, 5000); // 2500 milliseconds = 2.5 seconds
|
||||
}, 5000);
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
@@ -25,7 +25,7 @@ Current enrollment decisions are based on `student_decisions.decision` or `stude
|
||||
| `REPEAT_CLASS` | Parent re-enrollment is allowed into the same class when available; otherwise administration must assign the class. |
|
||||
| `MAKE_UP_EXAM` | Parent re-enrollment is allowed, but placement is temporary in the same grade until the make-up exam result is resolved. |
|
||||
| `EXPELLED` | Parent re-enrollment is blocked and administrative review is flagged. |
|
||||
| `WITHDRAWN` | Parent re-enrollment is blocked and withdrawal review is flagged. Historical misspellings such as `widthraw`, `widthdraw`, and `widthrwan` are already normalized to `WITHDRAWN`. |
|
||||
| `WITHDRAWN` | Parent re-enrollment is blocked and withdrawal review is flagged. Decision values containing the correct spelling `withdraw` are normalized to `WITHDRAWN`. |
|
||||
| `DEFERRED_DECISION` or pending/missing decision | Parent re-enrollment is blocked until administration records or reviews the decision. |
|
||||
|
||||
Also preserve these existing controls:
|
||||
|
||||
@@ -7,6 +7,26 @@ if (! getenv('CI_ENVIRONMENT')) {
|
||||
$_SERVER['CI_ENVIRONMENT'] = 'testing';
|
||||
}
|
||||
|
||||
// Minimal Locale polyfill when the intl extension is unavailable in the test PHP build.
|
||||
if (! class_exists(\Locale::class, false)) {
|
||||
class Locale
|
||||
{
|
||||
private static string $default = 'en';
|
||||
|
||||
public static function setDefault(string $locale): bool
|
||||
{
|
||||
self::$default = $locale;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function getDefault(): string
|
||||
{
|
||||
return self::$default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$writablePath = realpath(__DIR__ . '/../..') . DIRECTORY_SEPARATOR . 'writable';
|
||||
$runtimeDirectories = [
|
||||
$writablePath . DIRECTORY_SEPARATOR . 'cache',
|
||||
|
||||
@@ -20,30 +20,74 @@ namespace {
|
||||
return date($format);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('site_url')) {
|
||||
function site_url($uri = '')
|
||||
{
|
||||
return 'https://test.alrahmaisgl.org/' . ltrim((string) $uri, '/');
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('base_url')) {
|
||||
function base_url($uri = '')
|
||||
{
|
||||
return 'https://test.alrahmaisgl.org/' . ltrim((string) $uri, '/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace Tests\App\Controllers\View {
|
||||
|
||||
use App\Controllers\View\AdministratorController;
|
||||
use App\Models\UserModel;
|
||||
use CodeIgniter\HTTP\RedirectResponse;
|
||||
use App\Models\UserModel;
|
||||
use App\Services\AdministratorDashboardService;
|
||||
use App\Services\AdminNotificationSettingsService;
|
||||
use App\Services\EnrollmentWithdrawalService;
|
||||
use App\Services\TeacherSubmissionReportService;
|
||||
use CodeIgniter\HTTP\RedirectResponse;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
use Config\Services;
|
||||
|
||||
class DummyPostRequest
|
||||
{
|
||||
public function __construct(private array $post = [])
|
||||
{
|
||||
public function __construct(private array $post = [], private array $get = [])
|
||||
{
|
||||
}
|
||||
|
||||
public function getPost($key = null)
|
||||
{
|
||||
if ($key === null) {
|
||||
return $this->post;
|
||||
}
|
||||
return $this->post[$key] ?? null;
|
||||
}
|
||||
|
||||
public function getGet($key = null)
|
||||
{
|
||||
if ($key === null) {
|
||||
return $this->get;
|
||||
}
|
||||
return $this->get[$key] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
public function getPost($key = null)
|
||||
class FakeJsonResponse
|
||||
{
|
||||
if ($key === null) {
|
||||
return $this->post;
|
||||
public mixed $json = null;
|
||||
public int $statusCode = 200;
|
||||
|
||||
public function setJSON($data): self
|
||||
{
|
||||
$this->json = $data;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setStatusCode(int $code): self
|
||||
{
|
||||
$this->statusCode = $code;
|
||||
return $this;
|
||||
}
|
||||
return $this->post[$key] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRenderer
|
||||
{
|
||||
@@ -97,17 +141,17 @@ use CodeIgniter\HTTP\RedirectResponse;
|
||||
// Skip expensive parent constructor to avoid real DB/models.
|
||||
}
|
||||
|
||||
public function setUserModel(UserModel $model): self
|
||||
{
|
||||
$this->userModel = $model;
|
||||
return $this;
|
||||
}
|
||||
public function setUserModel(UserModel $model): self
|
||||
{
|
||||
$this->userModel = $model;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setStaffAttendanceModel($model): self
|
||||
{
|
||||
$this->staffAttendanceModel = $model;
|
||||
return $this;
|
||||
}
|
||||
public function setStaffAttendanceModel($model): self
|
||||
{
|
||||
$this->staffAttendanceModel = $model;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setSemester(string $semester): self
|
||||
{
|
||||
@@ -126,6 +170,12 @@ use CodeIgniter\HTTP\RedirectResponse;
|
||||
$this->request = $request;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setResponse($response): self
|
||||
{
|
||||
$this->response = $response;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
class AdministratorControllerTest extends CIUnitTestCase
|
||||
@@ -147,117 +197,316 @@ use CodeIgniter\HTTP\RedirectResponse;
|
||||
Services::injectMock('renderer', $this->renderer);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Services::resetSingle('renderer');
|
||||
session()->destroy();
|
||||
parent::tearDown();
|
||||
}
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Services::resetSingle('renderer');
|
||||
Services::resetSingle('administratorDashboard');
|
||||
Services::resetSingle('teacherSubmissionReport');
|
||||
Services::resetSingle('adminNotificationSettings');
|
||||
Services::resetSingle('enrollmentWithdrawal');
|
||||
Services::resetSingle('emailService');
|
||||
session()->destroy();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testAbsenceFormRedirectsWhenNotLoggedIn()
|
||||
{
|
||||
session()->remove('user_id');
|
||||
public function testAbsenceFormRedirectsWhenNotLoggedIn()
|
||||
{
|
||||
session()->remove('user_id');
|
||||
|
||||
$response = $this->controller->absenceFormAdmin();
|
||||
$response = $this->controller->absenceFormAdmin();
|
||||
|
||||
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||
$this->assertStringContainsString('/login', $response->getHeaderLine('Location') ?: '');
|
||||
}
|
||||
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||
$this->assertStringContainsString('/login', $response->getHeaderLine('Location') ?: '');
|
||||
}
|
||||
|
||||
public function testAbsenceFormBuildsViewWithAttendance()
|
||||
{
|
||||
session()->set('user_id', 7);
|
||||
public function testAbsenceFormBuildsViewWithAttendance()
|
||||
{
|
||||
session()->set('user_id', 7);
|
||||
|
||||
$userModel = $this->createMock(UserModel::class);
|
||||
$userModel->method('find')->with(7)->willReturn([
|
||||
'firstname' => 'Alex',
|
||||
'lastname' => 'Admin',
|
||||
]);
|
||||
$userModel = $this->createMock(UserModel::class);
|
||||
$userModel->method('find')->with(7)->willReturn([
|
||||
'firstname' => 'Alex',
|
||||
'lastname' => 'Admin',
|
||||
]);
|
||||
|
||||
$attendance = new FakeAttendanceModel([
|
||||
['date' => '2024-12-01'],
|
||||
]);
|
||||
$attendance = new FakeAttendanceModel([
|
||||
['date' => '2024-12-01'],
|
||||
]);
|
||||
|
||||
$this->controller->setUserModel($userModel)->setStaffAttendanceModel($attendance);
|
||||
$this->controller->setUserModel($userModel)->setStaffAttendanceModel($attendance);
|
||||
|
||||
$result = $this->controller->absenceFormAdmin();
|
||||
$result = $this->controller->absenceFormAdmin();
|
||||
|
||||
if (is_array($result)) {
|
||||
$this->assertSame('administrator/absence_vacation', $result['view']);
|
||||
$data = $result['data'];
|
||||
} else {
|
||||
$this->assertSame('fake-render', $result);
|
||||
$this->assertSame('administrator/absence_vacation', $this->renderer->lastName);
|
||||
$data = $this->renderer->lastData;
|
||||
}
|
||||
$this->assertSame('Alex Admin', $data['admin_name']);
|
||||
$this->assertSame('Fall', $data['semester']);
|
||||
$this->assertSame('2024-2025', $data['schoolYear']);
|
||||
$this->assertSame([['date' => '2024-12-01']], $data['existing']);
|
||||
$this->assertIsArray($data['availableDates']);
|
||||
}
|
||||
|
||||
public function testSubmitAbsenceRejectsMissingReason()
|
||||
{
|
||||
session()->set('user_id', 9);
|
||||
|
||||
$req = new DummyPostRequest([
|
||||
'dates' => ['2024-12-01'],
|
||||
'reason_type' => '',
|
||||
'reason' => '',
|
||||
]);
|
||||
|
||||
$this->controller->setRequest($req);
|
||||
|
||||
$response = $this->controller->submitAbsenceAdmin();
|
||||
|
||||
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||
$this->assertStringContainsString('/administrator/absence', $response->getHeaderLine('Location') ?: '');
|
||||
$this->assertSame('Reason is required.', session()->getFlashdata('message'));
|
||||
}
|
||||
|
||||
public function testSubmitAbsenceSavesAndRedirects()
|
||||
{
|
||||
session()->set('user_id', 10);
|
||||
|
||||
$userModel = $this->createMock(UserModel::class);
|
||||
$userModel->method('getUserRole')->with(10)->willReturn('administrator');
|
||||
$userModel->method('find')->with(10)->willReturn([
|
||||
'firstname' => 'Sam',
|
||||
'lastname' => 'Admin',
|
||||
'email' => 'sam@school.test',
|
||||
]);
|
||||
|
||||
$attendance = new FakeAttendanceModel();
|
||||
$this->controller->setUserModel($userModel)->setStaffAttendanceModel($attendance);
|
||||
|
||||
$method = new \ReflectionMethod($this->controller, 'allowedAbsenceDates');
|
||||
$method->setAccessible(true);
|
||||
$allowed = $method->invoke($this->controller);
|
||||
|
||||
if (empty($allowed)) {
|
||||
$this->markTestSkipped('No allowed absence dates are currently available.');
|
||||
}
|
||||
|
||||
$date = $allowed[0];
|
||||
|
||||
$req = new DummyPostRequest([
|
||||
'dates' => [$date],
|
||||
'reason_type' => 'Personal',
|
||||
'reason' => 'Conference',
|
||||
]);
|
||||
$this->controller->setRequest($req);
|
||||
|
||||
$emailService = $this->createMock(\App\Services\EmailService::class);
|
||||
$emailService->method('send')->willReturn(true);
|
||||
Services::injectMock('emailService', $emailService);
|
||||
|
||||
$response = $this->controller->submitAbsenceAdmin();
|
||||
|
||||
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||
$this->assertStringContainsString('/administrator/absence', $response->getHeaderLine('Location') ?: '');
|
||||
$this->assertSame('success', session()->getFlashdata('status'));
|
||||
$this->assertStringContainsString('saved', (string) session()->getFlashdata('message'));
|
||||
}
|
||||
|
||||
public function testAdministratorDashboardUsesDashboardServiceSearch(): void
|
||||
{
|
||||
$dashboard = $this->createMock(AdministratorDashboardService::class);
|
||||
$dashboard->expects($this->once())
|
||||
->method('search')
|
||||
->with('alex')
|
||||
->willReturn([
|
||||
'query' => 'alex',
|
||||
'results' => ['users' => []],
|
||||
'scope_used' => 'unscoped-raw',
|
||||
'scope_label' => 'all years/semesters (raw, tokenized)',
|
||||
'total_found' => 0,
|
||||
]);
|
||||
Services::injectMock('administratorDashboard', $dashboard);
|
||||
|
||||
$this->controller->setRequest(new DummyPostRequest([], ['query' => 'alex']));
|
||||
|
||||
$result = $this->controller->administratorDashboard();
|
||||
$data = $this->viewData($result, 'administrator/administratordashboard');
|
||||
|
||||
$this->assertSame('alex', $data['query']);
|
||||
$this->assertSame(0, $data['total_found']);
|
||||
$this->assertArrayHasKey('dashboardEndpoint', $data);
|
||||
}
|
||||
|
||||
public function testDashboardMetricsDelegatesToDashboardService(): void
|
||||
{
|
||||
$payload = [
|
||||
'counts' => ['students' => 3],
|
||||
'recentActivities' => [],
|
||||
'meta' => ['schoolYear' => '2024-2025', 'semester' => 'Fall'],
|
||||
];
|
||||
|
||||
$dashboard = $this->createMock(AdministratorDashboardService::class);
|
||||
$dashboard->expects($this->once())
|
||||
->method('metrics')
|
||||
->with('2024-2025', 'Fall')
|
||||
->willReturn($payload);
|
||||
Services::injectMock('administratorDashboard', $dashboard);
|
||||
|
||||
$response = new FakeJsonResponse();
|
||||
$this->controller->setResponse($response);
|
||||
|
||||
$result = $this->controller->dashboardMetrics();
|
||||
|
||||
$this->assertSame($response, $result);
|
||||
$this->assertSame($payload, $response->json);
|
||||
}
|
||||
|
||||
public function testUserSearchUsesDashboardService(): void
|
||||
{
|
||||
$dashboard = $this->createMock(AdministratorDashboardService::class);
|
||||
$dashboard->expects($this->once())
|
||||
->method('search')
|
||||
->with('parent')
|
||||
->willReturn([
|
||||
'query' => 'parent',
|
||||
'results' => [],
|
||||
'total_found' => 0,
|
||||
]);
|
||||
Services::injectMock('administratorDashboard', $dashboard);
|
||||
|
||||
$this->controller->setRequest(new DummyPostRequest([], ['query' => 'parent']));
|
||||
|
||||
$result = $this->controller->userSearch();
|
||||
$data = $this->viewData($result, 'administrator/search_results');
|
||||
|
||||
$this->assertSame('parent', $data['query']);
|
||||
}
|
||||
|
||||
public function testNotificationsAlertsRedirectsWhenUnauthorized(): void
|
||||
{
|
||||
session()->remove('is_logged_in');
|
||||
session()->remove('role');
|
||||
|
||||
$notification = $this->createMock(AdminNotificationSettingsService::class);
|
||||
$notification->method('excludedRoles')->willReturn(['parent', 'teacher']);
|
||||
Services::injectMock('adminNotificationSettings', $notification);
|
||||
|
||||
$response = $this->controller->notificationsAlerts();
|
||||
|
||||
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||
$this->assertStringContainsString('/login', $response->getHeaderLine('Location') ?: '');
|
||||
}
|
||||
|
||||
public function testNotificationsAlertsUsesNotificationServiceWhenAuthorized(): void
|
||||
{
|
||||
session()->set('is_logged_in', true);
|
||||
session()->set('role', 'administrator');
|
||||
|
||||
$notification = $this->createMock(AdminNotificationSettingsService::class);
|
||||
$notification->method('excludedRoles')->willReturn(['parent', 'teacher']);
|
||||
$notification->expects($this->once())
|
||||
->method('alertsPage')
|
||||
->willReturn([
|
||||
'admins' => [['id' => 1]],
|
||||
'subjects' => ['finance' => 'Finance'],
|
||||
'assignedSubjects' => [],
|
||||
'tableReady' => true,
|
||||
]);
|
||||
Services::injectMock('adminNotificationSettings', $notification);
|
||||
|
||||
$result = $this->controller->notificationsAlerts();
|
||||
$data = $this->viewData($result, 'administrator/notifications_alerts');
|
||||
|
||||
$this->assertTrue($data['tableReady']);
|
||||
$this->assertSame([['id' => 1]], $data['admins']);
|
||||
}
|
||||
|
||||
public function testSendTeacherSubmissionNotificationsRedirectsWithServiceFlash(): void
|
||||
{
|
||||
session()->set('user_id', 12);
|
||||
|
||||
$report = $this->createMock(TeacherSubmissionReportService::class);
|
||||
$report->expects($this->once())
|
||||
->method('sendNotifications')
|
||||
->willReturn([
|
||||
'redirect' => 'back',
|
||||
'type' => 'success',
|
||||
'message' => '1 notification sent.',
|
||||
]);
|
||||
Services::injectMock('teacherSubmissionReport', $report);
|
||||
|
||||
$this->controller->setRequest(new DummyPostRequest(['notify' => [1 => [2 => '1']]]));
|
||||
|
||||
$response = $this->controller->sendTeacherSubmissionNotifications();
|
||||
|
||||
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||
$this->assertSame('1 notification sent.', session()->getFlashdata('success'));
|
||||
}
|
||||
|
||||
public function testAdminEnrollmentWithdrawalHandlerUsesEnrollmentService(): void
|
||||
{
|
||||
session()->set('user_id', 5);
|
||||
|
||||
$enrollment = $this->createMock(EnrollmentWithdrawalService::class);
|
||||
$enrollment->expects($this->once())
|
||||
->method('updateStatuses')
|
||||
->with(
|
||||
['10' => 'enrolled'],
|
||||
'2024-2025',
|
||||
'Fall',
|
||||
5
|
||||
)
|
||||
->willReturn([
|
||||
'ok' => true,
|
||||
'message' => 'Enrollment statuses updated and notifications sent.',
|
||||
]);
|
||||
Services::injectMock('enrollmentWithdrawal', $enrollment);
|
||||
|
||||
$this->controller->setRequest(new DummyPostRequest([
|
||||
'enrollment_status' => ['10' => 'enrolled'],
|
||||
]));
|
||||
|
||||
$response = $this->controller->adminEnrollmentWithdrawalHandler();
|
||||
|
||||
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||
$this->assertStringContainsString('enroll_withdraw/enrollment_withdrawal', $response->getHeaderLine('Location') ?: '');
|
||||
$this->assertSame(
|
||||
'Enrollment statuses updated and notifications sent.',
|
||||
session()->getFlashdata('success')
|
||||
);
|
||||
}
|
||||
|
||||
public function testShowNewStudentsUsesEnrollmentService(): void
|
||||
{
|
||||
$enrollment = $this->createMock(EnrollmentWithdrawalService::class);
|
||||
$enrollment->expects($this->once())
|
||||
->method('newStudents')
|
||||
->with('2024-2025')
|
||||
->willReturn([
|
||||
'new_students' => [['id' => 1, 'new_student' => 'Yes']],
|
||||
'total_new' => 1,
|
||||
]);
|
||||
Services::injectMock('enrollmentWithdrawal', $enrollment);
|
||||
|
||||
$result = $this->controller->showNewStudents();
|
||||
$data = $this->viewData($result, 'enroll_withdraw/new-students');
|
||||
|
||||
$this->assertSame(1, $data['total_new']);
|
||||
}
|
||||
|
||||
private function viewData(mixed $result, string $expectedView): array
|
||||
{
|
||||
if (is_array($result)) {
|
||||
$this->assertSame($expectedView, $result['view']);
|
||||
return $result['data'];
|
||||
}
|
||||
|
||||
if (is_array($result)) {
|
||||
$this->assertSame('administrator/absence_vacation', $result['view']);
|
||||
$data = $result['data'];
|
||||
} else {
|
||||
$this->assertSame('fake-render', $result);
|
||||
$this->assertSame('administrator/absence_vacation', $this->renderer->lastName);
|
||||
$data = $this->renderer->lastData;
|
||||
$this->assertSame($expectedView, $this->renderer->lastName);
|
||||
return $this->renderer->lastData;
|
||||
}
|
||||
$this->assertSame('Alex Admin', $data['admin_name']);
|
||||
$this->assertSame('Fall', $data['semester']);
|
||||
$this->assertSame('2024-2025', $data['schoolYear']);
|
||||
$this->assertSame([['date' => '2024-12-01']], $data['existing']);
|
||||
$this->assertIsArray($data['availableDates']);
|
||||
}
|
||||
|
||||
public function testSubmitAbsenceRejectsMissingReason()
|
||||
{
|
||||
session()->set('user_id', 9);
|
||||
|
||||
$req = new DummyPostRequest([
|
||||
'dates' => ['2024-12-01'],
|
||||
'reason_type' => '',
|
||||
'reason' => '',
|
||||
]);
|
||||
|
||||
$this->controller->setRequest($req);
|
||||
|
||||
$response = $this->controller->submitAbsenceAdmin();
|
||||
|
||||
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||
$this->assertStringContainsString('/administrator/absence', $response->getHeaderLine('Location') ?: '');
|
||||
$this->assertSame('Reason is required.', session()->getFlashdata('message'));
|
||||
}
|
||||
|
||||
public function testSubmitAbsenceSavesAndRedirects()
|
||||
{
|
||||
session()->set('user_id', 10);
|
||||
|
||||
$userModel = $this->createMock(UserModel::class);
|
||||
$userModel->method('getUserRole')->with(10)->willReturn('administrator');
|
||||
$userModel->method('find')->with(10)->willReturn([
|
||||
'firstname' => 'Sam',
|
||||
'lastname' => 'Admin',
|
||||
'email' => 'sam@school.test',
|
||||
]);
|
||||
|
||||
$attendance = new FakeAttendanceModel();
|
||||
$this->controller->setUserModel($userModel)->setStaffAttendanceModel($attendance);
|
||||
|
||||
$method = new \ReflectionMethod($this->controller, 'allowedAbsenceDates');
|
||||
$method->setAccessible(true);
|
||||
$allowed = $method->invoke($this->controller);
|
||||
|
||||
if (empty($allowed)) {
|
||||
$this->markTestSkipped('No allowed absence dates are currently available.');
|
||||
}
|
||||
|
||||
$date = $allowed[0];
|
||||
|
||||
$req = new DummyPostRequest([
|
||||
'dates' => [$date],
|
||||
'reason_type' => 'Personal',
|
||||
'reason' => 'Conference',
|
||||
]);
|
||||
$this->controller->setRequest($req);
|
||||
|
||||
$emailService = $this->createMock(\App\Services\EmailService::class);
|
||||
$emailService->method('send')->willReturn(true);
|
||||
Services::injectMock('emailService', $emailService);
|
||||
|
||||
$response = $this->controller->submitAbsenceAdmin();
|
||||
|
||||
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||
$this->assertStringContainsString('/administrator/absence', $response->getHeaderLine('Location') ?: '');
|
||||
$this->assertSame('success', session()->getFlashdata('status'));
|
||||
$this->assertStringContainsString('saved', (string) session()->getFlashdata('message'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
namespace {
|
||||
if (!function_exists('view')) {
|
||||
function view($name, array $data = [], $options = [])
|
||||
{
|
||||
return ['view' => $name, 'data' => $data, 'options' => $options];
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('base_url')) {
|
||||
function base_url($uri = '')
|
||||
{
|
||||
return 'https://test.alrahmaisgl.org/' . ltrim((string) $uri, '/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace Tests\App\Controllers\View {
|
||||
|
||||
use App\Controllers\View\GradingController;
|
||||
use App\Services\BelowSixtyService;
|
||||
use App\Services\GradingScoreService;
|
||||
use App\Services\PlacementGradingService;
|
||||
use App\Services\StudentDecisionService;
|
||||
use CodeIgniter\HTTP\RedirectResponse;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
use Config\Services;
|
||||
|
||||
class GradingDummyRequest
|
||||
{
|
||||
public function __construct(private array $get = [], private array $post = [])
|
||||
{
|
||||
}
|
||||
|
||||
public function getGet($key = null)
|
||||
{
|
||||
if ($key === null) {
|
||||
return $this->get;
|
||||
}
|
||||
|
||||
return $this->get[$key] ?? null;
|
||||
}
|
||||
|
||||
public function getPost($key = null)
|
||||
{
|
||||
if ($key === null) {
|
||||
return $this->post;
|
||||
}
|
||||
|
||||
return $this->post[$key] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class GradingFakeJsonResponse
|
||||
{
|
||||
public mixed $json = null;
|
||||
public int $statusCode = 200;
|
||||
|
||||
public function setJSON($data): self
|
||||
{
|
||||
$this->json = $data;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setStatusCode(int $code): self
|
||||
{
|
||||
$this->statusCode = $code;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
class GradingFakeRenderer
|
||||
{
|
||||
public array $lastData = [];
|
||||
public string $lastName = '';
|
||||
|
||||
public function setData(array $data, string $type = 'raw')
|
||||
{
|
||||
$this->lastData = $data;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function render(string $name, array $options = [], bool $saveData = false)
|
||||
{
|
||||
$this->lastName = $name;
|
||||
return 'fake-render';
|
||||
}
|
||||
}
|
||||
|
||||
class TestableGradingController extends GradingController
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
// Skip ConfigurationModel lookup in the real constructor.
|
||||
}
|
||||
|
||||
public function setTerm(string $schoolYear, string $semester): self
|
||||
{
|
||||
$this->schoolYear = $schoolYear;
|
||||
$this->semester = $semester;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setRequest($request): self
|
||||
{
|
||||
$this->request = $request;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setResponse($response): self
|
||||
{
|
||||
$this->response = $response;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
class GradingControllerTest extends CIUnitTestCase
|
||||
{
|
||||
private TestableGradingController $controller;
|
||||
private GradingFakeRenderer $renderer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Services::resetSingle('session');
|
||||
session()->start();
|
||||
|
||||
$this->controller = (new TestableGradingController())
|
||||
->setTerm('2024-2025', 'Fall')
|
||||
->setRequest(new GradingDummyRequest());
|
||||
$this->renderer = new GradingFakeRenderer();
|
||||
Services::injectMock('renderer', $this->renderer);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ([
|
||||
'renderer',
|
||||
'gradingScore',
|
||||
'placementGrading',
|
||||
'belowSixty',
|
||||
'studentDecision',
|
||||
] as $service) {
|
||||
Services::resetSingle($service);
|
||||
}
|
||||
session()->destroy();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testGradingPageRendersViewFromScoreService(): void
|
||||
{
|
||||
$scoreService = $this->createMock(GradingScoreService::class);
|
||||
$scoreService->expects($this->once())->method('setTerm')->with('2024-2025', 'Fall');
|
||||
$scoreService->expects($this->once())
|
||||
->method('gradingPage')
|
||||
->willReturn([
|
||||
'kind' => 'view',
|
||||
'view' => 'grading/grading_main',
|
||||
'data' => [
|
||||
'semester' => 'Fall',
|
||||
'schoolYear' => '2024-2025',
|
||||
'grades' => [],
|
||||
],
|
||||
]);
|
||||
Services::injectMock('gradingScore', $scoreService);
|
||||
|
||||
$result = $this->controller->grading();
|
||||
$data = $this->viewData($result, 'grading/grading_main');
|
||||
|
||||
$this->assertSame('Fall', $data['semester']);
|
||||
$this->assertSame('2024-2025', $data['schoolYear']);
|
||||
}
|
||||
|
||||
public function testUpdateMapsFlashResultToRedirect(): void
|
||||
{
|
||||
$scoreService = $this->createMock(GradingScoreService::class);
|
||||
$scoreService->method('setTerm');
|
||||
$scoreService->expects($this->once())
|
||||
->method('updateScores')
|
||||
->willReturn([
|
||||
'kind' => 'flash',
|
||||
'redirect' => 'back',
|
||||
'type' => 'status',
|
||||
'message' => 'Scores updated successfully.',
|
||||
]);
|
||||
Services::injectMock('gradingScore', $scoreService);
|
||||
|
||||
$this->controller->setRequest(new GradingDummyRequest([], [
|
||||
'type' => 'homework',
|
||||
'student_id' => 1,
|
||||
]));
|
||||
|
||||
$response = $this->controller->update();
|
||||
|
||||
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||
$this->assertSame('Scores updated successfully.', session()->getFlashdata('status'));
|
||||
}
|
||||
|
||||
public function testGetScoreCommentReturnsJsonFromScoreService(): void
|
||||
{
|
||||
$payload = ['1' => ['homework' => [['score' => '10']]]];
|
||||
|
||||
$scoreService = $this->createMock(GradingScoreService::class);
|
||||
$scoreService->method('setTerm');
|
||||
$scoreService->expects($this->once())
|
||||
->method('getScoreComment')
|
||||
->willReturn($payload);
|
||||
Services::injectMock('gradingScore', $scoreService);
|
||||
|
||||
$response = new GradingFakeJsonResponse();
|
||||
$this->controller->setResponse($response);
|
||||
|
||||
$result = $this->controller->getScoreComment();
|
||||
|
||||
$this->assertSame($response, $result);
|
||||
$this->assertSame($payload, $response->json);
|
||||
}
|
||||
|
||||
public function testPlacementUsesPlacementService(): void
|
||||
{
|
||||
$placement = $this->createMock(PlacementGradingService::class);
|
||||
$placement->expects($this->once())->method('setSchoolYear')->with('2024-2025');
|
||||
$placement->expects($this->once())
|
||||
->method('placementPage')
|
||||
->willReturn([
|
||||
'kind' => 'view',
|
||||
'view' => 'grading/placement_index',
|
||||
'data' => ['batches' => []],
|
||||
]);
|
||||
Services::injectMock('placementGrading', $placement);
|
||||
|
||||
$result = $this->controller->placement();
|
||||
$data = $this->viewData($result, 'grading/placement_index');
|
||||
|
||||
$this->assertSame([], $data['batches']);
|
||||
}
|
||||
|
||||
public function testAllDecisionsUsesDecisionService(): void
|
||||
{
|
||||
$decision = $this->createMock(StudentDecisionService::class);
|
||||
$decision->method('setTerm');
|
||||
$decision->expects($this->once())
|
||||
->method('allDecisionsPage')
|
||||
->willReturn([
|
||||
'kind' => 'view',
|
||||
'view' => 'grading/all_decisions',
|
||||
'data' => ['rows' => [], 'schoolYear' => '2024-2025'],
|
||||
]);
|
||||
Services::injectMock('studentDecision', $decision);
|
||||
|
||||
$result = $this->controller->allDecisions();
|
||||
$data = $this->viewData($result, 'grading/all_decisions');
|
||||
|
||||
$this->assertSame('2024-2025', $data['schoolYear']);
|
||||
}
|
||||
|
||||
public function testRespondGradingMapsJsonKindWithStatus(): void
|
||||
{
|
||||
$belowSixty = $this->createMock(BelowSixtyService::class);
|
||||
$belowSixty->method('setTerm');
|
||||
$belowSixty->expects($this->once())
|
||||
->method('studentDecisionDetails')
|
||||
->willReturn([
|
||||
'kind' => 'json',
|
||||
'status' => 400,
|
||||
'data' => ['error' => 'Missing student or school year.'],
|
||||
]);
|
||||
Services::injectMock('belowSixty', $belowSixty);
|
||||
|
||||
$response = new GradingFakeJsonResponse();
|
||||
$this->controller->setResponse($response);
|
||||
|
||||
$result = $this->controller->studentDecisionDetails();
|
||||
|
||||
$this->assertSame($response, $result);
|
||||
$this->assertSame(400, $response->statusCode);
|
||||
$this->assertSame(['error' => 'Missing student or school year.'], $response->json);
|
||||
}
|
||||
|
||||
public function testShowTypeDelegatesRouteArgsToScoreService(): void
|
||||
{
|
||||
$scoreService = $this->createMock(GradingScoreService::class);
|
||||
$scoreService->method('setTerm');
|
||||
$scoreService->expects($this->once())
|
||||
->method('showType')
|
||||
->with('homework', '12', '34', $this->isType('array'))
|
||||
->willReturn([
|
||||
'kind' => 'view',
|
||||
'view' => 'grading/homework',
|
||||
'data' => ['type' => 'homework'],
|
||||
]);
|
||||
Services::injectMock('gradingScore', $scoreService);
|
||||
|
||||
$result = $this->controller->show('homework', '12', '34');
|
||||
$data = $this->viewData($result, 'grading/homework');
|
||||
|
||||
$this->assertSame('homework', $data['type']);
|
||||
}
|
||||
|
||||
private function viewData(mixed $result, string $expectedView): array
|
||||
{
|
||||
if (is_array($result)) {
|
||||
$this->assertSame($expectedView, $result['view']);
|
||||
return $result['data'];
|
||||
}
|
||||
|
||||
$this->assertSame('fake-render', $result);
|
||||
$this->assertSame($expectedView, $this->renderer->lastName);
|
||||
return $this->renderer->lastData;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Controllers\View;
|
||||
|
||||
use App\Controllers\View\ParentController;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
use ReflectionMethod;
|
||||
|
||||
final class ParentControllerEnrollmentContactTest extends CIUnitTestCase
|
||||
{
|
||||
public function testNormalizeEnrollmentParentContactAcceptsValidAddressAndPhone(): void
|
||||
{
|
||||
$result = $this->normalize([
|
||||
'cellphone' => '(203) 555-1212',
|
||||
'address_street' => '12 Oak Street',
|
||||
'apt' => '2B',
|
||||
'city' => 'bridgeport',
|
||||
'state' => 'ct',
|
||||
'zip' => '06604',
|
||||
]);
|
||||
|
||||
$this->assertSame([], $result['errors']);
|
||||
$this->assertSame('203-555-1212', $result['data']['cellphone']);
|
||||
$this->assertSame('12 Oak Street', $result['data']['address_street']);
|
||||
$this->assertSame('2B', $result['data']['apt']);
|
||||
$this->assertSame('Bridgeport', $result['data']['city']);
|
||||
$this->assertSame('CT', $result['data']['state']);
|
||||
$this->assertSame('06604', $result['data']['zip']);
|
||||
}
|
||||
|
||||
public function testNormalizeEnrollmentParentContactRejectsMissingPhoneAndAddress(): void
|
||||
{
|
||||
$result = $this->normalize([
|
||||
'cellphone' => '12345',
|
||||
'address_street' => '12',
|
||||
'city' => '',
|
||||
'state' => '',
|
||||
'zip' => 'abc',
|
||||
]);
|
||||
|
||||
$this->assertNotSame([], $result['errors']);
|
||||
$this->assertSame([], $result['data']);
|
||||
$this->assertStringContainsString('phone', strtolower(implode(' ', $result['errors'])));
|
||||
$this->assertStringContainsString('street address', strtolower(implode(' ', $result['errors'])));
|
||||
$this->assertStringContainsString('city', strtolower(implode(' ', $result['errors'])));
|
||||
$this->assertStringContainsString('state', strtolower(implode(' ', $result['errors'])));
|
||||
$this->assertStringContainsString('zip', strtolower(implode(' ', $result['errors'])));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $fields
|
||||
* @return array{errors: list<string>, data: array<string, string>}
|
||||
*/
|
||||
private function normalize(array $fields): array
|
||||
{
|
||||
$controller = new class extends ParentController {
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
$method = new ReflectionMethod(ParentController::class, 'normalizeEnrollmentParentContact');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke($controller, $fields);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Services;
|
||||
|
||||
use App\Services\AdministratorDashboardService;
|
||||
use App\Services\AdminNotificationSettingsService;
|
||||
use App\Services\StudentDecisionService;
|
||||
use App\Support\Enrollment\DeliberationDecision;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ExtractedAdminAndGradingServiceTest extends TestCase
|
||||
{
|
||||
public function testAdminNotificationSubjectOptionsAreStable(): void
|
||||
{
|
||||
$service = new AdminNotificationSettingsService(
|
||||
$this->createMock(BaseConnection::class),
|
||||
$this->createMock(\App\Models\AdminNotificationSubjectModel::class)
|
||||
);
|
||||
|
||||
$this->assertSame([
|
||||
'academics' => 'Academics',
|
||||
'attendance' => 'Attendance',
|
||||
'events' => 'Events',
|
||||
'finance' => 'Finance',
|
||||
'general' => 'General',
|
||||
'print_requests' => 'Print Requests',
|
||||
], $service->subjectOptions());
|
||||
}
|
||||
|
||||
public function testAdminNotificationExcludedRolesIncludeParentAndTeacher(): void
|
||||
{
|
||||
$service = new AdminNotificationSettingsService(
|
||||
$this->createMock(BaseConnection::class),
|
||||
$this->createMock(\App\Models\AdminNotificationSubjectModel::class)
|
||||
);
|
||||
|
||||
$excluded = $service->excludedRoles();
|
||||
|
||||
$this->assertContains('parent', $excluded);
|
||||
$this->assertContains('teacher', $excluded);
|
||||
$this->assertContains('teacher_assistant', $excluded);
|
||||
}
|
||||
|
||||
public function testDashboardCountUniqueEntitiesDedupesIds(): void
|
||||
{
|
||||
$service = new AdministratorDashboardService(
|
||||
$this->createMock(BaseConnection::class),
|
||||
$this->createMock(\App\Models\UserModel::class),
|
||||
$this->createMock(\App\Models\LoginActivityModel::class)
|
||||
);
|
||||
|
||||
$count = $this->invoke($service, 'countUniqueEntities', [[
|
||||
['id' => 1],
|
||||
['id' => 1],
|
||||
['user_id' => 2],
|
||||
['user_id' => 2],
|
||||
['name' => 'ignored'],
|
||||
]]);
|
||||
|
||||
$this->assertSame(2, $count);
|
||||
}
|
||||
|
||||
public function testStudentDecisionClassLabelHelpers(): void
|
||||
{
|
||||
$service = new StudentDecisionService(
|
||||
$this->createMock(BaseConnection::class),
|
||||
$this->createMock(\App\Models\ConfigurationModel::class),
|
||||
$this->createMock(\App\Models\StudentModel::class),
|
||||
$this->createMock(\App\Models\StudentClassModel::class),
|
||||
$this->createMock(\App\Models\UserModel::class)
|
||||
);
|
||||
|
||||
$this->assertSame('Class 8A', $this->invoke($service, 'classOnlyLabel', ['Class 8A']));
|
||||
$this->assertSame('Class 8', $this->invoke($service, 'classOnlyLabel', ['Class 8-A']));
|
||||
$this->assertTrue($this->invoke($service, 'isKgClass', ['KG']));
|
||||
$this->assertTrue($this->invoke($service, 'isKgClass', ['Kindergarten']));
|
||||
$this->assertFalse($this->invoke($service, 'isKgClass', ['Class 1']));
|
||||
}
|
||||
|
||||
public function testNextYearPlacementLabelUsesDeliberationDecision(): void
|
||||
{
|
||||
$service = new StudentDecisionService(
|
||||
$this->createMock(BaseConnection::class),
|
||||
$this->createMock(\App\Models\ConfigurationModel::class),
|
||||
$this->createMock(\App\Models\StudentModel::class),
|
||||
$this->createMock(\App\Models\StudentClassModel::class),
|
||||
$this->createMock(\App\Models\UserModel::class)
|
||||
);
|
||||
|
||||
$repeat = $this->invoke($service, 'nextYearPlacementLabel', [
|
||||
DeliberationDecision::REPEAT_CLASS,
|
||||
'Class 5',
|
||||
'5A',
|
||||
'2015-01-01',
|
||||
'2024-2025',
|
||||
]);
|
||||
$this->assertSame('Class 5', $repeat);
|
||||
|
||||
$passed = $this->invoke($service, 'nextYearPlacementLabel', [
|
||||
DeliberationDecision::PASSED,
|
||||
'Class 5',
|
||||
'5A',
|
||||
'2015-01-01',
|
||||
'2024-2025',
|
||||
]);
|
||||
$this->assertSame('', $passed);
|
||||
}
|
||||
|
||||
private function invoke(object $service, string $method, array $args = []): mixed
|
||||
{
|
||||
$ref = new \ReflectionMethod($service, $method);
|
||||
$ref->setAccessible(true);
|
||||
|
||||
return $ref->invokeArgs($service, $args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Services;
|
||||
|
||||
use App\Models\StudentYearStatusModel;
|
||||
use App\Services\StudentYearStatusService;
|
||||
use App\Support\SchoolYear\SchoolYearTableRegistry;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class StudentYearStatusServiceTest extends TestCase
|
||||
{
|
||||
public function testIsNewDefaultsTrueWhenNoRowExists(): void
|
||||
{
|
||||
$db = $this->createMock(BaseConnection::class);
|
||||
$db->method('tableExists')->willReturn(true);
|
||||
|
||||
$yearStatusModel = $this->getMockBuilder(StudentYearStatusModel::class)
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods(['first'])
|
||||
->addMethods(['where'])
|
||||
->getMock();
|
||||
$yearStatusModel->method('where')->willReturnSelf();
|
||||
$yearStatusModel->method('first')->willReturn(null);
|
||||
|
||||
$service = new StudentYearStatusService($db, $yearStatusModel);
|
||||
|
||||
$this->assertTrue($service->isNew(42, '2025-2026'));
|
||||
}
|
||||
|
||||
public function testIsNewReadsYearScopedFlag(): void
|
||||
{
|
||||
$db = $this->createMock(BaseConnection::class);
|
||||
$db->method('tableExists')->willReturn(true);
|
||||
|
||||
$yearStatusModel = $this->getMockBuilder(StudentYearStatusModel::class)
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods(['first'])
|
||||
->addMethods(['where'])
|
||||
->getMock();
|
||||
$yearStatusModel->method('where')->willReturnSelf();
|
||||
$yearStatusModel->method('first')->willReturn([
|
||||
'student_id' => 7,
|
||||
'school_year' => '2025-2026',
|
||||
'is_new' => 0,
|
||||
]);
|
||||
|
||||
$service = new StudentYearStatusService($db, $yearStatusModel);
|
||||
|
||||
$this->assertFalse($service->isNew(7, '2025-2026'));
|
||||
}
|
||||
|
||||
public function testAttachToStudentsAppliesYearFlags(): void
|
||||
{
|
||||
$db = $this->createMock(BaseConnection::class);
|
||||
$db->method('tableExists')->willReturn(true);
|
||||
|
||||
$yearStatusModel = $this->getMockBuilder(StudentYearStatusModel::class)
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods(['findAll'])
|
||||
->addMethods(['select', 'whereIn', 'where'])
|
||||
->getMock();
|
||||
$yearStatusModel->method('select')->willReturnSelf();
|
||||
$yearStatusModel->method('whereIn')->willReturnSelf();
|
||||
$yearStatusModel->method('where')->willReturnSelf();
|
||||
$yearStatusModel->method('findAll')->willReturn([
|
||||
['student_id' => 1, 'is_new' => 1],
|
||||
['student_id' => 2, 'is_new' => 0],
|
||||
]);
|
||||
|
||||
$service = new StudentYearStatusService($db, $yearStatusModel);
|
||||
|
||||
$students = [
|
||||
['id' => 1, 'is_new' => 0],
|
||||
['student_id' => 2, 'is_new' => 1],
|
||||
['id' => 3, 'is_new' => 0],
|
||||
];
|
||||
|
||||
$service->attachToStudents($students, '2025-2026');
|
||||
|
||||
$this->assertSame(1, $students[0]['is_new']);
|
||||
$this->assertSame(0, $students[1]['is_new']);
|
||||
$this->assertSame(1, $students[2]['is_new']);
|
||||
}
|
||||
|
||||
public function testUpsertInsertsYearRowOnly(): void
|
||||
{
|
||||
$db = $this->createMock(BaseConnection::class);
|
||||
$db->method('tableExists')->willReturn(true);
|
||||
|
||||
$yearStatusModel = $this->getMockBuilder(StudentYearStatusModel::class)
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods(['insert', 'update', 'first'])
|
||||
->addMethods(['where'])
|
||||
->getMock();
|
||||
$yearStatusModel->method('where')->willReturnSelf();
|
||||
$yearStatusModel->method('first')->willReturn(null);
|
||||
$yearStatusModel->expects($this->once())->method('insert')->with([
|
||||
'student_id' => 9,
|
||||
'school_year' => '2025-2026',
|
||||
'is_new' => 0,
|
||||
]);
|
||||
|
||||
$service = new StudentYearStatusService($db, $yearStatusModel);
|
||||
$service->upsert(9, '2025-2026', false);
|
||||
}
|
||||
|
||||
public function testRegistryIncludesStudentYearStatus(): void
|
||||
{
|
||||
$this->assertTrue(SchoolYearTableRegistry::isYearScoped('student_year_status'));
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ final class DeliberationDecisionTest extends CIUnitTestCase
|
||||
'Expelled' => DeliberationDecision::EXPELLED,
|
||||
'Withdrawn' => DeliberationDecision::WITHDRAWN,
|
||||
'Withdraw' => DeliberationDecision::WITHDRAWN,
|
||||
'Widthrwan' => DeliberationDecision::WITHDRAWN,
|
||||
'Deferred' => DeliberationDecision::DEFERRED_DECISION,
|
||||
'Deferred decision' => DeliberationDecision::DEFERRED_DECISION,
|
||||
];
|
||||
|
||||
@@ -39,7 +39,7 @@ final class EnrollmentEligibilityTest extends CIUnitTestCase
|
||||
);
|
||||
$withdrawn = EnrollmentEligibility::parentDecisionMessage(
|
||||
['firstname' => 'A', 'lastname' => 'B', 'dob' => '2010-01-01'],
|
||||
['decision' => 'Widthrwan', 'source' => 'manual'],
|
||||
['decision' => 'Withdrawn', 'source' => 'manual'],
|
||||
'2026-2027'
|
||||
);
|
||||
$deferred = EnrollmentEligibility::parentDecisionMessage(
|
||||
|
||||
Reference in New Issue
Block a user