2738 lines
112 KiB
PHP
2738 lines
112 KiB
PHP
<?php
|
|
|
|
namespace App\Controllers\View;
|
|
|
|
use App\Controllers\BaseController;
|
|
use App\Models\UserModel;
|
|
use App\Models\StudentModel;
|
|
use App\Models\ClassSectionModel;
|
|
use App\Models\StudentClassModel;
|
|
use App\Models\StudentSectionDistributionDraftModel;
|
|
use App\Models\AuthorizedUserModel;
|
|
use App\Models\ParentPolicyAcceptanceModel;
|
|
use App\Models\EmergencyContactModel;
|
|
use App\Models\ConfigurationModel;
|
|
use App\Models\StudentMedicalConditionModel;
|
|
use App\Models\StudentAllergyModel;
|
|
use \App\Models\EnrollmentModel;
|
|
use \App\Models\EventChargesModel;
|
|
use \App\Models\EventModel;
|
|
use CodeIgniter\Events\Events;
|
|
use App\Services\FeeCalculationService;
|
|
use App\Services\SchoolIdService;
|
|
use App\Services\PhoneFormatterService;
|
|
use App\Services\Parents\ParentAccountService;
|
|
use App\Services\Parents\ParentAttendanceService;
|
|
use App\Services\Parents\ParentEnrollmentService;
|
|
use App\Services\Parents\ParentEventParticipationService;
|
|
use App\Services\Parents\ParentPaymentService;
|
|
use App\Services\Parents\ParentRegistrationService;
|
|
use App\Support\Enrollment\DeliberationDecision;
|
|
use App\Support\Enrollment\EnrollmentEligibility;
|
|
use InvalidArgumentException;
|
|
use DateTimeImmutable;
|
|
use DateTimeZone;
|
|
use Throwable;
|
|
|
|
|
|
use DateTime;
|
|
use Exception;
|
|
use CodeIgniter\Database\Exceptions\DatabaseException;
|
|
|
|
|
|
class ParentController extends BaseController
|
|
{
|
|
protected $userModel;
|
|
protected $studentModel;
|
|
protected $db;
|
|
protected $classSectionModel;
|
|
protected $studentClassModel;
|
|
protected $configModel;
|
|
protected $lastDayOfRegistration;
|
|
protected $withdrawalDeadline;
|
|
protected $schoolYear;
|
|
protected $semester;
|
|
protected $dateAgeReference;
|
|
protected $enrollmentModel;
|
|
protected $maxChilds;
|
|
protected $maxEmergency;
|
|
protected $chargesModel;
|
|
protected $eventModel;
|
|
protected $eventController;
|
|
protected $emergencyContactModel;
|
|
protected $medicalConditionModel;
|
|
protected $allergyModel;
|
|
protected $authorizedUsersModel;
|
|
protected ParentPolicyAcceptanceModel $policyAcceptanceModel;
|
|
protected $schoolStartDate;
|
|
protected $ageDateRefernce;
|
|
protected ParentAccountService $parentAccountService;
|
|
protected ParentAttendanceService $parentAttendanceService;
|
|
protected ?ParentEnrollmentService $parentEnrollmentService = null;
|
|
protected ParentEventParticipationService $parentEventParticipationService;
|
|
protected ParentPaymentService $parentPaymentService;
|
|
protected ?ParentRegistrationService $parentRegistrationService = null;
|
|
|
|
|
|
|
|
public function __construct()
|
|
{
|
|
$this->db = \Config\Database::connect();
|
|
$this->userModel = new UserModel();
|
|
$this->studentModel = new StudentModel();
|
|
$this->classSectionModel = new ClassSectionModel();
|
|
$this->studentClassModel = new StudentClassModel();
|
|
$this->configModel = new ConfigurationModel();
|
|
$this->enrollmentModel = new EnrollmentModel();
|
|
$this->chargesModel = new EventChargesModel();
|
|
$this->eventModel = new EventModel();
|
|
$this->eventController = new \App\Controllers\View\InvoiceController();
|
|
$this->emergencyContactModel = new EmergencyContactModel();
|
|
$this->medicalConditionModel = new StudentMedicalConditionModel();
|
|
$this->allergyModel = new StudentAllergyModel();
|
|
$this->authorizedUsersModel = new AuthorizedUserModel();
|
|
$this->policyAcceptanceModel = new ParentPolicyAcceptanceModel();
|
|
$this->parentAccountService = new ParentAccountService($this->db, $this->userModel, $this->authorizedUsersModel);
|
|
$this->parentAttendanceService = new ParentAttendanceService($this->db);
|
|
$this->parentPaymentService = new ParentPaymentService($this->db);
|
|
$this->parentEnrollmentService = new ParentEnrollmentService(
|
|
$this->db,
|
|
$this->userModel,
|
|
$this->studentModel,
|
|
$this->studentClassModel,
|
|
$this->configModel,
|
|
$this->medicalConditionModel,
|
|
$this->allergyModel,
|
|
$this->policyAcceptanceModel,
|
|
$this->eventController
|
|
);
|
|
$this->parentEventParticipationService = new ParentEventParticipationService(
|
|
$this->chargesModel,
|
|
$this->eventModel,
|
|
$this->enrollmentModel,
|
|
$this->eventController
|
|
);
|
|
$this->parentRegistrationService = new ParentRegistrationService(
|
|
$this->db,
|
|
$this->userModel,
|
|
$this->studentModel,
|
|
$this->enrollmentModel,
|
|
$this->emergencyContactModel,
|
|
$this->medicalConditionModel,
|
|
$this->allergyModel
|
|
);
|
|
|
|
$this->ageDateRefernce = $this->configModel->getConfig('date_age_reference');
|
|
$this->lastDayOfRegistration = $this->configModel->getConfig('enrollment_deadline');
|
|
$this->schoolStartDate = $this->configModel->getConfig('first_day_of_school')
|
|
?: $this->configModel->getConfig('fall_semester_start');
|
|
$this->withdrawalDeadline = $this->configModel->getConfig('refund_deadline');
|
|
$this->schoolYear = $this->configModel->getConfig('school_year');
|
|
$this->semester = getSemester();
|
|
$this->dateAgeReference = $this->configModel->getConfig('date_age_reference');
|
|
$this->maxChilds = (int) $this->configModel->getConfig('max_kids') ?? 0;
|
|
$this->maxEmergency = (int) $this->configModel->getConfig('max_emergency') ?? 0;
|
|
|
|
helper(['url', 'form']);
|
|
}
|
|
|
|
private function parentEnrollmentService(): ParentEnrollmentService
|
|
{
|
|
if ($this->parentEnrollmentService !== null) {
|
|
return $this->parentEnrollmentService;
|
|
}
|
|
|
|
$db = isset($this->db) ? $this->db : \Config\Database::connect();
|
|
$this->parentEnrollmentService = new ParentEnrollmentService(
|
|
$db,
|
|
isset($this->userModel) ? $this->userModel : new UserModel(),
|
|
isset($this->studentModel) ? $this->studentModel : new StudentModel(),
|
|
isset($this->studentClassModel) ? $this->studentClassModel : new StudentClassModel(),
|
|
isset($this->configModel) ? $this->configModel : new ConfigurationModel(),
|
|
isset($this->medicalConditionModel) ? $this->medicalConditionModel : new StudentMedicalConditionModel(),
|
|
isset($this->allergyModel) ? $this->allergyModel : new StudentAllergyModel(),
|
|
isset($this->policyAcceptanceModel) ? $this->policyAcceptanceModel : new ParentPolicyAcceptanceModel(),
|
|
isset($this->eventController) ? $this->eventController : null
|
|
);
|
|
|
|
return $this->parentEnrollmentService;
|
|
}
|
|
|
|
private function parentRegistrationService(): ParentRegistrationService
|
|
{
|
|
if ($this->parentRegistrationService !== null) {
|
|
return $this->parentRegistrationService;
|
|
}
|
|
|
|
$db = isset($this->db) ? $this->db : \Config\Database::connect();
|
|
$this->parentRegistrationService = new ParentRegistrationService(
|
|
$db,
|
|
isset($this->userModel) ? $this->userModel : new UserModel(),
|
|
isset($this->studentModel) ? $this->studentModel : new StudentModel(),
|
|
isset($this->enrollmentModel) ? $this->enrollmentModel : new EnrollmentModel(),
|
|
isset($this->emergencyContactModel) ? $this->emergencyContactModel : new EmergencyContactModel(),
|
|
isset($this->medicalConditionModel) ? $this->medicalConditionModel : new StudentMedicalConditionModel(),
|
|
isset($this->allergyModel) ? $this->allergyModel : new StudentAllergyModel()
|
|
);
|
|
|
|
return $this->parentRegistrationService;
|
|
}
|
|
|
|
public function index()
|
|
{
|
|
// Retrieve all parents from the database where role is parent
|
|
$parents = $this->userModel->where('role', 'parent')->findAll();
|
|
// Pass the parents to the view
|
|
return view('administrator/parent', ['parents' => $parents]);
|
|
}
|
|
|
|
public function create()
|
|
{
|
|
// Show the form to create a new parent
|
|
return view('administrator/create_parent');
|
|
}
|
|
|
|
public function store()
|
|
{
|
|
// Handle the form submission to create a new parent
|
|
$data = [
|
|
'firstname' => $this->request->getPost('firstname'),
|
|
'lastname' => $this->request->getPost('lastname'),
|
|
'email' => strtolower($this->request->getPost('email')),
|
|
'password' => password_hash($this->request->getPost('password'), PASSWORD_DEFAULT),
|
|
'role' => 'parent',
|
|
];
|
|
$this->userModel->insert($data);
|
|
return redirect()->to('/administrator/parent');
|
|
}
|
|
|
|
public function edit($id)
|
|
{
|
|
// Retrieve the parent details to edit
|
|
$parent = $this->userModel->find($id);
|
|
return view('administrator/edit_parent', ['parent' => $parent]);
|
|
}
|
|
|
|
public function update($id)
|
|
{
|
|
// Handle the form submission to update an existing parent
|
|
$data = [
|
|
'firstname' => $this->request->getPost('firstname'),
|
|
'lastname' => $this->request->getPost('lastname'),
|
|
'email' => strtolower($this->request->getPost('email')),
|
|
];
|
|
if ($this->request->getPost('password')) {
|
|
$data['password'] = password_hash($this->request->getPost('password'), PASSWORD_DEFAULT);
|
|
}
|
|
$this->userModel->update($id, $data);
|
|
return redirect()->to('/administrator/parent');
|
|
}
|
|
|
|
public function destroy($id)
|
|
{
|
|
// Delete the parent
|
|
$this->userModel->delete($id);
|
|
return redirect()->to('/administrator/parent');
|
|
}
|
|
|
|
public function attendance()
|
|
{
|
|
try {
|
|
// Get parent ID from session
|
|
$parentId = session()->get('user_id');
|
|
|
|
if (!$parentId) {
|
|
return redirect()->back()->with('error', 'Parent session not found.');
|
|
}
|
|
|
|
$selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
|
|
|
$attendanceResults = $this->parentAttendanceService->attendanceForParent((int) $parentId, $selectedYear);
|
|
|
|
// If no records found, set a flag to show message in the view
|
|
if (empty($attendanceResults)) {
|
|
return view('/parent/attendance', [
|
|
'attendance' => null,
|
|
'selectedYear' => $selectedYear,
|
|
'selectedSemester' => null,
|
|
//'error' => 'No attendance records found for the selected school year and semester.'
|
|
]);
|
|
}
|
|
|
|
// Return view with attendance results
|
|
return view('/parent/attendance', [
|
|
'attendance' => $attendanceResults,
|
|
'selectedYear' => $selectedYear,
|
|
'selectedSemester' => null,
|
|
'error' => null
|
|
]);
|
|
} catch (Exception $e) {
|
|
log_message('error', 'Failed to retrieve attendance data: ' . $e->getMessage());
|
|
|
|
return view('/parent/attendance', [
|
|
'attendance' => null,
|
|
'selectedYear' => $selectedYear ?? null,
|
|
'selectedSemester' => null,
|
|
'error' => 'Failed to retrieve attendance data. Please try again later.'
|
|
]);
|
|
}
|
|
}
|
|
public function viewPayments()
|
|
{
|
|
$parentId = session()->get('user_id');
|
|
|
|
// Log the user ID for debugging
|
|
log_message('info', 'Logged in user ID: ' . $parentId);
|
|
|
|
if (!$parentId) {
|
|
log_message('error', 'No user ID found in session.');
|
|
return view('/parent/payment', ['invoices' => [], 'error' => 'No user ID found in session.']);
|
|
}
|
|
|
|
$invoices = $this->parentPaymentService->invoicesForParent((int) $parentId);
|
|
|
|
// Log the query and results for debugging
|
|
log_message('info', 'invoices: ' . print_r($invoices, true));
|
|
|
|
if (empty($invoices)) {
|
|
log_message('info', 'No invoices found for user ID: ' . $parentId);
|
|
} else {
|
|
log_message('info', count($invoices) . ' payment(s) found for user ID: ' . $parentId);
|
|
}
|
|
|
|
// Pass the data to the view
|
|
return view('/parent/payment', [
|
|
'invoices' => $invoices
|
|
]);
|
|
}
|
|
|
|
public function enrollClasses()
|
|
{
|
|
try {
|
|
if (!$this->schoolYear) {
|
|
log_message('error', 'Current school year not found in configuration.');
|
|
return redirect()->back()->with('error', 'Configuration error: School year missing.');
|
|
}
|
|
|
|
$context = $this->resolveSchoolYearContext();
|
|
$selectedYear = $context->yearName();
|
|
$isEditable = ! $context->isReadonly();
|
|
$parentId = session()->get('user_id');
|
|
|
|
if (!$parentId) {
|
|
log_message('error', 'User ID not found in session.');
|
|
return redirect()->back()->with('error', 'User session error. Please log in again.');
|
|
}
|
|
|
|
$result = $this->parentEnrollmentService()->enrollClassesData(
|
|
(int) $parentId,
|
|
$selectedYear,
|
|
$isEditable,
|
|
$this->withdrawalDeadline,
|
|
$this->lastDayOfRegistration,
|
|
$this->schoolStartDate,
|
|
(int) $this->request->getGet('start'),
|
|
trim((string) $this->request->getGet('students'))
|
|
);
|
|
|
|
if (empty($result['ok'])) {
|
|
if (($result['code'] ?? '') === 'no_students') {
|
|
return redirect()->to('/no-kids')->with('error', (string) ($result['message'] ?? 'No students found.'));
|
|
}
|
|
|
|
return redirect()->back()->with('error', (string) ($result['message'] ?? 'Unable to load enrollment.'));
|
|
}
|
|
|
|
return view('/parent/enroll_classes', $result['data']);
|
|
} catch (Exception $e) {
|
|
log_message('error', 'An error occurred in enrollClasses: ' . $e->getMessage());
|
|
return view('errors/html/error_500');
|
|
}
|
|
}
|
|
|
|
public function noKids()
|
|
{
|
|
return view('/parent/no_kids_registred');
|
|
}
|
|
|
|
public function enrollClassesHandler()
|
|
{
|
|
// Retrieve enrollment and withdrawal data from the POST request
|
|
$enroll = $this->request->getPost('enroll'); // Selected students for enrollment
|
|
$withdraw = $this->request->getPost('withdraw'); // Selected students for withdrawal
|
|
$parentId = session()->get('user_id'); // Parent ID from session
|
|
|
|
if (!empty($enroll)) {
|
|
$parent = $this->userModel->find((int) $parentId);
|
|
$selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
|
$hasAcceptedPolicy = $this->hasAcceptedPolicyForYear((int) $parentId, $selectedYear);
|
|
$acceptedOnSubmit = (string) ($this->request->getPost('accept_school_policy') ?? '') === '1';
|
|
|
|
if (!$hasAcceptedPolicy && !$acceptedOnSubmit) {
|
|
return redirect()->back()->withInput()->with('error', 'You must read and accept the school policy before enrolling students.');
|
|
}
|
|
|
|
if (!$hasAcceptedPolicy && $acceptedOnSubmit) {
|
|
$this->recordPolicyAcceptance((int) $parentId, $selectedYear, 'returning_parent_enrollment');
|
|
$this->userModel->update((int) $parentId, [
|
|
'accept_school_policy' => 1,
|
|
'updated_at' => utc_now(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
if ($this->lastDayOfRegistration && local_date(utc_now(), 'Y-m-d') > date('Y-m-d', strtotime($this->lastDayOfRegistration))) {
|
|
if (!empty($enroll)) {
|
|
return redirect()->back()->with('error', 'Enrollment deadline has passed. New enrollments are not allowed.');
|
|
}
|
|
}
|
|
|
|
// Ensure there are students selected for either enrollment or withdrawal
|
|
if (empty($enroll) && empty($withdraw)) {
|
|
return redirect()->back()->with('error', 'No students selected for enrollment or withdrawal.');
|
|
}
|
|
|
|
// Handle enrollments
|
|
$studentData = [];
|
|
$enrollmentResultMessages = [];
|
|
$invoiceResultMessage = null;
|
|
$invoiceErrorMessage = null;
|
|
|
|
if (!empty($enroll)) {
|
|
$selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
|
$previousSchoolYear = $this->previousSchoolYearName($selectedYear);
|
|
if ($previousSchoolYear === null) {
|
|
return redirect()->back()->withInput()->with('error', 'Enrollment cannot be submitted because the closing school year could not be determined.');
|
|
}
|
|
|
|
$parent = $this->userModel->find((int) $parentId);
|
|
if (! is_array($parent) || ($parent['user_type'] ?? '') !== 'primary') {
|
|
return redirect()->back()->withInput()->with('error', 'Only primary parents can enroll students.');
|
|
}
|
|
|
|
$transitionService = service('enrollmentTransition');
|
|
$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));
|
|
}
|
|
|
|
$parentContactErrors = $this->updateEnrollmentParentContact((int) $parentId);
|
|
if ($parentContactErrors !== []) {
|
|
return redirect()->back()->withInput()->with('error', implode(' ', $parentContactErrors));
|
|
}
|
|
|
|
foreach ($submittedStudentIds as $studentId) {
|
|
try {
|
|
$evaluation = $transitionService->evaluateForParent((int) $parentId, $studentId, $previousSchoolYear, $selectedYear, 'parent');
|
|
} catch (Throwable $e) {
|
|
log_message('error', 'Parent enrollment eligibility evaluation failed for student {studentId}: {message}', [
|
|
'studentId' => $studentId,
|
|
'message' => $e->getMessage(),
|
|
]);
|
|
$errors[] = 'Student ID ' . $studentId . ': enrollment eligibility could not be evaluated. Please contact administration.';
|
|
continue;
|
|
}
|
|
|
|
$studentInfo = $this->studentModel->find($studentId);
|
|
if (! is_array($studentInfo)) {
|
|
$errors[] = 'Student ID ' . $studentId . ': student record was not found.';
|
|
continue;
|
|
}
|
|
|
|
$studentName = trim((string) ($studentInfo['firstname'] ?? '') . ' ' . (string) ($studentInfo['lastname'] ?? '')) ?: 'Student ID ' . $studentId;
|
|
if (empty($evaluation['can_enroll']) && empty($evaluation['parent_enrollment_allowed'])) {
|
|
$transitionService->logEnrollmentBlock($evaluation, 'parent_enroll_submit', (int) $parentId, (int) $parentId);
|
|
$messages = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['blockers'] ?? []))));
|
|
$codes = array_values(array_filter(array_map('strval', array_merge($evaluation['blocking_rule_codes'] ?? [], $evaluation['review_rule_codes'] ?? []))));
|
|
$errors[] = $studentName . ': ' . ($messages !== [] ? implode(' ', $messages) : 'Enrollment is not currently allowed' . ($codes !== [] ? ' (' . implode(', ', $codes) . ')' : '') . '.');
|
|
continue;
|
|
}
|
|
|
|
if (! $this->studentModel->getStudentSchoolIdByStudentId($studentId)) {
|
|
$errors[] = $studentName . ': Student school ID not found.';
|
|
continue;
|
|
}
|
|
|
|
$evaluations[$studentId] = $evaluation;
|
|
}
|
|
|
|
if ($errors !== []) {
|
|
return redirect()->back()->withInput()->with('error', implode(' ', $errors));
|
|
}
|
|
|
|
$this->db->transStart();
|
|
$enrollmentStatusService = \Config\Services::enrollmentStatus(false);
|
|
foreach ($enroll as $studentId) {
|
|
$studentId = (int) $studentId;
|
|
if (! isset($evaluations[$studentId])) {
|
|
continue;
|
|
}
|
|
|
|
$evaluation = $evaluations[$studentId];
|
|
// Get student full name (supports both string return or array with firstname/lastname)
|
|
$studentInfo = $this->studentModel->getFullNameById($studentId);
|
|
|
|
// Save student info into $studentData
|
|
$studentData[$studentId] = $studentInfo; // raw return from getFullName()
|
|
|
|
// Check if the student already has an enrollment record for the current school year and semester
|
|
$existingEnrollment = $this->enrollmentModel
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $selectedYear)
|
|
->where('semester', $this->semester)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
$studentName = trim((string) ($studentData[$studentId]['firstname'] ?? '') . ' ' . (string) ($studentData[$studentId]['lastname'] ?? '')) ?: 'Student ID ' . $studentId;
|
|
$isReturningReEnrollment = $this->isReturningReEnrollmentStudent((int)$studentId, $selectedYear);
|
|
$isNewForSelectedYear = ! $isReturningReEnrollment
|
|
&& service('studentYearStatus')->isNew($studentId, $selectedYear);
|
|
$submittedEnrollmentContexts[$studentId] = $isNewForSelectedYear ? 'first_enrollment' : 're_enrollment';
|
|
$targetEnrollmentStatus = $isNewForSelectedYear ? 'admission under review' : 'payment pending';
|
|
$targetAdmissionStatus = $isNewForSelectedYear ? 'pending' : 'accepted';
|
|
|
|
if ($existingEnrollment) {
|
|
$update = $this->enrollmentPayloadFromEvaluation($evaluation, [
|
|
'is_withdrawn' => 0,
|
|
'withdrawal_date' => null,
|
|
'enrollment_status' => $targetEnrollmentStatus,
|
|
'admission_status' => $targetAdmissionStatus,
|
|
'updated_at' => utc_now(),
|
|
]);
|
|
|
|
if ($existingEnrollment['is_withdrawn'] == 1) {
|
|
// Reactivate the enrollment if the student was previously withdrawn
|
|
$enrollmentStatusService->upsertStatus(array_merge($update, [
|
|
'id' => (int) $existingEnrollment['id'],
|
|
'student_id' => $studentId,
|
|
'parent_id' => $parentId,
|
|
'school_year' => $selectedYear,
|
|
'semester' => $this->semester,
|
|
]), (int) $parentId, 'parent_re_enrollment_submitted');
|
|
log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) has been re-enrolled in enrollment ID {$existingEnrollment['id']}.");
|
|
// Apply promotion-based class placement for the upcoming year
|
|
$this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment);
|
|
} else {
|
|
$currentStatus = (string) ($existingEnrollment['enrollment_status'] ?? '');
|
|
if ($currentStatus === 'enrolled') {
|
|
$update['admission_status'] = 'accepted';
|
|
}
|
|
$enrollmentStatusService->upsertStatus(array_merge($update, [
|
|
'id' => (int) $existingEnrollment['id'],
|
|
'student_id' => $studentId,
|
|
'parent_id' => $parentId,
|
|
'school_year' => $selectedYear,
|
|
'semester' => $this->semester,
|
|
]), (int) $parentId, 'parent_enrollment_submitted');
|
|
log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) is already actively enrolled.");
|
|
$this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment);
|
|
}
|
|
|
|
$enrollmentId = (int) $existingEnrollment['id'];
|
|
if (! empty($evaluation['admin_exception']['id'])) {
|
|
$transitionService->markExceptionUsed((int) $evaluation['admin_exception']['id'], $enrollmentId);
|
|
}
|
|
$transitionService->auditEnrollmentDecision(
|
|
$studentId,
|
|
$selectedYear,
|
|
$previousSchoolYear,
|
|
! empty($evaluation['admin_exception']) ? 'parent_enrollment_submitted_with_exception' : 'parent_enrollment_submitted',
|
|
(int) $parentId,
|
|
$existingEnrollment,
|
|
$this->enrollmentAuditPayload($update, $evaluation),
|
|
implode(' ', array_map('strval', $evaluation['rule_codes'] ?? []))
|
|
);
|
|
$enrollmentResultMessages[] = $studentName . ': ' . $targetEnrollmentStatus;
|
|
} else {
|
|
// If no enrollment record exists, insert a new enrollment record
|
|
$payload = $this->enrollmentPayloadFromEvaluation($evaluation, [
|
|
'student_id' => $studentId,
|
|
'parent_id' => $parentId,
|
|
'school_year' => $selectedYear,
|
|
'semester' => $this->semester,
|
|
'enrollment_date' => local_date(utc_now(), 'Y-m-d'),
|
|
'is_withdrawn' => 0,
|
|
'enrollment_status' => $targetEnrollmentStatus,
|
|
'admission_status' => $targetAdmissionStatus,
|
|
'created_at' => utc_now()
|
|
]);
|
|
$result = $enrollmentStatusService->upsertStatus($payload, (int) $parentId, 'parent_enrollment_submitted');
|
|
|
|
if ((int) ($result['id'] ?? 0) <= 0) {
|
|
$this->db->transRollback();
|
|
return redirect()->back()->withInput()->with('error', $studentName . ': Unable to save enrollment.');
|
|
} else {
|
|
log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) has been newly enrolled.");
|
|
// Apply promotion-based class placement for the upcoming year
|
|
$this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment);
|
|
}
|
|
|
|
$enrollmentId = (int) $result['id'];
|
|
if (! empty($evaluation['admin_exception']['id'])) {
|
|
$transitionService->markExceptionUsed((int) $evaluation['admin_exception']['id'], $enrollmentId);
|
|
}
|
|
$transitionService->auditEnrollmentDecision(
|
|
$studentId,
|
|
$selectedYear,
|
|
$previousSchoolYear,
|
|
! empty($evaluation['admin_exception']) ? 'parent_enrollment_submitted_with_exception' : 'parent_enrollment_submitted',
|
|
(int) $parentId,
|
|
null,
|
|
$this->enrollmentAuditPayload($payload, $evaluation),
|
|
implode(' ', array_map('strval', $evaluation['rule_codes'] ?? []))
|
|
);
|
|
$enrollmentResultMessages[] = $studentName . ': ' . $targetEnrollmentStatus;
|
|
}
|
|
}
|
|
$this->db->transComplete();
|
|
|
|
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
|
|
|
|
// Handle withdrawals
|
|
$withdrawalResultMessages = [];
|
|
$withdrawalErrors = [];
|
|
if (!empty($withdraw)) {
|
|
foreach ($withdraw as $studentId) {
|
|
$studentId = (int) $studentId;
|
|
$enrollment = $this->enrollmentModel
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $this->schoolYear)
|
|
->where('semester', $this->semester)
|
|
->where('is_withdrawn', 0) // Only withdraw active enrollments
|
|
->get()
|
|
->getRowArray();
|
|
|
|
if ($enrollment !== null) {
|
|
$withdrawalRequestDate = local_date(utc_now(), 'Y-m-d');
|
|
service('withdrawalFinancial')->requestWithdrawal(
|
|
(int) $enrollment['id'],
|
|
$withdrawalRequestDate,
|
|
(int) $parentId
|
|
);
|
|
log_message('info', "Student ID $studentId has been withdrawn from enrollment ID {$enrollment['id']}.");
|
|
$withdrawalResultMessages[] = 'Student ID ' . $studentId . ': withdraw under review';
|
|
} else {
|
|
log_message('error', "No active enrollment found for student ID $studentId.");
|
|
$withdrawalErrors[] = 'Student ID ' . $studentId . ': no active enrollment was found.';
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!empty($enroll) && empty($withdraw)) {
|
|
$invoiceResult = $this->generateInvoiceForParentEnrollment((int) $parentId);
|
|
if (! empty($invoiceResult['ok'])) {
|
|
$invoiceResultMessage = (string) ($invoiceResult['message'] ?? 'Invoice generated.');
|
|
} else {
|
|
$invoiceErrorMessage = (string) ($invoiceResult['message'] ?? 'Enrollment was submitted, but the invoice could not be generated.');
|
|
}
|
|
}
|
|
|
|
$parentData = $this->userModel->getUserInfoById($parentId);
|
|
$parentData['user_id'] = $parentId;
|
|
// Redirect to the success page after processing enrollment and withdrawal
|
|
if (!empty($withdraw)) {
|
|
// Redirect to withdrawal success page if there are withdrawals //parent/enroll_classes
|
|
$redirect = redirect()->to('/parent/enroll_classes');
|
|
$successParts = [];
|
|
$errorParts = [];
|
|
if ($withdrawalResultMessages !== []) {
|
|
$successParts[] = 'Withdrawal request submitted. ' . implode(' ', $withdrawalResultMessages);
|
|
}
|
|
if ($withdrawalErrors !== []) {
|
|
$errorParts[] = 'Some withdrawal requests failed. ' . implode(' ', $withdrawalErrors);
|
|
}
|
|
if ($successParts !== []) {
|
|
$redirect = $redirect->with('success', implode(' ', $successParts));
|
|
}
|
|
if ($errorParts !== []) {
|
|
$redirect = $redirect->with('error', implode(' ', $errorParts));
|
|
}
|
|
|
|
return $redirect;
|
|
} else {
|
|
// Redirect to enrollment success page if there are enrollments
|
|
$contextValues = array_values(array_unique($submittedEnrollmentContexts ?? []));
|
|
$parentData['enrollment_context'] = count($contextValues) === 1 ? $contextValues[0] : 'mixed';
|
|
try {
|
|
Events::trigger('admissionUnderReview', $parentData, $studentData);
|
|
} catch (Throwable $e) {
|
|
log_message('error', 'Enrollment notification failed after successful submission: {message}', [
|
|
'message' => $e->getMessage(),
|
|
]);
|
|
}
|
|
$successMessage = 'Enrollment submitted successfully.';
|
|
if ($enrollmentResultMessages !== []) {
|
|
$successMessage .= ' ' . implode(' ', $enrollmentResultMessages);
|
|
}
|
|
if ($invoiceResultMessage !== null) {
|
|
$successMessage .= ' ' . $invoiceResultMessage;
|
|
}
|
|
|
|
$redirect = redirect()->to('/parent/enroll_success')->with('success', $successMessage);
|
|
if ($invoiceErrorMessage !== null) {
|
|
$redirect = $redirect->with('error', $invoiceErrorMessage);
|
|
}
|
|
|
|
return $redirect;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate or refresh the parent's school-year invoice after parent-submitted enrollment.
|
|
*
|
|
* The invoice engine is intentionally authoritative for billable statuses. For example,
|
|
* first-time students still under admission review may not produce billable lines yet.
|
|
*
|
|
* @return array{ok: bool, message: string}
|
|
*/
|
|
private function generateInvoiceForParentEnrollment(int $parentId): array
|
|
{
|
|
return $this->parentEnrollmentService()->generateInvoiceForParentEnrollment(
|
|
$parentId,
|
|
$this->currentSchoolYearName((string) ($this->schoolYear ?? '')),
|
|
(string) ($this->semester ?? getSemester())
|
|
);
|
|
}
|
|
|
|
private function enrollmentPayloadFromEvaluation(array $evaluation, array $base): array
|
|
{
|
|
$payload = array_merge($base, [
|
|
'source_school_year' => $evaluation['source_school_year'] ?? null,
|
|
'deliberation_decision' => $evaluation['deliberation_decision'] ?? null,
|
|
'source_grade_id' => $evaluation['source_grade_id'] ?? null,
|
|
'assigned_grade_id' => $evaluation['assigned_grade_id'] ?? null,
|
|
'source_class_section_id' => $evaluation['source_class_section_id'] ?? null,
|
|
'assigned_class_section_id' => $evaluation['assigned_class_section_id'] ?? null,
|
|
'class_section_id' => $evaluation['assigned_class_section_id'] ?? ($base['class_section_id'] ?? null),
|
|
'placement_status' => $evaluation['placement_status'] ?? null,
|
|
'age_reference_date' => $evaluation['age_reference_date'] ?? null,
|
|
'age_on_reference_date' => $evaluation['age_on_reference_date'] ?? null,
|
|
'adult_student' => ! empty($evaluation['adult_student']) ? 1 : 0,
|
|
'parent_enrollment_allowed' => ! empty($evaluation['parent_enrollment_allowed']) ? 1 : 0,
|
|
'student_self_enrollment_allowed' => ! empty($evaluation['student_self_enrollment_allowed']) ? 1 : 0,
|
|
'exception_required' => ! empty($evaluation['admin_exception']) || ! empty($evaluation['flags']) ? 1 : 0,
|
|
'exception_reason' => $this->exceptionReasonFromEvaluation($evaluation),
|
|
'registration_submitted_at' => utc_now(),
|
|
]);
|
|
|
|
return $this->filterEnrollmentPayloadByColumns($payload);
|
|
}
|
|
|
|
private function updateEnrollmentStudentInfo(array $studentIds, int $parentId, string $schoolYear): array
|
|
{
|
|
$studentInfo = $this->request->getPost('student_info');
|
|
return $this->parentEnrollmentService()->updateEnrollmentStudentInfo(
|
|
$studentIds,
|
|
$parentId,
|
|
is_array($studentInfo) ? $studentInfo : []
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $user
|
|
* @return array<string, string>
|
|
*/
|
|
private function parentContactForEnrollment(array $user): array
|
|
{
|
|
$posted = old('parent_contact');
|
|
$posted = is_array($posted) ? $posted : [];
|
|
$phoneDigits = preg_replace('/\D/', '', (string) ($posted['cellphone'] ?? $user['cellphone'] ?? '')) ?? '';
|
|
$phoneDisplay = strlen($phoneDigits) === 10
|
|
? substr($phoneDigits, 0, 3) . '-' . substr($phoneDigits, 3, 3) . '-' . substr($phoneDigits, 6)
|
|
: trim((string) ($posted['cellphone'] ?? $user['cellphone'] ?? ''));
|
|
|
|
return [
|
|
'firstname' => trim((string) ($user['firstname'] ?? '')),
|
|
'lastname' => trim((string) ($user['lastname'] ?? '')),
|
|
'cellphone' => $phoneDisplay,
|
|
'address_street' => trim((string) ($posted['address_street'] ?? $user['address_street'] ?? '')),
|
|
'apt' => trim((string) ($posted['apt'] ?? $user['apt'] ?? '')),
|
|
'city' => trim((string) ($posted['city'] ?? $user['city'] ?? '')),
|
|
'state' => strtoupper(trim((string) ($posted['state'] ?? $user['state'] ?? ''))),
|
|
'zip' => trim((string) ($posted['zip'] ?? $user['zip'] ?? '')),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function updateEnrollmentParentContact(int $parentId): array
|
|
{
|
|
$fields = $this->request->getPost('parent_contact');
|
|
return $this->parentEnrollmentService()->updateEnrollmentParentContact(
|
|
$parentId,
|
|
is_array($fields) ? $fields : []
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $fields
|
|
* @return array{errors: list<string>, data: array<string, string>}
|
|
*/
|
|
private function normalizeEnrollmentParentContact(array $fields, string $existingState = ''): array
|
|
{
|
|
return $this->parentEnrollmentService()->normalizeEnrollmentParentContact($fields, $existingState);
|
|
}
|
|
|
|
private function collapseContactWhitespace(string $value): string
|
|
{
|
|
return trim(preg_replace('/\s+/', ' ', $value) ?? '');
|
|
}
|
|
|
|
/**
|
|
* @param mixed $selected
|
|
* @return list<string>
|
|
*/
|
|
private function normalizeEnrollmentHealthSelections($selected, string $otherText): array
|
|
{
|
|
$values = [];
|
|
foreach ((array) $selected as $value) {
|
|
$value = trim((string) $value);
|
|
if ($value === '') {
|
|
continue;
|
|
}
|
|
$values[] = $value;
|
|
}
|
|
|
|
$otherText = trim($otherText);
|
|
if (in_array('Other', $values, true)) {
|
|
$values = array_values(array_filter($values, static fn(string $value): bool => $value !== 'Other'));
|
|
if ($otherText !== '') {
|
|
$values[] = mb_substr($otherText, 0, 100);
|
|
}
|
|
}
|
|
|
|
$unique = [];
|
|
foreach ($values as $value) {
|
|
$unique[$value] = $value;
|
|
}
|
|
|
|
return array_values($unique);
|
|
}
|
|
|
|
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'])) {
|
|
return 'Admin exception: ' . (string) ($evaluation['admin_exception']['reason_code'] ?? 'approved');
|
|
}
|
|
|
|
$codes = array_values(array_filter(array_map('strval', array_merge(
|
|
$evaluation['blocking_rule_codes'] ?? [],
|
|
$evaluation['review_rule_codes'] ?? [],
|
|
$evaluation['warning_rule_codes'] ?? []
|
|
))));
|
|
|
|
return $codes !== [] ? implode(', ', array_unique($codes)) : null;
|
|
}
|
|
|
|
private function enrollmentAuditPayload(array $payload, array $evaluation): array
|
|
{
|
|
return [
|
|
'enrollment' => $payload,
|
|
'eligibility' => [
|
|
'decision' => $evaluation['decision'] ?? null,
|
|
'can_enroll' => ! empty($evaluation['can_enroll']),
|
|
'rule_codes' => $evaluation['rule_codes'] ?? [],
|
|
'blocking_rule_codes' => $evaluation['blocking_rule_codes'] ?? [],
|
|
'review_rule_codes' => $evaluation['review_rule_codes'] ?? [],
|
|
'warning_rule_codes' => $evaluation['warning_rule_codes'] ?? [],
|
|
'admin_exception' => $evaluation['admin_exception'] ?? null,
|
|
'financial_summary' => $evaluation['financial_summary'] ?? null,
|
|
'last_name_exception_carry_forward' => $evaluation['last_name_exception_carry_forward'] ?? null,
|
|
],
|
|
];
|
|
}
|
|
|
|
private function filterEnrollmentPayloadByColumns(array $payload): array
|
|
{
|
|
return $this->filterPayloadByTableColumns($payload, 'enrollments');
|
|
}
|
|
|
|
private function filterPayloadByTableColumns(array $payload, string $table): array
|
|
{
|
|
foreach (array_keys($payload) as $column) {
|
|
if (! $this->db->fieldExists($column, $table)) {
|
|
unset($payload[$column]);
|
|
}
|
|
}
|
|
|
|
return $payload;
|
|
}
|
|
|
|
private function hasAcceptedPolicyForYear(int $parentId, string $schoolYear): bool
|
|
{
|
|
return $this->parentEnrollmentService()->hasAcceptedPolicyForYear($parentId, $schoolYear);
|
|
}
|
|
|
|
private function recordPolicyAcceptance(int $parentId, string $schoolYear, string $source): void
|
|
{
|
|
$this->parentEnrollmentService()->recordPolicyAcceptance(
|
|
$parentId,
|
|
$schoolYear,
|
|
$source,
|
|
$this->request->getIPAddress(),
|
|
$this->request->getUserAgent()->getAgentString()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* If a promotion_queue record exists for this student and the given school year,
|
|
* create/update student_class row accordingly (base section until distribution),
|
|
* and mark the queue row as applied.
|
|
*/
|
|
private function applyPromotionAssignment(int $studentId, string $year, bool $updateEnrollmentPlacement = true): void
|
|
{
|
|
try {
|
|
$this->ensureClassSectionsForYear($year);
|
|
$promo = new \App\Models\PromotionQueueModel();
|
|
$classSectionModel = new \App\Models\ClassSectionModel();
|
|
$draftModel = new StudentSectionDistributionDraftModel();
|
|
$studentClass = new \App\Models\StudentClassModel();
|
|
$previousYear = $this->previousSchoolYearName($year);
|
|
$previousDecision = $previousYear !== null
|
|
? $this->studentDecisionForYear($studentId, $previousYear)
|
|
: null;
|
|
|
|
if ($this->isFallMakeupExamDecision($previousDecision)) {
|
|
$this->keepMakeupExamStudentInPreviousGrade($studentId, $year, $previousYear);
|
|
return;
|
|
}
|
|
|
|
$draft = $draftModel->where('student_id', $studentId)
|
|
->where('school_year', $year)
|
|
->where('status', 'pending')
|
|
->first();
|
|
|
|
$row = $promo->where('student_id', $studentId)
|
|
->where('school_year_to', $year)
|
|
->first();
|
|
|
|
$targetSectionId = (int)($draft['class_section_id'] ?? 0);
|
|
if ($targetSectionId <= 0) {
|
|
$targetSectionId = (int)($row['to_class_section_id'] ?? 0);
|
|
}
|
|
|
|
$placementStatus = $draft ? 'automatic_distribution_applied' : 'manual_class_assigned';
|
|
if (!$row && !$draft && $previousYear !== null) {
|
|
$evaluation = service('enrollmentTransition')->evaluate($studentId, $previousYear, $year, 'admin');
|
|
if (!($evaluation['academic_eligible'] ?? false) || ($evaluation['blockers'] ?? []) !== []) {
|
|
log_message('warning', 'applyPromotionAssignment: transition fallback blocked for student_id=' . $studentId . ', year=' . $year . ', blockers=' . implode('; ', $evaluation['blockers'] ?? []));
|
|
return;
|
|
}
|
|
|
|
$targetSectionId = (int)($evaluation['assigned_class_section_id'] ?? 0);
|
|
if ($targetSectionId <= 0 && (int)($evaluation['assigned_grade_id'] ?? 0) > 0) {
|
|
$base = $this->baseSectionForClassYear((int)$evaluation['assigned_grade_id'], $year);
|
|
$targetSectionId = (int)($base['class_section_id'] ?? 0);
|
|
}
|
|
|
|
$placementStatus = match ((string)($evaluation['placement_status'] ?? '')) {
|
|
'automatic_distribution_pending' => 'base_section_pending_distribution',
|
|
'same_class_assigned', 'temporary_same_grade' => (string)$evaluation['placement_status'],
|
|
default => 'manual_class_assigned',
|
|
};
|
|
}
|
|
|
|
if (!$row && !$draft && $targetSectionId <= 0) {
|
|
log_message('warning', 'applyPromotionAssignment: no placement source found for student_id=' . $studentId . ', year=' . $year);
|
|
return;
|
|
}
|
|
|
|
if ($targetSectionId <= 0) {
|
|
// Resolve base section for target class (e.g., '3')
|
|
$base = $this->baseSectionForClassYear((int)($row['to_class_id'] ?? 0), $year)
|
|
?? $classSectionModel->getBaseSectionByClassId((int)($row['to_class_id'] ?? 0));
|
|
if (!$base) {
|
|
log_message('warning', 'applyPromotionAssignment: no draft or base section found for student_id=' . $studentId . ', year=' . $year);
|
|
return;
|
|
}
|
|
$targetSectionId = (int)($base['class_section_id'] ?? 0);
|
|
}
|
|
|
|
if ($targetSectionId <= 0) return;
|
|
|
|
// Upsert into student_class for the target school year
|
|
$exists = $studentClass->where('student_id', $studentId)
|
|
->where('school_year', $year)
|
|
->first();
|
|
|
|
$payload = [
|
|
'student_id' => $studentId,
|
|
'class_section_id' => $targetSectionId,
|
|
'school_year' => $year,
|
|
'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
|
|
'updated_at' => utc_now(),
|
|
];
|
|
|
|
if ($exists) {
|
|
$studentClass->update((int)$exists['id'], $payload);
|
|
} else {
|
|
$payload['created_at'] = utc_now();
|
|
$studentClass->insert($payload);
|
|
}
|
|
|
|
if ($updateEnrollmentPlacement) {
|
|
$this->db->table('enrollments')
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $year)
|
|
->whereIn('enrollment_status', ['admission under review', 'review & decision', 'payment pending', 'enrolled'])
|
|
->update([
|
|
'class_section_id' => $targetSectionId,
|
|
'assigned_class_section_id' => $targetSectionId,
|
|
'placement_status' => $placementStatus,
|
|
'updated_at' => utc_now(),
|
|
]);
|
|
}
|
|
|
|
// Mark promotion as applied when promotion_queue is the source.
|
|
if ($row) {
|
|
$promo->update((int)$row['id'], [
|
|
'status' => 'applied',
|
|
'updated_at' => utc_now(),
|
|
'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
|
|
]);
|
|
}
|
|
|
|
if ($draft) {
|
|
$draftModel->update((int)$draft['id'], [
|
|
'status' => 'applied',
|
|
'applied_at' => utc_now(),
|
|
'updated_at' => utc_now(),
|
|
]);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
log_message('error', 'applyPromotionAssignment failed: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
private function isReturningReEnrollmentStudent(int $studentId, string $targetSchoolYear): bool
|
|
{
|
|
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
|
if ($studentId <= 0 || $previousSchoolYear === null) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
if ($this->db->tableExists('student_decisions')) {
|
|
$decision = $this->db->table('student_decisions')
|
|
->select('id')
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $previousSchoolYear)
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
if ($decision !== null) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if ($this->db->tableExists('student_class')) {
|
|
$assignment = $this->db->table('student_class')
|
|
->select('id')
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $previousSchoolYear)
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
if ($assignment !== null) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if ($this->db->tableExists('enrollments')) {
|
|
$enrollment = $this->db->table('enrollments')
|
|
->select('id')
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $previousSchoolYear)
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
if ($enrollment !== null) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if ($this->db->tableExists('promotion_queue')) {
|
|
$promotion = $this->db->table('promotion_queue')
|
|
->select('id')
|
|
->where('student_id', $studentId)
|
|
->where('school_year_from', $previousSchoolYear)
|
|
->where('school_year_to', $targetSchoolYear)
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
if ($promotion !== null) {
|
|
return true;
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
log_message('error', 'isReturningReEnrollmentStudent failed: ' . $e->getMessage());
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function isFallMakeupExamDecision(?array $decisionRow): bool
|
|
{
|
|
return DeliberationDecision::normalize($decisionRow['decision'] ?? null) === DeliberationDecision::MAKE_UP_EXAM;
|
|
}
|
|
|
|
private function keepMakeupExamStudentInPreviousGrade(int $studentId, string $targetSchoolYear, ?string $previousSchoolYear): void
|
|
{
|
|
if ($studentId <= 0 || $targetSchoolYear === '' || $previousSchoolYear === null) {
|
|
return;
|
|
}
|
|
|
|
$previousAssignment = $this->db->table('student_class sc')
|
|
->select('sc.class_section_id, cs.class_section_name')
|
|
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
|
|
->where('sc.student_id', $studentId)
|
|
->where('sc.school_year', $previousSchoolYear)
|
|
->where('sc.class_section_id IS NOT NULL', null, false)
|
|
->orderBy('sc.updated_at', 'DESC')
|
|
->orderBy('sc.id', 'DESC')
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
if ($previousAssignment === null) {
|
|
$this->openMakeupExamPlacementFlag($studentId, $targetSchoolYear, null, null);
|
|
log_message('warning', 'No previous class assignment found for fall make-up exam student_id=' . $studentId . ', year=' . $previousSchoolYear);
|
|
return;
|
|
}
|
|
|
|
$previousSectionId = (int) ($previousAssignment['class_section_id'] ?? 0);
|
|
$previousSectionName = trim((string) ($previousAssignment['class_section_name'] ?? ''));
|
|
$targetSectionId = $this->equivalentClassSectionIdForYear($previousSectionId, $previousSectionName, $targetSchoolYear);
|
|
|
|
if ($targetSectionId <= 0) {
|
|
$targetSectionId = $previousSectionId;
|
|
}
|
|
|
|
if ($targetSectionId <= 0) {
|
|
$this->openMakeupExamPlacementFlag($studentId, $targetSchoolYear, null, $previousSectionName !== '' ? $previousSectionName : null);
|
|
return;
|
|
}
|
|
|
|
$now = utc_now();
|
|
$updatedBy = (int) (session()->get('user_id') ?? 0) ?: null;
|
|
$studentClass = new \App\Models\StudentClassModel();
|
|
$existing = $studentClass->where('student_id', $studentId)
|
|
->where('school_year', $targetSchoolYear)
|
|
->first();
|
|
|
|
$payload = [
|
|
'student_id' => $studentId,
|
|
'class_section_id' => $targetSectionId,
|
|
'school_year' => $targetSchoolYear,
|
|
'updated_by' => $updatedBy,
|
|
'updated_at' => $now,
|
|
'description' => 'Held at previous grade pending fall make-up exam result.',
|
|
];
|
|
|
|
if ($existing) {
|
|
$studentClass->update((int) $existing['id'], $payload);
|
|
} else {
|
|
$payload['created_at'] = $now;
|
|
$studentClass->insert($payload);
|
|
}
|
|
|
|
$this->db->table('enrollments')
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $targetSchoolYear)
|
|
->whereIn('enrollment_status', ['admission under review', 'review & decision', 'payment pending', 'enrolled'])
|
|
->update([
|
|
'class_section_id' => $targetSectionId,
|
|
'updated_at' => $now,
|
|
]);
|
|
|
|
$targetSectionName = $this->classSectionNameById($targetSectionId) ?? $previousSectionName;
|
|
$this->openMakeupExamPlacementFlag(
|
|
$studentId,
|
|
$targetSchoolYear,
|
|
$targetSectionId,
|
|
$targetSectionName !== '' ? $targetSectionName : null
|
|
);
|
|
}
|
|
|
|
private function equivalentClassSectionIdForYear(int $sourceSectionId, string $sourceSectionName, string $targetSchoolYear): int
|
|
{
|
|
if ($sourceSectionName !== '' && $this->db->fieldExists('school_year', 'classSection')) {
|
|
$row = $this->db->table('classSection')
|
|
->select('class_section_id')
|
|
->where('class_section_name', $sourceSectionName)
|
|
->where('school_year', $targetSchoolYear)
|
|
->orderBy('id', 'DESC')
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
if ($row !== null && (int) ($row['class_section_id'] ?? 0) > 0) {
|
|
return (int) $row['class_section_id'];
|
|
}
|
|
}
|
|
|
|
return $sourceSectionId;
|
|
}
|
|
|
|
private function classSectionNameById(int $classSectionId): ?string
|
|
{
|
|
if ($classSectionId <= 0) {
|
|
return null;
|
|
}
|
|
|
|
$row = $this->db->table('classSection')
|
|
->select('class_section_name')
|
|
->where('class_section_id', $classSectionId)
|
|
->orderBy('id', 'DESC')
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
$name = trim((string) ($row['class_section_name'] ?? ''));
|
|
|
|
return $name !== '' ? $name : null;
|
|
}
|
|
|
|
private function baseSectionForClassYear(int $classId, string $schoolYear): ?array
|
|
{
|
|
$this->ensureClassSectionsForYear($schoolYear);
|
|
if ($classId <= 0 || $schoolYear === '' || ! $this->db->tableExists('classSection')) {
|
|
return null;
|
|
}
|
|
|
|
$builder = $this->db->table('classSection')
|
|
->select('class_section_id, class_section_name, class_id')
|
|
->where('class_id', $classId)
|
|
->where("class_section_name NOT LIKE '%-%'", null, false)
|
|
->orderBy('id', 'ASC')
|
|
->limit(1);
|
|
|
|
if ($this->db->fieldExists('school_year', 'classSection')) {
|
|
$builder->where('school_year', $schoolYear);
|
|
}
|
|
|
|
$row = $builder->get()->getRowArray();
|
|
|
|
return $row !== null && (int) ($row['class_section_id'] ?? 0) > 0 ? $row : null;
|
|
}
|
|
|
|
private function ensureClassSectionsForYear(string $targetSchoolYear): void
|
|
{
|
|
$targetSchoolYear = trim($targetSchoolYear);
|
|
if ($targetSchoolYear === '' || ! $this->db->tableExists('classSection') || ! $this->db->fieldExists('school_year', 'classSection')) {
|
|
return;
|
|
}
|
|
|
|
if ($this->db->table('classSection')->where('school_year', $targetSchoolYear)->countAllResults() > 0) {
|
|
return;
|
|
}
|
|
|
|
$sourceSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
|
if ($sourceSchoolYear === null) {
|
|
return;
|
|
}
|
|
|
|
$sourceRows = $this->db->table('classSection')
|
|
->select('class_id, class_section_id, class_section_name')
|
|
->where('school_year', $sourceSchoolYear)
|
|
->orderBy('id', 'ASC')
|
|
->get()
|
|
->getResultArray();
|
|
|
|
$now = utc_now();
|
|
foreach ($sourceRows as $row) {
|
|
$classSectionId = (int)($row['class_section_id'] ?? 0);
|
|
if ($classSectionId <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$exists = $this->db->table('classSection')
|
|
->where('school_year', $targetSchoolYear)
|
|
->where('class_section_id', $classSectionId)
|
|
->countAllResults();
|
|
if ($exists > 0) {
|
|
continue;
|
|
}
|
|
|
|
$this->db->table('classSection')->insert([
|
|
'class_id' => (int)($row['class_id'] ?? 0),
|
|
'class_section_id' => $classSectionId,
|
|
'class_section_name' => (string)($row['class_section_name'] ?? ''),
|
|
'school_year' => $targetSchoolYear,
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
]);
|
|
}
|
|
}
|
|
|
|
private function openMakeupExamPlacementFlag(
|
|
int $studentId,
|
|
string $targetSchoolYear,
|
|
?int $classSectionId,
|
|
?string $classSectionName
|
|
): void {
|
|
if ($studentId <= 0 || $targetSchoolYear === '' || ! $this->db->tableExists('current_flag')) {
|
|
return;
|
|
}
|
|
|
|
$student = $this->db->table('students')
|
|
->select('firstname, lastname')
|
|
->where('id', $studentId)
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray();
|
|
$studentName = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? ''));
|
|
$studentName = $studentName !== '' ? $studentName : 'Student ID ' . $studentId;
|
|
$heldGradeText = $classSectionName !== null && trim($classSectionName) !== ''
|
|
? ' Student is currently held in ' . trim($classSectionName) . '.'
|
|
: '';
|
|
$description = 'Fall make-up exam enrollment: keep student at the closed-year grade until the exam is passed. After passing, place the student in the new grade.' . $heldGradeText;
|
|
|
|
$existing = $this->db->table('current_flag')
|
|
->select('id')
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $targetSchoolYear)
|
|
->where('flag', 'grade')
|
|
->where('flag_state', 'Open')
|
|
->where('open_description', $description)
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
if ($existing !== null) {
|
|
return;
|
|
}
|
|
|
|
$now = utc_now();
|
|
$this->db->table('current_flag')->insert([
|
|
'student_id' => $studentId,
|
|
'student_name' => $studentName,
|
|
'grade' => $classSectionId !== null && $classSectionId > 0 ? (string) $classSectionId : '',
|
|
'flag' => 'grade',
|
|
'flag_datetime' => $now,
|
|
'flag_state' => 'Open',
|
|
'updated_by_open' => (int) (session()->get('user_id') ?? 0) ?: null,
|
|
'open_description' => $description,
|
|
'semester' => (string) ($this->semester ?? ''),
|
|
'school_year' => $targetSchoolYear,
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
]);
|
|
}
|
|
|
|
private function studentDecisionForYear(int $studentId, string $schoolYear): ?array
|
|
{
|
|
if ($studentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('student_decisions')) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return $this->db->table('student_decisions')
|
|
->select('decision, source, notes, class_section_name')
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $schoolYear)
|
|
->orderBy('updated_at', 'DESC')
|
|
->orderBy('id', 'DESC')
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray() ?: null;
|
|
} catch (\Throwable $e) {
|
|
log_message('error', 'studentDecisionForYear failed: ' . $e->getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function fallMakeupExamDateForYear(string $schoolYear): ?string
|
|
{
|
|
if ($schoolYear === '' || ! $this->db->tableExists('school_years') || ! $this->db->fieldExists('fall_makeup_exam_on', 'school_years')) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$row = $this->db->table('school_years')
|
|
->select('fall_makeup_exam_on')
|
|
->where('name', $schoolYear)
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
$date = trim((string) ($row['fall_makeup_exam_on'] ?? ''));
|
|
return $date !== '' ? $date : null;
|
|
} catch (\Throwable $e) {
|
|
log_message('error', 'fallMakeupExamDateForYear failed: ' . $e->getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function blockedEnrollmentDecisionMessages(array $studentIds, string $targetSchoolYear): array
|
|
{
|
|
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
|
|
|
$messages = [];
|
|
foreach (array_unique($studentIds) as $studentId) {
|
|
$studentId = (int) $studentId;
|
|
if ($studentId <= 0) {
|
|
continue;
|
|
}
|
|
|
|
if ($previousSchoolYear === null) {
|
|
$messages[] = 'Student ID ' . $studentId . ': enrollment cannot be submitted because the closing school year could not be determined.';
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$studentName = $this->studentNameForEnrollmentMessage($studentId);
|
|
$parentId = (int) session()->get('user_id');
|
|
$evaluation = service('enrollmentTransition')->evaluateForParent($parentId, $studentId, $previousSchoolYear, $targetSchoolYear, 'parent');
|
|
foreach ($evaluation['blockers'] ?? [] as $blocker) {
|
|
$blocker = trim((string) $blocker);
|
|
if ($blocker !== '') {
|
|
$messages[] = $this->messageWithStudentName($studentName, $blocker);
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
log_message('error', 'Enrollment transition evaluation failed: ' . $e->getMessage());
|
|
$messages[] = 'Student ID ' . $studentId . ': enrollment eligibility could not be evaluated. Please contact the administration.';
|
|
}
|
|
}
|
|
|
|
return $messages;
|
|
}
|
|
|
|
private function enrollmentEligibilityMessageForStudent(
|
|
array $student,
|
|
?array $decisionRow,
|
|
string $targetSchoolYear,
|
|
?string $fallMakeupExamOn = null
|
|
): array {
|
|
return EnrollmentEligibility::parentDecisionMessage($student, $decisionRow, $targetSchoolYear, $fallMakeupExamOn);
|
|
}
|
|
|
|
private function transitionEvaluationForStudent(int $parentId, int $studentId, string $previousSchoolYear, string $selectedYear): ?array
|
|
{
|
|
try {
|
|
return service('enrollmentTransition')->evaluateForParent($parentId, $studentId, $previousSchoolYear, $selectedYear, 'parent');
|
|
} catch (\Throwable $e) {
|
|
log_message('error', 'Enrollment transition evaluation failed for student ' . $studentId . ': ' . $e->getMessage());
|
|
|
|
return [
|
|
'blockers' => ['Enrollment eligibility could not be evaluated. Please contact the administration.'],
|
|
'warnings' => [],
|
|
'placement_status' => 'unknown',
|
|
'deliberation_decision' => null,
|
|
'parent_enrollment_allowed' => false,
|
|
'student_self_enrollment_allowed' => false,
|
|
'assigned_grade_id' => null,
|
|
'assigned_class_section_id' => null,
|
|
];
|
|
}
|
|
}
|
|
|
|
private function readonlyEnrollmentEvaluation(?array $decisionRow): array
|
|
{
|
|
$decision = DeliberationDecision::normalize($decisionRow['deliberation_decision_standard'] ?? null)
|
|
?? DeliberationDecision::normalize($decisionRow['decision'] ?? null);
|
|
|
|
return [
|
|
'blockers' => [],
|
|
'warnings' => [],
|
|
'placement_status' => 'readonly',
|
|
'deliberation_decision' => $decision,
|
|
'decision_label' => DeliberationDecision::display($decisionRow['decision'] ?? '') ?: 'Pending',
|
|
'parent_enrollment_allowed' => false,
|
|
'student_self_enrollment_allowed' => false,
|
|
'assigned_grade_id' => null,
|
|
'assigned_class_section_id' => null,
|
|
];
|
|
}
|
|
|
|
private function eligibilityMessageFromTransition(array $student, ?array $evaluation, ?string $fallMakeupExamOn): array
|
|
{
|
|
if ($this->hasSettledParentEnrollmentStatus(
|
|
(string) ($student['enrollment_status'] ?? ''),
|
|
(string) ($student['admission_status'] ?? '')
|
|
)) {
|
|
return [
|
|
'message' => EnrollmentEligibility::alreadyEnrolledMessage((string) ($student['enrollment_status'] ?? '')),
|
|
'blocking' => true,
|
|
'level' => 'info',
|
|
'primary_block_reason' => 'ALREADY_ENROLLED',
|
|
];
|
|
}
|
|
|
|
if ($evaluation === null) {
|
|
return $this->enrollmentEligibilityMessageForStudent(
|
|
$student,
|
|
$student['previous_year_decision'] ?? null,
|
|
$this->currentSchoolYearName((string) ($this->schoolYear ?? '')),
|
|
$fallMakeupExamOn
|
|
);
|
|
}
|
|
|
|
if (! empty($evaluation['can_enroll']) && ! empty($evaluation['admin_exception'])) {
|
|
return [
|
|
'message' => 'Enrollment has been authorized by administration.',
|
|
'blocking' => false,
|
|
'level' => 'info',
|
|
];
|
|
}
|
|
|
|
if (($evaluation['can_enroll'] ?? false) === false && ! empty($evaluation['primary_parent_message'])) {
|
|
return [
|
|
'message' => (string) $evaluation['primary_parent_message'],
|
|
'blocking' => true,
|
|
'level' => 'danger',
|
|
'primary_block_reason' => $evaluation['primary_block_reason'] ?? null,
|
|
];
|
|
}
|
|
|
|
$blockers = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['blockers'] ?? []))));
|
|
if ($blockers !== []) {
|
|
$name = $this->studentNameFromRow($student);
|
|
$blockers = array_map(fn(string $message): string => $this->messageWithStudentName($name, $message), $blockers);
|
|
|
|
return [
|
|
'message' => implode(' ', $blockers),
|
|
'blocking' => true,
|
|
'level' => 'danger',
|
|
];
|
|
}
|
|
|
|
$warnings = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['warnings'] ?? []))));
|
|
if ($warnings !== []) {
|
|
return [
|
|
'message' => implode(' ', $warnings),
|
|
'blocking' => false,
|
|
'level' => 'warning',
|
|
];
|
|
}
|
|
|
|
$decision = (string) ($evaluation['deliberation_decision'] ?? '');
|
|
if ($decision === DeliberationDecision::MAKE_UP_EXAM) {
|
|
$name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? ''));
|
|
$dateText = $fallMakeupExamOn !== null ? ' on ' . local_date($fallMakeupExamOn, 'm-d-Y') : '';
|
|
return [
|
|
'message' => ($name !== '' ? $name : 'The student') . ' may complete enrollment now and will initially remain in the same grade until the make-up exam' . $dateText . ' is resolved by administration.',
|
|
'blocking' => false,
|
|
'level' => 'warning',
|
|
];
|
|
}
|
|
|
|
return ['message' => '', 'blocking' => false, 'level' => 'info'];
|
|
}
|
|
|
|
private function studentNameFromRow(array $student): string
|
|
{
|
|
$name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? ''));
|
|
|
|
return $name !== '' ? $name : 'The student';
|
|
}
|
|
|
|
private function studentNameForEnrollmentMessage(int $studentId): string
|
|
{
|
|
try {
|
|
$row = $this->db->table('students')
|
|
->select('firstname, lastname')
|
|
->where('id', $studentId)
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
return is_array($row) ? $this->studentNameFromRow($row) : 'Student ID ' . $studentId;
|
|
} catch (\Throwable $e) {
|
|
log_message('error', 'Unable to load student name for enrollment blocker: ' . $e->getMessage());
|
|
|
|
return 'Student ID ' . $studentId;
|
|
}
|
|
}
|
|
|
|
private function messageWithStudentName(string $studentName, string $message): string
|
|
{
|
|
$studentName = trim($studentName) !== '' ? trim($studentName) : 'The student';
|
|
$message = trim($message);
|
|
|
|
if ($message === '' || str_contains($message, $studentName)) {
|
|
return $message;
|
|
}
|
|
|
|
return $studentName . ': ' . $message;
|
|
}
|
|
|
|
private function expectedPlacementLabel(?array $evaluation): string
|
|
{
|
|
if ($evaluation === null || ($evaluation['blockers'] ?? []) !== [] && empty($evaluation['assigned_grade_id'])) {
|
|
return 'Pending';
|
|
}
|
|
|
|
$status = (string) ($evaluation['placement_status'] ?? '');
|
|
return match ($status) {
|
|
'automatic_distribution_pending' => $this->gradeLabel($this->classNameById((int) ($evaluation['assigned_grade_id'] ?? 0))),
|
|
'same_class_assigned', 'temporary_same_grade' => $this->classSectionNameById((int) ($evaluation['assigned_class_section_id'] ?? 0)) ?: 'Same grade',
|
|
'manual_class_required' => 'Same grade - administration must assign class',
|
|
'temporary_manual_class_required' => 'Same grade initially - administration must assign temporary class',
|
|
'exit_required' => 'Completion or exit process required',
|
|
default => 'Pending',
|
|
};
|
|
}
|
|
|
|
private function requiredActionLabel(?array $evaluation): string
|
|
{
|
|
if ($evaluation === null) {
|
|
return 'Complete re-enrollment before the registration deadline.';
|
|
}
|
|
|
|
$state = $this->parentEnrollmentStateFromEvaluation($evaluation, null);
|
|
return match ($state) {
|
|
'Enroll' => 'Complete re-enrollment before the registration deadline.',
|
|
'Eligible with follow-up' => 'Complete re-enrollment and follow the listed next step.',
|
|
'Already submitted' => 'Already submitted',
|
|
'Action needed' => 'Action needed: pay the previous-year balance or contact administration.',
|
|
'Under review' => 'Contact the school administration.',
|
|
default => 'Contact administration',
|
|
};
|
|
}
|
|
|
|
private function parentEnrollmentState(array $student): string
|
|
{
|
|
$status = strtolower(trim((string) ($student['enrollment_status'] ?? '')));
|
|
if ($this->hasSettledParentEnrollmentStatus($status, (string) ($student['admission_status'] ?? ''))) {
|
|
return 'Already submitted';
|
|
}
|
|
|
|
return $this->parentEnrollmentStateFromEvaluation(
|
|
is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : null,
|
|
$status
|
|
);
|
|
}
|
|
|
|
private function parentEnrollmentStateFromEvaluation(?array $evaluation, ?string $enrollmentStatus): string
|
|
{
|
|
if ($evaluation === null) {
|
|
return 'Contact administration';
|
|
}
|
|
|
|
$decision = (string) ($evaluation['decision'] ?? '');
|
|
$codes = array_map('strval', $evaluation['blocking_rule_codes'] ?? []);
|
|
|
|
if ($decision === 'ALREADY_ENROLLED' || $enrollmentStatus === 'already enrolled') {
|
|
return 'Already submitted';
|
|
}
|
|
|
|
if (! empty($evaluation['can_enroll']) || ! empty($evaluation['parent_enrollment_allowed']) || $decision === 'EXCEPTION_ELIGIBLE' || $decision === 'ELIGIBLE') {
|
|
if ($decision === 'ELIGIBLE_WITH_WARNING' || ($evaluation['warning_rule_codes'] ?? []) !== []) {
|
|
return 'Eligible with follow-up';
|
|
}
|
|
return 'Enroll';
|
|
}
|
|
|
|
if ($decision === 'ELIGIBLE_WITH_WARNING') {
|
|
return 'Eligible with follow-up';
|
|
}
|
|
|
|
if (in_array('OUTSTANDING_BALANCE_BLOCKED', $codes, true)
|
|
|| in_array('FINANCE_APPROVAL_REQUIRED', $codes, true)
|
|
|| in_array('SIBLING_LAST_NAME_MISMATCH', $codes, true)
|
|
) {
|
|
return 'Action needed';
|
|
}
|
|
|
|
if ($decision === 'REVIEW_REQUIRED' || in_array((string) ($evaluation['deliberation_decision'] ?? ''), [
|
|
DeliberationDecision::EXPELLED,
|
|
DeliberationDecision::WITHDRAWN,
|
|
DeliberationDecision::DEFERRED_DECISION,
|
|
], true)) {
|
|
return 'Under review';
|
|
}
|
|
|
|
return 'Contact administration';
|
|
}
|
|
|
|
private function hasSettledParentEnrollmentStatus(string $status, string $admissionStatus = ''): bool
|
|
{
|
|
if (strtolower(trim($admissionStatus)) === 'accepted') {
|
|
return true;
|
|
}
|
|
|
|
return in_array(strtolower(trim($status)), [
|
|
'admission under review',
|
|
'review & decision',
|
|
'payment pending',
|
|
'enrolled',
|
|
'waitlist',
|
|
'withdraw under review',
|
|
'refund pending',
|
|
], true);
|
|
}
|
|
|
|
private function familyFinancialSummary(int $parentId, ?string $previousSchoolYear, string $selectedYear, array $students = []): array
|
|
{
|
|
$tuitionDue = $this->enrollmentTuitionDue($students);
|
|
$summary = service('enrollmentTransition')->getEnrollmentFinancialSummary(
|
|
$parentId,
|
|
$previousSchoolYear ?? '',
|
|
$selectedYear,
|
|
$tuitionDue
|
|
);
|
|
$behavior = (string) ($summary['balance_behavior'] ?? 'submission_blocked_until_payment');
|
|
$schoolYearConfig = $this->schoolYearConfig($selectedYear);
|
|
$summary['policy_message'] = $this->financialPolicyMessage(
|
|
$behavior,
|
|
(string) ($schoolYearConfig['financial_policy_message'] ?? '')
|
|
);
|
|
|
|
return $summary;
|
|
}
|
|
|
|
public function enrollmentEligibilityRefresh()
|
|
{
|
|
$parentId = (int) session()->get('user_id');
|
|
if ($parentId <= 0) {
|
|
return $this->response->setStatusCode(401)->setJSON(['error' => 'Unauthorized']);
|
|
}
|
|
|
|
$selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
|
return $this->response->setJSON(
|
|
$this->parentEnrollmentService()->eligibilityRefreshData($parentId, $selectedYear)
|
|
);
|
|
}
|
|
|
|
private function enrollmentTuitionDue(array $students): float
|
|
{
|
|
$tuitionStudents = [];
|
|
$existingTuitionStudentCount = 0;
|
|
|
|
foreach ($students as $student) {
|
|
if ($this->countsTowardCurrentYearTuition($student)) {
|
|
$existingTuitionStudentCount++;
|
|
continue;
|
|
}
|
|
|
|
$evaluation = is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : [];
|
|
if (
|
|
($evaluation['can_enroll'] ?? false) !== true
|
|
&& ($evaluation['parent_enrollment_allowed'] ?? false) !== true
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
$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;
|
|
}
|
|
|
|
if ($existingTuitionStudentCount > 0) {
|
|
$additionalStudentFee = (float) ($this->configModel->getConfig('second_student_fee') ?? 280);
|
|
|
|
return round(count($tuitionStudents) * $additionalStudentFee, 2);
|
|
}
|
|
|
|
return (new FeeCalculationService())->calculateEnrollmentTuition($tuitionStudents);
|
|
}
|
|
|
|
private function countsTowardCurrentYearTuition(array $student): bool
|
|
{
|
|
$status = strtolower(trim((string) ($student['enrollment_status'] ?? '')));
|
|
if (in_array($status, ['withdrawn', 'withdraw under review', 'refund pending', 'denied', 'not enrolled'], true)) {
|
|
return false;
|
|
}
|
|
|
|
if (in_array($status, ['admission under review', 'review & decision', 'payment pending', 'enrolled', 'waitlist'], true)) {
|
|
return true;
|
|
}
|
|
|
|
return strtolower(trim((string) ($student['admission_status'] ?? ''))) === 'accepted';
|
|
}
|
|
|
|
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),
|
|
'tuition_due_at_registration' => round((float) ($schoolYearConfig['tuition_due_at_registration'] ?? 0), 2),
|
|
];
|
|
}
|
|
|
|
private function financialPolicyMessage(string $behavior, string $configured): string
|
|
{
|
|
$configured = trim($configured);
|
|
if ($configured !== '') {
|
|
return $configured;
|
|
}
|
|
|
|
return match ($behavior) {
|
|
'payment_plan_required' => 'Registration may continue under an approved payment plan.',
|
|
'submission_allowed_confirmation_blocked' => 'Registration may be submitted, but it will not be confirmed until the balance is settled.',
|
|
'submission_blocked_until_payment' => 'The balance must be paid before registration can be submitted.',
|
|
'admin_approval_required' => 'Please contact the finance office to arrange an approved exception.',
|
|
default => 'The previous-year balance must be paid before registration can be submitted.',
|
|
};
|
|
}
|
|
|
|
private function auditParentStudentFieldChanges(array $original, array $updated, int $parentId, string $source): void
|
|
{
|
|
if (! $this->db->tableExists('enrollment_transition_audits')) {
|
|
return;
|
|
}
|
|
|
|
$studentId = (int) ($original['id'] ?? 0);
|
|
if ($studentId <= 0) {
|
|
return;
|
|
}
|
|
|
|
$fields = ['firstname', 'lastname', 'dob'];
|
|
$changes = [];
|
|
foreach ($fields as $field) {
|
|
$oldValue = trim((string) ($original[$field] ?? ''));
|
|
$newValue = trim((string) ($updated[$field] ?? ''));
|
|
if ($oldValue !== $newValue) {
|
|
$changes[$field] = [
|
|
'old_value' => $oldValue,
|
|
'new_value' => $newValue,
|
|
'changed' => true,
|
|
'changed_by' => $parentId,
|
|
'changed_at' => date('Y-m-d H:i:s'),
|
|
'source' => $source,
|
|
];
|
|
}
|
|
}
|
|
|
|
if ($changes === []) {
|
|
return;
|
|
}
|
|
|
|
$this->db->table('enrollment_transition_audits')->insert([
|
|
'student_id' => $studentId,
|
|
'school_year' => (string) ($updated['school_year'] ?? $original['school_year'] ?? date('Y')),
|
|
'source_school_year' => null,
|
|
'action' => 'parent_student_field_edit',
|
|
'performed_by' => $parentId,
|
|
'original_values_json' => json_encode($original, JSON_UNESCAPED_SLASHES),
|
|
'new_values_json' => json_encode(['changes' => $changes, 'updated' => $updated], JSON_UNESCAPED_SLASHES),
|
|
'reason' => $source,
|
|
'created_at' => date('Y-m-d H:i:s'),
|
|
]);
|
|
}
|
|
|
|
private function parentEditAffectsEligibility(array $original, array $updated): bool
|
|
{
|
|
return trim((string) ($original['dob'] ?? '')) !== trim((string) ($updated['dob'] ?? ''))
|
|
|| trim((string) ($original['lastname'] ?? '')) !== trim((string) ($updated['lastname'] ?? ''));
|
|
}
|
|
|
|
private function recheckEligibilityAfterParentEdit(int $studentId, int $parentId, string $targetSchoolYear): void
|
|
{
|
|
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
|
if ($previousSchoolYear === null) {
|
|
return;
|
|
}
|
|
|
|
$evaluation = service('enrollmentTransition')->evaluateForParent(
|
|
$parentId,
|
|
$studentId,
|
|
$previousSchoolYear,
|
|
$targetSchoolYear,
|
|
'parent'
|
|
);
|
|
|
|
if (($evaluation['can_enroll'] ?? false) === true || ($evaluation['parent_enrollment_allowed'] ?? false) === true) {
|
|
return;
|
|
}
|
|
|
|
$message = (string) ($evaluation['primary_parent_message'] ?? 'Updated student information affects enrollment eligibility.');
|
|
service('enrollmentTransition')->logEnrollmentBlock($evaluation, 'parent_student_edit', $parentId, $parentId);
|
|
session()->setFlashdata('warning', $message);
|
|
}
|
|
|
|
private function schoolYearConfig(string $schoolYear): array
|
|
{
|
|
if ($schoolYear === '' || ! $this->db->tableExists('school_years')) {
|
|
return [];
|
|
}
|
|
|
|
return $this->db->table('school_years')
|
|
->where('name', $schoolYear)
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray() ?: [];
|
|
}
|
|
|
|
private function classNameById(int $classId): string
|
|
{
|
|
if ($classId <= 0 || ! $this->db->tableExists('classes')) {
|
|
return 'Assigned grade';
|
|
}
|
|
|
|
$row = $this->db->table('classes')
|
|
->select('class_name')
|
|
->where('id', $classId)
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
return trim((string) ($row['class_name'] ?? '')) ?: 'Assigned grade';
|
|
}
|
|
|
|
private function gradeLabel(string $className): string
|
|
{
|
|
$className = trim($className);
|
|
if ($className === '') {
|
|
return 'Assigned grade';
|
|
}
|
|
|
|
return preg_match('/^grade\b/i', $className) === 1 ? $className : 'Grade ' . $className;
|
|
}
|
|
|
|
private function previousSchoolYearName(string $schoolYear): ?string
|
|
{
|
|
$schoolYear = trim($schoolYear);
|
|
|
|
if (! preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $matches)) {
|
|
return null;
|
|
}
|
|
|
|
return ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1);
|
|
}
|
|
|
|
public function enrollSuccess()
|
|
{
|
|
return view('/parent/enroll_success');
|
|
}
|
|
|
|
public function enrollFailure()
|
|
{
|
|
echo view('/parent/enroll_failure');
|
|
}
|
|
|
|
public function payment()
|
|
{
|
|
// Get the logged-in user's ID
|
|
$userId = session()->get('user_id');
|
|
|
|
$invoices = $this->parentPaymentService->invoicesForParent((int) $userId, true);
|
|
|
|
// Pass the invoices data to the view
|
|
return view('/parent/payment', [
|
|
'invoices' => $invoices
|
|
]);
|
|
}
|
|
|
|
public function paymentHandler()
|
|
{
|
|
$parentId = session()->get('user_id');
|
|
|
|
$this->parentPaymentService->markTuitionPaidForParent((int) $parentId);
|
|
|
|
// Clear the total tuition fee from the session
|
|
session()->remove('total_tuition_fee');
|
|
|
|
// Set success message in session
|
|
session()->setFlashdata('success', 'Tuition fees paid successfully');
|
|
|
|
return redirect()->to('/parent/payment_success');
|
|
}
|
|
|
|
public function messageParent()
|
|
{
|
|
return view('/parent/parent_message');
|
|
}
|
|
|
|
public function successMessage()
|
|
{
|
|
// Load the success message view
|
|
return view('/parent/success_message');
|
|
}
|
|
|
|
public function addSecondParent()
|
|
{
|
|
return view('/parent/add_second_parent');
|
|
}
|
|
|
|
public function createUserFromParentOrAuthorizedUser($userData, $relationToStudent)
|
|
{
|
|
return $this->parentAccountService->createRelatedUser(
|
|
(array) $userData,
|
|
(string) $relationToStudent,
|
|
(string) $this->semester,
|
|
(string) $this->schoolYear
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Updates or inserts an authorized user record based on user ID and email.
|
|
*
|
|
* @param int $userId The ID of the parent/user who is authorizing access.
|
|
* @param array $data Contains 'email', 'name', and any other required fields.
|
|
*/
|
|
protected function updateAuthorizedUsers($userId, $data)
|
|
{
|
|
$this->parentAccountService->updateAuthorizedUsers((int) $userId, (array) $data);
|
|
}
|
|
|
|
public function profile($id)
|
|
{
|
|
if (! $this->canAccessUserRecord((int) $id)) {
|
|
return redirect()->to('/access_denied');
|
|
}
|
|
|
|
// Fetch the user's data based on the given ID
|
|
$user = $this->userModel->find($id);
|
|
|
|
// Check if user exists
|
|
if (!$user) {
|
|
return redirect()->to('/')->with('error', 'User not found');
|
|
}
|
|
|
|
// Load the profile view with the user's data
|
|
return view('/profile', ['user' => $user]);
|
|
}
|
|
|
|
public function updateProfile($id)
|
|
{
|
|
if (! $this->canAccessUserRecord((int) $id)) {
|
|
return redirect()->to('/access_denied');
|
|
}
|
|
|
|
$user = $this->userModel->find($id);
|
|
|
|
// Step 1: Check if user exists
|
|
if (!$user) {
|
|
return redirect()->to('/')->with('error', 'User not found');
|
|
}
|
|
|
|
// Step 2: Define validation rules
|
|
$validation = \Config\Services::validation();
|
|
$validation->setRules([
|
|
'firstname' => [
|
|
'label' => 'First Name',
|
|
'rules' => 'required|min_length[3]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]',
|
|
'errors' => [
|
|
'regex_match' => 'First name must only contain letters, spaces, and dashes.'
|
|
]
|
|
],
|
|
'lastname' => [
|
|
'label' => 'Last Name',
|
|
'rules' => 'required|min_length[3]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]',
|
|
'errors' => [
|
|
'regex_match' => 'Last name must only contain letters, spaces, and dashes.'
|
|
]
|
|
],
|
|
'cellphone' => [
|
|
'label' => 'Cell Phone',
|
|
'rules' => 'required|regex_match[/^\d{10}$/]',
|
|
'errors' => [
|
|
'regex_match' => 'Cell phone must be exactly 10 digits.'
|
|
]
|
|
],
|
|
'address_street' => [
|
|
'label' => 'Street Address',
|
|
'rules' => 'required|min_length[5]|max_length[255]'
|
|
],
|
|
'city' => [
|
|
'label' => 'City',
|
|
'rules' => 'required|min_length[2]|max_length[100]'
|
|
],
|
|
'state' => [
|
|
'label' => 'State',
|
|
'rules' => 'required|min_length[2]|max_length[100]'
|
|
],
|
|
'zip' => [
|
|
'label' => 'ZIP Code',
|
|
'rules' => 'required|regex_match[/^\d{5}$/]',
|
|
'errors' => [
|
|
'regex_match' => 'ZIP code must be exactly 5 digits.'
|
|
]
|
|
]
|
|
]);
|
|
|
|
// Step 3: Validate input; reload view with error messages if validation fails
|
|
if (!$this->validate($validation->getRules())) {
|
|
return view('/profile', [
|
|
'user' => $user,
|
|
'validation' => $this->validator,
|
|
]);
|
|
}
|
|
|
|
// Step 4: Prepare cleaned and validated data for update
|
|
$updatedData = [
|
|
'firstname' => ucfirst(strtolower($this->request->getPost('firstname'))),
|
|
'lastname' => ucfirst(strtolower($this->request->getPost('lastname'))),
|
|
'cellphone' => $this->request->getPost('cellphone'),
|
|
'address_street' => $this->request->getPost('address_street'),
|
|
'city' => ucfirst(strtolower($this->request->getPost('city'))),
|
|
'state' => strtoupper($this->request->getPost('state')),
|
|
'zip' => $this->request->getPost('zip'),
|
|
];
|
|
|
|
// Step 5: Update user profile in the database
|
|
$this->userModel->update($id, $updatedData);
|
|
|
|
// Step 6: Redirect with success message
|
|
session()->setFlashdata('success', 'Profile updated successfully');
|
|
return redirect()->to('/profile/' . $id);
|
|
}
|
|
|
|
public function isEmailUnique(string $email): bool
|
|
{
|
|
return $this->parentAccountService->isEmailUnique($email);
|
|
}
|
|
|
|
|
|
//form to save student and emergency contact data
|
|
public function saveStudentRegistration()
|
|
{
|
|
log_message('debug', print_r($this->request->getPost(), true));
|
|
|
|
$schoolIdService = new SchoolIdService();
|
|
$parentId = session()->get('user_id');
|
|
$schoolYearContext = $this->resolveSchoolYearContext();
|
|
$this->assertSchoolYearWritable($schoolYearContext);
|
|
$selectedSchoolYear = $schoolYearContext->yearName();
|
|
$this->schoolYear = $selectedSchoolYear;
|
|
|
|
if (!$this->lastDayOfRegistration || !strtotime($this->lastDayOfRegistration)) {
|
|
throw new \Exception('Invalid enrollment deadline date.');
|
|
}
|
|
|
|
if (strtolower($this->request->getMethod()) !== 'post') {
|
|
return redirect()->back()->with('error', 'Invalid request method.');
|
|
}
|
|
|
|
try {
|
|
$data = $this->getRegistrationData($parentId);
|
|
$post = $this->request->getPost();
|
|
$validation = $this->parentRegistrationService()->validateRegistrationSubmission($post, $data);
|
|
if (empty($validation['ok'])) {
|
|
return redirect()->back()->withInput()->with('error', (string) ($validation['error'] ?? 'Registration could not be submitted.'));
|
|
}
|
|
|
|
$this->db->transStart();
|
|
|
|
$studentAdded = false;
|
|
$registeredStudentIds = [];
|
|
$incomingFirstNames = (array) ($post['studentFirstName'] ?? []);
|
|
$incomingECFirst = (array) ($post['emergency_firstname'] ?? []);
|
|
|
|
// Loop through students
|
|
foreach ($incomingFirstNames as $idx => $firstName) {
|
|
if (!empty($firstName)) {
|
|
// ✅ 7. Extract is_new flag (convert to boolean)
|
|
$lastYearKey = 'last_year_' . $idx;
|
|
$lastYearVal = strtolower(trim($post[$lastYearKey] ?? ''));
|
|
|
|
$isNew = match ($lastYearVal) {
|
|
'yes' => false, // was with us last year => not new
|
|
'no' => true, // was not with us => new student
|
|
default => null // missing or invalid
|
|
};
|
|
|
|
if (!isset($isNew)) {
|
|
return redirect()->back()->withInput()->with('error', "Please select if student #" . ($idx + 1) . " was with us last year.");
|
|
}
|
|
|
|
// ✅ 8. Pass $isNew to the student save function
|
|
$result = $this->validateAndSaveOrUpdateStudent($idx, $parentId, $this->semester, $this->schoolYear, $schoolIdService, $isNew, null);
|
|
|
|
if ($result) {
|
|
$studentAdded = true;
|
|
if (is_int($result) && $result > 0) {
|
|
$registeredStudentIds[] = $result;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$hasEC = !empty($incomingECFirst[0]) || !empty(($this->request->getPost('emergency_lastname')[0] ?? ''));
|
|
|
|
if ($hasEC) {
|
|
$this->saveEmergencyContact($parentId, $this->semester, $this->schoolYear);
|
|
}
|
|
|
|
if (!$studentAdded && !$hasEC) {
|
|
throw new \Exception('Please fill in at least a form before submitting.');
|
|
}
|
|
|
|
$this->db->transComplete();
|
|
|
|
if ($this->db->transStatus() === false) {
|
|
throw new \Exception('DB transaction failed.');
|
|
}
|
|
|
|
if ($registeredStudentIds !== []) {
|
|
$this->notifyAdminsAboutRegisteredStudents($registeredStudentIds, (int) $parentId);
|
|
}
|
|
|
|
if ($studentAdded) {
|
|
return redirect()->to(base_url('/parent/child_register'))
|
|
->with('success', 'Registration successful!');
|
|
}
|
|
|
|
return redirect()->to(base_url('/parent/child_register'))->with('success', 'Registration successful!');
|
|
} catch (\Throwable $e) {
|
|
log_message('error', $e->getMessage());
|
|
$this->db->transRollback();
|
|
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send admin registration emails after commit so only saved students are reported.
|
|
*
|
|
* @param list<int> $studentIds
|
|
*/
|
|
private function notifyAdminsAboutRegisteredStudents(array $studentIds, int $parentId): void
|
|
{
|
|
$parent = $this->userModel->find($parentId);
|
|
if (! is_array($parent)) {
|
|
log_message('warning', 'Unable to send admin student registration email: parent not found for ID ' . $parentId);
|
|
return;
|
|
}
|
|
|
|
foreach (array_values(array_unique(array_filter(array_map('intval', $studentIds)))) as $studentId) {
|
|
$student = $this->studentModel->find($studentId);
|
|
if (! is_array($student)) {
|
|
log_message('warning', 'Unable to send admin student registration email: student not found for ID ' . $studentId);
|
|
continue;
|
|
}
|
|
|
|
$payload = $student;
|
|
$payload['parents'] = [
|
|
'user_id' => $parentId,
|
|
'firstname' => (string) ($parent['firstname'] ?? ''),
|
|
'lastname' => (string) ($parent['lastname'] ?? ''),
|
|
'email' => (string) ($parent['email'] ?? ''),
|
|
];
|
|
|
|
$studentFullName = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student ID ' . $studentId;
|
|
$adminMessage = view('emails/admin_student_registered', ['student' => $payload], ['saveData' => true]);
|
|
|
|
try {
|
|
$sent = service('emailService')->send(
|
|
'registration@alrahmaisgl.org',
|
|
'New Student Registered: ' . $studentFullName,
|
|
$adminMessage,
|
|
'notifications'
|
|
);
|
|
|
|
if (! $sent) {
|
|
log_message('error', 'Admin student registration email failed for student ID ' . $studentId);
|
|
}
|
|
} catch (Throwable $e) {
|
|
log_message('error', 'Admin student registration email failed for student ID ' . $studentId . ': ' . $e->getMessage());
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// Function to check if the parent has registered kids and redirect accordingly
|
|
public function registerKidCheck()
|
|
{
|
|
$parentId = session()->get('user_id');
|
|
|
|
if (!$parentId) {
|
|
return redirect()->back()->with('error', 'Session expired. Please log in again.');
|
|
}
|
|
|
|
try {
|
|
$data = $this->getRegistrationData($parentId);
|
|
} catch (\RuntimeException $e) {
|
|
return redirect()->back()->with('error', $e->getMessage());
|
|
} catch (\Throwable $e) {
|
|
log_message('error', "Failed to fetch registration data: " . $e->getMessage());
|
|
return redirect()->back()->with('error', 'Failed to retrieve registration info.');
|
|
}
|
|
$selectedSchoolYear = (string) ($data['selectedYear'] ?? '');
|
|
$data['lastDayOfRegistration'] = $this->lastDayOfRegistration;
|
|
$data['registrationAgeDeadline'] = $this->registrationMinimumAgeDeadline($selectedSchoolYear);
|
|
$data['schoolYearAgeDeadline'] = $this->schoolYearAgeDeadline($selectedSchoolYear);
|
|
return view('parent/register_student', $data);
|
|
}
|
|
|
|
private function getRegistrationData(int $parentId): array
|
|
{
|
|
$schoolYearContext = $this->resolveSchoolYearContext();
|
|
$selectedSchoolYear = $schoolYearContext->yearName();
|
|
return $this->parentRegistrationService()->registrationData(
|
|
$parentId,
|
|
$selectedSchoolYear,
|
|
! $schoolYearContext->isReadonly(),
|
|
$this->maxChilds,
|
|
$this->maxEmergency
|
|
);
|
|
}
|
|
|
|
private function validateAndSaveOrUpdateStudent($idx, $parentId, $semester, $schoolYear, $schoolIdService, $isNew = null, $studentId = null)
|
|
{
|
|
$result = $this->parentRegistrationService()->saveStudentAtIndex(
|
|
(int) $idx,
|
|
$this->request->getPost(),
|
|
(int) $parentId,
|
|
(string) $schoolYear,
|
|
$schoolIdService,
|
|
$isNew === null ? null : (bool) $isNew,
|
|
$studentId === null ? null : (int) $studentId,
|
|
$this->schoolStartDate,
|
|
$this->ageDateRefernce ?? $this->dateAgeReference ?? null
|
|
);
|
|
|
|
if (empty($result['ok'])) {
|
|
if (! empty($result['error'])) {
|
|
session()->setFlashdata('error', (string) $result['error']);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
return (int) ($result['student_id'] ?? 0) > 0 ? (int) $result['student_id'] : true;
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Validates if a date of birth falls within required age range by a deadline
|
|
*
|
|
* @param string $dob Date of birth (YYYY-MM-DD format)
|
|
* @param string $registrationAgeDeadline Deadline date (YYYY-MM-DD format)
|
|
* @param int $minAge Minimum required age (default: 5)
|
|
* @param int $maxAge Maximum allowed age (default: 18)
|
|
* @return array ['isValid' => bool, 'message' => string]
|
|
*/
|
|
function validateDobAge(
|
|
string $dob,
|
|
string $registrationAgeDeadline,
|
|
int $minAge = 5,
|
|
int $maxAge = 18,
|
|
?string $schoolYearAgeDeadline = null
|
|
): array
|
|
{
|
|
$response = ['isValid' => false, 'message' => '', 'age' => null];
|
|
|
|
// -- Use a stable timezone for all comparisons (UTC is safest)
|
|
$tz = new DateTimeZone('UTC');
|
|
|
|
// 1) Empty?
|
|
$dob = trim($dob);
|
|
if ($dob === '') {
|
|
$response['message'] = 'Date of birth is required';
|
|
return $response;
|
|
}
|
|
|
|
// 2) Strict-parse DOB as a date-only at 00:00:00 (use leading "!" to zero unspecified fields)
|
|
$birthDate = DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $tz);
|
|
$errs = DateTimeImmutable::getLastErrors();
|
|
if ($birthDate === false || ($errs['warning_count'] ?? 0) > 0 || ($errs['error_count'] ?? 0) > 0) {
|
|
$response['message'] = 'Invalid date format (Use YYYY-MM-DD)';
|
|
return $response;
|
|
}
|
|
|
|
// 3) Parse deadlines; Dec 31 is only the special minimum-age registration grace date.
|
|
try {
|
|
$minimumAgeDeadline = new DateTimeImmutable($registrationAgeDeadline, $tz);
|
|
} catch (Throwable $e) {
|
|
$minimumAgeDeadline = new DateTimeImmutable('now', $tz);
|
|
}
|
|
|
|
try {
|
|
$ageDeadline = new DateTimeImmutable($schoolYearAgeDeadline ?: $registrationAgeDeadline, $tz);
|
|
} catch (Throwable $e) {
|
|
$ageDeadline = $minimumAgeDeadline;
|
|
}
|
|
|
|
$minimumAgeDeadline = $minimumAgeDeadline->setTime(23, 59, 59);
|
|
$ageDeadline = $ageDeadline->setTime(23, 59, 59);
|
|
|
|
// 4) The persisted/general age is based on Sep 1 of the school year.
|
|
$ageAtDeadline = $birthDate->diff($ageDeadline)->y;
|
|
$ageAtMinimumAgeDeadline = $birthDate->diff($minimumAgeDeadline)->y;
|
|
$response['age'] = $ageAtDeadline;
|
|
|
|
// 5) Allowed birthdate window (inclusive)
|
|
// - Earliest birthdate = Sep 1 - maxAge years.
|
|
// - Latest birthdate = Dec 31 - minAge years, only for minimum-age registration eligibility.
|
|
$minBirthDate = $ageDeadline->modify('-' . ($maxAge + 1) . ' years')->modify('+1 day')->setTime(0, 0, 0); // earliest allowed
|
|
$maxBirthDate = $minimumAgeDeadline->modify("-{$minAge} years")->setTime(23, 59, 59); // youngest allowed
|
|
|
|
// 6) Validate
|
|
$isValid = ($birthDate >= $minBirthDate) && ($birthDate <= $maxBirthDate);
|
|
$response['isValid'] = $isValid;
|
|
|
|
if (!$isValid) {
|
|
$response['message'] = sprintf(
|
|
'Must be at least %d years old by %s and no older than %d by %s. Current registration age would be: %d',
|
|
$minAge,
|
|
$minimumAgeDeadline->format('m-d-Y'),
|
|
$maxAge,
|
|
$ageDeadline->format('m-d-Y'),
|
|
$ageAtMinimumAgeDeadline
|
|
);
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
|
|
private function saveEmergencyContact($parentId, $semester, $schoolYear, $single = null, $id = null)
|
|
{
|
|
$result = $this->parentRegistrationService()->saveEmergencyContact(
|
|
(int) $parentId,
|
|
$this->request->getPost(),
|
|
is_array($single) ? $single : null,
|
|
$id === null ? null : (int) $id
|
|
);
|
|
|
|
if (empty($result['ok'])) {
|
|
session()->setFlashdata('error', (string) ($result['error'] ?? 'Emergency contact could not be saved.'));
|
|
return redirect()->back()->withInput();
|
|
}
|
|
}
|
|
|
|
public function editEmergencyContact($id = null)
|
|
{
|
|
$schoolYearContext = $this->resolveSchoolYearContext();
|
|
$this->assertSchoolYearWritable($schoolYearContext);
|
|
|
|
if ($id === null) {
|
|
$parentId = session()->get('user_id');
|
|
$contacts = $this->emergencyContactModel->where('parent_id', $parentId)->findAll();
|
|
return view('/parent/edit_emergency_contact', ['contacts' => $contacts]);
|
|
}
|
|
|
|
if (strtolower($this->request->getMethod()) === 'post') {
|
|
$rules = [
|
|
'emergency_first_name' => [
|
|
'label' => 'First Name',
|
|
'rules' => 'required|min_length[2]|max_length[50]|regex_match[/^[-A-Za-z ]+$/]',
|
|
'errors' => [
|
|
'regex_match' => 'First name may only contain letters, spaces, and dashes.'
|
|
]
|
|
],
|
|
'emergency_last_name' => [
|
|
'label' => 'Last Name',
|
|
'rules' => 'required|min_length[2]|max_length[50]|regex_match[/^[-A-Za-z ]+$/]',
|
|
'errors' => [
|
|
'regex_match' => 'Last name may only contain letters, spaces, and dashes.'
|
|
]
|
|
],
|
|
'cellphone' => [
|
|
'label' => 'Cell Phone',
|
|
'rules' => 'required|regex_match[/^\(?\d{3}\)?[-\s]?\d{3}[-\s]?\d{4}$/]',
|
|
'errors' => [
|
|
'regex_match' => 'Please enter a valid phone number (e.g., 123-456-7890 or (123) 456 7890).'
|
|
]
|
|
],
|
|
|
|
'relation' => [
|
|
'label' => 'Relation',
|
|
'rules' => 'required|min_length[3]|max_length[50]'
|
|
],
|
|
'email' => [
|
|
'label' => 'Email',
|
|
'rules' => 'permit_empty|valid_email|max_length[150]'
|
|
]
|
|
];
|
|
|
|
if (!$this->validate($rules)) {
|
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
|
}
|
|
|
|
$parentId = session()->get('user_id');
|
|
$semester = $this->semester;
|
|
$schoolYear = $schoolYearContext->yearName();
|
|
|
|
$this->saveEmergencyContact($parentId, $semester, $schoolYear, [
|
|
'first_name' => $this->request->getPost('emergency_first_name'),
|
|
'last_name' => $this->request->getPost('emergency_last_name'),
|
|
'cellphone' => $this->request->getPost('cellphone'),
|
|
'email' => $this->request->getPost('email'),
|
|
'relation' => $this->request->getPost('relation'),
|
|
], $id);
|
|
|
|
return redirect()->to('/parent/child_register')
|
|
->with('message', 'Emergency contact updated successfully.');
|
|
}
|
|
}
|
|
|
|
public function editStudent($id)
|
|
{
|
|
$schoolYearContext = $this->resolveSchoolYearContext();
|
|
$this->assertSchoolYearWritable($schoolYearContext);
|
|
$this->schoolYear = $schoolYearContext->yearName();
|
|
$schoolIdService = new \App\Services\SchoolIdService();
|
|
|
|
if (strtolower($this->request->getMethod()) !== 'post') {
|
|
return redirect()->back()->with('error', 'Invalid request.');
|
|
}
|
|
|
|
$rules = [
|
|
'firstname' => [
|
|
'label' => 'First Name',
|
|
'rules' => 'required|min_length[2]|max_length[255]|regex_match[/^[A-Za-z\s\-]+$/]',
|
|
'errors' => ['regex_match' => 'Only letters, spaces, and dashes allowed.']
|
|
],
|
|
'lastname' => [
|
|
'label' => 'Last Name',
|
|
'rules' => 'required|min_length[2]|max_length[255]|regex_match[/^[A-Za-z\s\-]+$/]',
|
|
'errors' => ['regex_match' => 'Only letters, spaces, and dashes allowed.']
|
|
],
|
|
'dob' => 'required|valid_date',
|
|
'gender' => 'required|in_list[Male,Female]',
|
|
'photo_consent' => 'required|in_list[0,1]',
|
|
'registration_grade' => 'permit_empty|max_length[50]',
|
|
];
|
|
|
|
// ✅ Validate first, keep scalar post data for proper error messages
|
|
if (!$this->validate($rules)) {
|
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
|
}
|
|
|
|
$parentId = session()->get('user_id');
|
|
|
|
// Prepare formData only AFTER validation
|
|
$formData = [
|
|
'studentFirstName' => [$this->request->getPost('firstname')],
|
|
'studentLastName' => [$this->request->getPost('lastname')],
|
|
'dob' => [$this->request->getPost('dob')],
|
|
'gender' => [$this->request->getPost('gender')],
|
|
'registration_grade' => [$this->request->getPost('registration_grade')],
|
|
'photo_consent' => [$this->request->getPost('photo_consent')],
|
|
'allergies' => [explode(',', $this->request->getPost('allergies') ?? '')],
|
|
'medical_conditions' => [explode(',', $this->request->getPost('medical_conditions') ?? '')],
|
|
];
|
|
|
|
// Inject temporarily only for update logic, not before validation
|
|
$this->request->setGlobal('post', $formData);
|
|
|
|
// Save/update
|
|
$this->validateAndSaveOrUpdateStudent(0, $parentId, $this->semester, $this->schoolYear, $schoolIdService, null, (int) $id);
|
|
|
|
return redirect()->to('/parent/child_register')->with('success', 'Student updated!');
|
|
}
|
|
|
|
public function deleteStudent($id)
|
|
{
|
|
$this->assertSchoolYearWritable($this->resolveSchoolYearContext());
|
|
|
|
if (strtolower($this->request->getMethod()) !== 'post') {
|
|
return redirect()->back()->with('error', 'Invalid request method.');
|
|
}
|
|
|
|
$parentId = session()->get('user_id');
|
|
|
|
// Confirm student exists and belongs to the parent
|
|
$student = $this->studentModel
|
|
->where('id', $id)
|
|
->where('parent_id', $parentId)
|
|
->first();
|
|
|
|
if (!$student) {
|
|
return redirect()->back()->with('error', 'Student not found or unauthorized.');
|
|
}
|
|
|
|
if (! $this->canParentDeleteStudent($student, (int) $parentId)) {
|
|
return redirect()->back()->with('error', 'This student has enrollment history and cannot be deleted. Please contact administration if a change is needed.');
|
|
}
|
|
|
|
// Perform deletion
|
|
if (!$this->studentModel->delete($id)) {
|
|
return redirect()->back()->with('error', 'Failed to delete student.');
|
|
}
|
|
|
|
// ✅ Check if parent has any students left
|
|
$remainingStudents = $this->studentModel
|
|
->where('parent_id', $parentId)
|
|
->countAllResults();
|
|
|
|
if ($remainingStudents === 0) {
|
|
|
|
// Delete all emergency contacts linked to this parent
|
|
$this->emergencyContactModel->where('parent_id', $parentId)->delete();
|
|
}
|
|
|
|
return redirect()->to('/parent/child_register')->with('success', 'Student deleted successfully.');
|
|
}
|
|
|
|
private function canParentDeleteStudent(array $student, int $parentId): bool
|
|
{
|
|
return $this->parentRegistrationService()->canParentDeleteStudent($student, $parentId);
|
|
}
|
|
|
|
public function addStudentForm()
|
|
{
|
|
$session = session();
|
|
$count = $session->get('registeredStudentsCount') ?? 0;
|
|
|
|
return view('partials/student_form', [
|
|
'registeredStudentsCount' => $count
|
|
]);
|
|
}
|
|
|
|
private function calculateAge($dob)
|
|
{
|
|
try {
|
|
$parts = explode('-', $this->schoolYear);
|
|
$startYear = isset($parts[0]) ? trim($parts[0]) : date('Y');
|
|
|
|
$registrationAgeDeadline = "$startYear-09-01";
|
|
|
|
// Convert both dates into DateTime objects
|
|
$deadlineObj = \DateTime::createFromFormat('Y-m-d', $registrationAgeDeadline);
|
|
$dobObj = \DateTime::createFromFormat('Y-m-d', $dob);
|
|
|
|
if (!$deadlineObj || !$dobObj) {
|
|
throw new \Exception('Invalid date format for DOB or Enrollment_deadline.');
|
|
}
|
|
|
|
// For display or logging purposes (if needed)
|
|
$deadlineForDisplay = $deadlineObj->format('Y-m-d');
|
|
$dobForDisplay = $dobObj->format('Y-m-d');
|
|
|
|
// Compare dates
|
|
if ($dobObj > $deadlineObj) {
|
|
log_message('debug', "DOB ($dobForDisplay) is after deadline ($deadlineForDisplay)");
|
|
return -1; // Invalid age case
|
|
}
|
|
|
|
// Calculate age
|
|
$ageInterval = $dobObj->diff($deadlineObj);
|
|
return $ageInterval->y;
|
|
} catch (\Exception $e) {
|
|
log_message('error', 'Invalid DOB or deadline in calculateAge(): ' . $e->getMessage());
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
private function calculateAgeAsOfSchoolYearStartYear(?string $dob, string $schoolYear): ?int
|
|
{
|
|
$dob = trim((string) $dob);
|
|
$schoolYear = trim($schoolYear);
|
|
|
|
if ($dob === '' || ! preg_match('/^(\d{4})/', $schoolYear, $matches)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$timezone = new \DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone()));
|
|
$birthDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $timezone);
|
|
$errors = \DateTimeImmutable::getLastErrors();
|
|
$hasParseErrors = is_array($errors)
|
|
&& (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0);
|
|
|
|
if ($birthDate === false || $hasParseErrors) {
|
|
return null;
|
|
}
|
|
|
|
$schoolYearStartYearCutoff = new \DateTimeImmutable($matches[1] . '-09-01', $timezone);
|
|
|
|
if ($birthDate > $schoolYearStartYearCutoff) {
|
|
return null;
|
|
}
|
|
|
|
return $birthDate->diff($schoolYearStartYearCutoff)->y;
|
|
} catch (\Throwable $e) {
|
|
log_message('warning', 'Unable to calculate school-year age from DOB: {message}', [
|
|
'message' => $e->getMessage(),
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function schoolYearAgeDeadline(string $schoolYear): string
|
|
{
|
|
if (preg_match('/^(\d{4})/', trim($schoolYear), $matches)) {
|
|
return $matches[1] . '-09-01';
|
|
}
|
|
|
|
if (!empty($this->schoolStartDate) && strtotime((string) $this->schoolStartDate)) {
|
|
return (new \DateTimeImmutable((string) $this->schoolStartDate))->format('Y-m-d');
|
|
}
|
|
|
|
return date('Y') . '-09-01';
|
|
}
|
|
|
|
private function registrationMinimumAgeDeadline(string $schoolYear): string
|
|
{
|
|
$configured = trim((string) ($this->ageDateRefernce ?? $this->dateAgeReference ?? ''));
|
|
if ($configured !== '' && strtotime($configured)) {
|
|
return (new \DateTimeImmutable($configured))->format('Y-m-d');
|
|
}
|
|
|
|
if (preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)) {
|
|
return $matches[2] . '-12-31';
|
|
}
|
|
|
|
return date('Y') . '-12-31';
|
|
}
|
|
|
|
public function parentEventPage()
|
|
{
|
|
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
|
$semester = session()->get('semester');
|
|
$parentId = session()->get('user_id');
|
|
|
|
return view('parent/event_participation', $this->parentEventParticipationService->pageData(
|
|
(int) $parentId,
|
|
$schoolYear,
|
|
(string) $semester
|
|
));
|
|
}
|
|
|
|
public function updateParticipation()
|
|
{
|
|
$participations = $this->request->getPost('participation'); // ['student_id:event_id' => 'yes'|'no']
|
|
$parentId = session()->get('user_id');
|
|
|
|
$this->parentEventParticipationService->updateParticipation(
|
|
is_array($participations) ? $participations : [],
|
|
(int) $parentId,
|
|
(string) $this->schoolYear,
|
|
(string) $this->semester
|
|
);
|
|
|
|
return redirect()->back()->with('success', 'Participation updated');
|
|
}
|
|
|
|
private function canAccessUserRecord(int $id): bool
|
|
{
|
|
$userId = (int) (session()->get('user_id') ?? 0);
|
|
return $this->parentAccountService->canAccessUserRecord(
|
|
$id,
|
|
$userId,
|
|
array_merge((array) session()->get('roles'), [session()->get('role')])
|
|
);
|
|
}
|
|
}
|