Compare commits

...

4 Commits

Author SHA1 Message Date
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
22 changed files with 1082 additions and 329 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) {
@@ -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)
));
}
}
+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);
+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();
}
}