Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 140be9922d | |||
| 361d0c0d3a | |||
| 0f8ad86b4f | |||
| 48ae2805d3 | |||
| 22c4cf6fd2 | |||
| 6cf3a607a4 | |||
| 611a5c8e4b |
+65
-72
@@ -198,15 +198,15 @@ $routes->group('administrator/subject-curriculum', ['filter' => 'auth:update_cur
|
|||||||
$routes->post('delete/(:num)', 'View\SubjectCurriculumController::delete/$1');
|
$routes->post('delete/(:num)', 'View\SubjectCurriculumController::delete/$1');
|
||||||
});
|
});
|
||||||
|
|
||||||
$routes->get('administrator/trophy', 'View\TrophyController::index', ['filter' => 'auth:admin']);
|
$routes->get('administrator/trophy', 'View\TrophyController::index', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('administrator/trophy/winners', 'View\TrophyController::winners', ['filter' => 'auth:admin']);
|
$routes->get('administrator/trophy/winners', 'View\TrophyController::winners', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('administrator/trophy/final', 'View\TrophyController::final', ['filter' => 'auth:admin']);
|
$routes->get('administrator/trophy/final', 'View\TrophyController::final', ['filter' => 'auth:admin|principal']);
|
||||||
|
|
||||||
// Certificates
|
// Certificates
|
||||||
$routes->get('administrator/certificates', 'View\CertificateController::index', ['filter' => 'auth:admin']);
|
$routes->get('administrator/certificates', 'View\CertificateController::index', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('administrator/certificates/csrf-token', 'View\CertificateController::csrfToken', ['filter' => 'auth:admin']);
|
$routes->get('administrator/certificates/csrf-token', 'View\CertificateController::csrfToken', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('administrator/certificates/generate', 'View\CertificateController::generate', ['filter' => 'auth:admin']);
|
$routes->post('administrator/certificates/generate', 'View\CertificateController::generate', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('administrator/certificates/log', 'View\CertificateController::auditLog', ['filter' => 'auth:admin']);
|
$routes->get('administrator/certificates/log', 'View\CertificateController::auditLog', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('administrator/certificates/reprint/(:any)', 'View\CertificateController::reprint/$1', ['filter' => 'auth:read']);
|
$routes->get('administrator/certificates/reprint/(:any)', 'View\CertificateController::reprint/$1', ['filter' => 'auth:read']);
|
||||||
$routes->get('verify/(:segment)', 'View\CertificateController::verify/$1');
|
$routes->get('verify/(:segment)', 'View\CertificateController::verify/$1');
|
||||||
|
|
||||||
@@ -423,7 +423,7 @@ $routes->get('/teacher/exam-drafts/status', 'View\ExamDraftController::teacherSt
|
|||||||
|
|
||||||
$routes->get('teacher/print-requests', 'PrintRequests::teacher_index', ['filter' => 'auth:teacher,teacher_assistant']);
|
$routes->get('teacher/print-requests', 'PrintRequests::teacher_index', ['filter' => 'auth:teacher,teacher_assistant']);
|
||||||
|
|
||||||
$routes->get('admin/print-requests', 'PrintRequests::admin_index', ['filter' => 'auth:admin']);
|
$routes->get('admin/print-requests', 'PrintRequests::admin_index', ['filter' => 'auth:admin|principal']);
|
||||||
|
|
||||||
$routes->post('print-requests/create', 'PrintRequests::create', ['filter' => 'auth:teacher,teacher_assistant']);
|
$routes->post('print-requests/create', 'PrintRequests::create', ['filter' => 'auth:teacher,teacher_assistant']);
|
||||||
$routes->post('print-requests/create-copy', 'PrintRequests::createCopy', ['filter' => 'auth:teacher,teacher_assistant']);
|
$routes->post('print-requests/create-copy', 'PrintRequests::createCopy', ['filter' => 'auth:teacher,teacher_assistant']);
|
||||||
@@ -442,10 +442,6 @@ $routes->get('exam-drafts/files/final/(:segment)', 'View\FilesController::examDr
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
$routes->get('teacher/progress', 'ClassProgressController::history', ['filter' => 'auth:teacher,teacher_assistant']);
|
$routes->get('teacher/progress', 'ClassProgressController::history', ['filter' => 'auth:teacher,teacher_assistant']);
|
||||||
$routes->get('teacher/progress/submit', 'ClassProgressController::create', ['filter' => 'auth:teacher,teacher_assistant']);
|
$routes->get('teacher/progress/submit', 'ClassProgressController::create', ['filter' => 'auth:teacher,teacher_assistant']);
|
||||||
$routes->post('teacher/progress/store', 'ClassProgressController::store', ['filter' => 'auth:teacher,teacher_assistant']);
|
$routes->post('teacher/progress/store', 'ClassProgressController::store', ['filter' => 'auth:teacher,teacher_assistant']);
|
||||||
@@ -460,18 +456,18 @@ $routes->get('parent/progress/view/(:num)', 'ParentProgressController::view/$1',
|
|||||||
$routes->get('parent/progress/attachment/(:num)', 'ParentProgressController::attachment/$1', ['filter' => 'auth:parent']);
|
$routes->get('parent/progress/attachment/(:num)', 'ParentProgressController::attachment/$1', ['filter' => 'auth:parent']);
|
||||||
$routes->get('parent/progress/attachment-file/(:num)', 'ParentProgressController::attachmentFile/$1', ['filter' => 'auth:parent']);
|
$routes->get('parent/progress/attachment-file/(:num)', 'ParentProgressController::attachmentFile/$1', ['filter' => 'auth:parent']);
|
||||||
|
|
||||||
$routes->get('admin/progress', 'AdminProgressController::index', ['filter' => 'auth:admin']);
|
$routes->get('admin/progress', 'AdminProgressController::index', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('admin/progress/view/(:num)', 'AdminProgressController::view/$1', ['filter' => 'auth:admin']);
|
$routes->get('admin/progress/view/(:num)', 'AdminProgressController::view/$1', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('admin/progress/attachment/(:num)', 'AdminProgressController::attachment/$1', ['filter' => 'auth:admin']);
|
$routes->get('admin/progress/attachment/(:num)', 'AdminProgressController::attachment/$1', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('admin/progress/attachment-file/(:num)', 'AdminProgressController::attachmentFile/$1', ['filter' => 'auth:admin']);
|
$routes->get('admin/progress/attachment-file/(:num)', 'AdminProgressController::attachmentFile/$1', ['filter' => 'auth:admin|principal']);
|
||||||
|
|
||||||
$routes->get('/teacher/calendar', 'View\SchoolCalendarController::calendarTeacherView');
|
$routes->get('/teacher/calendar', 'View\SchoolCalendarController::calendarTeacherView');
|
||||||
$routes->get('/teacher/absence', 'View\TeacherController::absenceForm', ['filter' => 'auth:teacher,teacher_assistant']);
|
$routes->get('/teacher/absence', 'View\TeacherController::absenceForm', ['filter' => 'auth:teacher,teacher_assistant']);
|
||||||
$routes->post('/teacher/absence', 'View\TeacherController::submitAbsence', ['filter' => 'auth:teacher,teacher_assistant']);
|
$routes->post('/teacher/absence', 'View\TeacherController::submitAbsence', ['filter' => 'auth:teacher,teacher_assistant']);
|
||||||
|
|
||||||
// Admin self-service staff absence (same features as teacher page)
|
// Admin self-service staff absence (same features as teacher page)
|
||||||
$routes->get('/administrator/absence', 'View\AdministratorController::absenceFormAdmin', ['filter' => 'auth:admin']);
|
$routes->get('/administrator/absence', 'View\AdministratorController::absenceFormAdmin', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('/administrator/absence', 'View\AdministratorController::submitAbsenceAdmin', ['filter' => 'auth:admin']);
|
$routes->post('/administrator/absence', 'View\AdministratorController::submitAbsenceAdmin', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('/timeoff/notify/(:segment)', 'TimeOffNotificationController::notify/$1');
|
$routes->get('/timeoff/notify/(:segment)', 'TimeOffNotificationController::notify/$1');
|
||||||
$routes->get('/teacher/showupdate_attendance', 'View\AttendanceController::showUpdateAttendanceForm');
|
$routes->get('/teacher/showupdate_attendance', 'View\AttendanceController::showUpdateAttendanceForm');
|
||||||
$routes->post('/teacher/update_attendance', 'View\AttendanceController::updateAttendance');
|
$routes->post('/teacher/update_attendance', 'View\AttendanceController::updateAttendance');
|
||||||
@@ -586,11 +582,11 @@ $routes->post('grading/release-scores', 'View\GradingController::toggleParentSco
|
|||||||
$routes->post('grading/refresh-semester-scores', 'View\GradingController::refreshSemesterScores', ['filter' => 'auth:read']);
|
$routes->post('grading/refresh-semester-scores', 'View\GradingController::refreshSemesterScores', ['filter' => 'auth:read']);
|
||||||
|
|
||||||
// Event admin routes
|
// Event admin routes
|
||||||
$routes->get('administrator/events', 'View\EventController::index', ['filter' => 'auth:admin']);
|
$routes->get('administrator/events', 'View\EventController::index', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('administrator/events/create', 'View\EventController::create', ['filter' => 'auth:admin']);
|
$routes->get('administrator/events/create', 'View\EventController::create', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('administrator/events/create', 'View\EventController::create', ['filter' => 'auth:admin']);
|
$routes->post('administrator/events/create', 'View\EventController::create', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->match(['get', 'post'], 'administrator/events/edit/(:num)', 'View\EventController::edit/$1', ['filter' => 'auth:admin']);
|
$routes->match(['get', 'post'], 'administrator/events/edit/(:num)', 'View\EventController::edit/$1', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('administrator/events/delete/(:num)', 'View\EventController::delete/$1', ['filter' => 'auth:admin']);
|
$routes->post('administrator/events/delete/(:num)', 'View\EventController::delete/$1', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('payment/event_charges', 'View\EventController::eventUpdate', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
$routes->post('payment/event_charges', 'View\EventController::eventUpdate', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||||
|
|
||||||
// Parent event participation
|
// Parent event participation
|
||||||
@@ -697,20 +693,20 @@ $routes->get('reimbursements/export', 'View\ReimbursementController::export', ['
|
|||||||
$routes->get('reimbursements', 'View\ReimbursementController::index', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
|
$routes->get('reimbursements', 'View\ReimbursementController::index', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
|
||||||
|
|
||||||
// Health check (upload dirs + DB timezone columns)
|
// Health check (upload dirs + DB timezone columns)
|
||||||
$routes->get('admin/health', 'View\HealthController::index', ['filter' => 'auth:admin']);
|
$routes->get('admin/health', 'View\HealthController::index', ['filter' => 'auth:admin|principal']);
|
||||||
|
|
||||||
//Notifications
|
//Notifications
|
||||||
$routes->get('notifications/active', 'View\NotificationsController::listActive', ['filter' => 'auth:admin']);
|
$routes->get('notifications/active', 'View\NotificationsController::listActive', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('api/notifications/active', 'View\NotificationsController::activeNotificationsData', ['filter' => 'auth:admin']);
|
$routes->get('api/notifications/active', 'View\NotificationsController::activeNotificationsData', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('notifications/deleted', 'View\NotificationsController::listDeleted', ['filter' => 'auth:admin']);
|
$routes->get('notifications/deleted', 'View\NotificationsController::listDeleted', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('api/notifications/deleted', 'View\NotificationsController::deletedNotificationsData', ['filter' => 'auth:admin']);
|
$routes->get('api/notifications/deleted', 'View\NotificationsController::deletedNotificationsData', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('notifications/restore/(:num)', 'View\NotificationsController::restore/$1', ['filter' => 'auth:admin']);
|
$routes->post('notifications/restore/(:num)', 'View\NotificationsController::restore/$1', ['filter' => 'auth:admin|principal']);
|
||||||
//$routes->post('notifications/restore/(:num)', 'View\NotificationsController::restore/$1');
|
//$routes->post('notifications/restore/(:num)', 'View\NotificationsController::restore/$1');
|
||||||
//$routes->get('notifications/mark-read/(:num)', 'View\NotificationsController::markAsRead/$1');
|
//$routes->get('notifications/mark-read/(:num)', 'View\NotificationsController::markAsRead/$1');
|
||||||
$routes->get('/administrator/notifications_alerts', 'View\AdministratorController::notificationsAlerts', ['filter' => 'auth:admin']);
|
$routes->get('/administrator/notifications_alerts', 'View\AdministratorController::notificationsAlerts', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('/administrator/notifications_alerts/save', 'View\AdministratorController::saveNotificationSubjects', ['filter' => 'auth:admin']);
|
$routes->post('/administrator/notifications_alerts/save', 'View\AdministratorController::saveNotificationSubjects', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('/administrator/print-notifications', 'View\AdministratorController::printNotificationRecipients', ['filter' => 'auth:admin']);
|
$routes->get('/administrator/print-notifications', 'View\AdministratorController::printNotificationRecipients', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('/administrator/print-notifications/save', 'View\AdministratorController::savePrintNotificationRecipients', ['filter' => 'auth:admin']);
|
$routes->post('/administrator/print-notifications/save', 'View\AdministratorController::savePrintNotificationRecipients', ['filter' => 'auth:admin|principal']);
|
||||||
|
|
||||||
|
|
||||||
$routes->get(
|
$routes->get(
|
||||||
@@ -782,14 +778,14 @@ $routes->get('administrator/userSearch', 'View\AdministratorController::userSear
|
|||||||
|
|
||||||
$routes->get('/administrator/student_profiles', 'View\AdministratorController::studentProfiles');
|
$routes->get('/administrator/student_profiles', 'View\AdministratorController::studentProfiles');
|
||||||
$routes->get('/administrator/parent_profiles', 'View\AdministratorController::parentProfiles');
|
$routes->get('/administrator/parent_profiles', 'View\AdministratorController::parentProfiles');
|
||||||
$routes->get('/administrator/teacher-submissions', 'View\AdministratorController::teacherSubmissionsReport', ['filter' => 'auth:admin']);
|
$routes->get('/administrator/teacher-submissions', 'View\AdministratorController::teacherSubmissionsReport', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('/administrator/teacher-submissions/notify', 'View\AdministratorController::sendTeacherSubmissionNotifications', ['filter' => 'auth:admin']);
|
$routes->post('/administrator/teacher-submissions/notify', 'View\AdministratorController::sendTeacherSubmissionNotifications', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('/administrator/exam-drafts', 'View\ExamDraftController::adminIndex', ['filter' => 'auth:admin']);
|
$routes->get('/administrator/exam-drafts', 'View\ExamDraftController::adminIndex', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('/administrator/exam-drafts/review', 'View\ExamDraftController::adminReview', ['filter' => 'auth:admin']);
|
$routes->post('/administrator/exam-drafts/review', 'View\ExamDraftController::adminReview', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('/administrator/exam-drafts/upload-legacy', 'View\ExamDraftController::adminUploadLegacy', ['filter' => 'auth:admin']);
|
$routes->post('/administrator/exam-drafts/upload-legacy', 'View\ExamDraftController::adminUploadLegacy', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('/principal/exam-drafts', 'View\ExamDraftController::principalIndex', ['filter' => 'auth:admin']);
|
$routes->get('/principal/exam-drafts', 'View\ExamDraftController::principalIndex', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('/principal/exam-drafts/review', 'View\ExamDraftController::principalReview', ['filter' => 'auth:admin']);
|
$routes->post('/principal/exam-drafts/review', 'View\ExamDraftController::principalReview', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('/principal/exam-drafts/upload-legacy', 'View\ExamDraftController::principalUploadLegacy', ['filter' => 'auth:admin']);
|
$routes->post('/principal/exam-drafts/upload-legacy', 'View\ExamDraftController::principalUploadLegacy', ['filter' => 'auth:admin|principal']);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* --------------------------------------------------------------------
|
* --------------------------------------------------------------------
|
||||||
@@ -887,13 +883,13 @@ $routes->get('/parent/attendance', 'View\ParentController::attendance', ['filter
|
|||||||
$routes->get('parent/report-attendance', 'View\ParentAttendanceReportController::form', ['filter' => 'auth:parent']);
|
$routes->get('parent/report-attendance', 'View\ParentAttendanceReportController::form', ['filter' => 'auth:parent']);
|
||||||
$routes->post('parent/report-attendance', 'View\ParentAttendanceReportController::submit', ['filter' => 'auth:parent']);
|
$routes->post('parent/report-attendance', 'View\ParentAttendanceReportController::submit', ['filter' => 'auth:parent']);
|
||||||
$routes->post('parent/report-attendance/update', 'View\ParentAttendanceReportController::update', ['filter' => 'auth:parent']);
|
$routes->post('parent/report-attendance/update', 'View\ParentAttendanceReportController::update', ['filter' => 'auth:parent']);
|
||||||
$routes->get('attendance/parent-reports', 'View\ParentAttendanceReportController::list', ['filter' => 'auth:admin']);
|
$routes->get('attendance/parent-reports', 'View\ParentAttendanceReportController::list', ['filter' => 'auth:admin|principal']);
|
||||||
// Admin/Teacher: early dismissal page by month
|
// Admin/Teacher: early dismissal page by month
|
||||||
$routes->get('attendance/early-dismissals', 'View\ParentAttendanceReportController::earlyDismissals', ['filter' => 'auth:admin']);
|
$routes->get('attendance/early-dismissals', 'View\ParentAttendanceReportController::earlyDismissals', ['filter' => 'auth:admin|principal']);
|
||||||
// Admin/Teacher: add early dismissal
|
// Admin/Teacher: add early dismissal
|
||||||
$routes->get('attendance/early-dismissals/new', 'View\ParentAttendanceReportController::addEarlyDismissalForm', ['filter' => 'auth:admin']);
|
$routes->get('attendance/early-dismissals/new', 'View\ParentAttendanceReportController::addEarlyDismissalForm', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('attendance/early-dismissals', 'View\ParentAttendanceReportController::saveEarlyDismissal', ['filter' => 'auth:admin']);
|
$routes->post('attendance/early-dismissals', 'View\ParentAttendanceReportController::saveEarlyDismissal', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('attendance/early-dismissals/signature', 'View\ParentAttendanceReportController::uploadEarlyDismissalSignature', ['filter' => 'auth:admin']);
|
$routes->post('attendance/early-dismissals/signature', 'View\ParentAttendanceReportController::uploadEarlyDismissalSignature', ['filter' => 'auth:admin|principal']);
|
||||||
// Parent report: client-side check API
|
// Parent report: client-side check API
|
||||||
$routes->post('api/parent/report-attendance/check', 'View\ParentAttendanceReportController::checkExisting', ['filter' => 'auth:parent']);
|
$routes->post('api/parent/report-attendance/check', 'View\ParentAttendanceReportController::checkExisting', ['filter' => 'auth:parent']);
|
||||||
|
|
||||||
@@ -980,9 +976,6 @@ $routes->group('inventory', ['filter' => 'auth:view_inventory|administrator|admi
|
|||||||
// $routes->get('(:alpha)', 'View\InventoryController::index/$1');
|
// $routes->get('(:alpha)', 'View\InventoryController::index/$1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
$routes->get('admin/enrollment/new-students', 'View\AdministratorController::showNewStudents', ['filter' => 'auth:view_new_students']);
|
$routes->get('admin/enrollment/new-students', 'View\AdministratorController::showNewStudents', ['filter' => 'auth:view_new_students']);
|
||||||
|
|
||||||
|
|
||||||
@@ -1031,13 +1024,13 @@ $routes->group('communications', static function ($routes) {
|
|||||||
////////////////////////////////////////////////
|
////////////////////////////////////////////////
|
||||||
|
|
||||||
// app/Config/Routes.php
|
// app/Config/Routes.php
|
||||||
$routes->group('api', ['filter' => 'auth:admin'], static function ($routes) {
|
$routes->group('api', ['filter' => 'auth:admin|principal'], static function ($routes) {
|
||||||
// Families & Guardians API
|
// Families & Guardians API
|
||||||
$routes->get('students/(:num)/families', 'View\FamilyController::familiesByStudent/$1');
|
$routes->get('students/(:num)/families', 'View\FamilyController::familiesByStudent/$1');
|
||||||
$routes->get('families/(:num)/guardians', 'View\FamilyController::guardiansByFamily/$1');
|
$routes->get('families/(:num)/guardians', 'View\FamilyController::guardiansByFamily/$1');
|
||||||
});
|
});
|
||||||
|
|
||||||
$routes->group('families', ['filter' => 'auth:admin'], static function ($routes) {
|
$routes->group('families', ['filter' => 'auth:admin|principal'], static function ($routes) {
|
||||||
// Allow GET for convenience in browser; keep POST for API/automation
|
// Allow GET for convenience in browser; keep POST for API/automation
|
||||||
$routes->match(['get', 'post'], 'bootstrap', 'View\FamilyController::bootstrap'); // protect with auth in production
|
$routes->match(['get', 'post'], 'bootstrap', 'View\FamilyController::bootstrap'); // protect with auth in production
|
||||||
$routes->post('attach-second-by-user', 'View\FamilyController::attachSecondByUser');
|
$routes->post('attach-second-by-user', 'View\FamilyController::attachSecondByUser');
|
||||||
@@ -1049,10 +1042,10 @@ $routes->group('families', ['filter' => 'auth:admin'], static function ($routes)
|
|||||||
$routes->post('unlink-student', 'View\FamilyController::unlinkStudent');
|
$routes->post('unlink-student', 'View\FamilyController::unlinkStudent');
|
||||||
});
|
});
|
||||||
// Allow GET for manual triggering from browser
|
// Allow GET for manual triggering from browser
|
||||||
$routes->match(['get', 'post'], 'families/import-legacy', 'View\FamilyController::importSecondParentsFromLegacy', ['filter' => 'auth:admin']);
|
$routes->match(['get', 'post'], 'families/import-legacy', 'View\FamilyController::importSecondParentsFromLegacy', ['filter' => 'auth:admin|principal']);
|
||||||
|
|
||||||
// Admin page (protect with your auth/permission)
|
// Admin page (protect with your auth/permission)
|
||||||
$routes->group('family', ['filter' => 'auth:admin'], static function ($routes) {
|
$routes->group('family', ['filter' => 'auth:admin|principal'], static function ($routes) {
|
||||||
$routes->get('', 'View\FamilyAdminController::index');
|
$routes->get('', 'View\FamilyAdminController::index');
|
||||||
$routes->get('index', 'View\FamilyAdminController::index');
|
$routes->get('index', 'View\FamilyAdminController::index');
|
||||||
$routes->get('search', 'View\FamilyAdminController::search');
|
$routes->get('search', 'View\FamilyAdminController::search');
|
||||||
@@ -1061,7 +1054,7 @@ $routes->group('family', ['filter' => 'auth:admin'], static function ($routes) {
|
|||||||
$routes->post('compose-email/send', 'View\FamilyAdminController::sendComposeEmail');
|
$routes->post('compose-email/send', 'View\FamilyAdminController::sendComposeEmail');
|
||||||
});
|
});
|
||||||
// Convenience alias
|
// Convenience alias
|
||||||
$routes->get('family', 'View\FamilyAdminController::index', ['filter' => 'auth:admin']);
|
$routes->get('family', 'View\FamilyAdminController::index', ['filter' => 'auth:admin|principal']);
|
||||||
//////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////
|
||||||
|
|
||||||
//upload files
|
//upload files
|
||||||
@@ -1079,24 +1072,24 @@ $routes->post('reimbursements/update/(:num)', 'View\ReimbursementController::upd
|
|||||||
|
|
||||||
|
|
||||||
// app/Config/Routes.php
|
// app/Config/Routes.php
|
||||||
$routes->get('whatsapp/', 'View\WhatsappController::index', ['filter' => 'auth:admin']);
|
$routes->get('whatsapp/', 'View\WhatsappController::index', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('whatsapp/sendInvites', 'View\WhatsappController::sendInvites', ['filter' => 'auth:admin']);
|
$routes->post('whatsapp/sendInvites', 'View\WhatsappController::sendInvites', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('whatsapp/saveLink', 'View\WhatsappController::saveLink', ['filter' => 'auth:admin']);
|
$routes->post('whatsapp/saveLink', 'View\WhatsappController::saveLink', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('whatsapp/parent-contacts', 'View\WhatsappController::parentContacts', ['filter' => 'auth:admin']);
|
$routes->get('whatsapp/parent-contacts', 'View\WhatsappController::parentContacts', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('whatsapp/parent-contacts-by-class', 'View\WhatsappController::parentContactsByClass', ['filter' => 'auth:admin']);
|
$routes->get('whatsapp/parent-contacts-by-class', 'View\WhatsappController::parentContactsByClass', ['filter' => 'auth:admin|principal']);
|
||||||
// Track WhatsApp group membership per class/parent
|
// Track WhatsApp group membership per class/parent
|
||||||
$routes->match(['get', 'post'], 'whatsapp/update-membership', 'View\WhatsappController::updateMembership', ['filter' => 'auth:admin']);
|
$routes->match(['get', 'post'], 'whatsapp/update-membership', 'View\WhatsappController::updateMembership', ['filter' => 'auth:admin|principal']);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
//Attendance
|
//Attendance
|
||||||
$routes->get('admin/teacher-attendance', 'View\AttendanceController::index', ['filter' => 'auth:admin']);
|
$routes->get('admin/teacher-attendance', 'View\AttendanceController::index', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('admin/teacher-attendance/save', 'View\AttendanceController::save', ['filter' => 'auth:admin']);
|
$routes->post('admin/teacher-attendance/save', 'View\AttendanceController::save', ['filter' => 'auth:admin|principal']);
|
||||||
// NEW: monthly overview (all sections)
|
// NEW: monthly overview (all sections)
|
||||||
$routes->get('admin/teacher-attendance/month', 'View\AttendanceController::month', ['filter' => 'auth:admin']);
|
$routes->get('admin/teacher-attendance/month', 'View\AttendanceController::month', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('admin/teacher-attendance/month.csv', 'View\AttendanceController::monthCsv', ['filter' => 'auth:admin']);
|
$routes->get('admin/teacher-attendance/month.csv', 'View\AttendanceController::monthCsv', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('api/admin/teacher-attendance/month', 'View\AttendanceController::monthData', ['filter' => 'auth:admin']);
|
$routes->get('api/admin/teacher-attendance/month', 'View\AttendanceController::monthData', ['filter' => 'auth:admin|principal']);
|
||||||
|
|
||||||
|
|
||||||
$routes->post('attendance/record', 'View\AttendanceTrackingController::record');
|
$routes->post('attendance/record', 'View\AttendanceTrackingController::record');
|
||||||
@@ -1785,13 +1778,13 @@ if (file_exists(APPPATH . 'Config/' . ENVIRONMENT . '/Routes.php')) {
|
|||||||
// ---- Administrator API endpoints (added by Codex) ----
|
// ---- Administrator API endpoints (added by Codex) ----
|
||||||
$routes->group('api/administrator', ['namespace' => 'App\\Controllers\\Api'], static function ($routes) {
|
$routes->group('api/administrator', ['namespace' => 'App\\Controllers\\Api'], static function ($routes) {
|
||||||
$routes->get('dashboard', 'AdministratorController::dashboard');
|
$routes->get('dashboard', 'AdministratorController::dashboard');
|
||||||
$routes->get('absence', 'AdministratorController::absenceInfo', ['filter' => 'auth:admin']);
|
$routes->get('absence', 'AdministratorController::absenceInfo', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('absence', 'AdministratorController::submitAbsence', ['filter' => 'auth:admin']);
|
$routes->post('absence', 'AdministratorController::submitAbsence', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('search', 'AdministratorController::search', ['filter' => 'auth:admin']);
|
$routes->get('search', 'AdministratorController::search', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('enrollment-withdrawal/data', 'AdministratorController::enrollmentWithdrawalData', ['filter' => 'auth:admin']);
|
$routes->get('enrollment-withdrawal/data', 'AdministratorController::enrollmentWithdrawalData', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->post('enrollment-withdrawal/update', 'AdministratorController::updateEnrollmentStatuses', ['filter' => 'auth:admin']);
|
$routes->post('enrollment-withdrawal/update', 'AdministratorController::updateEnrollmentStatuses', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('students', 'AdministratorController::studentProfiles', ['filter' => 'auth:admin']);
|
$routes->get('students', 'AdministratorController::studentProfiles', ['filter' => 'auth:admin|principal']);
|
||||||
$routes->get('parents', 'AdministratorController::parentProfiles', ['filter' => 'auth:admin']);
|
$routes->get('parents', 'AdministratorController::parentProfiles', ['filter' => 'auth:admin|principal']);
|
||||||
});
|
});
|
||||||
// ---- Parent API additions (parity with View) ----
|
// ---- Parent API additions (parity with View) ----
|
||||||
$routes->group('api/parents', ['namespace' => 'App\\Controllers\\Api'], static function ($routes) {
|
$routes->group('api/parents', ['namespace' => 'App\\Controllers\\Api'], static function ($routes) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Controllers;
|
|||||||
use App\Models\LoginActivityModel;
|
use App\Models\LoginActivityModel;
|
||||||
use App\Models\UserModel;
|
use App\Models\UserModel;
|
||||||
use App\Models\UserRoleModel;
|
use App\Models\UserRoleModel;
|
||||||
|
use App\Models\UserAccessProfileModel;
|
||||||
use CodeIgniter\Events\Events;
|
use CodeIgniter\Events\Events;
|
||||||
use App\Models\IpAttemptModel;
|
use App\Models\IpAttemptModel;
|
||||||
use App\Models\PasswordResetModel;
|
use App\Models\PasswordResetModel;
|
||||||
@@ -212,6 +213,7 @@ class AuthController extends BaseController
|
|||||||
|
|
||||||
// Fetch roles
|
// Fetch roles
|
||||||
$roleNames = $this->getUserRoleNames((int) $user['id']);
|
$roleNames = $this->getUserRoleNames((int) $user['id']);
|
||||||
|
$accessProfile = $this->accessProfileForUser((int) $user['id'], $roleNames);
|
||||||
|
|
||||||
// Build roles map (object with keys per example)
|
// Build roles map (object with keys per example)
|
||||||
$rolesMap = [];
|
$rolesMap = [];
|
||||||
@@ -248,6 +250,10 @@ class AuthController extends BaseController
|
|||||||
'id' => (int) $user['id'],
|
'id' => (int) $user['id'],
|
||||||
'name' => $payload['name'],
|
'name' => $payload['name'],
|
||||||
'roles' => (object) $rolesMap,
|
'roles' => (object) $rolesMap,
|
||||||
|
'primary_category' => $accessProfile['primary_category'],
|
||||||
|
'is_admin' => $accessProfile['is_admin'],
|
||||||
|
'is_teacher' => $accessProfile['is_teacher'],
|
||||||
|
'is_parent' => $accessProfile['is_parent'],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -274,6 +280,10 @@ class AuthController extends BaseController
|
|||||||
'type' => session()->get('user_type'),
|
'type' => session()->get('user_type'),
|
||||||
'roles' => $roles,
|
'roles' => $roles,
|
||||||
'role' => $activeRole,
|
'role' => $activeRole,
|
||||||
|
'primary_category' => session()->get('primary_category'),
|
||||||
|
'is_admin' => (bool) session()->get('is_admin'),
|
||||||
|
'is_teacher' => (bool) session()->get('is_teacher'),
|
||||||
|
'is_parent' => (bool) session()->get('is_parent'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -371,6 +381,7 @@ class AuthController extends BaseController
|
|||||||
'iat' => $now,
|
'iat' => $now,
|
||||||
'exp' => $exp,
|
'exp' => $exp,
|
||||||
];
|
];
|
||||||
|
$accessProfile = $this->accessProfileForUser((int) $userId, $payload['roles']);
|
||||||
|
|
||||||
$secret = require_env('JWT_SECRET');
|
$secret = require_env('JWT_SECRET');
|
||||||
$token = jwt_encode($payload, $secret, 'HS256');
|
$token = jwt_encode($payload, $secret, 'HS256');
|
||||||
@@ -384,6 +395,10 @@ class AuthController extends BaseController
|
|||||||
'name' => $payload['name'],
|
'name' => $payload['name'],
|
||||||
'email' => $userData['email'],
|
'email' => $userData['email'],
|
||||||
'roles' => $payload['roles'],
|
'roles' => $payload['roles'],
|
||||||
|
'primary_category' => $accessProfile['primary_category'],
|
||||||
|
'is_admin' => $accessProfile['is_admin'],
|
||||||
|
'is_teacher' => $accessProfile['is_teacher'],
|
||||||
|
'is_parent' => $accessProfile['is_parent'],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -477,11 +492,17 @@ class AuthController extends BaseController
|
|||||||
protected function getUserRoleNames(int $userId): array
|
protected function getUserRoleNames(int $userId): array
|
||||||
{
|
{
|
||||||
$userRoleModel = new UserRoleModel();
|
$userRoleModel = new UserRoleModel();
|
||||||
$rolesRows = $userRoleModel->select('roles.name')
|
$db = \Config\Database::connect();
|
||||||
|
$builder = $userRoleModel->select('roles.name')
|
||||||
->join('roles', 'roles.id = user_roles.role_id')
|
->join('roles', 'roles.id = user_roles.role_id')
|
||||||
->where('user_roles.user_id', $userId)
|
->where('user_roles.user_id', $userId)
|
||||||
->get()
|
->where('COALESCE(roles.is_active, 1) = 1', null, false);
|
||||||
->getResultArray();
|
|
||||||
|
if ($db->fieldExists('deleted_at', 'user_roles')) {
|
||||||
|
$builder->where('user_roles.deleted_at', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rolesRows = $builder->get()->getResultArray();
|
||||||
|
|
||||||
return array_column($rolesRows, 'name');
|
return array_column($rolesRows, 'name');
|
||||||
}
|
}
|
||||||
@@ -565,11 +586,17 @@ class AuthController extends BaseController
|
|||||||
private function loginUser($user, ?string $redirectTo = null)
|
private function loginUser($user, ?string $redirectTo = null)
|
||||||
{
|
{
|
||||||
$userRoleModel = new UserRoleModel();
|
$userRoleModel = new UserRoleModel();
|
||||||
$roles = $userRoleModel->select('roles.name')
|
$db = \Config\Database::connect();
|
||||||
|
$rolesBuilder = $userRoleModel->select('roles.name')
|
||||||
->join('roles', 'roles.id = user_roles.role_id')
|
->join('roles', 'roles.id = user_roles.role_id')
|
||||||
->where('user_roles.user_id', $user['id'])
|
->where('user_roles.user_id', $user['id'])
|
||||||
->get()
|
->where('COALESCE(roles.is_active, 1) = 1', null, false);
|
||||||
->getResultArray();
|
|
||||||
|
if ($db->fieldExists('deleted_at', 'user_roles')) {
|
||||||
|
$rolesBuilder->where('user_roles.deleted_at', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = $rolesBuilder->get()->getResultArray();
|
||||||
|
|
||||||
if (empty($roles)) {
|
if (empty($roles)) {
|
||||||
log_message('error', 'No roles found for user ID: ' . $user['id']);
|
log_message('error', 'No roles found for user ID: ' . $user['id']);
|
||||||
@@ -577,6 +604,7 @@ class AuthController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$roleNames = array_column($roles, 'name');
|
$roleNames = array_column($roles, 'name');
|
||||||
|
$accessProfile = $this->accessProfileForUser((int) $user['id'], $roleNames);
|
||||||
|
|
||||||
session()->regenerate(true);
|
session()->regenerate(true);
|
||||||
session()->set([
|
session()->set([
|
||||||
@@ -588,6 +616,10 @@ class AuthController extends BaseController
|
|||||||
'login_time' => time(),
|
'login_time' => time(),
|
||||||
'last_activity' => time(),
|
'last_activity' => time(),
|
||||||
'roles' => $roleNames,
|
'roles' => $roleNames,
|
||||||
|
'primary_category' => $accessProfile['primary_category'],
|
||||||
|
'is_admin' => $accessProfile['is_admin'],
|
||||||
|
'is_teacher' => $accessProfile['is_teacher'],
|
||||||
|
'is_parent' => $accessProfile['is_parent'],
|
||||||
'semester' => $this->semester,
|
'semester' => $this->semester,
|
||||||
'school_year' => $this->schoolYear,
|
'school_year' => $this->schoolYear,
|
||||||
]);
|
]);
|
||||||
@@ -612,6 +644,32 @@ class AuthController extends BaseController
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function accessProfileForUser(int $userId, array $roleNames): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$profile = model(UserAccessProfileModel::class)->getForUser($userId);
|
||||||
|
if ($profile !== null) {
|
||||||
|
return [
|
||||||
|
'primary_category' => (string) ($profile['primary_category'] ?? UserAccessProfileModel::CATEGORY_GUEST),
|
||||||
|
'is_admin' => (bool) ($profile['is_admin'] ?? false),
|
||||||
|
'is_teacher' => (bool) ($profile['is_teacher'] ?? false),
|
||||||
|
'is_parent' => (bool) ($profile['is_parent'] ?? false),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('warning', 'Unable to load user access profile: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$flags = UserAccessProfileModel::flagsForRoles($roleNames);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'primary_category' => UserAccessProfileModel::primaryCategory($flags),
|
||||||
|
'is_admin' => (bool) $flags['is_admin'],
|
||||||
|
'is_teacher' => (bool) $flags['is_teacher'],
|
||||||
|
'is_parent' => (bool) $flags['is_parent'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function applyStylePreferences(int $userId): void
|
private function applyStylePreferences(int $userId): void
|
||||||
{
|
{
|
||||||
if (!$userId) {
|
if (!$userId) {
|
||||||
|
|||||||
@@ -521,6 +521,7 @@ class AdministratorController extends BaseController
|
|||||||
|
|
||||||
return view('administrator/administratordashboard', array_merge($searchData, [
|
return view('administrator/administratordashboard', array_merge($searchData, [
|
||||||
'dashboardEndpoint' => site_url('api/administrator/dashboard'),
|
'dashboardEndpoint' => site_url('api/administrator/dashboard'),
|
||||||
|
'schoolYear' => $this->schoolYear,
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -235,6 +235,13 @@ class EnrollmentAdminController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$ruleCodes = $this->ruleCodesForFlag((string) $flag['flag_type']);
|
$ruleCodes = $this->ruleCodesForFlag((string) $flag['flag_type']);
|
||||||
|
if (
|
||||||
|
(string) $flag['flag_type'] === 'FINANCIAL_REVIEW_REQUIRED'
|
||||||
|
&& (string) ($this->request->getPost('allow_current_year_installments') ?? '') === '1'
|
||||||
|
) {
|
||||||
|
$ruleCodes[] = 'CURRENT_YEAR_INSTALLMENT_OVERRIDE';
|
||||||
|
$ruleCodes = array_values(array_unique($ruleCodes));
|
||||||
|
}
|
||||||
$now = date('Y-m-d H:i:s');
|
$now = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
$this->db->transStart();
|
$this->db->transStart();
|
||||||
@@ -349,7 +356,9 @@ class EnrollmentAdminController extends BaseController
|
|||||||
$postedCodes = is_array($postedCodes) ? array_values(array_filter(array_map('strval', $postedCodes))) : [];
|
$postedCodes = is_array($postedCodes) ? array_values(array_filter(array_map('strval', $postedCodes))) : [];
|
||||||
$postedCodes = array_values(array_unique(array_map(static fn (string $code): string => strtoupper(trim($code)), $postedCodes)));
|
$postedCodes = array_values(array_unique(array_map(static fn (string $code): string => strtoupper(trim($code)), $postedCodes)));
|
||||||
$failedCodes = array_values(array_unique(array_map(static fn (string $code): string => strtoupper(trim($code)), $failedCodes)));
|
$failedCodes = array_values(array_unique(array_map(static fn (string $code): string => strtoupper(trim($code)), $failedCodes)));
|
||||||
$ruleCodes = $postedCodes !== [] ? array_values(array_intersect($postedCodes, $failedCodes)) : $failedCodes;
|
$allowedCodes = $failedCodes;
|
||||||
|
$allowedCodes[] = 'CURRENT_YEAR_INSTALLMENT_OVERRIDE';
|
||||||
|
$ruleCodes = $postedCodes !== [] ? array_values(array_intersect($postedCodes, $allowedCodes)) : $failedCodes;
|
||||||
$ruleCodes = array_values(array_filter($ruleCodes, static fn (string $code): bool => $code !== ''));
|
$ruleCodes = array_values(array_filter($ruleCodes, static fn (string $code): bool => $code !== ''));
|
||||||
$nonOverridable = array_values(array_intersect($ruleCodes, ['ADULT_STUDENT_PARENT_BLOCKED']));
|
$nonOverridable = array_values(array_intersect($ruleCodes, ['ADULT_STUDENT_PARENT_BLOCKED']));
|
||||||
if ($nonOverridable !== []) {
|
if ($nonOverridable !== []) {
|
||||||
|
|||||||
@@ -1052,13 +1052,11 @@ class InvoiceController extends ResourceController
|
|||||||
$invoiceId = $invoice !== null ? (int) ($invoice['id'] ?? 0) : null;
|
$invoiceId = $invoice !== null ? (int) ($invoice['id'] ?? 0) : null;
|
||||||
$invoiceDate = null;
|
$invoiceDate = null;
|
||||||
|
|
||||||
if ($invoice !== null) {
|
if ($invoice !== null && ! empty($invoice['updated_at'])) {
|
||||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||||
$invoiceDate = ! empty($invoice['issue_date'])
|
$invoiceDate = (new \DateTimeImmutable($invoice['updated_at'], new \DateTimeZone('UTC')))
|
||||||
? (new \DateTimeImmutable($invoice['issue_date'], new \DateTimeZone('UTC')))
|
|
||||||
->setTimezone(new \DateTimeZone($tzName))
|
->setTimezone(new \DateTimeZone($tzName))
|
||||||
->format('Y-m-d H:i:s')
|
->format('Y-m-d H:i:s');
|
||||||
: ($invoice['updated_at'] ?? null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$description = '';
|
$description = '';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -391,9 +391,14 @@ class PaymentController extends ResourceController
|
|||||||
if ($parent && !empty($parent['id'])) {
|
if ($parent && !empty($parent['id'])) {
|
||||||
$parentData = $parent;
|
$parentData = $parent;
|
||||||
$parentId = (int) $parent['id'];
|
$parentId = (int) $parent['id'];
|
||||||
$carryForwardPaymentRequired = $this->parentHasActiveCarryForwardBalance($parentId, $manualPaySchoolYear);
|
$hasCarryForwardInvoice = $this->parentHasCarryForwardInvoice($parentId, $manualPaySchoolYear);
|
||||||
if ($carryForwardPaymentRequired) {
|
$hasCurrentYearInstallmentOverride = $this->parentHasCurrentYearInstallmentOverride($parentId, $manualPaySchoolYear);
|
||||||
$carryForwardPaymentMessage = 'This parent has a balance carried over from a previous school year. Manual payments must be paid in full; installments are not allowed.';
|
$carryForwardPaymentRequired = $hasCarryForwardInvoice && ! $hasCurrentYearInstallmentOverride;
|
||||||
|
if ($hasCarryForwardInvoice) {
|
||||||
|
$carryForwardPaymentMessage = 'This parent has a carry-over balance record from a previous school year. Installments are not allowed without an admin exception.';
|
||||||
|
if ($hasCurrentYearInstallmentOverride) {
|
||||||
|
$carryForwardPaymentMessage = 'Carry-over invoices must be paid in full. Installments are allowed only for current-year balances by admin override.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Students
|
// Students
|
||||||
@@ -433,6 +438,7 @@ class PaymentController extends ResourceController
|
|||||||
|
|
||||||
// Always use the configured end date for installments
|
// Always use the configured end date for installments
|
||||||
$inv['due_ymd'] = $installmentEndYmd;
|
$inv['due_ymd'] = $installmentEndYmd;
|
||||||
|
$inv['is_carry_forward_invoice'] = $this->isCarryForwardInvoiceRow($inv) ? 1 : 0;
|
||||||
|
|
||||||
// Optional: keep a start marker if you ever need it elsewhere
|
// Optional: keep a start marker if you ever need it elsewhere
|
||||||
$issueYmd = '';
|
$issueYmd = '';
|
||||||
@@ -516,7 +522,7 @@ class PaymentController extends ResourceController
|
|||||||
return $this->response->setJSON(['items' => $items]);
|
return $this->response->setJSON(['items' => $items]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function updateEnrollmentStatusIfPaid(int $invoiceId): int
|
private function updateEnrollmentStatusAfterRecordedPayment(int $invoiceId): int
|
||||||
{
|
{
|
||||||
// 1) Fetch invoice
|
// 1) Fetch invoice
|
||||||
$invoice = $this->invoiceModel->find($invoiceId);
|
$invoice = $this->invoiceModel->find($invoiceId);
|
||||||
@@ -525,19 +531,19 @@ class PaymentController extends ResourceController
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Payment check: enrollment transitions only after the invoice is fully paid
|
// 2) Payment check: any successful payment, including the first installment, enrolls the student.
|
||||||
$currentBal = $this->getCurrentInvoiceBalance($invoiceId);
|
if ($this->getSuccessfulPaymentCount($invoiceId) < 1) {
|
||||||
if ($currentBal > 0.00001) {
|
log_message('info', 'No successful payment found. Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]);
|
||||||
log_message('info', 'Invoice not fully paid. Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]);
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$parentId = (int) ($invoice['parent_id'] ?? 0);
|
$parentId = (int) ($invoice['parent_id'] ?? 0);
|
||||||
$schoolYear = (string) ($invoice['school_year'] ?? $this->schoolYear);
|
$schoolYear = (string) ($invoice['school_year'] ?? $this->schoolYear);
|
||||||
$semester = isset($this->semester) && $this->semester !== '' ? (string)$this->semester : null;
|
$semester = trim((string) ($invoice['semester'] ?? ''));
|
||||||
|
$semester = $semester !== '' ? $semester : null;
|
||||||
|
|
||||||
if ($parentId <= 0 || $schoolYear === '') {
|
if ($parentId <= 0 || $schoolYear === '') {
|
||||||
log_message('warning', 'updateEnrollmentStatusIfPaid: missing parent_id/school_year for invoice #{id}', ['id' => $invoiceId]);
|
log_message('warning', 'updateEnrollmentStatusAfterRecordedPayment: missing parent_id/school_year for invoice #{id}', ['id' => $invoiceId]);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,7 +631,7 @@ class PaymentController extends ResourceController
|
|||||||
'enrollment_status' => 'enrolled',
|
'enrollment_status' => 'enrolled',
|
||||||
'admission_status' => 'accepted',
|
'admission_status' => 'accepted',
|
||||||
'updated_at' => utc_now(),
|
'updated_at' => utc_now(),
|
||||||
], (int) (session()->get('user_id') ?? 0) ?: null, 'payment_completed');
|
], (int) (session()->get('user_id') ?? 0) ?: null, 'payment_recorded');
|
||||||
}
|
}
|
||||||
|
|
||||||
$affected = count($rowsToUpdate);
|
$affected = count($rowsToUpdate);
|
||||||
@@ -633,7 +639,7 @@ class PaymentController extends ResourceController
|
|||||||
|
|
||||||
log_message(
|
log_message(
|
||||||
'info',
|
'info',
|
||||||
'Enrollment status -> enrolled for {n} row(s). parent={p}, year={y}, sem={s}, ids=[{ids}]',
|
'Enrollment status -> enrolled after payment for {n} row(s). parent={p}, year={y}, sem={s}, ids=[{ids}]',
|
||||||
[
|
[
|
||||||
'n' => $affected,
|
'n' => $affected,
|
||||||
'p' => $parentId,
|
'p' => $parentId,
|
||||||
@@ -646,7 +652,7 @@ class PaymentController extends ResourceController
|
|||||||
return $affected;
|
return $affected;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$db->transRollback();
|
$db->transRollback();
|
||||||
log_message('error', 'updateEnrollmentStatusIfPaid error: ' . $e->getMessage());
|
log_message('error', 'updateEnrollmentStatusAfterRecordedPayment error: ' . $e->getMessage());
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -956,7 +962,7 @@ class PaymentController extends ResourceController
|
|||||||
try {
|
try {
|
||||||
// Lock invoice & get context (also ensure totals are up-to-date before validation)
|
// Lock invoice & get context (also ensure totals are up-to-date before validation)
|
||||||
$row = $this->db->query(
|
$row = $this->db->query(
|
||||||
'SELECT id, parent_id, invoice_number, total_amount, school_year FROM invoices WHERE id = ? FOR UPDATE',
|
'SELECT id, parent_id, invoice_number, total_amount, school_year, semester, description FROM invoices WHERE id = ? FOR UPDATE',
|
||||||
[$invoiceId]
|
[$invoiceId]
|
||||||
)->getRowArray();
|
)->getRowArray();
|
||||||
|
|
||||||
@@ -972,7 +978,10 @@ class PaymentController extends ResourceController
|
|||||||
// Recompute invoice totals from tuition + events + additional charges
|
// Recompute invoice totals from tuition + events + additional charges
|
||||||
$currentBalance = (float) $this->invoiceLedgerService->recalculateInvoice($invoiceId)['balance'];
|
$currentBalance = (float) $this->invoiceLedgerService->recalculateInvoice($invoiceId)['balance'];
|
||||||
|
|
||||||
$carryForwardPaymentRequired = $this->parentHasActiveCarryForwardBalance($parentId, $invYear);
|
$hasCarryForwardInvoice = $this->parentHasCarryForwardInvoice($parentId, $invYear);
|
||||||
|
$hasCurrentYearInstallmentOverride = $this->parentHasCurrentYearInstallmentOverride($parentId, $invYear);
|
||||||
|
$carryForwardPaymentRequired = $hasCarryForwardInvoice
|
||||||
|
&& ($this->isCarryForwardInvoiceRow($row) || ! $hasCurrentYearInstallmentOverride);
|
||||||
if ($carryForwardPaymentRequired && $paymentType === 'installment') {
|
if ($carryForwardPaymentRequired && $paymentType === 'installment') {
|
||||||
$this->db->transRollback();
|
$this->db->transRollback();
|
||||||
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||||
@@ -1061,11 +1070,18 @@ class PaymentController extends ResourceController
|
|||||||
// Post-payment balance from snapshot
|
// Post-payment balance from snapshot
|
||||||
$postBalance = (float) ($ledger['balance'] ?? max(0.0, round($initialPreBalance - $amount, 2)));
|
$postBalance = (float) ($ledger['balance'] ?? max(0.0, round($initialPreBalance - $amount, 2)));
|
||||||
|
|
||||||
|
$this->syncEnrollmentFinanceAfterPayment($parentId, $invYear);
|
||||||
|
|
||||||
// Optional enrollment update
|
// Optional enrollment update
|
||||||
$enrollmentupdated = $this->updateEnrollmentStatusIfPaid($invoiceId);
|
$enrollmentupdated = $this->updateEnrollmentStatusAfterRecordedPayment($invoiceId);
|
||||||
if ($enrollmentupdated != 0) {
|
if ($enrollmentupdated != 0) {
|
||||||
$studentIds = $this->studentModel->getStudentIdsByParentId($parentId);
|
$studentIds = $this->studentModel->getStudentIdsByParentId($parentId);
|
||||||
[$eventDataEnroll, $studentDataEnroll] = $this->buildStudentEnrolledEventData($parentId, $studentIds);
|
[$eventDataEnroll, $studentDataEnroll] = $this->buildStudentEnrolledEventData(
|
||||||
|
$parentId,
|
||||||
|
$studentIds,
|
||||||
|
$invYear,
|
||||||
|
(string) ($row['semester'] ?? $this->semester)
|
||||||
|
);
|
||||||
Events::trigger('studentEnrolled', $eventDataEnroll, $studentDataEnroll);
|
Events::trigger('studentEnrolled', $eventDataEnroll, $studentDataEnroll);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1268,15 +1284,14 @@ class PaymentController extends ResourceController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function parentHasActiveCarryForwardBalance(int $parentId, ?string $schoolYear = null): bool
|
private function parentHasCarryForwardInvoice(int $parentId, ?string $schoolYear = null): bool
|
||||||
{
|
{
|
||||||
if ($parentId <= 0 || ! $this->db->tableExists('invoices')) {
|
if ($parentId <= 0 || ! $this->db->tableExists('invoices')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$builder = $this->db->table('invoices')
|
$builder = $this->db->table('invoices')
|
||||||
->where('parent_id', $parentId)
|
->where('parent_id', $parentId);
|
||||||
->where('balance >', 0);
|
|
||||||
|
|
||||||
if ($schoolYear !== null && $schoolYear !== '') {
|
if ($schoolYear !== null && $schoolYear !== '') {
|
||||||
$builder->where('school_year', $schoolYear);
|
$builder->where('school_year', $schoolYear);
|
||||||
@@ -1290,6 +1305,7 @@ class PaymentController extends ResourceController
|
|||||||
$builder
|
$builder
|
||||||
->orLike('description', 'carried over')
|
->orLike('description', 'carried over')
|
||||||
->orLike('description', 'carry-forward')
|
->orLike('description', 'carry-forward')
|
||||||
|
->orLike('description', 'carry over')
|
||||||
->orLike('description', 'previous school year');
|
->orLike('description', 'previous school year');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1298,6 +1314,85 @@ class PaymentController extends ResourceController
|
|||||||
return $builder->countAllResults() > 0;
|
return $builder->countAllResults() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function parentHasCurrentYearInstallmentOverride(int $parentId, string $schoolYear): bool
|
||||||
|
{
|
||||||
|
if ($parentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('enrollment_exceptions')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->db->table('enrollment_exceptions')
|
||||||
|
->select('bypassed_rule_codes_json')
|
||||||
|
->where('parent_id', $parentId)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->whereIn('status', ['active', 'used'])
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$codes = json_decode((string) ($row['bypassed_rule_codes_json'] ?? ''), true);
|
||||||
|
if (! is_array($codes)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$codes = array_map(static fn ($code): string => strtoupper(trim((string) $code)), $codes);
|
||||||
|
if (in_array('CURRENT_YEAR_INSTALLMENT_OVERRIDE', $codes, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isCarryForwardInvoiceRow(array $invoice): bool
|
||||||
|
{
|
||||||
|
$invoiceNumber = (string) ($invoice['invoice_number'] ?? '');
|
||||||
|
if (str_starts_with($invoiceNumber, 'CF-')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcasecmp((string) ($invoice['semester'] ?? ''), 'Opening Balance') === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$description = strtolower((string) ($invoice['description'] ?? ''));
|
||||||
|
|
||||||
|
return str_contains($description, 'carried over')
|
||||||
|
|| str_contains($description, 'carry-forward')
|
||||||
|
|| str_contains($description, 'carry over')
|
||||||
|
|| str_contains($description, 'previous school year');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncEnrollmentFinanceAfterPayment(int $parentId, string $targetSchoolYear): void
|
||||||
|
{
|
||||||
|
$sourceSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
||||||
|
if ($parentId <= 0 || $sourceSchoolYear === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$studentIds = $this->studentModel->getStudentIdsByParentId($parentId);
|
||||||
|
service('enrollmentTransition')->syncParentFinancialReviewFlags(
|
||||||
|
$parentId,
|
||||||
|
$sourceSchoolYear,
|
||||||
|
$targetSchoolYear,
|
||||||
|
array_values(array_map('intval', $studentIds ?? []))
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'Enrollment finance sync after payment failed for parent {parent_id}, school year {school_year}: {error}', [
|
||||||
|
'parent_id' => $parentId,
|
||||||
|
'school_year' => $targetSchoolYear,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function previousSchoolYearName(string $schoolYear): ?string
|
||||||
|
{
|
||||||
|
return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)
|
||||||
|
? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔄 Helper: Recalculate invoice totals and status based on all payments for current school year
|
* 🔄 Helper: Recalculate invoice totals and status based on all payments for current school year
|
||||||
|
|||||||
@@ -651,6 +651,13 @@ class WhatsappController extends BaseController
|
|||||||
// ignore membership annotation errors
|
// ignore membership annotation errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Annotate latest invite delivery state from whatsapp_invites_log.
|
||||||
|
try {
|
||||||
|
$this->annotateWhatsappDeliveryStatuses($classSections, $schoolYear);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Keep the roster usable even if delivery history cannot be loaded.
|
||||||
|
}
|
||||||
|
|
||||||
// 4) (Optional) Teachers, if you keep them in teacher_class
|
// 4) (Optional) Teachers, if you keep them in teacher_class
|
||||||
try {
|
try {
|
||||||
$tb = $this->db->table('teacher_class tc')
|
$tb = $this->db->table('teacher_class tc')
|
||||||
@@ -687,6 +694,83 @@ class WhatsappController extends BaseController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add latest email delivery state for each primary parent/class row.
|
||||||
|
*
|
||||||
|
* Exact class-section logs win. Older consolidated "all classes" logs without
|
||||||
|
* class_section_id are used only as a fallback for the parent/year.
|
||||||
|
*/
|
||||||
|
private function annotateWhatsappDeliveryStatuses(array &$classSections, string $schoolYear): void
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('whatsapp_invites_log') || empty($classSections)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sectionIds = array_keys($classSections);
|
||||||
|
$parentIds = [];
|
||||||
|
foreach ($classSections as $sec) {
|
||||||
|
foreach (($sec['parents'] ?? []) as $p) {
|
||||||
|
$primaryId = (int) ($p['primary_id'] ?? 0);
|
||||||
|
if ($primaryId > 0) {
|
||||||
|
$parentIds[$primaryId] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($sectionIds) || empty($parentIds)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->db->table('whatsapp_invites_log')
|
||||||
|
->select('parent_id, class_section_id, status, error_message, sent_at')
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->whereIn('parent_id', array_keys($parentIds))
|
||||||
|
->groupStart()
|
||||||
|
->whereIn('class_section_id', $sectionIds)
|
||||||
|
->orWhere('class_section_id IS NULL', null, false)
|
||||||
|
->groupEnd()
|
||||||
|
->orderBy('sent_at', 'DESC')
|
||||||
|
->orderBy('id', 'DESC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
$exact = [];
|
||||||
|
$fallbackByParent = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$parentId = (int) ($row['parent_id'] ?? 0);
|
||||||
|
$sectionId = (int) ($row['class_section_id'] ?? 0);
|
||||||
|
if ($parentId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sectionId > 0) {
|
||||||
|
$key = $sectionId . ':' . $parentId;
|
||||||
|
if (! isset($exact[$key])) {
|
||||||
|
$exact[$key] = $row;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($fallbackByParent[$parentId])) {
|
||||||
|
$fallbackByParent[$parentId] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($classSections as $sid => &$sec) {
|
||||||
|
foreach ($sec['parents'] as &$p) {
|
||||||
|
$primaryId = (int) ($p['primary_id'] ?? 0);
|
||||||
|
$log = $exact[$sid . ':' . $primaryId] ?? $fallbackByParent[$primaryId] ?? null;
|
||||||
|
|
||||||
|
$p['delivery_status'] = $log ? strtolower((string) ($log['status'] ?? '')) : 'not_sent';
|
||||||
|
$p['delivery_sent_at'] = $log['sent_at'] ?? null;
|
||||||
|
$p['delivery_error'] = $log['error_message'] ?? null;
|
||||||
|
$p['delivery_class_specific'] = $log && ! empty($log['class_section_id']);
|
||||||
|
}
|
||||||
|
unset($p);
|
||||||
|
}
|
||||||
|
unset($sec);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST: Update WhatsApp group membership flags for a class/parent(s).
|
* POST: Update WhatsApp group membership flags for a class/parent(s).
|
||||||
* Accepts fields:
|
* Accepts fields:
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ final class AlignSchemaToScoolViewDump extends Migration
|
|||||||
$this->ensureIndex(
|
$this->ensureIndex(
|
||||||
'whatsapp_group_links',
|
'whatsapp_group_links',
|
||||||
'uq_section_term',
|
'uq_section_term',
|
||||||
['class_section_id'],
|
['class_section_id', 'school_year'],
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class EnsureWhatsappGroupLinksUniquePerSchoolYear extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if ($this->db->DBDriver !== 'MySQLi') {
|
||||||
|
throw new RuntimeException('This migration requires MySQL/MariaDB through the MySQLi driver.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->db->tableExists('whatsapp_group_links')
|
||||||
|
|| ! $this->db->fieldExists('class_section_id', 'whatsapp_group_links')
|
||||||
|
|| ! $this->db->fieldExists('school_year', 'whatsapp_group_links')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$duplicates = $this->db->query(
|
||||||
|
'SELECT class_section_id, school_year, COUNT(*) AS row_count
|
||||||
|
FROM `whatsapp_group_links`
|
||||||
|
GROUP BY class_section_id, school_year
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
LIMIT 5'
|
||||||
|
)->getResultArray();
|
||||||
|
|
||||||
|
if ($duplicates !== []) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Cannot add whatsapp_group_links unique key by school year because duplicate class_section_id/school_year rows exist: '
|
||||||
|
. json_encode($duplicates)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->dropIndexIfExists('whatsapp_group_links', 'uq_section_term');
|
||||||
|
|
||||||
|
$this->db->query(
|
||||||
|
'ALTER TABLE `whatsapp_group_links`
|
||||||
|
ADD UNIQUE INDEX `uq_section_term` (`class_section_id`, `school_year`)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
throw new RuntimeException('This migration is intentionally irreversible because reverting can fail once multiple school years exist for the same section.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dropIndexIfExists(string $table, string $index): void
|
||||||
|
{
|
||||||
|
$exists = $this->db->query(
|
||||||
|
'SELECT COUNT(*) AS aggregate
|
||||||
|
FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = ?
|
||||||
|
AND INDEX_NAME = ?',
|
||||||
|
[$table, $index]
|
||||||
|
)->getRow();
|
||||||
|
|
||||||
|
if ((int) ($exists->aggregate ?? 0) === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->query(sprintf(
|
||||||
|
'ALTER TABLE `%s` DROP INDEX `%s`',
|
||||||
|
str_replace('`', '``', $table),
|
||||||
|
str_replace('`', '``', $index)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
final class CreateUserAccessProfiles extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('user_access_profiles')) {
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => [
|
||||||
|
'type' => 'INT',
|
||||||
|
'constraint' => 11,
|
||||||
|
'unsigned' => true,
|
||||||
|
'auto_increment' => true,
|
||||||
|
],
|
||||||
|
'user_id' => [
|
||||||
|
'type' => 'INT',
|
||||||
|
'constraint' => 11,
|
||||||
|
'unsigned' => true,
|
||||||
|
],
|
||||||
|
'primary_category' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 20,
|
||||||
|
'default' => 'guest',
|
||||||
|
],
|
||||||
|
'is_admin' => [
|
||||||
|
'type' => 'TINYINT',
|
||||||
|
'constraint' => 1,
|
||||||
|
'default' => 0,
|
||||||
|
],
|
||||||
|
'is_teacher' => [
|
||||||
|
'type' => 'TINYINT',
|
||||||
|
'constraint' => 1,
|
||||||
|
'default' => 0,
|
||||||
|
],
|
||||||
|
'is_parent' => [
|
||||||
|
'type' => 'TINYINT',
|
||||||
|
'constraint' => 1,
|
||||||
|
'default' => 0,
|
||||||
|
],
|
||||||
|
'role_names' => [
|
||||||
|
'type' => 'TEXT',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'created_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'updated_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addKey('user_id', false, true);
|
||||||
|
$this->forge->addKey('primary_category');
|
||||||
|
$this->forge->addKey('is_admin');
|
||||||
|
$this->forge->addKey('is_teacher');
|
||||||
|
$this->forge->addKey('is_parent');
|
||||||
|
$this->forge->createTable('user_access_profiles', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->backfillProfiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('user_access_profiles', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function backfillProfiles(): void
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
! $this->db->tableExists('users')
|
||||||
|
|| ! $this->db->tableExists('roles')
|
||||||
|
|| ! $this->db->tableExists('user_roles')
|
||||||
|
|| ! $this->db->tableExists('user_access_profiles')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$deletedFilter = $this->db->fieldExists('deleted_at', 'user_roles')
|
||||||
|
? 'AND ur.deleted_at IS NULL'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
INSERT INTO user_access_profiles (
|
||||||
|
user_id,
|
||||||
|
primary_category,
|
||||||
|
is_admin,
|
||||||
|
is_teacher,
|
||||||
|
is_parent,
|
||||||
|
role_names,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
u.id AS user_id,
|
||||||
|
CASE
|
||||||
|
WHEN MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) NOT IN ('guest', 'parent', 'student', 'teacher', 'teacher_assistant', 'assistant_teacher', 'ta') THEN 1 ELSE 0 END) = 1 THEN 'admin'
|
||||||
|
WHEN MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) IN ('teacher', 'teacher_assistant', 'assistant_teacher', 'ta') THEN 1 ELSE 0 END) = 1 THEN 'teacher'
|
||||||
|
WHEN MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) = 'parent' THEN 1 ELSE 0 END) = 1 THEN 'parent'
|
||||||
|
ELSE 'guest'
|
||||||
|
END AS primary_category,
|
||||||
|
MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) NOT IN ('guest', 'parent', 'student', 'teacher', 'teacher_assistant', 'assistant_teacher', 'ta') THEN 1 ELSE 0 END) AS is_admin,
|
||||||
|
MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) IN ('teacher', 'teacher_assistant', 'assistant_teacher', 'ta') THEN 1 ELSE 0 END) AS is_teacher,
|
||||||
|
MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) = 'parent' THEN 1 ELSE 0 END) AS is_parent,
|
||||||
|
GROUP_CONCAT(DISTINCT r.name ORDER BY COALESCE(r.priority, 999), r.name SEPARATOR ', ') AS role_names,
|
||||||
|
? AS created_at,
|
||||||
|
? AS updated_at
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_roles ur ON ur.user_id = u.id {$deletedFilter}
|
||||||
|
LEFT JOIN roles r ON r.id = ur.role_id AND COALESCE(r.is_active, 1) = 1
|
||||||
|
GROUP BY u.id
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
primary_category = VALUES(primary_category),
|
||||||
|
is_admin = VALUES(is_admin),
|
||||||
|
is_teacher = VALUES(is_teacher),
|
||||||
|
is_parent = VALUES(is_parent),
|
||||||
|
role_names = VALUES(role_names),
|
||||||
|
updated_at = VALUES(updated_at)
|
||||||
|
";
|
||||||
|
|
||||||
|
$this->db->query($sql, [$now, $now]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -168,10 +168,11 @@ class StudentModel extends Model
|
|||||||
->join('emergency_contacts ec', 'ec.parent_id = students.parent_id', 'left');
|
->join('emergency_contacts ec', 'ec.parent_id = students.parent_id', 'left');
|
||||||
|
|
||||||
if ($useYearScopedIsNew) {
|
if ($useYearScopedIsNew) {
|
||||||
|
$statusJoinType = ($filterByYear || $isNew === 0 || $isNew === 1) ? 'inner' : 'left';
|
||||||
$builder->join(
|
$builder->join(
|
||||||
'student_year_status sys',
|
'student_year_status sys',
|
||||||
'sys.student_id = students.id AND sys.school_year = ' . $this->db->escape($statusYear),
|
'sys.student_id = students.id AND sys.school_year = ' . $this->db->escape($statusYear),
|
||||||
'left'
|
$statusJoinType
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +183,7 @@ class StudentModel extends Model
|
|||||||
$builder->where('COALESCE(sys.is_new, 1)', $isNew, false);
|
$builder->where('COALESCE(sys.is_new, 1)', $isNew, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($filterByYear) {
|
if ($filterByYear && ! $useYearScopedIsNew) {
|
||||||
$builder
|
$builder
|
||||||
->join('student_class sc_filter', 'sc_filter.student_id = students.id', 'inner')
|
->join('student_class sc_filter', 'sc_filter.student_id = students.id', 'inner')
|
||||||
->where('sc_filter.school_year', $schoolYear);
|
->where('sc_filter.school_year', $schoolYear);
|
||||||
@@ -491,18 +492,6 @@ class StudentModel extends Model
|
|||||||
(
|
(
|
||||||
student_class.student_id IS NOT NULL
|
student_class.student_id IS NOT NULL
|
||||||
OR enrollments.student_id IS NOT NULL
|
OR enrollments.student_id IS NOT NULL
|
||||||
OR (
|
|
||||||
NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM student_class sc_history
|
|
||||||
WHERE sc_history.student_id = students.id
|
|
||||||
)
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM enrollments e_history
|
|
||||||
WHERE e_history.student_id = students.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
";
|
";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class UserAccessProfileModel extends Model
|
||||||
|
{
|
||||||
|
public const CATEGORY_ADMIN = 'admin';
|
||||||
|
public const CATEGORY_TEACHER = 'teacher';
|
||||||
|
public const CATEGORY_PARENT = 'parent';
|
||||||
|
public const CATEGORY_GUEST = 'guest';
|
||||||
|
|
||||||
|
private const TEACHER_ROLE_TOKENS = ['teacher', 'teacher_assistant', 'teacher assistant', 'assistant_teacher', 'ta'];
|
||||||
|
private const NON_ADMIN_ROLE_TOKENS = ['guest', 'parent', 'student', 'teacher', 'teacher_assistant', 'teacher assistant', 'assistant_teacher', 'ta'];
|
||||||
|
|
||||||
|
protected $table = 'user_access_profiles';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $allowedFields = [
|
||||||
|
'user_id',
|
||||||
|
'primary_category',
|
||||||
|
'is_admin',
|
||||||
|
'is_teacher',
|
||||||
|
'is_parent',
|
||||||
|
'role_names',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
];
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = 'updated_at';
|
||||||
|
|
||||||
|
public function syncUser(int $userId): ?array
|
||||||
|
{
|
||||||
|
if ($userId <= 0 || ! $this->db->tableExists($this->table)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = $this->rolesForUser($userId);
|
||||||
|
$flags = self::flagsForRoles($roles);
|
||||||
|
$now = utc_now();
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'primary_category' => self::primaryCategory($flags),
|
||||||
|
'is_admin' => $flags['is_admin'] ? 1 : 0,
|
||||||
|
'is_teacher' => $flags['is_teacher'] ? 1 : 0,
|
||||||
|
'is_parent' => $flags['is_parent'] ? 1 : 0,
|
||||||
|
'role_names' => implode(', ', array_values(array_unique(array_map(
|
||||||
|
static fn (array $role): string => (string) ($role['name'] ?? ''),
|
||||||
|
$roles
|
||||||
|
)))),
|
||||||
|
'updated_at' => $now,
|
||||||
|
];
|
||||||
|
|
||||||
|
$existing = $this->where('user_id', $userId)->first();
|
||||||
|
if ($existing) {
|
||||||
|
$this->update((int) $existing['id'], $data);
|
||||||
|
return $this->find((int) $existing['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['created_at'] = $now;
|
||||||
|
$id = $this->insert($data);
|
||||||
|
|
||||||
|
return $id ? $this->find((int) $id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function syncAll(): void
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('users') || ! $this->db->tableExists($this->table)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->db->table('users')->select('id')->get()->getResultArray();
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$this->syncUser((int) ($row['id'] ?? 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getForUser(int $userId): ?array
|
||||||
|
{
|
||||||
|
if ($userId <= 0 || ! $this->db->tableExists($this->table)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$profile = $this->where('user_id', $userId)->first();
|
||||||
|
|
||||||
|
return $profile ?: $this->syncUser($userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUsersByCategory(string $category): array
|
||||||
|
{
|
||||||
|
$field = match (self::normalizeRoleToken($category)) {
|
||||||
|
self::CATEGORY_ADMIN => 'is_admin',
|
||||||
|
self::CATEGORY_TEACHER => 'is_teacher',
|
||||||
|
self::CATEGORY_PARENT => 'is_parent',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($field === null || ! $this->db->tableExists($this->table)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->select('users.*, user_access_profiles.primary_category, user_access_profiles.role_names')
|
||||||
|
->join('users', 'users.id = user_access_profiles.user_id', 'inner')
|
||||||
|
->where($field, 1)
|
||||||
|
->orderBy('users.lastname', 'ASC')
|
||||||
|
->orderBy('users.firstname', 'ASC')
|
||||||
|
->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function flagsForRoles(array $roles): array
|
||||||
|
{
|
||||||
|
$tokens = [];
|
||||||
|
foreach ($roles as $role) {
|
||||||
|
if (is_array($role)) {
|
||||||
|
$tokens[] = self::normalizeRoleToken((string) ($role['slug'] ?? ''));
|
||||||
|
$tokens[] = self::normalizeRoleToken((string) ($role['name'] ?? ''));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens[] = self::normalizeRoleToken((string) $role);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens = array_values(array_unique(array_filter($tokens)));
|
||||||
|
$isParent = in_array('parent', $tokens, true);
|
||||||
|
$isTeacher = count(array_intersect($tokens, array_map([self::class, 'normalizeRoleToken'], self::TEACHER_ROLE_TOKENS))) > 0;
|
||||||
|
$isAdmin = false;
|
||||||
|
|
||||||
|
foreach ($tokens as $token) {
|
||||||
|
if (! in_array($token, array_map([self::class, 'normalizeRoleToken'], self::NON_ADMIN_ROLE_TOKENS), true)) {
|
||||||
|
$isAdmin = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'is_admin' => $isAdmin,
|
||||||
|
'is_teacher' => $isTeacher,
|
||||||
|
'is_parent' => $isParent,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function primaryCategory(array $flags): string
|
||||||
|
{
|
||||||
|
if (! empty($flags['is_admin'])) {
|
||||||
|
return self::CATEGORY_ADMIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($flags['is_teacher'])) {
|
||||||
|
return self::CATEGORY_TEACHER;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($flags['is_parent'])) {
|
||||||
|
return self::CATEGORY_PARENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::CATEGORY_GUEST;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rolesForUser(int $userId): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('user_roles') || ! $this->db->tableExists('roles')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder = $this->db->table('user_roles ur')
|
||||||
|
->select('r.name, r.slug')
|
||||||
|
->join('roles r', 'r.id = ur.role_id', 'inner')
|
||||||
|
->where('ur.user_id', $userId)
|
||||||
|
->where('COALESCE(r.is_active, 1) = 1', null, false);
|
||||||
|
|
||||||
|
if ($this->db->fieldExists('deleted_at', 'user_roles')) {
|
||||||
|
$builder->where('ur.deleted_at', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $builder->get()->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeRoleToken(string $value): string
|
||||||
|
{
|
||||||
|
$value = strtolower(trim($value));
|
||||||
|
return str_replace([' ', '-'], '_', $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,36 @@ class UserModel extends Model
|
|||||||
protected $useTimestamps = true; // Enable automatic timestamps
|
protected $useTimestamps = true; // Enable automatic timestamps
|
||||||
protected $createdField = 'created_at'; // Define the field name for the created timestamp
|
protected $createdField = 'created_at'; // Define the field name for the created timestamp
|
||||||
protected $updatedField = 'updated_at'; // Define the field name for the updated timestamp
|
protected $updatedField = 'updated_at'; // Define the field name for the updated timestamp
|
||||||
|
protected $afterInsert = ['syncAccessProfileAfterInsert'];
|
||||||
|
protected $afterDelete = ['deleteAccessProfileAfterDelete'];
|
||||||
|
|
||||||
|
protected function syncAccessProfileAfterInsert(array $data): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$userId = (int) ($data['id'] ?? 0);
|
||||||
|
if ($userId > 0) {
|
||||||
|
model(UserAccessProfileModel::class)->syncUser($userId);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'UserModel access profile sync failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function deleteAccessProfileAfterDelete(array $data): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$ids = array_filter(array_map('intval', (array) ($data['id'] ?? [])));
|
||||||
|
if ($ids !== [] && $this->db->tableExists('user_access_profiles')) {
|
||||||
|
$this->db->table('user_access_profiles')->whereIn('user_id', $ids)->delete();
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'UserModel access profile cleanup failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
// Existing methods remain unchanged
|
// Existing methods remain unchanged
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,10 @@ class UserRoleModel extends Model
|
|||||||
$userId = (int) ($data['data']['user_id'] ?? 0);
|
$userId = (int) ($data['data']['user_id'] ?? 0);
|
||||||
if ($userId > 0) {
|
if ($userId > 0) {
|
||||||
service('staffDirectorySync')->syncUser($userId);
|
service('staffDirectorySync')->syncUser($userId);
|
||||||
|
model(UserAccessProfileModel::class)->syncUser($userId);
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
log_message('error', 'UserRoleModel staff directory sync failed: ' . $e->getMessage());
|
log_message('error', 'UserRoleModel role-derived sync failed: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
@@ -45,8 +46,9 @@ class UserRoleModel extends Model
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
service('staffDirectorySync')->syncAll();
|
service('staffDirectorySync')->syncAll();
|
||||||
|
model(UserAccessProfileModel::class)->syncAll();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
log_message('error', 'UserRoleModel staff directory delete sync failed: ' . $e->getMessage());
|
log_message('error', 'UserRoleModel role-derived delete sync failed: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
|
|||||||
@@ -34,14 +34,13 @@ public function metrics(string $schoolYear, string $semester): array
|
|||||||
|
|
||||||
$totalAdmins = (int) ($this->userModel->countAdminsBySchoolYear($this->schoolYear) ?? 0);
|
$totalAdmins = (int) ($this->userModel->countAdminsBySchoolYear($this->schoolYear) ?? 0);
|
||||||
|
|
||||||
$teachers = $this->userModel->getUsersByRoleAndSchoolYear('teacher', $this->schoolYear);
|
$teachers = $this->userModel->getUsersByRole('teacher');
|
||||||
$totalTeachers = $this->countUniqueEntities($teachers);
|
$totalTeachers = $this->countUniqueEntities($teachers);
|
||||||
|
|
||||||
$teacherAssistants = $this->userModel->getUsersByRoleAndSchoolYear('teacher_assistant', $this->schoolYear);
|
$teacherAssistants = $this->userModel->getUsersByRole('teacher_assistant');
|
||||||
$totalTeacherAssistants = $this->countUniqueEntities($teacherAssistants);
|
$totalTeacherAssistants = $this->countUniqueEntities($teacherAssistants);
|
||||||
|
|
||||||
$parents = $this->userModel->getUsersByRoleAndSchoolYear('parent', $this->schoolYear);
|
$totalParents = $this->countParentsWithEnrolledStudents($this->schoolYear);
|
||||||
$totalParents = $this->countUniqueEntities($parents);
|
|
||||||
|
|
||||||
// Count only students that have a class assigned and exist in student_class for the current school year
|
// Count only students that have a class assigned and exist in student_class for the current school year
|
||||||
$totalStudents = (int) (
|
$totalStudents = (int) (
|
||||||
@@ -103,6 +102,36 @@ private function countUniqueEntities($rows): int
|
|||||||
return count(array_unique($ids));
|
return count(array_unique($ids));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function countParentsWithEnrolledStudents(string $schoolYear): int
|
||||||
|
{
|
||||||
|
$schoolYear = trim($schoolYear);
|
||||||
|
if ($schoolYear === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) (
|
||||||
|
$this->db->table('students')
|
||||||
|
->select('COUNT(DISTINCT students.parent_id) AS cnt')
|
||||||
|
->join('student_class', 'student_class.student_id = students.id', 'inner')
|
||||||
|
->join('users', 'users.id = students.parent_id', 'inner')
|
||||||
|
->join('user_roles', 'user_roles.user_id = users.id', 'inner')
|
||||||
|
->join('roles', 'roles.id = user_roles.role_id', 'inner')
|
||||||
|
->where('student_class.school_year', $schoolYear)
|
||||||
|
->where('student_class.class_section_id IS NOT NULL', null, false)
|
||||||
|
->where('students.is_active', 1)
|
||||||
|
->where('students.parent_id IS NOT NULL', null, false)
|
||||||
|
->where('students.parent_id >', 0)
|
||||||
|
->where('user_roles.deleted_at', null)
|
||||||
|
->groupStart()
|
||||||
|
->where('LOWER(roles.name)', 'parent')
|
||||||
|
->orWhere('roles.slug', 'parent')
|
||||||
|
->groupEnd()
|
||||||
|
->get()
|
||||||
|
->getRow('cnt')
|
||||||
|
?? 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function search(string $query): array
|
public function search(string $query): array
|
||||||
{
|
{
|
||||||
$q = trim($query);
|
$q = trim($query);
|
||||||
@@ -122,6 +151,15 @@ public function search(string $query): array
|
|||||||
// 1) Tokenize input: split by whitespace and punctuation, keep meaningful pieces
|
// 1) Tokenize input: split by whitespace and punctuation, keep meaningful pieces
|
||||||
$rawTokens = preg_split('/[,\s]+/u', $q, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
$rawTokens = preg_split('/[,\s]+/u', $q, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||||
$tokens = array_values(array_filter(array_map('trim', $rawTokens)));
|
$tokens = array_values(array_filter(array_map('trim', $rawTokens)));
|
||||||
|
if ($tokens === []) {
|
||||||
|
return [
|
||||||
|
'query' => $q,
|
||||||
|
'results' => [],
|
||||||
|
'scope_used' => 'unscoped-merged',
|
||||||
|
'scope_label' => 'all years/semesters (merged)',
|
||||||
|
'total_found' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// 2) Build phone variants for any token that looks numeric-ish
|
// 2) Build phone variants for any token that looks numeric-ish
|
||||||
$phoneMap = []; // token => variants[]
|
$phoneMap = []; // token => variants[]
|
||||||
@@ -229,22 +267,174 @@ public function search(string $query): array
|
|||||||
$applyMultiTokenLike($ecQB, $ecCols, $tokens, ['cellphone']);
|
$applyMultiTokenLike($ecQB, $ecCols, $tokens, ['cellphone']);
|
||||||
$emergency = $ecQB->limit(150)->get()->getResultArray();
|
$emergency = $ecQB->limit(150)->get()->getResultArray();
|
||||||
|
|
||||||
$raw = [
|
$results = $this->mergeSearchResults($users, $students, $parents, $staff, $emergency);
|
||||||
'users' => $users,
|
|
||||||
'students' => $students,
|
|
||||||
'parents' => $parents,
|
|
||||||
'staff' => $staff,
|
|
||||||
'emergency_contacts' => $emergency,
|
|
||||||
];
|
|
||||||
|
|
||||||
$total = count($users) + count($students) + count($parents) + count($staff) + count($emergency);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'query' => $q,
|
'query' => $q,
|
||||||
'results' => $raw,
|
'results' => $results,
|
||||||
'scope_used' => 'unscoped-raw',
|
'scope_used' => 'unscoped-merged',
|
||||||
'scope_label' => 'all years/semesters (raw, tokenized)',
|
'scope_label' => 'all years/semesters (merged, tokenized)',
|
||||||
'total_found' => $total,
|
'total_found' => count($results),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function mergeSearchResults(array $users, array $students, array $parents, array $staff, array $emergency): array
|
||||||
|
{
|
||||||
|
$bundles = [];
|
||||||
|
$userIds = [];
|
||||||
|
|
||||||
|
$ensureBundle = static function (int $userId) use (&$bundles, &$userIds): void {
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($bundles[$userId])) {
|
||||||
|
$bundles[$userId] = [
|
||||||
|
'user' => null,
|
||||||
|
'students' => [],
|
||||||
|
'parents' => [],
|
||||||
|
'staff' => [],
|
||||||
|
'emergency_contacts' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$userIds[$userId] = $userId;
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$userId = (int) ($user['id'] ?? 0);
|
||||||
|
$ensureBundle($userId);
|
||||||
|
if ($userId > 0) {
|
||||||
|
$bundles[$userId]['user'] = $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($students as $student) {
|
||||||
|
$parentId = (int) ($student['parent_id'] ?? 0);
|
||||||
|
$ensureBundle($parentId);
|
||||||
|
if ($parentId > 0) {
|
||||||
|
$bundles[$parentId]['students'][(int) ($student['id'] ?? 0)] = $student;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($parents as $parent) {
|
||||||
|
$firstParentId = (int) ($parent['firstparent_id'] ?? 0);
|
||||||
|
$ensureBundle($firstParentId);
|
||||||
|
if ($firstParentId > 0) {
|
||||||
|
$bundles[$firstParentId]['parents'][(int) ($parent['id'] ?? 0)] = $parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($staff as $staffRow) {
|
||||||
|
$userId = (int) ($staffRow['user_id'] ?? 0);
|
||||||
|
$ensureBundle($userId);
|
||||||
|
if ($userId > 0) {
|
||||||
|
$bundles[$userId]['staff'][(int) ($staffRow['id'] ?? 0)] = $staffRow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($emergency as $emergencyRow) {
|
||||||
|
$parentId = (int) ($emergencyRow['parent_id'] ?? 0);
|
||||||
|
$ensureBundle($parentId);
|
||||||
|
if ($parentId > 0) {
|
||||||
|
$bundles[$parentId]['emergency_contacts'][(int) ($emergencyRow['id'] ?? 0)] = $emergencyRow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($userIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->hydrateSearchBundles($bundles, array_values($userIds));
|
||||||
|
|
||||||
|
$results = array_values($bundles);
|
||||||
|
usort($results, static function (array $a, array $b): int {
|
||||||
|
$aUser = $a['user'] ?? [];
|
||||||
|
$bUser = $b['user'] ?? [];
|
||||||
|
$aName = trim((string) ($aUser['lastname'] ?? '') . ' ' . (string) ($aUser['firstname'] ?? ''));
|
||||||
|
$bName = trim((string) ($bUser['lastname'] ?? '') . ' ' . (string) ($bUser['firstname'] ?? ''));
|
||||||
|
|
||||||
|
return strcasecmp($aName, $bName);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hydrateSearchBundles(array &$bundles, array $userIds): void
|
||||||
|
{
|
||||||
|
$userRows = $this->db->table('users')
|
||||||
|
->select('id, firstname, lastname, email, cellphone, school_id, city, state')
|
||||||
|
->whereIn('id', $userIds)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($userRows as $user) {
|
||||||
|
$userId = (int) ($user['id'] ?? 0);
|
||||||
|
if ($userId > 0 && isset($bundles[$userId]) && empty($bundles[$userId]['user'])) {
|
||||||
|
$bundles[$userId]['user'] = $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$studentRows = $this->db->table('students')
|
||||||
|
->select('id, parent_id, school_id, firstname, lastname, dob, gender, rfid_tag, is_active')
|
||||||
|
->whereIn('parent_id', $userIds)
|
||||||
|
->orderBy('lastname', 'ASC')
|
||||||
|
->orderBy('firstname', 'ASC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($studentRows as $student) {
|
||||||
|
$parentId = (int) ($student['parent_id'] ?? 0);
|
||||||
|
if ($parentId > 0 && isset($bundles[$parentId])) {
|
||||||
|
$bundles[$parentId]['students'][(int) ($student['id'] ?? 0)] = $student;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$parentRows = $this->db->table('parents')
|
||||||
|
->select('id, firstparent_id, secondparent_firstname, secondparent_lastname, secondparent_email, secondparent_phone')
|
||||||
|
->whereIn('firstparent_id', $userIds)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($parentRows as $parent) {
|
||||||
|
$firstParentId = (int) ($parent['firstparent_id'] ?? 0);
|
||||||
|
if ($firstParentId > 0 && isset($bundles[$firstParentId])) {
|
||||||
|
$bundles[$firstParentId]['parents'][(int) ($parent['id'] ?? 0)] = $parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$staffRows = $this->db->table('staff')
|
||||||
|
->select('id, user_id, firstname, lastname, email, phone, role_name, active_role')
|
||||||
|
->whereIn('user_id', $userIds)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($staffRows as $staffRow) {
|
||||||
|
$userId = (int) ($staffRow['user_id'] ?? 0);
|
||||||
|
if ($userId > 0 && isset($bundles[$userId])) {
|
||||||
|
$bundles[$userId]['staff'][(int) ($staffRow['id'] ?? 0)] = $staffRow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$emergencyRows = $this->db->table('emergency_contacts')
|
||||||
|
->select('id, parent_id, emergency_contact_name, relation, cellphone, email')
|
||||||
|
->whereIn('parent_id', $userIds)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($emergencyRows as $emergencyRow) {
|
||||||
|
$parentId = (int) ($emergencyRow['parent_id'] ?? 0);
|
||||||
|
if ($parentId > 0 && isset($bundles[$parentId])) {
|
||||||
|
$bundles[$parentId]['emergency_contacts'][(int) ($emergencyRow['id'] ?? 0)] = $emergencyRow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($bundles as &$bundle) {
|
||||||
|
$bundle['students'] = array_values($bundle['students']);
|
||||||
|
$bundle['parents'] = array_values($bundle['parents']);
|
||||||
|
$bundle['staff'] = array_values($bundle['staff']);
|
||||||
|
$bundle['emergency_contacts'] = array_values($bundle['emergency_contacts']);
|
||||||
|
}
|
||||||
|
unset($bundle);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Parents;
|
||||||
|
|
||||||
|
use App\Controllers\View\EmailController;
|
||||||
|
use App\Models\AuthorizedUserModel;
|
||||||
|
use App\Models\UserModel;
|
||||||
|
use App\Services\SchoolIdService;
|
||||||
|
use CodeIgniter\Database\BaseConnection;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class ParentAccountService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly BaseConnection $db,
|
||||||
|
private readonly UserModel $userModel,
|
||||||
|
private readonly AuthorizedUserModel $authorizedUsersModel,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canAccessUserRecord(int $requestedUserId, int $sessionUserId, array $sessionRoles): bool
|
||||||
|
{
|
||||||
|
if ($sessionUserId <= 0 || $requestedUserId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sessionUserId === $requestedUserId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = array_map(
|
||||||
|
static fn ($role): string => strtolower(trim((string) $role)),
|
||||||
|
array_filter($sessionRoles)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (bool) array_intersect($roles, ['administrator', 'administrative staff', 'principal', 'admin']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEmailUnique(string $email): bool
|
||||||
|
{
|
||||||
|
foreach (['users' => 'email', 'emergency_contacts' => 'email'] as $table => $column) {
|
||||||
|
if ($this->db->table($table)->where($column, $email)->countAllResults() > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createRelatedUser(array $userData, string $relationToStudent, string $semester, string $schoolYear): int|false
|
||||||
|
{
|
||||||
|
$schoolIdService = new SchoolIdService();
|
||||||
|
$token = bin2hex(random_bytes(48));
|
||||||
|
$tokenHash = hash('sha256', $token);
|
||||||
|
$userType = in_array(strtolower($relationToStudent), ['wife', 'husband'], true) ? 'Secondary' : 'Tertiary';
|
||||||
|
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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' => $semester,
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'school_id' => $schoolIdService->generateUserSchoolId(),
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (! $this->userModel->insert($userEntry)) {
|
||||||
|
log_message('error', 'Failed to insert user: ' . print_r($this->userModel->errors(), true));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int) $this->userModel->getInsertID();
|
||||||
|
$this->sendActivationEmail((string) $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateAuthorizedUsers(int $userId, array $data): void
|
||||||
|
{
|
||||||
|
$validation = \Config\Services::validation();
|
||||||
|
$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.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $validation->run($data)) {
|
||||||
|
log_message('error', 'Invalid authorized user data: ' . json_encode($validation->getErrors()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingAuthorizedUser = $this->authorizedUsersModel
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('email', $data['email'])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existingAuthorizedUser) {
|
||||||
|
$this->authorizedUsersModel->update($existingAuthorizedUser['id'], $data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['user_id'] = $userId;
|
||||||
|
$data['status'] = 'Pending';
|
||||||
|
$this->authorizedUsersModel->insert($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendActivationEmail(string $email, string $token): void
|
||||||
|
{
|
||||||
|
$emailController = new 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Parents;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\BaseConnection;
|
||||||
|
|
||||||
|
class ParentAttendanceService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly BaseConnection $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attendanceForParent(int $parentId, string $schoolYear): array
|
||||||
|
{
|
||||||
|
if ($parentId <= 0 || $schoolYear === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->db->table('attendance_data')
|
||||||
|
->select('students.firstname, students.lastname, attendance_data.date, attendance_data.status, attendance_data.reason')
|
||||||
|
->join('students', 'students.id = attendance_data.student_id')
|
||||||
|
->where('attendance_data.school_year', $schoolYear)
|
||||||
|
->where('students.parent_id', $parentId)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Parents;
|
||||||
|
|
||||||
|
use App\Controllers\View\InvoiceController;
|
||||||
|
use App\Models\EnrollmentModel;
|
||||||
|
use App\Models\EventChargesModel;
|
||||||
|
use App\Models\EventModel;
|
||||||
|
|
||||||
|
class ParentEventParticipationService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EventChargesModel $chargesModel,
|
||||||
|
private readonly EventModel $eventModel,
|
||||||
|
private readonly EnrollmentModel $enrollmentModel,
|
||||||
|
private readonly InvoiceController $invoiceController,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pageData(int $parentId, string $schoolYear, string $semester): array
|
||||||
|
{
|
||||||
|
$activeEvents = $this->eventModel->getActiveEvents($schoolYear, $semester);
|
||||||
|
$chargesList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear, $semester);
|
||||||
|
|
||||||
|
$charges = [];
|
||||||
|
$externalParticipantsByEvent = [];
|
||||||
|
foreach ($chargesList as $charge) {
|
||||||
|
$studentId = $charge['student_id'] ?? null;
|
||||||
|
$eventId = (int) ($charge['event_id'] ?? 0);
|
||||||
|
|
||||||
|
if (! empty($studentId)) {
|
||||||
|
$charges[$studentId . ':' . $eventId] = [
|
||||||
|
'participation' => $charge['participation'],
|
||||||
|
'date' => $charge['updated_at'] ?? $charge['created_at'],
|
||||||
|
];
|
||||||
|
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)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'activeEvents' => $activeEvents,
|
||||||
|
'charges' => $charges,
|
||||||
|
'externalParticipantsByEvent' => $externalParticipantsByEvent,
|
||||||
|
'yourStudents' => $this->enrollmentModel->getEnrolledStudents($parentId, $schoolYear),
|
||||||
|
'activeEventCount' => is_array($activeEvents) ? count($activeEvents) : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateParticipation(array $participations, int $parentId, string $schoolYear, string $semester): void
|
||||||
|
{
|
||||||
|
foreach ($participations as $key => $value) {
|
||||||
|
[$studentId, $eventId] = explode(':', (string) $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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$this->chargesModel->update($existing['id'], ['participation' => $value]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$event = $this->eventModel->getEvent($eventId, $schoolYear);
|
||||||
|
$this->chargesModel->insert([
|
||||||
|
'parent_id' => $parentId,
|
||||||
|
'student_id' => $studentId,
|
||||||
|
'event_id' => $eventId,
|
||||||
|
'participation' => $value,
|
||||||
|
'charged' => $event['amount'],
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'semester' => $semester,
|
||||||
|
'updated_by' => $parentId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->invoiceController->generateInvoice($parentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Parents;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\BaseConnection;
|
||||||
|
|
||||||
|
class ParentPaymentService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly BaseConnection $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invoicesForParent(int $parentId, bool $includeRegisteredKids = false): array
|
||||||
|
{
|
||||||
|
if ($parentId <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$select = $includeRegisteredKids ? 'invoices.*, registeredKids' : '*';
|
||||||
|
|
||||||
|
return $this->db->table('invoices')
|
||||||
|
->select($select)
|
||||||
|
->where('parent_id', $parentId)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markTuitionPaidForParent(int $parentId): void
|
||||||
|
{
|
||||||
|
if ($parentId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->table('students')
|
||||||
|
->where('parent_id', $parentId)
|
||||||
|
->update(['tuition_paid' => 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,724 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Parents;
|
||||||
|
|
||||||
|
use App\Models\EmergencyContactModel;
|
||||||
|
use App\Models\EnrollmentModel;
|
||||||
|
use App\Models\StudentAllergyModel;
|
||||||
|
use App\Models\StudentMedicalConditionModel;
|
||||||
|
use App\Models\StudentModel;
|
||||||
|
use App\Models\UserModel;
|
||||||
|
use App\Services\PhoneFormatterService;
|
||||||
|
use App\Services\SchoolIdService;
|
||||||
|
use CodeIgniter\Database\BaseConnection;
|
||||||
|
use CodeIgniter\Database\Exceptions\DatabaseException;
|
||||||
|
use DateTime;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class ParentRegistrationService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly BaseConnection $db,
|
||||||
|
private readonly UserModel $userModel,
|
||||||
|
private readonly StudentModel $studentModel,
|
||||||
|
private readonly EnrollmentModel $enrollmentModel,
|
||||||
|
private readonly EmergencyContactModel $emergencyContactModel,
|
||||||
|
private readonly StudentMedicalConditionModel $medicalConditionModel,
|
||||||
|
private readonly StudentAllergyModel $allergyModel,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registrationData(
|
||||||
|
int $parentId,
|
||||||
|
string $selectedSchoolYear,
|
||||||
|
bool $isEditable,
|
||||||
|
int $maxChilds,
|
||||||
|
int $maxEmergency
|
||||||
|
): array {
|
||||||
|
$enrollments = $this->getEnrollmentsByParent($parentId, $selectedSchoolYear);
|
||||||
|
$enrollmentMap = [];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
unset($kid);
|
||||||
|
|
||||||
|
$this->ensureStudentYearStatusRows($kids, $selectedSchoolYear);
|
||||||
|
service('studentYearStatus')->attachToStudents($kids, $selectedSchoolYear);
|
||||||
|
|
||||||
|
foreach ($kids as &$kid) {
|
||||||
|
$kid['can_delete'] = $this->canParentDeleteStudent($kid, $parentId);
|
||||||
|
}
|
||||||
|
unset($kid);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'existingKids' => $kids,
|
||||||
|
'emergencies' => $this->emergencyContactModel->where('parent_id', $parentId)->findAll(),
|
||||||
|
'parent' => $user,
|
||||||
|
'maxChilds' => $maxChilds,
|
||||||
|
'maxEmergency' => $maxEmergency,
|
||||||
|
'enrollments' => $enrollments,
|
||||||
|
'selectedYear' => $selectedSchoolYear,
|
||||||
|
'isEditable' => $isEditable,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateRegistrationSubmission(array $post, array $registrationData): array
|
||||||
|
{
|
||||||
|
$existingKids = $registrationData['existingKids'] ?? [];
|
||||||
|
$existingECs = $registrationData['emergencies'] ?? [];
|
||||||
|
$maxChilds = (int) ($registrationData['maxChilds'] ?? 0);
|
||||||
|
$maxEmergency = (int) ($registrationData['maxEmergency'] ?? 0);
|
||||||
|
|
||||||
|
$incomingFirstNames = (array) ($post['studentFirstName'] ?? []);
|
||||||
|
$incomingLastNames = (array) ($post['studentLastName'] ?? []);
|
||||||
|
$incomingDOBs = (array) ($post['dob'] ?? []);
|
||||||
|
$newStudentCount = count(array_filter($incomingFirstNames));
|
||||||
|
|
||||||
|
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 ['ok' => false, 'error' => "Duplicate student detected: {$firstName} {$lastName} with DOB {$dob} already exists."];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 ['ok' => false, 'error' => "Duplicate student entry in the form: {$firstName} {$lastName} with DOB {$dob}."];
|
||||||
|
}
|
||||||
|
$seenStudents[$key] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$incomingECFirst = (array) ($post['emergency_firstname'] ?? []);
|
||||||
|
$incomingECLast = (array) ($post['emergency_lastname'] ?? []);
|
||||||
|
$incomingECPhones = (array) ($post['emergency_phone'] ?? []);
|
||||||
|
$incomingECEmails = (array) ($post['emergency_email'] ?? []);
|
||||||
|
$newECCount = count(array_filter($incomingECFirst));
|
||||||
|
|
||||||
|
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 ['ok' => false, 'error' => "Duplicate emergency contact: {$first} {$last} already exists."];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 ['ok' => false, 'error' => "Duplicate emergency contact entry in the form: {$first} {$last}."];
|
||||||
|
}
|
||||||
|
$seenContacts[$key] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingKidsCount = count($existingKids);
|
||||||
|
$existingECCount = count($existingECs);
|
||||||
|
if (($existingKidsCount + $newStudentCount) > $maxChilds) {
|
||||||
|
return ['ok' => false, 'error' => "Student limit exceeded. You have $existingKidsCount and tried to add $newStudentCount (limit: $maxChilds)."];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($existingECCount + $newECCount) > $maxEmergency) {
|
||||||
|
return ['ok' => false, 'error' => "Emergency contact limit exceeded. You have $existingECCount and tried to add $newECCount (limit: $maxEmergency)."];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveStudentAtIndex(
|
||||||
|
int $idx,
|
||||||
|
array $post,
|
||||||
|
int $parentId,
|
||||||
|
string $schoolYear,
|
||||||
|
SchoolIdService $schoolIdService,
|
||||||
|
?bool $isNew = null,
|
||||||
|
?int $studentId = null,
|
||||||
|
?string $schoolStartDate = null,
|
||||||
|
?string $ageDateReference = null
|
||||||
|
): array {
|
||||||
|
$firstName = $post['studentFirstName'][$idx] ?? null;
|
||||||
|
$lastName = $post['studentLastName'][$idx] ?? null;
|
||||||
|
$dob = $post['dob'][$idx] ?? null;
|
||||||
|
$gender = $post['gender'][$idx] ?? null;
|
||||||
|
$grade = $post['registration_grade'][$idx] ?? null;
|
||||||
|
$conditions = $post['medical_conditions'][$idx] ?? [];
|
||||||
|
$allergies = $post['allergies'][$idx] ?? [];
|
||||||
|
$photoRaw = $post['photo_consent'][$idx] ?? '';
|
||||||
|
|
||||||
|
if (! $firstName || ! $lastName || ! $dob || ! $gender || ! $grade) {
|
||||||
|
return ['ok' => false, 'empty' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
$firstName = $this->normalizeStudentName((string) $firstName);
|
||||||
|
$lastName = $this->normalizeStudentName((string) $lastName);
|
||||||
|
$this->validateNames($firstName);
|
||||||
|
$this->validateNames($lastName);
|
||||||
|
|
||||||
|
$dobObj = new DateTime((string) $dob);
|
||||||
|
$schoolYearAgeDeadline = $this->schoolYearAgeDeadline($schoolYear, $schoolStartDate);
|
||||||
|
$age = $this->calculateAgeAsOfSchoolYearStartYear((string) $dob, $schoolYear);
|
||||||
|
$validation = $this->validateDobAge(
|
||||||
|
(string) $dob,
|
||||||
|
$this->registrationMinimumAgeDeadline($schoolYear, $ageDateReference),
|
||||||
|
5,
|
||||||
|
18,
|
||||||
|
$schoolYearAgeDeadline
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $validation['isValid']) {
|
||||||
|
$displayDeadline = (new DateTime($schoolYearAgeDeadline))->format('m-d-Y');
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'error' => "Student '{$firstName} {$lastName}' {$validation['message']}. General age is calculated as of {$displayDeadline}.",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$studentData = [
|
||||||
|
'firstname' => $firstName,
|
||||||
|
'lastname' => $lastName,
|
||||||
|
'age' => $age,
|
||||||
|
'dob' => $dobObj->format('Y-m-d'),
|
||||||
|
'gender' => $gender,
|
||||||
|
'registration_grade' => $grade,
|
||||||
|
'photo_consent' => strtolower((string) $photoRaw) === 'yes' ? 1 : 0,
|
||||||
|
'parent_id' => $parentId,
|
||||||
|
'year_of_registration' => date('Y'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->db->fieldExists('school_year', 'students')) {
|
||||||
|
$studentData['school_year'] = $schoolYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isNew !== null) {
|
||||||
|
$studentData['is_new'] = $isNew ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingBuilder = $this->studentModel
|
||||||
|
->where('parent_id', $parentId)
|
||||||
|
->where('dob', $dobObj->format('Y-m-d'))
|
||||||
|
->where('firstname', $firstName)
|
||||||
|
->where('lastname', $lastName);
|
||||||
|
|
||||||
|
if ($this->db->fieldExists('school_year', 'students')) {
|
||||||
|
$existingBuilder->where('school_year', $schoolYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $studentId && $existingBuilder->first()) {
|
||||||
|
return ['ok' => false, 'error' => "Student '{$firstName} {$lastName}' with the same birthdate is already registered for $schoolYear."];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($studentId) {
|
||||||
|
$existing = $this->studentModel->find($studentId);
|
||||||
|
if (! is_array($existing) || (int) ($existing['parent_id'] ?? 0) !== $parentId) {
|
||||||
|
return ['ok' => false, 'error' => 'Student record was not found for this parent account.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->auditParentStudentFieldChanges($existing, $studentData, $parentId, 'parent_student_edit');
|
||||||
|
$this->studentModel->update($studentId, $studentData);
|
||||||
|
|
||||||
|
if ($this->parentEditAffectsEligibility($existing, $studentData)) {
|
||||||
|
$this->recheckEligibilityAfterParentEdit($studentId, $parentId, $schoolYear);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$studentData['registration_date'] = utc_now();
|
||||||
|
$studentData['tuition_paid'] = 0;
|
||||||
|
$studentData['school_id'] = $schoolIdService->generateStudentSchoolId();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$studentId = (int) $this->studentModel->insert($studentData, true);
|
||||||
|
} catch (DatabaseException $e) {
|
||||||
|
if (strpos($e->getMessage(), '1062') !== false) {
|
||||||
|
return ['ok' => false, 'error' => "Student '{$firstName} {$lastName}' with the same birthdate is already registered for $schoolYear."];
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isNew !== null && $studentId > 0) {
|
||||||
|
$studentYearStatus = service('studentYearStatus');
|
||||||
|
$statusSaved = $studentYearStatus->upsert($studentId, $schoolYear, $isNew);
|
||||||
|
if (! $statusSaved || ! $studentYearStatus->hasStatus($studentId, $schoolYear)) {
|
||||||
|
throw new \RuntimeException('Student year status could not be saved for student ID ' . $studentId . ' and school year ' . $schoolYear . '.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->medicalConditionModel->where('student_id', $studentId)->delete();
|
||||||
|
foreach ((array) $conditions as $condition) {
|
||||||
|
$condition = trim((string) $condition);
|
||||||
|
if ($condition !== '') {
|
||||||
|
$this->medicalConditionModel->insert(['student_id' => $studentId, 'condition_name' => $condition]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->allergyModel->where('student_id', $studentId)->delete();
|
||||||
|
foreach ((array) $allergies as $allergy) {
|
||||||
|
$allergy = trim((string) $allergy);
|
||||||
|
if ($allergy !== '') {
|
||||||
|
$this->allergyModel->insert(['student_id' => $studentId, 'allergy' => $allergy]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => true, 'student_id' => $studentId];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveEmergencyContact(int $parentId, array $post, ?array $single = null, ?int $id = null): array
|
||||||
|
{
|
||||||
|
$phoneFormatter = new PhoneFormatterService();
|
||||||
|
|
||||||
|
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 ['ok' => true, 'empty' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->validateNames($firstName);
|
||||||
|
$this->validateNames($lastName);
|
||||||
|
|
||||||
|
if ($email && ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
throw new \Exception('Invalid email format for emergency contact.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'parent_id' => $parentId,
|
||||||
|
'emergency_contact_name' => $firstName . ' ' . $lastName,
|
||||||
|
'cellphone' => $phone,
|
||||||
|
'email' => $email,
|
||||||
|
'relation' => $relation,
|
||||||
|
'updated_at' => utc_now(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$duplicateBuilder = $this->emergencyContactModel
|
||||||
|
->where('parent_id', $parentId)
|
||||||
|
->where('emergency_contact_name', $data['emergency_contact_name'])
|
||||||
|
->where('cellphone', $phone)
|
||||||
|
->where('email', $email)
|
||||||
|
->where('relation', $relation);
|
||||||
|
|
||||||
|
if ($id !== null) {
|
||||||
|
$duplicateBuilder->where('id !=', $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($duplicateBuilder->first()) {
|
||||||
|
return ['ok' => false, 'error' => $id !== null
|
||||||
|
? 'Another emergency contact with the same information already exists.'
|
||||||
|
: 'This emergency contact is already registered.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($id !== null) {
|
||||||
|
$this->emergencyContactModel->update($id, $data);
|
||||||
|
} else {
|
||||||
|
$this->emergencyContactModel->insert($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
$firstNames = (array) ($post['emergency_firstname'] ?? []);
|
||||||
|
$lastNames = (array) ($post['emergency_lastname'] ?? []);
|
||||||
|
$relations = (array) ($post['emergency_relation'] ?? []);
|
||||||
|
$phones = (array) ($post['emergency_phone'] ?? []);
|
||||||
|
$emails = (array) ($post['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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canParentDeleteStudent(array $student, int $parentId): bool
|
||||||
|
{
|
||||||
|
$studentId = (int) ($student['id'] ?? 0);
|
||||||
|
if ($studentId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$statusYear = trim((string) ($student['school_year'] ?? ''));
|
||||||
|
if ($statusYear === '') {
|
||||||
|
$statusYear = (string) (service('studentYearStatus')->activeSchoolYear() ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$isNew = $statusYear !== ''
|
||||||
|
? service('studentYearStatus')->isNew($studentId, $statusYear)
|
||||||
|
: ((string) ($student['is_new'] ?? '1') === '1');
|
||||||
|
|
||||||
|
return $isNew
|
||||||
|
&& ! $this->studentHasEnrollmentHistory($studentId, $parentId)
|
||||||
|
&& ! $this->studentHasClassAssignmentHistory($studentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateDobAge(
|
||||||
|
string $dob,
|
||||||
|
string $registrationAgeDeadline,
|
||||||
|
int $minAge = 5,
|
||||||
|
int $maxAge = 18,
|
||||||
|
?string $schoolYearAgeDeadline = null
|
||||||
|
): array {
|
||||||
|
$response = ['isValid' => false, 'message' => '', 'age' => null];
|
||||||
|
$tz = new DateTimeZone('UTC');
|
||||||
|
$dob = trim($dob);
|
||||||
|
if ($dob === '') {
|
||||||
|
$response['message'] = 'Date of birth is required';
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$birthDate = DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $tz);
|
||||||
|
$errs = DateTimeImmutable::getLastErrors();
|
||||||
|
if ($birthDate === false || (is_array($errs) && (($errs['warning_count'] ?? 0) > 0 || ($errs['error_count'] ?? 0) > 0))) {
|
||||||
|
$response['message'] = 'Invalid date format (Use YYYY-MM-DD)';
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
$ageAtDeadline = $birthDate->diff($ageDeadline)->y;
|
||||||
|
$ageAtMinimumAgeDeadline = $birthDate->diff($minimumAgeDeadline)->y;
|
||||||
|
$response['age'] = $ageAtDeadline;
|
||||||
|
|
||||||
|
$minBirthDate = $ageDeadline->modify('-' . ($maxAge + 1) . ' years')->modify('+1 day')->setTime(0, 0, 0);
|
||||||
|
$maxBirthDate = $minimumAgeDeadline->modify("-{$minAge} years")->setTime(23, 59, 59);
|
||||||
|
$response['isValid'] = ($birthDate >= $minBirthDate) && ($birthDate <= $maxBirthDate);
|
||||||
|
|
||||||
|
if (! $response['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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function schoolYearAgeDeadline(string $schoolYear, ?string $schoolStartDate = null): string
|
||||||
|
{
|
||||||
|
if (preg_match('/^(\d{4})/', trim($schoolYear), $matches)) {
|
||||||
|
return $matches[1] . '-09-01';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($schoolStartDate) && strtotime($schoolStartDate)) {
|
||||||
|
return (new DateTimeImmutable($schoolStartDate))->format('Y-m-d');
|
||||||
|
}
|
||||||
|
|
||||||
|
return date('Y') . '-09-01';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registrationMinimumAgeDeadline(string $schoolYear, ?string $ageDateReference = null): string
|
||||||
|
{
|
||||||
|
$configured = trim((string) $ageDateReference);
|
||||||
|
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 formatName(string $name): string
|
||||||
|
{
|
||||||
|
$name = trim($name);
|
||||||
|
$name = strtolower($name);
|
||||||
|
$name = ucwords($name, ' ');
|
||||||
|
|
||||||
|
return implode('-', array_map('ucfirst', explode('-', $name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateNames(string $name): void
|
||||||
|
{
|
||||||
|
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 getEnrollmentsByParent(int $parentId, string $schoolYear): array
|
||||||
|
{
|
||||||
|
return $this->enrollmentModel
|
||||||
|
->where('parent_id', $parentId)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->orderBy('enrollment_date', 'DESC')
|
||||||
|
->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureStudentYearStatusRows(array $students, string $schoolYear): void
|
||||||
|
{
|
||||||
|
$schoolYear = trim($schoolYear);
|
||||||
|
if ($students === [] || ! preg_match('/^\d{4}-\d{4}$/', $schoolYear)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$studentYearStatus = service('studentYearStatus');
|
||||||
|
foreach ($students as $student) {
|
||||||
|
$studentId = (int) ($student['id'] ?? $student['student_id'] ?? 0);
|
||||||
|
if ($studentId <= 0 || $studentYearStatus->hasStatus($studentId, $schoolYear)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isNew = (int) ($student['is_new'] ?? 1) === 1;
|
||||||
|
if (! $studentYearStatus->upsert($studentId, $schoolYear, $isNew)) {
|
||||||
|
log_message('error', 'Unable to repair student_year_status for student_id={studentId}, school_year={schoolYear}', [
|
||||||
|
'studentId' => $studentId,
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 normalizeStudentName(string $name): string
|
||||||
|
{
|
||||||
|
$name = trim(preg_replace('/\s+/', ' ', $name) ?? '');
|
||||||
|
|
||||||
|
return mb_convert_case($name, MB_CASE_TITLE, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditParentStudentFieldChanges(array $original, array $updated, int $parentId, string $source): void
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('enrollment_transition_audits')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$studentId = (int) ($original['id'] ?? 0);
|
||||||
|
if ($studentId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changes = [];
|
||||||
|
foreach (['firstname', 'lastname', 'dob'] as $field) {
|
||||||
|
$oldValue = trim((string) ($original[$field] ?? ''));
|
||||||
|
$newValue = trim((string) ($updated[$field] ?? ''));
|
||||||
|
if ($oldValue !== $newValue) {
|
||||||
|
$changes[$field] = [
|
||||||
|
'old_value' => $oldValue,
|
||||||
|
'new_value' => $newValue,
|
||||||
|
'changed' => true,
|
||||||
|
'changed_by' => $parentId,
|
||||||
|
'changed_at' => date('Y-m-d H:i:s'),
|
||||||
|
'source' => $source,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($changes === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->table('enrollment_transition_audits')->insert([
|
||||||
|
'student_id' => $studentId,
|
||||||
|
'school_year' => (string) ($updated['school_year'] ?? $original['school_year'] ?? date('Y')),
|
||||||
|
'source_school_year' => null,
|
||||||
|
'action' => 'parent_student_field_edit',
|
||||||
|
'performed_by' => $parentId,
|
||||||
|
'original_values_json' => json_encode($original, JSON_UNESCAPED_SLASHES),
|
||||||
|
'new_values_json' => json_encode(['changes' => $changes, 'updated' => $updated], JSON_UNESCAPED_SLASHES),
|
||||||
|
'reason' => $source,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parentEditAffectsEligibility(array $original, array $updated): bool
|
||||||
|
{
|
||||||
|
return trim((string) ($original['dob'] ?? '')) !== trim((string) ($updated['dob'] ?? ''))
|
||||||
|
|| trim((string) ($original['lastname'] ?? '')) !== trim((string) ($updated['lastname'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recheckEligibilityAfterParentEdit(int $studentId, int $parentId, string $targetSchoolYear): void
|
||||||
|
{
|
||||||
|
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
||||||
|
if ($previousSchoolYear === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$evaluation = service('enrollmentTransition')->evaluateForParent(
|
||||||
|
$parentId,
|
||||||
|
$studentId,
|
||||||
|
$previousSchoolYear,
|
||||||
|
$targetSchoolYear,
|
||||||
|
'parent'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (($evaluation['can_enroll'] ?? false) === true || ($evaluation['parent_enrollment_allowed'] ?? false) === true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = (string) ($evaluation['primary_parent_message'] ?? 'Updated student information affects enrollment eligibility.');
|
||||||
|
service('enrollmentTransition')->logEnrollmentBlock($evaluation, 'parent_student_edit', $parentId, $parentId);
|
||||||
|
session()->setFlashdata('warning', $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function previousSchoolYearName(string $schoolYear): ?string
|
||||||
|
{
|
||||||
|
if (! preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ final class EnrollmentEligibility
|
|||||||
public const DEFERRED_MESSAGE = 'Re-enrollment cannot currently be completed because the final deliberation decision is deferred. Please contact the school administration for the next required step.';
|
public const DEFERRED_MESSAGE = 'Re-enrollment cannot currently be completed because the final deliberation decision is deferred. Please contact the school administration for the next required step.';
|
||||||
public const KG_MISSING_DECISION_ELIGIBLE_MESSAGE = 'KG students may complete registration now. Their new-year grade placement will be based on the school age-placement rule.';
|
public const KG_MISSING_DECISION_ELIGIBLE_MESSAGE = 'KG students may complete registration now. Their new-year grade placement will be based on the school age-placement rule.';
|
||||||
public const MISSING_DECISION_MESSAGE = 'Re-enrollment cannot currently be completed because no final deliberation decision is recorded for the student. Registration will become available after the school records a final decision.';
|
public const MISSING_DECISION_MESSAGE = 'Re-enrollment cannot currently be completed because no final deliberation decision is recorded for the student. Registration will become available after the school records a final decision.';
|
||||||
public const ADULT_STUDENT_MESSAGE = 'This student will be 18 years old or older on September 1 of the selected school year. The student can no longer enroll in the school.';
|
public const ADULT_STUDENT_MESSAGE = 'This student will be 18 years old or older on September 1 of the selected school year. A parent or guardian cannot complete registration because the student can no longer enroll in the school.';
|
||||||
public const ADULT_STUDENT_PARENT_PORTAL_MESSAGE = self::ADULT_STUDENT_MESSAGE;
|
public const ADULT_STUDENT_PARENT_PORTAL_MESSAGE = self::ADULT_STUDENT_MESSAGE;
|
||||||
public const WITHDRAWN_PORTAL_MESSAGE = 'This student is currently marked as Withdrawn and cannot be enrolled at this time. Please contact school administration.';
|
public const WITHDRAWN_PORTAL_MESSAGE = 'This student is currently marked as Withdrawn and cannot be enrolled at this time. Please contact school administration.';
|
||||||
public const SIBLING_PORTAL_MESSAGE = 'Enrollment cannot continue because the family record requires administrative review. Please contact school administration.';
|
public const SIBLING_PORTAL_MESSAGE = 'Enrollment cannot continue because the family record requires administrative review. Please contact school administration.';
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ if (!function_exists('enrollment_admin_rule_label')) {
|
|||||||
'SIBLING_LAST_NAME_MISMATCH' => 'Sibling last names do not match',
|
'SIBLING_LAST_NAME_MISMATCH' => 'Sibling last names do not match',
|
||||||
'OUTSTANDING_BALANCE_BLOCKED' => 'Previous-year balance must be paid',
|
'OUTSTANDING_BALANCE_BLOCKED' => 'Previous-year balance must be paid',
|
||||||
'FINANCE_APPROVAL_REQUIRED' => 'Finance approval required',
|
'FINANCE_APPROVAL_REQUIRED' => 'Finance approval required',
|
||||||
|
'CURRENT_YEAR_INSTALLMENT_OVERRIDE' => 'Allow installments for new-year balance only',
|
||||||
'AGE_RULE_BLOCKED' => 'Age rule not met',
|
'AGE_RULE_BLOCKED' => 'Age rule not met',
|
||||||
'REGISTRATION_CLOSED' => 'Registration is closed',
|
'REGISTRATION_CLOSED' => 'Registration is closed',
|
||||||
'REGISTRATION_NOT_OPEN' => 'Registration is not open yet',
|
'REGISTRATION_NOT_OPEN' => 'Registration is not open yet',
|
||||||
@@ -427,6 +428,14 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes :
|
|||||||
<form method="post" action="<?= site_url('administrator/enrollment-admin/flags/' . $flagId . '/approve-exception') ?>" class="mb-2">
|
<form method="post" action="<?= site_url('administrator/enrollment-admin/flags/' . $flagId . '/approve-exception') ?>" class="mb-2">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<div class="small fw-semibold mb-1">Approve an exception for this student</div>
|
<div class="small fw-semibold mb-1">Approve an exception for this student</div>
|
||||||
|
<?php if ($flagTypeValue === 'FINANCIAL_REVIEW_REQUIRED'): ?>
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" name="allow_current_year_installments" id="allow_current_year_installments_<?= $flagId ?>" value="1">
|
||||||
|
<label class="form-check-label small" for="allow_current_year_installments_<?= $flagId ?>">
|
||||||
|
<?= esc(enrollment_admin_rule_label('CURRENT_YEAR_INSTALLMENT_OVERRIDE')) ?>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
<input class="form-control form-control-sm mb-2" name="reason" placeholder="Approval reason" required>
|
<input class="form-control form-control-sm mb-2" name="reason" placeholder="Approval reason" required>
|
||||||
<button class="btn btn-sm btn-warning" type="submit">Approve exception</button>
|
<button class="btn btn-sm btn-warning" type="submit">Approve exception</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -516,17 +525,13 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes :
|
|||||||
$warningCodes = array_values(array_filter(array_map('strval', $evaluation['warning_rule_codes'] ?? [])));
|
$warningCodes = array_values(array_filter(array_map('strval', $evaluation['warning_rule_codes'] ?? [])));
|
||||||
$failedCodes = array_values(array_unique(array_merge($blockingCodes, $reviewCodes)));
|
$failedCodes = array_values(array_unique(array_merge($blockingCodes, $reviewCodes)));
|
||||||
$overridableCodes = array_values(array_diff($failedCodes, $nonOverridableCodes));
|
$overridableCodes = array_values(array_diff($failedCodes, $nonOverridableCodes));
|
||||||
$hasGrantableStudent = $hasGrantableStudent || $overridableCodes !== [];
|
$hasGrantableStudent = true;
|
||||||
$decision = (string) ($evaluation['decision'] ?? '');
|
$decision = (string) ($evaluation['decision'] ?? '');
|
||||||
$canEnroll = !empty($evaluation['can_enroll']);
|
$canEnroll = !empty($evaluation['can_enroll']);
|
||||||
?>
|
?>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<?php if ($overridableCodes !== []): ?>
|
|
||||||
<input class="form-check-input" type="checkbox" name="student_ids[]" value="<?= $studentId ?>">
|
<input class="form-check-input" type="checkbox" name="student_ids[]" value="<?= $studentId ?>">
|
||||||
<?php else: ?>
|
|
||||||
<span class="text-muted">-</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<?= esc($studentPreview['student_name'] ?? '') ?>
|
<?= esc($studentPreview['student_name'] ?? '') ?>
|
||||||
@@ -550,9 +555,13 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes :
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php else: ?>
|
|
||||||
<span class="text-muted small">None</span>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<div class="form-check mt-1">
|
||||||
|
<input class="form-check-input" type="checkbox" name="bypassed_rule_codes_by_student[<?= $studentId ?>][]" id="bypass_<?= $studentId ?>_current_year_installments" value="CURRENT_YEAR_INSTALLMENT_OVERRIDE">
|
||||||
|
<label class="form-check-label small" for="bypass_<?= $studentId ?>_current_year_installments">
|
||||||
|
<?= esc(enrollment_admin_rule_label('CURRENT_YEAR_INSTALLMENT_OVERRIDE')) ?>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<?php if ($warningCodes !== []): ?>
|
<?php if ($warningCodes !== []): ?>
|
||||||
|
|||||||
@@ -156,7 +156,7 @@
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="text-center">No students found.</td>
|
<td colspan="8" class="text-center">No students found.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,4 +1,81 @@
|
|||||||
<?= $this->extend('layout/management_layout') ?>
|
<?= $this->extend('layout/management_layout') ?>
|
||||||
|
<?= $this->section('styles') ?>
|
||||||
|
<style>
|
||||||
|
.homework-table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.homework-sticky-table {
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
min-width: max-content;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.homework-sticky-table th,
|
||||||
|
.homework-sticky-table td {
|
||||||
|
box-sizing: border-box;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.homework-sticky-table th.homework-rownum-col,
|
||||||
|
.homework-sticky-table td.homework-rownum-col {
|
||||||
|
min-width: 56px;
|
||||||
|
width: 56px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.homework-sticky-table th.homework-school-id-col,
|
||||||
|
.homework-sticky-table td.homework-school-id-col {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 4;
|
||||||
|
min-width: 112px;
|
||||||
|
width: 112px;
|
||||||
|
max-width: 112px;
|
||||||
|
}
|
||||||
|
.homework-sticky-table th.homework-first-name-col,
|
||||||
|
.homework-sticky-table td.homework-first-name-col {
|
||||||
|
position: sticky;
|
||||||
|
left: 112px;
|
||||||
|
z-index: 4;
|
||||||
|
min-width: 168px;
|
||||||
|
width: 168px;
|
||||||
|
max-width: 168px;
|
||||||
|
}
|
||||||
|
.homework-sticky-table th.homework-last-name-col,
|
||||||
|
.homework-sticky-table td.homework-last-name-col {
|
||||||
|
position: sticky;
|
||||||
|
left: 280px;
|
||||||
|
z-index: 4;
|
||||||
|
min-width: 168px;
|
||||||
|
width: 168px;
|
||||||
|
max-width: 168px;
|
||||||
|
box-shadow: 1px 0 0 rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
.homework-sticky-table th.homework-school-id-col,
|
||||||
|
.homework-sticky-table td.homework-school-id-col,
|
||||||
|
.homework-sticky-table th.homework-first-name-col,
|
||||||
|
.homework-sticky-table td.homework-first-name-col,
|
||||||
|
.homework-sticky-table th.homework-last-name-col,
|
||||||
|
.homework-sticky-table td.homework-last-name-col {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.homework-sticky-table thead th.homework-school-id-col,
|
||||||
|
.homework-sticky-table thead th.homework-first-name-col,
|
||||||
|
.homework-sticky-table thead th.homework-last-name-col {
|
||||||
|
z-index: 6;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.homework-sticky-table th.text-center,
|
||||||
|
.homework-sticky-table tbody td:not(.homework-rownum-col):not(.homework-school-id-col):not(.homework-first-name-col):not(.homework-last-name-col) {
|
||||||
|
min-width: 132px;
|
||||||
|
width: 132px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
<?= $this->section('content') ?>
|
<?= $this->section('content') ?>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
@@ -37,13 +114,14 @@ $lockAttr = $scoresLocked ? 'disabled' : '';
|
|||||||
<input type="hidden" name="school_year" value="<?= esc($schoolYear) ?>">
|
<input type="hidden" name="school_year" value="<?= esc($schoolYear) ?>">
|
||||||
<input type="hidden" name="class_section_id" value="<?= esc($classSectionId ?? '') ?>">
|
<input type="hidden" name="class_section_id" value="<?= esc($classSectionId ?? '') ?>">
|
||||||
|
|
||||||
<table id="homeworkTable" class="table table-bordered mt-4 w-100" data-no-mgmt-sticky>
|
<div class="homework-table-scroll">
|
||||||
|
<table id="homeworkTable" class="table table-bordered mt-4 homework-sticky-table homework-sticky-table--grading" data-no-mgmt-sticky>
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th>
|
<th class="homework-rownum-col">#</th>
|
||||||
<th>School ID</th>
|
<th class="homework-school-id-col">School ID</th>
|
||||||
<th>First Name</th>
|
<th class="homework-first-name-col">First Name</th>
|
||||||
<th>Last Name</th>
|
<th class="homework-last-name-col">Last Name</th>
|
||||||
<?php foreach ($homeworkHeaders as $index): ?>
|
<?php foreach ($homeworkHeaders as $index): ?>
|
||||||
<th class="text-center"><?= esc("Homework " . $index) ?></th>
|
<th class="text-center"><?= esc("Homework " . $index) ?></th>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
@@ -59,15 +137,15 @@ $lockAttr = $scoresLocked ? 'disabled' : '';
|
|||||||
$rowLockAttr = $scoresLocked ? $lockAttr : ($rowLocked ? 'readonly aria-disabled="true"' : '');
|
$rowLockAttr = $scoresLocked ? $lockAttr : ($rowLocked ? 'readonly aria-disabled="true"' : '');
|
||||||
?>
|
?>
|
||||||
<tr class="<?= $rowLocked ? 'table-secondary text-muted' : '' ?>">
|
<tr class="<?= $rowLocked ? 'table-secondary text-muted' : '' ?>">
|
||||||
<td><?= $row++ ?></td>
|
<td class="homework-rownum-col"><?= $row++ ?></td>
|
||||||
<td><?= esc($student['school_id']) ?></td>
|
<td class="homework-school-id-col"><?= esc($student['school_id']) ?></td>
|
||||||
<td>
|
<td class="homework-first-name-col">
|
||||||
<a href="#" class="text-decoration-none" data-family-student-id="<?= (int)$studentId ?>">
|
<a href="#" class="text-decoration-none" data-family-student-id="<?= (int)$studentId ?>">
|
||||||
<?= esc($student['firstname']) ?>
|
<?= esc($student['firstname']) ?>
|
||||||
</a>
|
</a>
|
||||||
<?= student_enrollment_status_button($student, $schoolYear ?? null) ?>
|
<?= student_enrollment_status_button($student, $schoolYear ?? null) ?>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="homework-last-name-col">
|
||||||
<a href="#" class="text-decoration-none" data-family-student-id="<?= (int)$studentId ?>">
|
<a href="#" class="text-decoration-none" data-family-student-id="<?= (int)$studentId ?>">
|
||||||
<?= esc($student['lastname']) ?>
|
<?= esc($student['lastname']) ?>
|
||||||
</a>
|
</a>
|
||||||
@@ -89,6 +167,7 @@ $lockAttr = $scoresLocked ? 'disabled' : '';
|
|||||||
</tbody>
|
</tbody>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between mt-4 flex-wrap gap-2">
|
<div class="d-flex justify-content-between mt-4 flex-wrap gap-2">
|
||||||
<button type="submit" class="btn btn-success" <?= $lockAttr ?>>
|
<button type="submit" class="btn btn-success" <?= $lockAttr ?>>
|
||||||
@@ -144,9 +223,11 @@ $lockAttr = $scoresLocked ? 'disabled' : '';
|
|||||||
autoWidth: false,
|
autoWidth: false,
|
||||||
order: [[2, 'asc'], [3, 'asc']],
|
order: [[2, 'asc'], [3, 'asc']],
|
||||||
columnDefs: [
|
columnDefs: [
|
||||||
{ targets: 0, orderable: false, searchable: false },
|
{ targets: 0, orderable: false, searchable: false, width: '56px' },
|
||||||
{ targets: 1, className: 'text-nowrap' },
|
{ targets: 1, className: 'text-nowrap', width: '112px' },
|
||||||
...(scoreCols.length ? [{ targets: scoreCols, orderDataType: 'dom-num-input', orderSequence: ['desc', 'asc'], type: 'num' }] : []),
|
{ targets: 2, width: '168px' },
|
||||||
|
{ targets: 3, width: '168px' },
|
||||||
|
...(scoreCols.length ? [{ targets: scoreCols, width: '132px', orderDataType: 'dom-num-input', orderSequence: ['desc', 'asc'], type: 'num' }] : []),
|
||||||
],
|
],
|
||||||
fixedHeader: {
|
fixedHeader: {
|
||||||
header: true,
|
header: true,
|
||||||
|
|||||||
@@ -594,7 +594,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- About Start - Our Mission Section -->
|
<!-- About Start - Our Mission Section -->
|
||||||
<div class="container-xxl py-2 content-section">
|
<div class="container-xxl py-2 content-section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
@@ -147,7 +147,7 @@
|
|||||||
}
|
}
|
||||||
function renderInvoiceRow(r) {
|
function renderInvoiceRow(r) {
|
||||||
const isCarryForward = !!r.is_carry_forward;
|
const isCarryForward = !!r.is_carry_forward;
|
||||||
const ts = Date.parse(r.invoice_date || new Date().toISOString());
|
const ts = Date.parse(r.invoice_date || '');
|
||||||
const genBtn = isCarryForward
|
const genBtn = isCarryForward
|
||||||
? '<span class="text-muted small">Audit only</span>'
|
? '<span class="text-muted small">Audit only</span>'
|
||||||
: `<button type=\"button\" class=\"btn btn-primary btn-sm gen-invoice\" data-parent-id=\"${escAttr(r.parent_id)}\" data-parent-name=\"${escAttr(r.parent_name || '')}\">Generate Invoice</button>`;
|
: `<button type=\"button\" class=\"btn btn-primary btn-sm gen-invoice\" data-parent-id=\"${escAttr(r.parent_id)}\" data-parent-name=\"${escAttr(r.parent_name || '')}\">Generate Invoice</button>`;
|
||||||
@@ -165,11 +165,12 @@
|
|||||||
genBtn,
|
genBtn,
|
||||||
fmtMoney(r.invoice_amount),
|
fmtMoney(r.invoice_amount),
|
||||||
renderRefundCell(r),
|
renderRefundCell(r),
|
||||||
`<span data-order=\"${ts}\">${formatDateTime(ts)}</span>`,
|
`<span data-order=\"${Number.isNaN(ts) ? 0 : ts}\">${Number.isNaN(ts) ? '' : formatDateTime(ts)}</span>`,
|
||||||
pdf,
|
pdf,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function generateInvoice(parentId) {
|
async function generateInvoice(parentId) {
|
||||||
const body = new URLSearchParams();
|
const body = new URLSearchParams();
|
||||||
body.append('parent_id', parentId);
|
body.append('parent_id', parentId);
|
||||||
|
|||||||
@@ -423,6 +423,7 @@
|
|||||||
data-display-total="<?= esc($invoice['display_total'] ?? $invoice['total_amount'] ?? '') ?>"
|
data-display-total="<?= esc($invoice['display_total'] ?? $invoice['total_amount'] ?? '') ?>"
|
||||||
data-balance-due-cents="<?= (int)($invoice['balance_due_cents'] ?? 0) ?>"
|
data-balance-due-cents="<?= (int)($invoice['balance_due_cents'] ?? 0) ?>"
|
||||||
data-customer-credit-cents="<?= (int)($invoice['customer_credit_cents'] ?? 0) ?>"
|
data-customer-credit-cents="<?= (int)($invoice['customer_credit_cents'] ?? 0) ?>"
|
||||||
|
data-carry-forward-invoice="<?= !empty($invoice['is_carry_forward_invoice']) ? '1' : '0' ?>"
|
||||||
data-next-installment="<?= (int)($invoice['next_installment'] ?? 1) ?>">
|
data-next-installment="<?= (int)($invoice['next_installment'] ?? 1) ?>">
|
||||||
<?php
|
<?php
|
||||||
$uiPaid = (float)($invoice['paid_amount'] ?? 0);
|
$uiPaid = (float)($invoice['paid_amount'] ?? 0);
|
||||||
@@ -634,6 +635,11 @@
|
|||||||
return isFinite(v) ? v : 1;
|
return isFinite(v) ? v : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectedInvoiceRequiresCarryForwardFull() {
|
||||||
|
const opt = currentOpt();
|
||||||
|
return carryForwardFullRequired || (opt && opt.getAttribute('data-carry-forward-invoice') === '1');
|
||||||
|
}
|
||||||
|
|
||||||
function monthsUntil(endYmd) {
|
function monthsUntil(endYmd) {
|
||||||
if (!endYmd) return 0;
|
if (!endYmd) return 0;
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -788,12 +794,15 @@
|
|||||||
|
|
||||||
if (m === 'card') {
|
if (m === 'card') {
|
||||||
forceCardRules();
|
forceCardRules();
|
||||||
} else if (carryForwardFullRequired) {
|
} else if (selectedInvoiceRequiresCarryForwardFull()) {
|
||||||
forceCarryForwardFullRules();
|
forceCarryForwardFullRules();
|
||||||
} else {
|
} else {
|
||||||
// enable full/installment select
|
// enable full/installment select
|
||||||
$type().removeAttribute('disabled');
|
const type = $type();
|
||||||
$type().value = 'full';
|
type.removeAttribute('disabled');
|
||||||
|
const installmentOption = type.querySelector('option[value="installment"]');
|
||||||
|
if (installmentOption) installmentOption.disabled = false;
|
||||||
|
type.value = 'full';
|
||||||
$instSec().style.display = 'none';
|
$instSec().style.display = 'none';
|
||||||
$instSeqRow().style.display = 'none';
|
$instSeqRow().style.display = 'none';
|
||||||
$amount().removeAttribute('readonly');
|
$amount().removeAttribute('readonly');
|
||||||
@@ -810,7 +819,7 @@
|
|||||||
forceCardRules();
|
forceCardRules();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (carryForwardFullRequired) {
|
if (selectedInvoiceRequiresCarryForwardFull()) {
|
||||||
forceCarryForwardFullRules();
|
forceCarryForwardFullRules();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -834,7 +843,7 @@
|
|||||||
|
|
||||||
if (($method().value || '').toLowerCase() === 'card') {
|
if (($method().value || '').toLowerCase() === 'card') {
|
||||||
forceCardRules();
|
forceCardRules();
|
||||||
} else if (carryForwardFullRequired) {
|
} else if (selectedInvoiceRequiresCarryForwardFull()) {
|
||||||
forceCarryForwardFullRules();
|
forceCarryForwardFullRules();
|
||||||
} else {
|
} else {
|
||||||
updateAmountHint();
|
updateAmountHint();
|
||||||
@@ -908,7 +917,7 @@
|
|||||||
alert('Please enter a valid amount > 0.');
|
alert('Please enter a valid amount > 0.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (carryForwardFullRequired && type === 'installment') {
|
if (selectedInvoiceRequiresCarryForwardFull() && type === 'installment') {
|
||||||
alert(carryForwardFullMessage);
|
alert(carryForwardFullMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -924,7 +933,7 @@
|
|||||||
|
|
||||||
const newBal = (isFinite(balance) && isFinite(amount)) ? (balance - amount) : NaN;
|
const newBal = (isFinite(balance) && isFinite(amount)) ? (balance - amount) : NaN;
|
||||||
const overpay = (isFinite(newBal) && newBal < -0.005);
|
const overpay = (isFinite(newBal) && newBal < -0.005);
|
||||||
if (carryForwardFullRequired && isFinite(balance) && Math.abs(amount - balance) > 0.005) {
|
if (selectedInvoiceRequiresCarryForwardFull() && isFinite(balance) && Math.abs(amount - balance) > 0.005) {
|
||||||
alert(carryForwardFullMessage);
|
alert(carryForwardFullMessage);
|
||||||
$amount().value = Math.max(0, balance).toFixed(2);
|
$amount().value = Math.max(0, balance).toFixed(2);
|
||||||
return;
|
return;
|
||||||
|
|||||||
+114
-133
@@ -1,4 +1,77 @@
|
|||||||
<?= $this->extend('layout/main_layout') ?>
|
<?= $this->extend('layout/main_layout') ?>
|
||||||
|
<?= $this->section('styles') ?>
|
||||||
|
<style>
|
||||||
|
.homework-table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.homework-sticky-table {
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
min-width: max-content;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.homework-sticky-table th,
|
||||||
|
.homework-sticky-table td {
|
||||||
|
box-sizing: border-box;
|
||||||
|
vertical-align: middle;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
.homework-sticky-table th.homework-rownum-col,
|
||||||
|
.homework-sticky-table td.homework-rownum-col {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 4;
|
||||||
|
min-width: 56px;
|
||||||
|
width: 56px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.homework-sticky-table th.homework-student-name-col,
|
||||||
|
.homework-sticky-table td.homework-student-name-col {
|
||||||
|
position: sticky;
|
||||||
|
left: 56px;
|
||||||
|
z-index: 4;
|
||||||
|
min-width: var(--homework-name-col-width, 18ch);
|
||||||
|
width: var(--homework-name-col-width, 18ch);
|
||||||
|
max-width: var(--homework-name-col-width, 18ch);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 1px 0 0 rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
.homework-sticky-table thead th.homework-rownum-col,
|
||||||
|
.homework-sticky-table thead th.homework-student-name-col {
|
||||||
|
z-index: 6;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.homework-sticky-table th.homework-score-col,
|
||||||
|
.homework-sticky-table td.homework-score-col,
|
||||||
|
.homework-sticky-table th.dynamic-col,
|
||||||
|
.homework-sticky-table td.dynamic-col {
|
||||||
|
min-width: 180px;
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
.homework-sticky-table .form-control {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.score-empty {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
}
|
||||||
|
.missing-check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6c757d;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
<?= $this->section('content') ?>
|
<?= $this->section('content') ?>
|
||||||
<div class="container-fluid py-5">
|
<div class="container-fluid py-5">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
@@ -16,36 +89,42 @@
|
|||||||
return ($value === null || $value === '') ? '' : esc($value);
|
return ($value === null || $value === '') ? '' : esc($value);
|
||||||
};
|
};
|
||||||
$missingOkMap = $missingOkMap ?? [];
|
$missingOkMap = $missingOkMap ?? [];
|
||||||
|
$studentNameWidthCh = 18;
|
||||||
|
foreach ($students as $student) {
|
||||||
|
$fullName = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? ''));
|
||||||
|
$studentNameWidthCh = max($studentNameWidthCh, strlen($fullName) + 3);
|
||||||
|
}
|
||||||
|
$studentNameWidthCh = min($studentNameWidthCh, 42);
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<form id="homeworkForm" action="<?= base_url('/teacher/updateHomework') ?>" method="post">
|
<form id="homeworkForm" action="<?= base_url('/teacher/updateHomework') ?>" method="post">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="semester" value="<?= esc($semester) ?>">
|
<input type="hidden" name="semester" value="<?= esc($semester) ?>">
|
||||||
<input type="hidden" name="school_year" value="<?= esc($schoolYear) ?>">
|
<input type="hidden" name="school_year" value="<?= esc($schoolYear) ?>">
|
||||||
<div class="table-responsive">
|
<div class="homework-table-scroll">
|
||||||
<table id="homeworkTable" class="table table-bordered mt-4 w-100">
|
<table id="homeworkTable" class="table table-bordered mt-4 homework-sticky-table homework-sticky-table--teacher" style="--homework-name-col-width: <?= (int) $studentNameWidthCh ?>ch;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th>
|
<th class="homework-rownum-col">#</th>
|
||||||
<th style="text-align: left;">Student Name</th>
|
<th class="homework-student-name-col" style="text-align: left;">Student Name</th>
|
||||||
|
|
||||||
<?php foreach ($homeworkHeaders as $homeworkIndex): ?>
|
<?php foreach ($homeworkHeaders as $homeworkIndex): ?>
|
||||||
<th class="text-center" data-index="<?= esc($homeworkIndex) ?>"><?= esc("Homework " . $homeworkIndex) ?></th>
|
<th class="text-center homework-score-col" data-index="<?= esc($homeworkIndex) ?>"><?= esc("Homework " . $homeworkIndex) ?></th>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($students as $index => $student): ?>
|
<?php foreach ($students as $index => $student): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><?= $index + 1 ?></td>
|
<td class="homework-rownum-col"><?= $index + 1 ?></td>
|
||||||
|
|
||||||
<td style="text-align: left;">
|
<td class="homework-student-name-col" style="text-align: left;">
|
||||||
<?= esc($student['firstname'] . ' ' . $student['lastname']) ?>
|
<?= esc($student['firstname'] . ' ' . $student['lastname']) ?>
|
||||||
<input type="hidden" class="student-id" value="<?= $student['student_id'] ?>">
|
<input type="hidden" class="student-id" value="<?= $student['student_id'] ?>">
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<?php foreach ($homeworkHeaders as $homeworkIndex): ?>
|
<?php foreach ($homeworkHeaders as $homeworkIndex): ?>
|
||||||
<td>
|
<td class="homework-score-col">
|
||||||
<?php
|
<?php
|
||||||
$rawScore = $student['scores'][$homeworkIndex] ?? null;
|
$rawScore = $student['scores'][$homeworkIndex] ?? null;
|
||||||
$isEmptyScore = ($rawScore === null || $rawScore === '');
|
$isEmptyScore = ($rawScore === null || $rawScore === '');
|
||||||
@@ -86,90 +165,26 @@
|
|||||||
<?= $this->endSection() ?>
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
<?= $this->section('scripts') ?>
|
<?= $this->section('scripts') ?>
|
||||||
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.10/css/jquery.dataTables.min.css">
|
|
||||||
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.10/css/dataTables.bootstrap5.min.css">
|
|
||||||
<script src="https://cdn.datatables.net/1.13.10/js/jquery.dataTables.min.js"></script>
|
|
||||||
<script src="https://cdn.datatables.net/1.13.10/js/dataTables.bootstrap5.min.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const $table = window.jQuery ? window.jQuery('#homeworkTable') : null;
|
const table = document.getElementById('homeworkTable');
|
||||||
|
if (!table) return;
|
||||||
// Numeric ordering based on input values
|
|
||||||
if (window.jQuery && jQuery.fn && jQuery.fn.dataTable && !jQuery.fn.dataTable.ext.order['dom-num-input']) {
|
|
||||||
jQuery.fn.dataTable.ext.order['dom-num-input'] = function (settings, col) {
|
|
||||||
return this.api()
|
|
||||||
.column(col, { order: 'index' })
|
|
||||||
.nodes()
|
|
||||||
.map(function (td) {
|
|
||||||
const val = jQuery('input', td).val();
|
|
||||||
const num = parseFloat(val);
|
|
||||||
return Number.isFinite(num) ? num : -Infinity;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncHeaderTitles() {
|
function syncHeaderTitles() {
|
||||||
document.querySelectorAll('thead th[data-index]').forEach((th) => {
|
table.querySelectorAll('thead th[data-index]').forEach((th) => {
|
||||||
const idx = th.dataset.index;
|
const idx = th.dataset.index;
|
||||||
if (!idx) return;
|
if (!idx) return;
|
||||||
const text = th.textContent.trim();
|
const text = th.textContent.trim();
|
||||||
if (!/^Homework\\s+\\d+$/i.test(text)) {
|
if (!/^Homework\s+\d+$/i.test(text)) {
|
||||||
th.textContent = "Homework " + idx;
|
th.textContent = 'Homework ' + idx;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function initHomeworkTable() {
|
|
||||||
if (!(window.jQuery && jQuery.fn && jQuery.fn.DataTable && $table && $table.length)) return;
|
|
||||||
|
|
||||||
const totalCols = $table.find('thead th').length;
|
|
||||||
const scoreCols = [];
|
|
||||||
for (let i = 2; i < totalCols; i++) scoreCols.push(i);
|
|
||||||
|
|
||||||
$table.DataTable({
|
|
||||||
paging: false,
|
|
||||||
info: false,
|
|
||||||
searching: false,
|
|
||||||
autoWidth: false,
|
|
||||||
order: [[1, 'asc']],
|
|
||||||
columnDefs: [
|
|
||||||
{ targets: 0, orderable: false, searchable: false },
|
|
||||||
...(scoreCols.length ? [{ targets: scoreCols, orderDataType: 'dom-num-input', orderSequence: ['desc', 'asc'], type: 'num' }] : []),
|
|
||||||
],
|
|
||||||
drawCallback: function () {
|
|
||||||
const api = this.api();
|
|
||||||
api.column(0, { search: 'applied', order: 'applied' }).nodes().each(function (cell, i) {
|
|
||||||
cell.textContent = i + 1;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
syncHeaderTitles();
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshDataTable() {
|
|
||||||
if (!(window.jQuery && jQuery.fn && jQuery.fn.DataTable && $table && $table.length)) return;
|
|
||||||
if (jQuery.fn.DataTable.isDataTable($table)) {
|
|
||||||
$table.DataTable().destroy();
|
|
||||||
}
|
|
||||||
initHomeworkTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
function destroyDataTable() {
|
|
||||||
if (!(window.jQuery && jQuery.fn && jQuery.fn.DataTable && $table && $table.length)) return;
|
|
||||||
if (jQuery.fn.DataTable.isDataTable($table)) {
|
|
||||||
$table.DataTable().destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
syncHeaderTitles();
|
|
||||||
initHomeworkTable();
|
|
||||||
const toggleEmptyClass = (input) => {
|
const toggleEmptyClass = (input) => {
|
||||||
if (input.value === '' || input.value === null) {
|
input.classList.toggle('score-empty', input.value === '' || input.value === null);
|
||||||
input.classList.add('score-empty');
|
|
||||||
} else {
|
|
||||||
input.classList.remove('score-empty');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleMissingCheck = (input) => {
|
const toggleMissingCheck = (input) => {
|
||||||
const label = input.parentElement ? input.parentElement.querySelector('.missing-check') : null;
|
const label = input.parentElement ? input.parentElement.querySelector('.missing-check') : null;
|
||||||
if (!label) return;
|
if (!label) return;
|
||||||
@@ -182,7 +197,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const attachInputListeners = () => {
|
const attachInputListeners = () => {
|
||||||
document.querySelectorAll('input[type="number"]').forEach((input) => {
|
table.querySelectorAll('input[type="number"]').forEach((input) => {
|
||||||
toggleEmptyClass(input);
|
toggleEmptyClass(input);
|
||||||
toggleMissingCheck(input);
|
toggleMissingCheck(input);
|
||||||
if (!input.dataset.listenerAttached) {
|
if (!input.dataset.listenerAttached) {
|
||||||
@@ -194,64 +209,52 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
attachInputListeners();
|
|
||||||
|
|
||||||
// Initialize counter from current highest Homework index
|
|
||||||
let homeworkCounter = getMaxHomeworkIndex() + 1;
|
|
||||||
|
|
||||||
function getMaxHomeworkIndex() {
|
function getMaxHomeworkIndex() {
|
||||||
const headers = document.querySelectorAll('thead th');
|
|
||||||
let maxIndex = 0;
|
let maxIndex = 0;
|
||||||
headers.forEach(th => {
|
table.querySelectorAll('thead th').forEach(th => {
|
||||||
const dataIndex = th.dataset.index;
|
const dataIndex = th.dataset.index;
|
||||||
if (dataIndex && !isNaN(parseInt(dataIndex, 10))) {
|
if (dataIndex && !Number.isNaN(parseInt(dataIndex, 10))) {
|
||||||
maxIndex = Math.max(maxIndex, parseInt(dataIndex, 10));
|
maxIndex = Math.max(maxIndex, parseInt(dataIndex, 10));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const text = th.textContent.trim();
|
const match = th.textContent.trim().match(/^Homework\s+(\d+)$/i);
|
||||||
const match = text.match(/^Homework\s+(\d+)$/i);
|
|
||||||
if (match) {
|
if (match) {
|
||||||
const index = parseInt(match[1], 10);
|
maxIndex = Math.max(maxIndex, parseInt(match[1], 10));
|
||||||
if (!isNaN(index)) {
|
|
||||||
maxIndex = Math.max(maxIndex, index);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return maxIndex;
|
return maxIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncHeaderTitles();
|
||||||
|
attachInputListeners();
|
||||||
|
|
||||||
|
let homeworkCounter = getMaxHomeworkIndex() + 1;
|
||||||
const addBtn = document.getElementById('addColumnBtn');
|
const addBtn = document.getElementById('addColumnBtn');
|
||||||
const removeBtn = document.getElementById('removeColumnBtn');
|
const removeBtn = document.getElementById('removeColumnBtn');
|
||||||
|
|
||||||
// ➕ Add Column
|
addBtn?.addEventListener('click', function(e) {
|
||||||
addBtn.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
destroyDataTable();
|
|
||||||
const newIndex = homeworkCounter++;
|
const newIndex = homeworkCounter++;
|
||||||
|
|
||||||
const headerRow = document.querySelector('thead tr');
|
const headerRow = table.querySelector('thead tr');
|
||||||
const newTh = document.createElement('th');
|
const newTh = document.createElement('th');
|
||||||
newTh.textContent = "Homework " + newIndex;
|
newTh.textContent = 'Homework ' + newIndex;
|
||||||
newTh.classList.add(`homework-col-${newIndex}`, 'homework-header', 'dynamic-col', 'text-center');
|
newTh.classList.add(`homework-col-${newIndex}`, 'homework-header', 'dynamic-col', 'text-center');
|
||||||
newTh.dataset.index = newIndex;
|
newTh.dataset.index = newIndex;
|
||||||
headerRow.appendChild(newTh);
|
headerRow.appendChild(newTh);
|
||||||
|
|
||||||
const rows = document.querySelectorAll('tbody tr');
|
table.querySelectorAll('tbody tr').forEach(row => {
|
||||||
rows.forEach(row => {
|
|
||||||
const studentIdInput = row.querySelector('.student-id');
|
const studentIdInput = row.querySelector('.student-id');
|
||||||
const studentId = studentIdInput ? studentIdInput.value : null;
|
const studentId = studentIdInput ? studentIdInput.value : null;
|
||||||
|
|
||||||
if (!studentId) {
|
|
||||||
console.warn("Missing student ID in row", row);
|
|
||||||
const newTd = document.createElement('td');
|
const newTd = document.createElement('td');
|
||||||
newTd.classList.add(`homework-col-${newIndex}`, 'dynamic-col');
|
newTd.classList.add(`homework-col-${newIndex}`, 'dynamic-col');
|
||||||
|
|
||||||
|
if (!studentId) {
|
||||||
row.appendChild(newTd);
|
row.appendChild(newTd);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newTd = document.createElement('td');
|
|
||||||
newTd.classList.add(`homework-col-${newIndex}`, 'dynamic-col');
|
|
||||||
newTd.innerHTML = `
|
newTd.innerHTML = `
|
||||||
<input type="number"
|
<input type="number"
|
||||||
name="scores[${studentId}][${newIndex}]"
|
name="scores[${studentId}][${newIndex}]"
|
||||||
@@ -269,17 +272,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
attachInputListeners();
|
attachInputListeners();
|
||||||
initHomeworkTable();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ❌ Remove Last Column
|
removeBtn?.addEventListener('click', function(e) {
|
||||||
removeBtn.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const dynamicHeaders = table.querySelectorAll('th.dynamic-col');
|
||||||
destroyDataTable();
|
|
||||||
const dynamicHeaders = document.querySelectorAll('th.dynamic-col');
|
|
||||||
if (dynamicHeaders.length === 0) {
|
if (dynamicHeaders.length === 0) {
|
||||||
alert("No dynamic columns to remove.");
|
alert('No dynamic columns to remove.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,30 +286,12 @@
|
|||||||
const index = lastHeader.dataset.index;
|
const index = lastHeader.dataset.index;
|
||||||
lastHeader.remove();
|
lastHeader.remove();
|
||||||
|
|
||||||
const rows = document.querySelectorAll('tbody tr');
|
table.querySelectorAll('tbody tr').forEach(row => {
|
||||||
rows.forEach(row => {
|
row.querySelector(`.homework-col-${index}.dynamic-col`)?.remove();
|
||||||
const cell = row.querySelector(`.homework-col-${index}.dynamic-col`);
|
|
||||||
if (cell) {
|
|
||||||
cell.remove();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ✅ Sync the counter with the current highest index
|
|
||||||
homeworkCounter = getMaxHomeworkIndex() + 1;
|
homeworkCounter = getMaxHomeworkIndex() + 1;
|
||||||
initHomeworkTable();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<style>
|
|
||||||
.score-empty {
|
|
||||||
background-color: #fff3cd;
|
|
||||||
}
|
|
||||||
.missing-check {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<?= $this->endSection() ?>
|
<?= $this->endSection() ?>
|
||||||
|
|||||||
@@ -67,12 +67,38 @@
|
|||||||
<label for="selectAllSecond<?= $index ?>" class="form-check-label small">All</label>
|
<label for="selectAllSecond<?= $index ?>" class="form-check-label small">All</label>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
|
<th>Delivery</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($classSection['parents'] as $rIdx => $p): ?>
|
<?php foreach ($classSection['parents'] as $rIdx => $p): ?>
|
||||||
<?php $formId = 'mform-' . $index . '-' . $rIdx; ?>
|
<?php $formId = 'mform-' . $index . '-' . $rIdx; ?>
|
||||||
|
<?php
|
||||||
|
$deliveryStatus = strtolower((string)($p['delivery_status'] ?? 'not_sent'));
|
||||||
|
$deliveryLabel = 'Not sent';
|
||||||
|
$deliveryClass = 'bg-secondary';
|
||||||
|
if ($deliveryStatus === 'sent') {
|
||||||
|
$deliveryLabel = 'Sent';
|
||||||
|
$deliveryClass = 'bg-success';
|
||||||
|
} elseif ($deliveryStatus === 'failed') {
|
||||||
|
$deliveryLabel = 'Failed';
|
||||||
|
$deliveryClass = 'bg-danger';
|
||||||
|
}
|
||||||
|
$deliveryTitleParts = [];
|
||||||
|
if (!empty($p['delivery_sent_at'])) {
|
||||||
|
$deliveryTitleParts[] = 'Last attempt: ' . $p['delivery_sent_at'];
|
||||||
|
}
|
||||||
|
if (!empty($p['delivery_error'])) {
|
||||||
|
$deliveryTitleParts[] = 'Error: ' . $p['delivery_error'];
|
||||||
|
}
|
||||||
|
if (!empty($p['delivery_class_specific']) && $deliveryStatus !== 'not_sent') {
|
||||||
|
$deliveryTitleParts[] = 'Class-specific log';
|
||||||
|
} elseif ($deliveryStatus !== 'not_sent') {
|
||||||
|
$deliveryTitleParts[] = 'Parent-level log';
|
||||||
|
}
|
||||||
|
$deliveryTitle = implode(' | ', $deliveryTitleParts);
|
||||||
|
?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><?= esc($p['primary_name']) ?></td>
|
<td><?= esc($p['primary_name']) ?></td>
|
||||||
<td>
|
<td>
|
||||||
@@ -133,6 +159,14 @@
|
|||||||
<span class="text-muted">—</span>
|
<span class="text-muted">—</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span class="badge <?= esc($deliveryClass) ?>" title="<?= esc($deliveryTitle) ?>">
|
||||||
|
<?= esc($deliveryLabel) ?>
|
||||||
|
</span>
|
||||||
|
<?php if (!empty($p['delivery_sent_at'])): ?>
|
||||||
|
<div class="small text-muted"><?= esc($p['delivery_sent_at']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<form id="<?= $formId ?>" method="post" action="<?= site_url('whatsapp/update-membership') ?>" class="d-inline wa-membership-form">
|
<form id="<?= $formId ?>" method="post" action="<?= site_url('whatsapp/update-membership') ?>" class="d-inline wa-membership-form">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
- Islamic Studies - Student Workbook - Level 8: **$5.00**
|
||||||
|
- Islamic Studies - Student Workbook - Level 7: **$5.00**
|
||||||
|
- Islamic Studies - Student Workbook - Level 6: **$5.00**
|
||||||
|
- Islamic Studies - Student Workbook - Level 5: **$5.00**
|
||||||
|
- Islamic Studies - Student Workbook - Level 4: **$5.00**
|
||||||
|
- Islamic Studies - Student Workbook - Level 3: **$5.00**
|
||||||
|
- Islamic Studies - Student Workbook - Level 2: **$5.00**
|
||||||
|
- Islamic Studies - Student Workbook - Level 1: **$5.00**
|
||||||
|
- Arabic Writing Workbook: **$11.00**
|
||||||
|
- Beginners Arabic Reading: **$6.00**
|
||||||
|
- Ready to Write Alif Ba Ta: **$11.00**
|
||||||
|
- Teacher's Manual - Level 8: **$20.00**
|
||||||
|
- Teacher's Manual - Level 7: **$20.00**
|
||||||
|
- Teacher's Manual - Level 6: **$20.00**
|
||||||
|
- Teacher's Manual - Level 5: **$20.00**
|
||||||
|
- Teacher's Manual - Level 4: **$20.00**
|
||||||
|
- Teacher's Manual - Level 3: **$20.00**
|
||||||
|
- Teacher's Manual - Level 2: **$20.00**
|
||||||
|
- Teacher's Manual - Level 1: **$20.00**
|
||||||
|
- Juz Tabarak: **$14.00**
|
||||||
|
- Juz Amma Workbook - Vol 2: **$8.00**
|
||||||
|
- Juz Amma Workbook - Vol 1: **$8.00**
|
||||||
|
- Juz Amma Workbook - Vol 1 (B&W version): **$4.00**
|
||||||
|
- Juz Amma for School Students: **$14.00**
|
||||||
|
- Islamic Studies Level 9 (Revised and Enlarged Edition): **$17.00**
|
||||||
|
- Islamic Studies Level 8 (Revised & Enlarged Edition): **$17.00**
|
||||||
|
- Islamic Studies Level 7 (Revised & Enlarged Edition): **$17.00**
|
||||||
|
- Islamic Studies Level 6 (Revised & Enlarged Edition): **$17.00**
|
||||||
|
- Islamic Studies Level 5 (Revised & Enlarged Edition): **$17.00**
|
||||||
|
- Islamic Studies Level 4 (Revised & Enlarged Edition): **$17.00**
|
||||||
|
- Islamic Studies Level 3 (Revised & Enlarged Edition): **$17.00**
|
||||||
|
- Islamic Studies Level 2 (Revised & Enlarged Edition): **$17.00**
|
||||||
|
- Islamic Studies Level 1 (Revised & Enlarged Edition): **$17.00**
|
||||||
|
- Islamic Studies Level K (Revised & Enlarged Edition): **$17.00**
|
||||||
@@ -4,6 +4,7 @@ namespace Tests\App\Models;
|
|||||||
|
|
||||||
use Tests\Support\ModelCrudTestCase;
|
use Tests\Support\ModelCrudTestCase;
|
||||||
use App\Models\StudentModel;
|
use App\Models\StudentModel;
|
||||||
|
use Config\Database;
|
||||||
|
|
||||||
class StudentModelTest extends ModelCrudTestCase
|
class StudentModelTest extends ModelCrudTestCase
|
||||||
{
|
{
|
||||||
@@ -22,4 +23,124 @@ class StudentModelTest extends ModelCrudTestCase
|
|||||||
{
|
{
|
||||||
$this->assertModelCanDelete(StudentModel::class);
|
$this->assertModelCanDelete(StudentModel::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testNewStudentQueryIncludesStudentsWithoutClassAssignment(): void
|
||||||
|
{
|
||||||
|
$db = Database::connect('tests');
|
||||||
|
$schoolYear = $this->validSchoolYear();
|
||||||
|
$parentId = $this->insertParent($db, 'parent-with-new-student@example.test');
|
||||||
|
$newStudentId = $this->insertStudent($db, $parentId, 'Unassigned', 'New');
|
||||||
|
$returningStudentId = $this->insertStudent($db, $parentId, 'Assigned', 'Returning');
|
||||||
|
|
||||||
|
$db->table('student_year_status')->insert([
|
||||||
|
'student_id' => $newStudentId,
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'is_new' => 1,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
$db->table('student_year_status')->insert([
|
||||||
|
'student_id' => $returningStudentId,
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'is_new' => 0,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rows = (new StudentModel())->getStudentsWithParentsAndEmergency($schoolYear, 1);
|
||||||
|
$ids = array_map(static fn (array $row): int => (int) $row['id'], $rows);
|
||||||
|
|
||||||
|
$this->assertContains($newStudentId, $ids);
|
||||||
|
$this->assertNotContains($returningStudentId, $ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEnrollmentWithdrawalRosterExcludesRegisteredOnlyStudents(): void
|
||||||
|
{
|
||||||
|
$db = Database::connect('tests');
|
||||||
|
$schoolYear = $this->validSchoolYear();
|
||||||
|
$parentId = $this->insertParent($db, 'parent-roster-filter@example.test');
|
||||||
|
$registeredOnlyId = $this->insertStudent($db, $parentId, 'Registered', 'Only');
|
||||||
|
$enrolledId = $this->insertStudent($db, $parentId, 'Roster', 'Student');
|
||||||
|
|
||||||
|
$db->table('student_year_status')->insert([
|
||||||
|
'student_id' => $registeredOnlyId,
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'is_new' => 1,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
$db->table('student_year_status')->insert([
|
||||||
|
'student_id' => $enrolledId,
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'is_new' => 1,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
$db->table('enrollments')->insert([
|
||||||
|
'student_id' => $enrolledId,
|
||||||
|
'class_section_id' => null,
|
||||||
|
'parent_id' => $parentId,
|
||||||
|
'enrollment_date' => date('Y-m-d'),
|
||||||
|
'enrollment_status' => 'admission under review',
|
||||||
|
'withdrawal_date' => null,
|
||||||
|
'is_withdrawn' => 0,
|
||||||
|
'admission_status' => 'pending',
|
||||||
|
'semester' => 'Fall',
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rows = (new StudentModel())->getStudentsWithClassAndEnrollment($schoolYear);
|
||||||
|
$ids = array_map(static fn (array $row): int => (int) $row['id'], $rows);
|
||||||
|
|
||||||
|
$this->assertNotContains($registeredOnlyId, $ids);
|
||||||
|
$this->assertContains($enrolledId, $ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function insertParent($db, string $email): int
|
||||||
|
{
|
||||||
|
$db->table('users')->insert([
|
||||||
|
'school_id' => random_int(100000, 999999),
|
||||||
|
'firstname' => 'Test',
|
||||||
|
'lastname' => 'Parent',
|
||||||
|
'gender' => 'Male',
|
||||||
|
'cellphone' => '555-0100',
|
||||||
|
'email' => $email,
|
||||||
|
'address_street' => '1 Test St',
|
||||||
|
'city' => 'Lowell',
|
||||||
|
'state' => 'MA',
|
||||||
|
'zip' => '01852',
|
||||||
|
'accept_school_policy' => 1,
|
||||||
|
'is_verified' => 1,
|
||||||
|
'status' => 'Active',
|
||||||
|
'password' => password_hash('password', PASSWORD_DEFAULT),
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (int) $db->insertID();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function insertStudent($db, int $parentId, string $firstname, string $lastname): int
|
||||||
|
{
|
||||||
|
$db->table('students')->insert([
|
||||||
|
'school_id' => uniqid('STU', false),
|
||||||
|
'firstname' => $firstname,
|
||||||
|
'lastname' => $lastname,
|
||||||
|
'dob' => '2018-01-01',
|
||||||
|
'age' => 8,
|
||||||
|
'gender' => 'Male',
|
||||||
|
'is_active' => 1,
|
||||||
|
'registration_grade' => '1',
|
||||||
|
'is_new' => 1,
|
||||||
|
'photo_consent' => 1,
|
||||||
|
'parent_id' => $parentId,
|
||||||
|
'registration_date' => date('Y-m-d H:i:s'),
|
||||||
|
'tuition_paid' => 0,
|
||||||
|
'year_of_registration' => '2026',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (int) $db->insertID();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\App\Models;
|
||||||
|
|
||||||
|
use App\Models\UserAccessProfileModel;
|
||||||
|
use CodeIgniter\Test\CIUnitTestCase;
|
||||||
|
|
||||||
|
final class UserAccessProfileModelTest extends CIUnitTestCase
|
||||||
|
{
|
||||||
|
public function testTeacherAssistantCountsAsTeacher(): void
|
||||||
|
{
|
||||||
|
$flags = UserAccessProfileModel::flagsForRoles([
|
||||||
|
['name' => 'teacher_assistant', 'slug' => 'teacher_assistant'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertFalse($flags['is_admin']);
|
||||||
|
$this->assertTrue($flags['is_teacher']);
|
||||||
|
$this->assertFalse($flags['is_parent']);
|
||||||
|
$this->assertSame('teacher', UserAccessProfileModel::primaryCategory($flags));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnyNonTeacherStaffRoleCountsAsAdmin(): void
|
||||||
|
{
|
||||||
|
$flags = UserAccessProfileModel::flagsForRoles([
|
||||||
|
['name' => 'head of fa', 'slug' => 'head_of_fa'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertTrue($flags['is_admin']);
|
||||||
|
$this->assertFalse($flags['is_teacher']);
|
||||||
|
$this->assertFalse($flags['is_parent']);
|
||||||
|
$this->assertSame('admin', UserAccessProfileModel::primaryCategory($flags));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMultiRoleUserKeepsAllAccessFlags(): void
|
||||||
|
{
|
||||||
|
$flags = UserAccessProfileModel::flagsForRoles(['parent', 'teacher']);
|
||||||
|
|
||||||
|
$this->assertFalse($flags['is_admin']);
|
||||||
|
$this->assertTrue($flags['is_teacher']);
|
||||||
|
$this->assertTrue($flags['is_parent']);
|
||||||
|
$this->assertSame('teacher', UserAccessProfileModel::primaryCategory($flags));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user