Compare commits

...

7 Commits

Author SHA1 Message Date
root 140be9922d fix registration issue and split parent controller into services
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 49s
Tests / PHPUnit (push) Successful in 1m32s
2026-08-30 20:40:37 -04:00
root 361d0c0d3a Add canonical user access profile table
Create user_access_profiles as a denormalized access index over the existing roles and user_roles tables. Store primary_category plus is_admin, is_teacher, and is_parent flags so permission checks can use a single canonical source for broad user groups.

Classify teacher and teacher_assistant/TA roles as teacher access, parent as parent access, and every other active staff role as admin access. Backfill all existing users during migration and keep profiles synchronized when role assignments are inserted, updated, removed, or replaced.

Create a default access profile whenever a user account is created, then refresh it when roles are assigned. Delete the access profile when a user is deleted so the table does not keep orphaned authorization rows.

Expose the computed access profile in web sessions and auth API responses while preserving the existing detailed roles array and route-filter behavior. Add unit coverage for TA, staff-admin, and multi-role parent/teacher classification.
2026-08-30 20:40:37 -04:00
root 0f8ad86b4f Add canonical user access profile table
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Successful in 1m20s
Create user_access_profiles as a denormalized access index over the existing roles and user_roles tables. Store primary_category plus is_admin, is_teacher, and is_parent flags so permission checks can use a single canonical source for broad user groups.

Classify teacher and teacher_assistant/TA roles as teacher access, parent as parent access, and every other active staff role as admin access. Backfill all existing users during migration and keep profiles synchronized when role assignments are inserted, updated, or deleted.

Expose the computed access profile in web sessions and auth API responses while preserving the existing detailed roles array and route-filter behavior. Add unit coverage for TA, staff-admin, and multi-role parent/teacher classification.
2026-08-29 23:20:40 -04:00
root 48ae2805d3 fix principal access denied
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 49s
Tests / PHPUnit (push) Successful in 1m19s
2026-08-29 22:48:12 -04:00
root 22c4cf6fd2 fixed enrolled after first payment
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Successful in 1m19s
2026-08-29 18:47:18 -04:00
root 6cf3a607a4 fix installment for carry over balance parent account
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Successful in 1m19s
2026-08-29 18:33:28 -04:00
root 611a5c8e4b fix test and add invocie to parent enrollment
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 49s
Tests / PHPUnit (push) Successful in 1m23s
2026-08-29 15:25:17 -04:00
34 changed files with 3945 additions and 1589 deletions
+65 -72
View File
@@ -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) {
+64 -6
View File
@@ -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 !== []) {
+3 -5
View File
@@ -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
+115 -20
View File
@@ -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
); );
@@ -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]);
}
}
+3 -14
View File
@@ -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
)
)
) )
"; ";
} }
+186
View File
@@ -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);
}
}
+30
View File
@@ -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
+4 -2
View File
@@ -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;
+207 -17
View File
@@ -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 !== []): ?>
+1 -1
View File
@@ -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>
+93 -12
View File
@@ -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,
-1
View File
@@ -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);
+16 -7
View File
@@ -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
View File
@@ -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() ?>
+34
View File
@@ -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**
+121
View File
@@ -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));
}
}