From 3cbfc409340cfdd46e5e4195bc9899e30d70d665 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 15 Aug 2026 21:15:05 -0400 Subject: [PATCH] fix school year and enrollment steps --- .../SchoolYearClosingController.php | 6 +- .../ParentReportCardController.php | 87 ++++ app/Controllers/View/ParentController.php | 178 +++++++- .../View/ParentFinancialAidController.php | 5 +- .../View/ReportCardsController.php | 36 +- ...000002_AddUniqueFinancialAidParentYear.php | 61 +++ app/Listeners/SchoolEventListener.php | 23 +- app/Models/FinancialAidRequestModel.php | 8 + .../EnrollmentRegistrationEmailService.php | 47 +- app/Services/FeeCalculationService.php | 7 +- app/Views/emails/status_admission_review.php | 59 ++- app/Views/layout/main_layout.php | 28 +- app/Views/parent/enroll_classes.php | 298 ++++++++++-- app/Views/parent/financial_aid.php | 8 +- app/Views/parent/report_cards.php | 9 +- app/Views/school_years/closing_preview.php | 431 +++++++++++++++--- app/Views/school_years/index.php | 4 - ...EnrollmentRegistrationEmailServiceTest.php | 10 +- 18 files changed, 1142 insertions(+), 163 deletions(-) create mode 100644 app/Database/Migrations/2026-08-15-000002_AddUniqueFinancialAidParentYear.php diff --git a/app/Controllers/Administrator/SchoolYearClosingController.php b/app/Controllers/Administrator/SchoolYearClosingController.php index 699c415..914a13e 100644 --- a/app/Controllers/Administrator/SchoolYearClosingController.php +++ b/app/Controllers/Administrator/SchoolYearClosingController.php @@ -62,7 +62,7 @@ class SchoolYearClosingController extends BaseController } service('schoolYearClosing')->start($id, $targetId, $this->userId()); - return redirect()->to('/administrator/school-years/' . $id . '/closing/preview')->with('success', 'End-year process started.'); + return redirect()->to('/administrator/school-years/' . $id . '/closing/preview?close_flow=1')->with('success', 'End-year process started.'); } catch (Throwable $e) { return redirect()->back()->with('error', $e->getMessage()); } @@ -72,7 +72,7 @@ class SchoolYearClosingController extends BaseController { try { service('schoolYearClosing')->execute($id, $this->userId()); - return redirect()->to('/administrator/school-years/' . $id . '/closing/preview')->with('success', 'Carry-forward confirmed.'); + return redirect()->to('/administrator/school-years/' . $id . '/closing/preview?close_flow=1')->with('success', 'Carry-forward confirmed.'); } catch (Throwable $e) { return redirect()->back()->with('error', $e->getMessage()); } @@ -82,7 +82,7 @@ class SchoolYearClosingController extends BaseController { try { service('schoolYearClosing')->complete($id, $this->userId()); - return redirect()->to('/administrator/school-years')->with('success', 'School year ended and closed.'); + return redirect()->to('/administrator/school-years/' . $id . '/closing/preview?close_flow=1')->with('success', 'School year ended and closed.'); } catch (Throwable $e) { return redirect()->back()->with('error', $e->getMessage()); } diff --git a/app/Controllers/ParentReportCardController.php b/app/Controllers/ParentReportCardController.php index c1388f9..8cae5c7 100644 --- a/app/Controllers/ParentReportCardController.php +++ b/app/Controllers/ParentReportCardController.php @@ -52,6 +52,7 @@ class ParentReportCardController extends BaseController $studentIds = array_values(array_filter(array_map(static fn ($s) => (int) ($s['id'] ?? 0), $students))); $ackMap = []; + $reportAvailableMap = $this->reportAvailabilityMap($studentIds, $schoolYear, $semester); if (! empty($studentIds)) { $rows = $this->ackModel ->where('parent_id', $parentId) @@ -67,6 +68,7 @@ class ParentReportCardController extends BaseController return view('parent/report_cards', [ 'students' => $students, 'ackMap' => $ackMap, + 'reportAvailableMap' => $reportAvailableMap, 'schoolYear' => $schoolYear, 'semester' => $semester, 'isEditable' => ! $schoolYearContext->isReadonly(), @@ -89,6 +91,11 @@ class ParentReportCardController extends BaseController $schoolYear = trim($schoolYearContext->yearName()); $semester = trim((string) ($this->request->getGet('semester') ?? $this->configModel->getConfig('semester') ?? '')); + if (! $this->reportExists((int) $studentId, $schoolYear, $semester)) { + return redirect()->to(site_url('parent/report-cards')) + ->with('error', 'No report card exists for the selected school year and semester.'); + } + if (! $schoolYearContext->isReadonly()) { $this->touchAcknowledgement($parentId, (int) $studentId, $schoolYear, $semester, [ 'viewed_at' => date('Y-m-d H:i:s'), @@ -136,6 +143,12 @@ class ParentReportCardController extends BaseController $schoolYear = trim($schoolYearContext->yearName()); $semester = trim((string) ($this->configModel->getConfig('semester') ?? '')); + + if (! $this->reportExists((int) $studentId, $schoolYear, $semester)) { + return redirect()->to(site_url('parent/report-cards')) + ->with('error', 'No report card exists for the selected school year and semester.'); + } + $now = date('Y-m-d H:i:s'); $this->touchAcknowledgement($parentId, (int) $studentId, $schoolYear, $semester, [ 'viewed_at' => $now, @@ -173,6 +186,80 @@ class ParentReportCardController extends BaseController return $parentId ?: null; } + protected function reportAvailabilityMap(array $studentIds, string $schoolYear, string $semester): array + { + $studentIds = array_values(array_filter(array_map('intval', $studentIds), static fn ($id) => $id > 0)); + if (empty($studentIds) || $schoolYear === '') { + return []; + } + + $builder = $this->db->table('semester_scores') + ->select('student_id') + ->whereIn('student_id', $studentIds) + ->where('school_year', $schoolYear); + + $semesterVariants = $this->semesterVariants($semester); + if (! empty($semesterVariants)) { + $builder->whereIn('semester', $semesterVariants); + } + + $rows = $builder + ->groupBy('student_id') + ->get() + ->getResultArray(); + + $map = []; + foreach ($rows as $row) { + $sid = (int) ($row['student_id'] ?? 0); + if ($sid > 0) { + $map[$sid] = true; + } + } + + return $map; + } + + protected function reportExists(int $studentId, string $schoolYear, string $semester): bool + { + if ($studentId <= 0 || $schoolYear === '') { + return false; + } + + $builder = $this->db->table('semester_scores') + ->select('id') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->limit(1); + + $semesterVariants = $this->semesterVariants($semester); + if (! empty($semesterVariants)) { + $builder->whereIn('semester', $semesterVariants); + } + + return (bool) $builder->get()->getRowArray(); + } + + protected function semesterVariants(string $semester): array + { + $raw = trim($semester); + if ($raw === '') { + return []; + } + + $normalized = strtolower($raw); + if ($normalized === 'fall' || $normalized === 'first' || str_contains($normalized, 'fall') || str_contains($normalized, '1')) { + $values = ['Fall', 'fall', 'First', 'first', 'Semester 1', 'semester 1', 'Semester1', 'semester1', 'Sem 1', 'sem 1', '1', '01', 'S1', 's1']; + } elseif ($normalized === 'spring' || $normalized === 'second' || str_contains($normalized, 'spring') || str_contains($normalized, '2')) { + $values = ['Spring', 'spring', 'Second', 'second', 'Semester 2', 'semester 2', 'Semester2', 'semester2', 'Sem 2', 'sem 2', '2', '02', 'S2', 's2']; + } else { + $values = [$raw]; + } + + $values[] = $raw; + + return array_values(array_unique($values)); + } + protected function touchAcknowledgement( int $parentId, int $studentId, diff --git a/app/Controllers/View/ParentController.php b/app/Controllers/View/ParentController.php index 820b83f..dc35931 100644 --- a/app/Controllers/View/ParentController.php +++ b/app/Controllers/View/ParentController.php @@ -391,7 +391,8 @@ class ParentController extends BaseController 'lastDayOfRegistration' => $this->lastDayOfRegistration, 'schoolStartDate' => $this->schoolStartDate, 'hasAcceptedSchoolPolicy' => $this->hasAcceptedPolicyForYear((int) $parentId, $selectedYear), - 'familyFinancialSummary' => $this->familyFinancialSummary((int) $parentId, $previousSchoolYear, $selectedYear), + 'familyFinancialSummary' => $this->familyFinancialSummary((int) $parentId, $previousSchoolYear, $selectedYear, $students), + 'enrollmentFeeSchedule' => $this->enrollmentFeeSchedule($selectedYear), ]); } catch (Exception $e) { log_message('error', 'An error occurred in enrollClasses: ' . $e->getMessage()); @@ -462,6 +463,12 @@ class ParentController extends BaseController $submittedStudentIds = array_values(array_unique(array_filter(array_map('intval', (array) $enroll), static fn (int $id): bool => $id > 0))); $evaluations = []; $errors = []; + $submittedEnrollmentContexts = []; + + $studentInfoErrors = $this->updateEnrollmentStudentInfo($submittedStudentIds, (int) $parentId, $selectedYear); + if ($studentInfoErrors !== []) { + return redirect()->back()->withInput()->with('error', implode(' ', $studentInfoErrors)); + } foreach ($submittedStudentIds as $studentId) { try { @@ -524,8 +531,10 @@ class ParentController extends BaseController ->getRowArray(); $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'; + if ($existingEnrollment) { - $isReturningReEnrollment = $this->isReturningReEnrollmentStudent((int)$studentId, $selectedYear); $targetEnrollmentStatus = $isReturningReEnrollment ? 'payment pending' : 'admission under review'; $targetAdmissionStatus = $isReturningReEnrollment ? 'accepted' : 'pending'; @@ -568,7 +577,6 @@ class ParentController extends BaseController implode(' ', array_map('strval', $evaluation['rule_codes'] ?? [])) ); } else { - $isReturningReEnrollment = $this->isReturningReEnrollmentStudent((int)$studentId, $selectedYear); $targetEnrollmentStatus = $isReturningReEnrollment ? 'payment pending' : 'admission under review'; $targetAdmissionStatus = $isReturningReEnrollment ? 'accepted' : 'pending'; @@ -616,6 +624,7 @@ class ParentController extends BaseController if (! $this->db->transStatus()) { return redirect()->back()->withInput()->with('error', 'A database error occurred while submitting enrollment.'); } + } // $studentData now holds info for all students processed @@ -710,6 +719,8 @@ class ParentController extends BaseController return redirect()->to('/parent/enroll_classes'); } 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'); } @@ -739,6 +750,115 @@ class ParentController extends BaseController return $this->filterEnrollmentPayloadByColumns($payload); } + private function updateEnrollmentStudentInfo(array $studentIds, int $parentId, string $schoolYear): array + { + $studentInfo = $this->request->getPost('student_info'); + if (! is_array($studentInfo)) { + return []; + } + + $submittedIds = array_fill_keys(array_map('intval', $studentIds), true); + $errors = []; + $updates = []; + + foreach ($studentInfo as $studentId => $fields) { + $studentId = (int) $studentId; + if ($studentId <= 0 || ! isset($submittedIds[$studentId]) || ! is_array($fields)) { + continue; + } + + $existing = $this->studentModel + ->where('id', $studentId) + ->where('parent_id', $parentId) + ->first(); + + if (! is_array($existing)) { + $errors[] = 'Student ID ' . $studentId . ': student record was not found.'; + continue; + } + + $firstName = $this->normalizeEnrollmentStudentName((string) ($fields['firstname'] ?? '')); + $lastName = $this->normalizeEnrollmentStudentName((string) ($fields['lastname'] ?? '')); + $dob = trim((string) ($fields['dob'] ?? '')); + $gender = trim((string) ($fields['gender'] ?? '')); + $registrationGrade = trim((string) ($fields['registration_grade'] ?? '')); + $photoConsent = (string) ($fields['photo_consent'] ?? ''); + $studentLabel = trim($firstName . ' ' . $lastName) ?: 'Student ID ' . $studentId; + + try { + $this->validateNames($firstName); + $this->validateNames($lastName); + } catch (InvalidArgumentException $e) { + $errors[] = $studentLabel . ': ' . $e->getMessage(); + continue; + } + + if (! in_array($gender, ['Male', 'Female'], true)) { + $errors[] = $studentLabel . ': gender is required.'; + continue; + } + + if ($photoConsent !== '0' && $photoConsent !== '1') { + $errors[] = $studentLabel . ': photo consent is required.'; + continue; + } + + if ($registrationGrade === '' || mb_strlen($registrationGrade) > 50) { + $errors[] = $studentLabel . ': registration grade is required.'; + continue; + } + + $dobObj = \DateTimeImmutable::createFromFormat('!Y-m-d', $dob, new \DateTimeZone('UTC')); + $dobErrors = \DateTimeImmutable::getLastErrors(); + $dobWarningCount = is_array($dobErrors) ? (int) ($dobErrors['warning_count'] ?? 0) : 0; + $dobErrorCount = is_array($dobErrors) ? (int) ($dobErrors['error_count'] ?? 0) : 0; + if ($dobObj === false || $dobWarningCount > 0 || $dobErrorCount > 0) { + $errors[] = $studentLabel . ': date of birth must use YYYY-MM-DD format.'; + continue; + } + + $validation = $this->validateDobAge( + $dob, + $this->registrationMinimumAgeDeadline($schoolYear), + 5, + 18, + $this->schoolYearAgeDeadline($schoolYear) + ); + if (! $validation['isValid']) { + $errors[] = $studentLabel . ': ' . $validation['message'] . '.'; + continue; + } + + $updates[$studentId] = [ + 'firstname' => $firstName, + 'lastname' => $lastName, + 'dob' => $dobObj->format('Y-m-d'), + 'age' => $this->calculateAgeAsOfSchoolYearStartYear($dobObj->format('Y-m-d'), $schoolYear), + 'gender' => $gender, + 'registration_grade' => $registrationGrade, + 'photo_consent' => (int) $photoConsent, + ]; + } + + if ($errors !== []) { + return $errors; + } + + foreach ($updates as $studentId => $payload) { + if (! $this->studentModel->update($studentId, $payload)) { + $errors[] = 'Student ID ' . $studentId . ': student information could not be updated.'; + } + } + + return $errors; + } + + private function normalizeEnrollmentStudentName(string $name): string + { + $name = trim(preg_replace('/\s+/', ' ', $name) ?? ''); + return mb_convert_case($name, MB_CASE_TITLE, 'UTF-8'); + } + private function exceptionReasonFromEvaluation(array $evaluation): ?string { if (! empty($evaluation['admin_exception'])) { @@ -1625,16 +1745,16 @@ class ParentController extends BaseController return 'Contact administration'; } - private function familyFinancialSummary(int $parentId, ?string $previousSchoolYear, string $selectedYear): array + private function familyFinancialSummary(int $parentId, ?string $previousSchoolYear, string $selectedYear, array $students = []): array { $schoolYearConfig = $this->schoolYearConfig($selectedYear); $carryOver = $previousSchoolYear !== null ? $this->invoiceBalanceForParent($parentId, $previousSchoolYear) : 0.0; $currentBalance = $this->invoiceBalanceForParent($parentId, $selectedYear); $registrationFee = round((float) ($schoolYearConfig['registration_fee'] ?? 0), 2); - $tuitionDue = round((float) ($schoolYearConfig['tuition_due_at_registration'] ?? 0), 2); + $tuitionDue = $this->enrollmentTuitionDue($students); $mandatoryFees = round((float) ($schoolYearConfig['mandatory_fees'] ?? 0), 2); $behavior = (string) ($schoolYearConfig['carry_over_balance_behavior'] ?? 'submission_blocked_until_payment'); - $amountDue = max(0.0, $carryOver) + $registrationFee + $tuitionDue + $mandatoryFees; + $amountDue = max(0.0, $carryOver) + max(0.0, $currentBalance) + $registrationFee + $tuitionDue + $mandatoryFees; return [ 'currency' => '$', @@ -1650,6 +1770,52 @@ class ParentController extends BaseController ]; } + private function enrollmentTuitionDue(array $students): float + { + $tuitionStudents = []; + + foreach ($students as $student) { + $status = strtolower(trim((string) ($student['enrollment_status'] ?? ''))); + $eligibilityMessage = is_array($student['enrollment_eligibility_message'] ?? null) + ? $student['enrollment_eligibility_message'] + : ['blocking' => false]; + + if ($status !== 'not enrolled' || ! empty($eligibilityMessage['blocking'])) { + continue; + } + + $evaluation = is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : []; + $tuitionStudents[] = [ + 'student_id' => (int) ($student['id'] ?? $student['student_id'] ?? 0), + 'class_section_id' => (int) ( + $evaluation['assigned_class_section_id'] + ?? $student['class_section_id'] + ?? 0 + ), + ]; + } + + if ($tuitionStudents === []) { + return 0.0; + } + + return (new FeeCalculationService())->calculateEnrollmentTuition($tuitionStudents); + } + + private function enrollmentFeeSchedule(string $selectedYear): array + { + $schoolYearConfig = $this->schoolYearConfig($selectedYear); + + return [ + 'currency' => '$', + 'first_student_fee' => round((float) ($this->configModel->getConfig('first_student_fee') ?? 380), 2), + 'second_student_fee' => round((float) ($this->configModel->getConfig('second_student_fee') ?? 280), 2), + 'registration_fee' => round((float) ($schoolYearConfig['registration_fee'] ?? 0), 2), + 'tuition_due_at_registration' => round((float) ($schoolYearConfig['tuition_due_at_registration'] ?? 0), 2), + 'mandatory_fees' => round((float) ($schoolYearConfig['mandatory_fees'] ?? 0), 2), + ]; + } + private function financialPolicyMessage(string $behavior, string $configured): string { $configured = trim($configured); diff --git a/app/Controllers/View/ParentFinancialAidController.php b/app/Controllers/View/ParentFinancialAidController.php index 02ed22a..e3bb09b 100644 --- a/app/Controllers/View/ParentFinancialAidController.php +++ b/app/Controllers/View/ParentFinancialAidController.php @@ -35,6 +35,7 @@ class ParentFinancialAidController extends BaseController 'students' => $students, 'requests' => $requests, 'openRequest' => $model->openRequestForParent($parentId, $schoolYear), + 'existingRequest' => $model->requestForParentYear($parentId, $schoolYear), ]); } @@ -47,8 +48,8 @@ class ParentFinancialAidController extends BaseController $schoolYear = (string) ((new ConfigurationModel())->getConfig('school_year') ?? ''); $model = new FinancialAidRequestModel(); - if ($model->openRequestForParent($parentId, $schoolYear) !== null) { - return redirect()->back()->with('error', 'You already have an open financial aid request for this school year.'); + if ($model->requestForParentYear($parentId, $schoolYear) !== null) { + return redirect()->back()->with('error', 'You can submit only one financial aid application per school year.'); } $studentIds = array_values(array_unique(array_filter(array_map('intval', (array) $this->request->getPost('student_ids'))))); diff --git a/app/Controllers/View/ReportCardsController.php b/app/Controllers/View/ReportCardsController.php index 953788a..06bf1a7 100644 --- a/app/Controllers/View/ReportCardsController.php +++ b/app/Controllers/View/ReportCardsController.php @@ -1224,12 +1224,6 @@ $scoresEndY = $pdf->GetY(); public function generateSingleReport($studentId) { - // Prevent Debug Toolbar from corrupting PDF - if (ENVIRONMENT !== 'production') { - // Turn off toolbar manually by unsetting the collector - unset(service('toolbar')->collectors['CodeIgniter\Debug\Toolbar\Collectors\Timers']); - } - $download = $this->request->getGet('download') === '1'; $schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear; $semester = $this->request->getGet('semester') ?? $this->semester; @@ -1237,7 +1231,7 @@ $scoresEndY = $pdf->GetY(); $data = $this->prepareStudentReportData((int)$studentId, (string)$schoolYear, (string)$semester); if (!$data) { - return "Student not found or missing scores."; + return $this->missingReportResponse((int)$studentId, (string)$schoolYear, (string)$semester); } $data['report_date'] = $reportDate['iso']; $data['report_date_display'] = $reportDate['display']; @@ -1264,12 +1258,6 @@ $scoresEndY = $pdf->GetY(); public function generateClassReport($classSectionId) { - // Disable toolbar output to prevent PDF corruption - if (ENVIRONMENT !== 'production') { - unset(service('toolbar')->collectors['CodeIgniter\Debug\Toolbar\Collectors\Timers']); - } - - $download = $this->request->getGet('download') === '1'; $schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear; $semester = $this->request->getGet('semester') ?? $this->semester; @@ -1290,10 +1278,11 @@ $scoresEndY = $pdf->GetY(); ->findAll(); if (!$scores) { - return "No students found for this class section."; + return $this->missingReportResponse(null, (string)$schoolYear, (string)$semester, (int)$classSectionId); } $pdf = new \FPDF('P', 'mm', 'Letter'); + $generatedCount = 0; foreach ($scores as $score) { $studentId = $score['student_id']; @@ -1304,9 +1293,14 @@ $scoresEndY = $pdf->GetY(); $data['report_date'] = $reportDate['iso']; $data['report_date_display'] = $reportDate['display']; $this->formatReportPDF($pdf, $data); + $generatedCount++; } } + if ($generatedCount === 0) { + return $this->missingReportResponse(null, (string)$schoolYear, (string)$semester, (int)$classSectionId); + } + $filename = 'ClassReport_Section_' . $classSectionId . '.pdf'; // Clean any existing output buffer @@ -1324,6 +1318,20 @@ $scoresEndY = $pdf->GetY(); ->setBody($pdfContent); } + protected function missingReportResponse(?int $studentId, string $schoolYear, string $semester, ?int $classSectionId = null) + { + $target = $studentId !== null + ? 'student #' . $studentId + : 'class section #' . (int)$classSectionId; + $period = trim($schoolYear . ($semester !== '' ? ' ' . $semester : '')); + $message = 'No report card exists for ' . $target . ($period !== '' ? ' for ' . $period : '') . '.'; + + return $this->response + ->setStatusCode(404) + ->setHeader('Content-Type', 'text/plain; charset=UTF-8') + ->setBody($message . "\nPlease select a school year and semester that has saved scores before viewing or signing the report card."); + } + protected function normalizeSemester(?string $input): string { diff --git a/app/Database/Migrations/2026-08-15-000002_AddUniqueFinancialAidParentYear.php b/app/Database/Migrations/2026-08-15-000002_AddUniqueFinancialAidParentYear.php new file mode 100644 index 0000000..3e5989c --- /dev/null +++ b/app/Database/Migrations/2026-08-15-000002_AddUniqueFinancialAidParentYear.php @@ -0,0 +1,61 @@ +db->tableExists($this->table) || $this->indexExists()) { + return; + } + + $duplicate = $this->db->table($this->table) + ->select('parent_id, school_year, COUNT(*) AS total', false) + ->groupBy(['parent_id', 'school_year']) + ->having('total >', 1) + ->limit(1) + ->get() + ->getRowArray(); + + if ($duplicate !== null) { + log_message('warning', 'Skipped unique financial aid parent/year index because duplicate requests already exist.'); + return; + } + + $this->db->query(sprintf( + 'CREATE UNIQUE INDEX %s ON %s (parent_id, school_year)', + $this->db->escapeIdentifiers($this->index), + $this->db->escapeIdentifiers($this->table) + )); + } + + public function down() + { + if (! $this->db->tableExists($this->table) || ! $this->indexExists()) { + return; + } + + $this->db->query(sprintf( + 'DROP INDEX %s ON %s', + $this->db->escapeIdentifiers($this->index), + $this->db->escapeIdentifiers($this->table) + )); + } + + private function indexExists(): bool + { + foreach ($this->db->getIndexData($this->table) as $index) { + if (($index->name ?? '') === $this->index) { + return true; + } + } + + return false; + } +} diff --git a/app/Listeners/SchoolEventListener.php b/app/Listeners/SchoolEventListener.php index 3d10d3d..b911649 100644 --- a/app/Listeners/SchoolEventListener.php +++ b/app/Listeners/SchoolEventListener.php @@ -78,6 +78,20 @@ class SchoolEventListener public function handleAdmissionUnderReview(array $data, array $studentdata) { $parentName = trim(($data['firstname'] ?? '') . ' ' . ($data['lastname'] ?? '')); + $enrollmentContext = (string) ($data['enrollment_context'] ?? 'first_enrollment'); + $isReEnrollment = $enrollmentContext === 're_enrollment'; + $isMixedEnrollment = $enrollmentContext === 'mixed'; + $emailSubject = $isReEnrollment + ? 'Re-Enrollment Submission Received' + : ($isMixedEnrollment ? 'Enrollment Submission Received' : 'Admission Application Under Review'); + $notificationTitle = $isReEnrollment + ? 'Re-Enrollment Received' + : ($isMixedEnrollment ? 'Enrollment Submission Received' : 'Admission Under Review'); + $notificationMessage = $isReEnrollment + ? 'Your re-enrollment submission has been received. Please check your email for details.' + : ($isMixedEnrollment + ? 'Your enrollment submission has been received. Please check your email for details.' + : 'Your admission application is under review. We will notify you once it is approved.'); $studentNames = []; foreach ($studentdata as $student) { @@ -89,15 +103,16 @@ class SchoolEventListener $emailData = [ 'parentName' => $parentName, 'studentName' => $studentNameData, + 'enrollmentContext' => $enrollmentContext, 'portalLink' => base_url('/login'), - 'title' => 'Admission Application Under Review', // used by layout , optional + 'title' => $emailSubject, ]; // In-app notification (unchanged) $this->notifyUser( $data['user_id'], - 'Admission Under Review', - 'Your admission application is under review. We will notify you once it is approved.', + $notificationTitle, + $notificationMessage, ['in_app'], 'registration' ); @@ -109,7 +124,7 @@ class SchoolEventListener // Send via EmailService (sets HTML + alt body) $ok = $this->emailService->send( $data['email'], - 'Admission Application Under Review', + $emailSubject, $emailBody, 'registration' ); diff --git a/app/Models/FinancialAidRequestModel.php b/app/Models/FinancialAidRequestModel.php index ec5ea86..9557e6b 100644 --- a/app/Models/FinancialAidRequestModel.php +++ b/app/Models/FinancialAidRequestModel.php @@ -38,4 +38,12 @@ class FinancialAidRequestModel extends Model ->orderBy('id', 'DESC') ->first(); } + + public function requestForParentYear(int $parentId, string $schoolYear): ?array + { + return $this->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->orderBy('id', 'DESC') + ->first(); + } } diff --git a/app/Services/EnrollmentRegistrationEmailService.php b/app/Services/EnrollmentRegistrationEmailService.php index 0d73fde..d661ff7 100644 --- a/app/Services/EnrollmentRegistrationEmailService.php +++ b/app/Services/EnrollmentRegistrationEmailService.php @@ -8,7 +8,7 @@ use DateTimeInterface; final class EnrollmentRegistrationEmailService { - public const TEMPLATE_VERSION = 'phase5_consolidated_v1'; + public const TEMPLATE_VERSION = 'phase5_consolidated_v2'; public function __construct( private readonly BaseConnection $db, @@ -149,7 +149,7 @@ final class EnrollmentRegistrationEmailService $previousYear = $this->previousSchoolYearName($schoolYearName); $deadline = $this->dateText($schoolYear['registration_deadline_at'] ?? $schoolYear['registration_ends_on'] ?? null); $opens = $this->dateText($schoolYear['registration_opens_at'] ?? $schoolYear['registration_starts_on'] ?? null); - $students = []; + $studentRows = []; $studentIds = []; foreach ($family['students'] as $student) { @@ -159,20 +159,19 @@ final class EnrollmentRegistrationEmailService } $evaluation = $this->transitionService->evaluate($studentId, $previousYear, $schoolYearName, 'parent'); - $students[] = $this->studentSection($student, $evaluation, $opens, $deadline); + $studentRows[] = $this->studentRow($student, $evaluation, $opens, $deadline); $studentIds[] = $studentId; } $financial = $this->financialSection((int) $family['parent_user_id'], $schoolYear, $previousYear); $subject = 'Registration for ' . $schoolYearName . ' Is Now Open'; $bodyHtml = '<p>Dear ' . esc($family['name']) . ',</p>' - . '<p>We are pleased to welcome your family to the registration process for the ' . esc($schoolYearName) . ' school year.</p>' - . '<p>Registration opens on ' . esc($opens) . ' and closes on ' . esc($deadline) . '.</p>' - . '<p>Please review the information below for each of your children, including the deliberation decision, registration eligibility, expected academic placement, and any required action.</p>' - . implode('', $students) + . '<p>Registration for the ' . esc($schoolYearName) . ' school year is now open. Please review the student summary below and complete the portal steps before ' . esc($deadline) . '.</p>' + . $this->studentsTable($studentRows) . $financial - . '<p>To complete registration for each eligible student:</p>' - . '<ol><li>Sign in to the parent portal.</li><li>Review and update student information.</li><li>Upload required documents.</li><li>Review and acknowledge school policies.</li><li>Review tuition, fees, and any carry-over balance.</li><li>Submit registration before ' . esc($deadline) . '.</li></ol>' + . '<h3>Registration Steps</h3>' + . '<p>Re-enrollment opens on ' . esc($opens) . ' and closes on ' . esc($deadline) . '. Complete re-enrollment for each eligible student before the deadline to secure their enrollment for the upcoming school year.</p>' + . '<ol><li>Sign in to the parent portal.</li><li>Review student information and upload any required documents.</li><li>Acknowledge school policies.</li><li>Review tuition, fees, and any carry-over balance.</li><li>Submit Enrollment.</li></ol>' . '<p>Registration portal: <a href="' . esc(site_url('parent/enroll_classes')) . '">' . esc(site_url('parent/enroll_classes')) . '</a></p>' . '<p>For assistance, please contact the school administration.</p>' . '<p>Sincerely,<br>Al Rahma Sunday School<br>School Administration</p>'; @@ -190,7 +189,19 @@ final class EnrollmentRegistrationEmailService ]; } - private function studentSection(array $student, array $evaluation, string $opens, string $deadline): string + private function studentsTable(array $studentRows): string + { + if ($studentRows === []) { + return '<p>No eligible linked students were found for this email.</p>'; + } + + return '<table style="width:100%; border-collapse:collapse; margin:12px 0 18px; font-size:14px;">' + . '<tbody>' + . implode('', $studentRows) + . '</tbody></table>'; + } + + private function studentRow(array $student, array $evaluation, string $opens, string $deadline): string { $name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student'; $decision = DeliberationDecision::display((string) ($evaluation['deliberation_decision'] ?? '')); @@ -199,12 +210,12 @@ final class EnrollmentRegistrationEmailService $requiredAction = $this->requiredAction($evaluation, $deadline); $message = $this->decisionMessage($name, $evaluation, $opens, $deadline); - return '<h3>' . esc($name) . '</h3>' - . '<p><strong>Deliberation decision:</strong> ' . esc($decision ?: 'Pending') . '<br>' - . '<strong>Registration status:</strong> ' . esc($status) . '<br>' - . '<strong>Expected placement:</strong> ' . esc($placement) . '</p>' - . '<p>' . esc($message) . '</p>' - . '<p><strong>Required action:</strong> ' . esc($requiredAction) . '</p>'; + return '<tr>' + . '<td style="border:1px solid #ddd; padding:8px; vertical-align:top;">' . esc($name) . '</td>' + . '<td style="border:1px solid #ddd; padding:8px; vertical-align:top;">' . esc($decision ?: 'Pending') . '</td>' + . '<td style="border:1px solid #ddd; padding:8px; vertical-align:top;"><strong>' . esc($status) . '</strong><br>' . esc($placement) . '</td>' + . '<td style="border:1px solid #ddd; padding:8px; vertical-align:top;">' . esc($message) . '<br><strong>' . esc($requiredAction) . '</strong></td>' + . '</tr>'; } private function decisionMessage(string $name, array $evaluation, string $opens, string $deadline): string @@ -212,9 +223,7 @@ final class EnrollmentRegistrationEmailService if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) { $grade = $this->currentGradeText($evaluation); - return 'We are pleased to inform you that ' . $name . ' has successfully passed ' . $grade . '. ' - . 'Re-enrollment for the new school year will open on ' . $opens . '. Please make sure to re-enroll your child before ' . $deadline . ' to secure their enrollment for the upcoming school year. ' - . 'To complete the process, sign in to the parent portal, review the student’s information, submit the required documents, acknowledge the school policies, and complete any applicable payment steps.'; + return $name . ' has successfully passed ' . $grade . '.'; } if (($evaluation['blockers'] ?? []) !== []) { diff --git a/app/Services/FeeCalculationService.php b/app/Services/FeeCalculationService.php index c4ce3b4..935d2fc 100644 --- a/app/Services/FeeCalculationService.php +++ b/app/Services/FeeCalculationService.php @@ -8,6 +8,11 @@ use App\Models\ClassSectionModel; class FeeCalculationService { + public function calculateEnrollmentTuition(array $students): float + { + return round($this->calculateTotalTuitionFee($students), 2); + } + public function calculateRefund(array $students, int $parentId): float { $configModel = new ConfigurationModel(); @@ -145,7 +150,7 @@ class FeeCalculationService // ✅ Pre-fetch and assign grade/class section names before sorting foreach ($students as &$student) { - $gradeName = $classSectionModel->getClassSectionNameBySectionId($student['class_section_id']); + $gradeName = $classSectionModel->getClassSectionNameBySectionId((int) ($student['class_section_id'] ?? 0)); $student['grade'] = strtoupper(trim($gradeName)); } unset($student); // break reference diff --git a/app/Views/emails/status_admission_review.php b/app/Views/emails/status_admission_review.php index ac8814b..8656606 100644 --- a/app/Views/emails/status_admission_review.php +++ b/app/Views/emails/status_admission_review.php @@ -1,12 +1,27 @@ <?= $this->extend('layout/email_layout') ?> <?= $this->section('content') ?> -<h2 style="font-size:22px; font-weight:bold;">Admission Under Review</h2> +<?php + $enrollmentContext = (string) ($enrollmentContext ?? 'first_enrollment'); + $isReEnrollment = $enrollmentContext === 're_enrollment'; + $isMixedEnrollment = $enrollmentContext === 'mixed'; + $studentCount = is_array($studentName) ? count($studentName) : 1; + $applicationWord = $studentCount === 1 ? 'submission' : 'submissions'; +?> +<h2 style="font-size:22px; font-weight:bold;"> + <?= $isReEnrollment ? 'Re-Enrollment Received' : ($isMixedEnrollment ? 'Enrollment Submission Received' : 'Admission Under Review') ?> +</h2> <p style="font-size:16px;">Dear <?= $parentName ?>,</p> <?php if (is_array($studentName)): ?> <p style="font-size:16px;"> - Thank you for submitting the enrollment application for the following <?= count($studentName) ?> students: + <?php if ($isReEnrollment): ?> + Thank you for submitting re-enrollment for the following <?= count($studentName) ?> students: + <?php elseif ($isMixedEnrollment): ?> + Thank you for submitting enrollment updates for the following <?= count($studentName) ?> students: + <?php else: ?> + Thank you for submitting the enrollment application for the following <?= count($studentName) ?> students: + <?php endif; ?> </p> <ul style="font-size:16px;"> <?php foreach ($studentName as $student): ?> @@ -15,18 +30,40 @@ </ul> <?php else: ?> <p style="font-size:16px;"> - Thank you for submitting the enrollment application for <?= $studentName ?>. + <?php if ($isReEnrollment): ?> + Thank you for submitting re-enrollment for <?= $studentName ?>. + <?php elseif ($isMixedEnrollment): ?> + Thank you for submitting the enrollment update for <?= $studentName ?>. + <?php else: ?> + Thank you for submitting the enrollment application for <?= $studentName ?>. + <?php endif; ?> </p> <?php endif; ?> +<?php if ($isReEnrollment): ?> + <p style="font-size:16px;"> + Your re-enrollment <?= $applicationWord ?> <?= $studentCount === 1 ? 'has' : 'have' ?> been received. The <?= $studentCount === 1 ? 'student is' : 'students are' ?> approved to continue, and the next step is to complete any required payment or follow-up items shown in the parent portal. + </p> + <p style="font-size:16px;"> + You will be notified if any additional information is needed. + </p> +<?php elseif ($isMixedEnrollment): ?> + <p style="font-size:16px;"> + Your enrollment <?= $applicationWord ?> <?= $studentCount === 1 ? 'is' : 'are' ?> being processed. New student applications may require admissions review, while returning student re-enrollment may move directly to the next required portal step. + </p> + <p style="font-size:16px;"> + You will be notified immediately once an update is available. + </p> +<?php else: ?> + <p style="font-size:16px;"> + Your application<?= is_array($studentName) ? 's are' : ' is' ?> currently under review by our admissions committee. This process typically takes 1–2 business days. + </p> + <p style="font-size:16px;"> + You will be notified immediately once a decision has been made. + </p> +<?php endif; ?> <p style="font-size:16px;"> - Your application<?= is_array($studentName) ? 's are' : ' is' ?> currently under review by our admissions committee. This process typically takes 1–2 business days. -</p> -<p style="font-size:16px;"> - You will be notified immediately once a decision has been made. -</p> -<p style="font-size:16px;"> - You can check your application status at any time through the parent portal. + You can check your <?= $isReEnrollment ? 're-enrollment' : 'application' ?> status at any time through the parent portal. </p> <p> <!-- make sure helper('url') is loaded --> @@ -42,4 +79,4 @@ Best regards,<br>Admissions Team </p> -<?= $this->endSection() ?> \ No newline at end of file +<?= $this->endSection() ?> diff --git a/app/Views/layout/main_layout.php b/app/Views/layout/main_layout.php index 6b33784..6013a0f 100644 --- a/app/Views/layout/main_layout.php +++ b/app/Views/layout/main_layout.php @@ -111,9 +111,31 @@ .custom-navbar .dropdown-item:hover { color: var(--app-primary) !important; } .custom-navbar .dropdown-menu { z-index: 2050 !important; } - /* Improve toggler contrast on dark menus */ - body[data-app-menu-mode="dark"] .custom-navbar .navbar-toggler { border-color: var(--mgmt-menu-text); } - body[data-app-menu-mode="dark"] .custom-navbar .navbar-toggler-icon { filter: invert(1) brightness(2); } + .custom-navbar .navbar-toggler { + background: #fff; + border: 1px solid rgba(15, 23, 42, 0.18); + border-radius: 0.5rem; + padding: 0.35rem 0.55rem; + } + .custom-navbar .navbar-toggler-icon { + position: relative; + width: 1.65rem; + height: 1.35rem; + background-image: none !important; + filter: none !important; + } + .custom-navbar .navbar-toggler-icon::before { + content: ""; + position: absolute; + left: 0.1rem; + right: 0.1rem; + top: 50%; + height: 0.18rem; + border-radius: 999px; + background: #0f172a; + transform: translateY(-50%); + box-shadow: 0 -0.45rem 0 #0f172a, 0 0.45rem 0 #0f172a; + } </style> <!-- Calendar JS (optional early load) --> diff --git a/app/Views/parent/enroll_classes.php b/app/Views/parent/enroll_classes.php index 29018b0..71cdb85 100644 --- a/app/Views/parent/enroll_classes.php +++ b/app/Views/parent/enroll_classes.php @@ -11,7 +11,7 @@ .enrollment-stepper { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(4, 1fr); gap: .5rem; } @@ -162,6 +162,7 @@ $nowObj = new DateTime('now', new DateTimeZone($tz)); $deadlinePassed = $nowObj > $deadlineObj; $hasAcceptedSchoolPolicy = (bool) ($hasAcceptedSchoolPolicy ?? false); $familyFinancialSummary = is_array($familyFinancialSummary ?? null) ? $familyFinancialSummary : []; +$enrollmentFeeSchedule = is_array($enrollmentFeeSchedule ?? null) ? $enrollmentFeeSchedule : []; $money = static function ($amount) use ($familyFinancialSummary): string { $currency = (string) ($familyFinancialSummary['currency'] ?? '$'); return $currency . number_format((float) $amount, 2); @@ -220,12 +221,12 @@ foreach (($students ?? []) as $student) { <div class="border rounded p-3 mb-3 bg-light"> <div class="fw-semibold mb-2">Family Account Information</div> <div class="row g-2"> - <div class="col-md-4"><span class="text-muted">Previous-year carry-over balance:</span> <strong><?= esc($money($familyFinancialSummary['carry_over_balance'] ?? 0)) ?></strong></div> - <div class="col-md-4"><span class="text-muted">Registration fee:</span> <strong><?= esc($money($familyFinancialSummary['registration_fee'] ?? 0)) ?></strong></div> - <div class="col-md-4"><span class="text-muted">Tuition due now:</span> <strong><?= esc($money($familyFinancialSummary['tuition_due_at_registration'] ?? 0)) ?></strong></div> - <div class="col-md-4"><span class="text-muted">Mandatory fees:</span> <strong><?= esc($money($familyFinancialSummary['mandatory_fees'] ?? 0)) ?></strong></div> - <div class="col-md-4"><span class="text-muted">Current-year account balance:</span> <strong><?= esc($money($familyFinancialSummary['current_balance'] ?? 0)) ?></strong></div> - <div class="col-md-4"><span class="text-muted">Total currently due:</span> <strong><?= esc($money($familyFinancialSummary['amount_due'] ?? 0)) ?></strong></div> + <div class="col-md-4"><span class="text-muted">Previous-year carry-over balance:</span> <strong data-financial-value="carry_over_balance"><?= esc($money($familyFinancialSummary['carry_over_balance'] ?? 0)) ?></strong></div> + <div class="col-md-4"><span class="text-muted">Registration fee:</span> <strong data-financial-value="registration_fee"><?= esc($money($familyFinancialSummary['registration_fee'] ?? 0)) ?></strong></div> + <div class="col-md-4"><span class="text-muted">Tuition due now:</span> <strong data-financial-value="tuition_due_at_registration"><?= esc($money($familyFinancialSummary['tuition_due_at_registration'] ?? 0)) ?></strong></div> + <div class="col-md-4"><span class="text-muted">Mandatory fees:</span> <strong data-financial-value="mandatory_fees"><?= esc($money($familyFinancialSummary['mandatory_fees'] ?? 0)) ?></strong></div> + <div class="col-md-4"><span class="text-muted">Current-year account balance:</span> <strong data-financial-value="current_balance"><?= esc($money($familyFinancialSummary['current_balance'] ?? 0)) ?></strong></div> + <div class="col-md-4"><span class="text-muted">Total currently due:</span> <strong data-financial-value="amount_due"><?= esc($money($familyFinancialSummary['amount_due'] ?? 0)) ?></strong></div> </div> <?php if (!empty($familyFinancialSummary['policy_message'])): ?> <div class="small text-muted mt-2"><?= esc($familyFinancialSummary['policy_message']) ?></div> @@ -244,7 +245,7 @@ foreach (($students ?? []) as $student) { <div class="col-lg"> <div class="fw-semibold">Enrollment process</div> <div class="text-muted small"> - Select students, review policy information, then submit enrollment. + Review student information, acknowledge policies, review tuition and balances, then submit enrollment. </div> </div> <div class="col-lg-auto"> @@ -322,19 +323,20 @@ foreach (($students ?? []) as $student) { <div class="modal-dialog modal-xl modal-dialog-scrollable modal-fullscreen-sm-down"> <div class="modal-content"> <div class="modal-header bg-success text-white"> - <h5 class="modal-title" id="enrollmentFlowModalLabel">Enroll your childs</h5> + <h5 class="modal-title" id="enrollmentFlowModalLabel">Submit Enrollment</h5> <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button> </div> <div class="modal-body"> <div class="enrollment-stepper mb-3" aria-label="Enrollment steps"> - <div class="enrollment-step is-active" data-step-indicator="0">Students</div> - <div class="enrollment-step" data-step-indicator="1">Policy</div> - <div class="enrollment-step" data-step-indicator="2">Submit</div> + <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> <div data-step-panel="0"> - <h6 class="fw-semibold">Select student names</h6> - <div class="text-muted small mb-3">Tap each student you want to enroll for <?= esc($selectedYear ?? 'the selected school year') ?>.</div> + <h6 class="fw-semibold">Review student information</h6> + <div class="text-muted small mb-3">Select each student you want to enroll for <?= esc($selectedYear ?? 'the selected school year') ?> and update their information if needed.</div> <div class="d-grid gap-2"> <?php foreach ($students as $student): ?> <?php @@ -353,6 +355,10 @@ foreach (($students ?? []) as $student) { <div class="student-select-card <?= $canEnroll ? '' : 'is-disabled' ?>" data-student-card data-student-id="<?= esc($student['id']) ?>" + data-school-id="<?= esc($student['school_id'] ?? 'N/A') ?>" + 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-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> @@ -361,7 +367,55 @@ foreach (($students ?? []) as $student) { <div class="fw-semibold"><?= esc($studentName) ?></div> <?= $statusBadge($student['enrollment_status'] ?? '') ?> </div> - <div class="small text-muted">Grade: <?= esc($gradeLabel) ?> · School ID: <?= esc($student['school_id'] ?? 'N/A') ?></div> + <div class="small text-muted">Current grade: <?= esc($gradeLabel) ?> · School ID: <?= esc($student['school_id'] ?? 'N/A') ?></div> + <?php if ($canEnroll): ?> + <div class="row g-2 mt-2" data-student-edit-fields> + <div class="col-md-6"> + <label class="form-label small fw-semibold" for="student-firstname-<?= esc($student['id']) ?>">First name</label> + <input class="form-control form-control-sm" type="text" id="student-firstname-<?= esc($student['id']) ?>" name="student_info[<?= esc($student['id']) ?>][firstname]" value="<?= esc($student['firstname'] ?? '') ?>" required maxlength="30" disabled data-student-edit-input> + </div> + <div class="col-md-6"> + <label class="form-label small fw-semibold" for="student-lastname-<?= esc($student['id']) ?>">Last name</label> + <input class="form-control form-control-sm" type="text" id="student-lastname-<?= esc($student['id']) ?>" name="student_info[<?= esc($student['id']) ?>][lastname]" value="<?= esc($student['lastname'] ?? '') ?>" required maxlength="30" disabled data-student-edit-input> + </div> + <div class="col-md-4"> + <label class="form-label small fw-semibold" for="student-dob-<?= esc($student['id']) ?>">Date of birth</label> + <input class="form-control form-control-sm" type="date" id="student-dob-<?= esc($student['id']) ?>" name="student_info[<?= esc($student['id']) ?>][dob]" value="<?= esc($student['dob'] ?? '') ?>" required disabled data-student-edit-input> + </div> + <div class="col-md-4"> + <label class="form-label small fw-semibold" for="student-gender-<?= esc($student['id']) ?>">Gender</label> + <select class="form-select form-select-sm" id="student-gender-<?= esc($student['id']) ?>" name="student_info[<?= esc($student['id']) ?>][gender]" required disabled data-student-edit-input> + <option value="">Select</option> + <option value="Male" <?= ($student['gender'] ?? '') === 'Male' ? 'selected' : '' ?>>Male</option> + <option value="Female" <?= ($student['gender'] ?? '') === 'Female' ? 'selected' : '' ?>>Female</option> + </select> + </div> + <div class="col-md-4"> + <label class="form-label small fw-semibold" for="student-grade-<?= esc($student['id']) ?>">Registration grade</label> + <input class="form-control form-control-sm" type="text" id="student-grade-<?= esc($student['id']) ?>" name="student_info[<?= esc($student['id']) ?>][registration_grade]" value="<?= esc($student['registration_grade'] ?? '') ?>" required maxlength="50" disabled data-student-edit-input> + </div> + <div class="col-md-6"> + <label class="form-label small fw-semibold" for="student-photo-consent-<?= esc($student['id']) ?>">Photo consent</label> + <select class="form-select form-select-sm" id="student-photo-consent-<?= esc($student['id']) ?>" name="student_info[<?= esc($student['id']) ?>][photo_consent]" required disabled data-student-edit-input> + <option value="">Select</option> + <option value="1" <?= (string) ($student['photo_consent'] ?? '') === '1' ? 'selected' : '' ?>>Yes</option> + <option value="0" <?= (string) ($student['photo_consent'] ?? '') === '0' ? 'selected' : '' ?>>No</option> + </select> + </div> + <div class="col-md-6"> + <label class="form-label small fw-semibold">Expected placement</label> + <div class="form-control form-control-sm bg-light"><?= esc($student['expected_placement_label'] ?? $gradeLabel) ?></div> + </div> + </div> + <?php else: ?> + <div class="small text-muted"> + Date of birth: <?= esc(!empty($student['dob']) ? local_date($student['dob'], 'm-d-Y') : 'N/A') ?> + <?php if (isset($student['age'])): ?> + · Age: <?= esc($student['age']) ?> + <?php endif; ?> + </div> + <div class="small text-muted">Expected placement: <?= esc($student['expected_placement_label'] ?? $gradeLabel) ?></div> + <?php endif; ?> <div class="small">Required action: <?= esc($student['required_action_label'] ?? 'Contact administration') ?></div> <?php if (($eligibilityMessage['message'] ?? '') !== ''): ?> <div class="alert alert-<?= esc($eligibilityMessage['level'] ?? 'info') ?> py-2 px-3 mt-2 mb-0 small"> @@ -379,7 +433,7 @@ foreach (($students ?? []) as $student) { </div> <div class="d-none" data-step-panel="1"> - <h6 class="fw-semibold">Policy info update</h6> + <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> <div class="form-check mt-3"> @@ -391,11 +445,76 @@ foreach (($students ?? []) as $student) { </div> <div class="d-none" data-step-panel="2"> - <h6 class="fw-semibold">Enroll submit</h6> - <div class="text-muted small mb-3">Review the selected students, then submit enrollment.</div> - <div id="enrollmentReviewList" class="d-grid gap-2"></div> - <div class="alert alert-warning mt-3 mb-0 small"> - Submitting sends the selected enrollment(s) to admission review. + <h6 class="fw-semibold">Review tuition, fees, and balance</h6> + <div class="text-muted small mb-3">Review the family account information before submitting enrollment.</div> + <?php if ($familyFinancialSummary !== []): ?> + <div class="border rounded p-3 bg-light"> + <div class="row g-2"> + <div class="col-md-4"><span class="text-muted">Previous-year carry-over balance:</span> <strong data-financial-value="carry_over_balance"><?= esc($money($familyFinancialSummary['carry_over_balance'] ?? 0)) ?></strong></div> + <div class="col-md-4"><span class="text-muted">Registration fee:</span> <strong data-financial-value="registration_fee"><?= esc($money($familyFinancialSummary['registration_fee'] ?? 0)) ?></strong></div> + <div class="col-md-4"><span class="text-muted">Tuition due now:</span> <strong data-financial-value="tuition_due_at_registration"><?= esc($money($familyFinancialSummary['tuition_due_at_registration'] ?? 0)) ?></strong></div> + <div class="col-md-4"><span class="text-muted">Mandatory fees:</span> <strong data-financial-value="mandatory_fees"><?= esc($money($familyFinancialSummary['mandatory_fees'] ?? 0)) ?></strong></div> + <div class="col-md-4"><span class="text-muted">Current-year account balance:</span> <strong data-financial-value="current_balance"><?= esc($money($familyFinancialSummary['current_balance'] ?? 0)) ?></strong></div> + <div class="col-md-4"><span class="text-muted">Total currently due:</span> <strong data-financial-value="amount_due"><?= esc($money($familyFinancialSummary['amount_due'] ?? 0)) ?></strong></div> + </div> + <?php if (!empty($familyFinancialSummary['policy_message'])): ?> + <div class="small text-muted mt-2"><?= esc($familyFinancialSummary['policy_message']) ?></div> + <?php endif; ?> + <div class="small mt-2"><a href="<?= site_url('parent/financial-aid') ?>">Request financial aid</a></div> + </div> + <?php else: ?> + <div class="alert alert-warning mb-0">Family account information is not available. Please contact the administration if you have questions about tuition or balance.</div> + <?php endif; ?> + </div> + + <div class="d-none" data-step-panel="3"> + <h6 class="fw-semibold">Submit enrollment</h6> + <div class="text-muted small mb-3">Review the enrollment summary before submitting.</div> + + <div class="mb-3"> + <div class="fw-semibold mb-2">Fees by student</div> + <div id="enrollmentFeeReviewList" class="d-grid gap-2"></div> + </div> + + <div class="row g-3 mb-3"> + <div class="col-md-6"> + <div class="border rounded p-3 h-100"> + <div class="fw-semibold mb-2">School dates</div> + <div class="small"><span class="text-muted">First school day:</span> <strong><?= esc(!empty($schoolStartDate) ? local_date($schoolStartDate, 'm-d-Y') : 'TBD') ?></strong></div> + <div class="small"><span class="text-muted">Make-up exam date:</span> <strong><?= esc(!empty($fallMakeupExamOn) ? local_date($fallMakeupExamOn, 'm-d-Y') : 'No make-up exam date currently listed') ?></strong></div> + <div class="small"><span class="text-muted">Enrollment deadline:</span> <strong><?= esc(local_date($lastDayOfRegistration, 'm-d-Y')) ?></strong></div> + </div> + </div> + <div class="col-md-6"> + <div class="border rounded p-3 h-100"> + <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> + <?php endif; ?> + </div> + </div> + </div> + + <div class="alert alert-warning mb-0 small"> + <div class="fw-semibold mb-1">Important information</div> + <ul class="mb-0 ps-3"> + <li>Submitting sends the selected enrollment(s) to admission review.</li> + <li>Payments are processed on the first day of school.</li> + <?php if (!empty($familyFinancialSummary['policy_message'])): ?> + <li><?= esc($familyFinancialSummary['policy_message']) ?></li> + <?php endif; ?> + <?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> + <?php endif; ?> + <li>Contact administration if any student information, fee, or placement detail looks incorrect.</li> + </ul> </div> </div> </div> @@ -447,7 +566,24 @@ foreach (($students ?? []) as $student) { const backButton = document.getElementById('enrollmentBackButton'); const nextButton = document.getElementById('enrollmentNextButton'); const submitButton = document.getElementById('enrollmentSubmitButton'); - const reviewList = document.getElementById('enrollmentReviewList'); + const feeReviewList = document.getElementById('enrollmentFeeReviewList'); + const finalStep = 3; + const feeSchedule = <?= json_encode([ + 'currency' => (string) ($enrollmentFeeSchedule['currency'] ?? '$'), + 'firstStudentFee' => (float) ($enrollmentFeeSchedule['first_student_fee'] ?? 0), + 'secondStudentFee' => (float) ($enrollmentFeeSchedule['second_student_fee'] ?? 0), + 'registrationFee' => (float) ($enrollmentFeeSchedule['registration_fee'] ?? 0), + 'tuitionDueAtRegistration' => (float) ($enrollmentFeeSchedule['tuition_due_at_registration'] ?? 0), + 'mandatoryFees' => (float) ($enrollmentFeeSchedule['mandatory_fees'] ?? 0), + ], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>; + const familyFinancial = <?= json_encode([ + 'carryOverBalance' => (float) ($familyFinancialSummary['carry_over_balance'] ?? 0), + 'registrationFee' => (float) ($familyFinancialSummary['registration_fee'] ?? 0), + 'tuitionDueAtRegistration' => (float) ($familyFinancialSummary['tuition_due_at_registration'] ?? 0), + 'mandatoryFees' => (float) ($familyFinancialSummary['mandatory_fees'] ?? 0), + 'currentBalance' => (float) ($familyFinancialSummary['current_balance'] ?? 0), + 'amountDue' => (float) ($familyFinancialSummary['amount_due'] ?? 0), + ], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>; let currentStep = 0; let hasAcceptedSchoolPolicy = <?= $hasAcceptedSchoolPolicy ? 'true' : 'false' ?>; @@ -455,7 +591,7 @@ foreach (($students ?? []) as $student) { const selectedWithdrawInputs = () => form ? Array.from(form.querySelectorAll("input[name='withdraw[]']:checked")) : []; function setStep(step) { - currentStep = Math.max(0, Math.min(2, step)); + currentStep = Math.max(0, Math.min(finalStep, step)); document.querySelectorAll('[data-step-panel]').forEach(panel => { panel.classList.toggle('d-none', Number(panel.dataset.stepPanel) !== currentStep); }); @@ -463,21 +599,100 @@ foreach (($students ?? []) as $student) { indicator.classList.toggle('is-active', Number(indicator.dataset.stepIndicator) === currentStep); }); backButton.classList.toggle('d-none', currentStep === 0); - nextButton.classList.toggle('d-none', currentStep === 2); - submitButton.classList.toggle('d-none', currentStep !== 2); - if (currentStep === 2) { + nextButton.classList.toggle('d-none', currentStep === finalStep); + submitButton.classList.toggle('d-none', currentStep !== finalStep); + if (currentStep >= 2) { + updateFinancialReview(); + } + if (currentStep === finalStep) { renderReview(); } } + function calculateSelectedTuition() { + return selectedEnrollInputs().reduce((total, input, index) => { + return total + (index === 0 ? Number(feeSchedule.firstStudentFee || 0) : Number(feeSchedule.secondStudentFee || 0)); + }, 0); + } + + function updateFinancialReview() { + const selectedCount = selectedEnrollInputs().length; + const tuitionDue = selectedCount > 0 ? calculateSelectedTuition() : 0; + const carryOver = Number(familyFinancial.carryOverBalance || 0); + const registrationFee = Number(familyFinancial.registrationFee || feeSchedule.registrationFee || 0); + const mandatoryFees = Number(familyFinancial.mandatoryFees || feeSchedule.mandatoryFees || 0); + const currentBalance = Number(familyFinancial.currentBalance || 0); + const amountDue = Math.max(0, carryOver) + Math.max(0, currentBalance) + registrationFee + tuitionDue + mandatoryFees; + const values = { + carry_over_balance: carryOver, + registration_fee: registrationFee, + tuition_due_at_registration: tuitionDue, + mandatory_fees: mandatoryFees, + current_balance: currentBalance, + amount_due: amountDue, + }; + + Object.keys(values).forEach(key => { + document.querySelectorAll('[data-financial-value="' + key + '"]').forEach(node => { + node.textContent = formatMoney(values[key]); + }); + }); + } + function renderReview() { + updateFinancialReview(); const cards = selectedEnrollInputs().map(input => { const card = input.closest('[data-student-card]'); - const name = card ? card.querySelector('.fw-semibold')?.textContent?.trim() : 'Student'; - const meta = card ? card.querySelector('.small.text-muted')?.textContent?.trim() : ''; - return '<div class="border rounded p-3"><div class="fw-semibold">' + escapeHtml(name || 'Student') + '</div><div class="small text-muted">' + escapeHtml(meta || '') + '</div></div>'; + const firstName = card ? card.querySelector("input[name$='[firstname]']")?.value?.trim() : ''; + 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 schoolId = card?.dataset.schoolId || 'N/A'; + const currentGrade = card?.dataset.currentGrade || ''; + const expectedPlacement = card?.dataset.expectedPlacement || ''; + const requiredAction = card?.dataset.requiredAction || ''; + 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(' - '), + currentGrade, + expectedPlacement, + requiredAction, + }; }); - reviewList.innerHTML = cards.length ? cards.join('') : '<div class="alert alert-warning mb-0">No students selected.</div>'; + + if (!feeReviewList) { + return; + } + + if (!cards.length) { + feeReviewList.innerHTML = '<div class="alert alert-warning mb-0">No students selected.</div>'; + 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>' + + '</div>' + + '</div>'; + }).join(''); } function escapeHtml(value) { @@ -486,6 +701,11 @@ foreach (($students ?? []) as $student) { }); } + function formatMoney(amount) { + const numericAmount = Number(amount || 0); + return String(feeSchedule.currency || '$') + numericAmount.toFixed(2); + } + function syncPolicyAccepted() { hasAcceptedSchoolPolicy = !!(policyAcceptedCheckbox && policyAcceptedCheckbox.checked); if (policyAcceptedInput) { @@ -493,6 +713,12 @@ foreach (($students ?? []) as $student) { } } + function setStudentFieldsEnabled(card, enabled) { + card.querySelectorAll('[data-student-edit-input]').forEach(field => { + field.disabled = !enabled; + }); + } + if (startButton && flowModal) { startButton.addEventListener('click', function() { if (deadlinePassed) { @@ -505,7 +731,10 @@ foreach (($students ?? []) as $student) { } document.querySelectorAll('[data-student-card]').forEach(card => { - card.addEventListener('click', function() { + card.addEventListener('click', function(event) { + if (event.target.closest('input, select, textarea, label')) { + return; + } if (this.dataset.selectable !== '1') { return; } @@ -515,6 +744,7 @@ foreach (($students ?? []) as $student) { } input.checked = !input.checked; this.classList.toggle('is-selected', input.checked); + setStudentFieldsEnabled(this, input.checked); }); }); @@ -550,7 +780,11 @@ foreach (($students ?? []) as $student) { if (e.submitter && e.submitter.id === 'withdrawSubmitButton') { selectedEnrollInputs().forEach(input => { input.checked = false; - input.closest('[data-student-card]')?.classList.remove('is-selected'); + const card = input.closest('[data-student-card]'); + card?.classList.remove('is-selected'); + if (card) { + setStudentFieldsEnabled(card, false); + } }); } diff --git a/app/Views/parent/financial_aid.php b/app/Views/parent/financial_aid.php index 55f2f92..4650198 100644 --- a/app/Views/parent/financial_aid.php +++ b/app/Views/parent/financial_aid.php @@ -58,7 +58,7 @@ </div> <?php endif; ?> - <?php if (empty($openRequest)): ?> + <?php if (empty($existingRequest)): ?> <form method="post" action="<?= site_url('parent/financial-aid') ?>" class="border rounded p-3 bg-light"> <?= csrf_field() ?> <div class="mb-3"> @@ -87,7 +87,11 @@ <button class="btn btn-success" type="submit">Submit request</button> </form> <?php else: ?> - <div class="alert alert-info">You already have an open request. Administration will update it after review. Remember to bring recent pay stubs and last year’s tax return to the school office.</div> + <div class="alert alert-info"> + You already submitted a financial aid application for <?= esc($schoolYear ?: 'this school year') ?>. + Parents can submit only one application per school year. Administration will update your request after review. + Remember to bring recent pay stubs and last year’s tax return to the school office. + </div> <?php endif; ?> </div> <?= $this->endSection() ?> diff --git a/app/Views/parent/report_cards.php b/app/Views/parent/report_cards.php index 8e8ff48..bd78317 100644 --- a/app/Views/parent/report_cards.php +++ b/app/Views/parent/report_cards.php @@ -48,6 +48,7 @@ $viewedAt = $ack['viewed_at'] ?? ''; $signedAt = $ack['signed_at'] ?? ''; $signedName = $ack['signed_name'] ?? ''; + $hasReport = !empty(($reportAvailableMap ?? [])[$sid]); ?> <tr> <td><?= esc(trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? ''))) ?></td> @@ -62,8 +63,12 @@ <?php endif; ?> </td> <td class="text-end"> - <a class="btn btn-sm btn-outline-primary" target="_blank" href="<?= base_url('parent/report-cards/view/' . $sid) ?>">View Report</a> - <?php if (! $signedAt): ?> + <?php if ($hasReport): ?> + <a class="btn btn-sm btn-outline-primary" target="_blank" href="<?= base_url('parent/report-cards/view/' . $sid) ?>">View Report</a> + <?php else: ?> + <button class="btn btn-sm btn-outline-secondary" type="button" disabled>No report available</button> + <?php endif; ?> + <?php if ($hasReport && ! $signedAt): ?> <form class="d-inline-flex align-items-center gap-2 ms-2" method="post" action="<?= base_url('parent/report-cards/sign/' . $sid) ?>"> <?= csrf_field() ?> <input type="text" name="signed_name" class="form-control form-control-sm" placeholder="Full name" required<?= $disabledAttr ?>> diff --git a/app/Views/school_years/closing_preview.php b/app/Views/school_years/closing_preview.php index b2b4346..f59ddc8 100644 --- a/app/Views/school_years/closing_preview.php +++ b/app/Views/school_years/closing_preview.php @@ -13,11 +13,51 @@ $blockers = $preview['blockers'] ?? []; $warnings = $preview['warnings'] ?? []; $carryForward = $preview['carry_forward'] ?? []; + $carryForwardTotal = array_reduce( + $carryForward, + static fn (float $total, array $row): float => $total + (float) ($row['carry_forward_amount'] ?? 0), + 0.0 + ); $status = (string) ($source['status'] ?? ''); $batchStatus = (string) ($latestBatch['status'] ?? ''); $missingCarryForwardInvoices = (int) ($missingCarryForwardInvoices ?? 0); $money = static fn ($value): string => '$' . number_format((float) $value, 2); $score = static fn ($value): string => is_numeric($value) ? number_format((float) $value, 2) : '-'; + $sourceId = (int) ($source['id'] ?? 0); + $targetId = (int) ($target['id'] ?? 0); + $flowStep = 1; + if ($status === 'closing' && $batchStatus === 'started') { + $flowStep = 3; + } elseif ($status === 'closing' && $batchStatus === 'executed') { + $flowStep = 4; + } elseif ($status === 'closed' || $batchStatus === 'completed') { + $flowStep = 5; + } elseif ($status === 'active') { + $flowStep = $blockers === [] && $target ? 2 : 1; + } + $initialModalStep = $flowStep === 2 ? 1 : $flowStep; + $flowSteps = [ + 1 => [ + 'title' => 'Review checklist', + 'description' => 'Confirm promotion decisions, blocking issues, warnings, and financial totals before the year is locked.', + ], + 2 => [ + 'title' => 'Start close', + 'description' => 'Lock the current year into end-year review and connect it to the selected next school year.', + ], + 3 => [ + 'title' => 'Carry forward', + 'description' => 'Create or update target-year balances from the family balances shown in this checklist.', + ], + 4 => [ + 'title' => 'Finish close', + 'description' => 'Mark the current school year closed and activate the next school year.', + ], + 5 => [ + 'title' => 'Closed', + 'description' => 'The end-year process is complete. The closed year remains available for reporting and audit review.', + ], + ]; $ageBySchoolYearSecondSeptember = static function (array $row) use ($source): string { $dob = trim((string) ($row['dob'] ?? '')); $schoolYear = (string) ($source['name'] ?? ''); @@ -94,16 +134,12 @@ <span>Generated: <?= esc($preview['generated_at'] ?? '') ?></span> </div> </div> - <a class="btn btn-outline-secondary" href="<?= site_url('administrator/school-years') ?>">Back to School Years</a> - </div> - - <div class="border rounded bg-white p-3 mb-4"> - <h5 class="mb-2">How to End This Year</h5> - <ol class="mb-0"> - <li>Clear all blocking issues below.</li> - <li>Start the end-year process to lock the checklist.</li> - <li>Confirm carry-forward balances, then mark this school year closed.</li> - </ol> + <div class="d-flex flex-wrap gap-2"> + <button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#closeSchoolYearFlowModal"> + Continue End-Year Flow + </button> + <a class="btn btn-outline-secondary" href="<?= site_url('administrator/school-years') ?>">Back to School Years</a> + </div> </div> <form class="row g-2 align-items-end mb-4" method="get" action="<?= site_url('administrator/school-years/' . (int) ($source['id'] ?? 0) . '/closing/preview') ?>"> @@ -342,55 +378,218 @@ </div> </div> - <div class="d-flex flex-wrap gap-2 mb-4"> - <?php if ($status === 'active' && $target && $blockers === []): ?> - <form action="<?= site_url('administrator/school-years/' . (int) $source['id'] . '/closing/start') ?>" method="post"> - <?= csrf_field() ?> - <input type="hidden" name="target_school_year_id" value="<?= (int) $target['id'] ?>"> - <button class="btn btn-warning" type="submit">Close Year</button> - </form> - <?php elseif ($status === 'active'): ?> - <button class="btn btn-warning" type="button" disabled>Close Year</button> - <span class="align-self-center text-muted small"> - <?php if (! $target): ?> - Create or select the next school year first. - <?php elseif ($blockers !== []): ?> - Resolve the blocking issues above first. - <?php endif; ?> - </span> - <?php endif; ?> +</div> - <?php if ($status === 'closing' && $batchStatus === 'started'): ?> - <form action="<?= site_url('administrator/school-years/' . (int) $source['id'] . '/closing/execute') ?>" method="post"> - <?= csrf_field() ?> - <button class="btn btn-primary" type="submit">Confirm Carry-Forward</button> - </form> - <?php endif; ?> +<div + class="modal fade" + id="closeSchoolYearFlowModal" + tabindex="-1" + aria-labelledby="closeSchoolYearFlowModalLabel" + aria-hidden="true" + data-initial-step="<?= esc((string) $initialModalStep, 'attr') ?>" + data-max-step="<?= esc((string) $flowStep, 'attr') ?>" +> + <div class="modal-dialog modal-xl modal-dialog-scrollable"> + <div class="modal-content"> + <div class="modal-header"> + <div> + <h5 class="modal-title" id="closeSchoolYearFlowModalLabel">Close <?= esc($source['name'] ?? '') ?></h5> + <div class="text-muted small"> + <?php if ($target): ?> + Next school year: <span class="fw-semibold"><?= esc($target['name'] ?? '') ?></span> + <?php else: ?> + Select or create a next school year before closing can start. + <?php endif; ?> + </div> + </div> + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> + </div> + <div class="modal-body"> + <div class="row g-3 mb-4"> + <?php foreach ($flowSteps as $stepNumber => $step): ?> + <?php + $stepClass = $stepNumber < $flowStep + ? 'border-success bg-light' + : ($stepNumber === $flowStep ? 'border-primary' : 'border-light bg-light'); + $badgeClass = $stepNumber < $flowStep + ? 'bg-success' + : ($stepNumber === $flowStep ? 'bg-primary' : 'bg-secondary'); + ?> + <div class="col-lg col-md-4 col-sm-6"> + <div class="border rounded p-3 h-100 <?= esc($stepClass) ?>" data-step-indicator="<?= esc((string) $stepNumber, 'attr') ?>"> + <span class="badge <?= esc($badgeClass) ?> mb-2"><?= esc((string) $stepNumber) ?></span> + <div class="fw-semibold"><?= esc($step['title']) ?></div> + <div class="text-muted small"><?= esc($step['description']) ?></div> + </div> + </div> + <?php endforeach; ?> + </div> - <?php if ($status === 'closing' && $batchStatus === 'executed'): ?> - <form action="<?= site_url('administrator/school-years/' . (int) $source['id'] . '/closing/complete') ?>" method="post"> - <?= csrf_field() ?> - <button class="btn btn-success" type="submit">Close Year</button> - </form> - <?php endif; ?> + <div class="close-school-year-step" data-step-pane="1"> + <h6 class="mb-3">Review checklist details</h6> + <div class="row g-3 mb-4"> + <div class="col-md-3"> + <div class="border rounded p-3 h-100"> + <div class="text-muted small">Students reviewed</div> + <div class="fs-5 fw-semibold"><?= esc((string) ($overview['students'] ?? 0)) ?></div> + </div> + </div> + <div class="col-md-3"> + <div class="border rounded p-3 h-100"> + <div class="text-muted small">Pending decisions</div> + <div class="fs-5 fw-semibold <?= (int) ($promotionSummary['pending_decision'] ?? 0) > 0 ? 'text-danger' : '' ?>"> + <?= esc((string) ($promotionSummary['pending_decision'] ?? 0)) ?> + </div> + </div> + </div> + <div class="col-md-3"> + <div class="border rounded p-3 h-100"> + <div class="text-muted small">Blocking issues</div> + <div class="fs-5 fw-semibold <?= $blockers === [] ? 'text-success' : 'text-danger' ?>"><?= esc((string) count($blockers)) ?></div> + </div> + </div> + <div class="col-md-3"> + <div class="border rounded p-3 h-100"> + <div class="text-muted small">Net carry-forward</div> + <div class="fs-5 fw-semibold"><?= esc($money($carryForwardTotal)) ?></div> + </div> + </div> + </div> - <?php if (in_array($batchStatus, ['executed', 'completed'], true)): ?> - <form action="<?= site_url('administrator/school-years/' . (int) $source['id'] . '/closing/execute') ?>" method="post"> - <?= csrf_field() ?> - <button class="btn btn-primary" type="submit"> - <?= $missingCarryForwardInvoices > 0 - ? 'Repair Carry-Forward Invoices (' . $missingCarryForwardInvoices . ')' - : 'Re-run Carry-Forward Sync' ?> - </button> - </form> - <?php endif; ?> + <?php if ($blockers !== []): ?> + <div class="alert alert-danger"> + <div class="fw-semibold mb-2">Resolve these blockers before the year can be closed.</div> + <ul class="mb-0"> + <?php foreach ($blockers as $finding): ?> + <li><strong><?= esc($finding['title']) ?>:</strong> <?= esc($finding['detail']) ?></li> + <?php endforeach; ?> + </ul> + </div> + <?php elseif (! $target && $status === 'active'): ?> + <div class="alert alert-warning mb-0"> + A next school year is required. Use the selector on this page to choose a target year, then refresh the checklist. + </div> + <?php else: ?> + <div class="alert alert-success mb-0"> + Checklist review is ready. Continue to the next step to start closing this school year. + </div> + <?php endif; ?> - <?php if ($status === 'closing' && ! in_array($batchStatus, ['executed', 'completed'], true)): ?> - <form action="<?= site_url('administrator/school-years/' . (int) $source['id'] . '/closing/cancel') ?>" method="post"> - <?= csrf_field() ?> - <button class="btn btn-outline-secondary" type="submit">Cancel End-Year Process</button> - </form> - <?php endif; ?> + <?php if ($warnings !== []): ?> + <div class="border rounded p-3 mt-3"> + <div class="fw-semibold mb-2">Warnings to review</div> + <ul class="mb-0"> + <?php foreach ($warnings as $finding): ?> + <li><strong><?= esc($finding['title']) ?>:</strong> <?= esc($finding['detail']) ?></li> + <?php endforeach; ?> + </ul> + </div> + <?php endif; ?> + </div> + + <div class="close-school-year-step d-none" data-step-pane="2"> + <h6 class="mb-3">Start the close</h6> + <div class="alert alert-warning mb-3"> + Starting the close locks <?= esc($source['name'] ?? 'this school year') ?> into end-year review. Normal edits for this year stop while carry-forward is prepared. + </div> + <dl class="row mb-0"> + <dt class="col-sm-4">Current school year</dt> + <dd class="col-sm-8"><?= esc($source['name'] ?? '') ?></dd> + <dt class="col-sm-4">Next school year</dt> + <dd class="col-sm-8"><?= esc($target['name'] ?? 'Not selected') ?></dd> + <dt class="col-sm-4">Blocking issues</dt> + <dd class="col-sm-8"><?= esc((string) count($blockers)) ?></dd> + </dl> + </div> + + <div class="close-school-year-step d-none" data-step-pane="3"> + <h6 class="mb-3">Confirm carry-forward</h6> + <div class="alert alert-primary mb-3"> + Carry-forward will create or update next-year invoices from the balances shown in the Carry-Forward Families table. + </div> + <div class="row g-3"> + <div class="col-md-6"> + <div class="border rounded p-3 h-100"> + <div class="text-muted small">Families with carry-forward rows</div> + <div class="fs-5 fw-semibold"><?= esc((string) count($carryForward)) ?></div> + </div> + </div> + <div class="col-md-6"> + <div class="border rounded p-3 h-100"> + <div class="text-muted small">Net carry-forward</div> + <div class="fs-5 fw-semibold"><?= esc($money($carryForwardTotal)) ?></div> + </div> + </div> + </div> + </div> + + <div class="close-school-year-step d-none" data-step-pane="4"> + <h6 class="mb-3">Finish the close</h6> + <div class="alert alert-success mb-0"> + Carry-forward is complete. Finishing the close will mark <?= esc($source['name'] ?? 'this school year') ?> closed and activate <?= esc($target['name'] ?? 'the next school year') ?>. + </div> + </div> + + <div class="close-school-year-step d-none" data-step-pane="5"> + <h6 class="mb-3">Closed</h6> + <div class="alert alert-secondary mb-0"> + This close flow is complete. Use the report tables on this page for audit review. + </div> + </div> + </div> + <div class="modal-footer justify-content-between"> + <div> + <?php if ($status === 'closing' && ! in_array($batchStatus, ['executed', 'completed'], true)): ?> + <form action="<?= site_url('administrator/school-years/' . $sourceId . '/closing/cancel') ?>" method="post" data-action-step="3"> + <?= csrf_field() ?> + <button class="btn btn-outline-secondary" type="submit">Cancel End-Year Process</button> + </form> + <?php endif; ?> + </div> + <div class="d-flex flex-wrap gap-2"> + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Review Page</button> + <button type="button" class="btn btn-outline-primary" data-step-back>Back</button> + <button type="button" class="btn btn-primary" data-step-next>Next</button> + + <?php if ($status === 'active' && $target && $blockers === []): ?> + <form action="<?= site_url('administrator/school-years/' . $sourceId . '/closing/start') ?>" method="post" data-action-step="2"> + <?= csrf_field() ?> + <input type="hidden" name="target_school_year_id" value="<?= $targetId ?>"> + <button class="btn btn-warning" type="submit">Next</button> + </form> + <?php elseif ($status === 'active'): ?> + <button class="btn btn-warning" type="button" disabled data-action-step="1">Next</button> + <?php endif; ?> + + <?php if ($status === 'closing' && $batchStatus === 'started'): ?> + <form action="<?= site_url('administrator/school-years/' . $sourceId . '/closing/execute') ?>" method="post" data-action-step="3"> + <?= csrf_field() ?> + <button class="btn btn-primary" type="submit">Next</button> + </form> + <?php endif; ?> + + <?php if ($status === 'closing' && $batchStatus === 'executed'): ?> + <form action="<?= site_url('administrator/school-years/' . $sourceId . '/closing/complete') ?>" method="post" data-action-step="4"> + <?= csrf_field() ?> + <button class="btn btn-success" type="submit">Finish Close</button> + </form> + <?php endif; ?> + + <?php if ($status === 'closed' || $batchStatus === 'completed'): ?> + <a class="btn btn-success" href="<?= site_url('administrator/school-years') ?>" data-action-step="5">Finish</a> + <?php elseif ($batchStatus === 'executed'): ?> + <form action="<?= site_url('administrator/school-years/' . $sourceId . '/closing/execute') ?>" method="post" data-action-step="4"> + <?= csrf_field() ?> + <button class="btn btn-outline-primary" type="submit"> + <?= $missingCarryForwardInvoices > 0 + ? 'Repair Carry-Forward Invoices (' . $missingCarryForwardInvoices . ')' + : 'Re-run Carry-Forward Sync' ?> + </button> + </form> + <?php endif; ?> + </div> + </div> + </div> </div> </div> @@ -399,6 +598,128 @@ <?= $this->section('scripts') ?> <script> document.addEventListener('DOMContentLoaded', function () { + var closeFlowModal = document.getElementById('closeSchoolYearFlowModal'); + var setupCloseFlowModal = function () { + if (!closeFlowModal) { + return; + } + + var currentStep = parseInt(closeFlowModal.getAttribute('data-initial-step') || '1', 10); + var maxStep = parseInt(closeFlowModal.getAttribute('data-max-step') || '1', 10); + var backButton = closeFlowModal.querySelector('[data-step-back]'); + var nextButton = closeFlowModal.querySelector('[data-step-next]'); + var panes = Array.prototype.slice.call(closeFlowModal.querySelectorAll('[data-step-pane]')); + var indicators = Array.prototype.slice.call(closeFlowModal.querySelectorAll('[data-step-indicator]')); + var actions = Array.prototype.slice.call(closeFlowModal.querySelectorAll('[data-action-step]')); + var actionForms = actions.filter(function (action) { + return action.tagName && action.tagName.toLowerCase() === 'form'; + }); + + var showStep = function (step) { + currentStep = Math.max(1, Math.min(maxStep, step)); + + panes.forEach(function (pane) { + pane.classList.toggle('d-none', parseInt(pane.getAttribute('data-step-pane') || '0', 10) !== currentStep); + }); + + indicators.forEach(function (indicator) { + var indicatorStep = parseInt(indicator.getAttribute('data-step-indicator') || '0', 10); + var badge = indicator.querySelector('.badge'); + indicator.classList.remove('border-success', 'border-primary', 'border-light', 'bg-light'); + if (badge) { + badge.classList.remove('bg-success', 'bg-primary', 'bg-secondary'); + } + + if (indicatorStep < currentStep) { + indicator.classList.add('border-success', 'bg-light'); + if (badge) badge.classList.add('bg-success'); + } else if (indicatorStep === currentStep) { + indicator.classList.add('border-primary'); + if (badge) badge.classList.add('bg-primary'); + } else { + indicator.classList.add('border-light', 'bg-light'); + if (badge) badge.classList.add('bg-secondary'); + } + }); + + actions.forEach(function (action) { + action.classList.toggle('d-none', parseInt(action.getAttribute('data-action-step') || '0', 10) !== currentStep); + }); + + if (backButton) { + backButton.classList.toggle('d-none', currentStep <= 1); + } + if (nextButton) { + nextButton.classList.toggle('d-none', currentStep >= maxStep); + } + }; + + if (backButton) { + backButton.addEventListener('click', function () { + showStep(currentStep - 1); + }); + } + if (nextButton) { + nextButton.addEventListener('click', function () { + showStep(currentStep + 1); + }); + } + + actionForms.forEach(function (form) { + form.addEventListener('submit', function (event) { + var submitButton = form.querySelector('button[type="submit"]'); + event.preventDefault(); + + if (submitButton) { + submitButton.disabled = true; + } + + fetch(form.action, { + method: form.method || 'POST', + body: new FormData(form), + credentials: 'same-origin', + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then(function (response) { + return response.text(); + }) + .then(function (html) { + var parsed = new DOMParser().parseFromString(html, 'text/html'); + var updatedModal = parsed.getElementById('closeSchoolYearFlowModal'); + + if (!updatedModal) { + window.location.reload(); + return; + } + + closeFlowModal.setAttribute('data-initial-step', updatedModal.getAttribute('data-initial-step') || '1'); + closeFlowModal.setAttribute('data-max-step', updatedModal.getAttribute('data-max-step') || '1'); + closeFlowModal.querySelector('.modal-content').innerHTML = updatedModal.querySelector('.modal-content').innerHTML; + setupCloseFlowModal(); + }) + .catch(function () { + form.submit(); + }) + .finally(function () { + if (submitButton) { + submitButton.disabled = false; + } + }); + }); + }); + + showStep(currentStep); + }; + + if (closeFlowModal) { + setupCloseFlowModal(); + if (new URLSearchParams(window.location.search).get('close_flow') === '1' && window.bootstrap && window.bootstrap.Modal) { + window.bootstrap.Modal.getOrCreateInstance(closeFlowModal).show(); + } + } + if (!window.jQuery || !window.jQuery.fn || !window.jQuery.fn.DataTable) { return; } diff --git a/app/Views/school_years/index.php b/app/Views/school_years/index.php index 6935c2e..b56a4b0 100644 --- a/app/Views/school_years/index.php +++ b/app/Views/school_years/index.php @@ -181,10 +181,6 @@ <button class="dropdown-item text-danger" type="submit">Delete draft</button> </form> </li> - <?php elseif ($status === 'active'): ?> - <li> - <a class="dropdown-item" href="<?= site_url('administrator/school-years/' . $id . '/closing/preview') ?>">Close year</a> - </li> <?php elseif ($status === 'closing'): ?> <li> <a class="dropdown-item" href="<?= site_url('administrator/school-years/' . $id . '/closing/preview') ?>">Finish end-year checklist</a> diff --git a/tests/app/Services/EnrollmentRegistrationEmailServiceTest.php b/tests/app/Services/EnrollmentRegistrationEmailServiceTest.php index 8f23206..4d9733e 100644 --- a/tests/app/Services/EnrollmentRegistrationEmailServiceTest.php +++ b/tests/app/Services/EnrollmentRegistrationEmailServiceTest.php @@ -12,7 +12,7 @@ use PHPUnit\Framework\TestCase; final class EnrollmentRegistrationEmailServiceTest extends TestCase { - public function testPassedStudentUsesReEnrollmentOpeningLanguage(): void + public function testPassedStudentMessageStaysConcise(): void { $service = $this->service(); @@ -32,10 +32,10 @@ final class EnrollmentRegistrationEmailServiceTest extends TestCase $action = $this->invoke($service, 'requiredAction', [$evaluation, 'August 31, 2026']); $this->assertSame('Eligible', $status); - $this->assertStringContainsString('We are pleased to inform you that Student Name has successfully passed Grade 8.', $message); - $this->assertStringContainsString('Re-enrollment for the new school year will open on August 1, 2026.', $message); - $this->assertStringContainsString('Please make sure to re-enroll your child before August 31, 2026', $message); - $this->assertStringContainsString('sign in to the parent portal', $message); + $this->assertStringContainsString('Student Name has successfully passed Grade 8.', $message); + $this->assertStringNotContainsString('Re-enrollment for the new school year will open on August 1, 2026.', $message); + $this->assertStringNotContainsString('Please make sure to re-enroll your child before August 31, 2026', $message); + $this->assertStringNotContainsString('sign in to the parent portal', $message); $this->assertStringContainsString('Complete re-enrollment before August 31, 2026.', $action); $this->assertStringNotContainsString('Not Eligible', $status); $this->assertStringNotContainsString('Registration for the new school year has not opened yet', $message);