Compare commits

..

5 Commits

Author SHA1 Message Date
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
27 changed files with 1511 additions and 337 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->get('administrator/trophy', 'View\TrophyController::index', ['filter' => 'auth:admin']);
$routes->get('administrator/trophy/winners', 'View\TrophyController::winners', ['filter' => 'auth:admin']);
$routes->get('administrator/trophy/final', 'View\TrophyController::final', ['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|principal']);
$routes->get('administrator/trophy/final', 'View\TrophyController::final', ['filter' => 'auth:admin|principal']);
// Certificates
$routes->get('administrator/certificates', 'View\CertificateController::index', ['filter' => 'auth:admin']);
$routes->get('administrator/certificates/csrf-token', 'View\CertificateController::csrfToken', ['filter' => 'auth:admin']);
$routes->post('administrator/certificates/generate', 'View\CertificateController::generate', ['filter' => 'auth:admin']);
$routes->get('administrator/certificates/log', 'View\CertificateController::auditLog', ['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|principal']);
$routes->post('administrator/certificates/generate', 'View\CertificateController::generate', ['filter' => 'auth:admin|principal']);
$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('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('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-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/submit', 'ClassProgressController::create', ['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-file/(:num)', 'ParentProgressController::attachmentFile/$1', ['filter' => 'auth:parent']);
$routes->get('admin/progress', 'AdminProgressController::index', ['filter' => 'auth:admin']);
$routes->get('admin/progress/view/(:num)', 'AdminProgressController::view/$1', ['filter' => 'auth:admin']);
$routes->get('admin/progress/attachment/(:num)', 'AdminProgressController::attachment/$1', ['filter' => 'auth:admin']);
$routes->get('admin/progress/attachment-file/(:num)', 'AdminProgressController::attachmentFile/$1', ['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|principal']);
$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|principal']);
$routes->get('/teacher/calendar', 'View\SchoolCalendarController::calendarTeacherView');
$routes->get('/teacher/absence', 'View\TeacherController::absenceForm', ['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)
$routes->get('/administrator/absence', 'View\AdministratorController::absenceFormAdmin', ['filter' => 'auth:admin']);
$routes->post('/administrator/absence', 'View\AdministratorController::submitAbsenceAdmin', ['filter' => 'auth:admin']);
$routes->get('/administrator/absence', 'View\AdministratorController::absenceFormAdmin', ['filter' => 'auth:admin|principal']);
$routes->post('/administrator/absence', 'View\AdministratorController::submitAbsenceAdmin', ['filter' => 'auth:admin|principal']);
$routes->get('/timeoff/notify/(:segment)', 'TimeOffNotificationController::notify/$1');
$routes->get('/teacher/showupdate_attendance', 'View\AttendanceController::showUpdateAttendanceForm');
$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']);
// Event admin routes
$routes->get('administrator/events', 'View\EventController::index', ['filter' => 'auth:admin']);
$routes->get('administrator/events/create', 'View\EventController::create', ['filter' => 'auth:admin']);
$routes->post('administrator/events/create', 'View\EventController::create', ['filter' => 'auth:admin']);
$routes->match(['get', 'post'], 'administrator/events/edit/(:num)', 'View\EventController::edit/$1', ['filter' => 'auth:admin']);
$routes->post('administrator/events/delete/(:num)', 'View\EventController::delete/$1', ['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|principal']);
$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|principal']);
$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']);
// 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']);
// 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
$routes->get('notifications/active', 'View\NotificationsController::listActive', ['filter' => 'auth:admin']);
$routes->get('api/notifications/active', 'View\NotificationsController::activeNotificationsData', ['filter' => 'auth:admin']);
$routes->get('notifications/deleted', 'View\NotificationsController::listDeleted', ['filter' => 'auth:admin']);
$routes->get('api/notifications/deleted', 'View\NotificationsController::deletedNotificationsData', ['filter' => 'auth:admin']);
$routes->post('notifications/restore/(:num)', 'View\NotificationsController::restore/$1', ['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|principal']);
$routes->get('notifications/deleted', 'View\NotificationsController::listDeleted', ['filter' => 'auth:admin|principal']);
$routes->get('api/notifications/deleted', 'View\NotificationsController::deletedNotificationsData', ['filter' => 'auth:admin|principal']);
$routes->post('notifications/restore/(:num)', 'View\NotificationsController::restore/$1', ['filter' => 'auth:admin|principal']);
//$routes->post('notifications/restore/(:num)', 'View\NotificationsController::restore/$1');
//$routes->get('notifications/mark-read/(:num)', 'View\NotificationsController::markAsRead/$1');
$routes->get('/administrator/notifications_alerts', 'View\AdministratorController::notificationsAlerts', ['filter' => 'auth:admin']);
$routes->post('/administrator/notifications_alerts/save', 'View\AdministratorController::saveNotificationSubjects', ['filter' => 'auth:admin']);
$routes->get('/administrator/print-notifications', 'View\AdministratorController::printNotificationRecipients', ['filter' => 'auth:admin']);
$routes->post('/administrator/print-notifications/save', 'View\AdministratorController::savePrintNotificationRecipients', ['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|principal']);
$routes->get('/administrator/print-notifications', 'View\AdministratorController::printNotificationRecipients', ['filter' => 'auth:admin|principal']);
$routes->post('/administrator/print-notifications/save', 'View\AdministratorController::savePrintNotificationRecipients', ['filter' => 'auth:admin|principal']);
$routes->get(
@@ -782,14 +778,14 @@ $routes->get('administrator/userSearch', 'View\AdministratorController::userSear
$routes->get('/administrator/student_profiles', 'View\AdministratorController::studentProfiles');
$routes->get('/administrator/parent_profiles', 'View\AdministratorController::parentProfiles');
$routes->get('/administrator/teacher-submissions', 'View\AdministratorController::teacherSubmissionsReport', ['filter' => 'auth:admin']);
$routes->post('/administrator/teacher-submissions/notify', 'View\AdministratorController::sendTeacherSubmissionNotifications', ['filter' => 'auth:admin']);
$routes->get('/administrator/exam-drafts', 'View\ExamDraftController::adminIndex', ['filter' => 'auth:admin']);
$routes->post('/administrator/exam-drafts/review', 'View\ExamDraftController::adminReview', ['filter' => 'auth:admin']);
$routes->post('/administrator/exam-drafts/upload-legacy', 'View\ExamDraftController::adminUploadLegacy', ['filter' => 'auth:admin']);
$routes->get('/principal/exam-drafts', 'View\ExamDraftController::principalIndex', ['filter' => 'auth:admin']);
$routes->post('/principal/exam-drafts/review', 'View\ExamDraftController::principalReview', ['filter' => 'auth:admin']);
$routes->post('/principal/exam-drafts/upload-legacy', 'View\ExamDraftController::principalUploadLegacy', ['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|principal']);
$routes->get('/administrator/exam-drafts', 'View\ExamDraftController::adminIndex', ['filter' => 'auth:admin|principal']);
$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|principal']);
$routes->get('/principal/exam-drafts', 'View\ExamDraftController::principalIndex', ['filter' => 'auth:admin|principal']);
$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|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->post('parent/report-attendance', 'View\ParentAttendanceReportController::submit', ['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
$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
$routes->get('attendance/early-dismissals/new', 'View\ParentAttendanceReportController::addEarlyDismissalForm', ['filter' => 'auth:admin']);
$routes->post('attendance/early-dismissals', 'View\ParentAttendanceReportController::saveEarlyDismissal', ['filter' => 'auth:admin']);
$routes->post('attendance/early-dismissals/signature', 'View\ParentAttendanceReportController::uploadEarlyDismissalSignature', ['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|principal']);
$routes->post('attendance/early-dismissals/signature', 'View\ParentAttendanceReportController::uploadEarlyDismissalSignature', ['filter' => 'auth:admin|principal']);
// Parent report: client-side check API
$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('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
$routes->group('api', ['filter' => 'auth:admin'], static function ($routes) {
$routes->group('api', ['filter' => 'auth:admin|principal'], static function ($routes) {
// Families & Guardians API
$routes->get('students/(:num)/families', 'View\FamilyController::familiesByStudent/$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
$routes->match(['get', 'post'], 'bootstrap', 'View\FamilyController::bootstrap'); // protect with auth in production
$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');
});
// 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)
$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('index', 'View\FamilyAdminController::index');
$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');
});
// Convenience alias
$routes->get('family', 'View\FamilyAdminController::index', ['filter' => 'auth:admin']);
$routes->get('family', 'View\FamilyAdminController::index', ['filter' => 'auth:admin|principal']);
//////////////////////////////////////////////////////////
//upload files
@@ -1079,24 +1072,24 @@ $routes->post('reimbursements/update/(:num)', 'View\ReimbursementController::upd
// app/Config/Routes.php
$routes->get('whatsapp/', 'View\WhatsappController::index', ['filter' => 'auth:admin']);
$routes->post('whatsapp/sendInvites', 'View\WhatsappController::sendInvites', ['filter' => 'auth:admin']);
$routes->post('whatsapp/saveLink', 'View\WhatsappController::saveLink', ['filter' => 'auth:admin']);
$routes->get('whatsapp/parent-contacts', 'View\WhatsappController::parentContacts', ['filter' => 'auth:admin']);
$routes->get('whatsapp/parent-contacts-by-class', 'View\WhatsappController::parentContactsByClass', ['filter' => 'auth:admin']);
$routes->get('whatsapp/', 'View\WhatsappController::index', ['filter' => 'auth:admin|principal']);
$routes->post('whatsapp/sendInvites', 'View\WhatsappController::sendInvites', ['filter' => 'auth:admin|principal']);
$routes->post('whatsapp/saveLink', 'View\WhatsappController::saveLink', ['filter' => 'auth:admin|principal']);
$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|principal']);
// 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
$routes->get('admin/teacher-attendance', 'View\AttendanceController::index', ['filter' => 'auth:admin']);
$routes->post('admin/teacher-attendance/save', 'View\AttendanceController::save', ['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|principal']);
// NEW: monthly overview (all sections)
$routes->get('admin/teacher-attendance/month', 'View\AttendanceController::month', ['filter' => 'auth:admin']);
$routes->get('admin/teacher-attendance/month.csv', 'View\AttendanceController::monthCsv', ['filter' => 'auth:admin']);
$routes->get('api/admin/teacher-attendance/month', 'View\AttendanceController::monthData', ['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|principal']);
$routes->get('api/admin/teacher-attendance/month', 'View\AttendanceController::monthData', ['filter' => 'auth:admin|principal']);
$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) ----
$routes->group('api/administrator', ['namespace' => 'App\\Controllers\\Api'], static function ($routes) {
$routes->get('dashboard', 'AdministratorController::dashboard');
$routes->get('absence', 'AdministratorController::absenceInfo', ['filter' => 'auth:admin']);
$routes->post('absence', 'AdministratorController::submitAbsence', ['filter' => 'auth:admin']);
$routes->get('search', 'AdministratorController::search', ['filter' => 'auth:admin']);
$routes->get('enrollment-withdrawal/data', 'AdministratorController::enrollmentWithdrawalData', ['filter' => 'auth:admin']);
$routes->post('enrollment-withdrawal/update', 'AdministratorController::updateEnrollmentStatuses', ['filter' => 'auth:admin']);
$routes->get('students', 'AdministratorController::studentProfiles', ['filter' => 'auth:admin']);
$routes->get('parents', 'AdministratorController::parentProfiles', ['filter' => 'auth:admin']);
$routes->get('absence', 'AdministratorController::absenceInfo', ['filter' => 'auth:admin|principal']);
$routes->post('absence', 'AdministratorController::submitAbsence', ['filter' => 'auth:admin|principal']);
$routes->get('search', 'AdministratorController::search', ['filter' => 'auth:admin|principal']);
$routes->get('enrollment-withdrawal/data', 'AdministratorController::enrollmentWithdrawalData', ['filter' => 'auth:admin|principal']);
$routes->post('enrollment-withdrawal/update', 'AdministratorController::updateEnrollmentStatuses', ['filter' => 'auth:admin|principal']);
$routes->get('students', 'AdministratorController::studentProfiles', ['filter' => 'auth:admin|principal']);
$routes->get('parents', 'AdministratorController::parentProfiles', ['filter' => 'auth:admin|principal']);
});
// ---- Parent API additions (parity with View) ----
$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\UserModel;
use App\Models\UserRoleModel;
use App\Models\UserAccessProfileModel;
use CodeIgniter\Events\Events;
use App\Models\IpAttemptModel;
use App\Models\PasswordResetModel;
@@ -212,6 +213,7 @@ class AuthController extends BaseController
// Fetch roles
$roleNames = $this->getUserRoleNames((int) $user['id']);
$accessProfile = $this->accessProfileForUser((int) $user['id'], $roleNames);
// Build roles map (object with keys per example)
$rolesMap = [];
@@ -248,6 +250,10 @@ class AuthController extends BaseController
'id' => (int) $user['id'],
'name' => $payload['name'],
'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'),
'roles' => $roles,
'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,
'exp' => $exp,
];
$accessProfile = $this->accessProfileForUser((int) $userId, $payload['roles']);
$secret = require_env('JWT_SECRET');
$token = jwt_encode($payload, $secret, 'HS256');
@@ -384,6 +395,10 @@ class AuthController extends BaseController
'name' => $payload['name'],
'email' => $userData['email'],
'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) {
@@ -477,11 +492,17 @@ class AuthController extends BaseController
protected function getUserRoleNames(int $userId): array
{
$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')
->where('user_roles.user_id', $userId)
->get()
->getResultArray();
->where('COALESCE(roles.is_active, 1) = 1', null, false);
if ($db->fieldExists('deleted_at', 'user_roles')) {
$builder->where('user_roles.deleted_at', null);
}
$rolesRows = $builder->get()->getResultArray();
return array_column($rolesRows, 'name');
}
@@ -565,11 +586,17 @@ class AuthController extends BaseController
private function loginUser($user, ?string $redirectTo = null)
{
$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')
->where('user_roles.user_id', $user['id'])
->get()
->getResultArray();
->where('COALESCE(roles.is_active, 1) = 1', null, false);
if ($db->fieldExists('deleted_at', 'user_roles')) {
$rolesBuilder->where('user_roles.deleted_at', null);
}
$roles = $rolesBuilder->get()->getResultArray();
if (empty($roles)) {
log_message('error', 'No roles found for user ID: ' . $user['id']);
@@ -577,6 +604,7 @@ class AuthController extends BaseController
}
$roleNames = array_column($roles, 'name');
$accessProfile = $this->accessProfileForUser((int) $user['id'], $roleNames);
session()->regenerate(true);
session()->set([
@@ -588,6 +616,10 @@ class AuthController extends BaseController
'login_time' => time(),
'last_activity' => time(),
'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,
'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
{
if (!$userId) {
@@ -521,6 +521,7 @@ class AdministratorController extends BaseController
return view('administrator/administratordashboard', array_merge($searchData, [
'dashboardEndpoint' => site_url('api/administrator/dashboard'),
'schoolYear' => $this->schoolYear,
]));
}
@@ -235,6 +235,13 @@ class EnrollmentAdminController extends BaseController
}
$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');
$this->db->transStart();
@@ -349,7 +356,9 @@ class EnrollmentAdminController extends BaseController
$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)));
$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 !== ''));
$nonOverridable = array_values(array_intersect($ruleCodes, ['ADULT_STUDENT_PARENT_BLOCKED']));
if ($nonOverridable !== []) {
+4 -6
View File
@@ -1052,13 +1052,11 @@ class InvoiceController extends ResourceController
$invoiceId = $invoice !== null ? (int) ($invoice['id'] ?? 0) : null;
$invoiceDate = null;
if ($invoice !== null) {
if ($invoice !== null && ! empty($invoice['updated_at'])) {
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$invoiceDate = ! empty($invoice['issue_date'])
? (new \DateTimeImmutable($invoice['issue_date'], new \DateTimeZone('UTC')))
->setTimezone(new \DateTimeZone($tzName))
->format('Y-m-d H:i:s')
: ($invoice['updated_at'] ?? null);
$invoiceDate = (new \DateTimeImmutable($invoice['updated_at'], new \DateTimeZone('UTC')))
->setTimezone(new \DateTimeZone($tzName))
->format('Y-m-d H:i:s');
}
$description = '';
+96 -12
View File
@@ -287,6 +287,15 @@ class ParentController extends BaseController
$previousSchoolYear = $this->previousSchoolYearName($selectedYear);
$fallMakeupExamOn = $this->fallMakeupExamDateForYear($selectedYear);
if ($previousSchoolYear !== null) {
service('enrollmentTransition')->syncParentFinancialReviewFlags(
(int) $parentId,
$previousSchoolYear,
$selectedYear,
array_values(array_map(static fn (array $student): int => (int) ($student['id'] ?? 0), $students))
);
}
// Map enrollment statuses
$statusMap = [
'admission under review' => 'admission under review',
@@ -404,15 +413,6 @@ class ParentController extends BaseController
)));
}
if ($previousSchoolYear !== null) {
service('enrollmentTransition')->syncParentFinancialReviewFlags(
(int) $parentId,
$previousSchoolYear,
$selectedYear,
array_values(array_map(static fn (array $student): int => (int) ($student['id'] ?? 0), $students))
);
}
// Render view
return view('/parent/enroll_classes', [
'students' => $students,
@@ -481,6 +481,8 @@ class ParentController extends BaseController
// Handle enrollments
$studentData = [];
$enrollmentResultMessages = [];
$invoiceResultMessage = null;
$invoiceErrorMessage = null;
if (!empty($enroll)) {
$selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
@@ -713,17 +715,34 @@ class ParentController extends BaseController
}
}
if (!empty($enroll) && empty($withdraw)) {
$invoiceResult = $this->generateInvoiceForParentEnrollment((int) $parentId);
if (! empty($invoiceResult['ok'])) {
$invoiceResultMessage = (string) ($invoiceResult['message'] ?? 'Invoice generated.');
} else {
$invoiceErrorMessage = (string) ($invoiceResult['message'] ?? 'Enrollment was submitted, but the invoice could not be generated.');
}
}
$parentData = $this->userModel->getUserInfoById($parentId);
$parentData['user_id'] = $parentId;
// Redirect to the success page after processing enrollment and withdrawal
if (!empty($withdraw)) {
// Redirect to withdrawal success page if there are withdrawals //parent/enroll_classes
$redirect = redirect()->to('/parent/enroll_classes');
$successParts = [];
$errorParts = [];
if ($withdrawalResultMessages !== []) {
$redirect = $redirect->with('success', 'Withdrawal request submitted. ' . implode(' ', $withdrawalResultMessages));
$successParts[] = 'Withdrawal request submitted. ' . implode(' ', $withdrawalResultMessages);
}
if ($withdrawalErrors !== []) {
$redirect = $redirect->with('error', 'Some withdrawal requests failed. ' . implode(' ', $withdrawalErrors));
$errorParts[] = 'Some withdrawal requests failed. ' . implode(' ', $withdrawalErrors);
}
if ($successParts !== []) {
$redirect = $redirect->with('success', implode(' ', $successParts));
}
if ($errorParts !== []) {
$redirect = $redirect->with('error', implode(' ', $errorParts));
}
return $redirect;
@@ -742,11 +761,76 @@ class ParentController extends BaseController
if ($enrollmentResultMessages !== []) {
$successMessage .= ' ' . implode(' ', $enrollmentResultMessages);
}
if ($invoiceResultMessage !== null) {
$successMessage .= ' ' . $invoiceResultMessage;
}
return redirect()->to('/parent/enroll_success')->with('success', $successMessage);
$redirect = redirect()->to('/parent/enroll_success')->with('success', $successMessage);
if ($invoiceErrorMessage !== null) {
$redirect = $redirect->with('error', $invoiceErrorMessage);
}
return $redirect;
}
}
/**
* Generate or refresh the parent's school-year invoice after parent-submitted enrollment.
*
* The invoice engine is intentionally authoritative for billable statuses. For example,
* first-time students still under admission review may not produce billable lines yet.
*
* @return array{ok: bool, message: string}
*/
private function generateInvoiceForParentEnrollment(int $parentId): array
{
if ($parentId <= 0) {
return ['ok' => false, 'message' => 'Enrollment was submitted, but the parent invoice could not be generated.'];
}
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
$semester = (string) ($this->semester ?? getSemester());
try {
$result = $this->eventController->generateInvoice((string) $parentId, $schoolYear, $semester);
} catch (Throwable $e) {
log_message('error', 'Invoice generation failed after parent enrollment: {message}', [
'message' => $e->getMessage(),
'parentId' => $parentId,
'schoolYear' => $schoolYear,
'semester' => $semester,
]);
return ['ok' => false, 'message' => 'Enrollment was submitted, but the invoice could not be generated. Please contact the school administration.'];
}
if (is_array($result) && ! empty($result['ok'])) {
return [
'ok' => true,
'message' => ! empty($result['updated']) ? 'Invoice updated.' : 'Invoice generated.',
];
}
$message = is_array($result) ? (string) ($result['message'] ?? '') : '';
if ($message === 'Invoice requires at least one non-zero line.') {
log_message('info', 'No invoice generated after parent enrollment because no billable invoice lines exist yet for parent {parentId}, year {schoolYear}.', [
'parentId' => $parentId,
'schoolYear' => $schoolYear,
]);
return ['ok' => true, 'message' => 'No invoice was generated yet because there are no billable enrollment charges.'];
}
log_message('error', 'Invoice generation returned an unsuccessful result after parent enrollment: {result}', [
'result' => json_encode($result),
'parentId' => $parentId,
'schoolYear' => $schoolYear,
'semester' => $semester,
]);
return ['ok' => false, 'message' => 'Enrollment was submitted, but the invoice could not be generated. Please contact the school administration.'];
}
private function enrollmentPayloadFromEvaluation(array $evaluation, array $base): array
{
$payload = array_merge($base, [
+115 -20
View File
@@ -391,9 +391,14 @@ class PaymentController extends ResourceController
if ($parent && !empty($parent['id'])) {
$parentData = $parent;
$parentId = (int) $parent['id'];
$carryForwardPaymentRequired = $this->parentHasActiveCarryForwardBalance($parentId, $manualPaySchoolYear);
if ($carryForwardPaymentRequired) {
$carryForwardPaymentMessage = 'This parent has a balance carried over from a previous school year. Manual payments must be paid in full; installments are not allowed.';
$hasCarryForwardInvoice = $this->parentHasCarryForwardInvoice($parentId, $manualPaySchoolYear);
$hasCurrentYearInstallmentOverride = $this->parentHasCurrentYearInstallmentOverride($parentId, $manualPaySchoolYear);
$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
@@ -433,6 +438,7 @@ class PaymentController extends ResourceController
// Always use the configured end date for installments
$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
$issueYmd = '';
@@ -516,7 +522,7 @@ class PaymentController extends ResourceController
return $this->response->setJSON(['items' => $items]);
}
private function updateEnrollmentStatusIfPaid(int $invoiceId): int
private function updateEnrollmentStatusAfterRecordedPayment(int $invoiceId): int
{
// 1) Fetch invoice
$invoice = $this->invoiceModel->find($invoiceId);
@@ -525,19 +531,19 @@ class PaymentController extends ResourceController
return 0;
}
// 2) Payment check: enrollment transitions only after the invoice is fully paid
$currentBal = $this->getCurrentInvoiceBalance($invoiceId);
if ($currentBal > 0.00001) {
log_message('info', 'Invoice not fully paid. Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]);
// 2) Payment check: any successful payment, including the first installment, enrolls the student.
if ($this->getSuccessfulPaymentCount($invoiceId) < 1) {
log_message('info', 'No successful payment found. Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]);
return 0;
}
$parentId = (int) ($invoice['parent_id'] ?? 0);
$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 === '') {
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;
}
@@ -625,7 +631,7 @@ class PaymentController extends ResourceController
'enrollment_status' => 'enrolled',
'admission_status' => 'accepted',
'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);
@@ -633,7 +639,7 @@ class PaymentController extends ResourceController
log_message(
'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,
'p' => $parentId,
@@ -646,7 +652,7 @@ class PaymentController extends ResourceController
return $affected;
} catch (\Throwable $e) {
$db->transRollback();
log_message('error', 'updateEnrollmentStatusIfPaid error: ' . $e->getMessage());
log_message('error', 'updateEnrollmentStatusAfterRecordedPayment error: ' . $e->getMessage());
return 0;
}
}
@@ -956,7 +962,7 @@ class PaymentController extends ResourceController
try {
// Lock invoice & get context (also ensure totals are up-to-date before validation)
$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]
)->getRowArray();
@@ -972,7 +978,10 @@ class PaymentController extends ResourceController
// Recompute invoice totals from tuition + events + additional charges
$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') {
$this->db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
@@ -1061,11 +1070,18 @@ class PaymentController extends ResourceController
// Post-payment balance from snapshot
$postBalance = (float) ($ledger['balance'] ?? max(0.0, round($initialPreBalance - $amount, 2)));
$this->syncEnrollmentFinanceAfterPayment($parentId, $invYear);
// Optional enrollment update
$enrollmentupdated = $this->updateEnrollmentStatusIfPaid($invoiceId);
$enrollmentupdated = $this->updateEnrollmentStatusAfterRecordedPayment($invoiceId);
if ($enrollmentupdated != 0) {
$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);
}
@@ -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')) {
return false;
}
$builder = $this->db->table('invoices')
->where('parent_id', $parentId)
->where('balance >', 0);
->where('parent_id', $parentId);
if ($schoolYear !== null && $schoolYear !== '') {
$builder->where('school_year', $schoolYear);
@@ -1290,6 +1305,7 @@ class PaymentController extends ResourceController
$builder
->orLike('description', 'carried over')
->orLike('description', 'carry-forward')
->orLike('description', 'carry over')
->orLike('description', 'previous school year');
}
@@ -1298,6 +1314,85 @@ class PaymentController extends ResourceController
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
@@ -651,6 +651,13 @@ class WhatsappController extends BaseController
// 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
try {
$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).
* Accepts fields:
@@ -110,7 +110,7 @@ final class AlignSchemaToScoolViewDump extends Migration
$this->ensureIndex(
'whatsapp_group_links',
'uq_section_term',
['class_section_id'],
['class_section_id', 'school_year'],
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 -2
View File
@@ -168,10 +168,11 @@ class StudentModel extends Model
->join('emergency_contacts ec', 'ec.parent_id = students.parent_id', 'left');
if ($useYearScopedIsNew) {
$statusJoinType = ($filterByYear || $isNew === 0 || $isNew === 1) ? 'inner' : 'left';
$builder->join(
'student_year_status sys',
'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);
}
if ($filterByYear) {
if ($filterByYear && ! $useYearScopedIsNew) {
$builder
->join('student_class sc_filter', 'sc_filter.student_id = students.id', 'inner')
->where('sc_filter.school_year', $schoolYear);
+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);
}
}
+4 -2
View File
@@ -33,9 +33,10 @@ class UserRoleModel extends Model
$userId = (int) ($data['data']['user_id'] ?? 0);
if ($userId > 0) {
service('staffDirectorySync')->syncUser($userId);
model(UserAccessProfileModel::class)->syncUser($userId);
}
} 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;
@@ -45,8 +46,9 @@ class UserRoleModel extends Model
{
try {
service('staffDirectorySync')->syncAll();
model(UserAccessProfileModel::class)->syncAll();
} 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;
+207 -17
View File
@@ -34,14 +34,13 @@ public function metrics(string $schoolYear, string $semester): array
$totalAdmins = (int) ($this->userModel->countAdminsBySchoolYear($this->schoolYear) ?? 0);
$teachers = $this->userModel->getUsersByRoleAndSchoolYear('teacher', $this->schoolYear);
$teachers = $this->userModel->getUsersByRole('teacher');
$totalTeachers = $this->countUniqueEntities($teachers);
$teacherAssistants = $this->userModel->getUsersByRoleAndSchoolYear('teacher_assistant', $this->schoolYear);
$teacherAssistants = $this->userModel->getUsersByRole('teacher_assistant');
$totalTeacherAssistants = $this->countUniqueEntities($teacherAssistants);
$parents = $this->userModel->getUsersByRoleAndSchoolYear('parent', $this->schoolYear);
$totalParents = $this->countUniqueEntities($parents);
$totalParents = $this->countParentsWithEnrolledStudents($this->schoolYear);
// Count only students that have a class assigned and exist in student_class for the current school year
$totalStudents = (int) (
@@ -103,6 +102,36 @@ private function countUniqueEntities($rows): int
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
{
$q = trim($query);
@@ -122,6 +151,15 @@ public function search(string $query): array
// 1) Tokenize input: split by whitespace and punctuation, keep meaningful pieces
$rawTokens = preg_split('/[,\s]+/u', $q, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$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
$phoneMap = []; // token => variants[]
@@ -229,22 +267,174 @@ public function search(string $query): array
$applyMultiTokenLike($ecQB, $ecCols, $tokens, ['cellphone']);
$emergency = $ecQB->limit(150)->get()->getResultArray();
$raw = [
'users' => $users,
'students' => $students,
'parents' => $parents,
'staff' => $staff,
'emergency_contacts' => $emergency,
];
$total = count($users) + count($students) + count($parents) + count($staff) + count($emergency);
$results = $this->mergeSearchResults($users, $students, $parents, $staff, $emergency);
return [
'query' => $q,
'results' => $raw,
'scope_used' => 'unscoped-raw',
'scope_label' => 'all years/semesters (raw, tokenized)',
'total_found' => $total,
'results' => $results,
'scope_used' => 'unscoped-merged',
'scope_label' => 'all years/semesters (merged, tokenized)',
'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);
}
}
@@ -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 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 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 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.';
@@ -161,6 +161,7 @@ if (!function_exists('enrollment_admin_rule_label')) {
'SIBLING_LAST_NAME_MISMATCH' => 'Sibling last names do not match',
'OUTSTANDING_BALANCE_BLOCKED' => 'Previous-year balance must be paid',
'FINANCE_APPROVAL_REQUIRED' => 'Finance approval required',
'CURRENT_YEAR_INSTALLMENT_OVERRIDE' => 'Allow installments for new-year balance only',
'AGE_RULE_BLOCKED' => 'Age rule not met',
'REGISTRATION_CLOSED' => 'Registration is closed',
'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">
<?= csrf_field() ?>
<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>
<button class="btn btn-sm btn-warning" type="submit">Approve exception</button>
</form>
@@ -516,17 +525,13 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes :
$warningCodes = array_values(array_filter(array_map('strval', $evaluation['warning_rule_codes'] ?? [])));
$failedCodes = array_values(array_unique(array_merge($blockingCodes, $reviewCodes)));
$overridableCodes = array_values(array_diff($failedCodes, $nonOverridableCodes));
$hasGrantableStudent = $hasGrantableStudent || $overridableCodes !== [];
$hasGrantableStudent = true;
$decision = (string) ($evaluation['decision'] ?? '');
$canEnroll = !empty($evaluation['can_enroll']);
?>
<tr>
<td>
<?php if ($overridableCodes !== []): ?>
<input class="form-check-input" type="checkbox" name="student_ids[]" value="<?= $studentId ?>">
<?php else: ?>
<span class="text-muted">-</span>
<?php endif; ?>
<input class="form-check-input" type="checkbox" name="student_ids[]" value="<?= $studentId ?>">
</td>
<td>
<?= esc($studentPreview['student_name'] ?? '') ?>
@@ -550,9 +555,13 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes :
</label>
</div>
<?php endforeach; ?>
<?php else: ?>
<span class="text-muted small">None</span>
<?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>
<?php if ($warningCodes !== []): ?>
+1 -1
View File
@@ -156,7 +156,7 @@
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="5" class="text-center">No students found.</td>
<td colspan="8" class="text-center">No students found.</td>
</tr>
<?php endif; ?>
</tbody>
+93 -12
View File
@@ -1,4 +1,81 @@
<?= $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') ?>
<?php
@@ -37,13 +114,14 @@ $lockAttr = $scoresLocked ? 'disabled' : '';
<input type="hidden" name="school_year" value="<?= esc($schoolYear) ?>">
<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">
<tr>
<th>#</th>
<th>School ID</th>
<th>First Name</th>
<th>Last Name</th>
<th class="homework-rownum-col">#</th>
<th class="homework-school-id-col">School ID</th>
<th class="homework-first-name-col">First Name</th>
<th class="homework-last-name-col">Last Name</th>
<?php foreach ($homeworkHeaders as $index): ?>
<th class="text-center"><?= esc("Homework " . $index) ?></th>
<?php endforeach; ?>
@@ -59,15 +137,15 @@ $lockAttr = $scoresLocked ? 'disabled' : '';
$rowLockAttr = $scoresLocked ? $lockAttr : ($rowLocked ? 'readonly aria-disabled="true"' : '');
?>
<tr class="<?= $rowLocked ? 'table-secondary text-muted' : '' ?>">
<td><?= $row++ ?></td>
<td><?= esc($student['school_id']) ?></td>
<td>
<td class="homework-rownum-col"><?= $row++ ?></td>
<td class="homework-school-id-col"><?= esc($student['school_id']) ?></td>
<td class="homework-first-name-col">
<a href="#" class="text-decoration-none" data-family-student-id="<?= (int)$studentId ?>">
<?= esc($student['firstname']) ?>
</a>
<?= student_enrollment_status_button($student, $schoolYear ?? null) ?>
</td>
<td>
<td class="homework-last-name-col">
<a href="#" class="text-decoration-none" data-family-student-id="<?= (int)$studentId ?>">
<?= esc($student['lastname']) ?>
</a>
@@ -89,6 +167,7 @@ $lockAttr = $scoresLocked ? 'disabled' : '';
</tbody>
</table>
</div>
<div class="d-flex justify-content-between mt-4 flex-wrap gap-2">
<button type="submit" class="btn btn-success" <?= $lockAttr ?>>
@@ -144,9 +223,11 @@ $lockAttr = $scoresLocked ? 'disabled' : '';
autoWidth: false,
order: [[2, 'asc'], [3, 'asc']],
columnDefs: [
{ targets: 0, orderable: false, searchable: false },
{ targets: 1, className: 'text-nowrap' },
...(scoreCols.length ? [{ targets: scoreCols, orderDataType: 'dom-num-input', orderSequence: ['desc', 'asc'], type: 'num' }] : []),
{ targets: 0, orderable: false, searchable: false, width: '56px' },
{ targets: 1, className: 'text-nowrap', width: '112px' },
{ targets: 2, width: '168px' },
{ targets: 3, width: '168px' },
...(scoreCols.length ? [{ targets: scoreCols, width: '132px', orderDataType: 'dom-num-input', orderSequence: ['desc', 'asc'], type: 'num' }] : []),
],
fixedHeader: {
header: true,
-1
View File
@@ -594,7 +594,6 @@
</div>
</div>
<!-- About Start - Our Mission Section -->
<div class="container-xxl py-2 content-section">
<div class="container">
@@ -147,7 +147,7 @@
}
function renderInvoiceRow(r) {
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
? '<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>`;
@@ -165,11 +165,12 @@
genBtn,
fmtMoney(r.invoice_amount),
renderRefundCell(r),
`<span data-order=\"${ts}\">${formatDateTime(ts)}</span>`,
`<span data-order=\"${Number.isNaN(ts) ? 0 : ts}\">${Number.isNaN(ts) ? '' : formatDateTime(ts)}</span>`,
pdf,
];
}
async function generateInvoice(parentId) {
const body = new URLSearchParams();
body.append('parent_id', parentId);
+16 -7
View File
@@ -423,6 +423,7 @@
data-display-total="<?= esc($invoice['display_total'] ?? $invoice['total_amount'] ?? '') ?>"
data-balance-due-cents="<?= (int)($invoice['balance_due_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) ?>">
<?php
$uiPaid = (float)($invoice['paid_amount'] ?? 0);
@@ -634,6 +635,11 @@
return isFinite(v) ? v : 1;
}
function selectedInvoiceRequiresCarryForwardFull() {
const opt = currentOpt();
return carryForwardFullRequired || (opt && opt.getAttribute('data-carry-forward-invoice') === '1');
}
function monthsUntil(endYmd) {
if (!endYmd) return 0;
const today = new Date();
@@ -788,12 +794,15 @@
if (m === 'card') {
forceCardRules();
} else if (carryForwardFullRequired) {
} else if (selectedInvoiceRequiresCarryForwardFull()) {
forceCarryForwardFullRules();
} else {
// enable full/installment select
$type().removeAttribute('disabled');
$type().value = 'full';
const type = $type();
type.removeAttribute('disabled');
const installmentOption = type.querySelector('option[value="installment"]');
if (installmentOption) installmentOption.disabled = false;
type.value = 'full';
$instSec().style.display = 'none';
$instSeqRow().style.display = 'none';
$amount().removeAttribute('readonly');
@@ -810,7 +819,7 @@
forceCardRules();
return;
}
if (carryForwardFullRequired) {
if (selectedInvoiceRequiresCarryForwardFull()) {
forceCarryForwardFullRules();
return;
}
@@ -834,7 +843,7 @@
if (($method().value || '').toLowerCase() === 'card') {
forceCardRules();
} else if (carryForwardFullRequired) {
} else if (selectedInvoiceRequiresCarryForwardFull()) {
forceCarryForwardFullRules();
} else {
updateAmountHint();
@@ -908,7 +917,7 @@
alert('Please enter a valid amount > 0.');
return;
}
if (carryForwardFullRequired && type === 'installment') {
if (selectedInvoiceRequiresCarryForwardFull() && type === 'installment') {
alert(carryForwardFullMessage);
return;
}
@@ -924,7 +933,7 @@
const newBal = (isFinite(balance) && isFinite(amount)) ? (balance - amount) : NaN;
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);
$amount().value = Math.max(0, balance).toFixed(2);
return;
+127 -146
View File
@@ -1,4 +1,77 @@
<?= $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') ?>
<div class="container-fluid py-5">
<div class="container-fluid">
@@ -16,36 +89,42 @@
return ($value === null || $value === '') ? '' : esc($value);
};
$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">
<?= csrf_field() ?>
<input type="hidden" name="semester" value="<?= esc($semester) ?>">
<input type="hidden" name="school_year" value="<?= esc($schoolYear) ?>">
<div class="table-responsive">
<table id="homeworkTable" class="table table-bordered mt-4 w-100">
<div class="homework-table-scroll">
<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>
<tr>
<th>#</th>
<th style="text-align: left;">Student Name</th>
<th class="homework-rownum-col">#</th>
<th class="homework-student-name-col" style="text-align: left;">Student Name</th>
<?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; ?>
</tr>
</thead>
<tbody>
<?php foreach ($students as $index => $student): ?>
<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']) ?>
<input type="hidden" class="student-id" value="<?= $student['student_id'] ?>">
</td>
<?php foreach ($homeworkHeaders as $homeworkIndex): ?>
<td>
<td class="homework-score-col">
<?php
$rawScore = $student['scores'][$homeworkIndex] ?? null;
$isEmptyScore = ($rawScore === null || $rawScore === '');
@@ -70,7 +149,7 @@
</tr>
<?php endforeach; ?>
</tbody>
</table>
</table>
</div>
<div class="d-flex justify-content-between mt-3">
@@ -86,90 +165,26 @@
<?= $this->endSection() ?>
<?= $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>
document.addEventListener('DOMContentLoaded', function() {
const $table = window.jQuery ? window.jQuery('#homeworkTable') : null;
// 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;
});
};
}
const table = document.getElementById('homeworkTable');
if (!table) return;
function syncHeaderTitles() {
document.querySelectorAll('thead th[data-index]').forEach((th) => {
table.querySelectorAll('thead th[data-index]').forEach((th) => {
const idx = th.dataset.index;
if (!idx) return;
const text = th.textContent.trim();
if (!/^Homework\\s+\\d+$/i.test(text)) {
th.textContent = "Homework " + idx;
if (!/^Homework\s+\d+$/i.test(text)) {
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) => {
if (input.value === '' || input.value === null) {
input.classList.add('score-empty');
} else {
input.classList.remove('score-empty');
}
input.classList.toggle('score-empty', input.value === '' || input.value === null);
};
const toggleMissingCheck = (input) => {
const label = input.parentElement ? input.parentElement.querySelector('.missing-check') : null;
if (!label) return;
@@ -182,7 +197,7 @@
};
const attachInputListeners = () => {
document.querySelectorAll('input[type="number"]').forEach((input) => {
table.querySelectorAll('input[type="number"]').forEach((input) => {
toggleEmptyClass(input);
toggleMissingCheck(input);
if (!input.dataset.listenerAttached) {
@@ -194,92 +209,76 @@
}
});
};
attachInputListeners();
// Initialize counter from current highest Homework index
let homeworkCounter = getMaxHomeworkIndex() + 1;
function getMaxHomeworkIndex() {
const headers = document.querySelectorAll('thead th');
let maxIndex = 0;
headers.forEach(th => {
table.querySelectorAll('thead th').forEach(th => {
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));
return;
}
const text = th.textContent.trim();
const match = text.match(/^Homework\s+(\d+)$/i);
const match = th.textContent.trim().match(/^Homework\s+(\d+)$/i);
if (match) {
const index = parseInt(match[1], 10);
if (!isNaN(index)) {
maxIndex = Math.max(maxIndex, index);
}
maxIndex = Math.max(maxIndex, parseInt(match[1], 10));
}
});
return maxIndex;
}
syncHeaderTitles();
attachInputListeners();
let homeworkCounter = getMaxHomeworkIndex() + 1;
const addBtn = document.getElementById('addColumnBtn');
const removeBtn = document.getElementById('removeColumnBtn');
// Add Column
addBtn.addEventListener('click', function(e) {
addBtn?.addEventListener('click', function(e) {
e.preventDefault();
destroyDataTable();
const newIndex = homeworkCounter++;
const headerRow = document.querySelector('thead tr');
const headerRow = table.querySelector('thead tr');
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.dataset.index = newIndex;
headerRow.appendChild(newTh);
const rows = document.querySelectorAll('tbody tr');
rows.forEach(row => {
table.querySelectorAll('tbody tr').forEach(row => {
const studentIdInput = row.querySelector('.student-id');
const studentId = studentIdInput ? studentIdInput.value : null;
const newTd = document.createElement('td');
newTd.classList.add(`homework-col-${newIndex}`, 'dynamic-col');
if (!studentId) {
console.warn("Missing student ID in row", row);
const newTd = document.createElement('td');
newTd.classList.add(`homework-col-${newIndex}`, 'dynamic-col');
row.appendChild(newTd);
return;
}
const newTd = document.createElement('td');
newTd.classList.add(`homework-col-${newIndex}`, 'dynamic-col');
newTd.innerHTML = `
<input type="number"
name="scores[${studentId}][${newIndex}]"
class="form-control text-center score-empty"
min="0" max="100" step="0.01">
<label class="missing-check mt-1">
<input type="checkbox"
name="missing_ok[${studentId}][${newIndex}]"
value="1"
class="missing-score-checkbox"
data-field-label="Homework ${newIndex}">
Missing ok
</label>`;
<input type="number"
name="scores[${studentId}][${newIndex}]"
class="form-control text-center score-empty"
min="0" max="100" step="0.01">
<label class="missing-check mt-1">
<input type="checkbox"
name="missing_ok[${studentId}][${newIndex}]"
value="1"
class="missing-score-checkbox"
data-field-label="Homework ${newIndex}">
Missing ok
</label>`;
row.appendChild(newTd);
});
attachInputListeners();
initHomeworkTable();
});
// ❌ Remove Last Column
removeBtn.addEventListener('click', function(e) {
removeBtn?.addEventListener('click', function(e) {
e.preventDefault();
destroyDataTable();
const dynamicHeaders = document.querySelectorAll('th.dynamic-col');
const dynamicHeaders = table.querySelectorAll('th.dynamic-col');
if (dynamicHeaders.length === 0) {
alert("No dynamic columns to remove.");
alert('No dynamic columns to remove.');
return;
}
@@ -287,30 +286,12 @@
const index = lastHeader.dataset.index;
lastHeader.remove();
const rows = document.querySelectorAll('tbody tr');
rows.forEach(row => {
const cell = row.querySelector(`.homework-col-${index}.dynamic-col`);
if (cell) {
cell.remove();
}
table.querySelectorAll('tbody tr').forEach(row => {
row.querySelector(`.homework-col-${index}.dynamic-col`)?.remove();
});
// ✅ Sync the counter with the current highest index
homeworkCounter = getMaxHomeworkIndex() + 1;
initHomeworkTable();
});
});
</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() ?>
+54 -20
View File
@@ -60,21 +60,47 @@
<th>Second Parent</th>
<th>Phone</th>
<!--th>Email</th-->
<th>
In Group (Second)
<div class="form-check d-inline-block ms-2">
<input class="form-check-input" type="checkbox" id="selectAllSecond<?= $index ?>" title="Set all second parents: checked=Yes, unchecked=No">
<label for="selectAllSecond<?= $index ?>" class="form-check-label small">All</label>
</div>
</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<?php foreach ($classSection['parents'] as $rIdx => $p): ?>
<?php $formId = 'mform-' . $index . '-' . $rIdx; ?>
<tr>
<td><?= esc($p['primary_name']) ?></td>
<th>
In Group (Second)
<div class="form-check d-inline-block ms-2">
<input class="form-check-input" type="checkbox" id="selectAllSecond<?= $index ?>" title="Set all second parents: checked=Yes, unchecked=No">
<label for="selectAllSecond<?= $index ?>" class="form-check-label small">All</label>
</div>
</th>
<th>Delivery</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<?php foreach ($classSection['parents'] as $rIdx => $p): ?>
<?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>
<td><?= esc($p['primary_name']) ?></td>
<td>
<?php if (!empty($p['primary_phone'])): ?>
<a href="tel:<?= esc(preg_replace('/\D+/', '', $p['primary_phone'])) ?>">
@@ -131,11 +157,19 @@
</select>
<?php else: ?>
<span class="text-muted">—</span>
<?php endif; ?>
</td>
<td class="text-center">
<form id="<?= $formId ?>" method="post" action="<?= site_url('whatsapp/update-membership') ?>" class="d-inline wa-membership-form">
<?= csrf_field() ?>
<?php endif; ?>
</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">
<form id="<?= $formId ?>" method="post" action="<?= site_url('whatsapp/update-membership') ?>" class="d-inline wa-membership-form">
<?= csrf_field() ?>
<input type="hidden" name="class_section_id" value="<?= (int)($p['class_section_id'] ?? $classSection['class_section_id']) ?>">
<input type="hidden" name="school_year" value="<?= esc($classSection['school_year'] ?? '') ?>">
<input type="hidden" name="semester" value="<?= esc($classSection['semester'] ?? '') ?>">
+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**
+77
View File
@@ -4,6 +4,7 @@ namespace Tests\App\Models;
use Tests\Support\ModelCrudTestCase;
use App\Models\StudentModel;
use Config\Database;
class StudentModelTest extends ModelCrudTestCase
{
@@ -22,4 +23,80 @@ class StudentModelTest extends ModelCrudTestCase
{
$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);
}
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));
}
}