fix school year and enrollment steps
Tests / PHPUnit (push) Failing after 58s

This commit is contained in:
root
2026-08-15 21:15:05 -04:00
parent 4603d9ced2
commit 3cbfc40934
18 changed files with 1142 additions and 163 deletions
@@ -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());
}
@@ -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,
+172 -6
View File
@@ -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);
@@ -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')))));
+22 -14
View File
@@ -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
{
@@ -0,0 +1,61 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddUniqueFinancialAidParentYear extends Migration
{
private string $table = 'financial_aid_requests';
private string $index = 'uniq_financial_aid_parent_year';
public function up()
{
if (! $this->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;
}
}
+19 -4
View File
@@ -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 <title>, 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'
);
+8
View File
@@ -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();
}
}
@@ -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 students information, submit the required documents, acknowledge the school policies, and complete any applicable payment steps.';
return $name . ' has successfully passed ' . $grade . '.';
}
if (($evaluation['blockers'] ?? []) !== []) {
+6 -1
View File
@@ -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
+48 -11
View File
@@ -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 12 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 12 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() ?>
<?= $this->endSection() ?>
+25 -3
View File
@@ -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) -->
+266 -32
View File
@@ -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) ?> &middot; School ID: <?= esc($student['school_id'] ?? 'N/A') ?></div>
<div class="small text-muted">Current grade: <?= esc($gradeLabel) ?> &middot; 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'])): ?>
&middot; 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);
}
});
}
+6 -2
View File
@@ -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 years 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 years tax return to the school office.
</div>
<?php endif; ?>
</div>
<?= $this->endSection() ?>
+7 -2
View File
@@ -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 ?>>
+376 -55
View File
@@ -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;
}
-4
View File
@@ -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>
@@ -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);