3351 lines
140 KiB
PHP
3351 lines
140 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\SchoolIdService;
|
||
use App\Services\FeeCalculationService;
|
||
use App\Services\PhoneFormatterService;
|
||
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;
|
||
|
||
|
||
|
||
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->ageDateRefernce = $this->configModel->getConfig('date_age_reference');
|
||
$this->lastDayOfRegistration = $this->configModel->getConfig('enrollment_deadline');
|
||
$this->schoolStartDate = $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']);
|
||
}
|
||
|
||
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 ?? ''));
|
||
|
||
// Build query to retrieve attendance
|
||
$builder = $this->db->table('attendance_data');
|
||
$builder->select('students.firstname, students.lastname, attendance_data.date, attendance_data.status, attendance_data.reason');
|
||
$builder->join('students', 'students.id = attendance_data.student_id');
|
||
$builder->where('attendance_data.school_year', $selectedYear);
|
||
// No semester filter so both semesters show for the selected year
|
||
$builder->where('students.parent_id', $parentId);
|
||
|
||
$query = $builder->get();
|
||
$attendanceResults = $query->getResultArray();
|
||
|
||
// 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.']);
|
||
}
|
||
|
||
// Fetch invoices for the logged-in user based on parent IDs
|
||
$builder = $this->db->table('invoices');
|
||
$builder->select('*');
|
||
$builder->groupStart()
|
||
->where('parent_id', $parentId)
|
||
//->orWhere('secondparent_user_id', $userId)
|
||
->groupEnd();
|
||
$query = $builder->get();
|
||
$invoices = $query->getResultArray();
|
||
|
||
// Log the query and results for debugging
|
||
log_message('info', 'Query executed: ' . $builder->getCompiledSelect());
|
||
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 {
|
||
// Get deadlines and school year from config
|
||
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();
|
||
|
||
// Get parent ID from session
|
||
$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.');
|
||
}
|
||
|
||
// Verify user type is "parent" from the `users` table
|
||
$userData = $this->db->table('users')
|
||
->select('user_type, accept_school_policy')
|
||
->where('id', $parentId)
|
||
->get()
|
||
->getRowArray();
|
||
|
||
if (!$userData || $userData['user_type'] !== 'primary') {
|
||
return redirect()->back()->with('error', 'Invalid user type. Only primary parents can enroll.');
|
||
}
|
||
|
||
// Fetch students with parent_id = current logged in user
|
||
$students = $this->db->table('students')
|
||
->where('parent_id', $parentId)
|
||
->get()
|
||
->getResultArray();
|
||
|
||
if (empty($students)) {
|
||
log_message('error', 'No students found for Parent ID: ' . $parentId);
|
||
return redirect()->to('/no-kids')->with('error', 'No students found. Please register your child first.');
|
||
}
|
||
|
||
$previousSchoolYear = $this->previousSchoolYearName($selectedYear);
|
||
$fallMakeupExamOn = $this->fallMakeupExamDateForYear($selectedYear);
|
||
|
||
// Map enrollment statuses
|
||
$statusMap = [
|
||
'admission under review' => 'admission under review',
|
||
'review & decision' => 'review & decision',
|
||
'payment pending' => 'payment pending',
|
||
'enrolled' => 'enrolled',
|
||
'withdraw under review' => 'withdraw under review',
|
||
'refund pending' => 'refund pending',
|
||
'withdrawn' => 'withdrawn',
|
||
'denied' => 'denied',
|
||
'waitlist' => 'waitlist' // ✅ Add this line
|
||
];
|
||
|
||
|
||
// Attach enrollment and class section data to each student
|
||
foreach ($students as &$student) {
|
||
$studentId = $student['id'];
|
||
$student['age'] = $this->calculateAgeAsOfSchoolYearStartYear($student['dob'] ?? null, $selectedYear);
|
||
|
||
// Get class section info (can be multiple sections like Grade + Arabic)
|
||
$classSections = $this->studentClassModel->getClassSectionsByStudentId($studentId, $selectedYear, true);
|
||
$student['class_section'] = !empty($classSections)
|
||
? implode(', ', $classSections)
|
||
: 'Class not Assigned';
|
||
$isArabicClass = !empty($classSections) && array_reduce(
|
||
$classSections,
|
||
static fn($carry, $name) => $carry || (is_string($name) && stripos($name, 'arabic') === 0),
|
||
false
|
||
);
|
||
|
||
// ✅ Get enrollment status AND admission status
|
||
$enrollment = $this->db->table('enrollments')
|
||
->select('enrollment_status, admission_status')
|
||
->where('student_id', $studentId)
|
||
->where('school_year', $selectedYear)
|
||
->get()
|
||
->getRowArray();
|
||
|
||
// ✅ Apply admission status override logic
|
||
if ($enrollment && isset($enrollment['admission_status'])) {
|
||
$student['admission_status'] = $enrollment['admission_status'];
|
||
|
||
if ($enrollment['admission_status'] === 'denied') {
|
||
// Force enrollment_status to denied if admission is denied
|
||
$student['enrollment_status'] = 'denied';
|
||
} else {
|
||
// Use enrollment_status from DB if admission is accepted/pending
|
||
$student['enrollment_status'] = $enrollment && isset($statusMap[$enrollment['enrollment_status']])
|
||
? $statusMap[$enrollment['enrollment_status']]
|
||
: 'not enrolled';
|
||
}
|
||
} else {
|
||
// No enrollment record found
|
||
$student['admission_status'] = null;
|
||
$student['enrollment_status'] = 'not enrolled';
|
||
}
|
||
|
||
// If assigned to Arabic class without an enrollment record, display as enrolled.
|
||
if ($student['enrollment_status'] === 'not enrolled' && $isArabicClass) {
|
||
$student['enrollment_status'] = 'enrolled';
|
||
}
|
||
|
||
// ✅ Updated disable logic to include denied status
|
||
$student['disable_enroll'] = in_array(
|
||
$student['enrollment_status'],
|
||
['admission under review', 'review & decision', 'payment pending', 'enrolled', 'withdraw under review', 'denied']
|
||
);
|
||
|
||
$decisionYear = $isEditable ? $previousSchoolYear : $selectedYear;
|
||
$student['previous_year_decision'] = $decisionYear !== null
|
||
? $this->studentDecisionForYear((int) $studentId, $decisionYear)
|
||
: null;
|
||
|
||
if ($isEditable) {
|
||
$student['transition_evaluation'] = $previousSchoolYear !== null
|
||
? $this->transitionEvaluationForStudent((int) $parentId, (int) $studentId, $previousSchoolYear, $selectedYear)
|
||
: null;
|
||
$student['enrollment_eligibility_message'] = $this->eligibilityMessageFromTransition(
|
||
$student,
|
||
$student['transition_evaluation'],
|
||
$fallMakeupExamOn
|
||
);
|
||
$student['expected_placement_label'] = $this->expectedPlacementLabel($student['transition_evaluation']);
|
||
$student['required_action_label'] = $this->requiredActionLabel($student['transition_evaluation']);
|
||
$student['parent_enrollment_state'] = $this->parentEnrollmentState($student);
|
||
} else {
|
||
$student['transition_evaluation'] = $this->readonlyEnrollmentEvaluation($student['previous_year_decision']);
|
||
$student['enrollment_eligibility_message'] = ['message' => '', 'blocking' => false, 'level' => 'info'];
|
||
$student['expected_placement_label'] = (string) ($student['class_section'] ?? 'Class not Assigned');
|
||
$student['required_action_label'] = 'Read-only closed school year.';
|
||
$student['parent_enrollment_state'] = $this->parentEnrollmentState($student);
|
||
}
|
||
}
|
||
|
||
// Render view
|
||
return view('/parent/enroll_classes', [
|
||
'students' => $students,
|
||
'selectedYear' => $selectedYear,
|
||
'previousSchoolYear' => $previousSchoolYear,
|
||
'fallMakeupExamOn' => $fallMakeupExamOn,
|
||
'isEditable' => $isEditable,
|
||
'withdrawalDeadline' => $this->withdrawalDeadline,
|
||
'lastDayOfRegistration' => $this->lastDayOfRegistration,
|
||
'schoolStartDate' => $this->schoolStartDate,
|
||
'hasAcceptedSchoolPolicy' => $this->hasAcceptedPolicyForYear((int) $parentId, $selectedYear),
|
||
'familyFinancialSummary' => $this->familyFinancialSummary((int) $parentId, $previousSchoolYear, $selectedYear, $students),
|
||
'enrollmentFeeSchedule' => $this->enrollmentFeeSchedule($selectedYear),
|
||
]);
|
||
} catch (Exception $e) {
|
||
log_message('error', 'An error occurred in enrollClasses: ' . $e->getMessage());
|
||
return view('errors/html/error_500');
|
||
}
|
||
}
|
||
|
||
public function noKids()
|
||
{
|
||
return view('/parent/no_kids_registred');
|
||
}
|
||
|
||
public function enrollClassesHandler()
|
||
{
|
||
$refundService = new FeeCalculationService();
|
||
|
||
// 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 = [];
|
||
|
||
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));
|
||
}
|
||
|
||
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'])) {
|
||
$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);
|
||
$submittedEnrollmentContexts[$studentId] = $isReturningReEnrollment ? 're_enrollment' : 'first_enrollment';
|
||
|
||
if ($existingEnrollment) {
|
||
$targetEnrollmentStatus = $isReturningReEnrollment ? 'payment pending' : 'admission under review';
|
||
$targetAdmissionStatus = $isReturningReEnrollment ? 'accepted' : 'pending';
|
||
|
||
$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'] ?? []))
|
||
);
|
||
} else {
|
||
$targetEnrollmentStatus = $isReturningReEnrollment ? 'payment pending' : 'admission under review';
|
||
$targetAdmissionStatus = $isReturningReEnrollment ? 'accepted' : 'pending';
|
||
|
||
// If no enrollment record exists, insert a new enrollment record
|
||
$payload = $this->enrollmentPayloadFromEvaluation($evaluation, [
|
||
'student_id' => $studentId,
|
||
'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'] ?? []))
|
||
);
|
||
}
|
||
}
|
||
$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
|
||
if (!empty($withdraw)) {
|
||
foreach ($withdraw as $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) {
|
||
// Update enrollment as withdrawn
|
||
$enrollmentStatusService = \Config\Services::enrollmentStatus(false);
|
||
$enrollmentStatusService->upsertStatus([
|
||
'id' => (int) $enrollment['id'],
|
||
'student_id' => (int) $studentId,
|
||
'parent_id' => (int) ($enrollment['parent_id'] ?? $parentId),
|
||
'school_year' => (string) $this->schoolYear,
|
||
'semester' => (string) ($enrollment['semester'] ?? $this->semester),
|
||
'withdrawal_date' => local_date(utc_now(), 'Y-m-d'),
|
||
'enrollment_status' => 'withdraw under review', // Withdrawal needs review
|
||
'updated_at' => utc_now()
|
||
], (int) $parentId, 'parent_withdrawal_requested');
|
||
log_message('info', "Student ID $studentId has been withdrawn from enrollment ID {$enrollment['id']}.");
|
||
|
||
// === Trigger refund process ===
|
||
// Find the related invoice (you may need to adjust this based on your DB structure)
|
||
$invoice = $this->db->table('invoices')
|
||
->where('parent_id', $parentId)
|
||
->where('school_year', $this->schoolYear)
|
||
->orderBy('created_at', 'DESC')
|
||
->get()
|
||
->getRowArray();
|
||
|
||
if ($invoice !== null) {
|
||
$invoiceId = $invoice['id'];
|
||
$studentsForRefund = $this->enrollmentModel
|
||
->where('parent_id', $parentId)
|
||
->where('school_year', $this->schoolYear)
|
||
->findAll();
|
||
$refundAmount = $refundService->calculateRefund($studentsForRefund, (int) $parentId);
|
||
$refundCents = max(0, (int) round($refundAmount * 100));
|
||
|
||
$refundTable = $this->db->table('refunds');
|
||
|
||
$existingRefund = $refundTable
|
||
->where('parent_id', $parentId)
|
||
->where('invoice_id', $invoiceId)
|
||
->where('school_year', $this->schoolYear)
|
||
->whereIn('status', ['Pending', 'Approved', 'Partial', 'pending', 'requested', 'approved', 'partial', 'partially_paid'])
|
||
->get()
|
||
->getRow();
|
||
|
||
if ($existingRefund) {
|
||
// Only update the fields that should change
|
||
$updateData = [
|
||
'refund_amount' => $refundAmount,
|
||
'requested_amount_cents' => $refundCents,
|
||
'currency' => 'USD',
|
||
'reason' => 'Withdrawal under review for student ID ' . $studentId,
|
||
'note' => null,
|
||
'request' => 'tuition',
|
||
'source_type' => 'tuition_withdrawal',
|
||
'source_id' => (int) $invoiceId,
|
||
'status' => 'Pending',
|
||
'updated_by' => session()->get('user_id'), // optionally track updates
|
||
// Add other fields if *and only if* they must be changed
|
||
];
|
||
|
||
$refundTable
|
||
->where('id', $existingRefund->id)
|
||
->update($this->filterPayloadByTableColumns($updateData, 'refunds'));
|
||
|
||
log_message('info', "Refund record updated for invoice ID {$invoiceId}, student ID {$studentId}.");
|
||
} else {
|
||
// Only set these once, for new entries
|
||
$insertData = [
|
||
'parent_id' => $parentId,
|
||
'invoice_id' => $invoiceId,
|
||
'refund_amount' => $refundAmount,
|
||
'requested_amount_cents' => $refundCents,
|
||
'approved_amount_cents' => null,
|
||
'currency' => 'USD',
|
||
'requested_at' => utc_now(),
|
||
'school_year' => $this->schoolYear,
|
||
'status' => 'Pending',
|
||
'reason' => 'Withdrawal under review for student ID ' . $studentId,
|
||
'request' => 'tuition',
|
||
'source_type' => 'tuition_withdrawal',
|
||
'source_id' => (int) $invoiceId,
|
||
'semester' => $this->semester,
|
||
'refund_paid_amount' => 0.0,
|
||
];
|
||
|
||
$refundTable->insert($this->filterPayloadByTableColumns($insertData, 'refunds'));
|
||
|
||
log_message('info', "Refund record created for invoice ID {$invoiceId}, student ID {$studentId}.");
|
||
}
|
||
} else {
|
||
log_message('error', "No invoice found for parent ID {$parentId}, student ID {$studentId}.");
|
||
}
|
||
} else {
|
||
log_message('error', "No active enrollment found for student ID $studentId.");
|
||
}
|
||
}
|
||
}
|
||
|
||
$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
|
||
return redirect()->to('/parent/enroll_classes');
|
||
} else {
|
||
// Redirect to enrollment success page if there are enrollments
|
||
$contextValues = array_values(array_unique($submittedEnrollmentContexts ?? []));
|
||
$parentData['enrollment_context'] = count($contextValues) === 1 ? $contextValues[0] : 'mixed';
|
||
Events::trigger('admissionUnderReview', $parentData, $studentData); //send notification for enrolled student
|
||
return redirect()->to('/parent/enroll_classes');
|
||
}
|
||
}
|
||
|
||
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');
|
||
if (! is_array($studentInfo)) {
|
||
return [];
|
||
}
|
||
|
||
$submittedIds = array_fill_keys(array_map('intval', $studentIds), true);
|
||
$errors = [];
|
||
$updates = [];
|
||
|
||
foreach ($studentInfo as $studentId => $fields) {
|
||
$studentId = (int) $studentId;
|
||
if ($studentId <= 0 || ! isset($submittedIds[$studentId]) || ! is_array($fields)) {
|
||
continue;
|
||
}
|
||
|
||
$existing = $this->studentModel
|
||
->where('id', $studentId)
|
||
->where('parent_id', $parentId)
|
||
->first();
|
||
|
||
if (! is_array($existing)) {
|
||
$errors[] = 'Student ID ' . $studentId . ': student record was not found.';
|
||
continue;
|
||
}
|
||
|
||
$firstName = $this->normalizeEnrollmentStudentName((string) ($fields['firstname'] ?? ''));
|
||
$lastName = $this->normalizeEnrollmentStudentName((string) ($fields['lastname'] ?? ''));
|
||
$dob = trim((string) ($fields['dob'] ?? ''));
|
||
$gender = trim((string) ($fields['gender'] ?? ''));
|
||
$registrationGrade = trim((string) ($fields['registration_grade'] ?? ''));
|
||
$photoConsent = (string) ($fields['photo_consent'] ?? '');
|
||
$studentLabel = trim($firstName . ' ' . $lastName) ?: 'Student ID ' . $studentId;
|
||
|
||
try {
|
||
$this->validateNames($firstName);
|
||
$this->validateNames($lastName);
|
||
} catch (InvalidArgumentException $e) {
|
||
$errors[] = $studentLabel . ': ' . $e->getMessage();
|
||
continue;
|
||
}
|
||
|
||
if (! in_array($gender, ['Male', 'Female'], true)) {
|
||
$errors[] = $studentLabel . ': gender is required.';
|
||
continue;
|
||
}
|
||
|
||
if ($photoConsent !== '0' && $photoConsent !== '1') {
|
||
$errors[] = $studentLabel . ': photo consent is required.';
|
||
continue;
|
||
}
|
||
|
||
if ($registrationGrade === '' || mb_strlen($registrationGrade) > 50) {
|
||
$errors[] = $studentLabel . ': registration grade is required.';
|
||
continue;
|
||
}
|
||
|
||
$dobObj = \DateTimeImmutable::createFromFormat('!Y-m-d', $dob, new \DateTimeZone('UTC'));
|
||
$dobErrors = \DateTimeImmutable::getLastErrors();
|
||
$dobWarningCount = is_array($dobErrors) ? (int) ($dobErrors['warning_count'] ?? 0) : 0;
|
||
$dobErrorCount = is_array($dobErrors) ? (int) ($dobErrors['error_count'] ?? 0) : 0;
|
||
if ($dobObj === false || $dobWarningCount > 0 || $dobErrorCount > 0) {
|
||
$errors[] = $studentLabel . ': date of birth must use YYYY-MM-DD format.';
|
||
continue;
|
||
}
|
||
|
||
$validation = $this->validateDobAge(
|
||
$dob,
|
||
$this->registrationMinimumAgeDeadline($schoolYear),
|
||
5,
|
||
18,
|
||
$this->schoolYearAgeDeadline($schoolYear)
|
||
);
|
||
if (! $validation['isValid']) {
|
||
$errors[] = $studentLabel . ': ' . $validation['message'] . '.';
|
||
continue;
|
||
}
|
||
|
||
$updates[$studentId] = [
|
||
'firstname' => $firstName,
|
||
'lastname' => $lastName,
|
||
'dob' => $dobObj->format('Y-m-d'),
|
||
'age' => $this->calculateAgeAsOfSchoolYearStartYear($dobObj->format('Y-m-d'), $schoolYear),
|
||
'gender' => $gender,
|
||
'registration_grade' => $registrationGrade,
|
||
'photo_consent' => (int) $photoConsent,
|
||
];
|
||
}
|
||
|
||
if ($errors !== []) {
|
||
return $errors;
|
||
}
|
||
|
||
foreach ($updates as $studentId => $payload) {
|
||
if (! $this->studentModel->update($studentId, $payload)) {
|
||
$errors[] = 'Student ID ' . $studentId . ': student information could not be updated.';
|
||
}
|
||
}
|
||
|
||
return $errors;
|
||
}
|
||
|
||
private function normalizeEnrollmentStudentName(string $name): string
|
||
{
|
||
$name = trim(preg_replace('/\s+/', ' ', $name) ?? '');
|
||
return mb_convert_case($name, MB_CASE_TITLE, 'UTF-8');
|
||
}
|
||
|
||
private function exceptionReasonFromEvaluation(array $evaluation): ?string
|
||
{
|
||
if (! empty($evaluation['admin_exception'])) {
|
||
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
|
||
{
|
||
if ($parentId <= 0 || $schoolYear === '') {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
return $this->policyAcceptanceModel->hasAccepted($parentId, $schoolYear);
|
||
} catch (Throwable $e) {
|
||
log_message('error', 'Failed to read parent policy acceptance: {message}', [
|
||
'message' => $e->getMessage(),
|
||
]);
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private function recordPolicyAcceptance(int $parentId, string $schoolYear, string $source): void
|
||
{
|
||
if ($parentId <= 0 || $schoolYear === '') {
|
||
throw new \RuntimeException('Unable to record school policy acceptance.');
|
||
}
|
||
|
||
if (! $this->policyAcceptanceModel->recordAcceptance(
|
||
$parentId,
|
||
$schoolYear,
|
||
$source,
|
||
$this->request->getIPAddress(),
|
||
$this->request->getUserAgent()->getAgentString()
|
||
)) {
|
||
throw new \RuntimeException('Unable to record school policy acceptance.');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 studentPassedPreviousYear(int $studentId, string $targetSchoolYear): bool
|
||
{
|
||
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
||
|
||
if ($studentId <= 0 || $previousSchoolYear === null) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
if ($this->db->tableExists('promotion_queue')) {
|
||
$queuedPromotion = $this->db->table('promotion_queue')
|
||
->select('id')
|
||
->where('student_id', $studentId)
|
||
->where('school_year_from', $previousSchoolYear)
|
||
->where('school_year_to', $targetSchoolYear)
|
||
->whereIn('status', ['queued', 'assigned', 'applied'])
|
||
->limit(1)
|
||
->get()
|
||
->getRowArray();
|
||
|
||
if ($queuedPromotion !== null) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
if ($this->db->tableExists('student_decisions')) {
|
||
$decisionRow = $this->db->table('student_decisions')
|
||
->select('decision')
|
||
->where('student_id', $studentId)
|
||
->where('school_year', $previousSchoolYear)
|
||
->orderBy('updated_at', 'DESC')
|
||
->orderBy('id', 'DESC')
|
||
->limit(1)
|
||
->get()
|
||
->getRowArray();
|
||
|
||
return DeliberationDecision::normalize($decisionRow['decision'] ?? null) === DeliberationDecision::PASSED;
|
||
}
|
||
} catch (\Throwable $e) {
|
||
log_message('error', 'studentPassedPreviousYear failed: ' . $e->getMessage());
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
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 ($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',
|
||
];
|
||
}
|
||
|
||
$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 (in_array($status, [
|
||
'admission under review',
|
||
'review & decision',
|
||
'payment pending',
|
||
'enrolled',
|
||
'waitlist',
|
||
'withdraw under review',
|
||
], true)) {
|
||
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 familyFinancialSummary(int $parentId, ?string $previousSchoolYear, string $selectedYear, array $students = []): array
|
||
{
|
||
$schoolYearConfig = $this->schoolYearConfig($selectedYear);
|
||
$carryOver = $previousSchoolYear !== null ? $this->invoiceBalanceForParent($parentId, $previousSchoolYear) : 0.0;
|
||
$currentBalance = $this->invoiceBalanceForParent($parentId, $selectedYear);
|
||
$registrationFee = round((float) ($schoolYearConfig['registration_fee'] ?? 0), 2);
|
||
$tuitionDue = $this->enrollmentTuitionDue($students);
|
||
$mandatoryFees = round((float) ($schoolYearConfig['mandatory_fees'] ?? 0), 2);
|
||
$behavior = (string) ($schoolYearConfig['carry_over_balance_behavior'] ?? 'submission_blocked_until_payment');
|
||
$amountDue = max(0.0, $carryOver) + max(0.0, $currentBalance) + $registrationFee + $tuitionDue + $mandatoryFees;
|
||
|
||
return [
|
||
'currency' => '$',
|
||
'carry_over_balance' => round($carryOver, 2),
|
||
'current_balance' => round($currentBalance, 2),
|
||
'registration_fee' => $registrationFee,
|
||
'tuition_due_at_registration' => $tuitionDue,
|
||
'mandatory_fees' => $mandatoryFees,
|
||
'amount_due' => round($amountDue, 2),
|
||
'balance_behavior' => $behavior,
|
||
'payment_plan_available' => (bool) ($schoolYearConfig['payment_plan_available'] ?? false),
|
||
'policy_message' => $this->financialPolicyMessage($behavior, (string) ($schoolYearConfig['financial_policy_message'] ?? '')),
|
||
];
|
||
}
|
||
|
||
private function enrollmentTuitionDue(array $students): float
|
||
{
|
||
$tuitionStudents = [];
|
||
|
||
foreach ($students as $student) {
|
||
$status = strtolower(trim((string) ($student['enrollment_status'] ?? '')));
|
||
$eligibilityMessage = is_array($student['enrollment_eligibility_message'] ?? null)
|
||
? $student['enrollment_eligibility_message']
|
||
: ['blocking' => false];
|
||
|
||
if ($status !== 'not enrolled' || ! empty($eligibilityMessage['blocking'])) {
|
||
continue;
|
||
}
|
||
|
||
$evaluation = is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : [];
|
||
$tuitionStudents[] = [
|
||
'student_id' => (int) ($student['id'] ?? $student['student_id'] ?? 0),
|
||
'class_section_id' => (int) (
|
||
$evaluation['assigned_class_section_id']
|
||
?? $student['class_section_id']
|
||
?? 0
|
||
),
|
||
];
|
||
}
|
||
|
||
if ($tuitionStudents === []) {
|
||
return 0.0;
|
||
}
|
||
|
||
return (new FeeCalculationService())->calculateEnrollmentTuition($tuitionStudents);
|
||
}
|
||
|
||
private function enrollmentFeeSchedule(string $selectedYear): array
|
||
{
|
||
$schoolYearConfig = $this->schoolYearConfig($selectedYear);
|
||
|
||
return [
|
||
'currency' => '$',
|
||
'first_student_fee' => round((float) ($this->configModel->getConfig('first_student_fee') ?? 380), 2),
|
||
'second_student_fee' => round((float) ($this->configModel->getConfig('second_student_fee') ?? 280), 2),
|
||
'registration_fee' => round((float) ($schoolYearConfig['registration_fee'] ?? 0), 2),
|
||
'tuition_due_at_registration' => round((float) ($schoolYearConfig['tuition_due_at_registration'] ?? 0), 2),
|
||
'mandatory_fees' => round((float) ($schoolYearConfig['mandatory_fees'] ?? 0), 2),
|
||
];
|
||
}
|
||
|
||
private function financialPolicyMessage(string $behavior, string $configured): string
|
||
{
|
||
$configured = trim($configured);
|
||
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 invoiceBalanceForParent(int $parentId, string $schoolYear): float
|
||
{
|
||
if ($parentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('invoices')) {
|
||
return 0.0;
|
||
}
|
||
|
||
$row = $this->db->table('invoices')
|
||
->select('COALESCE(SUM(balance), 0) AS balance', false)
|
||
->where('parent_id', $parentId)
|
||
->where('school_year', $schoolYear)
|
||
->get()
|
||
->getRowArray();
|
||
|
||
return round((float) ($row['balance'] ?? 0), 2);
|
||
}
|
||
|
||
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 enrollFailure()
|
||
{
|
||
echo view('/parent/enroll_failure');
|
||
}
|
||
|
||
public function payment()
|
||
{
|
||
// Get the logged-in user's ID
|
||
$userId = session()->get('user_id');
|
||
|
||
// Fetch invoices records where the user is either the first or second parent
|
||
$invoices = $this->db->table('invoices')
|
||
->select('invoices.*, registeredKids') // Assuming 'registeredKids' is a field
|
||
->where('parent_id', $userId)
|
||
//->orWhere('secondparent_user_id', $userId)
|
||
->get()
|
||
->getResultArray();
|
||
|
||
// Pass the invoices data to the view
|
||
return view('/parent/payment', [
|
||
'invoices' => $invoices
|
||
]);
|
||
}
|
||
|
||
public function paymentHandler()
|
||
{
|
||
$parentId = session()->get('user_id');
|
||
|
||
// Update all students for this parent to mark tuition as paid
|
||
$this->db->table('students')
|
||
->where('parent_id', $parentId)
|
||
->update(['tuition_paid' => 1]);
|
||
|
||
// 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)
|
||
{
|
||
$schoolIdService = new SchoolIdService();
|
||
|
||
// Step 1: Generate a secure token for email verification
|
||
$token = bin2hex(random_bytes(48));
|
||
$tokenHash = hash('sha256', $token);
|
||
|
||
// Step 2: Determine user type based on relationship
|
||
$userType = in_array(strtolower($relationToStudent), ['wife', 'husband']) ? 'Secondary' : 'Tertiary';
|
||
|
||
// Step 3: Validate incoming user data
|
||
$validation = \Config\Services::validation();
|
||
$validation->setRules([
|
||
'firstname' => [
|
||
'label' => 'First Name',
|
||
'rules' => 'required|min_length[2]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]',
|
||
'errors' => [
|
||
'regex_match' => 'First name may only contain letters, spaces, and dashes.'
|
||
]
|
||
],
|
||
'lastname' => [
|
||
'label' => 'Last Name',
|
||
'rules' => 'required|min_length[2]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]',
|
||
'errors' => [
|
||
'regex_match' => 'Last name may only contain letters, spaces, and dashes.'
|
||
]
|
||
],
|
||
'email' => [
|
||
'label' => 'Email Address',
|
||
'rules' => 'required|valid_email|max_length[150]|is_unique[users.email]',
|
||
'errors' => [
|
||
'is_unique' => 'This email is already registered.'
|
||
]
|
||
],
|
||
'cellphone' => [
|
||
'label' => 'Cell Phone',
|
||
'rules' => 'required|regex_match[/^\d{10}$/]',
|
||
'errors' => [
|
||
'regex_match' => 'Phone number must be exactly 10 digits.'
|
||
]
|
||
],
|
||
'gender' => 'required|in_list[Male,Female]',
|
||
'city' => 'required|max_length[100]',
|
||
'state' => 'required|max_length[100]',
|
||
'zip' => 'required|regex_match[/^\d{5}$/]',
|
||
]);
|
||
|
||
if (!$validation->run($userData)) {
|
||
log_message('error', 'User creation failed due to invalid data: ' . json_encode($validation->getErrors()));
|
||
return false;
|
||
}
|
||
|
||
// Step 4: Prepare sanitized user data
|
||
$userEntry = [
|
||
'firstname' => ucfirst(strtolower($userData['firstname'])),
|
||
'lastname' => ucfirst(strtolower($userData['lastname'])),
|
||
'gender' => $userData['gender'],
|
||
'cellphone' => $userData['cellphone'],
|
||
'email' => strtolower($userData['email']),
|
||
'address_street' => $userData['address_street'] ?? '',
|
||
'apt' => $userData['apt'] ?? null,
|
||
'city' => ucfirst(strtolower($userData['city'])),
|
||
'state' => strtoupper($userData['state']),
|
||
'zip' => $userData['zip'],
|
||
'accept_school_policy' => $userData['accept_school_policy'] ?? 0,
|
||
'token' => $tokenHash,
|
||
'is_verified' => 0,
|
||
'status' => 'Inactive',
|
||
'user_type' => $userType,
|
||
'semester' => $this->semester,
|
||
'school_year' => $this->schoolYear,
|
||
'school_id' => $schoolIdService->generateUserSchoolId(),
|
||
];
|
||
|
||
try {
|
||
// Step 5: Insert user into database
|
||
if (!$this->userModel->insert($userEntry)) {
|
||
log_message('error', 'Failed to insert user: ' . print_r($this->userModel->errors(), true));
|
||
return false;
|
||
}
|
||
|
||
$userId = $this->userModel->getInsertID();
|
||
|
||
// Step 6: Send activation email
|
||
$this->sendActivationEmail($userData['email'], $token);
|
||
|
||
log_message('info', "User with ID $userId created successfully and activation email sent.");
|
||
return $userId;
|
||
} catch (Exception $e) {
|
||
log_message('error', 'Exception during user creation: ' . $e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private function sendActivationEmail($email, $token)
|
||
{
|
||
$emailController = new \App\Controllers\View\EmailController();
|
||
|
||
$subject = 'Activate Your Account';
|
||
$activationLink = site_url('/user/confirm/' . $token);
|
||
$message = "Please click the following link to confirm your email and set your password: $activationLink";
|
||
|
||
if ($emailController->sendEmail($email, $subject, $message)) {
|
||
log_message('info', 'Activation email sent successfully to ' . $email);
|
||
} else {
|
||
log_message('error', 'Failed to send activation email to ' . $email);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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)
|
||
{
|
||
$validation = \Config\Services::validation();
|
||
|
||
// Step 1: Define validation rules for authorized user fields
|
||
$validation->setRules([
|
||
'email' => [
|
||
'label' => 'Email',
|
||
'rules' => 'required|valid_email|max_length[150]',
|
||
'errors' => [
|
||
'required' => 'Email is required.',
|
||
'valid_email' => 'Please provide a valid email address.',
|
||
'max_length' => 'Email must be less than 150 characters.'
|
||
]
|
||
],
|
||
'name' => [
|
||
'label' => 'Name',
|
||
'rules' => 'required|min_length[3]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]',
|
||
'errors' => [
|
||
'required' => 'Name is required.',
|
||
'regex_match' => 'Name can only contain letters, spaces, and dashes.',
|
||
'min_length' => 'Name must be at least 3 characters long.',
|
||
'max_length' => 'Name must be less than 100 characters.'
|
||
]
|
||
]
|
||
]);
|
||
|
||
// Step 2: Run validation
|
||
if (!$validation->run($data)) {
|
||
log_message('error', 'Invalid authorized user data: ' . json_encode($validation->getErrors()));
|
||
return; // Skip update/insert if validation fails
|
||
}
|
||
|
||
// Step 3: Check if an authorized user with same email/user ID exists
|
||
$existingAuthorizedUser = $this->authorizedUsersModel
|
||
->where('user_id', $userId)
|
||
->where('email', $data['email'])
|
||
->first();
|
||
|
||
if ($existingAuthorizedUser) {
|
||
// Step 4: Update existing authorized user
|
||
$this->authorizedUsersModel->update($existingAuthorizedUser['id'], $data);
|
||
} else {
|
||
// Step 5: Insert new authorized user with pending status
|
||
$data['user_id'] = $userId;
|
||
$data['status'] = 'Pending'; // default status for new records
|
||
$this->authorizedUsersModel->insert($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
|
||
{
|
||
$tablesAndColumns = [
|
||
'users' => 'email',
|
||
'emergency_contacts' => 'email',
|
||
];
|
||
|
||
foreach ($tablesAndColumns as $table => $column) {
|
||
// Skip duplicate table lookups with different columns
|
||
$builder = $this->db->table($table);
|
||
$builder->where($column, $email);
|
||
$exists = $builder->countAllResults();
|
||
|
||
if ($exists > 0) {
|
||
return false; // Email already exists
|
||
}
|
||
}
|
||
|
||
return true; // Email is unique
|
||
}
|
||
|
||
|
||
//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 {
|
||
// ✅ 1. Get existing data
|
||
$data = $this->getRegistrationData($parentId);
|
||
$existingKids = $data['existingKids'];
|
||
$existingECs = $data['emergencies'];
|
||
$maxChilds = $data['maxChilds'];
|
||
$maxEmergency = $data['maxEmergency'];
|
||
|
||
$existingKidsCount = count($existingKids);
|
||
$existingECCount = count($existingECs);
|
||
|
||
$incomingFirstNames = $this->request->getPost('studentFirstName') ?? [];
|
||
$incomingLastNames = $this->request->getPost('studentLastName') ?? [];
|
||
$incomingDOBs = $this->request->getPost('dob') ?? [];
|
||
|
||
$newStudentCount = count(array_filter($incomingFirstNames));
|
||
|
||
// ✅ 2. Duplicate student check against existing records
|
||
foreach ($incomingFirstNames as $i => $firstName) {
|
||
$lastName = trim($incomingLastNames[$i] ?? '');
|
||
$dob = trim($incomingDOBs[$i] ?? '');
|
||
|
||
if (empty($firstName) || empty($lastName) || empty($dob)) continue;
|
||
|
||
foreach ($existingKids as $kid) {
|
||
if (
|
||
strtolower($kid['firstname']) === strtolower($firstName) &&
|
||
strtolower($kid['lastname']) === strtolower($lastName) &&
|
||
$kid['dob'] === $dob
|
||
) {
|
||
return redirect()->back()->withInput()->with('error', "Duplicate student detected: {$firstName} {$lastName} with DOB {$dob} already exists.");
|
||
}
|
||
}
|
||
}
|
||
|
||
// ✅ 3. Duplicate student check within incoming data
|
||
$seenStudents = [];
|
||
foreach ($incomingFirstNames as $i => $firstName) {
|
||
$lastName = trim($incomingLastNames[$i] ?? '');
|
||
$dob = trim($incomingDOBs[$i] ?? '');
|
||
|
||
if (empty($firstName) || empty($lastName) || empty($dob)) continue;
|
||
|
||
$key = strtolower($firstName . '|' . $lastName . '|' . $dob);
|
||
if (isset($seenStudents[$key])) {
|
||
return redirect()->back()->withInput()->with('error', "Duplicate student entry in the form: {$firstName} {$lastName} with DOB {$dob}.");
|
||
}
|
||
$seenStudents[$key] = true;
|
||
}
|
||
|
||
// ✅ 4. Emergency contact validation
|
||
$incomingECFirst = $this->request->getPost('emergency_firstname') ?? [];
|
||
$incomingECLast = $this->request->getPost('emergency_lastname') ?? [];
|
||
$incomingECPhones = $this->request->getPost('emergency_phone') ?? [];
|
||
$incomingECEmails = $this->request->getPost('emergency_email') ?? [];
|
||
|
||
$newECCount = count(array_filter($incomingECFirst));
|
||
|
||
// Against existing
|
||
foreach ($incomingECFirst as $i => $first) {
|
||
$last = trim($incomingECLast[$i] ?? '');
|
||
$phone = preg_replace('/\D/', '', $incomingECPhones[$i] ?? '');
|
||
$email = strtolower(trim($incomingECEmails[$i] ?? ''));
|
||
|
||
if (empty($first) || empty($last)) continue;
|
||
|
||
foreach ($existingECs as $contact) {
|
||
$existingPhone = preg_replace('/\D/', '', $contact['cellphone']);
|
||
$existingEmail = strtolower($contact['email']);
|
||
|
||
if (
|
||
strtolower($contact['emergency_contact_name']) === strtolower(trim($first . ' ' . $last)) ||
|
||
($phone && $phone === $existingPhone) ||
|
||
($email && $email === $existingEmail)
|
||
) {
|
||
return redirect()->back()->withInput()->with('error', "Duplicate emergency contact: {$first} {$last} already exists.");
|
||
}
|
||
}
|
||
}
|
||
|
||
// Within incoming data
|
||
$seenContacts = [];
|
||
foreach ($incomingECFirst as $i => $first) {
|
||
$last = trim($incomingECLast[$i] ?? '');
|
||
$phone = preg_replace('/\D/', '', $incomingECPhones[$i] ?? '');
|
||
$email = strtolower(trim($incomingECEmails[$i] ?? ''));
|
||
|
||
if (empty($first) || empty($last)) continue;
|
||
|
||
$key = strtolower($first . '|' . $last . '|' . $phone . '|' . $email);
|
||
if (isset($seenContacts[$key])) {
|
||
return redirect()->back()->withInput()->with('error', "Duplicate emergency contact entry in the form: {$first} {$last}.");
|
||
}
|
||
$seenContacts[$key] = true;
|
||
}
|
||
|
||
// ✅ 5. Check limits
|
||
if (($existingKidsCount + $newStudentCount) > $maxChilds) {
|
||
return redirect()->back()->withInput()->with('error', "Student limit exceeded. You have $existingKidsCount and tried to add $newStudentCount (limit: $maxChilds).");
|
||
}
|
||
|
||
if (($existingECCount + $newECCount) > $maxEmergency) {
|
||
return redirect()->back()->withInput()->with('error', "Emergency contact limit exceeded. You have $existingECCount and tried to add $newECCount (limit: $maxEmergency).");
|
||
}
|
||
|
||
// ✅ 6. Start transaction and save
|
||
$this->db->transStart();
|
||
|
||
$studentAdded = false;
|
||
|
||
// Gather all POST data for last year flags
|
||
$rawPost = $this->request->getPost();
|
||
|
||
// 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($rawPost[$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, null);
|
||
|
||
if ($result) {
|
||
$studentAdded = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
$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.');
|
||
}
|
||
|
||
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());
|
||
}
|
||
}
|
||
|
||
|
||
// 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 getEnrollmentsByParent($parentId, $schoolYear)
|
||
{
|
||
return $this->enrollmentModel
|
||
->where('parent_id', $parentId)
|
||
->where('school_year', $schoolYear)
|
||
->orderBy('enrollment_date', 'DESC')
|
||
->findAll();
|
||
}
|
||
|
||
private function getRegistrationData(int $parentId): array
|
||
{
|
||
$schoolYearContext = $this->resolveSchoolYearContext();
|
||
$selectedSchoolYear = $schoolYearContext->yearName();
|
||
$enrollments = $this->getEnrollmentsByParent($parentId, $selectedSchoolYear);
|
||
|
||
$enrollmentMap = [];
|
||
if (!empty($enrollments)) {
|
||
foreach ($enrollments as $enroll) {
|
||
$enrollmentMap[$enroll['student_id']] = $enroll;
|
||
}
|
||
}
|
||
|
||
$user = $this->userModel->find($parentId);
|
||
if (!$user || $user['user_type'] !== 'primary') {
|
||
throw new \RuntimeException('Only primary parents are allowed to register children.');
|
||
}
|
||
|
||
$kids = $this->studentModel->where('parent_id', $parentId)->findAll();
|
||
foreach ($kids as &$kid) {
|
||
$studentId = (int) ($kid['id'] ?? 0);
|
||
$kid['allergies'] = $this->allergyModel
|
||
->where('student_id', $studentId)
|
||
->findColumn('allergy') ?? [];
|
||
|
||
$kid['medical_conditions'] = $this->medicalConditionModel
|
||
->where('student_id', $studentId)
|
||
->findColumn('condition_name') ?? [];
|
||
|
||
$kid['enrollment'] = isset($enrollmentMap[$studentId]['id']) && !empty($enrollmentMap[$studentId]['id']) ? 1 : 0;
|
||
$kid['can_delete'] = $this->canParentDeleteStudent($kid, $parentId);
|
||
}
|
||
|
||
$emergencies = $this->emergencyContactModel->where('parent_id', $parentId)->findAll();
|
||
|
||
return [
|
||
'existingKids' => $kids,
|
||
'emergencies' => $emergencies,
|
||
'parent' => $user,
|
||
'maxChilds' => $this->maxChilds,
|
||
'maxEmergency' => $this->maxEmergency,
|
||
'enrollments' => $enrollments,
|
||
'selectedYear' => $selectedSchoolYear,
|
||
'isEditable' => ! $schoolYearContext->isReadonly(),
|
||
];
|
||
}
|
||
|
||
private function validateAndSaveOrUpdateStudent($idx, $parentId, $semester, $schoolYear, $schoolIdService, $isNew = null, $studentId = null)
|
||
{
|
||
$firstName = $this->request->getPost('studentFirstName')[$idx] ?? null;
|
||
$lastName = $this->request->getPost('studentLastName')[$idx] ?? null;
|
||
$dob = $this->request->getPost('dob')[$idx] ?? null;
|
||
$gender = $this->request->getPost('gender')[$idx] ?? null;
|
||
$grade = $this->request->getPost('registration_grade')[$idx] ?? null;
|
||
$conditions = $this->request->getPost('medical_conditions')[$idx] ?? [];
|
||
$allergies = $this->request->getPost('allergies')[$idx] ?? [];
|
||
$photoRaw = $this->request->getPost('photo_consent')[$idx] ?? '';
|
||
|
||
if (!$firstName || !$lastName || !$dob || !$gender || !$grade) {
|
||
return false;
|
||
}
|
||
|
||
// Normalize names: trim, collapse internal spaces, ucfirst each word
|
||
$normalize = function (string $s): string {
|
||
$s = trim($s);
|
||
$s = preg_replace('/\s+/', ' ', $s); // collapse whitespace
|
||
// Keep your existing formatName if you prefer; this is a safe default:
|
||
return mb_convert_case($s, MB_CASE_TITLE, 'UTF-8'); // "ahmed ali" -> "Ahmed Ali"
|
||
};
|
||
|
||
$firstName = $normalize($firstName);
|
||
$lastName = $normalize($lastName);
|
||
|
||
// Still do your stricter name validation if desired
|
||
$this->validateNames($firstName);
|
||
$this->validateNames($lastName);
|
||
|
||
$dobObj = new \DateTime($dob);
|
||
$schoolYearAgeDeadline = $this->schoolYearAgeDeadline($schoolYear);
|
||
$age = $this->calculateAgeAsOfSchoolYearStartYear($dob, $schoolYear);
|
||
|
||
$validation = $this->validateDobAge(
|
||
$dob,
|
||
$this->registrationMinimumAgeDeadline($schoolYear),
|
||
5,
|
||
18,
|
||
$schoolYearAgeDeadline
|
||
);
|
||
if (!$validation['isValid']) {
|
||
$displayDeadline = (new DateTime($schoolYearAgeDeadline))->format('m-d-Y');
|
||
session()->setFlashdata(
|
||
'error',
|
||
"Student '{$firstName} {$lastName}' {$validation['message']}. General age is calculated as of {$displayDeadline}."
|
||
);
|
||
return false;
|
||
}
|
||
|
||
$photoConsent = strtolower($photoRaw) === 'yes' ? 1 : 0;
|
||
|
||
$studentData = [
|
||
'firstname' => $firstName,
|
||
'lastname' => $lastName,
|
||
'age' => $age,
|
||
'dob' => $dobObj->format('Y-m-d'),
|
||
'gender' => $gender,
|
||
'registration_grade' => $grade,
|
||
'photo_consent' => $photoConsent,
|
||
'parent_id' => (int) $parentId,
|
||
'year_of_registration' => date('Y'),
|
||
'school_year' => $schoolYear,
|
||
'semester' => $semester,
|
||
];
|
||
|
||
if (!is_null($isNew)) {
|
||
$studentData['is_new'] = $isNew ? 1 : 0;
|
||
}
|
||
|
||
// ---------- DUPLICATE CHECK (case/space-insensitive) ----------
|
||
// Using LOWER(TRIM(...)) bound safely; CI4 will bind the value.
|
||
$fnKey = mb_strtolower(trim(preg_replace('/\s+/', ' ', $firstName)), 'UTF-8');
|
||
$lnKey = mb_strtolower(trim(preg_replace('/\s+/', ' ', $lastName)), 'UTF-8');
|
||
$dobStr = $dobObj->format('Y-m-d');
|
||
|
||
$existing = $this->studentModel
|
||
->where('school_year', $schoolYear)
|
||
->where('dob', $dobStr)
|
||
->where('firstname', $firstName) // already normalized above
|
||
->where('lastname', $lastName) // already normalized above
|
||
->first();
|
||
|
||
if (!$studentId && $existing) {
|
||
session()->setFlashdata(
|
||
'error',
|
||
"Student '{$firstName} {$lastName}' with the same birthdate is already registered for $schoolYear."
|
||
);
|
||
return false;
|
||
}
|
||
|
||
|
||
// ---------- UPDATE OR INSERT ----------
|
||
if ($studentId) {
|
||
$this->studentModel->update($studentId, $studentData);
|
||
} else {
|
||
$studentData['registration_date'] = utc_now();
|
||
$studentData['tuition_paid'] = 0;
|
||
$studentData['school_id'] = $schoolIdService->generateStudentSchoolId();
|
||
|
||
try {
|
||
$studentId = $this->studentModel->insert($studentData, true);
|
||
} catch (\CodeIgniter\Database\Exceptions\DatabaseException $e) {
|
||
// If DB unique index (below) triggers 1062 duplicate-key, show a friendly message
|
||
if (strpos($e->getMessage(), '1062') !== false) {
|
||
session()->setFlashdata('error', "Student '{$firstName} {$lastName}' with the same birthdate is already registered for $schoolYear.");
|
||
return false;
|
||
}
|
||
throw $e; // other DB errors bubble up
|
||
}
|
||
}
|
||
|
||
// ---------- SAVE MEDICAL CONDITIONS ----------
|
||
$this->medicalConditionModel->where('student_id', $studentId)->delete();
|
||
foreach ((array) $conditions as $c) {
|
||
$c = trim($c);
|
||
if ($c !== '') {
|
||
$this->medicalConditionModel->insert([
|
||
'student_id' => $studentId,
|
||
'condition_name' => $c,
|
||
]);
|
||
}
|
||
}
|
||
|
||
// ---------- SAVE ALLERGIES ----------
|
||
$this->allergyModel->where('student_id', $studentId)->delete();
|
||
foreach ((array) $allergies as $a) {
|
||
$a = trim($a);
|
||
if ($a !== '') {
|
||
$this->allergyModel->insert([
|
||
'student_id' => $studentId,
|
||
'allergy' => $a,
|
||
]);
|
||
}
|
||
}
|
||
|
||
return 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 validateNames($name)
|
||
{
|
||
if (!preg_match('/^[A-Za-z\s\-]{2,30}$/', $name)) {
|
||
throw new InvalidArgumentException("Invalid name format: Only letters, spaces, or dashes (2–30 chars) allowed.");
|
||
}
|
||
}
|
||
|
||
private function saveEmergencyContact($parentId, $semester, $schoolYear, $single = null, $id = null)
|
||
{
|
||
$phoneFormatter = new PhoneFormatterService(); // Load the formatter service
|
||
|
||
// ✅ SINGLE CONTACT MODE
|
||
if ($single !== null) {
|
||
$firstName = $this->formatName($single['first_name'] ?? '');
|
||
$lastName = $this->formatName($single['last_name'] ?? '');
|
||
$relation = trim($single['relation'] ?? '');
|
||
$phone = $phoneFormatter->formatPhoneNumber($single['cellphone'] ?? '');
|
||
$email = strtolower(trim($single['email'] ?? ''));
|
||
|
||
if ($firstName === '' && $lastName === '' && $phone === '(000)-000-0000' && $email === '' && $relation === '') {
|
||
return;
|
||
}
|
||
|
||
$this->validateNames($firstName);
|
||
$this->validateNames($lastName);
|
||
|
||
if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||
throw new \Exception('Invalid email format for emergency contact.');
|
||
}
|
||
|
||
$fullName = $firstName . ' ' . $lastName;
|
||
|
||
$data = [
|
||
'parent_id' => $parentId,
|
||
'emergency_contact_name' => $fullName,
|
||
'cellphone' => $phone,
|
||
'email' => $email,
|
||
'relation' => $relation,
|
||
'updated_at' => utc_now(),
|
||
];
|
||
|
||
if ($id !== null) {
|
||
$duplicate = $this->emergencyContactModel
|
||
->where('parent_id', $parentId)
|
||
->where('emergency_contact_name', $fullName)
|
||
->where('cellphone', $phone)
|
||
->where('email', $email)
|
||
->where('relation', $relation)
|
||
->where('id !=', $id)
|
||
->first();
|
||
|
||
if ($duplicate) {
|
||
session()->setFlashdata('error', 'Another emergency contact with the same information already exists.');
|
||
return redirect()->back()->withInput();
|
||
}
|
||
|
||
$this->emergencyContactModel->update($id, $data);
|
||
} else {
|
||
$exists = $this->emergencyContactModel->where([
|
||
'parent_id' => $parentId,
|
||
'emergency_contact_name' => $fullName,
|
||
'cellphone' => $phone,
|
||
'email' => $email,
|
||
'relation' => $relation,
|
||
])->first();
|
||
|
||
if ($exists) {
|
||
session()->setFlashdata('error', 'This emergency contact is already registered.');
|
||
return redirect()->back()->withInput();
|
||
}
|
||
|
||
$this->emergencyContactModel->insert($data);
|
||
}
|
||
}
|
||
|
||
// ✅ BULK MODE
|
||
$firstNames = $this->request->getPost('emergency_firstname') ?? [];
|
||
$lastNames = $this->request->getPost('emergency_lastname') ?? [];
|
||
$relations = $this->request->getPost('emergency_relation') ?? [];
|
||
$phones = $this->request->getPost('emergency_phone') ?? [];
|
||
$emails = $this->request->getPost('emergency_email') ?? [];
|
||
|
||
foreach ($firstNames as $idx => $first) {
|
||
$firstName = $this->formatName($first ?? '');
|
||
$lastName = $this->formatName($lastNames[$idx] ?? '');
|
||
$relation = trim($relations[$idx] ?? '');
|
||
$phone = $phoneFormatter->formatPhoneNumber($phones[$idx] ?? '');
|
||
$email = strtolower(trim($emails[$idx] ?? ''));
|
||
|
||
if ($firstName === '' && $lastName === '' && $phone === '(000)-000-0000' && $email === '' && $relation === '') {
|
||
continue;
|
||
}
|
||
|
||
if ($phone === '(000)-000-0000') {
|
||
throw new \Exception('Invalid phone number.');
|
||
}
|
||
if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||
throw new \Exception('Invalid email format for emergency contact.');
|
||
}
|
||
|
||
$fullName = $firstName . ' ' . $lastName;
|
||
|
||
$exists = $this->emergencyContactModel->where([
|
||
'parent_id' => $parentId,
|
||
'emergency_contact_name' => $fullName,
|
||
'cellphone' => $phone,
|
||
'email' => $email,
|
||
'relation' => $relation,
|
||
])->first();
|
||
|
||
if (!$exists) {
|
||
$this->emergencyContactModel->insert([
|
||
'parent_id' => $parentId,
|
||
'emergency_contact_name' => $fullName,
|
||
'cellphone' => $phone,
|
||
'email' => $email,
|
||
'relation' => $relation,
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
|
||
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, $id, null, null);
|
||
|
||
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
|
||
{
|
||
$studentId = (int) ($student['id'] ?? 0);
|
||
if ($studentId <= 0) {
|
||
return false;
|
||
}
|
||
|
||
if ((string) ($student['is_new'] ?? '1') === '0') {
|
||
return false;
|
||
}
|
||
|
||
if ($this->studentHasEnrollmentHistory($studentId, $parentId)) {
|
||
return false;
|
||
}
|
||
|
||
if ($this->studentHasClassAssignmentHistory($studentId)) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private function studentHasEnrollmentHistory(int $studentId, int $parentId): bool
|
||
{
|
||
if (! $this->db->tableExists('enrollments')) {
|
||
return false;
|
||
}
|
||
|
||
return $this->db->table('enrollments')
|
||
->where('student_id', $studentId)
|
||
->where('parent_id', $parentId)
|
||
->countAllResults() > 0;
|
||
}
|
||
|
||
private function studentHasClassAssignmentHistory(int $studentId): bool
|
||
{
|
||
if (! $this->db->tableExists('student_class')) {
|
||
return false;
|
||
}
|
||
|
||
return $this->db->table('student_class')
|
||
->where('student_id', $studentId)
|
||
->countAllResults() > 0;
|
||
}
|
||
|
||
|
||
|
||
private function formatName(string $name): string
|
||
{
|
||
$name = trim($name);
|
||
$name = strtolower($name);
|
||
$name = ucwords($name, ' ');
|
||
$name = implode('-', array_map('ucfirst', explode('-', $name)));
|
||
|
||
return $name;
|
||
}
|
||
|
||
|
||
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');
|
||
|
||
// Get active events
|
||
$activeEvents = $this->eventModel->getActiveEvents($schoolYear, $semester);
|
||
$activeEventCount = is_array($activeEvents) ? count($activeEvents) : 0;
|
||
|
||
// Get charges (participation info)
|
||
$chargesList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear, $semester);
|
||
|
||
// Build a map: "studentId:eventId" => [ 'participation' => ..., 'date' => ... ]
|
||
$charges = [];
|
||
$externalParticipantsByEvent = [];
|
||
foreach ($chargesList as $charge) {
|
||
$studentId = $charge['student_id'] ?? null;
|
||
$eventId = (int) ($charge['event_id'] ?? 0);
|
||
|
||
if (!empty($studentId)) {
|
||
$key = $studentId . ':' . $eventId;
|
||
$charges[$key] = [
|
||
'participation' => $charge['participation'],
|
||
'date' => $charge['updated_at'] ?? $charge['created_at'], // Use updated_at if available
|
||
];
|
||
continue;
|
||
}
|
||
|
||
$externalName = trim((string) ($charge['external_firstname'] ?? '') . ' ' . (string) ($charge['external_lastname'] ?? ''));
|
||
if ($eventId > 0 && $externalName !== '') {
|
||
$externalParticipantsByEvent[$eventId][] = [
|
||
'name' => $externalName,
|
||
'note' => (string) ($charge['external_note'] ?? ''),
|
||
'participation' => (string) ($charge['participation'] ?? ''),
|
||
'event_paid' => !empty($charge['event_paid']),
|
||
'charged' => (float) ($charge['charged'] ?? ($charge['event_amount'] ?? 0)),
|
||
];
|
||
}
|
||
}
|
||
|
||
// Get enrolled students
|
||
$students = $this->enrollmentModel->getEnrolledStudents($parentId, $schoolYear);
|
||
|
||
return view('parent/event_participation', [
|
||
'activeEvents' => $activeEvents,
|
||
'charges' => $charges,
|
||
'externalParticipantsByEvent' => $externalParticipantsByEvent,
|
||
'yourStudents' => $students,
|
||
'activeEventCount' => $activeEventCount,
|
||
]);
|
||
}
|
||
|
||
public function updateParticipation()
|
||
{
|
||
$participations = $this->request->getPost('participation'); // ['student_id:event_id' => 'yes'|'no']
|
||
$parentId = session()->get('user_id');
|
||
|
||
foreach ($participations as $key => $value) {
|
||
[$studentId, $eventId] = explode(':', $key);
|
||
|
||
$existing = $this->chargesModel->where([
|
||
'parent_id' => $parentId,
|
||
'student_id' => $studentId,
|
||
'event_id' => $eventId,
|
||
])->first();
|
||
|
||
if ($value === 'no') {
|
||
if ($existing) {
|
||
$this->chargesModel->delete($existing['id']);
|
||
}
|
||
continue; // skip to next entry
|
||
}
|
||
|
||
// value is 'yes'
|
||
if ($existing) {
|
||
$this->chargesModel->update($existing['id'], ['participation' => $value]);
|
||
} else {
|
||
$event = $this->eventModel->getEvent($eventId, $this->schoolYear);
|
||
|
||
$this->chargesModel->insert([
|
||
'parent_id' => $parentId,
|
||
'student_id' => $studentId,
|
||
'event_id' => $eventId,
|
||
'participation' => $value,
|
||
'charged' => $event['amount'],
|
||
'school_year' => $this->schoolYear,
|
||
'semester' => $this->semester,
|
||
'updated_by' => $parentId
|
||
]);
|
||
}
|
||
}
|
||
//recalculate the invoice
|
||
$this->eventController->generateInvoice($parentId);
|
||
|
||
return redirect()->back()->with('success', 'Participation updated');
|
||
}
|
||
|
||
private function canAccessUserRecord(int $id): bool
|
||
{
|
||
$userId = (int) (session()->get('user_id') ?? 0);
|
||
if ($userId <= 0 || $id <= 0) {
|
||
return false;
|
||
}
|
||
if ($userId === $id) {
|
||
return true;
|
||
}
|
||
|
||
$roles = array_map(
|
||
static fn ($role): string => strtolower(trim((string) $role)),
|
||
array_filter(array_merge((array) session()->get('roles'), [session()->get('role')]))
|
||
);
|
||
|
||
return (bool) array_intersect($roles, ['administrator', 'administrative staff', 'principal', 'admin']);
|
||
}
|
||
}
|