fix is_new issue with enrollment fixes
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Failing after 1m22s

This commit is contained in:
root
2026-08-20 19:57:25 -04:00
parent 127098b87c
commit 889c037660
29 changed files with 77306 additions and 293 deletions
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.
+12
View File
@@ -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)
);
}
}
+6 -9
View File
@@ -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)
+212 -15
View File
@@ -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;
}
+11 -3
View File
@@ -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;
}
}
+4 -2
View File
@@ -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;
+36 -12
View File
@@ -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
View File
@@ -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);
}
+30
View File
@@ -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),
+5 -8
View File
@@ -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;
+39 -20
View File
@@ -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) {
+178
View File
@@ -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>
+348 -41
View File
@@ -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'])): ?> &middot; 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() ?>
+9 -11
View File
@@ -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() ?>
+1 -1
View File
@@ -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:
+20
View File
@@ -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(