diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..1267ea5 Binary files /dev/null and b/.DS_Store differ diff --git a/.env b/.env new file mode 100644 index 0000000..d47d416 --- /dev/null +++ b/.env @@ -0,0 +1,180 @@ +#-------------------------------------------------------------------- +# Example Environment Configuration file +# +# This file can be used as a starting point for your own +# custom .env files, and contains most of the possible settings +# available in a default install. +# +# By default, all of the settings are commented out. If you want +# to override the setting, you must un-comment it by removing the '#' +# at the beginning of the line. +#-------------------------------------------------------------------- + +#-------------------------------------------------------------------- +# ENVIRONMENT +#-------------------------------------------------------------------- + +CI_ENVIRONMENT = development + +# Which profile to use if none is supplied to sendEmail() +# Options: default, communication, payment, ... +MAIL_PROFILE_DEFAULT=default + +# ===== DEFAULT (system) ===== +# ---- Legacy fallbacks (kept for compatibility) ---- +SMTP_HOST = smtp.gmail.com +SMTP_USER = alrahma.sunday.school@gmail.com +SMTP_PASS = "psnp emdq dykw ypul" +SMTP_PORT = 587 +SMTP_ENCRYPTION = tls # was SSL; set to ssl to match 587 + +# Reply-To for all mail profiles +MAIL_DEFAULT_REPLY_TO=alrahma.isgl@gmail.com +MAIL_DEFAULT_REPLY_TO_NAME="Al Rahma Sunday School" +MAIL_COMMUNICATION_REPLY_TO=alrahma.isgl@gmail.com +MAIL_COMMUNICATION_REPLY_TO_NAME="School Communications" +MAIL_PAYMENT_REPLY_TO=alrahma.isgl@gmail.com +MAIL_PAYMENT_REPLY_TO_NAME="School Payments" +MAIL_DEFAULT_REPLY_TO="alrahma.isgl@gmail.com" + +# Optional sender directory you already use elsewhere +MAIL_SENDERS="{\"general\":{\"email\":\"alrahma.isgl@gmail.com\",\"name\":\"Al Rahma No-Reply\"},\ +\"registration\":{\"email\":\"alrahma.isgl@gmail.com\",\"name\":\"Al Rahma Register Office\"},\ +\"notifications\":{\"email\":\"alrahma.isgl@gmail.com\",\"name\":\"Al Rahma Notifications\"},\ +\"finance\":{\"email\":\"alrahma.isgl@gmail.com\",\"name\":\"Al Rahma Finance Office\"}}" + +# Principal notification recipient for TimeOff requests (comma-separated allowed) +PRINCIPAL_EMAIL="principal@alrahmaisgl.org" + +#-------------------------------------------------------------------- +# APP +#-------------------------------------------------------------------- + +# If you have trouble with `.`, you could also use `_`. + app_baseURL = 'http://localhost:8080/' +# app.forceGlobalSecureRequests = true +# app.CSPEnabled = false + +#-------------------------------------------------------------------- +# DATABASE +#-------------------------------------------------------------------- +database.default.hostname = 127.0.0.1 +database.default.database = school +database.default.username = root +database.default.password = root +database.default.DBDriver = MySQLi +database.default.DBPrefix = +database.default.port = 3306 + + +#-------------------------------------------------------------------- +# CONTENT SECURITY POLICY +#-------------------------------------------------------------------- + +# contentsecuritypolicy.reportOnly = false +# contentsecuritypolicy.defaultSrc = 'none' +# contentsecuritypolicy.scriptSrc = 'self' +# contentsecuritypolicy.styleSrc = 'self' +# contentsecuritypolicy.imageSrc = 'self' +# contentsecuritypolicy.baseURI = null +# contentsecuritypolicy.childSrc = null +# contentsecuritypolicy.connectSrc = 'self' +# contentsecuritypolicy.fontSrc = null +# contentsecuritypolicy.formAction = null +# contentsecuritypolicy.frameAncestors = null +# contentsecuritypolicy.frameSrc = null +# contentsecuritypolicy.mediaSrc = null +# contentsecuritypolicy.objectSrc = null +# contentsecuritypolicy.pluginTypes = null +# contentsecuritypolicy.reportURI = null +# contentsecuritypolicy.sandbox = false +# contentsecuritypolicy.upgradeInsecureRequests = false +# contentsecuritypolicy.styleNonceTag = '{csp-style-nonce}' +# contentsecuritypolicy.scriptNonceTag = '{csp-script-nonce}' +# contentsecuritypolicy.autoNonce = true + +#-------------------------------------------------------------------- +# COOKIE +#-------------------------------------------------------------------- + +# cookie.prefix = '' +# cookie.expires = 0 +# cookie.path = '/' +# cookie.domain = '' +# cookie.secure = false +# cookie.httponly = false +# cookie.samesite = 'Lax' +# cookie.raw = false + +#-------------------------------------------------------------------- +# ENCRYPTION +#-------------------------------------------------------------------- + +# encryption.key = +# encryption.driver = OpenSSL +# encryption.blockSize = 16 +# encryption.digest = SHA512 + +#-------------------------------------------------------------------- +# HONEYPOT +#-------------------------------------------------------------------- + +# honeypot.hidden = 'true' +# honeypot.label = 'Fill This Field' +# honeypot.name = 'honeypot' +# honeypot.template = '' +# honeypot.container = '
{template}
' + +#-------------------------------------------------------------------- +# SECURITY +#-------------------------------------------------------------------- + +# security.csrfProtection = 'cookie' +# security.tokenRandomize = false +# security.tokenName = 'csrf_token_name' +# security.headerName = 'X-CSRF-TOKEN' +# security.cookieName = 'csrf_cookie_name' +# security.expires = 7200 +# security.regenerate = true +# security.redirect = false +# security.samesite = 'Lax' + +#-------------------------------------------------------------------- +# SESSION +#-------------------------------------------------------------------- + +# session.driver = 'CodeIgniter\Session\Handlers\FileHandler' +# session.cookieName = 'ci_session' +# session.expiration = 7200 +# session.savePath = null +# session.matchIP = false +# session.timeToUpdate = 300 +# session.regenerateDestroy = false + +#-------------------------------------------------------------------- +# LOGGER +#-------------------------------------------------------------------- + +# logger.threshold = 4 + +#-------------------------------------------------------------------- +# CURLRequest +#-------------------------------------------------------------------- + +# curlrequest.shareOptions = false + + + + + +mail.protocol = smtp +mail.SMTPHost = ${SMTP_HOST} +mail.SMTPUser = ${SMTP_USER} +mail.SMTPPass = ${SMTP_PASS} +mail.SMTPPort = ${SMTP_PORT} +mail.SMTPCrypto = tls + +mail.fromEmail = ${SMTP_USER} +mail.fromName = "Al Rahma Sunday School" +mail.mailType = text +mail.charset = UTF-8 diff --git a/app/Commands/AttendanceAutoPublishCommand.php b/app/Commands/AttendanceAutoPublishCommand.php old mode 100644 new mode 100755 diff --git a/app/Commands/CheckMissedPayments.php b/app/Commands/CheckMissedPayments.php old mode 100644 new mode 100755 diff --git a/app/Commands/CleanupExpiredNotifications.php b/app/Commands/CleanupExpiredNotifications.php old mode 100644 new mode 100755 diff --git a/app/Commands/CleanupPasswordResets.php b/app/Commands/CleanupPasswordResets.php old mode 100644 new mode 100755 diff --git a/app/Commands/ConfigUpdate.php b/app/Commands/ConfigUpdate.php old mode 100644 new mode 100755 diff --git a/app/Commands/DeleteInactiveUsers.php b/app/Commands/DeleteInactiveUsers.php old mode 100644 new mode 100755 diff --git a/app/Commands/RecalculateAttendance.php b/app/Commands/RecalculateAttendance.php old mode 100644 new mode 100755 diff --git a/app/Commands/SendAbsenteesSummary.php b/app/Commands/SendAbsenteesSummary.php old mode 100644 new mode 100755 diff --git a/app/Commands/SendExamDraftDeadlineReminders.php b/app/Commands/SendExamDraftDeadlineReminders.php new file mode 100755 index 0000000..7adfe80 --- /dev/null +++ b/app/Commands/SendExamDraftDeadlineReminders.php @@ -0,0 +1,197 @@ +getConfig('school_year') ?? ''); + $semester = (string) ($configModel->getConfig('semester') ?? ''); + $deadlineValue = trim((string) ($configModel->getConfig('exam_draft_deadline') ?? '')); + if ($deadlineValue === '') { + CLI::write('exam_draft_deadline is not configured.', 'yellow'); + return; + } + + $tz = new \DateTimeZone(config('App')->appTimezone ?? 'UTC'); + try { + $deadline = new \DateTimeImmutable($deadlineValue, $tz); + } catch (\Throwable $e) { + CLI::write('Invalid exam_draft_deadline value.', 'red'); + return; + } + $deadline = $deadline->setTime(0, 0, 0); + $today = new \DateTimeImmutable('now', $tz); + $today = $today->setTime(0, 0, 0); + + if ($today > $deadline) { + CLI::write('Deadline has passed. No reminders sent.', 'yellow'); + return; + } + + $daysToDeadline = (int) $today->diff($deadline)->format('%r%a'); + if (!$this->shouldSendOnDay($daysToDeadline)) { + CLI::write('No reminder scheduled for today.', 'yellow'); + return; + } + + $db = Database::connect(); + $teacherClassRows = $db->table('teacher_class') + ->select('teacher_id, class_section_id, school_year, semester') + ->when($schoolYear !== '', static function ($builder) use ($schoolYear) { + return $builder->where('school_year', $schoolYear); + }) + ->when($semester !== '', static function ($builder) use ($semester) { + return $builder->where('semester', $semester); + }) + ->get() + ->getResultArray(); + + if (empty($teacherClassRows)) { + CLI::write('No teacher assignments found.', 'yellow'); + return; + } + + $draftTable = 'exam_drafts'; + $fields = $db->getFieldNames($draftTable); + $teacherColumn = in_array('teacher_id', $fields, true) ? 'teacher_id' : 'author_id'; + $hasStatusColumn = in_array('status', $fields, true); + $hasIsLegacyColumn = in_array('is_legacy', $fields, true); + + $draftsQuery = $db->table($draftTable) + ->select("{$teacherColumn} AS teacher_id, class_section_id") + ->when($schoolYear !== '', static function ($builder) use ($schoolYear) { + return $builder->where('school_year', $schoolYear); + }) + ->when($semester !== '', static function ($builder) use ($semester) { + return $builder->where('semester', $semester); + }); + + if ($hasStatusColumn) { + $draftsQuery = $draftsQuery->where('status !=', 'legacy'); + } + if ($hasIsLegacyColumn) { + $draftsQuery = $draftsQuery->where('is_legacy', 0); + } + + $draftRows = $draftsQuery->get()->getResultArray(); + $submittedMap = []; + foreach ($draftRows as $row) { + $key = (int) ($row['teacher_id'] ?? 0) . '|' . (int) ($row['class_section_id'] ?? 0); + $submittedMap[$key] = true; + } + + $missingByTeacher = []; + foreach ($teacherClassRows as $row) { + $teacherId = (int) ($row['teacher_id'] ?? 0); + $classSectionId = (int) ($row['class_section_id'] ?? 0); + if ($teacherId <= 0 || $classSectionId <= 0) { + continue; + } + $key = $teacherId . '|' . $classSectionId; + if (!isset($submittedMap[$key])) { + $missingByTeacher[$teacherId][] = $classSectionId; + } + } + + if (empty($missingByTeacher)) { + CLI::write('All teachers have submitted drafts.', 'green'); + return; + } + + $userModel = new UserModel(); + $classSectionModel = new ClassSectionModel(); + $historyModel = new TeacherSubmissionNotificationHistoryModel(); + $mailer = new EmailController(); + + $classSections = $classSectionModel + ->select('class_section_id, class_section_name') + ->findAll(); + $classLookup = []; + foreach ($classSections as $section) { + $classLookup[(int) ($section['class_section_id'] ?? 0)] = $section['class_section_name'] ?? ''; + } + + $todayStamp = $today->format('Y-m-d'); + foreach ($missingByTeacher as $teacherId => $sectionIds) { + $teacher = $userModel->find($teacherId); + $email = $teacher['email'] ?? ''; + if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + continue; + } + + $alreadySent = $historyModel + ->where('teacher_id', $teacherId) + ->where('notification_category', 'exam_draft_deadline') + ->where('school_year', $schoolYear) + ->where('semester', $semester) + ->like('sent_at', $todayStamp) + ->countAllResults() > 0; + if ($alreadySent) { + continue; + } + + $classNames = array_map(static function ($id) use ($classLookup) { + return $classLookup[(int) $id] ?? "Class {$id}"; + }, $sectionIds); + $classList = implode(', ', $classNames); + $teacherName = trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? '')); + if ($teacherName === '') { + $teacherName = 'Teacher'; + } + + $subject = 'Reminder: Exam draft submission deadline'; + $body = '

Dear ' . esc($teacherName) . ',

' + . '

This is a reminder to submit your exam draft(s) for: ' . esc($classList) . '.

' + . '

Deadline: ' . esc($deadline->format('Y-m-d')) . '

' + . '

Please submit your draft at Teacher Exam Drafts.

' + . '

Thank you.

'; + + $status = 'failed'; + if ($mailer->sendEmail($email, $subject, $body, 'notifications')) { + $status = 'sent'; + CLI::write("Sent reminder to {$email}", 'green'); + } + + $historyModel->insert([ + 'teacher_id' => $teacherId, + 'class_section_id' => 0, + 'admin_id' => null, + 'notification_category' => 'exam_draft_deadline', + 'message' => strip_tags($body), + 'status' => $status, + 'school_year' => $schoolYear, + 'semester' => $semester, + 'sent_at' => utc_now(), + ]); + } + } + + private function shouldSendOnDay(int $daysToDeadline): bool + { + if ($daysToDeadline < 0) { + return false; + } + if ($daysToDeadline <= 4) { + return true; + } + return in_array($daysToDeadline, [28, 21, 14, 7], true); + } +} diff --git a/app/Commands/SendLatesSummary.php b/app/Commands/SendLatesSummary.php old mode 100644 new mode 100755 diff --git a/app/Commands/SendMonthlyPaymentNotifications.php b/app/Commands/SendMonthlyPaymentNotifications.php old mode 100644 new mode 100755 diff --git a/app/Commands/SendTestPaymentNotification.php b/app/Commands/SendTestPaymentNotification.php old mode 100644 new mode 100755 diff --git a/app/Commands/SyncPaypalPayments.php b/app/Commands/SyncPaypalPayments.php old mode 100644 new mode 100755 diff --git a/app/Common.php b/app/Common.php old mode 100644 new mode 100755 diff --git a/app/Config/Api.php b/app/Config/Api.php old mode 100644 new mode 100755 diff --git a/app/Config/App.php b/app/Config/App.php old mode 100644 new mode 100755 index 4423270..58df5ce --- a/app/Config/App.php +++ b/app/Config/App.php @@ -82,7 +82,7 @@ class App extends BaseConfig | DO NOT CHANGE THIS UNLESS YOU FULLY UNDERSTAND THE REPERCUSSIONS!! | */ - public string $permittedURIChars = 'a-z 0-9~%.:_\-'; + public string $permittedURIChars = 'a-z 0-9~%.:_\-,'; /** * -------------------------------------------------------------------------- diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php old mode 100644 new mode 100755 diff --git a/app/Config/Boot/development.php b/app/Config/Boot/development.php old mode 100644 new mode 100755 diff --git a/app/Config/Boot/production.php b/app/Config/Boot/production.php old mode 100644 new mode 100755 diff --git a/app/Config/Boot/testing.php b/app/Config/Boot/testing.php old mode 100644 new mode 100755 diff --git a/app/Config/CURLRequest.php b/app/Config/CURLRequest.php old mode 100644 new mode 100755 diff --git a/app/Config/Cache.php b/app/Config/Cache.php old mode 100644 new mode 100755 diff --git a/app/Config/Commands.php b/app/Config/Commands.php old mode 100644 new mode 100755 diff --git a/app/Config/Constants.php b/app/Config/Constants.php old mode 100644 new mode 100755 diff --git a/app/Config/ContentSecurityPolicy.php b/app/Config/ContentSecurityPolicy.php old mode 100644 new mode 100755 diff --git a/app/Config/Cookie.php b/app/Config/Cookie.php old mode 100644 new mode 100755 diff --git a/app/Config/Cors.php b/app/Config/Cors.php old mode 100644 new mode 100755 diff --git a/app/Config/Database.php b/app/Config/Database.php old mode 100644 new mode 100755 index 78561d0..0af3e90 --- a/app/Config/Database.php +++ b/app/Config/Database.php @@ -9,43 +9,51 @@ class Database extends Config public string $filesPath = APPPATH . 'Database' . DIRECTORY_SEPARATOR; public string $defaultGroup = 'default'; - public array $default = [ - 'DSN' => '', - 'hostname' => 'localhost', - 'username' => 'u280815660_melabidi', - 'password' => '>tNxlRzP/W8', - 'database' => 'u280815660_school', - 'DBDriver' => 'MySQLi', - 'DBPrefix' => '', - 'pConnect' => false, - 'DBDebug' => (ENVIRONMENT !== 'development'), - 'charset' => 'utf8', - 'DBCollat' => 'utf8_general_ci', - 'swapPre' => '', - 'encrypt' => false, - 'compress' => false, - 'strictOn' => false, - 'failover' => [], - 'port' => 3306, - ]; + public array $default = []; + public array $tests = []; - public array $tests = [ - 'DSN' => '', - 'hostname' => 'localhost', - 'username' => 'u280815660_melabidi', - 'password' => '>tNxlRzP/W8', - 'database' => 'u280815660_school', - 'DBDriver' => 'MySQLi', - 'DBPrefix' => 'db_', - 'pConnect' => false, - 'DBDebug' => true, - 'charset' => 'utf8', - 'DBCollat' => 'utf8_general_ci', - 'swapPre' => '', - 'encrypt' => false, - 'compress' => false, - 'strictOn' => false, - 'failover' => [], - 'port' => 3306, - ]; + public function __construct() + { + parent::__construct(); + + $this->default = [ + 'DSN' => '', + 'hostname' => env('database.default.hostname'), + 'username' => env('database.default.username'), + 'password' => env('database.default.password'), + 'database' => env('database.default.database'), + 'DBDriver' => env('database.default.DBDriver', 'MySQLi'), + 'DBPrefix' => '', + 'pConnect' => false, + 'DBDebug' => (ENVIRONMENT !== 'development'), + 'charset' => 'utf8', + 'DBCollat' => 'utf8_general_ci', + 'swapPre' => '', + 'encrypt' => false, + 'compress' => false, + 'strictOn' => false, + 'failover' => [], + 'port' => (int) env('database.default.port', 3306), + ]; + + $this->tests = [ + 'DSN' => '', + 'hostname' => env('database.tests.hostname', env('database.default.hostname')), + 'username' => env('database.tests.username', env('database.default.username')), + 'password' => env('database.tests.password', env('database.default.password')), + 'database' => env('database.tests.database', env('database.default.database')), + 'DBDriver' => env('database.tests.DBDriver', env('database.default.DBDriver', 'MySQLi')), + 'DBPrefix' => env('database.tests.DBPrefix', 'db_'), + 'pConnect' => false, + 'DBDebug' => true, + 'charset' => 'utf8', + 'DBCollat' => 'utf8_general_ci', + 'swapPre' => '', + 'encrypt' => false, + 'compress' => false, + 'strictOn' => false, + 'failover' => [], + 'port' => (int) env('database.tests.port', env('database.default.port', 3306)), + ]; + } } diff --git a/app/Config/DocTypes.php b/app/Config/DocTypes.php old mode 100644 new mode 100755 diff --git a/app/Config/Email.php b/app/Config/Email.php old mode 100644 new mode 100755 diff --git a/app/Config/Encryption.php b/app/Config/Encryption.php old mode 100644 new mode 100755 diff --git a/app/Config/Events.php b/app/Config/Events.php old mode 100644 new mode 100755 diff --git a/app/Config/Exceptions.php b/app/Config/Exceptions.php old mode 100644 new mode 100755 diff --git a/app/Config/Feature.php b/app/Config/Feature.php old mode 100644 new mode 100755 diff --git a/app/Config/Filters.php b/app/Config/Filters.php old mode 100644 new mode 100755 diff --git a/app/Config/ForeignCharacters.php b/app/Config/ForeignCharacters.php old mode 100644 new mode 100755 diff --git a/app/Config/Format.php b/app/Config/Format.php old mode 100644 new mode 100755 diff --git a/app/Config/Generators.php b/app/Config/Generators.php old mode 100644 new mode 100755 diff --git a/app/Config/Honeypot.php b/app/Config/Honeypot.php old mode 100644 new mode 100755 diff --git a/app/Config/Images.php b/app/Config/Images.php old mode 100644 new mode 100755 diff --git a/app/Config/Kint.php b/app/Config/Kint.php old mode 100644 new mode 100755 diff --git a/app/Config/Logger.php b/app/Config/Logger.php old mode 100644 new mode 100755 diff --git a/app/Config/Migrations.php b/app/Config/Migrations.php old mode 100644 new mode 100755 diff --git a/app/Config/Mimes.php b/app/Config/Mimes.php old mode 100644 new mode 100755 diff --git a/app/Config/Modules.php b/app/Config/Modules.php old mode 100644 new mode 100755 diff --git a/app/Config/Optimize.php b/app/Config/Optimize.php old mode 100644 new mode 100755 diff --git a/app/Config/Pager.php b/app/Config/Pager.php old mode 100644 new mode 100755 diff --git a/app/Config/Paths.php b/app/Config/Paths.php old mode 100644 new mode 100755 diff --git a/app/Config/PaypalConfig.php b/app/Config/PaypalConfig.php old mode 100644 new mode 100755 diff --git a/app/Config/Publisher.php b/app/Config/Publisher.php old mode 100644 new mode 100755 diff --git a/app/Config/Roles.php b/app/Config/Roles.php old mode 100644 new mode 100755 diff --git a/app/Config/Routes.php b/app/Config/Routes.php old mode 100644 new mode 100755 index c722b7c..1ec333d --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -108,6 +108,7 @@ $routes->get('administrator/student-score-card', 'View\StudentController::scoreC // API for report card meta (students, class sections, school years) $routes->get('api/printables/report-card/meta', 'View\ReportCardsController::reportCardMeta', ['filter' => 'auth']); $routes->get('api/printables/report-card/completeness', 'View\ReportCardsController::reportCardCompleteness', ['filter' => 'auth']); +$routes->get('api/printables/report-card/ack', 'View\ReportCardsController::reportCardAcknowledgement', ['filter' => 'auth']); //Badges $routes->get('printables_reports/badge_form', 'View\BadgesController::badgeForm'); @@ -229,6 +230,10 @@ $routes->get('reset_password', 'View\UserController::resetPassword'); //$routes->get('/blocked', 'View\UserController::blocked'); +$routes->get('confirm_authorized_user', 'View\AuthorizedUsersController::confirm'); +$routes->get('set_authorized_user_password/(:num)', 'View\AuthorizedUsersController::setPassword/$1'); +$routes->post('set_authorized_user_password/(:num)', 'View\AuthorizedUsersController::savePassword/$1'); + $routes->post('assign_class_student', 'View\StudentController::assignClassStudent'); $routes->post('remove_class_student', 'View\StudentController::removeClassStudent'); $routes->post('administrator/remove_class_student', 'View\StudentController::removeClassStudent'); // alias to avoid 404s @@ -330,6 +335,9 @@ $routes->post('/administrator/scores/update/{id}', 'View\ScoreController::update // Route to delete a score record $routes->get('/administrator/scores/delete/{id}', 'View\ScoreController::destroy'); $routes->get('/parent/scores', 'View\ScoreController::viewStudentScore'); +$routes->get('parent/report-cards', 'ParentReportCardController::index', ['filter' => 'auth:parent']); +$routes->get('parent/report-cards/view/(:num)', 'ParentReportCardController::view/$1', ['filter' => 'auth:parent']); +$routes->post('parent/report-cards/sign/(:num)', 'ParentReportCardController::sign/$1', ['filter' => 'auth:parent']); @@ -355,6 +363,7 @@ $routes->get('/teacher/teacher_contactus', 'View\TeacherController::contactusTea $routes->get('/teacher/exam-drafts', 'View\ExamDraftController::teacherIndex', ['filter' => 'auth:teacher,teacher_assistant,teacher_dashboard,read']); $routes->post('/teacher/exam-drafts', 'View\ExamDraftController::teacherStore', ['filter' => 'auth:teacher,teacher_assistant,teacher_dashboard,read']); +$routes->get('/teacher/exam-drafts/status', 'View\ExamDraftController::teacherStatusFeed', ['filter' => 'auth:teacher,teacher_assistant,teacher_dashboard,read']); @@ -384,8 +393,8 @@ $routes->get('print-requests/file/(:segment)', 'PrintRequests::serveFile/$1', [' $routes->get('print-requests/file/(:segment)/(:alpha)', 'PrintRequests::serveFile/$1/$2', ['filter' => 'auth:teacher,teacher_assistant,admin']); $routes->get('uploads/print_requests/(:segment)', 'PrintRequests::serveFile/$1', ['filter' => 'auth:teacher,teacher_assistant,admin']); -$routes->get('exam-drafts/files/teacher/(:segment)', 'View\FilesController::examDraftTeacher/$1', ['filter' => 'auth:teacher,teacher_assistant,admin']); -$routes->get('exam-drafts/files/final/(:segment)', 'View\FilesController::examDraftFinal/$1', ['filter' => 'auth:teacher,teacher_assistant,admin']); +$routes->get('exam-drafts/files/teacher/(:segment)', 'View\FilesController::examDraftTeacher/$1', ['filter' => 'auth:teacher,teacher_assistant,admin,administrator,principal']); +$routes->get('exam-drafts/files/final/(:segment)', 'View\FilesController::examDraftFinal/$1', ['filter' => 'auth:teacher,teacher_assistant,admin,administrator,principal']); @@ -397,6 +406,8 @@ $routes->get('teacher/progress/submit', 'ClassProgressController::create', ['fil $routes->post('teacher/progress/store', 'ClassProgressController::store', ['filter' => 'auth:teacher,teacher_assistant']); $routes->get('teacher/progress/history', 'ClassProgressController::history', ['filter' => 'auth:teacher,teacher_assistant']); $routes->get('teacher/progress/view/(:num)', 'ClassProgressController::view/$1', ['filter' => 'auth:teacher,teacher_assistant']); +$routes->get('teacher/progress/edit/(:num)', 'ClassProgressController::edit/$1', ['filter' => 'auth:teacher,teacher_assistant']); +$routes->post('teacher/progress/update/(:num)', 'ClassProgressController::update/$1', ['filter' => 'auth:teacher,teacher_assistant']); $routes->get('teacher/progress/attachment/(:num)', 'ClassProgressController::attachment/$1', ['filter' => 'auth:teacher,teacher_assistant']); $routes->get('teacher/progress/attachment-file/(:num)', 'ClassProgressController::attachmentFile/$1', ['filter' => 'auth:teacher,teacher_assistant']); $routes->get('parent/progress', 'ParentProgressController::index', ['filter' => 'auth:parent']); @@ -495,6 +506,7 @@ $routes->get('grading/project/(:num)', 'View\ProjectController::showProjectMngt/ $routes->post('grading/updateProject', 'View\ProjectController::updateProject'); $routes->get('grading/below-60', 'View\GradingController::belowSixty', ['filter' => 'auth:read']); +$routes->get('grading/below-60/email/edit', 'View\GradingController::editBelowSixtyEmail', ['filter' => 'auth:read']); $routes->post('grading/below-60/email', 'View\GradingController::sendBelowSixtyEmail', ['filter' => 'auth:read']); $routes->post('grading/below-60/status', 'View\GradingController::updateBelowSixtyStatus', ['filter' => 'auth:read']); $routes->get('grading/below-60/schedule', 'View\GradingController::scheduleBelowSixty', ['filter' => 'auth:read']); @@ -523,9 +535,12 @@ $routes->post('payment/event_charges', 'View\EventController::eventUpdate'); // Parent event participation $routes->get('administrator/event-charges', 'View\EventController::eventShow'); +$routes->post('administrator/event-charges/remove/(:num)', 'View\EventController::removeCharge/$1'); +$routes->post('administrator/event-charges/payment/(:num)', 'View\EventController::toggleEventPayment/$1'); +$routes->post('administrator/event-charges/waiver/(:num)', 'View\EventController::toggleWaiverStatus/$1'); $routes->get('administrator/get-students-with-charges', 'View\EventController::getStudentsWithCharges'); -$routes->get('parent/events', 'View\ParentController::parentEventPage'); +$routes->get('parent/events', 'View\ParentController::parentEventPage', ['filter' => 'auth:parent']); // parent event participation page $routes->post('parent/updateParticipation', 'View\ParentController::updateParticipation'); // handle parent participation updates @@ -730,6 +745,9 @@ $routes->post('/administrator/teacher-submissions/notify', 'View\AdministratorCo $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']); /* * -------------------------------------------------------------------- @@ -998,6 +1016,8 @@ $routes->group('family', static function ($routes) { $routes->get('index', 'View\FamilyAdminController::index'); $routes->get('search', 'View\FamilyAdminController::search'); $routes->get('card', 'View\FamilyAdminController::card'); + $routes->get('compose-email', 'View\FamilyAdminController::composeEmail'); + $routes->post('compose-email/send', 'View\FamilyAdminController::sendComposeEmail'); }); // Convenience alias $routes->get('family', 'View\FamilyAdminController::index'); diff --git a/app/Config/Routing.php b/app/Config/Routing.php old mode 100644 new mode 100755 diff --git a/app/Config/School.php b/app/Config/School.php old mode 100644 new mode 100755 diff --git a/app/Config/Security.php b/app/Config/Security.php old mode 100644 new mode 100755 diff --git a/app/Config/Services.php b/app/Config/Services.php old mode 100644 new mode 100755 diff --git a/app/Config/Session.php b/app/Config/Session.php old mode 100644 new mode 100755 diff --git a/app/Config/SessionTimeout.php b/app/Config/SessionTimeout.php old mode 100644 new mode 100755 diff --git a/app/Config/Style.php b/app/Config/Style.php old mode 100644 new mode 100755 diff --git a/app/Config/Toolbar.php b/app/Config/Toolbar.php old mode 100644 new mode 100755 diff --git a/app/Config/UserAgents.php b/app/Config/UserAgents.php old mode 100644 new mode 100755 diff --git a/app/Config/Validation.php b/app/Config/Validation.php old mode 100644 new mode 100755 diff --git a/app/Config/View.php b/app/Config/View.php old mode 100644 new mode 100755 diff --git a/app/Controllers/Admin/CompetitionWinnersController.php b/app/Controllers/Admin/CompetitionWinnersController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/AdminProgressController.php b/app/Controllers/AdminProgressController.php old mode 100644 new mode 100755 index 5f323ad..3ef8c63 --- a/app/Controllers/AdminProgressController.php +++ b/app/Controllers/AdminProgressController.php @@ -5,7 +5,11 @@ namespace App\Controllers; use App\Models\ClassProgressReportModel; use App\Models\ClassProgressAttachmentModel; use App\Models\ClassSectionModel; +use App\Models\CalendarModel; +use App\Models\ConfigurationModel; use App\Models\StudentClassModel; +use App\Models\SubjectCurriculumModel; +use App\Services\SemesterRangeService; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\HTTP\ResponseInterface; @@ -15,6 +19,10 @@ class AdminProgressController extends BaseController protected ClassProgressAttachmentModel $attachmentModel; protected ClassSectionModel $classSectionModel; protected StudentClassModel $studentClassModel; + protected CalendarModel $calendarModel; + protected ConfigurationModel $configModel; + protected SemesterRangeService $semesterRangeService; + protected SubjectCurriculumModel $curriculumModel; public function __construct() { @@ -23,6 +31,10 @@ class AdminProgressController extends BaseController $this->attachmentModel = new ClassProgressAttachmentModel(); $this->classSectionModel = new ClassSectionModel(); $this->studentClassModel = new StudentClassModel(); + $this->calendarModel = new CalendarModel(); + $this->configModel = new ConfigurationModel(); + $this->semesterRangeService = new SemesterRangeService($this->configModel); + $this->curriculumModel = new SubjectCurriculumModel(); } public function index() @@ -53,22 +65,30 @@ class AdminProgressController extends BaseController } $rows = $builder->orderBy('week_start', 'DESC')->get()->getResultArray(); - $reportGroups = []; + $reportGroupsBySection = []; foreach ($rows as $row) { $row['status_label'] = ClassProgressController::STATUS_OPTIONS[$row['status']] ?? 'Unknown'; - $key = ($row['week_start'] ?? '') . '_' . ($row['class_section_id'] ?? ''); - if ($key === '_') { + $sectionId = (int) ($row['class_section_id'] ?? 0); + $weekKey = (string) ($row['week_start'] ?? ''); + if ($sectionId === 0 || $weekKey === '') { continue; } - if (! isset($reportGroups[$key])) { - $reportGroups[$key] = [ + if (! isset($reportGroupsBySection[$sectionId])) { + $reportGroupsBySection[$sectionId] = []; + } + if (! isset($reportGroupsBySection[$sectionId][$weekKey])) { + $reportGroupsBySection[$sectionId][$weekKey] = [ 'week_start' => $row['week_start'], 'week_end' => $row['week_end'], 'class_section_name' => $row['class_section_name'] ?? '', 'reports' => [], ]; } - $reportGroups[$key]['reports'][$row['subject']] = $row; + $reportGroupsBySection[$sectionId][$weekKey]['reports'][$row['subject']] = $row; + } + foreach ($reportGroupsBySection as $sectionId => $groups) { + krsort($groups); + $reportGroupsBySection[$sectionId] = $groups; } $classSections = $this->classSectionModel->getClassSections(); @@ -78,12 +98,47 @@ class AdminProgressController extends BaseController return isset($studentCounts[$sectionId]) && $studentCounts[$sectionId] > 0; })); + $filterStart = $this->normalizeDate($filters['from'] ?? ''); + $filterEnd = $this->normalizeDate($filters['to'] ?? ''); + [$dateList, $noSchoolDays, $totalPassedDays, $passedDatesSet] = $this->buildSemesterDates(); + [$expectedDays, $activeDatesSet] = $this->resolveExpectedDays( + $dateList, + $noSchoolDays, + $totalPassedDays, + $passedDatesSet, + $filterStart, + $filterEnd + ); + $sectionStats = $this->buildSectionSubmissionStats($rows, $activeDatesSet, $expectedDays); + $sectionSubjectCounts = $this->buildSectionSubjectCounts($rows); + $lowProgressSectionIds = []; + if ($expectedDays > 0) { + foreach ($filteredSections as $section) { + $sectionId = (int) ($section['class_section_id'] ?? 0); + if ($sectionId === 0) { + continue; + } + $stat = $sectionStats[$sectionId] ?? null; + $percent = $stat['percent'] ?? 0; + if ($stat === null) { + $percent = 0; + } + if ($percent < 50) { + $lowProgressSectionIds[] = $sectionId; + } + } + } + return view('admin/class_progress_list', [ - 'reportGroups' => $reportGroups, + 'reportGroupsBySection' => $reportGroupsBySection, 'filters' => $filters, 'classSections' => $filteredSections, 'statusOptions' => ClassProgressController::STATUS_OPTIONS, 'subjectSections' => ClassProgressController::SUBJECT_SECTIONS, + 'sectionStats' => $sectionStats, + 'sectionSubjectCounts' => $sectionSubjectCounts, + 'expectedDays' => $expectedDays, + 'lowProgressSectionIds' => $lowProgressSectionIds, ]); } @@ -211,4 +266,376 @@ class AdminProgressController extends BaseController $flags = json_decode($json, true); return is_array($flags) ? $flags : []; } + + protected function normalizeDate(string $value): string + { + $value = trim($value); + if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { + return ''; + } + [$y, $m, $d] = array_map('intval', explode('-', $value)); + return checkdate($m, $d, $y) ? $value : ''; + } + + protected function buildSemesterDates(): array + { + $schoolYear = (string) ($this->configModel->getConfig('school_year') ?? ''); + $semester = (string) ($this->configModel->getConfig('semester') ?? ''); + $schoolYearForRange = $schoolYear !== '' ? $schoolYear : (string) ($this->configModel->getConfig('school_year') ?? ''); + [$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYearForRange); + $semesterNorm = $this->semesterRangeService->normalizeSemester($semester); + if ($semesterNorm !== '' && $schoolYearForRange !== '') { + $semRange = $this->semesterRangeService->getSemesterRange($schoolYearForRange, $semesterNorm); + if ($semRange) { + [$rangeStart, $rangeEnd] = $semRange; + } + } + + $dateList = []; + try { + $start = new \DateTimeImmutable($rangeStart); + $end = new \DateTimeImmutable($rangeEnd); + $cursor = $start; + $w = (int) $cursor->format('w'); + if ($w !== 0) { + $cursor = $cursor->modify('next sunday'); + } + while ($cursor <= $end) { + $dateList[] = $cursor->format('Y-m-d'); + $cursor = $cursor->modify('+7 days'); + } + } catch (\Throwable $e) { + $dateList = []; + } + + $noSchoolDays = []; + $events = []; + try { + $events = $this->calendarModel->getEvents(); + } catch (\Throwable $e) { + $events = []; + } + foreach ($events as $event) { + $d = substr((string) ($event['date'] ?? ''), 0, 10); + if ($d === '' || empty($event['no_school'])) { + continue; + } + if ($d < $rangeStart || $d > $rangeEnd) { + continue; + } + $eventYear = trim((string) ($event['school_year'] ?? '')); + if ($schoolYearForRange !== '' && $eventYear !== '' && $eventYear !== $schoolYearForRange) { + continue; + } + $noSchoolDays[$d] = true; + } + + $anchorSundayYmd = ''; + try { + $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); + $tzObj = new \DateTimeZone($tzName ?: 'UTC'); + } catch (\Throwable $e) { + try { + $tzObj = new \DateTimeZone(user_timezone() ?: 'UTC'); + } catch (\Throwable $e2) { + $tzObj = new \DateTimeZone('UTC'); + } + } + try { + $nowDate = new \DateTime('now', $tzObj); + } catch (\Throwable $e) { + $nowDate = new \DateTime('now'); + } + $weekday = (int) $nowDate->format('w'); + $anchorSundayYmd = $weekday === 0 + ? $nowDate->format('Y-m-d') + : $nowDate->modify('next sunday')->format('Y-m-d'); + + $passedDatesSet = []; + if (! empty($dateList) && $anchorSundayYmd !== '') { + foreach ($dateList as $d) { + if ($d <= $anchorSundayYmd && empty($noSchoolDays[$d])) { + $passedDatesSet[$d] = true; + } + } + } + $totalPassedDays = count($passedDatesSet); + + return [$dateList, $noSchoolDays, $totalPassedDays, $passedDatesSet]; + } + + protected function resolveExpectedDays( + array $dateList, + array $noSchoolDays, + int $totalPassedDays, + array $passedDatesSet, + string $filterStart, + string $filterEnd + ): array { + $activeDatesSet = []; + if ($filterStart === '' && $filterEnd === '') { + $expectedDays = $totalPassedDays; + if ($expectedDays > 0) { + $activeDatesSet = $passedDatesSet; + } + return [$expectedDays, $activeDatesSet]; + } + + $expectedDays = 0; + foreach ($dateList as $d) { + if ($d === '') { + continue; + } + if ($filterStart !== '' && $d < $filterStart) { + continue; + } + if ($filterEnd !== '' && $d > $filterEnd) { + continue; + } + if (! empty($noSchoolDays[$d])) { + continue; + } + $activeDatesSet[$d] = true; + $expectedDays++; + } + + return [$expectedDays, $activeDatesSet]; + } + + protected function buildSectionSubmissionStats(array $rows, array $activeDatesSet, int $expectedDays): array + { + $submittedBySection = []; + foreach ($rows as $row) { + $sectionId = (int) ($row['class_section_id'] ?? 0); + $weekStart = (string) ($row['week_start'] ?? ''); + if ($sectionId === 0 || $weekStart === '') { + continue; + } + $submittedBySection[$sectionId][$weekStart] = true; + } + + $stats = []; + foreach ($submittedBySection as $sectionId => $weeks) { + $submitted = count($weeks); + $stats[$sectionId] = $this->buildSectionStat($submitted, $expectedDays); + } + + return $stats; + } + + protected function buildSectionSubjectCounts(array $rows): array + { + $allowedSubjects = []; + foreach (ClassProgressController::SUBJECT_SECTIONS as $section) { + $allowedSubjects[] = $section['db_subject'] ?? $section['label'] ?? ''; + } + $allowedSubjects = array_values(array_filter($allowedSubjects)); + + $counts = []; + $sectionClassMap = []; + foreach ($rows as $row) { + $sectionId = (int) ($row['class_section_id'] ?? 0); + $subject = (string) ($row['subject'] ?? ''); + if ($sectionId === 0 || $subject === '') { + continue; + } + if (! empty($allowedSubjects) && ! in_array($subject, $allowedSubjects, true)) { + continue; + } + if (! isset($sectionClassMap[$sectionId])) { + $sectionClassMap[$sectionId] = $this->classSectionModel->getClassId($sectionId); + } + } + + $curriculumUnits = $this->buildCurriculumUnitMap(array_values(array_filter($sectionClassMap))); + + foreach ($rows as $row) { + $sectionId = (int) ($row['class_section_id'] ?? 0); + $subject = (string) ($row['subject'] ?? ''); + if ($sectionId === 0 || $subject === '') { + continue; + } + if (! empty($allowedSubjects) && ! in_array($subject, $allowedSubjects, true)) { + continue; + } + $subjectSlug = $this->resolveSubjectSlug($subject); + $classId = $sectionClassMap[$sectionId] ?? null; + $chapterToUnit = []; + if ($classId && $subjectSlug && ! empty($curriculumUnits[$classId][$subjectSlug]['chapter_to_unit'])) { + $chapterToUnit = $curriculumUnits[$classId][$subjectSlug]['chapter_to_unit']; + } + $unitKeys = $this->extractUnitKeys((string) ($row['unit_title'] ?? ''), $chapterToUnit); + foreach ($unitKeys as $unitKey) { + $key = $subjectSlug . '|' . $unitKey; + $counts[$sectionId][$key] = true; + } + } + + $totals = []; + foreach ($counts as $sectionId => $unitSet) { + $totals[$sectionId] = count($unitSet); + } + + return $totals; + } + + protected function buildCurriculumUnitMap(array $classIds): array + { + $classIds = array_values(array_filter(array_map('intval', $classIds))); + if (empty($classIds)) { + return []; + } + + $rows = $this->curriculumModel + ->whereIn('class_id', $classIds) + ->findAll(); + + $map = []; + foreach ($rows as $row) { + $classId = (int) ($row['class_id'] ?? 0); + $subject = (string) ($row['subject'] ?? ''); + $chapter = trim((string) ($row['chapter_name'] ?? '')); + $unitNumber = $row['unit_number'] ?? null; + if ($classId === 0 || $subject === '' || $chapter === '') { + continue; + } + if ($unitNumber === null || $unitNumber === '') { + continue; + } + $unitKey = (string) $unitNumber; + $map[$classId][$subject]['chapter_to_unit'][$chapter] = $unitKey; + } + + return $map; + } + + protected function resolveSubjectSlug(string $subject): ?string + { + foreach (ClassProgressController::SUBJECT_SECTIONS as $slug => $section) { + $dbSubject = (string) ($section['db_subject'] ?? ''); + $label = (string) ($section['label'] ?? ''); + if ($subject === $dbSubject || $subject === $label) { + return $slug; + } + } + return null; + } + + protected function countUnitSegments(string $unitTitle, array $chapterToUnit): int + { + $unitTitle = trim($unitTitle); + if ($unitTitle === '') { + return 0; + } + $parts = array_filter(array_map('trim', explode(';', $unitTitle)), static fn ($part) => $part !== ''); + if (! $parts) { + return 0; + } + + $seen = []; + foreach ($parts as $part) { + [$unitPart, $chapterPart] = $this->splitUnitChapterSegment($part); + $key = $this->resolveUnitKey($unitPart, $chapterPart, $chapterToUnit); + if ($key === '') { + $key = $part; + } + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + } + + return count($seen); + } + + protected function splitUnitChapterSegment(string $segment): array + { + $segment = trim($segment); + if ($segment === '') { + return ['', '']; + } + $pos = strrpos($segment, '/'); + if ($pos === false) { + return [$segment, '']; + } + $unitPart = trim(substr($segment, 0, $pos)); + $chapterPart = trim(substr($segment, $pos + 1)); + return [$unitPart, $chapterPart]; + } + + protected function extractUnitKeys(string $unitTitle, array $chapterToUnit): array + { + $unitTitle = trim($unitTitle); + if ($unitTitle === '') { + return []; + } + $parts = array_filter(array_map('trim', explode(';', $unitTitle)), static fn ($part) => $part !== ''); + if (! $parts) { + return []; + } + + $keys = []; + foreach ($parts as $part) { + [$unitPart, $chapterPart] = $this->splitUnitChapterSegment($part); + $key = $this->resolveUnitKey($unitPart, $chapterPart, $chapterToUnit); + if ($key === '') { + continue; + } + $keys[$key] = true; + } + + return array_keys($keys); + } + + protected function resolveUnitKey(string $unitPart, string $chapterPart, array $chapterToUnit): string + { + if ($chapterPart !== '' && ! empty($chapterToUnit[$chapterPart])) { + return (string) $chapterToUnit[$chapterPart]; + } + if (empty($chapterToUnit)) { + if (preg_match('/\bunit\s*(\d+)\b/i', $unitPart, $matches)) { + return (string) $matches[1]; + } + return ''; + } + if (preg_match('/\bunit\s*(\d+)\b/i', $unitPart, $matches)) { + return (string) $matches[1]; + } + if ($unitPart !== '') { + return $unitPart; + } + if ($chapterPart !== '') { + return $chapterPart; + } + return ''; + } + + protected function buildSectionStat(int $submitted, int $expectedDays): array + { + $percent = $expectedDays > 0 ? round(($submitted * 100) / $expectedDays, 1) : 0.0; + if ($expectedDays === 0) { + $badgeClass = 'bg-secondary'; + $labelClass = 'text-muted'; + } elseif ($percent >= 100) { + $badgeClass = 'bg-success'; + $labelClass = 'text-success'; + } elseif ($percent >= 60) { + $badgeClass = 'bg-warning text-dark'; + $labelClass = 'text-warning'; + } elseif ($percent >= 40) { + $badgeClass = 'bg-orange text-dark'; + $labelClass = 'text-warning'; + } else { + $badgeClass = 'bg-danger'; + $labelClass = 'text-danger'; + } + + return [ + 'submitted' => $submitted, + 'expected' => $expectedDays, + 'percent' => $percent, + 'badgeClass' => $badgeClass, + 'labelClass' => $labelClass, + ]; + } } diff --git a/app/Controllers/ApiDocsController.php b/app/Controllers/ApiDocsController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php old mode 100644 new mode 100755 index 96aa341..df2a879 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -56,13 +56,32 @@ class AuthController extends Controller public function loginMask() { - // Serve the login view directly here - return view('user/login'); // Adjust the view path as needed + $redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getGet('redirect_to') ?? '')); + + if (session()->get('is_logged_in')) { + if ($redirectTo !== null) { + return redirect()->to($redirectTo); + } + + $roles = session()->get('roles') ?? []; + if (empty($roles) && session()->get('role')) { + $roles = [session()->get('role')]; + } + + if (!empty($roles)) { + return $this->redirectToDashboard($roles); + } + } + + return view('user/login', [ + 'redirect_to' => $redirectTo ?? '', + ]); } public function login() { log_message('info', 'Processing login form submission.'); + $redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getPost('redirect_to') ?? $this->request->getGet('redirect_to') ?? '')); // Step 1: Get email, password, and IP from the request $email = $this->request->getPost('email'); @@ -98,7 +117,7 @@ class AuthController extends Controller $this->logLoginAttempt($user['id'], $user['email'], $ip, $this->request->getUserAgent()); // ✅ Step 7: Call loginUser() to set session and redirect - return $this->loginUser($user); + return $this->loginUser($user, $redirectTo); } else { // Step 8: Password mismatch — log failed attempt $this->handleFailedLogin($user['id'], $user['email'], $ip); @@ -469,6 +488,7 @@ class AuthController extends Controller // Generate a secure token for the password reset helper('text'); $token = bin2hex(random_bytes(48)); + $tokenHash = hash('sha256', $token); // Calculate the expiration time for the token (1 hour from now) $expires_at = Time::now()->addHours(1); @@ -477,7 +497,7 @@ class AuthController extends Controller $passwordResetModel = new PasswordResetModel(); $passwordResetModel->insert([ 'email' => $email, - 'token' => $token, + 'token' => $tokenHash, 'created_at' => Time::now(), 'expires_at' => $expires_at, ]); @@ -490,7 +510,7 @@ class AuthController extends Controller return true; } - private function loginUser($user) + private function loginUser($user, ?string $redirectTo = null) { $userRoleModel = new UserRoleModel(); $roles = $userRoleModel->select('roles.name') @@ -524,9 +544,17 @@ class AuthController extends Controller if (count($roleNames) === 1) { // One role → set and redirect directly session()->set('role', $roleNames[0]); + if ($redirectTo !== null) { + return redirect()->to($redirectTo); + } return $this->redirectToDashboard([$roleNames[0]]); } else { // Multiple roles → redirect to role selection view + if ($redirectTo !== null) { + session()->set('post_login_redirect', $redirectTo); + } else { + session()->remove('post_login_redirect'); + } return redirect()->to('/select-role'); } @@ -591,12 +619,17 @@ private function redirectToDashboard(array $roles) { $selectedRole = $this->request->getPost('selected_role'); $availableRoles = session()->get('roles'); + $redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getPost('redirect_to') ?? session()->get('post_login_redirect') ?? '')); if (!$selectedRole || !in_array($selectedRole, $availableRoles)) { return redirect()->to('/select-role')->with('error', 'Invalid role selected.'); } session()->set('role', $selectedRole); + session()->remove('post_login_redirect'); + if ($redirectTo !== null) { + return redirect()->to($redirectTo); + } return $this->redirectToDashboard([$selectedRole]); } @@ -608,7 +641,44 @@ private function redirectToDashboard(array $roles) return redirect()->to('/login')->with('error', 'No roles available.'); } - return view('auth/select_role', ['roles' => $roles]); + return view('auth/select_role', [ + 'roles' => $roles, + 'redirect_to' => (string) (session()->get('post_login_redirect') ?? ''), + ]); + } + + private function sanitizeRedirectTarget(string $redirectTo): ?string + { + $redirectTo = trim($redirectTo); + if ($redirectTo === '') { + return null; + } + + if (preg_match('#^https?://#i', $redirectTo)) { + $appHost = (string) parse_url(base_url('/'), PHP_URL_HOST); + $targetHost = (string) parse_url($redirectTo, PHP_URL_HOST); + $targetPath = (string) parse_url($redirectTo, PHP_URL_PATH); + $targetQuery = (string) parse_url($redirectTo, PHP_URL_QUERY); + + if ($appHost === '' || $targetHost === '' || strcasecmp($appHost, $targetHost) !== 0) { + return null; + } + + $redirectTo = $targetPath !== '' ? $targetPath : '/'; + if ($targetQuery !== '') { + $redirectTo .= '?' . $targetQuery; + } + } + + if (!str_starts_with($redirectTo, '/')) { + $redirectTo = '/' . ltrim($redirectTo, '/'); + } + + if (str_starts_with($redirectTo, '//')) { + return null; + } + + return $redirectTo; } } diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/ClassProgressController.php b/app/Controllers/ClassProgressController.php old mode 100644 new mode 100755 index 134596b..859f308 --- a/app/Controllers/ClassProgressController.php +++ b/app/Controllers/ClassProgressController.php @@ -7,6 +7,7 @@ use App\Models\ClassProgressAttachmentModel; use App\Models\ConfigurationModel; use App\Models\SubjectCurriculumModel; use App\Models\TeacherClassModel; +use App\Services\SemesterRangeService; use CodeIgniter\Exceptions\PageNotFoundException; class ClassProgressController extends BaseController @@ -27,6 +28,10 @@ class ClassProgressController extends BaseController 'db_subject' => 'Quran/Arabic', ], ]; + + /** Unit column for teacher custom Islamic / Quran rows; must match teacher form JS. Segment: "Custom / {text}". */ + public const CUSTOM_UNIT_ROW_LABEL = 'Custom'; + protected ClassProgressReportModel $reportModel; protected ClassProgressAttachmentModel $attachmentModel; protected TeacherClassModel $teacherClassModel; @@ -56,6 +61,7 @@ class ClassProgressController extends BaseController $classSectionName = $first['class_section_name'] ?? null; $classId = $first['class_id'] ?? null; $sundayOptions = $this->buildSundayOptions(); + $defaultWeekStart = $this->pickDefaultWeekStart($sundayOptions); $subjectCurriculum = []; if ($classId) { foreach (self::SUBJECT_SECTIONS as $slug => $section) { @@ -69,7 +75,7 @@ class ClassProgressController extends BaseController 'classSectionName' => $classSectionName, 'classId' => $classId, 'sundayOptions' => $sundayOptions, - 'defaultWeekStart' => $sundayOptions[0] ?? '', + 'defaultWeekStart' => $defaultWeekStart, ]; return view('teacher/class_progress_submit', $data); } @@ -96,6 +102,10 @@ class ClassProgressController extends BaseController return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); } + if (! $this->hasIslamicUnitSelection()) { + return redirect()->back()->withInput()->with('error', 'Please select at least one Islamic Studies unit.'); + } + $attachmentErrors = $this->validateAttachmentFiles($subjectSections); if (! empty($attachmentErrors)) { return redirect()->back()->withInput()->with('errors', $attachmentErrors); @@ -117,6 +127,32 @@ class ClassProgressController extends BaseController return redirect()->back()->withInput()->with('error', 'No class assignment found for this report.'); } + $confirmOverwrite = (bool) $this->request->getPost('confirm_overwrite'); + $existingReports = $this->reportModel + ->select('id') + ->where('class_section_id', $classSectionId) + ->where('week_start', $weekStart) + ->where('teacher_id', $teacherId) + ->findAll(); + + if (! $confirmOverwrite && ! empty($existingReports)) { + return redirect()->back() + ->withInput() + ->with('warning', 'A progress report already exists for this week, are you sure you want to override it?') + ->with('confirm_overwrite', true); + } + + if ($confirmOverwrite && ! empty($existingReports)) { + $existingIds = array_values(array_filter(array_map( + static fn (array $row): int => (int) ($row['id'] ?? 0), + $existingReports + ))); + if (! empty($existingIds)) { + $this->attachmentModel->whereIn('report_id', $existingIds)->delete(); + $this->reportModel->whereIn('id', $existingIds)->delete(); + } + } + $status = self::DEFAULT_STATUS; $reportsCreated = 0; @@ -171,10 +207,16 @@ class ClassProgressController extends BaseController if ($selectedSectionId && ! in_array($selectedSectionId, $validSectionIds, true)) { $selectedSectionId = $validSectionIds[0] ?? null; } + [$semester, $schoolYear] = $this->resolveCurrentTerm(); + $allowedTeacherIds = $this->resolveAssignedTeacherIds($selectedSectionId, $semester, $schoolYear); + if (empty($allowedTeacherIds)) { + $allowedTeacherIds = [$teacherId]; + } $builder = $this->reportModel - ->select('class_progress_reports.*, cs.class_section_name') + ->select('class_progress_reports.*, cs.class_section_name, CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name') ->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left') - ->where('teacher_id', $teacherId); + ->join('users u', 'u.id = class_progress_reports.teacher_id', 'left') + ->whereIn('teacher_id', $allowedTeacherIds); if ($selectedSectionId) { $builder->where('class_progress_reports.class_section_id', $selectedSectionId); } @@ -216,20 +258,33 @@ class ClassProgressController extends BaseController { $teacherId = (int) session()->get('user_id'); $row = $this->reportModel - ->select('class_progress_reports.*, cs.class_section_name') + ->select('class_progress_reports.*, cs.class_section_name, CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name') ->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left') - ->where('teacher_id', $teacherId) - ->find((int) $id); + ->join('users u', 'u.id = class_progress_reports.teacher_id', 'left') + ->where('class_progress_reports.id', (int) $id) + ->first(); if (! $row) { throw new PageNotFoundException('Progress report not found.'); } + [$semester, $schoolYear] = $this->resolveCurrentTerm(); + $allowedTeacherIds = $this->resolveAssignedTeacherIds((int) $row['class_section_id'], $semester, $schoolYear); + if (empty($allowedTeacherIds)) { + if ($teacherId !== (int) $row['teacher_id']) { + throw new PageNotFoundException('Progress report not found.'); + } + $allowedTeacherIds = [(int) $row['teacher_id']]; + } elseif (! in_array($teacherId, $allowedTeacherIds, true)) { + throw new PageNotFoundException('Progress report not found.'); + } + $row['status_label'] = self::STATUS_OPTIONS[$row['status']] ?? 'Unknown'; $weeklyReports = $this->reportModel - ->select('class_progress_reports.*, cs.class_section_name') + ->select('class_progress_reports.*, cs.class_section_name, CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name') ->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left') - ->where('teacher_id', $teacherId) + ->join('users u', 'u.id = class_progress_reports.teacher_id', 'left') + ->whereIn('teacher_id', $allowedTeacherIds) ->where('class_progress_reports.class_section_id', $row['class_section_id']) ->where('week_start', $row['week_start']) ->orderBy('subject', 'ASC') @@ -255,6 +310,262 @@ class ClassProgressController extends BaseController ]); } + public function edit($id) + { + $teacherId = (int) session()->get('user_id'); + $row = $this->reportModel + ->select('class_progress_reports.*, cs.class_section_name') + ->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left') + ->where('class_progress_reports.id', (int) $id) + ->first(); + + if (! $row) { + throw new PageNotFoundException('Progress report not found.'); + } + + [$semester, $schoolYear] = $this->resolveCurrentTerm(); + $allowedTeacherIds = $this->resolveAssignedTeacherIds((int) $row['class_section_id'], $semester, $schoolYear); + if (empty($allowedTeacherIds)) { + if ($teacherId !== (int) $row['teacher_id']) { + throw new PageNotFoundException('Progress report not found.'); + } + $allowedTeacherIds = [(int) $row['teacher_id']]; + } elseif (! in_array($teacherId, $allowedTeacherIds, true)) { + throw new PageNotFoundException('Progress report not found.'); + } + + $weeklyReports = $this->reportModel + ->select('class_progress_reports.*') + ->whereIn('teacher_id', $allowedTeacherIds) + ->where('class_section_id', $row['class_section_id']) + ->where('week_start', $row['week_start']) + ->orderBy('subject', 'ASC') + ->findAll(); + + $reportMap = []; + foreach ($weeklyReports as $report) { + $subject = (string) ($report['subject'] ?? ''); + if ($subject === '') { + continue; + } + $reportMap[$subject] = $report; + } + + $subjectReports = []; + foreach (self::SUBJECT_SECTIONS as $slug => $section) { + $subjectName = $section['db_subject'] ?? $section['label'] ?? $slug; + $report = $reportMap[$subjectName] ?? null; + if (! $report) { + continue; + } + $parsed = $this->parseUnitChapterSummary((string) ($report['unit_title'] ?? '')); + $subjectReports[$slug] = [ + 'report_id' => (int) ($report['id'] ?? 0), + 'covered' => $report['covered'] ?? '', + 'homework' => $report['homework'] ?? '', + 'unit_title' => $report['unit_title'] ?? '', + 'unit_values' => $parsed['units'], + 'chapter_values' => $parsed['chapters'], + ]; + } + + $assignments = $this->loadTeacherSections($teacherId); + $classId = null; + $classSectionName = $row['class_section_name'] ?? ''; + foreach ($assignments as $assignment) { + if ((int) ($assignment['class_section_id'] ?? 0) === (int) $row['class_section_id']) { + $classId = $assignment['class_id'] ?? null; + $classSectionName = $assignment['class_section_name'] ?? $classSectionName; + break; + } + } + + $subjectCurriculum = []; + if ($classId) { + foreach (self::SUBJECT_SECTIONS as $slug => $section) { + $subjectCurriculum[$slug] = $this->curriculumModel->getOptionsForClass((int) $classId, $slug); + } + } + + $sundayOptions = $this->buildSundayOptions(); + if (!in_array($row['week_start'], $sundayOptions, true)) { + array_unshift($sundayOptions, $row['week_start']); + } + + return view('teacher/class_progress_submit', [ + 'subjectSections' => self::SUBJECT_SECTIONS, + 'subjectCurriculum' => $subjectCurriculum, + 'classSectionId' => $row['class_section_id'], + 'classSectionName' => $classSectionName, + 'classId' => $classId, + 'sundayOptions' => $sundayOptions, + 'defaultWeekStart' => $row['week_start'], + 'existingWeekEnd' => $row['week_end'], + 'existingReports' => $subjectReports, + 'isEdit' => true, + 'formAction' => base_url('teacher/progress/update/' . (int) $row['id']), + 'submitLabel' => 'Update Progress', + ]); + } + + public function update($id) + { + $teacherId = (int) session()->get('user_id'); + $row = $this->reportModel->find((int) $id); + if (! $row) { + throw new PageNotFoundException('Progress report not found.'); + } + + [$semester, $schoolYear] = $this->resolveCurrentTerm(); + $allowedTeacherIds = $this->resolveAssignedTeacherIds((int) $row['class_section_id'], $semester, $schoolYear); + if (empty($allowedTeacherIds)) { + if ($teacherId !== (int) $row['teacher_id']) { + throw new PageNotFoundException('Progress report not found.'); + } + $allowedTeacherIds = [(int) $row['teacher_id']]; + } elseif (! in_array($teacherId, $allowedTeacherIds, true)) { + throw new PageNotFoundException('Progress report not found.'); + } + + $subjectSections = self::SUBJECT_SECTIONS; + $rules = [ + 'class_section_id' => 'required|integer', + 'week_start' => 'required|valid_date[Y-m-d]', + 'week_end' => 'required|valid_date[Y-m-d]', + ]; + foreach ($subjectSections as $slug => $section) { + $rules["covered_$slug"] = 'required|string'; + $rules["homework_$slug"] = 'permit_empty|string'; + $rules["unit_{$slug}.*"] = 'permit_empty|string|max_length[120]'; + $rules["chapter_{$slug}.*"] = 'permit_empty|string|max_length[120]'; + } + + if (! $this->validate($rules)) { + return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); + } + + if (! $this->hasIslamicUnitSelection()) { + return redirect()->back()->withInput()->with('error', 'Please select at least one Islamic Studies unit.'); + } + + $attachmentErrors = $this->validateAttachmentFiles($subjectSections); + if (! empty($attachmentErrors)) { + return redirect()->back()->withInput()->with('errors', $attachmentErrors); + } + + $weekStart = (string) $this->request->getPost('week_start'); + $weekEnd = (string) $this->request->getPost('week_end'); + if ($weekStart && ! $weekEnd) { + $weekEnd = $this->buildWeekEndFromStart($weekStart); + } + if ($weekStart && $weekEnd && strtotime($weekEnd) < strtotime($weekStart)) { + return redirect()->back()->withInput()->with('error', 'Week end must be the same as or after the week start.'); + } + + $classSectionId = (int) ($row['class_section_id'] ?? 0); + if ($classSectionId === 0) { + return redirect()->back()->withInput()->with('error', 'No class assignment found for this report.'); + } + + $confirmOverwrite = (bool) $this->request->getPost('confirm_overwrite'); + if ($weekStart && $weekStart !== (string) ($row['week_start'] ?? '')) { + $conflicts = $this->reportModel + ->select('id') + ->where('class_section_id', $classSectionId) + ->where('week_start', $weekStart) + ->where('teacher_id', $teacherId) + ->findAll(); + if (! $confirmOverwrite && ! empty($conflicts)) { + return redirect()->back() + ->withInput() + ->with('warning', 'A progress report already exists for this week, are you sure you want to override it?') + ->with('confirm_overwrite', true); + } + if ($confirmOverwrite && ! empty($conflicts)) { + $conflictIds = array_values(array_filter(array_map( + static fn (array $row): int => (int) ($row['id'] ?? 0), + $conflicts + ))); + if (! empty($conflictIds)) { + $this->attachmentModel->whereIn('report_id', $conflictIds)->delete(); + $this->reportModel->whereIn('id', $conflictIds)->delete(); + } + } + } + + $weeklyReports = $this->reportModel + ->select('class_progress_reports.*') + ->whereIn('teacher_id', $allowedTeacherIds) + ->where('class_section_id', $classSectionId) + ->where('week_start', $row['week_start']) + ->orderBy('subject', 'ASC') + ->findAll(); + + $reportMap = []; + foreach ($weeklyReports as $report) { + $subject = (string) ($report['subject'] ?? ''); + if ($subject === '') { + continue; + } + $reportMap[$subject] = $report; + } + + $reportsUpdated = 0; + $flagsInput = $this->request->getPost('flags'); + foreach ($subjectSections as $slug => $section) { + $covered = trim((string) $this->request->getPost("covered_$slug")); + if ($covered === '') { + continue; + } + $homework = trim((string) $this->request->getPost("homework_$slug")); + $unitTitle = $this->buildUnitChapterSummary($slug); + $subjectName = $section['db_subject'] ?? $section['label'] ?? $slug; + $existing = $reportMap[$subjectName] ?? null; + if ($unitTitle === null && $existing) { + $unitTitle = $existing['unit_title'] ?? null; + } + + $data = [ + 'class_section_id' => $classSectionId, + 'week_start' => $weekStart, + 'week_end' => $weekEnd, + 'subject' => $subjectName, + 'unit_title' => $unitTitle, + 'covered' => $covered, + 'homework' => $homework ?: null, + ]; + if ($flagsInput !== null) { + $data['flags_json'] = $this->normalizeFlags($flagsInput); + } + + if ($existing) { + $this->reportModel->update((int) $existing['id'], $data); + $reportId = (int) $existing['id']; + } else { + $data['teacher_id'] = $teacherId; + $data['status'] = self::DEFAULT_STATUS; + $reportId = (int) $this->reportModel->insert($data, true); + } + + $attachmentField = "attachment_$slug"; + $attachments = $this->request->getFileMultiple($attachmentField) ?? []; + $storedAttachments = $this->storeAttachments($reportId, $attachments); + if (! empty($storedAttachments)) { + $this->attachmentModel->insertBatch($storedAttachments); + if (empty($existing['attachment_path'] ?? '')) { + $this->reportModel->update($reportId, ['attachment_path' => $storedAttachments[0]['file_path']]); + } + } + $reportsUpdated++; + } + + if ($reportsUpdated === 0) { + return redirect()->back()->withInput()->with('error', 'Please provide progress for at least one subject.'); + } + + return redirect()->to('teacher/progress/history')->with('success', 'Progress reports updated.'); + } + public function attachment($id) { $row = $this->reportModel->find((int)$id); @@ -398,6 +709,37 @@ class ClassProgressController extends BaseController return $flags ? json_encode($flags) : null; } + /** + * Split stored unit_title into curriculum lines vs custom teacher-typed subjects ("Custom / …"). + * Keep in sync with teacher form, {@see buildUnitChapterSummary()}, and admin views. + * + * @return array{curriculum: list, custom: list} + */ + public static function splitUnitTitleForDisplay(string $unitTitle): array + { + $unitTitle = trim($unitTitle); + if ($unitTitle === '') { + return ['curriculum' => [], 'custom' => []]; + } + + $segments = preg_split('/\s*;\s*/', $unitTitle, -1, PREG_SPLIT_NO_EMPTY); + $curriculum = []; + $custom = []; + foreach ($segments as $seg) { + $seg = trim((string) $seg); + if ($seg === '') { + continue; + } + if (preg_match('/^Custom\s*\/\s*(.+)$/iu', $seg, $m)) { + $custom[] = trim($m[1]); + } else { + $curriculum[] = $seg; + } + } + + return ['curriculum' => $curriculum, 'custom' => $custom]; + } + protected function buildUnitChapterSummary(string $slug): ?string { $unitValues = array_map('trim', (array) $this->request->getPost("unit_$slug")); @@ -410,9 +752,12 @@ class ClassProgressController extends BaseController if ($unit === '' && $chapter === '') { continue; } + if (strcasecmp($unit, self::CUSTOM_UNIT_ROW_LABEL) === 0 && $chapter !== '') { + $unit = self::CUSTOM_UNIT_ROW_LABEL; + } $segment = $unit; if ($chapter !== '') { - $segment = $segment !== '' ? $segment . ' / ' . $chapter : $chapter; + $segment = $segment !== '' ? $unit . ' / ' . $chapter : $chapter; } if ($segment === '') { continue; @@ -423,10 +768,98 @@ class ClassProgressController extends BaseController return null; } $summary = implode(' ; ', $parts); + return mb_strlen($summary) > 120 ? mb_substr($summary, 0, 120) : $summary; } + protected function hasIslamicUnitSelection(): bool + { + $unitValues = array_map('trim', (array) $this->request->getPost('unit_islamic')); + $chapterValues = array_map('trim', (array) $this->request->getPost('chapter_islamic')); + $count = max(count($unitValues), count($chapterValues)); + for ($i = 0; $i < $count; $i++) { + $unit = $unitValues[$i] ?? ''; + $chapter = $chapterValues[$i] ?? ''; + if ($unit !== '' || $chapter !== '') { + return true; + } + } + + return false; + } + + protected function parseUnitChapterSummary(string $summary): array + { + $summary = trim($summary); + if ($summary === '') { + return ['units' => [], 'chapters' => []]; + } + + $units = []; + $chapters = []; + $segments = preg_split('/\s*;\s*/', $summary, -1, PREG_SPLIT_NO_EMPTY); + foreach ($segments as $segment) { + $segment = trim($segment); + if ($segment === '') { + continue; + } + if (preg_match('/^Custom\s*\/\s*(.+)$/iu', $segment, $m)) { + $units[] = self::CUSTOM_UNIT_ROW_LABEL; + $chapters[] = trim($m[1]); + + continue; + } + $parts = preg_split('/\s*\/\s*/', $segment, 2); + if (count($parts) === 2) { + $u = trim($parts[0]); + if (strcasecmp($u, self::CUSTOM_UNIT_ROW_LABEL) === 0) { + $u = self::CUSTOM_UNIT_ROW_LABEL; + } + $units[] = $u; + $chapters[] = trim($parts[1]); + } else { + $units[] = $segment; + $chapters[] = ''; + } + } + + return ['units' => $units, 'chapters' => $chapters]; + } + protected function buildSundayOptions(int $count = 12): array + { + $range = $this->resolveProgressDateRange(); + if ($range === null) { + return $this->buildUpcomingSundayOptions($count); + } + [$rangeStart, $rangeEnd] = $range; + + try { + $start = new \DateTime($rangeStart); + $end = new \DateTime($rangeEnd); + } catch (\Exception $e) { + return $this->buildUpcomingSundayOptions($count); + } + + if ($end < $start) { + [$start, $end] = [$end, $start]; + } + + $weekday = (int) $start->format('w'); + if ($weekday !== 0) { + $start->modify('next sunday'); + } + + $options = []; + while ($start <= $end) { + $options[] = $start->format('Y-m-d'); + $start->modify('+7 days'); + } + + return $options; + } + + protected function buildUpcomingSundayOptions(int $count): array { $start = new \DateTime('today'); $weekday = (int) $start->format('w'); @@ -443,6 +876,48 @@ class ClassProgressController extends BaseController return $options; } + protected function resolveProgressDateRange(): ?array + { + $schoolYear = (string) ($this->configModel->getConfig('school_year') ?? ''); + if ($schoolYear === '') { + return null; + } + + $semesterResolver = new SemesterRangeService($this->configModel); + $semester = $semesterResolver->normalizeSemester((string) ($this->configModel->getConfig('semester') ?? '')); + if ($semester === '') { + $semester = $semesterResolver->getSemesterForDate(); + } + + if ($semester !== '') { + $range = $semesterResolver->getSemesterRange($schoolYear, $semester); + if ($range !== null) { + return $range; + } + } + + return $semesterResolver->getSchoolYearRange($schoolYear); + } + + protected function pickDefaultWeekStart(array $options): string + { + if (empty($options)) { + return ''; + } + + $today = date('Y-m-d'); + $default = ''; + foreach ($options as $option) { + if ($option <= $today) { + $default = $option; + continue; + } + break; + } + + return $default !== '' ? $default : $options[0]; + } + protected function buildWeekEndFromStart(string $weekStart): string { try { @@ -468,4 +943,27 @@ class ClassProgressController extends BaseController mkdir($this->attachmentStoragePath, 0755, true); } } + + protected function resolveCurrentTerm(): array + { + $schoolYear = (string) ($this->configModel->getConfig('school_year') ?? ''); + $semester = (string) ($this->configModel->getConfig('semester') ?? ''); + return [$semester, $schoolYear]; + } + + protected function resolveAssignedTeacherIds(?int $classSectionId, string $semester, string $schoolYear): array + { + if (! $classSectionId || $schoolYear === '') { + return []; + } + $rows = $this->teacherClassModel->assignedForSectionTerm($classSectionId, $semester, $schoolYear); + $ids = []; + foreach ($rows as $row) { + $id = (int) ($row['teacher_id'] ?? 0); + if ($id > 0) { + $ids[$id] = true; + } + } + return array_keys($ids); + } } diff --git a/app/Controllers/DocsController.php b/app/Controllers/DocsController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/ErrorController.php b/app/Controllers/ErrorController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/Home.php b/app/Controllers/Home.php old mode 100644 new mode 100755 diff --git a/app/Controllers/InitializeRolesPermissions.php b/app/Controllers/InitializeRolesPermissions.php old mode 100644 new mode 100755 diff --git a/app/Controllers/ParentProgressController.php b/app/Controllers/ParentProgressController.php old mode 100644 new mode 100755 index e32aede..de6f577 --- a/app/Controllers/ParentProgressController.php +++ b/app/Controllers/ParentProgressController.php @@ -29,18 +29,12 @@ class ParentProgressController extends BaseController public function index() { - $sectionIds = $this->getParentSectionIds(); - $sectionOptions = $this->buildSectionOptions($sectionIds); + $students = $this->getParentStudents(); + $sectionIds = array_values(array_unique(array_filter(array_map( + static fn (array $student): int => (int) ($student['class_section_id'] ?? 0), + $students + )))); $subjectSections = ClassProgressController::SUBJECT_SECTIONS; - $selectedSectionId = (int) $this->request->getGet('class_section_id'); - $validSectionIds = array_keys($sectionOptions); - - if ($selectedSectionId === 0 && ! empty($validSectionIds)) { - $selectedSectionId = $validSectionIds[0]; - } - if ($selectedSectionId && ! in_array($selectedSectionId, $validSectionIds, true)) { - $selectedSectionId = $validSectionIds[0] ?? null; - } $rows = []; if (! empty($sectionIds)) { @@ -50,23 +44,32 @@ class ParentProgressController extends BaseController ->join('users u', 'u.id = class_progress_reports.teacher_id', 'left') ->whereIn('class_progress_reports.class_section_id', $sectionIds); - if ($selectedSectionId) { - $builder->where('class_progress_reports.class_section_id', $selectedSectionId); - } - $rows = $builder ->orderBy('week_start', 'DESC') ->findAll(); } - $reportGroups = $this->groupReportsByWeek($rows); + $studentReportGroups = []; + foreach ($students as $student) { + $studentId = (int) ($student['student_id'] ?? 0); + if ($studentId === 0) { + continue; + } + $classSectionId = (int) ($student['class_section_id'] ?? 0); + $studentRows = $classSectionId + ? array_values(array_filter( + $rows, + static fn (array $row): bool => (int) ($row['class_section_id'] ?? 0) === $classSectionId + )) + : []; + $studentReportGroups[$studentId] = $this->groupReportsByWeek($studentRows); + } return view('parent/class_progress_list', [ - 'reportGroups' => $reportGroups, + 'students' => $students, + 'studentReportGroups' => $studentReportGroups, 'subjectSections' => $subjectSections, - 'classSectionOptions' => $sectionOptions, - 'selectedSectionId' => $selectedSectionId, - 'hasSections' => ! empty($sectionIds), + 'hasStudents' => ! empty($students), ]); } @@ -198,20 +201,53 @@ class ParentProgressController extends BaseController return $options; } + protected function getParentStudents(): array + { + $parentId = (int) session()->get('user_id'); + if ($parentId === 0) { + return []; + } + + $rows = $this->db->table('enrollments e') + ->select('e.student_id, e.class_section_id, e.updated_at, e.created_at, s.firstname, s.lastname, cs.class_section_name') + ->join('students s', 's.id = e.student_id') + ->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left') + ->where('e.parent_id', $parentId) + ->where('e.is_withdrawn', 0) + ->orderBy('e.updated_at', 'DESC') + ->orderBy('e.created_at', 'DESC') + ->get() + ->getResultArray(); + + $students = []; + foreach ($rows as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + if ($studentId === 0 || isset($students[$studentId])) { + continue; + } + $students[$studentId] = $row; + } + + return array_values($students); + } + protected function groupReportsByWeek(array $rows): array { $reportGroups = []; foreach ($rows as $row) { $row['status_label'] = ClassProgressController::STATUS_OPTIONS[$row['status']] ?? 'Unknown'; - $key = $row['week_start'] ?? ''; - if ($key === '') { + $weekStart = $row['week_start'] ?? ''; + $sectionId = (int) ($row['class_section_id'] ?? 0); + if ($weekStart === '' || $sectionId === 0) { continue; } + $key = $weekStart . ':' . $sectionId; if (! isset($reportGroups[$key])) { $reportGroups[$key] = [ 'week_start' => $row['week_start'] ?? '', 'week_end' => $row['week_end'] ?? '', 'class_section_name' => $row['class_section_name'] ?? '', + 'class_section_id' => $sectionId, 'reports' => [], ]; } diff --git a/app/Controllers/ParentReportCardController.php b/app/Controllers/ParentReportCardController.php new file mode 100755 index 0000000..8206712 --- /dev/null +++ b/app/Controllers/ParentReportCardController.php @@ -0,0 +1,186 @@ +db = \Config\Database::connect(); + $this->configModel = new ConfigurationModel(); + $this->ackModel = new ReportCardAcknowledgementModel(); + $this->studentModel = new StudentModel(); + } + + public function index() + { + $parentId = $this->resolvePrimaryParentId(); + if (! $parentId) { + return redirect()->back()->with('error', 'Unable to retrieve student data. Please contact support.'); + } + + $schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->configModel->getConfig('school_year') ?? '')); + $semester = trim((string) ($this->request->getGet('semester') ?? $this->configModel->getConfig('semester') ?? '')); + + $builder = $this->db->table('students s') + ->select('s.id, s.firstname, s.lastname, cs.class_section_name') + ->join('student_class sc', 'sc.student_id = s.id', 'left') + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->where('s.parent_id', $parentId) + ->orderBy('s.firstname', 'ASC') + ->orderBy('s.lastname', 'ASC'); + + if ($schoolYear !== '') { + $builder->where('sc.school_year', $schoolYear); + } + if ($semester !== '') { + $builder->where('sc.semester', $semester); + } + + $students = $builder->get()->getResultArray(); + $studentIds = array_values(array_filter(array_map(static fn ($s) => (int) ($s['id'] ?? 0), $students))); + + $ackMap = []; + if (! empty($studentIds)) { + $rows = $this->ackModel + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->where('semester', $semester) + ->whereIn('student_id', $studentIds) + ->findAll(); + foreach ($rows as $row) { + $ackMap[(int) $row['student_id']] = $row; + } + } + + return view('parent/report_cards', [ + 'students' => $students, + 'ackMap' => $ackMap, + 'schoolYear' => $schoolYear, + 'semester' => $semester, + ]); + } + + public function view($studentId) + { + $parentId = $this->resolvePrimaryParentId(); + if (! $parentId) { + return redirect()->back()->with('error', 'Unable to retrieve student data. Please contact support.'); + } + + $student = $this->studentModel->find((int) $studentId); + if (! $student || (int) ($student['parent_id'] ?? 0) !== $parentId) { + throw new PageNotFoundException('Student not found.'); + } + + $schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->configModel->getConfig('school_year') ?? '')); + $semester = trim((string) ($this->request->getGet('semester') ?? $this->configModel->getConfig('semester') ?? '')); + + $this->touchAcknowledgement($parentId, (int) $studentId, $schoolYear, $semester, [ + 'viewed_at' => date('Y-m-d H:i:s'), + ]); + + $url = site_url('report-card/student/' . (int) $studentId); + $query = []; + if ($schoolYear !== '') { + $query['school_year'] = $schoolYear; + } + if ($semester !== '') { + $query['semester'] = $semester; + } + if ($query) { + $url .= '?' . http_build_query($query); + } + + return redirect()->to($url); + } + + public function sign($studentId) + { + $parentId = $this->resolvePrimaryParentId(); + if (! $parentId) { + return redirect()->back()->with('error', 'Unable to retrieve student data. Please contact support.'); + } + + $student = $this->studentModel->find((int) $studentId); + if (! $student || (int) ($student['parent_id'] ?? 0) !== $parentId) { + throw new PageNotFoundException('Student not found.'); + } + + $name = trim((string) $this->request->getPost('signed_name')); + if ($name === '') { + return redirect()->back()->with('error', 'Please type your full name to sign.'); + } + + $schoolYear = trim((string) ($this->configModel->getConfig('school_year') ?? '')); + $semester = trim((string) ($this->configModel->getConfig('semester') ?? '')); + $now = date('Y-m-d H:i:s'); + $this->touchAcknowledgement($parentId, (int) $studentId, $schoolYear, $semester, [ + 'viewed_at' => $now, + 'signed_at' => $now, + 'signed_name' => $name, + 'signer_ip' => $this->request->getIPAddress(), + ]); + + return redirect()->to(site_url('parent/report-cards'))->with('success', 'Report card acknowledged.'); + } + + protected function resolvePrimaryParentId(): ?int + { + $parentId = (int) (session()->get('user_id') ?? 0); + $userType = (string) ($_SESSION['user_type'] ?? ''); + if ($userType === 'primary') { + return $parentId ?: null; + } + if ($userType === 'secondary') { + $row = $this->db->table('parents') + ->select('parent_id') + ->where('secondparent_user_id', $parentId) + ->get() + ->getRowArray(); + return $row ? (int) ($row['parent_id'] ?? 0) : null; + } + if ($userType === 'tertiary') { + $row = $this->db->table('authorized_users') + ->select('user_id as parent_id') + ->where('authorized_user_id', $parentId) + ->get() + ->getRowArray(); + return $row ? (int) ($row['parent_id'] ?? 0) : null; + } + return $parentId ?: null; + } + + protected function touchAcknowledgement( + int $parentId, + int $studentId, + string $schoolYear, + string $semester, + array $values + ): void { + $criteria = [ + 'parent_id' => $parentId, + 'student_id' => $studentId, + 'school_year' => $schoolYear, + 'semester' => $semester, + ]; + $existing = $this->ackModel->where($criteria)->first(); + if ($existing) { + $this->ackModel->update((int) $existing['id'], $values); + return; + } + $this->ackModel->insert(array_merge($criteria, $values)); + } +} diff --git a/app/Controllers/PrintRequests.php b/app/Controllers/PrintRequests.php old mode 100644 new mode 100755 diff --git a/app/Controllers/ProofreadController.php b/app/Controllers/ProofreadController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/TimeOffNotificationController.php b/app/Controllers/TimeOffNotificationController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/AdministratorController.php b/app/Controllers/View/AdministratorController.php old mode 100644 new mode 100755 index 6f538ae..d15eff5 --- a/app/Controllers/View/AdministratorController.php +++ b/app/Controllers/View/AdministratorController.php @@ -28,6 +28,8 @@ use App\Models\ScoreCommentModel; use App\Models\SemesterScoreModel; use App\Models\TeacherClassModel; use App\Models\TeacherSubmissionNotificationHistoryModel; +use App\Models\ExamDraftModel; +use App\Models\HomeworkModel; use App\Services\SemesterRangeService; use CodeIgniter\Events\Events; @@ -416,8 +418,10 @@ class AdministratorController extends BaseController $totalStudents = (int) ( $this->db->table('student_class') ->select('COUNT(DISTINCT student_class.student_id) AS cnt') + ->join('students', 'students.id = student_class.student_id', 'inner') ->where('student_class.school_year', $this->schoolYear) ->where('student_class.class_section_id IS NOT NULL', null, false) + ->where('students.is_active', 1) ->get() ->getRow('cnt') ?? 0 @@ -694,15 +698,26 @@ class AdministratorController extends BaseController public function teacherSubmissionsReport() { - $semester = (string)($this->semester ?? ''); - $schoolYear = (string)($this->schoolYear ?? ''); + $semester = (string)($this->configModel->getConfig('semester') ?? $this->semester ?? ''); + $schoolYear = (string)($this->configModel->getConfig('school_year') ?? $this->schoolYear ?? ''); + $semesterResolver = new SemesterRangeService($this->configModel); + $semesterNorm = $semesterResolver->normalizeSemester($semester); + $semesterFilter = $semesterNorm !== '' ? $semesterNorm : $semester; + $semesterCandidates = $this->buildSemesterCandidates($semesterFilter); + $lowProgressRaw = (string) $this->request->getGet('low_progress_sections'); + $lowProgressSectionIds = array_values(array_unique(array_filter(array_map( + 'intval', + preg_split('/\s*,\s*/', $lowProgressRaw, -1, PREG_SPLIT_NO_EMPTY) + )))); $scoreComments = new ScoreCommentModel(); $semesterScores = new SemesterScoreModel(); $attendanceDays = new AttendanceDayModel(); + $examDrafts = new ExamDraftModel(); + $homeworkModel = new HomeworkModel(); $historyModel = new TeacherSubmissionNotificationHistoryModel(); - $assignmentRows = $this->db->table('teacher_class tc') + $assignmentQuery = $this->db->table('teacher_class tc') ->select([ 'tc.class_section_id', 'cs.class_section_name', @@ -713,11 +728,97 @@ class AdministratorController extends BaseController ]) ->join('classSection cs', 'cs.class_section_id = tc.class_section_id', 'left') ->join('users u', 'u.id = tc.teacher_id', 'left') - ->where('tc.school_year', $schoolYear) - ->where('tc.semester', $semester) - ->orderBy('cs.class_section_name', 'ASC') - ->get() - ->getResultArray(); + ->orderBy('cs.class_section_name', 'ASC'); + + $filteredQuery = clone $assignmentQuery; + if ($schoolYear !== '') { + $filteredQuery = $filteredQuery->where('tc.school_year', $schoolYear); + } + if (!empty($semesterCandidates)) { + $filteredQuery = $filteredQuery->whereIn('tc.semester', $semesterCandidates); + } + + $assignmentRows = $filteredQuery->get()->getResultArray(); + if (empty($assignmentRows) && ($schoolYear !== '' || $semester !== '')) { + $assignmentRows = $assignmentQuery->get()->getResultArray(); + } + + $studentCounts = $this->studentClassModel->getStudentCountsBySection($schoolYear !== '' ? $schoolYear : null); + $sectionRows = $this->classSectionModel + ->select('class_section_id, class_section_name') + ->orderBy('class_section_name', 'ASC') + ->findAll(); + $sectionMap = []; + foreach ($sectionRows as $sectionRow) { + $sectionId = (int) ($sectionRow['class_section_id'] ?? 0); + if ($sectionId <= 0) { + continue; + } + if (empty($studentCounts[$sectionId])) { + continue; + } + $sectionMap[$sectionId] = $sectionRow['class_section_name'] ?? "Section {$sectionId}"; + } + $sectionIds = array_keys($sectionMap); + + [$progressExpectedWeeks, $progressSubmittedBySection] = $this->buildClassProgressStats($sectionIds); + $examDraftCounts = []; + $examDraftDeadline = $this->resolveTeacherDashboardExamDraftDeadline($semester, $schoolYear); + $examDraftDeadlineConfig = trim((string) ($this->configModel->getConfig('exam_draft_deadline') ?? '')); + $examDraftDeadlineFormatted = ''; + if ($examDraftDeadlineConfig !== '') { + $parsedUi = $this->parseExamDraftDeadlineConfigValue(); + $examDraftDeadlineFormatted = $parsedUi !== null ? $parsedUi->format('M j, Y') : ''; + } + $homeworkCounts = []; + if (! empty($sectionIds)) { + $draftBuilder = $examDrafts + ->select('class_section_id') + ->whereIn('class_section_id', $sectionIds); + if ($schoolYear !== '') { + $draftBuilder->where('school_year', $schoolYear); + } + if (!empty($semesterCandidates)) { + $draftBuilder->whereIn('semester', $semesterCandidates); + } + if ($this->db->fieldExists('is_legacy', 'exam_drafts')) { + $draftBuilder->where('is_legacy', 0); + } + $draftRows = $draftBuilder->findAll(); + foreach ($draftRows as $draft) { + $sectionId = (int) ($draft['class_section_id'] ?? 0); + if ($sectionId <= 0) { + continue; + } + $examDraftCounts[$sectionId] = ($examDraftCounts[$sectionId] ?? 0) + 1; + } + + $homeworkBuilder = $homeworkModel + ->select('class_section_id, homework_index') + ->whereIn('class_section_id', $sectionIds); + if ($schoolYear !== '') { + $homeworkBuilder->where('school_year', $schoolYear); + } + if (!empty($semesterCandidates)) { + $homeworkBuilder->whereIn('semester', $semesterCandidates); + } + $homeworkRows = $homeworkBuilder + ->where('score IS NOT NULL', null, false) + ->where('score !=', '') + ->groupBy('class_section_id, homework_index') + ->findAll(); + foreach ($homeworkRows as $row) { + $sectionId = (int) ($row['class_section_id'] ?? 0); + if ($sectionId <= 0) { + continue; + } + $homeworkCounts[$sectionId] = ($homeworkCounts[$sectionId] ?? 0) + 1; + } + } + + if (empty($lowProgressSectionIds)) { + $lowProgressSectionIds = $this->resolveLowProgressSectionIds($sectionIds); + } $teachersBySection = []; foreach ($assignmentRows as $assignment) { @@ -743,7 +844,7 @@ class AdministratorController extends BaseController $entry = &$teachersBySection[$sectionId]; if (!isset($entry)) { $entry = [ - 'class_section' => $assignment['class_section_name'] ?? "Section {$sectionId}", + 'class_section' => $assignment['class_section_name'] ?? ($sectionMap[$sectionId] ?? "Section {$sectionId}"), 'teachers' => [], ]; } @@ -763,35 +864,49 @@ class AdministratorController extends BaseController $missingItemCount = 0; $allTeacherIds = []; $allClassSectionIds = []; - foreach ($teachersBySection as $classSectionId => $section) { + $examTerm = $this->resolveExamTermLabel($semester); + $examScoreField = $examTerm === 'final' ? 'final_exam_score' : 'midterm_exam_score'; + + foreach ($sectionMap as $classSectionId => $sectionName) { $classSectionId = (int)$classSectionId; if ($classSectionId <= 0) { continue; } - $studentEntries = $this->studentClassModel + $studentQuery = $this->studentClassModel ->select('student_id') ->where('class_section_id', $classSectionId) - ->where('semester', $semester) - ->where('school_year', $schoolYear) - ->findAll(); + ->where('school_year', $schoolYear); + if (!empty($semesterCandidates)) { + $studentQuery->whereIn('semester', $semesterCandidates); + } + $studentEntries = $studentQuery->findAll(); + if (empty($studentEntries)) { + $studentEntries = $this->studentClassModel + ->select('student_id') + ->where('class_section_id', $classSectionId) + ->where('school_year', $schoolYear) + ->findAll(); + } $studentIds = array_filter(array_map(static fn($entry) => (int)($entry['student_id'] ?? 0), $studentEntries)); $expected = count($studentIds); $midtermStudents = []; $participationStudents = []; if ($classSectionId > 0) { - $scoreRecords = $semesterScores + $scoreQuery = $semesterScores ->where('class_section_id', $classSectionId) - ->where('semester', $semester) - ->where('school_year', $schoolYear) - ->findAll(); + ->where('school_year', $schoolYear); + if (!empty($semesterCandidates)) { + $scoreQuery->whereIn('semester', $semesterCandidates); + } + $scoreRecords = $scoreQuery->findAll(); foreach ($scoreRecords as $score) { $sid = (int)($score['student_id'] ?? 0); if ($sid <= 0 || ($expected > 0 && !in_array($sid, $studentIds, true))) { continue; } - $midtermValue = trim((string)($score['midterm_exam_score'] ?? '')); + $midtermValue = trim((string)($score[$examScoreField] ?? '')); if ($midtermValue !== '') { $midtermStudents[$sid] = true; } @@ -805,13 +920,15 @@ class AdministratorController extends BaseController $midtermCommentStudents = []; $ptapCommentStudents = []; if (!empty($studentIds)) { - $comments = $scoreComments + $commentQuery = $scoreComments ->select('student_id, score_type, comment') ->whereIn('student_id', $studentIds) - ->where('semester', $semester) ->where('school_year', $schoolYear) - ->whereIn('score_type', ['midterm', 'ptap']) - ->findAll(); + ->whereIn('score_type', [$examTerm, 'ptap']); + if (!empty($semesterCandidates)) { + $commentQuery->whereIn('semester', $semesterCandidates); + } + $comments = $commentQuery->findAll(); foreach ($comments as $comment) { $sid = (int)($comment['student_id'] ?? 0); if ($sid <= 0) { @@ -822,7 +939,7 @@ class AdministratorController extends BaseController continue; } $type = strtolower(trim((string)($comment['score_type'] ?? ''))); - if ($type === 'midterm') { + if ($type === $examTerm) { $midtermCommentStudents[$sid] = true; } if ($type === 'ptap') { @@ -831,14 +948,17 @@ class AdministratorController extends BaseController } } - $attendanceRow = $attendanceDays + $attendanceQuery = $attendanceDays ->where('class_section_id', $classSectionId) - ->where('semester', $semester) ->where('school_year', $schoolYear) - ->where('date', $today) - ->first(); + ->where('date', $today); + if (!empty($semesterCandidates)) { + $attendanceQuery->whereIn('semester', $semesterCandidates); + } + $attendanceRow = $attendanceQuery->first(); $attendanceSubmitted = $attendanceRow && in_array(strtolower((string)($attendanceRow['status'] ?? '')), ['submitted', 'published', 'finalized'], true); + $section = $teachersBySection[$classSectionId] ?? ['teachers' => []]; $teacherList = $section['teachers'] ?? []; if (!empty($teacherList)) { usort($teacherList, function ($a, $b) { @@ -859,18 +979,27 @@ class AdministratorController extends BaseController $participationStatus = $this->submissionStatus(count($participationStudents), $expected); $ptapCommentStatus = $this->submissionStatus(count($ptapCommentStudents), $expected); $attendanceStatus = $this->attendanceStatus($attendanceSubmitted); + $progressSubmitted = (int) ($progressSubmittedBySection[$classSectionId] ?? 0); + $classProgressStatus = $this->progressStatus($progressSubmitted, $progressExpectedWeeks); + $draftSubmitted = (int) ($examDraftCounts[$classSectionId] ?? 0); + $examDraftStatus = $this->draftStatus($draftSubmitted, $examDraftDeadline); + $homeworkSubmitted = (int) ($homeworkCounts[$classSectionId] ?? 0); + $homeworkStatus = $this->homeworkStatus($homeworkSubmitted); $statusDetails = [ 'midterm_score_status' => $midtermScoreStatus, 'midterm_comment_status' => $midtermCommentStatus, 'participation_status' => $participationStatus, 'ptap_comment_status' => $ptapCommentStatus, + 'class_progress_status' => $classProgressStatus, + 'exam_draft_status' => $examDraftStatus, + 'homework_status' => $homeworkStatus, ]; - $missingItemsForSection = $this->buildMissingItems($statusDetails); + $missingItemsForSection = $this->buildMissingItems($statusDetails, $semester); $missingItemCount += count($missingItemsForSection); $totalStatuses += count($statusDetails); $rows[] = [ - 'class_section' => $section['class_section'] ?? "Section {$classSectionId}", + 'class_section' => $sectionMap[$classSectionId] ?? ($section['class_section'] ?? "Section {$classSectionId}"), 'class_section_id' => $classSectionId, 'teachers' => $teacherList, 'midterm_score_status' => $midtermScoreStatus, @@ -878,6 +1007,9 @@ class AdministratorController extends BaseController 'participation_status' => $participationStatus, 'ptap_comment_status' => $ptapCommentStatus, 'attendance_status' => $attendanceStatus, + 'class_progress_status' => $classProgressStatus, + 'exam_draft_status' => $examDraftStatus, + 'homework_status' => $homeworkStatus, 'missing_items' => $missingItemsForSection, 'student_count' => $expected, ]; @@ -939,15 +1071,183 @@ class AdministratorController extends BaseController 'schoolYear' => $schoolYear, 'notificationHistory' => $historyMap, 'summary' => $summary, + 'lowProgressSectionIds' => $lowProgressSectionIds, + 'examDraftDeadlineConfig' => $examDraftDeadlineConfig, + 'examDraftDeadlineFormatted' => $examDraftDeadlineFormatted, ]); } + private function resolveLowProgressSectionIds(array $sectionIds): array + { + [$expectedWeeks, $submittedBySection] = $this->buildClassProgressStats($sectionIds); + if ($expectedWeeks <= 0) { + return []; + } + + $lowProgressSectionIds = []; + foreach ($sectionIds as $sectionId) { + $submitted = (int) ($submittedBySection[$sectionId] ?? 0); + $percent = ($submitted / $expectedWeeks) * 100; + if ($percent < 50) { + $lowProgressSectionIds[] = $sectionId; + } + } + + return $lowProgressSectionIds; + } + + private function buildClassProgressStats(array $sectionIds): array + { + $sectionIds = array_values(array_unique(array_filter(array_map('intval', $sectionIds)))); + if (empty($sectionIds)) { + return [0, []]; + } + + $semesterResolver = new SemesterRangeService($this->configModel); + $schoolYear = (string)($this->configModel->getConfig('school_year') ?? ''); + $semester = (string)($this->configModel->getConfig('semester') ?? ''); + $schoolYearForRange = $schoolYear !== '' ? $schoolYear : (string)($this->configModel->getConfig('school_year') ?? ''); + [$rangeStart, $rangeEnd] = $semesterResolver->getSchoolYearRange($schoolYearForRange); + $semesterNorm = $semesterResolver->normalizeSemester($semester); + if ($semesterNorm !== '' && $schoolYearForRange !== '') { + $semRange = $semesterResolver->getSemesterRange($schoolYearForRange, $semesterNorm); + if ($semRange) { + [$rangeStart, $rangeEnd] = $semRange; + } + } + + $dateList = []; + try { + $start = new \DateTimeImmutable($rangeStart); + $end = new \DateTimeImmutable($rangeEnd); + $cursor = $start; + $w = (int) $cursor->format('w'); + if ($w !== 0) { + $cursor = $cursor->modify('next sunday'); + } + while ($cursor <= $end) { + $dateList[] = $cursor->format('Y-m-d'); + $cursor = $cursor->modify('+7 days'); + } + } catch (\Throwable $e) { + $dateList = []; + } + + $noSchoolDays = []; + $events = []; + try { + $calendarModel = new \App\Models\CalendarModel(); + $events = $calendarModel->getEvents(); + } catch (\Throwable $e) { + $events = []; + } + foreach ($events as $event) { + $d = substr((string) ($event['date'] ?? ''), 0, 10); + if ($d === '' || empty($event['no_school'])) { + continue; + } + if ($d < $rangeStart || $d > $rangeEnd) { + continue; + } + $eventYear = trim((string) ($event['school_year'] ?? '')); + if ($schoolYearForRange !== '' && $eventYear !== '' && $eventYear !== $schoolYearForRange) { + continue; + } + $noSchoolDays[$d] = true; + } + + $anchorSundayYmd = ''; + try { + $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); + $tzObj = new \DateTimeZone($tzName ?: 'UTC'); + } catch (\Throwable $e) { + try { + $tzObj = new \DateTimeZone(user_timezone() ?: 'UTC'); + } catch (\Throwable $e2) { + $tzObj = new \DateTimeZone('UTC'); + } + } + try { + $nowDate = new \DateTime('now', $tzObj); + } catch (\Throwable $e) { + $nowDate = new \DateTime('now'); + } + $weekday = (int) $nowDate->format('w'); + $anchorSundayYmd = $weekday === 0 + ? $nowDate->format('Y-m-d') + : $nowDate->modify('next sunday')->format('Y-m-d'); + + $activeDatesSet = []; + if (! empty($dateList) && $anchorSundayYmd !== '') { + foreach ($dateList as $d) { + if ($d <= $anchorSundayYmd && empty($noSchoolDays[$d])) { + $activeDatesSet[$d] = true; + } + } + } + $expectedWeeks = count($activeDatesSet); + if ($expectedWeeks === 0) { + return [0, []]; + } + + $builder = $this->db->table('class_progress_reports') + ->select('class_section_id, week_start') + ->whereIn('class_section_id', $sectionIds); + if (! empty($activeDatesSet)) { + $builder->whereIn('week_start', array_keys($activeDatesSet)); + } + $rows = $builder->get()->getResultArray(); + + $submittedBySection = []; + foreach ($rows as $row) { + $sectionId = (int) ($row['class_section_id'] ?? 0); + $weekStart = (string) ($row['week_start'] ?? ''); + if ($sectionId === 0 || $weekStart === '' || empty($activeDatesSet[$weekStart])) { + continue; + } + $submittedBySection[$sectionId][$weekStart] = true; + } + + $counts = []; + foreach ($sectionIds as $sectionId) { + $counts[$sectionId] = isset($submittedBySection[$sectionId]) + ? count($submittedBySection[$sectionId]) + : 0; + } + + return [$expectedWeeks, $counts]; + } + public function sendTeacherSubmissionNotifications() {$notify = $this->request->getPost('notify'); if (!is_array($notify)) { return redirect()->back()->with('info', 'Select at least one teacher to notify.'); } + $semester = (string)($this->configModel->getConfig('semester') ?? $this->semester ?? ''); $missingItemsPayload = $this->request->getPost('missing_items') ?? []; + $homeworkNotifyAll = (bool) $this->request->getPost('homework_notify_all'); + $examTerm = $this->resolveExamTermLabel($semester); + $examScoreLabel = $examTerm === 'final' ? 'final scores' : 'midterm scores'; + $examCommentLabel = $examTerm === 'final' ? 'final comments' : 'midterm comments'; + $forcedItems = []; + if ($this->request->getPost('notify_midterm_score')) { + $forcedItems[] = $examScoreLabel; + } + if ($this->request->getPost('notify_midterm_comment')) { + $forcedItems[] = $examCommentLabel; + } + if ($this->request->getPost('notify_participation')) { + $forcedItems[] = 'participation'; + } + if ($this->request->getPost('notify_ptap_comment')) { + $forcedItems[] = 'PTAP comments'; + } + if ($this->request->getPost('notify_class_progress')) { + $forcedItems[] = 'class progress'; + } + if ($this->request->getPost('notify_exam_draft')) { + $forcedItems[] = 'exam draft'; + } $targets = []; foreach ($notify as $sectionIdRaw => $teachers) { @@ -1004,6 +1304,10 @@ class AdministratorController extends BaseController $historyModel = new TeacherSubmissionNotificationHistoryModel(); $scoreUrl = site_url('/'); + $progressUrl = site_url('teacher/progress/history'); + $examDraftUrl = site_url('teacher/exam-drafts'); + $homeworkUrl = site_url('teacher/addHomework'); + $examDraftDeadlineEmailHtml = $this->buildExamDraftDeadlineEmailHtml(); $sentCount = 0; $failCount = 0; @@ -1019,6 +1323,13 @@ class AdministratorController extends BaseController $subject = "Reminder: Complete submissions for {$sectionName}"; $missingPayload = $missingItemsPayload[$classSectionId][$teacherId] ?? ''; $missingItems = $this->parseMissingItemsPayload((string)$missingPayload); + $selectedItems = $forcedItems; + if ($homeworkNotifyAll && !in_array('homework', $selectedItems, true)) { + $selectedItems[] = 'homework'; + } + if (!empty($selectedItems)) { + $missingItems = array_values(array_unique($selectedItems)); + } if (!empty($missingItems)) { $missingText = htmlspecialchars( $this->formatMissingItemsText($missingItems), @@ -1031,10 +1342,46 @@ class AdministratorController extends BaseController } $subject = "Reminder: Complete submissions for {$sectionName}"; + $progressNote = ''; + if (in_array('class progress', $missingItems, true)) { + $progressNote = "

Class progress submissions can be updated at Teacher Progress History.

"; + } + $examDraftNote = ''; + if (in_array('exam draft', $missingItems, true)) { + $semesterLabel = strtolower(trim((string) $semester)); + if ($semesterLabel === 'fall') { + $draftLabel = 'midterm exam draft'; + } elseif ($semesterLabel === 'spring') { + $draftLabel = 'final exam draft'; + } else { + $draftLabel = 'exam draft'; + } + $examDraftNote = "

" . ucfirst($draftLabel) . " submissions can be updated at Teacher Exam Drafts.

" + . $examDraftDeadlineEmailHtml; + } + $homeworkNote = ''; + if (in_array('homework', $missingItems, true)) { + $homeworkNote = "

Homework scores can be submitted at Teacher Homework.

"; + } + $hasScoreItems = (bool) array_intersect($missingItems, [ + 'midterm scores', + 'midterm comments', + 'final scores', + 'final comments', + 'participation', + 'PTAP comments', + 'homework', + ]); + $nonScoreOnly = ! empty($missingItems) && ! $hasScoreItems; $body = "

Dear {$teacherName},

" - . "

Administration is gently reminding you to wrap up any remaining score submissions and/or comments for {$sectionName}.

" + . "

Administration is gently reminding you to wrap up any remaining " + . ($nonScoreOnly ? "submissions for {$sectionName}." : "score submissions, comments, and related items for {$sectionName}.") + . "

" . $missingNote - . "

Visit Teacher Score Submission to address any remaining items.

" + . $progressNote + . $examDraftNote + . $homeworkNote + . ($nonScoreOnly ? '' : "

Visit Teacher Score Submission to address any remaining items.

") . "

Thank you,
Al Rahma Administration

"; $email = $teacher['email'] ?? ''; @@ -1096,6 +1443,170 @@ class AdministratorController extends BaseController ]; } + private function progressStatus(int $submitted, int $expected): array + { + if ($expected <= 0) { + return [ + 'label' => 'N/A', + 'badge' => 'bg-secondary', + 'detail' => '', + 'completed' => true, + ]; + } + $completed = $submitted >= $expected; + return [ + 'label' => $completed ? 'Submitted' : 'Missing', + 'badge' => $completed ? 'bg-success' : 'bg-danger', + 'detail' => "{$submitted}/{$expected}", + 'completed' => $completed, + ]; + } + + private function homeworkStatus(int $submitted): array + { + $completed = $submitted > 0; + return [ + 'label' => $completed ? 'Submitted' : 'Missing', + 'badge' => $completed ? 'bg-success' : 'bg-danger', + 'detail' => $completed ? (string) $submitted : '0', + 'completed' => $completed, + ]; + } + + private function draftStatus(int $submitted, ?\DateTimeImmutable $deadline): array + { + if ($deadline !== null) { + $today = new \DateTimeImmutable('today'); + if ($today < $deadline) { + return [ + 'label' => 'Pending', + 'badge' => 'bg-secondary', + 'detail' => 'Not due', + 'completed' => true, + ]; + } + } + $completed = $submitted > 0; + return [ + 'label' => $completed ? 'Submitted' : 'Missing', + 'badge' => $completed ? 'bg-success' : 'bg-danger', + 'detail' => $completed ? (string) $submitted : '0', + 'completed' => $completed, + ]; + } + + /** + * Exam draft due date for the teacher submissions dashboard: prefers the configuration key + * `exam_draft_deadline` (same as automated reminders); otherwise fall/spring exam deadlines. + */ + private function resolveTeacherDashboardExamDraftDeadline(string $semester, string $schoolYear): ?\DateTimeImmutable + { + $fromExamDraftKey = $this->parseExamDraftDeadlineConfigValue(); + if ($fromExamDraftKey !== null) { + return $fromExamDraftKey; + } + + return $this->resolveExamDraftDeadline($semester, $schoolYear); + } + + /** + * Parses the `exam_draft_deadline` configuration value using the application timezone (midnight that calendar day). + */ + private function parseExamDraftDeadlineConfigValue(): ?\DateTimeImmutable + { + $raw = trim((string) ($this->configModel->getConfig('exam_draft_deadline') ?? '')); + if ($raw === '') { + return null; + } + $tz = new \DateTimeZone(config('App')->appTimezone ?? 'UTC'); + try { + $deadline = new \DateTimeImmutable($raw, $tz); + } catch (\Throwable $e) { + return null; + } + + return $deadline->setTime(0, 0, 0); + } + + /** + * HTML snippet for reminder emails when exam draft is included (deadline from exam_draft_deadline config). + */ + private function buildExamDraftDeadlineEmailHtml(): string + { + $raw = trim((string) ($this->configModel->getConfig('exam_draft_deadline') ?? '')); + if ($raw === '') { + return ''; + } + $parsed = $this->parseExamDraftDeadlineConfigValue(); + $display = $parsed !== null + ? htmlspecialchars($parsed->format('l, F j, Y'), ENT_QUOTES, 'UTF-8') + : htmlspecialchars($raw, ENT_QUOTES, 'UTF-8'); + $rawEsc = htmlspecialchars($raw, ENT_QUOTES, 'UTF-8'); + + return '

Exam draft submission deadline (exam_draft_deadline): ' + . "{$display}" + . ($parsed !== null && $rawEsc !== $display ? " (configured value: {$rawEsc})" : '') + . '.

'; + } + + private function resolveExamDraftDeadline(string $semester, string $schoolYear): ?\DateTimeImmutable + { + $semesterKey = strtolower(trim($semester)); + if ($semesterKey === 'fall') { + $deadlineValue = (string)($this->configModel->getConfig('fall_exam_deadline') ?? ''); + } elseif ($semesterKey === 'spring') { + $deadlineValue = (string)($this->configModel->getConfig('spring_exam_deadline') ?? ''); + } else { + return null; + } + $deadlineValue = trim($deadlineValue); + if ($deadlineValue === '') { + return null; + } + try { + $deadline = new \DateTimeImmutable($deadlineValue); + } catch (\Throwable $e) { + return null; + } + if ($schoolYear !== '' && preg_match('/^\d{4}-\d{4}$/', $schoolYear)) { + $deadlineYear = $deadline->format('Y'); + if ($deadlineYear === '1970') { + return null; + } + } + return $deadline->setTime(0, 0, 0); + } + + private function resolveExamTermLabel(string $semester): string + { + $semesterKey = strtolower(trim($semester)); + if ($semesterKey === '') { + return 'midterm'; + } + if (str_contains($semesterKey, 'spring')) { + return 'final'; + } + if (str_contains($semesterKey, 'fall')) { + return 'midterm'; + } + return 'midterm'; + } + + private function buildSemesterCandidates(string $semester): array + { + $semester = trim((string) $semester); + if ($semester === '') { + return []; + } + $candidates = [ + $semester, + strtolower($semester), + strtoupper($semester), + ucfirst(strtolower($semester)), + ]; + $candidates = array_values(array_unique(array_filter($candidates, static fn ($v) => $v !== ''))); + return $candidates; + } private function attendanceStatus(bool $submitted): array { return [ @@ -1105,14 +1616,20 @@ class AdministratorController extends BaseController ]; } - private function buildMissingItems(array $statusMap): array + private function buildMissingItems(array $statusMap, string $semester): array { + $examTerm = $this->resolveExamTermLabel($semester); + $examScoreLabel = $examTerm === 'final' ? 'final scores' : 'midterm scores'; + $examCommentLabel = $examTerm === 'final' ? 'final comments' : 'midterm comments'; $labels = [ - 'midterm_score_status' => 'midterm scores', - 'midterm_comment_status' => 'midterm comments', + 'midterm_score_status' => $examScoreLabel, + 'midterm_comment_status' => $examCommentLabel, 'participation_status' => 'participation', 'ptap_comment_status' => 'PTAP comments', 'attendance_status' => 'attendance', + 'class_progress_status' => 'class progress', + 'exam_draft_status' => 'exam draft', + 'homework_status' => 'homework', ]; $items = []; diff --git a/app/Controllers/View/AssignmentController.php b/app/Controllers/View/AssignmentController.php old mode 100644 new mode 100755 index 491da9c..1a44497 --- a/app/Controllers/View/AssignmentController.php +++ b/app/Controllers/View/AssignmentController.php @@ -119,7 +119,12 @@ class AssignmentController extends BaseController } $students = []; + $seenStudentIds = []; foreach ($studentClasses as $studentClass) { + $sid = (int)($studentClass['student_id'] ?? 0); + if ($sid <= 0 || isset($seenStudentIds[$sid])) { + continue; + } if ($sectionSemester === '' && !empty($studentClass['semester'])) { $sectionSemester = (string)$studentClass['semester']; } @@ -149,6 +154,7 @@ class AssignmentController extends BaseController 'tuition_paid' => esc($student['tuition_paid'] ? 'Yes' : 'No'), 'school_id' => esc($student['school_id']), ]; + $seenStudentIds[$sid] = true; } $sectionSemesterDisplay = $sectionSemester !== '' ? $sectionSemester : ((string)($this->semester ?? '')); diff --git a/app/Controllers/View/AttendanceCommentTemplateController.php b/app/Controllers/View/AttendanceCommentTemplateController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/AttendanceController.php b/app/Controllers/View/AttendanceController.php old mode 100644 new mode 100755 index eb0782f..3fdb1d3 --- a/app/Controllers/View/AttendanceController.php +++ b/app/Controllers/View/AttendanceController.php @@ -797,26 +797,25 @@ public function showUpdateAttendanceForm() } $noSchoolDays = []; - if ($schoolYearForRange !== '') { + $events = []; + try { + $events = $this->calendarModel->getEvents(); + } catch (\Throwable $e) { $events = []; - try { - $events = $this->calendarModel->getEventsBySchoolYearAndSemester( - $schoolYearForRange, - $semesterNorm !== '' ? $semesterNorm : null - ); - } catch (\Throwable $e) { - $events = []; + } + foreach ($events as $event) { + $d = substr((string)($event['date'] ?? ''), 0, 10); + if ($d === '' || empty($event['no_school'])) { + continue; } - foreach ($events as $event) { - $d = substr((string)($event['date'] ?? ''), 0, 10); - if ($d === '' || empty($event['no_school'])) { - continue; - } - if ($d < $rangeStart || $d > $rangeEnd) { - continue; - } - $noSchoolDays[$d] = true; + if ($d < $rangeStart || $d > $rangeEnd) { + continue; } + $eventYear = trim((string)($event['school_year'] ?? '')); + if ($schoolYearForRange !== '' && $eventYear !== '' && $eventYear !== $schoolYearForRange) { + continue; + } + $noSchoolDays[$d] = true; } // Total passed attendance days up to this Sunday (inclusive), excluding no-school days. @@ -1005,9 +1004,13 @@ public function showUpdateAttendanceForm() } $hasRoster = false; + $seenStudents = []; foreach ($students as $sc) { $studentId = (int)$sc['student_id']; + if ($studentId <= 0 || isset($seenStudents[$studentId])) { + continue; + } $student = $this->studentModel ->select('id, firstname, lastname, school_id') ->find($studentId); @@ -1015,6 +1018,7 @@ public function showUpdateAttendanceForm() $studentsBySection[$secCode][] = $student; $hasRoster = true; + $seenStudents[$studentId] = true; // Attendance history $qb = $this->attendanceDataModel diff --git a/app/Controllers/View/AttendanceTrackingController.php b/app/Controllers/View/AttendanceTrackingController.php old mode 100644 new mode 100755 index 00f11e5..bb1f765 --- a/app/Controllers/View/AttendanceTrackingController.php +++ b/app/Controllers/View/AttendanceTrackingController.php @@ -2003,6 +2003,58 @@ class AttendanceTrackingController extends BaseController return [$subject, $body]; } + private function buildFallbackTemplate(string $code, string $variant, array $context): array + { + $studentName = (string) ($context['{{student_name}}'] ?? 'the student'); + $incident = (string) ($context['{{incident_date}}'] ?? date('Y-m-d')); + $parentName = (string) ($context['{{parent_name}}'] ?? 'Parent/Guardian'); + $phone = (string) ($context['{{school_phone}}'] ?? 'the school office'); + $voicemail = (string) ($context['{{voicemail_phone}}'] ?? 'your voicemail'); + + $subject = match (true) { + str_starts_with($code, 'ABS_1') => 'Attendance Notice: Unreported Absence', + str_starts_with($code, 'ABS_2') => 'Attendance Follow-Up: Two Consecutive Absences', + str_starts_with($code, 'ABS_3') => 'Attendance Follow-Up: Three Absences', + str_starts_with($code, 'LATE_2') => 'Attendance Notice: Repeated Lateness', + str_starts_with($code, 'LATE_3'), + str_starts_with($code, 'LATE_4'), + str_starts_with($code, 'MIX') => 'Attendance Follow-Up: Repeated Lateness and Absence', + default => 'Attendance Notice', + }; + + $intro = "

Insha Allah this email finds you well"; + if ($variant === 'answered') { + $intro .= ", {$parentName}. Jazakum Allahu khayran for taking the time to speak with us today.

"; + } elseif ($variant === 'no_answer') { + $intro .= ".

We tried to reach you but could not connect and left a voice message at {$voicemail}.

"; + } else { + $intro .= ".

"; + } + + $details = match (true) { + str_starts_with($code, 'ABS_1') + => "

This is to let you know that {$studentName} was absent on {$incident} without prior notice.

", + str_starts_with($code, 'ABS_2') + => "

This is a reminder that {$studentName} has had repeated absences, most recently on {$incident}, without prior notice.

", + str_starts_with($code, 'ABS_3') + => "

This is a reminder that {$studentName} has been absent three times, most recently on {$incident}, without prior notice.

", + str_starts_with($code, 'LATE_2') + => "

This is a reminder that {$studentName} has been late multiple times, most recently on {$incident}.

", + str_starts_with($code, 'LATE_3'), + str_starts_with($code, 'LATE_4') + => "

This is a follow-up that {$studentName} has had repeated lateness concerns, most recently on {$incident}.

", + str_starts_with($code, 'MIX') + => "

This is a follow-up that {$studentName} has had a mix of repeated lateness and absence concerns, most recently on {$incident}.

", + default + => "

We'd like to inform you about {$studentName}'s recent attendance concern dated {$incident}.

", + }; + + $closing = "

If your child will be absent or late due to illness or family commitments, please let us know ahead of time.

" + . "

You can email/call/text us at {$phone}.

"; + + return [$subject, $intro . $details . $closing]; + } + public function compose() { @@ -2033,8 +2085,8 @@ class AttendanceTrackingController extends BaseController $rendered = $this->renderTemplate($code, $variant, $ctx); if (!$rendered) { - return redirect()->to(site_url('attendance/violations')) - ->with('error', 'Template not found for ' . $code . ' (' . $variant . ').'); + log_message('warning', 'Attendance email template missing; using fallback compose body. code=' . $code . ' variant=' . $variant); + $rendered = $this->buildFallbackTemplate($code, $variant, $ctx); } [$subject, $body] = $rendered; diff --git a/app/Controllers/View/AuthorizedUsersController.php b/app/Controllers/View/AuthorizedUsersController.php old mode 100644 new mode 100755 index 5e9ae4a..a6cc46c --- a/app/Controllers/View/AuthorizedUsersController.php +++ b/app/Controllers/View/AuthorizedUsersController.php @@ -10,6 +10,8 @@ use CodeIgniter\I18n\Time; class AuthorizedUsersController extends ResourceController { + private const TOKEN_TTL_HOURS = 24; + protected $userModel; protected $authorizedUserModel; @@ -18,6 +20,30 @@ class AuthorizedUsersController extends ResourceController $this->userModel = new UserModel(); $this->authorizedUserModel = new AuthorizedUserModel(); } + + private function requireLogin() + { + if (!session()->get('is_logged_in')) { + return $this->failUnauthorized('Authentication required.'); + } + + return null; + } + + private function requireOwnership(array $authorizedUser) + { + $userId = (int) session()->get('user_id'); + if ($userId <= 0 || (int) ($authorizedUser['user_id'] ?? 0) !== $userId) { + return $this->failForbidden('You do not have access to this resource.'); + } + + return null; + } + + private function hashToken(string $token): string + { + return hash('sha256', $token); + } /** * Return a list of authorized users for the logged-in main user. * @@ -25,7 +51,10 @@ class AuthorizedUsersController extends ResourceController */ public function index() { - + if ($resp = $this->requireLogin()) { + return $resp; + } + $userId = session()->get('user_id'); $authorizedUsers = $this->authorizedUserModel->where('user_id', $userId)->findAll(); @@ -40,12 +69,20 @@ class AuthorizedUsersController extends ResourceController */ public function show($id = null) { + if ($resp = $this->requireLogin()) { + return $resp; + } + $authorizedUser = $this->authorizedUserModel->find($id); if (!$authorizedUser) { return $this->failNotFound('Authorized user not found.'); } + if ($resp = $this->requireOwnership($authorizedUser)) { + return $resp; + } + return $this->respond($authorizedUser); } @@ -56,6 +93,10 @@ class AuthorizedUsersController extends ResourceController */ public function create() { + if ($resp = $this->requireLogin()) { + return $resp; + } + $email = strtolower($this->request->getPost('email')); // Validate email @@ -66,19 +107,20 @@ class AuthorizedUsersController extends ResourceController $user = $this->userModel->where('email', $email)->first(); if (!$user) { - return $this->failNotFound('No user found with this email.'); + return $this->respondCreated(['message' => 'Authorized user added. A confirmation email has been sent.']); } // Generate a token for confirmation helper('text'); $token = bin2hex(random_bytes(48)); + $tokenHash = $this->hashToken($token); // Add entry to the authorized_users table $this->authorizedUserModel->insert([ 'user_id' => session()->get('user_id'), // Main user ID 'authorized_user_id' => $user['id'], 'email' => $email, - 'token' => $token, + 'token' => $tokenHash, 'status' => 'Pending' ]); @@ -96,6 +138,10 @@ class AuthorizedUsersController extends ResourceController */ public function update($id = null) { + if ($resp = $this->requireLogin()) { + return $resp; + } + // Fetch the authorized user $authorizedUser = $this->authorizedUserModel->find($id); @@ -103,6 +149,10 @@ class AuthorizedUsersController extends ResourceController return $this->failNotFound('Authorized user not found.'); } + if ($resp = $this->requireOwnership($authorizedUser)) { + return $resp; + } + // Update the authorized user’s information (e.g., email) $email = strtolower($this->request->getPost('email')); if ($email && filter_var($email, FILTER_VALIDATE_EMAIL)) { @@ -122,12 +172,20 @@ class AuthorizedUsersController extends ResourceController */ public function delete($id = null) { + if ($resp = $this->requireLogin()) { + return $resp; + } + $authorizedUser = $this->authorizedUserModel->find($id); if (!$authorizedUser) { return $this->failNotFound('Authorized user not found.'); } + if ($resp = $this->requireOwnership($authorizedUser)) { + return $resp; + } + // Delete the authorized user record $this->authorizedUserModel->delete($id); @@ -147,16 +205,28 @@ class AuthorizedUsersController extends ResourceController return $this->fail('Invalid confirmation link.'); } - $authorizedUser = $this->authorizedUserModel->where('token', $token)->first(); + $tokenHash = $this->hashToken($token); + $authorizedUser = $this->authorizedUserModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() + ->where('created_at >=', Time::now()->subHours(self::TOKEN_TTL_HOURS)->toDateTimeString()) + ->first(); if (!$authorizedUser) { return $this->fail('Invalid or expired confirmation link.'); } - // Mark the authorized user as active - $this->authorizedUserModel->update($authorizedUser['id'], ['status' => 'Active', 'token' => null]); + // Mark the authorized user as active and rotate token for password setup + $nextToken = bin2hex(random_bytes(48)); + $nextTokenHash = $this->hashToken($nextToken); + $this->authorizedUserModel->update($authorizedUser['id'], [ + 'status' => 'Active', + 'token' => $nextTokenHash, + ]); - return redirect()->to('/set_authorized_user_password/' . $authorizedUser['authorized_user_id']); + return redirect()->to('/set_authorized_user_password/' . $authorizedUser['authorized_user_id'] . '?token=' . $nextToken); } /** @@ -167,13 +237,36 @@ class AuthorizedUsersController extends ResourceController */ public function setPassword($authorizedUserId) { + $token = (string) $this->request->getGet('token'); + if ($token === '') { + return $this->fail('Invalid confirmation link.'); + } + + $tokenHash = $this->hashToken($token); + $authorizedUser = $this->authorizedUserModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() + ->where('authorized_user_id', $authorizedUserId) + ->where('status', 'Active') + ->where('updated_at >=', Time::now()->subHours(self::TOKEN_TTL_HOURS)->toDateTimeString()) + ->first(); + + if (!$authorizedUser) { + return $this->fail('Invalid or expired confirmation link.'); + } + $user = $this->userModel->find($authorizedUserId); if (!$user) { return $this->failNotFound('User not found.'); } - return view('user/set_authorized_user_password', ['userId' => $authorizedUserId]); + return view('user/set_authorized_user_password', [ + 'userId' => $authorizedUserId, + 'token' => $token, + ]); } /** @@ -181,38 +274,59 @@ class AuthorizedUsersController extends ResourceController * * @return ResponseInterface */ - /* - public function savePassword() + public function savePassword($authorizedUserId = null) { - // Validate the request $validation = \Config\Services::validation(); $validation->setRules([ - 'password' => 'required|min_length[6]', + 'password' => [ + 'label' => 'Password', + 'rules' => 'required|min_length[8]|regex_match[/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@\\-=\\+*#$%&!?])[A-Za-z\\d@\\-=\\+*#$%&!?]{8,}$/]', + ], 'password_confirm' => 'required|matches[password]', - 'user_id' => 'required|integer' + 'user_id' => 'required|integer', + 'token' => 'required', ]); if (!$this->validate($validation->getRules())) { return $this->failValidationErrors($validation->getErrors()); } - // Get the validated input - $userId = $this->request->getPost('user_id'); - $password = $this->request->getPost('password'); + $userId = (int) $this->request->getPost('user_id'); + $token = (string) $this->request->getPost('token'); + $authorizedUserId = $authorizedUserId !== null ? (int) $authorizedUserId : $userId; - $model = new UserModel(); - $user = $model->find($userId); + if ($userId <= 0 || $authorizedUserId <= 0 || $userId !== $authorizedUserId) { + return $this->fail('Invalid request.'); + } + $tokenHash = $this->hashToken($token); + $authorizedUser = $this->authorizedUserModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() + ->where('authorized_user_id', $authorizedUserId) + ->where('status', 'Active') + ->where('updated_at >=', Time::now()->subHours(self::TOKEN_TTL_HOURS)->toDateTimeString()) + ->first(); + + if (!$authorizedUser) { + return $this->fail('Invalid or expired confirmation link.'); + } + + $user = $this->userModel->find($authorizedUserId); if (!$user) { return $this->failNotFound('User not found.'); } - // Save the password - $model->update($userId, ['password' => password_hash($password, PASSWORD_DEFAULT)]); + $password = (string) $this->request->getPost('password'); + $hashedPassword = pbkdf2_hash($password); + + $this->userModel->update($authorizedUserId, ['password' => $hashedPassword]); + $this->authorizedUserModel->update($authorizedUser['id'], ['token' => null]); return $this->respond(['message' => 'Password has been successfully set.']); } -*/ /** * Sends a confirmation email to the authorized user. * @@ -242,4 +356,4 @@ class AuthorizedUsersController extends ResourceController log_message('error', 'Failed to send authorized user confirmation email to ' . $email); } } -} \ No newline at end of file +} diff --git a/app/Controllers/View/BadgesController.php b/app/Controllers/View/BadgesController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/BroadcastEmailController.php b/app/Controllers/View/BroadcastEmailController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/ClassController.php b/app/Controllers/View/ClassController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/ClassPrepController.php b/app/Controllers/View/ClassPrepController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/ClassPreparationController.php b/app/Controllers/View/ClassPreparationController.php old mode 100644 new mode 100755 index 1da0b69..db9456e --- a/app/Controllers/View/ClassPreparationController.php +++ b/app/Controllers/View/ClassPreparationController.php @@ -60,13 +60,15 @@ class ClassPreparationController extends BaseController $limitToSemester = $this->hasRosterForSemester($schoolYear, $semester); // 1) Get student count per class-section (distinct students, correct semester) - $scQ = $this->studentClassModel - ->select('class_section_id, COUNT(DISTINCT student_id) AS student_count') - ->where('school_year', $schoolYear); + $scQ = $this->db->table('student_class sc') + ->select('sc.class_section_id, COUNT(DISTINCT sc.student_id) AS student_count', false) + ->join('students s', 's.id = sc.student_id', 'inner') + ->where('s.is_active', 1) + ->where('sc.school_year', $schoolYear); if ($limitToSemester && $semester !== '') { - $scQ->where('semester', $semester); + $scQ->where('sc.semester', $semester); } - $classSections = $scQ->groupBy('class_section_id')->findAll(); + $classSections = $scQ->groupBy('sc.class_section_id')->get()->getResultArray(); // 2) Inventory availability — prefer good_qty when present, else condition='good' quantity. $inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $limitToSemester, $allowed); @@ -298,14 +300,16 @@ class ClassPreparationController extends BaseController $className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId; // Distinct student count for this term - $studentQ = $this->studentClassModel - ->select('COUNT(DISTINCT student_id) AS cnt') - ->where('class_section_id', $classSectionId) - ->where('school_year', $schoolYear); + $studentQ = $this->db->table('student_class sc') + ->select('COUNT(DISTINCT sc.student_id) AS cnt', false) + ->join('students s', 's.id = sc.student_id', 'inner') + ->where('s.is_active', 1) + ->where('sc.class_section_id', $classSectionId) + ->where('sc.school_year', $schoolYear); if ($limitToSemester && $semester !== '') { - $studentQ->where('semester', $semester); + $studentQ->where('sc.semester', $semester); } - $studentRow = $studentQ->first(); + $studentRow = $studentQ->get()->getRowArray(); $studentCount = (int)($studentRow['cnt'] ?? 0); // Live calc + adjustments @@ -355,13 +359,15 @@ class ClassPreparationController extends BaseController $limitToSemester = $this->hasRosterForSemester($schoolYear, $semester); // Student counts - $scQ = $this->studentClassModel - ->select('class_section_id, COUNT(DISTINCT student_id) AS student_count') - ->where('school_year', $schoolYear); + $scQ = $this->db->table('student_class sc') + ->select('sc.class_section_id, COUNT(DISTINCT sc.student_id) AS student_count', false) + ->join('students s', 's.id = sc.student_id', 'inner') + ->where('s.is_active', 1) + ->where('sc.school_year', $schoolYear); if ($limitToSemester && $semester !== '') { - $scQ->where('semester', $semester); + $scQ->where('sc.semester', $semester); } - $classSections = $scQ->groupBy('class_section_id')->findAll(); + $classSections = $scQ->groupBy('sc.class_section_id')->get()->getResultArray(); // Build inventory availability maps $inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $limitToSemester, $allowed); @@ -448,14 +454,16 @@ class ClassPreparationController extends BaseController $now = utc_now(); $count = 0; foreach ($ids as $classSectionId) { - $studentQ = $this->studentClassModel - ->select('COUNT(DISTINCT student_id) AS cnt') - ->where('class_section_id', $classSectionId) - ->where('school_year', $schoolYear); + $studentQ = $this->db->table('student_class sc') + ->select('COUNT(DISTINCT sc.student_id) AS cnt', false) + ->join('students s', 's.id = sc.student_id', 'inner') + ->where('s.is_active', 1) + ->where('sc.class_section_id', $classSectionId) + ->where('sc.school_year', $schoolYear); if ($limitToSemester && $semester !== '') { - $studentQ->where('semester', $semester); + $studentQ->where('sc.semester', $semester); } - $studentRow = $studentQ->first(); + $studentRow = $studentQ->get()->getRowArray(); $studentCount = (int)($studentRow['cnt'] ?? 0); $classLevel = $this->getClassLevelBySection((string)$classSectionId); $className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId; diff --git a/app/Controllers/View/CommunicationController.php b/app/Controllers/View/CommunicationController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/CompetitionScoresController.php b/app/Controllers/View/CompetitionScoresController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/ConfigurationController.php b/app/Controllers/View/ConfigurationController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/ContactController.php b/app/Controllers/View/ContactController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/DashboardRedirectController.php b/app/Controllers/View/DashboardRedirectController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/DiscountController.php b/app/Controllers/View/DiscountController.php old mode 100644 new mode 100755 index 85fbf27..e87c073 --- a/app/Controllers/View/DiscountController.php +++ b/app/Controllers/View/DiscountController.php @@ -750,7 +750,9 @@ class DiscountController extends BaseController } } catch (\Throwable $e) {} - $newTotal = round($tuitionSubtotal + $eventSubtotal + $additionalSubtotal, 2); + $discountableTotal = $tuitionSubtotal + $additionalSubtotal; + $nonDiscountableTotal = $eventSubtotal; + $newTotal = round($discountableTotal + $nonDiscountableTotal, 2); // ---- Payments / Discounts / Refunds ---- $db = $this->db; @@ -794,7 +796,8 @@ class DiscountController extends BaseController ->get()->getRowArray(); $totalRefundPaid = (float)($refundRow['total_refund_paid'] ?? 0); - $newBalance = max(0.0, $newTotal - $totalDisc - $totalPaid - $totalRefundPaid); + $appliedDiscount = min($totalDisc, $discountableTotal); + $newBalance = max(0.0, $newTotal - $appliedDiscount - $totalPaid - $totalRefundPaid); $newStatus = ($newBalance <= 0.00001) ? 'Paid' : (($totalPaid > 0) ? 'Partially Paid' : 'Unpaid'); $updateData = [ diff --git a/app/Controllers/View/DocsController.php b/app/Controllers/View/DocsController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/EmailController.php b/app/Controllers/View/EmailController.php old mode 100644 new mode 100755 index 16dc51a..c9b4c4f --- a/app/Controllers/View/EmailController.php +++ b/app/Controllers/View/EmailController.php @@ -170,6 +170,163 @@ class EmailController extends Controller } } + /** + * Send one email with a single To recipient and many CC recipients. + * + * @param string $recipient + * @param string[] $ccRecipients + * @param string $subject + * @param string $htmlMessage + * @param string|null $profile + * @param string|null $replyToEmail + * @param string|null $replyToName + * @param array $attachments + */ + public function sendEmailWithCc( + string $recipient, + array $ccRecipients, + string $subject, + string $htmlMessage, + ?string $profile = null, + ?string $replyToEmail = null, + ?string $replyToName = null, + array $attachments = [] + ): bool { + $ccRecipients = array_values(array_unique(array_filter(array_map(static function ($email) { + $email = trim((string) $email); + return filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null; + }, $ccRecipients)))); + + if ($ccRecipients === []) { + return $this->sendEmail($recipient, $subject, $htmlMessage, $profile, $replyToEmail, $replyToName, $attachments); + } + + $autoload = APPPATH . '../vendor/autoload.php'; + if (is_file($autoload)) { + require_once $autoload; + } + + $profile = $this->resolveProfile($profile); + $cfg = $this->getProfileConfig($profile); + + if (empty($cfg['host']) || empty($cfg['user']) || $cfg['pass'] === '') { + log_message('error', "[mail:$profile] Missing SMTP config (host/user/pass). Check .env MAIL_{$this->envKeyFromProfile($profile)}_* or MAIL_DEFAULT_*."); + return false; + } + + $debugEnabled = (bool) env('MAIL_DEBUG', false); + $timeout = (int) env('MAIL_TIMEOUT', 15); + $keepAlive = (bool) env('MAIL_KEEPALIVE', false); + $verifyPeer = filter_var(env('MAIL_VERIFY_PEER', 'true'), FILTER_VALIDATE_BOOLEAN); + + $targetHost = $cfg['host']; + $targetPort = (int) $cfg['port']; + + $resolved = @gethostbyname($targetHost); + if (!$resolved || $resolved === $targetHost) { + log_message('debug', "[mail:$profile] DNS resolve note: host=$targetHost, resolved=$resolved"); + } + + $sockOk = @fsockopen($targetHost, $targetPort, $errno, $errstr, 5); + if (!$sockOk) { + log_message('error', "[mail:$profile] Socket preflight failed to {$targetHost}:{$targetPort} (errno=$errno, err=$errstr). Likely firewall/port/encryption mismatch or wrong host."); + } else { + fclose($sockOk); + } + + $mail = new PHPMailer(true); + + try { + if ($debugEnabled) { + ob_start(); + } + + $mail->isSMTP(); + $mail->Host = $cfg['host']; + $mail->Port = $cfg['port']; + $mail->SMTPAuth = true; + $mail->Username = $cfg['user']; + $mail->Password = $cfg['pass']; + $mail->CharSet = 'UTF-8'; + $mail->Timeout = $timeout; + $mail->SMTPKeepAlive = $keepAlive; + $mail->SMTPAutoTLS = true; + + if ($cfg['encryption'] === 'ssl') { + $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; + } else { + $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + } + + $mail->SMTPOptions = [ + 'ssl' => [ + 'verify_peer' => $verifyPeer, + 'verify_peer_name' => $verifyPeer, + 'allow_self_signed' => !$verifyPeer, + ], + ]; + + $mail->SMTPDebug = $debugEnabled ? 3 : 0; + $mail->Debugoutput = 'error_log'; + + $mail->setFrom($cfg['fromEmail'], $cfg['fromName']); + if (!empty($cfg['returnPath'])) { + $mail->Sender = $cfg['returnPath']; + } + + $mail->clearReplyTos(); + $rtEmail = env('MAIL_DEFAULT_REPLY_TO'); + $rtName = env('MAIL_DEFAULT_REPLY_TO_NAME'); + if (!$rtEmail || !filter_var($rtEmail, FILTER_VALIDATE_EMAIL)) { + $rtEmail = $replyToEmail ?: ($cfg['replyTo'] ?: $cfg['fromEmail']); + } + if (!$rtName) { + $rtName = $replyToName ?: ($cfg['replyToName'] ?: $cfg['fromName']); + } + $rtName = $this->sanitizeReplyToName($rtName, $cfg['fromName']); + if ($rtEmail) { + $mail->addReplyTo($rtEmail, $rtName); + } + + if (!empty($cfg['dkim']['domain']) && !empty($cfg['dkim']['private']) && !empty($cfg['dkim']['selector'])) { + $mail->DKIM_domain = $cfg['dkim']['domain']; + $mail->DKIM_private = $cfg['dkim']['private']; + $mail->DKIM_selector = $cfg['dkim']['selector']; + $mail->DKIM_identity = $cfg['fromEmail']; + } + + $mail->addAddress($recipient); + foreach ($ccRecipients as $ccRecipient) { + $mail->addCC($ccRecipient); + } + + foreach ($attachments as $att) { + if (!empty($att['path']) && is_file($att['path'])) { + $mail->addAttachment($att['path'], $att['name'] ?? ''); + } + } + + $mail->isHTML(true); + $mail->Subject = $subject; + $mail->Body = $htmlMessage; + + $ok = $mail->send(); + + $dbg = $debugEnabled ? (ob_get_clean() ?: '') : ''; + if ($ok) { + log_message('info', "[mail:$profile] Sent to {$recipient} with " . count($ccRecipients) . " CC recipient(s), subj='{$subject}' via {$cfg['host']}:{$cfg['port']}/{$cfg['encryption']}"); + return true; + } + + log_message('error', "[mail:$profile] Failed: {$mail->ErrorInfo}. Debug: {$dbg}"); + return false; + } catch (Exception $e) { + $dbg = $debugEnabled ? (ob_get_clean() ?: '') : ''; + log_message('error', "[mail:$profile] Exception: {$e->getMessage()} | PHPMailer: {$mail->ErrorInfo} | Debug: {$dbg}"); + return false; + } + } + /** Resolve null/alias profile names to a canonical env prefix. */ private function resolveProfile(?string $profile): string { diff --git a/app/Controllers/View/EmailExtractorController.php b/app/Controllers/View/EmailExtractorController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/EmergencyContactController.php b/app/Controllers/View/EmergencyContactController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/EventController.php b/app/Controllers/View/EventController.php old mode 100644 new mode 100755 index 442c46e..5e3caf1 --- a/app/Controllers/View/EventController.php +++ b/app/Controllers/View/EventController.php @@ -6,8 +6,18 @@ use App\Models\EventModel; use App\Models\EventChargesModel; use App\Models\UserModel; use App\Models\StudentModel; +use App\Models\StudentClassModel; use App\Models\ConfigurationModel; use App\Models\EnrollmentModel; +use App\Models\InvoiceModel; +use App\Models\InvoiceEventModel; +use App\Models\ClassSectionModel; +use App\Models\ParentModel; +use App\Models\PaymentModel; +use App\Models\CalendarModel; +use App\Services\EmailService; +use Config\Database; +#use ??? use App\Controllers\View\InvoiceController; use CodeIgniter\RESTful\ResourceController; @@ -19,10 +29,19 @@ class EventController extends ResourceController protected $configModel; protected $eventModel; protected $invoiceController; + protected $invoiceModel; + protected $invoiceEventModel; + protected $studentClassModel; + protected $classSectionModel; + protected $paymentModel; + protected $parentModel; + protected $emailService; protected $schoolYear; protected $semester; protected $categories; protected $enrollmentModel; + private ?bool $eventChargesHasCreatedBy = null; + private ?bool $eventChargesHasWaiverSigned = null; public function __construct() { @@ -32,7 +51,14 @@ class EventController extends ResourceController $this->userModel = new UserModel(); // Add this $this->eventModel = new EventModel(); $this->invoiceController = new InvoiceController(); + $this->invoiceModel = new InvoiceModel(); + $this->invoiceEventModel = new InvoiceEventModel(); + $this->studentClassModel = new StudentClassModel(); + $this->classSectionModel = new ClassSectionModel(); + $this->paymentModel = new PaymentModel(); $this->enrollmentModel = new EnrollmentModel(); + $this->parentModel = new ParentModel(); + $this->emailService = new EmailService(); $this->schoolYear = $this->configModel->getConfig('school_year'); $this->semester = $this->configModel->getConfig('semester'); @@ -44,6 +70,57 @@ class EventController extends ResourceController ]; } + private function eventChargesSupportsCreatedBy(): bool + { + if ($this->eventChargesHasCreatedBy !== null) { + return $this->eventChargesHasCreatedBy; + } + + try { + $db = Database::connect(); + $this->eventChargesHasCreatedBy = $db->fieldExists('created_by', 'event_charges'); + } catch (\Throwable $e) { + $this->eventChargesHasCreatedBy = false; + } + + return $this->eventChargesHasCreatedBy; + } + + private function eventChargesSupportsWaiverSigned(): bool + { + if ($this->eventChargesHasWaiverSigned !== null) { + return $this->eventChargesHasWaiverSigned; + } + + try { + $db = Database::connect(); + $this->eventChargesHasWaiverSigned = $db->fieldExists('waiver_signed', 'event_charges'); + } catch (\Throwable $e) { + $this->eventChargesHasWaiverSigned = false; + } + + return $this->eventChargesHasWaiverSigned; + } + + private function getEventChargesReturnTo(): string + { + $fallback = site_url('administrator/event-charges'); + $returnTo = trim((string) ($this->request->getPost('return_to') ?? '')); + if ($returnTo === '') { + return $fallback; + } + + $base = rtrim((string) base_url(), '/'); + if (str_starts_with($returnTo, '/')) { + return $returnTo; + } + if ($base !== '' && str_starts_with($returnTo, $base)) { + return $returnTo; + } + + return $fallback; + } + public function index() { $eventModel = new EventModel(); @@ -93,59 +170,52 @@ class EventController extends ResourceController 'created_by' => session()->get('user_id'), ]); - if ($eventId) { - $amount = (float) $this->request->getPost('amount'); - $semester = (string) $this->request->getPost('semester'); - $schoolYear = (string) $this->request->getPost('school_year'); - $userId = (int) (session()->get('user_id') ?? 0); + if ($eventId && $this->request->getPost('add_to_calendar')) { + $calendarModel = new CalendarModel(); - $enrollments = $this->enrollmentModel - ->select('enrollments.student_id, students.parent_id') - ->join('students', 'students.id = enrollments.student_id', 'left') - ->where('enrollments.school_year', $schoolYear) - ->whereIn('enrollments.enrollment_status', ['enrolled', 'payment pending']) - ->findAll(); + $title = trim((string)$this->request->getPost('event_name')); + $date = (string)$this->request->getPost('expiration_date'); + $schoolYear = (string)$this->request->getPost('school_year'); + $semester = (string)$this->request->getPost('semester'); - $parentIds = []; - foreach ($enrollments as $row) { - $studentId = (int) ($row['student_id'] ?? 0); - $parentId = (int) ($row['parent_id'] ?? 0); - if ($studentId <= 0 || $parentId <= 0) { - continue; + if ($title !== '' && $date !== '' && $schoolYear !== '') { + $data = [ + 'title' => $title, + 'description' => (string)$this->request->getPost('description'), + 'event_type' => 'Event', + 'date' => $date, + 'notify_parent' => $this->request->getPost('notify_parent') ? 1 : 0, + 'notify_teacher' => $this->request->getPost('notify_teacher') ? 1 : 0, + 'notify_admin' => $this->request->getPost('notify_admin') ? 1 : 0, + 'no_school' => 0, + 'school_year' => $schoolYear, + 'semester' => $semester ?: $this->semester, + ]; + if (!$calendarModel->supportsEventType()) { + unset($data['event_type']); } - $exists = $this->eventChargesModel - ->where('event_id', $eventId) - ->where('student_id', $studentId) - ->where('school_year', $schoolYear) - ->where('semester', $semester) - ->first(); - - if ($exists) { - continue; + $dupQuery = $calendarModel + ->where('school_year', $data['school_year']) + ->where('date', $data['date']) + ->where('title', $data['title']); + if ($calendarModel->supportsEventType() && isset($data['event_type'])) { + $dupQuery = $dupQuery->where('event_type', $data['event_type']); + } + $existing = $dupQuery->first(); + if (!$existing) { + $calendarModel->save($data); } - - $this->eventChargesModel->insert([ - 'event_id' => $eventId, - 'parent_id' => $parentId, - 'student_id' => $studentId, - 'participation' => 'yes', - 'charged' => $amount, - 'school_year' => $schoolYear, - 'semester' => $semester, - 'updated_by' => $userId ?: null, - ]); - - $parentIds[] = $parentId; - } - - $parentIds = array_unique($parentIds); - foreach ($parentIds as $pid) { - $this->invoiceController->generateInvoice((string) $pid); } } - return redirect()->to('/administrator/events')->with('success', 'Event created successfully'); + $redirect = redirect()->to('/administrator/events')->with('success', 'Event created successfully'); + if ($eventId && $this->request->getPost('send_email_parent')) { + $emailStatus = $this->broadcastEventToParents((int) $eventId); + $redirect = $this->applyEventEmailFlash($redirect, $emailStatus); + } + + return $redirect; } return view('administrator/events/create_event', [ @@ -186,7 +256,65 @@ class EventController extends ResourceController ]); if ($updated) { - return redirect()->to('/administrator/events')->with('success', 'Event updated successfully'); + if ($this->request->getPost('add_to_calendar')) { + $calendarModel = new CalendarModel(); + + $title = trim((string) $this->request->getPost('event_name')); + $date = (string) $this->request->getPost('expiration_date'); + $schoolYear = (string) $this->request->getPost('school_year'); + $semester = (string) $this->request->getPost('semester'); + + if ($title !== '' && $date !== '' && $schoolYear !== '') { + $data = [ + 'title' => $title, + 'description' => (string) $this->request->getPost('description'), + 'event_type' => 'Event', + 'date' => $date, + 'notify_parent' => $this->request->getPost('notify_parent') ? 1 : 0, + 'notify_teacher' => $this->request->getPost('notify_teacher') ? 1 : 0, + 'notify_admin' => $this->request->getPost('notify_admin') ? 1 : 0, + 'no_school' => 0, + 'school_year' => $schoolYear, + 'semester' => $semester ?: $this->semester, + ]; + if (!$calendarModel->supportsEventType()) { + unset($data['event_type']); + } + + $existingByPreviousEvent = $calendarModel + ->where('school_year', (string) ($event['school_year'] ?? '')) + ->where('date', (string) ($event['expiration_date'] ?? '')) + ->where('title', (string) ($event['event_name'] ?? '')); + if ($calendarModel->supportsEventType() && isset($data['event_type'])) { + $existingByPreviousEvent = $existingByPreviousEvent->where('event_type', $data['event_type']); + } + $existingCalendar = $existingByPreviousEvent->first(); + + if ($existingCalendar) { + $calendarModel->update($existingCalendar['id'], $data); + } else { + $dupQuery = $calendarModel + ->where('school_year', $data['school_year']) + ->where('date', $data['date']) + ->where('title', $data['title']); + if ($calendarModel->supportsEventType() && isset($data['event_type'])) { + $dupQuery = $dupQuery->where('event_type', $data['event_type']); + } + $duplicate = $dupQuery->first(); + + if (!$duplicate) { + $calendarModel->save($data); + } + } + } + } + + $redirect = redirect()->to('/administrator/events')->with('success', 'Event updated successfully'); + if ($this->request->getPost('send_email_parent')) { + $emailStatus = $this->broadcastEventToParents((int) $id); + $redirect = $this->applyEventEmailFlash($redirect, $emailStatus); + } + return $redirect; } else { log_message('debug', 'GET detected'); return redirect()->back()->with('error', 'Failed to update event'); @@ -199,6 +327,106 @@ class EventController extends ResourceController ]); } + private function broadcastEventToParents(int $eventId): array + { + $event = $this->eventModel->find($eventId); + if (!$event) { + return ['ok' => false, 'count' => 0, 'message' => 'Event email was skipped because the event could not be loaded.']; + } + + $emails = $this->collectParentBroadcastEmails((string) ($event['school_year'] ?? '')); + if ($emails === []) { + return ['ok' => false, 'count' => 0, 'message' => 'Event email was skipped because no parent email addresses were found.']; + } + + $subject = 'School Event: ' . trim((string) ($event['event_name'] ?? 'Upcoming Event')); + $innerBody = view('emails/event_broadcast', [ + 'event' => $event, + 'eventUrl' => base_url('parent/events'), + 'flyerUrl' => !empty($event['flyer']) ? base_url('uploads/' . ltrim((string) $event['flyer'], '/')) : null, + ]); + $body = view('emails/_wrap_layout', [ + 'title' => $subject, + 'subject' => $subject, + 'body_html' => $innerBody, + ]); + + $ok = $this->emailService->sendCc($emails, $subject, $body, 'general'); + $batchCount = $this->emailService->getLastCcBatchCount(); + + return [ + 'ok' => $ok, + 'count' => count($emails), + 'batches' => $batchCount, + 'message' => $ok + ? 'Event email was sent in ' . $batchCount . ' CC batch(es) for ' . count($emails) . ' recipient(s).' + : 'The event was saved, but sending the parent CC email failed.', + ]; + } + + private function collectParentBroadcastEmails(string $schoolYear = ''): array + { + $emails = []; + + $db = Database::connect(); + + $parentRole = $db->table('roles') + ->select('id') + ->where('LOWER(name)', 'parent') + ->get() + ->getRow(); + + if ($parentRole) { + $firstParentQuery = $db->table('users') + ->select('users.email') + ->join('user_roles', 'user_roles.user_id = users.id') + ->where('user_roles.role_id', (int) $parentRole->id) + ->where('users.email IS NOT NULL', null, false) + ->where('users.email !=', ''); + + if ($schoolYear !== '' && $db->fieldExists('school_year', 'users')) { + $firstParentQuery->where('users.school_year', $schoolYear); + } + + foreach ($firstParentQuery->get()->getResultArray() as $row) { + $email = trim((string) ($row['email'] ?? '')); + if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { + $emails[strtolower($email)] = $email; + } + } + } + + $secondParentQuery = $db->table('parents') + ->select('secondparent_email') + ->where('secondparent_email IS NOT NULL', null, false) + ->where('secondparent_email !=', ''); + + if ($schoolYear !== '' && $db->fieldExists('school_year', 'parents')) { + $secondParentQuery->where('school_year', $schoolYear); + } + + foreach ($secondParentQuery->get()->getResultArray() as $row) { + $email = trim((string) ($row['secondparent_email'] ?? '')); + if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { + $emails[strtolower($email)] = $email; + } + } + + return array_values($emails); + } + + private function applyEventEmailFlash($redirect, array $emailStatus) + { + if (!empty($emailStatus['message'])) { + return $redirect->with( + ($emailStatus['ok'] ?? false) ? 'email_status' : 'email_error', + $emailStatus['message'] + ); + } + + return $redirect; + } + public function delete($id = null) { @@ -218,6 +446,19 @@ class EventController extends ResourceController $parentIds[] = $charge['parent_id']; } + // Also remove any invoice_event rows tied to this event + $eventName = $event['event_name'] ?? null; + if ($eventName) { + $builder = $this->invoiceEventModel->where('event_name', $eventName); + if (!empty($event['school_year'])) { + $builder->where('school_year', $event['school_year']); + } + if (!empty($event['semester'])) { + $builder->where('semester', $event['semester']); + } + $builder->delete(); + } + // Delete event $this->eventModel->delete($id); @@ -239,27 +480,102 @@ class EventController extends ResourceController $semester = $this->request->getGet('semester') ?? $this->semester; $parents = $this->userModel->getParents(); - $events = $this->eventModel->getActiveEvents($this->schoolYear); + $events = $this->eventModel->getActiveEvents($schoolYear); + $filterEventId = (int) ($this->request->getGet('event_id') ?? 0); + $filterParentId = (int) ($this->request->getGet('parent_id') ?? 0); - $charges = $this->eventChargesModel + $chargesBuilder = $this->eventChargesModel ->select('event_charges.*, users.firstname AS parent_firstname, users.lastname AS parent_lastname, students.firstname AS student_firstname, students.lastname AS student_lastname, - events.event_name') + events.event_name, events.description AS event_description, events.amount AS event_amount') ->join('users', 'users.id = event_charges.parent_id', 'left') ->join('students', 'students.id = event_charges.student_id', 'left') ->join('events', 'events.id = event_charges.event_id', 'left') ->where('event_charges.school_year', $schoolYear) - ->where('event_charges.semester', $semester) + ->where('event_charges.semester', $semester); + + if ($filterEventId > 0) { + $chargesBuilder->where('event_charges.event_id', $filterEventId); + } + + $charges = $chargesBuilder ->orderBy('event_charges.created_at', 'DESC') ->findAll(); + foreach ($charges as &$charge) { + if (empty($charge['class_section_id']) && !empty($charge['student_id'])) { + $sections = $this->studentClassModel->getClassSectionIdsByStudentId( + (int)$charge['student_id'], + $schoolYear + ); + if (!empty($sections)) { + $charge['class_section_id'] = $sections[0]; + } + } + } + unset($charge); + + $parentBalances = []; + try { + $balanceRows = $this->invoiceModel + ->select('parent_id, COALESCE(SUM(balance),0) AS total_balance') + ->where('school_year', $schoolYear) + ->where('semester', $semester) + ->groupBy('parent_id') + ->findAll(); + + foreach ($balanceRows as $row) { + $parentBalances[(int)($row['parent_id'] ?? 0)] = (float)($row['total_balance'] ?? 0.0); + } + } catch (\Throwable $e) { + log_message('error', 'Failed to load parent balances: ' . $e->getMessage()); + } + + $sectionIds = array_unique(array_filter(array_column($charges, 'class_section_id'))); + $classSectionNames = []; + if (!empty($sectionIds)) { + $sections = $this->classSectionModel + ->select('class_section_id, class_section_name') + ->whereIn('class_section_id', $sectionIds) + ->findAll(); + foreach ($sections as $section) { + $classSectionNames[(int)($section['class_section_id'] ?? 0)] = $section['class_section_name'] ?? ''; + } + } + + $semesterOptions = (new EventChargesModel()) + ->select('semester') + ->distinct() + ->orderBy('semester', 'ASC') + ->findColumn('semester'); + $semesterOptions = array_values(array_filter(array_unique($semesterOptions ?? []))); + if (empty($semesterOptions)) { + $semesterOptions = [$this->semester]; + } + + $schoolYearOptions = (new EventChargesModel()) + ->select('school_year') + ->distinct() + ->orderBy('school_year', 'ASC') + ->findColumn('school_year'); + $schoolYearOptions = array_values(array_filter(array_unique($schoolYearOptions ?? []))); + if (empty($schoolYearOptions)) { + $schoolYearOptions = [$this->schoolYear]; + } + return view('administrator/events/event_charges', [ 'charges' => $charges, 'parents' => $parents, 'events' => $events, 'school_year' => $schoolYear, 'semester' => $semester, + 'filterEventId' => $filterEventId, + 'filterParentId' => $filterParentId, + 'parentBalances' => $parentBalances, + 'classSectionNames' => $classSectionNames, + 'semesterOptions' => $semesterOptions, + 'schoolYearOptions' => $schoolYearOptions, ]); } @@ -272,13 +588,25 @@ class EventController extends ResourceController $parentId = $this->request->getPost('parent_id'); $eventId = $this->request->getPost('event_id'); $participations = $this->request->getPost('participation') ?? []; + $externalParticipants = $this->request->getPost('external_participants') ?? []; - if (!$parentId || !$eventId || empty($participations)) { + // Allow "external only" submissions (no enrolled students selected). + if (!$eventId || (empty($participations) && empty($externalParticipants))) { return redirect()->back()->with('error', 'Missing required information.'); } + if (!empty($participations) && !$parentId) { + return redirect()->back()->with('error', 'Select a parent when adding enrolled students.'); + } $userId = session()->get('user_id'); $event = $this->eventModel->getEvent($eventId, $schoolYear); + if (!$event) { + return redirect()->back()->with('error', 'Selected event not found for the chosen school year.'); + } + + $parentsForInvoice = []; + $supportsCreatedBy = $this->eventChargesSupportsCreatedBy(); + $supportsWaiverSigned = $this->eventChargesSupportsWaiverSigned(); foreach ($participations as $studentId => $value) { $existing = $this->eventChargesModel->where([ @@ -296,33 +624,434 @@ class EventController extends ResourceController continue; } - // value is 'yes' + $sectionIds = $this->studentClassModel->getClassSectionIdsByStudentId($studentId, $schoolYear); + $classSectionId = !empty($sectionIds) ? $sectionIds[0] : null; + if ($existing) { - $this->eventChargesModel->update($existing['id'], [ - 'participation' => 'yes', - 'charged' => $event['amount'], - 'updated_by' => $userId - ]); + $updateData = [ + 'participation' => 'yes', + 'updated_by' => $userId, + 'class_section_id'=> $classSectionId, + ]; + if ($supportsWaiverSigned) { + $updateData['waiver_signed'] = (int) ($existing['waiver_signed'] ?? 0); + } + if (isset($event['amount']) && ((float)$event['amount'] !== (float)($existing['charged'] ?? 0))) { + $updateData['charged'] = $event['amount']; + } + $this->eventChargesModel->update($existing['id'], $updateData); + continue; } else { - $this->eventChargesModel->insert([ + $insertData = [ 'parent_id' => $parentId, 'student_id' => $studentId, 'event_id' => $eventId, 'participation' => 'yes', 'charged' => $event['amount'], + 'event_paid' => 0, + 'class_section_id' => $classSectionId, 'school_year' => $schoolYear, 'semester' => $semester, - 'created_by' => $userId, 'updated_by' => $userId - ]); + ]; + if ($supportsWaiverSigned) { + $insertData['waiver_signed'] = 0; + } + if ($supportsCreatedBy) { + $insertData['created_by'] = $userId; + } + $this->eventChargesModel->insert($insertData); } } - $this->invoiceController->generateInvoice($parentId); + foreach ($externalParticipants as $pieces) { + $firstname = $this->normalizeExternalName($pieces['firstname'] ?? ''); + $lastname = $this->normalizeExternalName($pieces['lastname'] ?? ''); + $note = trim($pieces['note'] ?? ''); + $parentFirst = $this->normalizeExternalName($pieces['parent_firstname'] ?? ''); + $parentLast = $this->normalizeExternalName($pieces['parent_lastname'] ?? ''); + $parentPhone = trim($pieces['parent_phone'] ?? ''); + $parentEmail = trim((string)($pieces['parent_email'] ?? '')); + $markPaid = (string)($pieces['paid'] ?? '0') === '1'; + $waiverSigned = (string)($pieces['waiver_signed'] ?? '0') === '1'; + + if (!$firstname && !$lastname) { + continue; + } + + $matchedParentId = $this->findExistingParentIdByExternalContact( + $parentFirst, + $parentLast, + $parentEmail, + $parentPhone + ); + + // Match external participants by kid name + event context. + // Parent contact info can change (or be fixed later) and should be updated, not used for matching. + $matchConditions = [ + 'event_id' => $eventId, + 'external_firstname' => $firstname, + 'external_lastname' => $lastname, + 'school_year' => $schoolYear, + 'semester' => $semester, + ]; + if ($matchedParentId) { + $matchConditions['parent_id'] = $matchedParentId; + } else { + // External-only parent: match by external parent contact to avoid duplicates. + $matchConditions['parent_id'] = null; + $matchConditions['external_parent_firstname'] = $parentFirst ?: null; + $matchConditions['external_parent_lastname'] = $parentLast ?: null; + $matchConditions['external_parent_phone'] = $parentPhone ?: null; + $matchConditions['external_parent_email'] = $parentEmail ?: null; + } + $existingExternal = $this->eventChargesModel->where($matchConditions)->first(); + + if ($existingExternal) { + $updateData = [ + 'participation' => 'yes', + 'charged' => $event['amount'], + 'updated_by' => $userId, + // Preserve existing payment status; only update contact fields when provided. + 'external_note' => $note !== '' ? $note : ($existingExternal['external_note'] ?? null), + 'external_parent_firstname' => $parentFirst !== '' ? $parentFirst : ($existingExternal['external_parent_firstname'] ?? null), + 'external_parent_lastname' => $parentLast !== '' ? $parentLast : ($existingExternal['external_parent_lastname'] ?? null), + 'external_parent_phone' => $parentPhone !== '' ? $parentPhone : ($existingExternal['external_parent_phone'] ?? null), + 'external_parent_email' => $parentEmail !== '' ? $parentEmail : ($existingExternal['external_parent_email'] ?? null), + ]; + if ($supportsWaiverSigned) { + $updateData['waiver_signed'] = $waiverSigned ? 1 : 0; + } + $this->eventChargesModel->update($existingExternal['id'], $updateData); + continue; + } + + $insertData = [ + 'parent_id' => $matchedParentId ?: null, + 'student_id' => null, + 'event_id' => $eventId, + 'participation' => 'yes', + 'charged' => $event['amount'], + 'event_paid' => $markPaid ? 1 : 0, + 'external_firstname' => $firstname, + 'external_lastname' => $lastname, + 'external_note' => $note, + 'external_parent_firstname' => $parentFirst, + 'external_parent_lastname' => $parentLast, + 'external_parent_phone' => $parentPhone, + 'external_parent_email' => $parentEmail, + 'school_year' => $schoolYear, + 'semester' => $semester, + 'updated_by' => $userId, + ]; + if ($supportsWaiverSigned) { + $insertData['waiver_signed'] = $waiverSigned ? 1 : 0; + } + if ($supportsCreatedBy) { + $insertData['created_by'] = $userId; + } + $this->eventChargesModel->insert($insertData); + + $newChargeId = (int)$this->eventChargesModel->getInsertID(); + if ($markPaid && $newChargeId > 0) { + $this->applyEventPaymentStatus($newChargeId, true); + } + + if ($matchedParentId && $this->parentHasEnrollment($matchedParentId, (string)$schoolYear)) { + $parentsForInvoice[$matchedParentId] = true; + } + } + + if ($parentId && $this->parentHasEnrollment((int)$parentId, (string)$schoolYear)) { + $parentsForInvoice[(int)$parentId] = true; + } + + foreach (array_keys($parentsForInvoice) as $pid) { + $this->invoiceController->generateInvoice((string)$pid, (string)$schoolYear, (string)$semester, false); + $this->fixInvoiceStatusAfterCharge((int)$pid, (string)$schoolYear); + } return redirect()->back()->with('success', 'Event charges updated successfully.'); } + public function removeCharge($chargeId = null) + { + $returnTo = $this->getEventChargesReturnTo(); + if (!$chargeId) { + return redirect()->to($returnTo)->with('error', 'Invalid charge.'); + } + + $charge = $this->eventChargesModel->find($chargeId); + if (!$charge) { + return redirect()->to($returnTo)->with('error', 'Charge not found.'); + } + + $paymentId = (int)($charge['event_payment_id'] ?? 0); + if ($paymentId > 0) { + $this->paymentModel->delete($paymentId); + } + + $this->eventChargesModel->delete($chargeId); + if (!empty($charge['parent_id']) && $this->parentHasEnrollment((int)$charge['parent_id'], (string)($charge['school_year'] ?? ''))) { + $this->invoiceController->generateInvoice((string)$charge['parent_id'], (string)($charge['school_year'] ?? $this->schoolYear), (string)($charge['semester'] ?? $this->semester), false); + } + + return redirect()->to($returnTo)->with('success', 'Participation removed, invoices updated.'); + } + + public function toggleEventPayment($chargeId = null) + { + $returnTo = $this->getEventChargesReturnTo(); + if (!$chargeId) { + return redirect()->to($returnTo)->with('error', 'Invalid charge.'); + } + + $isPaid = $this->request->getPost('paid') === '1'; + $meta = $this->applyEventPaymentStatus((int)$chargeId, $isPaid); + if (!$meta) { + return redirect()->to($returnTo)->with('error', 'Charge not found.'); + } + + if (!empty($meta['invoice_id'])) { + $this->invoiceController->generateInvoice((string)$meta['parent_id'], (string)$meta['school_year'], (string)($meta['semester'] ?? $this->semester), false); + $this->fixInvoiceStatusAfterCharge((int)$meta['parent_id'], (string)$meta['school_year']); + } + + return redirect()->to($returnTo)->with('success', 'Event payment status updated.'); + } + + public function toggleWaiverStatus($chargeId = null) + { + $returnTo = $this->getEventChargesReturnTo(); + if (!$chargeId) { + return redirect()->to($returnTo)->with('error', 'Invalid charge.'); + } + + if (!$this->eventChargesSupportsWaiverSigned()) { + return redirect()->to($returnTo)->with('error', 'Waiver tracking is not available until the database migration is applied.'); + } + + $charge = $this->eventChargesModel->find($chargeId); + if (!$charge) { + return redirect()->to($returnTo)->with('error', 'Charge not found.'); + } + + $signed = $this->request->getPost('waiver_signed') === '1'; + + $this->eventChargesModel->update((int) $chargeId, [ + 'waiver_signed' => $signed ? 1 : 0, + 'updated_by' => session()->get('user_id'), + ]); + + return redirect()->to($returnTo)->with('success', $signed ? 'Waiver marked as signed.' : 'Waiver marked as unsigned.'); + } + + private function parentHasEnrollment(int $parentId, string $schoolYear): bool + { + if ($parentId <= 0 || $schoolYear === '') { + return false; + } + + try { + return $this->enrollmentModel + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->countAllResults() > 0; + } catch (\Throwable $e) { + return false; + } + } + + private function findExistingParentIdByExternalContact(string $first, string $last, string $email, string $phone): ?int + { + $first = trim($first); + $last = trim($last); + $email = trim($email); + $phoneDigits = preg_replace('/\D+/', '', (string)$phone); + + if ($first === '' || $last === '' || $email === '' || $phoneDigits === '') { + return null; + } + + try { + $db = Database::connect(); + $expr = "REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') ="; + + $row = $this->userModel + ->select('id') + ->where('LOWER(firstname)', strtolower($first)) + ->where('LOWER(lastname)', strtolower($last)) + ->where('LOWER(email)', strtolower($email)) + ->where($expr, $db->escapeString($phoneDigits), false) + ->first(); + + $id = (int)($row['id'] ?? 0); + return $id > 0 ? $id : null; + } catch (\Throwable $e) { + log_message('error', 'Failed to match external parent to users: ' . $e->getMessage()); + return null; + } + } + + private function syncInvoicePaymentSummary(int $invoiceId): void + { + if ($invoiceId <= 0) { + return; + } + + $invoice = $this->invoiceModel->find($invoiceId); + if (!$invoice) { + return; + } + + $db = \Config\Database::connect(); + $exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled']; + + $paidSum = 0.0; + try { + $qb = $db->table('payments') + ->select('COALESCE(SUM(paid_amount),0) AS tot') + ->where('invoice_id', $invoiceId) + ->where('paid_amount >', 0); + + if ($db->fieldExists('status', 'payments')) { + $qb->groupStart() + ->whereNotIn('status', $exclude) + ->orWhere('status IS NULL', null, false) + ->groupEnd(); + } + if ($db->fieldExists('is_void', 'payments')) { + $qb->groupStart() + ->where('is_void', 0) + ->orWhere('is_void IS NULL', null, false) + ->groupEnd(); + } + + $row = $qb->get()->getRowArray(); + $paidSum = (float)($row['tot'] ?? 0.0); + } catch (\Throwable $e) { + log_message('error', 'Failed to sum payments for invoice ' . $invoiceId . ': ' . $e->getMessage()); + } + + $discountSum = 0.0; + try { + $row = $db->table('discount_usages') + ->select('COALESCE(SUM(discount_amount),0) AS tot') + ->where('invoice_id', $invoiceId) + ->get() + ->getRowArray(); + $discountSum = (float)($row['tot'] ?? 0.0); + } catch (\Throwable $e) { + log_message('error', 'Failed to sum discounts for invoice ' . $invoiceId . ': ' . $e->getMessage()); + } + + $refundSum = 0.0; + try { + $row = $db->table('refunds') + ->select('COALESCE(SUM(refund_paid_amount),0) AS tot') + ->where('invoice_id', $invoiceId) + ->whereIn('status', ['Partial', 'Paid']) + ->get() + ->getRowArray(); + $refundSum = (float)($row['tot'] ?? 0.0); + } catch (\Throwable $e) { + log_message('error', 'Failed to sum refunds for invoice ' . $invoiceId . ': ' . $e->getMessage()); + } + + $total = (float)($invoice['total_amount'] ?? 0.0); + $newBalance = round($total - $discountSum - $refundSum - $paidSum, 2); + $newStatus = ($newBalance <= 0.00001) + ? 'Paid' + : (($paidSum > 0) ? 'Partially Paid' : 'Unpaid'); + + $this->invoiceModel->update($invoiceId, [ + 'paid_amount' => $paidSum, + 'balance' => $newBalance, + 'status' => $newStatus, + 'updated_at' => utc_now(), + ]); + } + + private function applyEventPaymentStatus(int $chargeId, bool $isPaid): ?array + { + if ($chargeId <= 0) { + return null; + } + + $charge = $this->eventChargesModel->find($chargeId); + if (!$charge) { + return null; + } + + $event = $this->eventModel->find($charge['event_id']); + $eventAmount = max(0.0, (float)($event['amount'] ?? 0)); + $paymentId = (int)($charge['event_payment_id'] ?? 0); + + $parentId = (int)($charge['parent_id'] ?? 0); + $schoolYear = (string)($charge['school_year'] ?? $this->schoolYear); + $semester = (string)($charge['semester'] ?? $this->semester); + + if ($parentId > 0 && $eventAmount > 0) { + $hasEnrollment = $this->parentHasEnrollment($parentId, $schoolYear); + $invoiceQuery = $this->invoiceModel + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->orderBy('id', 'DESC'); + $invoice = $invoiceQuery->first(); + + if (!$invoice && $hasEnrollment) { + $this->invoiceController->generateInvoice((string)$parentId, $schoolYear, $semester, false); + $invoice = $invoiceQuery->first(); + } + + if ($invoice) { + $invoiceId = (int)($invoice['id'] ?? 0); + if ($invoiceId > 0) { + $this->syncInvoicePaymentSummary($invoiceId); + $invoice = $this->invoiceModel->find($invoiceId) ?? $invoice; + } + + if ($isPaid && !$paymentId) { + $paymentSchoolYear = (string)($invoice['school_year'] ?? $schoolYear); + $paymentSemester = (string)($invoice['semester'] ?? $semester); + $paymentId = (int)($this->createEventPayment( + $parentId, + (int)$invoice['id'], + $eventAmount, + $paymentSchoolYear, + $paymentSemester, + (float)($invoice['total_amount'] ?? 0.0), + (float)($invoice['paid_amount'] ?? 0.0), + (float)($invoice['balance'] ?? 0.0) + ) ?? 0); + } elseif (!$isPaid && $paymentId > 0) { + $this->paymentModel->delete($paymentId); + $paymentId = 0; + } + + if (!empty($invoiceId)) { + $this->syncInvoicePaymentSummary($invoiceId); + } + } elseif (!$hasEnrollment) { + // External-only parent: do not create an invoice/payment row; just persist the paid flag on the charge. + $paymentId = 0; + } + } + + $this->eventChargesModel->update($chargeId, [ + 'event_paid' => $isPaid ? 1 : 0, + 'charged' => $eventAmount, + 'event_payment_id' => $paymentId > 0 ? $paymentId : null, + ]); + + return [ + 'parent_id' => $parentId, + 'school_year' => $schoolYear, + 'semester' => $semester, + 'invoice_id' => isset($invoiceId) && $invoiceId > 0 ? $invoiceId : null, + ]; + } + @@ -336,10 +1065,18 @@ class EventController extends ResourceController $students = $this->studentModel->where('parent_id', $parentId)->findAll(); // Get student_ids that already have charges - $chargedStudentIds = $this->eventChargesModel + $eventId = $this->request->getGet('event_id'); + + $chargesBuilder = $this->eventChargesModel ->where('parent_id', $parentId) ->where('semester', $semester) - ->where('school_year', $schoolYear) + ->where('school_year', $schoolYear); + + if (!empty($eventId)) { + $chargesBuilder->where('event_id', $eventId); + } + + $chargedStudentIds = $chargesBuilder ->groupBy('student_id') ->select('student_id') ->findColumn('student_id'); @@ -356,5 +1093,98 @@ class EventController extends ResourceController return $this->response->setJSON($data); } + private function createEventPayment( + int $parentId, + int $invoiceId, + float $amount, + string $schoolYear, + ?string $semester, + float $invoiceTotal = 0.0, + float $invoicePaid = 0.0, + float $invoiceBalance = 0.0 + ): ?int + { + if ($amount <= 0 || $invoiceId <= 0 || $parentId <= 0) { + return null; + } + + $monthSemester = $semester ?: $this->semester; + $newPaid = (float)$invoicePaid + $amount; + $newBalance = (float)$invoiceBalance - $amount; + $paymentStatus = ($newBalance <= 0.00001) + ? 'Paid' + : (($newPaid > 0) ? 'Partially Paid' : 'Unpaid'); + + $exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled']; + $priorCount = $this->paymentModel + ->where('invoice_id', $invoiceId) + ->where('paid_amount >', 0) + ->whereNotIn('status', $exclude) + ->countAllResults(); + $installmentSeq = $priorCount + 1; + + $data = [ + 'parent_id' => $parentId, + 'invoice_id' => $invoiceId, + 'total_amount' => $invoiceTotal > 0 ? $invoiceTotal : $amount, + 'paid_amount' => $amount, + 'balance' => $newBalance, + 'number_of_installments' => $installmentSeq, + 'payment_method' => 'cash', + 'payment_date' => utc_now(), + 'school_year' => $schoolYear, + 'semester' => $monthSemester, + 'status' => $paymentStatus, + 'transaction_id' => $this->paymentModel->generateNewTransactionId(), + 'updated_by' => session()->get('user_id'), + ]; + + if (!$this->paymentModel->insert($data)) { + log_message('error', 'Failed to insert event payment: ' . json_encode($this->paymentModel->errors())); + return null; + } + + return (int)$this->paymentModel->getInsertID(); + } + + private function normalizeExternalName(?string $value): string + { + $raw = trim((string)$value); + if ($raw === '') { + return ''; + } + $parts = preg_split('/\s+/', $raw); + $formatted = array_map(function ($part) { + $part = trim($part); + if ($part === '') { + return ''; + } + return mb_strtoupper(mb_substr($part, 0, 1)) . mb_strtolower(mb_substr($part, 1)); + }, $parts); + return implode(' ', array_filter($formatted, fn($p) => $p !== '')); + } + + private function fixInvoiceStatusAfterCharge($parentId, $schoolYear) + { + if (!$parentId || !$schoolYear) { + return; + } + + $invoices = $this->invoiceModel + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->findAll(); + + foreach ($invoices as $invoice) { + $status = strtolower(trim($invoice['status'] ?? '')); + $balance = (float)($invoice['balance'] ?? 0.0); + + if ($balance <= 0.00001 && $status !== 'paid') { + $this->invoiceModel->update($invoice['id'], ['status' => 'Paid']); + } elseif ($status === 'paid' && $balance > 0) { + $this->invoiceModel->update($invoice['id'], ['status' => 'Unpaid']); + } + } + } } diff --git a/app/Controllers/View/ExamDraftController.php b/app/Controllers/View/ExamDraftController.php old mode 100644 new mode 100755 index f95e7a7..f4862f6 --- a/app/Controllers/View/ExamDraftController.php +++ b/app/Controllers/View/ExamDraftController.php @@ -6,6 +6,7 @@ use App\Controllers\BaseController; use App\Models\ClassSectionModel; use App\Models\ConfigurationModel; use App\Models\ExamDraftModel; +use App\Models\StudentClassModel; use App\Models\TeacherClassModel; use App\Models\UserModel; use CodeIgniter\HTTP\Files\UploadedFile; @@ -16,6 +17,7 @@ class ExamDraftController extends BaseController protected ExamDraftModel $examDraftModel; protected TeacherClassModel $teacherClassModel; protected ClassSectionModel $classSectionModel; + protected StudentClassModel $studentClassModel; protected UserModel $userModel; protected ConfigurationModel $configModel; @@ -23,9 +25,19 @@ class ExamDraftController extends BaseController protected string $semester; protected bool $hasFinalPdfColumn = false; protected bool $hasIsLegacyColumn = false; + protected bool $hasAcceptanceTypeColumn = false; + protected bool $hasAdminIdColumn = false; + protected bool $hasReviewRevisionColumn = false; + protected bool $hasReviewerCommentColumn = false; + protected bool $hasReviewerCommentsColumn = false; + protected bool $hasAdminCommentsColumn = false; + protected string $authorIdColumn = 'teacher_id'; + protected string $authorFileColumn = 'teacher_file'; + protected string $authorFilenameColumn = 'teacher_filename'; + protected string $reviewerIdColumn = 'admin_id'; + protected string $reviewerCommentColumn = 'reviewer_comment'; - // DB enum: draft, submitted, reviewed, finalized, rejected - // (string literals used to avoid excess constants) + // DB status: submitted, accepted, review needed, rejected, canceled, under review, legacy protected const TEACHER_UPLOAD_DIR = 'exams/drafts'; protected const FINAL_UPLOAD_DIR = 'exams/finals'; @@ -47,6 +59,7 @@ class ExamDraftController extends BaseController $this->examDraftModel = new ExamDraftModel(); $this->teacherClassModel = new TeacherClassModel(); $this->classSectionModel = new ClassSectionModel(); + $this->studentClassModel = new StudentClassModel(); $this->userModel = new UserModel(); $this->configModel = new ConfigurationModel(); $this->db = Database::connect(); @@ -55,6 +68,23 @@ class ExamDraftController extends BaseController $this->semester = (string) ($this->configModel->getConfig('semester') ?? ''); $this->hasFinalPdfColumn = $this->schemaHasColumn('exam_drafts', 'final_pdf_file'); $this->hasIsLegacyColumn = $this->schemaHasColumn('exam_drafts', 'is_legacy'); + $this->hasAcceptanceTypeColumn = $this->schemaHasColumn('exam_drafts', 'acceptance_type'); + $this->hasAdminIdColumn = $this->schemaHasColumn('exam_drafts', 'admin_id'); + $this->hasReviewRevisionColumn = $this->schemaHasColumn('exam_drafts', 'review_revision'); + $this->hasReviewerCommentColumn = $this->schemaHasColumn('exam_drafts', 'reviewer_comment'); + $this->hasReviewerCommentsColumn = $this->schemaHasColumn('exam_drafts', 'reviewer_comments'); + $this->hasAdminCommentsColumn = $this->schemaHasColumn('exam_drafts', 'admin_comments'); + $this->authorIdColumn = $this->schemaHasColumn('exam_drafts', 'teacher_id') ? 'teacher_id' : 'author_id'; + $this->authorFileColumn = $this->schemaHasColumn('exam_drafts', 'teacher_file') ? 'teacher_file' : 'author_file'; + $this->authorFilenameColumn = $this->schemaHasColumn('exam_drafts', 'teacher_filename') ? 'teacher_filename' : 'author_filename'; + $this->reviewerIdColumn = $this->hasAdminIdColumn + ? 'admin_id' + : ($this->schemaHasColumn('exam_drafts', 'reviewer_id') ? 'reviewer_id' : ''); + $this->reviewerCommentColumn = $this->schemaHasColumn('exam_drafts', 'reviewer_comment') + ? 'reviewer_comment' + : ($this->schemaHasColumn('exam_drafts', 'reviewer_comments') + ? 'reviewer_comments' + : ($this->schemaHasColumn('exam_drafts', 'admin_comments') ? 'admin_comments' : '')); helper(['form', 'url', 'date']); } @@ -72,8 +102,9 @@ class ExamDraftController extends BaseController session()->set('class_section_id', $selectedClass); } - $allDrafts = $this->examDraftModel - ->where('teacher_id', $teacherId) + $classSectionIds = array_map(static fn($a) => (int) $a['class_section_id'], $assignments); + + $allDrafts = $this->visibleTeacherDraftsQuery($teacherId, $classSectionIds) ->orderBy('created_at', 'DESC') ->findAll(); @@ -84,35 +115,55 @@ class ExamDraftController extends BaseController $row['final_pdf_file'] = $pdf; } } + $row = $this->attachTeacherDraftContext($row, $teacherId); } unset($row); - $classSectionIds = array_map(static fn($a) => (int) $a['class_section_id'], $assignments); $legacyExams = []; // Guard against missing schema column in older databases if ($this->hasIsLegacyColumn) { // Keep all submissions visible; legacy ones are also surfaced in a separate tab $drafts = $allDrafts; + $legacyQuery = $this->examDraftModel + ->select($this->draftSelectColumns()) + ->select('cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last') + ->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left') + ->join('users u', 'u.id = exam_drafts.' . $this->authorIdColumn, 'left') + ->where('exam_drafts.is_legacy', 1) + ->where('exam_drafts.status', 'legacy') + ->where('exam_drafts.final_file IS NOT NULL', null, false); + if (!empty($classSectionIds)) { - $legacyExams = $this->examDraftModel - ->select('exam_drafts.*, cs.class_section_name') - ->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left') - ->whereIn('exam_drafts.class_section_id', $classSectionIds) - ->where('exam_drafts.is_legacy', 1) - ->where('exam_drafts.final_file IS NOT NULL', null, false) - ->orderBy('cs.class_section_name', 'ASC') - ->orderBy('exam_drafts.created_at', 'DESC') - ->findAll(); + $legacyQuery = $legacyQuery->whereIn('exam_drafts.class_section_id', $classSectionIds); } + + $legacyExams = $legacyQuery + ->orderBy('cs.class_section_name', 'ASC') + ->orderBy('exam_drafts.created_at', 'DESC') + ->findAll(); + + foreach ($legacyExams as &$row) { + $row = $this->attachTeacherDraftContext($row, $teacherId); + } + unset($row); } else { // Legacy column absent, show all drafts and skip legacy tab query $drafts = $allDrafts; } + $printablePool = array_merge($drafts, $legacyExams); + $printableIds = $this->printableDraftIds($printablePool); + if ($this->hasReviewRevisionColumn) { + $drafts = $this->groupReviewRevisions($drafts); + $legacyExams = $this->groupReviewRevisions($legacyExams); + } + $drafts = $this->attachPrintableFlags($drafts, $printableIds); + $legacyExams = $this->attachPrintableFlags($legacyExams, $printableIds); + $validation = session()->getFlashdata('validation') ?? $this->validator; - return view('teacher/exam_drafts', [ + return view('teacher/drafts', [ 'assignments' => $assignments, 'selectedClassSection' => $selectedClass, 'drafts' => $drafts, @@ -123,6 +174,7 @@ class ExamDraftController extends BaseController 'semester' => $this->semester, 'maxUploadBytes' => self::MAX_UPLOAD_BYTES, 'validation' => $validation, + 'printableDraftIds' => $printableIds, ]); } @@ -134,8 +186,8 @@ class ExamDraftController extends BaseController } $classSectionId = (int) ($this->request->getPost('class_section_id') ?? session()->get('class_section_id') ?? 0); - $examType = trim((string) $this->request->getPost('exam_type')); - $description = trim((string) $this->request->getPost('description')); + $examType = $this->normalizeExamType($this->request->getPost('exam_type')); + $authorComment = trim((string) $this->request->getPost('author_comment')); if ($classSectionId <= 0) { return redirect()->back()->withInput()->with('error', 'Select a class section before submitting.'); @@ -152,12 +204,8 @@ class ExamDraftController extends BaseController session()->set('class_section_id', $classSectionId); - if ($classSectionId <= 0) { - $classSectionId = $this->resolveSelectedClassSection($assignments); - if ($classSectionId <= 0) { - return redirect()->back()->withInput()->with('error', 'Select a class section before submitting.'); - } - } + $saveAsDraft = $this->request->getPost('action') === 'draft' + || !empty($this->request->getPost('save_as_draft')); $file = $this->request->getFile('draft_file'); $teacherFile = null; @@ -174,38 +222,125 @@ class ExamDraftController extends BaseController } $existingDraft = $this->examDraftModel - ->where('teacher_id', $teacherId) + ->select($this->draftSelectColumns()) + ->where($this->authorIdColumn, $teacherId) ->where('class_section_id', $classSectionId) ->where('semester', $this->semester) ->where('school_year', $this->schoolYear) ->orderBy('version', 'DESC') ->first(); + if ($examType === '') { + return redirect()->back()->withInput()->with('error', 'Select an exam type before submitting.'); + } + + $title = $examType; + + if ($saveAsDraft) { + if ($teacherFile === null && (empty($existingDraft) || empty($this->draftTeacherFile($existingDraft)))) { + return redirect()->back()->withInput()->with('error', 'Upload a file to save a draft.'); + } + if (!empty($existingDraft) && strtolower((string) ($existingDraft['status'] ?? '')) === 'draft') { + $draftRowId = (int) ($existingDraft['id'] ?? 0); + $updateDraft = [ + 'exam_type' => $examType, + 'draft_title' => $title, + 'author_comment' => $authorComment === '' ? null : $authorComment, + 'status' => 'draft', + ]; + $this->applyAuthorFilePayload($updateDraft, $teacherFile, $teacherFilename); + if ($this->examDraftModel->update($draftRowId, $updateDraft)) { + $this->notifyExamDraftEvent($this->examDraftModel->find($draftRowId), 'draft_saved'); + return redirect()->to('/teacher/exam-drafts')->with('success', 'Draft saved.'); + } + return redirect()->back()->withInput()->with('error', 'Unable to update the draft.'); + } + + $nextVersion = 1; + $previousId = null; + if (!empty($existingDraft)) { + $currentVersion = (int) ($existingDraft['version'] ?? 0); + if ($currentVersion <= 0) { + $currentVersion = 1; + } + $nextVersion = $currentVersion + 1; + $previousId = (int) ($existingDraft['id'] ?? 0) ?: null; + } + $payload = [ + $this->authorIdColumn => $teacherId, + 'class_section_id' => $classSectionId, + 'semester' => $this->semester, + 'school_year' => $this->schoolYear, + 'exam_type' => $examType, + 'draft_title' => $title, + 'author_comment' => $authorComment === '' ? null : $authorComment, + 'status' => 'draft', + 'version' => $nextVersion, + 'previous_draft_id' => $previousId, + ]; + $this->applyAuthorFilePayload($payload, $teacherFile, $teacherFilename); + if ($this->examDraftModel->insert($payload)) { + $newDraftId = (int) $this->examDraftModel->getInsertID(); + $this->notifyExamDraftEvent($newDraftId > 0 ? $this->examDraftModel->find($newDraftId) : null, 'draft_saved'); + return redirect()->to('/teacher/exam-drafts')->with('success', 'Draft saved.'); + } + return redirect()->back()->withInput()->with('error', 'Unable to save the draft.'); + } + + $existingStatus = strtolower((string) ($existingDraft['status'] ?? '')); + if ($teacherFile === null) { + return redirect()->back()->withInput()->with('error', 'Upload a DOC/DOCX file to submit the exam draft.'); + } + + // Submit for review: overwrite only if latest is already submitted; otherwise create a new version. + if (!empty($existingDraft) && $existingStatus === 'submitted') { + $draftRowId = (int) ($existingDraft['id'] ?? 0); + $updateSubmit = [ + 'exam_type' => $examType, + 'draft_title' => $title, + 'author_comment' => $authorComment === '' ? null : $authorComment, + 'status' => 'submitted', + ]; + $this->applyAuthorFilePayload($updateSubmit, $teacherFile, $teacherFilename); + if ($this->examDraftModel->update($draftRowId, $updateSubmit)) { + $saved = $this->ensureDraftStatus($draftRowId, 'submitted'); + $this->notifyPrincipalExamDraftSubmitted($saved, $teacherId, $classSectionId); + $this->notifyExamDraftEvent($saved, 'submitted'); + return redirect()->to('/teacher/exam-drafts')->with('success', 'Exam draft submitted for review.'); + } + return redirect()->back()->withInput()->with('error', 'Unable to submit the exam draft.'); + } + $nextVersion = 1; $previousId = null; if (!empty($existingDraft)) { - $nextVersion = ((int) ($existingDraft['version'] ?? 1)) + 1; + $currentVersion = (int) ($existingDraft['version'] ?? 0); + if ($currentVersion <= 0) { + $currentVersion = 1; + } + $nextVersion = $currentVersion + 1; $previousId = (int) ($existingDraft['id'] ?? 0) ?: null; } - $title = $examType ?: 'Exam Draft'; - $payload = [ - 'teacher_id' => $teacherId, + $this->authorIdColumn => $teacherId, 'class_section_id' => $classSectionId, 'semester' => $this->semester, 'school_year' => $this->schoolYear, - 'exam_type' => $examType ?: null, + 'exam_type' => $examType, 'draft_title' => $title, - 'description' => $description === '' ? null : $description, - 'teacher_file' => $teacherFile, - 'teacher_filename' => $teacherFilename, + 'author_comment' => $authorComment === '' ? null : $authorComment, 'status' => 'submitted', 'version' => $nextVersion, 'previous_draft_id' => $previousId, ]; + $this->applyAuthorFilePayload($payload, $teacherFile, $teacherFilename); if ($this->examDraftModel->insert($payload)) { + $newId = (int) $this->examDraftModel->getInsertID(); + $saved = $newId > 0 ? $this->ensureDraftStatus($newId, 'submitted') : null; + $this->notifyPrincipalExamDraftSubmitted($saved, $teacherId, $classSectionId); + $this->notifyExamDraftEvent($saved, 'submitted'); return redirect()->to('/teacher/exam-drafts')->with('success', 'Exam draft submitted for review.'); } @@ -214,13 +349,38 @@ class ExamDraftController extends BaseController public function adminIndex() { - $allDrafts = $this->examDraftModel - ->select('exam_drafts.*, cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last, a.firstname AS admin_first, a.lastname AS admin_last') - ->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left') - ->join('users u', 'u.id = exam_drafts.teacher_id', 'left') - ->join('users a', 'a.id = exam_drafts.admin_id', 'left') - ->orderBy('exam_drafts.created_at', 'DESC') - ->findAll(); + return $this->reviewIndex(); + } + + public function principalIndex() + { + return $this->reviewIndex(); + } + + public function reviewIndex() + { + if ($this->reviewerIdColumn !== '') { + $allDrafts = $this->examDraftModel + ->select($this->draftSelectColumns()) + ->select('cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last, a.firstname AS admin_first, a.lastname AS admin_last') + ->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left') + ->join('users u', 'u.id = exam_drafts.' . $this->authorIdColumn, 'left') + ->join('users a', 'a.id = exam_drafts.' . $this->reviewerIdColumn, 'left') + ->orderBy('exam_drafts.created_at', 'DESC') + ->findAll(); + } else { + $allDrafts = $this->examDraftModel + ->select($this->draftSelectColumns()) + ->select('cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last, NULL AS admin_first, NULL AS admin_last', false) + ->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left') + ->join('users u', 'u.id = exam_drafts.' . $this->authorIdColumn, 'left') + ->orderBy('exam_drafts.created_at', 'DESC') + ->findAll(); + } + + if ($this->hasReviewRevisionColumn) { + $allDrafts = $this->groupReviewRevisions($allDrafts); + } foreach ($allDrafts as &$row) { if (empty($row['final_pdf_file'])) { @@ -237,36 +397,95 @@ class ExamDraftController extends BaseController ->orderBy('class_section_name', 'ASC') ->findAll(); - // Group legacy uploads (admin-uploaded finalized exams) by class_section for separate tab + // Group legacy uploads (admin-uploaded accepted exams) by class_section for separate tab $legacyByClass = []; if ($this->hasIsLegacyColumn) { - // Keep all submissions visible; additionally surface legacy items in a separate tab - $drafts = $allDrafts; - + // Keep legacy items out of the main submissions list; show them in the legacy tab only. + $drafts = []; foreach ($allDrafts as $d) { $isLegacy = !empty($d['is_legacy']); - if (!$isLegacy) { + if ($isLegacy) { + $cid = (int)($d['class_section_id'] ?? 0); + if (!isset($legacyByClass[$cid])) { + $legacyByClass[$cid] = [ + 'class_section_id' => $cid, + 'class_section_name' => $d['class_section_name'] ?? 'Class ' . $cid, + 'items' => [], + ]; + } + $legacyByClass[$cid]['items'][] = $d; continue; } - $cid = (int)($d['class_section_id'] ?? 0); - if (!isset($legacyByClass[$cid])) { - $legacyByClass[$cid] = [ - 'class_section_id' => $cid, - 'class_section_name' => $d['class_section_name'] ?? 'Class ' . $cid, - 'items' => [], - ]; - } - $legacyByClass[$cid]['items'][] = $d; + $drafts[] = $d; } } else { // Column missing: keep behavior simple and avoid legacy tab $drafts = $allDrafts; } + $legacyFlat = []; + foreach ($legacyByClass as $group) { + foreach ($group['items'] ?? [] as $item) { + $legacyFlat[] = $item; + } + } + $printablePool = array_merge($drafts, $legacyFlat); + $printableIds = $this->printableDraftIds($printablePool); + $drafts = $this->attachPrintableFlags($drafts, $printableIds); + foreach ($legacyByClass as &$group) { + $group['items'] = $this->attachPrintableFlags($group['items'] ?? [], $printableIds); + } + unset($group); + + $classSectionsById = []; + foreach ($classSections as $cs) { + $csId = (int) ($cs['class_section_id'] ?? 0); + if ($csId <= 0) { + continue; + } + $classSectionsById[$csId] = $cs; + } + + $studentCounts = $this->studentClassModel->getStudentCountsBySection($this->schoolYear); + $visibleClasses = []; + foreach ($studentCounts as $csId => $count) { + $csId = (int) $csId; + if ($csId <= 0 || $count <= 0) { + continue; + } + $classData = $classSectionsById[$csId] ?? $this->classSectionModel->where('class_section_id', $csId)->first(); + if ($classData === null) { + $classData = [ + 'class_section_id' => $csId, + 'class_section_name' => 'Class ' . $csId, + ]; + } + $classData['student_count'] = $count; + $visibleClasses[$csId] = $classData; + } + uasort($visibleClasses, static fn ($a, $b): int => strcasecmp($a['class_section_name'] ?? '', $b['class_section_name'] ?? '')); + + $classDraftGroups = []; + foreach ($drafts as $draft) { + $cid = (int) ($draft['class_section_id'] ?? 0); + if ($cid <= 0) { + continue; + } + $classDraftGroups[$cid][] = $draft; + } + $newSubmissionClasses = []; + foreach ($classDraftGroups as $cid => $group) { + foreach ($group as $draft) { + if (strtolower((string) ($draft['status'] ?? '')) === 'submitted') { + $newSubmissionClasses[$cid] = true; + break; + } + } + } + return view('administrator/exam_drafts', [ 'drafts' => $drafts, 'statusBadges' => $this->statusBadgeMap(), - 'statusOptions' => $this->statusOptions(), 'schoolYear' => $this->schoolYear, 'semester' => $this->semester, 'allowedExtensions' => self::ADMIN_ALLOWED_EXTENSIONS, @@ -274,23 +493,44 @@ class ExamDraftController extends BaseController 'examTypes' => $this->examTypes, 'classSections' => $classSections, 'legacyByClass' => $legacyByClass, + 'printableDraftIds' => $printableIds, + 'visibleClasses' => $visibleClasses, + 'classDraftGroups' => $classDraftGroups, + 'newSubmissionClasses' => $newSubmissionClasses, + 'reviewActionUrl' => base_url($this->reviewRoutePrefix() . '/exam-drafts/review'), + 'legacyUploadUrl' => base_url($this->reviewRoutePrefix() . '/exam-drafts/upload-legacy'), + 'reviewPortalLabel' => $this->reviewPortalLabel(), ]); } public function adminUploadLegacy() + { + return $this->reviewUploadLegacy(); + } + + public function principalUploadLegacy() + { + return $this->reviewUploadLegacy(); + } + + public function reviewUploadLegacy() { $adminId = (int) (session()->get('user_id') ?? 0); if ($adminId <= 0) { return redirect()->to('/login'); } - $classSectionId = (int) ($this->request->getPost('class_section_id') ?? 0); + $classSectionIds = $this->request->getPost('class_section_ids'); + if (!is_array($classSectionIds)) { + $classSectionIds = [$classSectionIds]; + } + $classSectionIds = array_values(array_filter(array_map('intval', $classSectionIds))); $schoolYear = trim((string) ($this->request->getPost('school_year') ?? $this->schoolYear)); $semester = trim((string) ($this->request->getPost('semester') ?? $this->semester)); - $examType = trim((string) $this->request->getPost('exam_type')); + $examType = $this->normalizeExamType($this->request->getPost('exam_type')); - if ($classSectionId <= 0) { - return redirect()->back()->withInput()->with('error', 'Select a class section.'); + if (empty($classSectionIds)) { + return redirect()->back()->withInput()->with('error', 'Select at least one class section.'); } if ($schoolYear === '') { return redirect()->back()->withInput()->with('error', 'School year is required.'); @@ -298,6 +538,9 @@ class ExamDraftController extends BaseController if ($semester === '') { return redirect()->back()->withInput()->with('error', 'Semester is required.'); } + if ($examType === '') { + return redirect()->back()->withInput()->with('error', 'Exam type is required.'); + } $file = $this->request->getFile('old_exam_file'); if (!$file || !$file->isValid()) { @@ -309,25 +552,6 @@ class ExamDraftController extends BaseController return redirect()->back()->withInput()->with('error', 'File type not allowed or upload failed.'); } - $payload = [ - 'teacher_id' => $adminId, // store under admin user since legacy uploads are admin-only - 'class_section_id' => $classSectionId, - 'semester' => ucfirst(strtolower($semester)), - 'school_year' => $schoolYear, - 'exam_type' => $examType === '' ? null : $examType, - 'draft_title' => $examType === '' ? 'Legacy Exam' : $examType, - 'description' => null, - 'final_file' => $stored, - 'final_filename' => $file->getClientName(), - 'status' => 'finalized', - 'admin_id' => $adminId, - 'reviewed_at' => utc_now(), - 'version' => 1, - ]; - if ($this->hasIsLegacyColumn) { - $payload['is_legacy'] = 1; - } - $pdfName = null; if (strtolower($file->getClientExtension()) === 'pdf') { $pdfName = $stored; @@ -337,30 +561,68 @@ class ExamDraftController extends BaseController self::FINAL_UPLOAD_DIR ); } + $basePayload = [ + $this->authorIdColumn => $adminId, // store under admin user since legacy uploads are admin-only + 'semester' => ucfirst(strtolower($semester)), + 'school_year' => $schoolYear, + 'exam_type' => $examType, + 'draft_title' => $examType, + 'author_comment' => null, + 'final_file' => $stored, + 'final_filename' => $file->getClientName(), + 'status' => 'legacy', + 'reviewed_at' => utc_now(), + 'version' => 1, + ]; + if ($this->reviewerIdColumn !== '') { + $basePayload[$this->reviewerIdColumn] = $adminId; + } + if ($this->hasIsLegacyColumn) { + $basePayload['is_legacy'] = 1; + } if ($pdfName !== null && $this->hasFinalPdfColumn) { - $payload['final_pdf_file'] = $pdfName; + $basePayload['final_pdf_file'] = $pdfName; } - if ($this->examDraftModel->insert($payload)) { - return redirect()->to('/administrator/exam-drafts')->with('success', 'Old exam uploaded successfully.'); + $saved = 0; + foreach ($classSectionIds as $classSectionId) { + $payload = $basePayload; + $payload['class_section_id'] = $classSectionId; + if ($this->examDraftModel->insert($payload)) { + $saved++; + } + } + + if ($saved > 0) { + return redirect()->to($this->reviewDashboardPath())->with('success', 'Old exam uploaded successfully.'); } return redirect()->back()->withInput()->with('error', 'Unable to save the old exam.'); } public function adminReview() + { + return $this->reviewSubmission(); + } + + public function principalReview() + { + return $this->reviewSubmission(); + } + + public function reviewSubmission() { $draftId = (int) ($this->request->getPost('draft_id') ?? 0); if ($draftId <= 0) { return redirect()->back()->with('error', 'Invalid submission selected.'); } - $draft = $this->examDraftModel->find($draftId); + $draft = $this->findDraft($draftId); if (empty($draft)) { return redirect()->back()->with('error', 'Submission not found.'); } - $comments = trim((string) $this->request->getPost('admin_comments')); + $reviewerComment = trim((string) ($this->request->getPost('reviewer_comment') ?? $this->request->getPost('admin_comments'))); $statusInput = trim((string) $this->request->getPost('review_status')); $currentStatus = (string) ($draft['status'] ?? 'draft'); $status = $this->normalizeStatus( @@ -369,23 +631,28 @@ class ExamDraftController extends BaseController ); $status = strtolower($status); if (!in_array($status, $this->statusOptions(), true)) { - $status = 'reviewed'; + $status = 'under review'; } + + $acceptanceType = null; + if ($status === 'accepted') { + $rawAccept = strtolower(trim((string) $this->request->getPost('acceptance_type'))); + $acceptanceType = in_array($rawAccept, ['as_is', 'minor_edits'], true) ? $rawAccept : 'as_is'; + } + $finalFile = null; $finalFilename = null; $file = $this->request->getFile('final_file'); if ($file && $file->isValid() && !$file->hasMoved()) { - $stored = $this->storeUploadedFile($file, self::FINAL_UPLOAD_DIR); + $stored = $this->storeUploadedFile($file, self::FINAL_UPLOAD_DIR, self::ADMIN_ALLOWED_EXTENSIONS); if ($stored === null) { return redirect()->back()->with('error', 'Unable to store the final draft.')->withInput(); } $finalFile = $stored; $finalFilename = $file->getClientName(); - // Only auto-finalize if the admin explicitly chose "finalized" - // (previously any uploaded file forced finalization) - if ($status === 'finalized') { - $status = 'finalized'; + if ($status === 'accepted') { + $status = 'accepted'; } } elseif ($file && $file->getError() !== UPLOAD_ERR_NO_FILE) { return redirect()->back()->with('error', 'Final file upload failed.'); @@ -393,36 +660,91 @@ class ExamDraftController extends BaseController $update = [ 'status' => $status, - 'admin_comments' => $comments === '' ? null : $comments, - 'admin_id' => (int) (session()->get('user_id') ?? 0), 'reviewed_at' => utc_now(), ]; + $existingComment = (string) ($draft['reviewer_comment'] ?? $draft['reviewer_comments'] ?? $draft['admin_comments'] ?? ''); + $commentValue = $reviewerComment === '' ? null : $this->appendReviewerComment($existingComment, $reviewerComment); + if ($this->hasReviewerCommentColumn) { + $update['reviewer_comment'] = $commentValue; + } + if ($this->hasReviewerCommentsColumn) { + $update['reviewer_comments'] = $commentValue; + } + if ($this->hasAdminCommentsColumn) { + $update['admin_comments'] = $commentValue; + } + if ($this->reviewerIdColumn !== '') { + $update[$this->reviewerIdColumn] = (int) (session()->get('user_id') ?? 0); + } + + if ($this->hasAcceptanceTypeColumn) { + $update['acceptance_type'] = $status === 'accepted' ? $acceptanceType : null; + } if ($finalFile !== null) { - $update['final_file'] = $finalFile; - $update['final_filename'] = $finalFilename; + $baseVersion = (int) ($draft['version'] ?? 1); + if ($baseVersion <= 0) { + $baseVersion = 1; + } + $reviewRevision = $this->nextReviewRevision($draft, $baseVersion); + $reviewExamType = $this->normalizeExamType($draft['exam_type'] ?? $draft['draft_title'] ?? ''); + if ($reviewExamType === '') { + return redirect()->back()->with('error', 'This draft is missing an exam type. Update the exam type before reviewing.'); + } + + $newRow = [ + $this->authorIdColumn => $this->draftTeacherId($draft), + 'class_section_id' => (int) ($draft['class_section_id'] ?? 0), + 'semester' => (string) ($draft['semester'] ?? ''), + 'school_year' => (string) ($draft['school_year'] ?? ''), + 'exam_type' => $reviewExamType, + 'draft_title' => $draft['draft_title'] ?? $reviewExamType, + 'author_comment' => $draft['author_comment'] ?? null, + 'status' => $status, + 'reviewed_at' => $update['reviewed_at'], + 'version' => $baseVersion, + 'previous_draft_id' => (int) ($draft['id'] ?? 0) ?: null, + ]; + if ($this->hasReviewRevisionColumn) { + $newRow['review_revision'] = $reviewRevision; + } + if ($this->hasReviewerCommentColumn) { + $newRow['reviewer_comment'] = $commentValue; + } + if ($this->hasReviewerCommentsColumn) { + $newRow['reviewer_comments'] = $commentValue; + } + if ($this->hasAdminCommentsColumn) { + $newRow['admin_comments'] = $commentValue; + } + if ($this->reviewerIdColumn !== '') { + $newRow[$this->reviewerIdColumn] = $update[$this->reviewerIdColumn] ?? null; + } + if ($this->hasAcceptanceTypeColumn) { + $newRow['acceptance_type'] = $status === 'accepted' ? $acceptanceType : null; + } + if ($this->hasIsLegacyColumn && !empty($draft['is_legacy'])) { + $newRow['is_legacy'] = 1; + } + $newRow['final_file'] = $finalFile; + $newRow['final_filename'] = $finalFilename; $pdfName = $this->ensurePdfExists($finalFile, $file ? $file->getClientExtension() : null); if ($pdfName !== null && $this->hasFinalPdfColumn) { - $update['final_pdf_file'] = $pdfName; + $newRow['final_pdf_file'] = $pdfName; } - } elseif (strtolower($status) === 'finalized' && !empty($draft['teacher_file'])) { - // Auto-promote teacher file when admin finalizes without uploading a final - $copied = $this->copyDraftToFinal($draft['teacher_file']); - if ($copied !== null) { - $update['final_file'] = $copied; - $update['final_filename'] = $draft['teacher_filename'] ?? $draft['teacher_file']; - $pdfName = $this->ensurePdfExists($copied, pathinfo($copied, PATHINFO_EXTENSION)); - if ($pdfName !== null && $this->hasFinalPdfColumn) { - $update['final_pdf_file'] = $pdfName; - } + if ($this->examDraftModel->insert($newRow)) { + $newId = (int) $this->examDraftModel->getInsertID(); + $after = $newId > 0 ? $this->examDraftModel->find($newId) : null; + $this->notifyExamDraftEvent($after, 'review_' . $status); + return redirect()->back()->with('success', 'Review saved successfully.'); } + return redirect()->back()->with('error', 'Unable to save the review.'); } - if (strtolower($status) === 'finalized' && $this->hasIsLegacyColumn) { - $update['is_legacy'] = 1; // finalized exams move to Previous Exams + if ($status === 'legacy') { + $this->prepareLegacyPdfVersion($update, $draft); } - if ($this->hasIsLegacyColumn) { - // Keep existing legacy flag, do not set legacy automatically here + $update['is_legacy'] = strtolower($status) === 'legacy' ? 1 : 0; } $updated = $this->examDraftModel @@ -431,12 +753,83 @@ class ExamDraftController extends BaseController ->update(); if ($updated) { + $after = $this->examDraftModel->find($draftId); + $this->notifyExamDraftEvent($after, 'review_' . $status); return redirect()->back()->with('success', 'Review saved successfully.'); } return redirect()->back()->with('error', 'Unable to save the review.'); } + public function teacherPrint(int $id) + { + $teacherId = (int) (session()->get('user_id') ?? 0); + if ($teacherId <= 0) { + return redirect()->to('/login'); + } + $draft = $this->findDraft($id); + if (empty($draft) || $this->draftTeacherId($draft) !== $teacherId) { + return redirect()->to('/teacher/exam-drafts')->with('error', 'Submission not found.'); + } + $all = $this->examDraftModel + ->select($this->draftSelectColumns()) + ->where($this->authorIdColumn, $teacherId) + ->findAll(); + $printable = $this->printableDraftIds($all); + if (!in_array($id, $printable, true)) { + return redirect()->to('/teacher/exam-drafts')->with('error', 'Only the latest accepted revision can be printed.'); + } + + return $this->streamFinalPdf($draft); + } + + public function teacherStatusFeed() + { + $teacherId = (int) (session()->get('user_id') ?? 0); + if ($teacherId <= 0) { + return $this->response->setStatusCode(401); + } + + $assignments = $this->teacherClassModel->getClassAssignmentsByUserId($teacherId, $this->schoolYear); + $classSectionIds = array_map(static fn($a) => (int) $a['class_section_id'], $assignments); + + $drafts = $this->visibleTeacherDraftsQuery($teacherId, $classSectionIds) + ->select('exam_drafts.id, exam_drafts.status, exam_drafts.acceptance_type, exam_drafts.updated_at, exam_drafts.reviewed_at') + ->orderBy('updated_at', 'DESC') + ->findAll(); + + $payload = [ + 'drafts' => array_map(static fn(array $row): array => [ + 'id' => (int) ($row['id'] ?? 0), + 'status' => strtolower((string) ($row['status'] ?? '')), + 'acceptance_type' => (string) ($row['acceptance_type'] ?? ''), + 'updated_at' => (string) ($row['updated_at'] ?? ''), + 'reviewed_at' => (string) ($row['reviewed_at'] ?? ''), + ], $drafts), + ]; + + return $this->response->setJSON($payload); + } + + public function adminPrint(int $id) + { + $adminId = (int) (session()->get('user_id') ?? 0); + if ($adminId <= 0) { + return redirect()->to('/login'); + } + $draft = $this->findDraft($id); + if (empty($draft)) { + return redirect()->to('/administrator/exam-drafts')->with('error', 'Submission not found.'); + } + $all = $this->examDraftModel->select($this->draftSelectColumns())->findAll(); + $printable = $this->printableDraftIds($all); + if (!in_array($id, $printable, true)) { + return redirect()->to('/administrator/exam-drafts')->with('error', 'Only the latest accepted revision can be printed.'); + } + + return $this->streamFinalPdf($draft); + } + private function resolveSelectedClassSection(array $assignments): int { $candidate = (int) ($this->request->getGet('class_section_id') ?? session()->get('class_section_id') ?? 0); @@ -447,44 +840,345 @@ class ExamDraftController extends BaseController return $validIds[0] ?? 0; } + private function visibleTeacherDraftsQuery(int $teacherId, array $classSectionIds) + { + $query = $this->examDraftModel + ->select($this->draftSelectColumns()) + ->select('cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last') + ->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left') + ->join('users u', 'u.id = exam_drafts.' . $this->authorIdColumn, 'left'); + + if (empty($classSectionIds)) { + return $query->where('exam_drafts.' . $this->authorIdColumn, $teacherId); + } + + $query->groupStart() + ->where('exam_drafts.' . $this->authorIdColumn, $teacherId) + ->orGroupStart() + ->whereIn('exam_drafts.class_section_id', $classSectionIds) + ->where('exam_drafts.' . $this->authorIdColumn . ' !=', $teacherId) + ->where('exam_drafts.status !=', 'draft'); + + if ($this->schoolYear !== '') { + $query->where('exam_drafts.school_year', $this->schoolYear); + } + if ($this->semester !== '') { + $query->where('exam_drafts.semester', $this->semester); + } + + $query->groupEnd() + ->groupEnd(); + + return $query; + } + + private function attachTeacherDraftContext(array $row, int $viewerId): array + { + $isOwn = $this->draftTeacherId($row) === $viewerId; + $row['is_own_submission'] = $isOwn; + $row['teacher_display_name'] = $isOwn ? 'You' : $this->draftTeacherName($row); + + return $row; + } + + private function draftTeacherName(array $draft): string + { + $name = trim(((string) ($draft['teacher_first'] ?? '')) . ' ' . ((string) ($draft['teacher_last'] ?? ''))); + if ($name !== '') { + return $name; + } + + $teacherId = $this->draftTeacherId($draft); + return $teacherId > 0 ? 'Teacher #' . $teacherId : 'Teacher'; + } + private function statusBadgeMap(): array { return [ - 'draft' => [ - 'label' => 'Draft', - 'class' => 'bg-secondary text-white', - ], 'submitted' => [ 'label' => 'Submitted', - 'class' => 'bg-warning text-dark', - ], - 'reviewed' => [ - 'label' => 'Reviewed', 'class' => 'bg-info text-dark', ], - 'finalized' => [ - 'label' => 'Finalized', + 'under review' => [ + 'label' => 'Under review', + 'class' => 'bg-warning text-dark', + ], + 'review needed' => [ + 'label' => 'Review needed', + 'class' => 'text-dark', + 'style' => 'background-color:#fd7e14;', + ], + 'accepted' => [ + 'label' => 'Accepted', 'class' => 'bg-success text-white', ], + 'legacy' => [ + 'label' => 'Legacy', + 'class' => 'bg-secondary text-white', + ], 'rejected' => [ 'label' => 'Rejected', 'class' => 'bg-danger text-white', ], + 'canceled' => [ + 'label' => 'Canceled', + 'class' => 'bg-danger text-white', + ], ]; } + private function draftSelectColumns(): string + { + $columns = ['exam_drafts.*']; + if ($this->authorIdColumn !== 'teacher_id') { + $columns[] = 'exam_drafts.' . $this->authorIdColumn . ' AS teacher_id'; + } + if ($this->authorFileColumn !== 'teacher_file') { + $columns[] = 'exam_drafts.' . $this->authorFileColumn . ' AS teacher_file'; + } + if ($this->authorFilenameColumn !== 'teacher_filename') { + $columns[] = 'exam_drafts.' . $this->authorFilenameColumn . ' AS teacher_filename'; + } + return implode(', ', $columns); + } + + private function applyAuthorFilePayload(array &$payload, ?string $file, ?string $filename): void + { + if ($file === null) { + return; + } + $payload[$this->authorFileColumn] = $file; + $payload[$this->authorFilenameColumn] = $filename; + } + + private function draftTeacherId(array $draft): int + { + if (array_key_exists('teacher_id', $draft)) { + return (int) $draft['teacher_id']; + } + return (int) ($draft[$this->authorIdColumn] ?? 0); + } + + private function draftTeacherFile(array $draft): ?string + { + if (array_key_exists('teacher_file', $draft)) { + return $draft['teacher_file'] ?? null; + } + return $draft[$this->authorFileColumn] ?? null; + } + + private function draftTeacherFilename(array $draft): ?string + { + if (array_key_exists('teacher_filename', $draft)) { + return $draft['teacher_filename'] ?? null; + } + return $draft[$this->authorFilenameColumn] ?? null; + } + + private function findDraft(int $id): ?array + { + return $this->examDraftModel + ->select($this->draftSelectColumns()) + ->where('id', $id) + ->first(); + } + + private function ensureDraftStatus(int $id, string $status): ?array + { + $saved = $this->examDraftModel->find($id); + if (empty($saved)) { + return null; + } + $current = strtolower((string) ($saved['status'] ?? '')); + if ($current !== $status) { + $this->examDraftModel->update($id, ['status' => $status]); + $saved = $this->examDraftModel->find($id); + } + return $saved; + } + + private function notifyPrincipalExamDraftSubmitted(?array $draft, int $teacherId, int $classSectionId): void + { + $principalEmail = trim((string) (env('PRINCIPAL_EMAIL') ?: '')); + if ($principalEmail === '' || !filter_var($principalEmail, FILTER_VALIDATE_EMAIL)) { + return; + } + if (empty($draft)) { + return; + } + + try { + $teacher = $this->userModel->find($teacherId) ?? []; + $teacherName = trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? '')); + if ($teacherName === '') { + $teacherName = 'Teacher #' . $teacherId; + } + + $class = $this->classSectionModel->where('class_section_id', (int) $classSectionId)->first() ?? []; + $className = $class['class_section_name'] ?? ('Class ' . $classSectionId); + + $subject = 'Exam draft submitted: ' . $className; + $submittedAt = $draft['created_at'] ?? $draft['updated_at'] ?? ''; + $body = '

A teacher has submitted a new exam draft.

' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '
Teacher' . esc($teacherName) . '
Class' . esc($className) . '
Exam type' . esc($draft['exam_type'] ?? 'N/A') . '
Version' . esc((string) ($draft['version'] ?? '')) . '
Status' . esc((string) ($draft['status'] ?? 'submitted')) . '
Submitted at' . esc((string) $submittedAt) . '
' + . '

Review drafts: ' . esc($this->reviewPortalLabel()) . ' exam drafts

'; + + $mailer = \Config\Services::emailService(); + $mailer->send($principalEmail, $subject, $body, 'notifications'); + } catch (\Throwable $e) { + log_message('error', 'Failed to send exam draft notification: ' . $e->getMessage()); + } + } + + private function reviewDashboardPath(): string + { + return '/' . $this->reviewRoutePrefix() . '/exam-drafts'; + } + + private function reviewRoutePrefix(): string + { + return $this->isPrincipalReviewer() ? 'principal' : 'administrator'; + } + + private function reviewPortalLabel(): string + { + return $this->isPrincipalReviewer() ? 'Principal' : 'Administrator'; + } + + private function isPrincipalReviewer(): bool + { + $roles = session()->get('roles'); + if (!is_array($roles)) { + $roles = $roles === null ? [] : [$roles]; + } + + foreach ($roles as $role) { + $normalized = strtolower(trim(str_replace([' ', '-'], '_', (string) $role))); + if ($normalized === 'principal') { + return true; + } + } + + return false; + } + + private function nextDraftVersion(array $draft): int + { + $query = $this->examDraftModel + ->select('version') + ->where($this->authorIdColumn, $this->draftTeacherId($draft)) + ->where('class_section_id', (int) ($draft['class_section_id'] ?? 0)) + ->where('semester', (string) ($draft['semester'] ?? '')) + ->where('school_year', (string) ($draft['school_year'] ?? '')); + + $examType = $draft['exam_type'] ?? null; + if ($examType === null || $examType === '') { + $query = $query->where('exam_type', null); + } else { + $query = $query->where('exam_type', $examType); + } + + $latest = $query->orderBy('version', 'DESC')->first(); + $current = (int) ($latest['version'] ?? 0); + if ($current <= 0) { + $current = (int) ($draft['version'] ?? 0); + } + if ($current <= 0) { + $current = 1; + } + return $current + 1; + } + + private function nextReviewRevision(array $draft, int $baseVersion): int + { + if (!$this->hasReviewRevisionColumn) { + return 1; + } + + $query = $this->examDraftModel + ->select('review_revision') + ->where($this->authorIdColumn, $this->draftTeacherId($draft)) + ->where('class_section_id', (int) ($draft['class_section_id'] ?? 0)) + ->where('semester', (string) ($draft['semester'] ?? '')) + ->where('school_year', (string) ($draft['school_year'] ?? '')) + ->where('version', $baseVersion); + + $examType = $draft['exam_type'] ?? null; + if ($examType === null || $examType === '') { + $query = $query->where('exam_type', null); + } else { + $query = $query->where('exam_type', $examType); + } + + $latest = $query->orderBy('review_revision', 'DESC')->first(); + $current = (int) ($latest['review_revision'] ?? 0); + if ($current <= 0) { + $current = (int) ($draft['review_revision'] ?? 0); + } + return $current + 1; + } + + private function appendReviewerComment(string $existing, string $incoming): string + { + $existing = trim($existing); + $incoming = trim($incoming); + if ($incoming === '') { + return $existing; + } + + if ($existing !== '' && str_starts_with($incoming, $existing)) { + $remainder = trim(substr($incoming, strlen($existing))); + if ($remainder === '') { + return $existing; + } + $incoming = $remainder; + } + + $lines = $existing === '' ? [] : preg_split('/\R/', $existing); + $lastLine = $lines ? trim((string) end($lines)) : ''; + if ($lastLine !== '') { + $lastBody = preg_replace('/^\d+\-\s*/', '', $lastLine); + if ($lastBody !== null && trim($lastBody) === $incoming) { + return $existing; + } + } + $nextIndex = count($lines) + 1; + $incoming = trim(preg_replace('/\R+/', ' ', $incoming) ?? $incoming); + $lines[] = $nextIndex . '- ' . $incoming; + return implode("\n", $lines); + } + private function statusOptions(): array { - return ['draft', 'submitted', 'reviewed', 'finalized', 'rejected']; + return ['submitted', 'accepted', 'review needed', 'rejected', 'canceled', 'under review', 'legacy']; } private function normalizeStatus(string $input, string $default): string { $input = strtolower(trim($input)); $aliases = [ - 'pending' => 'submitted', // legacy value - 'final' => 'finalized', - 'approved' => 'finalized', // legacy value + 'submitted' => 'submitted', + 'under review' => 'under review', + 'under_review' => 'under review', + 'reviewed' => 'review needed', + 'review needed' => 'review needed', + 'review_needed' => 'review needed', + 'accepted' => 'accepted', + 'approved' => 'accepted', + 'final' => 'accepted', + 'legacy' => 'legacy', + 'archived' => 'legacy', + 'cancelled' => 'canceled', + 'cancel' => 'canceled', + 'rejected' => 'rejected', + 'draft' => 'submitted', ]; if (isset($aliases[$input])) { $input = $aliases[$input]; @@ -492,6 +1186,202 @@ class ExamDraftController extends BaseController return in_array($input, $this->statusOptions(), true) ? $input : $default; } + /** + * One printable row per exam line: max(version) among accepted rows. + * + * @param list> $rows + * @return list + */ + private function printableDraftIds(array $rows): array + { + $best = []; + foreach ($rows as $row) { + if (strtolower((string) ($row['status'] ?? '')) !== 'accepted') { + continue; + } + $key = $this->examDraftLineKey($row); + $ver = (int) ($row['version'] ?? 0); + $id = (int) ($row['id'] ?? 0); + if ($id <= 0) { + continue; + } + if (!isset($best[$key]) || $ver > $best[$key]['version']) { + $best[$key] = ['version' => $ver, 'id' => $id]; + } + } + + return array_values(array_map(static fn(array $g): int => $g['id'], $best)); + } + + /** + * @param list> $rows + * @param list $printableIds + * @return list> + */ + private function attachPrintableFlags(array $rows, array $printableIds): array + { + foreach ($rows as &$row) { + $row['is_printable'] = in_array((int) ($row['id'] ?? 0), $printableIds, true); + } + unset($row); + + return $rows; + } + + private function examDraftLineKey(array $row): string + { + return implode('|', [ + $this->draftTeacherId($row), + (int) ($row['class_section_id'] ?? 0), + (string) ($row['school_year'] ?? ''), + strtolower(trim((string) ($row['semester'] ?? ''))), + (string) ($row['exam_type'] ?? ''), + ]); + } + + private function examDraftVersionKey(array $row): string + { + return implode('|', [ + $this->draftTeacherId($row), + (int) ($row['class_section_id'] ?? 0), + (string) ($row['school_year'] ?? ''), + strtolower(trim((string) ($row['semester'] ?? ''))), + (string) ($row['exam_type'] ?? ''), + (int) ($row['version'] ?? 0), + ]); + } + + /** + * @param list> $rows + * @return list> + */ + private function groupReviewRevisions(array $rows): array + { + $grouped = []; + + foreach ($rows as $row) { + $key = $this->examDraftVersionKey($row); + $reviewRev = (int) ($row['review_revision'] ?? 0); + + if ($reviewRev > 0) { + if (!isset($grouped[$key])) { + $grouped[$key] = $row; + $grouped[$key]['_is_review_row'] = true; + $grouped[$key]['review_files'] = []; + $grouped[$key]['final_version'] = null; + $grouped[$key]['_latest_review_comment'] = null; + } + $grouped[$key]['review_files'][] = [ + 'review_revision' => $reviewRev, + 'final_file' => $row['final_file'] ?? null, + 'final_filename' => $row['final_filename'] ?? null, + 'status' => $row['status'] ?? null, + ]; + $reviewComment = $row['reviewer_comment'] ?? $row['admin_comments'] ?? null; + if ($reviewComment !== null && $reviewComment !== '') { + $latestComment = $grouped[$key]['_latest_review_comment'] ?? null; + if ($latestComment === null || $reviewRev > (int) ($latestComment['review_revision'] ?? 0)) { + $grouped[$key]['_latest_review_comment'] = [ + 'review_revision' => $reviewRev, + 'comment' => $reviewComment, + ]; + } + } + if (strtolower((string) ($row['status'] ?? '')) === 'accepted') { + $currentFinal = $grouped[$key]['final_version'] ?? null; + if ($currentFinal === null || $reviewRev > (int) ($currentFinal['review_revision'] ?? 0)) { + $grouped[$key]['final_version'] = [ + 'review_revision' => $reviewRev, + 'final_file' => $row['final_file'] ?? null, + 'final_filename' => $row['final_filename'] ?? null, + ]; + } + } + continue; + } + + if (!isset($grouped[$key])) { + $grouped[$key] = $row; + $grouped[$key]['review_files'] = []; + continue; + } + + if (!empty($grouped[$key]['_is_review_row'])) { + $reviewFiles = $grouped[$key]['review_files'] ?? []; + $latestReviewComment = $grouped[$key]['_latest_review_comment'] ?? null; + $grouped[$key] = $row; + $grouped[$key]['review_files'] = $reviewFiles; + if (!empty($latestReviewComment['comment'])) { + $grouped[$key]['reviewer_comment'] = $latestReviewComment['comment']; + } + unset($grouped[$key]['_is_review_row']); + } + } + + foreach ($grouped as &$row) { + if (!empty($row['review_files']) && is_array($row['review_files'])) { + usort($row['review_files'], static fn($a, $b) => ($a['review_revision'] ?? 0) <=> ($b['review_revision'] ?? 0)); + } + if (!empty($row['final_version']) && is_array($row['final_version'])) { + $row['final_file'] = $row['final_version']['final_file'] ?? $row['final_file'] ?? null; + $row['final_filename'] = $row['final_version']['final_filename'] ?? $row['final_filename'] ?? null; + } + if (empty($row['reviewer_comment']) && !empty($row['_latest_review_comment']['comment'])) { + $row['reviewer_comment'] = $row['_latest_review_comment']['comment']; + } + unset($row['_is_review_row']); + unset($row['_latest_review_comment']); + } + unset($row); + + return array_values($grouped); + } + + private function notifyExamDraftEvent(?array $draft, string $event): void + { + if (empty($draft)) { + return; + } + $id = (int) ($draft['id'] ?? 0); + $version = (int) ($draft['version'] ?? 1); + log_message('info', "Exam draft notification [{$event}] id={$id} version={$version}"); + } + + private function streamFinalPdf(array $draft) + { + $storedName = !empty($draft['final_pdf_file']) + ? (string) $draft['final_pdf_file'] + : (string) ($draft['final_file'] ?? ''); + if ($storedName === '') { + return redirect()->back()->with('error', 'No final file is available for printing.'); + } + $ext = strtolower(pathinfo($storedName, PATHINFO_EXTENSION)); + if ($ext !== 'pdf') { + $pdfName = $this->ensurePdfExists($storedName, $ext); + if ($pdfName !== null) { + $storedName = $pdfName; + } + } + $path = $this->fullUploadPath(self::FINAL_UPLOAD_DIR, $storedName); + if (!is_file($path)) { + return redirect()->back()->with('error', 'The file could not be found.'); + } + $downloadName = $draft['final_filename'] ?? 'exam.pdf'; + if (strtolower(pathinfo($downloadName, PATHINFO_EXTENSION)) !== 'pdf') { + $downloadName = pathinfo($downloadName, PATHINFO_FILENAME) . '.pdf'; + } + + $body = file_get_contents($path); + if ($body === false) { + return redirect()->back()->with('error', 'Unable to read the file.'); + } + + return $this->response + ->setContentType('application/pdf') + ->setHeader('Content-Disposition', 'inline; filename="' . basename($downloadName) . '"') + ->setBody($body); + } + private function storeUploadedFile(UploadedFile $file, string $subdir, array $allowedExtensions = self::ALLOWED_EXTENSIONS): ?string { $ext = strtolower($file->getClientExtension()); @@ -533,9 +1423,14 @@ class ExamDraftController extends BaseController $base = pathinfo($sourcePath, PATHINFO_FILENAME); $targetPath = $targetDir . '/' . $base . '.pdf'; + if (!function_exists('exec')) { + log_message('warning', 'ExamDraftController::convertDocToPdf skipped because exec() is unavailable.'); + return is_file($targetPath) ? basename($targetPath) : null; + } + // Attempt conversion via LibreOffice if available $cmd = 'soffice --headless --convert-to pdf --outdir ' . escapeshellarg($targetDir) . ' ' . escapeshellarg($sourcePath) . ' 2>/dev/null'; - @exec($cmd); + @\exec($cmd); return is_file($targetPath) ? basename($targetPath) : null; } @@ -584,6 +1479,11 @@ class ExamDraftController extends BaseController ); } + private function normalizeExamType($value): string + { + return trim((string) $value); + } + private function copyDraftToFinal(string $draftFilename): ?string { $source = $this->fullUploadPath(self::TEACHER_UPLOAD_DIR, $draftFilename); @@ -602,4 +1502,44 @@ class ExamDraftController extends BaseController } return $destName; } + + private function prepareLegacyPdfVersion(array &$update, array $draft): void + { + $finalFile = $update['final_file'] ?? $draft['final_file'] ?? null; + if (empty($finalFile)) { + $teacherFile = $this->draftTeacherFile($draft); + if (!empty($teacherFile)) { + $copied = $this->copyDraftToFinal($teacherFile); + if ($copied !== null) { + $finalFile = $copied; + $update['final_file'] = $copied; + $update['final_filename'] = $this->draftTeacherFilename($draft) ?? $teacherFile; + } + } + } + if (empty($finalFile)) { + return; + } + $ext = strtolower(pathinfo($finalFile, PATHINFO_EXTENSION)); + $pdfName = $this->ensurePdfExists($finalFile, $ext); + if ($pdfName === null) { + return; + } + $filename = $update['final_filename'] ?? $draft['final_filename'] ?? ''; + $baseName = ''; + if ($filename !== '') { + $baseName = pathinfo($filename, PATHINFO_FILENAME); + } + if ($baseName === '') { + $baseName = pathinfo($pdfName, PATHINFO_FILENAME); + } + if ($baseName === '') { + $baseName = 'exam'; + } + $update['final_file'] = $pdfName; + $update['final_filename'] = $baseName . '.pdf'; + if ($this->hasFinalPdfColumn) { + $update['final_pdf_file'] = $pdfName; + } + } } diff --git a/app/Controllers/View/ExpenseController.php b/app/Controllers/View/ExpenseController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/ExtraChargesController.php b/app/Controllers/View/ExtraChargesController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/FamilyAdminController.php b/app/Controllers/View/FamilyAdminController.php old mode 100644 new mode 100755 index c35046b..10c1288 --- a/app/Controllers/View/FamilyAdminController.php +++ b/app/Controllers/View/FamilyAdminController.php @@ -406,4 +406,82 @@ class FamilyAdminController extends BaseController return service('response')->setBody(view('family/card', ['f' => $family])); } + + public function composeEmail() + { + $to = trim((string)$this->request->getGet('to')); + $name = trim((string)$this->request->getGet('name')); + $returnUrl = trim((string)$this->request->getGet('return_url')); + if ($returnUrl === '') { + $returnUrl = trim((string)$this->request->getServer('HTTP_REFERER')); + } + if ($returnUrl === '') { + $returnUrl = site_url('family'); + } + + return view('family/compose_email', [ + 'to' => $to, + 'name' => $name, + 'return_url' => $returnUrl, + ]); + } + + public function sendComposeEmail() + { + if (!$this->request->is('post')) { + return redirect()->to(site_url('family')); + } + + $to = trim((string)$this->request->getPost('to')); + $subject = trim((string)$this->request->getPost('subject')); + $html = (string)($this->request->getPost('html') ?? ''); + $returnUrl = trim((string)$this->request->getPost('return_url')); + + if ($returnUrl === '' || !$this->isLocalReturnUrl($returnUrl)) { + $returnUrl = site_url('family'); + } + + if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) { + return redirect()->back()->withInput()->with('error', 'Please enter a valid email address.'); + } + if ($subject === '') { + return redirect()->back()->withInput()->with('error', 'Subject is required.'); + } + if (trim($html) === '') { + return redirect()->back()->withInput()->with('error', 'Email body is required.'); + } + + $wrapped = view('emails/custom_html', [ + 'subject' => $subject, + 'body_html' => $html, + ]); + + $mailer = new \App\Controllers\View\EmailController(); + $ok = $mailer->sendEmail($to, $subject, $wrapped, 'communication'); + + if ($ok) { + return redirect()->to($returnUrl)->with('status', 'Email sent.'); + } + + return redirect()->to($returnUrl)->with('error', 'Unable to send email.'); + } + + private function isLocalReturnUrl(string $url): bool + { + $url = trim($url); + if ($url === '') return false; + + $parts = parse_url($url); + if ($parts === false) return false; + + if (!empty($parts['scheme']) || !empty($parts['host'])) { + $base = parse_url(site_url('/')); + if (!$base || empty($base['host'])) return false; + $hostMatch = strcasecmp((string)($parts['host'] ?? ''), (string)$base['host']) === 0; + return $hostMatch; + } + + // Relative URL (e.g., /family?x=1 or family?x=1) + return true; + } } diff --git a/app/Controllers/View/FamilyController.php b/app/Controllers/View/FamilyController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/FilesController.php b/app/Controllers/View/FilesController.php old mode 100644 new mode 100755 index 31a0494..386d2cb --- a/app/Controllers/View/FilesController.php +++ b/app/Controllers/View/FilesController.php @@ -224,6 +224,10 @@ class FilesController extends Controller throw PageNotFoundException::forPageNotFound(); } + if (!$this->canAccessExamDraftFile($name, $subdir)) { + return $this->response->setStatusCode(403, 'You are not allowed to access this file.'); + } + $mime = 'application/octet-stream'; if (function_exists('finfo_open')) { $fi = finfo_open(FILEINFO_MIME_TYPE); @@ -271,8 +275,8 @@ class FilesController extends Controller private function buildDraftDownloadName(string $filename, string $subdir): string { - $column = $subdir === 'finals' ? 'final_file' : 'teacher_file'; $db = Database::connect(); + $column = $subdir === 'finals' ? 'final_file' : $this->resolveExamDraftFileColumn($db); $row = $db->table('exam_drafts ed') ->select('ed.version, ed.exam_type, ed.class_section_id, cs.class_section_name') ->join('classSection cs', 'cs.class_section_id = ed.class_section_id', 'left') @@ -301,4 +305,124 @@ class FilesController extends Controller $value = trim($value, '_'); return $value === '' ? 'Exam' : mb_strtolower($value); } + + private function resolveExamDraftFileColumn($db): string + { + try { + $fields = $db->getFieldNames('exam_drafts'); + if (in_array('teacher_file', $fields, true)) { + return 'teacher_file'; + } + if (in_array('author_file', $fields, true)) { + return 'author_file'; + } + } catch (\Throwable $e) { + log_message('error', 'FilesController::resolveExamDraftFileColumn error: ' . $e->getMessage()); + } + return 'teacher_file'; + } + + private function resolveExamDraftAuthorIdColumn($db): string + { + try { + $fields = $db->getFieldNames('exam_drafts'); + if (in_array('teacher_id', $fields, true)) { + return 'teacher_id'; + } + if (in_array('author_id', $fields, true)) { + return 'author_id'; + } + } catch (\Throwable $e) { + log_message('error', 'FilesController::resolveExamDraftAuthorIdColumn error: ' . $e->getMessage()); + } + + return 'teacher_id'; + } + + private function canAccessExamDraftFile(string $filename, string $subdir): bool + { + $userId = (int) (session()->get('user_id') ?? 0); + if ($userId <= 0) { + return false; + } + + $role = strtolower((string) (session()->get('role') ?? '')); + if (in_array($role, ['admin', 'administrator', 'principal'], true)) { + return true; + } + + $db = Database::connect(); + $fileColumn = $subdir === 'finals' ? 'final_file' : $this->resolveExamDraftFileColumn($db); + $authorIdColumn = $this->resolveExamDraftAuthorIdColumn($db); + + $draft = $db->table('exam_drafts ed') + ->select('ed.class_section_id, ed.school_year, ed.semester, ed.status, ed.' . $authorIdColumn . ' AS draft_author_id') + ->where('ed.' . $fileColumn, $filename) + ->limit(1) + ->get() + ->getRowArray(); + + if (empty($draft)) { + return false; + } + + $authorId = (int) ($draft['draft_author_id'] ?? 0); + if ($authorId > 0 && $authorId === $userId) { + return true; + } + + $classSectionId = (int) ($draft['class_section_id'] ?? 0); + if ($classSectionId <= 0) { + return false; + } + + $status = strtolower(trim((string) ($draft['status'] ?? ''))); + if ($subdir === 'drafts' && $status === 'draft') { + return false; + } + + $currentSchoolYear = trim((string) (session()->get('school_year') ?? '')); + $currentSemester = trim((string) (session()->get('semester') ?? '')); + $draftSchoolYear = trim((string) ($draft['school_year'] ?? '')); + $draftSemester = trim((string) ($draft['semester'] ?? '')); + + $hasCurrentAssignment = $db->table('teacher_class') + ->select('id') + ->where('teacher_id', $userId) + ->where('class_section_id', $classSectionId); + + if ($currentSchoolYear !== '') { + $hasCurrentAssignment->where('school_year', $currentSchoolYear); + } + + $hasCurrentAssignment = $hasCurrentAssignment->limit(1)->countAllResults() > 0; + + if ($hasCurrentAssignment) { + if ($subdir === 'finals') { + return true; + } + + return $draftSchoolYear === '' || $currentSchoolYear === '' || $draftSchoolYear === $currentSchoolYear; + } + + $assignmentQuery = $db->table('teacher_class') + ->select('id') + ->where('teacher_id', $userId) + ->where('class_section_id', $classSectionId); + + if ($draftSchoolYear !== '') { + $assignmentQuery->where('school_year', $draftSchoolYear); + } + + $hasDraftYearAssignment = $assignmentQuery->limit(1)->countAllResults() > 0; + if (!$hasDraftYearAssignment) { + return false; + } + + if ($subdir === 'finals') { + return true; + } + + return $draftSemester === '' || $currentSemester === '' || $draftSemester === $currentSemester; + } } diff --git a/app/Controllers/View/FinalController.php b/app/Controllers/View/FinalController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/FinancialController.php b/app/Controllers/View/FinancialController.php old mode 100644 new mode 100755 index 75262d0..43c2939 --- a/app/Controllers/View/FinancialController.php +++ b/app/Controllers/View/FinancialController.php @@ -68,17 +68,45 @@ public function financialReport() } // === Invoices === - $invoices = $invoiceModel - ->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name") - ->join('users', 'users.id = invoices.parent_id') - ->findAll(); + // Business rule: one active invoice per parent per school year. + // If legacy data has multiple invoices, show only the latest one to avoid reporting older invoices as "Unpaid" + // after new charges/payments update the current invoice. + $db = \Config\Database::connect(); + if (!empty($schoolYear)) { + $latestSub = $db->table('invoices') + ->select('parent_id, MAX(id) AS max_id') + ->where('school_year', $schoolYear) + ->groupBy('parent_id') + ->getCompiledSelect(); + + $invBuilder = $db->table('invoices') + ->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name", false) + ->join("($latestSub) latest", 'latest.max_id = invoices.id', 'inner', false) + ->join('users', 'users.id = invoices.parent_id', 'left'); + + if ($hasFrom) { + $invBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >=', $dateFrom); + } + if ($hasTo) { + $invBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $dateTo); + } + + $invoices = $invBuilder->get()->getResultArray(); + } else { + $invoices = $invoiceModel + ->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name") + ->join('users', 'users.id = invoices.parent_id') + ->findAll(); + } + + $invoiceIds = array_values(array_unique(array_filter(array_map(static function ($inv) { + $id = (int)($inv['id'] ?? 0); + return $id > 0 ? $id : null; + }, $invoices)))); // Helper to build a fresh, filtered PaymentModel each time (so filters don't get lost between queries) $buildPaymentModel = function () use ($schoolYear, $dateFrom, $dateTo) { $pm = new PaymentModel(); - if ($schoolYear) { - $pm->where('school_year', $schoolYear); - } if (!empty($dateFrom)) { $pm->where('DATE(payment_date) >=', $dateFrom); } @@ -114,7 +142,13 @@ public function financialReport() }; // === Payments aggregated by invoice_id (for the "Paid" column) === - $payments = $applyPaymentFilters($buildPaymentModel()) + $paymentsQuery = $applyPaymentFilters($buildPaymentModel()); + if (!empty($invoiceIds)) { + $paymentsQuery->whereIn('invoice_id', $invoiceIds); + } else { + $paymentsQuery->where('invoice_id', -1); + } + $payments = $paymentsQuery ->select('invoice_id, SUM(paid_amount) AS paid_amount') ->where('invoice_id IS NOT NULL') ->groupBy('invoice_id') @@ -138,6 +172,7 @@ public function financialReport() SUM(paid_amount) AS amount ") ->where('invoice_id IS NOT NULL') + ->whereIn('invoice_id', $invoiceIds ?: [-1]) ->groupBy('invoice_id, method') ->findAll(); @@ -161,6 +196,7 @@ public function financialReport() SUM(CASE WHEN LOWER(TRIM(payment_method)) IN ('credit','card','credit card','debit','debit card','visa','mastercard') THEN paid_amount ELSE 0 END) AS total_credit ") ->where('invoice_id IS NOT NULL') + ->whereIn('invoice_id', $invoiceIds ?: [-1]) ->first() ?? []; $paymentTotals = [ @@ -216,25 +252,28 @@ public function financialReport() $schoolYears[] = (string)$schoolYear; } + $eventFeesTotal = $this->getEventFeesTotal($schoolYear, $dateFrom, $dateTo); + // JSON API support if ($this->wantsJson() || strtolower((string)($this->request->getGet('format') ?? '')) === 'json') { - return $this->response->setJSON([ - 'ok' => true, - 'selectedYear' => $schoolYear, - 'dateFrom' => $dateFrom, - 'dateTo' => $dateTo, + return $this->response->setJSON([ + 'ok' => true, + 'selectedYear' => $schoolYear, + 'dateFrom' => $dateFrom, + 'dateTo' => $dateTo, 'schoolYears' => $schoolYears, 'invoices' => $invoices, 'payments' => $payments, 'paymentBreakdown' => $paymentBreakdown, 'paymentTotals' => $paymentTotals, - 'refunds' => $refunds, - 'expenses' => $expenses, - 'reimbursements' => $reimbursements, - 'discounts' => $discounts, - 'csrf_token' => csrf_token(), - 'csrf_hash' => csrf_hash(), - ]); + 'refunds' => $refunds, + 'expenses' => $expenses, + 'reimbursements' => $reimbursements, + 'discounts' => $discounts, + 'eventFeesTotal' => $eventFeesTotal, + 'csrf_token' => csrf_token(), + 'csrf_hash' => csrf_hash(), + ]); } return view('payment/financial_report', [ @@ -246,6 +285,7 @@ public function financialReport() 'expenses' => $expenses, 'reimbursements' => $reimbursements, 'discounts' => $discounts, + 'eventFeesTotal' => $eventFeesTotal, 'selectedYear' => $schoolYear, 'schoolYears' => $schoolYears, 'dateFrom' => $dateFrom, @@ -1058,6 +1098,7 @@ public function financialReport() $amountCollected = $totalPaid; $netAmount = ($totalCharges - $totalDiscounts - $totalRefunds); + $totalEventFees = $this->getEventFeesTotal($schoolYear, $invoiceDateFrom, $invoiceDateTo); return [ 'schoolYear' => $schoolYear, 'dateFrom' => $dateFrom, @@ -1073,9 +1114,28 @@ public function financialReport() 'amountCollected' => $amountCollected, 'totalUnpaid' => $totalUnpaid, 'netAmount' => $netAmount, + 'totalEventFees' => $totalEventFees, ]; } + private function getEventFeesTotal(?string $schoolYear, ?string $dateFrom, ?string $dateTo): float + { + $db = \Config\Database::connect(); + $builder = $db->table('event_charges ec') + ->select('COALESCE(SUM(ec.charged),0) AS amount', false); + if (!empty($schoolYear)) { + $builder->where('ec.school_year', $schoolYear); + } + if (!empty($dateFrom)) { + $builder->where('DATE(ec.created_at) >=', $dateFrom); + } + if (!empty($dateTo)) { + $builder->where('DATE(ec.created_at) <=', $dateTo); + } + $row = $builder->get()->getRowArray(); + return $row ? (float)($row['amount'] ?? 0) : 0.0; + } + /** * Management page: list parents with outstanding balances (> 0) for a school year. @@ -1226,6 +1286,23 @@ public function financialReport() $byParent[$pid]['total_balance'] += $extra; } + $eventFeesPerParent = []; + try { + $eventFeesRows = $db->table('event_charges ec') + ->select('ec.parent_id, COALESCE(SUM(ec.charged),0) AS event_fees', false) + ->where('ec.school_year', $schoolYear) + ->groupBy('ec.parent_id') + ->get() + ->getResultArray(); + foreach ($eventFeesRows as $row) { + $pid = (int)($row['parent_id'] ?? 0); + if ($pid <= 0) continue; + $eventFeesPerParent[$pid] = (float)($row['event_fees'] ?? 0); + } + } catch (\Throwable $e) { + // ignore, fallback to no event fees data + } + // Reduce into rows list; only parents with positive balance // Also compute remaining installments and suggested monthly amount $rows = []; @@ -1283,10 +1360,11 @@ public function financialReport() $parentIds = array_values(array_unique(array_map(static fn($r) => (int)($r['parent_id'] ?? 0), $rows))); $hasPayments = []; $paidTotals = []; + $paymentCounts = []; if (!empty($parentIds)) { try { $pRows = $db->table('payments') - ->select('parent_id, SUM(paid_amount) AS total_paid') + ->select('parent_id, SUM(paid_amount) AS total_paid, COUNT(*) AS payment_count') ->whereIn('parent_id', $parentIds) ->where('school_year', $schoolYear) ->groupBy('parent_id') @@ -1296,6 +1374,7 @@ public function financialReport() if ($pid > 0) { $hasPayments[$pid] = true; $paidTotals[$pid] = (float)($pr['total_paid'] ?? 0); + $paymentCounts[$pid] = (int)($pr['payment_count'] ?? 0); } } } catch (\Throwable $e) { @@ -1303,7 +1382,7 @@ public function financialReport() } } - $dataRows = array_map(function(array $r) use ($hasPayments, $paidTotals, $nextInstallmentYmd) { + $dataRows = array_map(function(array $r) use ($hasPayments, $paidTotals, $paymentCounts, $nextInstallmentYmd, $eventFeesPerParent) { $pid = (int)($r['parent_id'] ?? 0); $name = trim((string)($r['firstname'] ?? '') . ' ' . (string)($r['lastname'] ?? '')); return [ @@ -1317,8 +1396,10 @@ public function financialReport() 'installment_amount' => (float)($r['installment_amount'] ?? 0), 'type' => isset($hasPayments[$pid]) ? 'installment' : 'no_payment', 'total_paid' => isset($paidTotals[$pid]) ? (float)$paidTotals[$pid] : 0.0, + 'payment_count' => isset($paymentCounts[$pid]) ? (int)$paymentCounts[$pid] : 0, 'has_installment'=> isset($hasPayments[$pid]) ? 1 : 0, 'next_installment' => $nextInstallmentYmd, + 'event_fees' => (float)($eventFeesPerParent[$pid] ?? 0), ]; }, $rows); diff --git a/app/Controllers/View/FlagController.php b/app/Controllers/View/FlagController.php old mode 100644 new mode 100755 index 6e6e7ef..6a62edc --- a/app/Controllers/View/FlagController.php +++ b/app/Controllers/View/FlagController.php @@ -421,17 +421,45 @@ class FlagController extends Controller log_message('debug', 'Flag state: ' . $this->request->getPost('flag_state')); $currentFlagModel = new CurrentFlagModel(); + $userId = session()->get('user_id'); // Get the new flag state from the form $newState = $this->request->getPost('flag_state'); + $stateDescription = (string) ($this->request->getPost('state_description') ?? ''); + $actionTaken = (string) ($this->request->getPost('action_taken') ?? ''); if (!$newState) { session()->setFlashdata('error', 'incident state not provided.'); return $this->index(); } + $update = ['flag_state' => $newState]; + if ($newState === 'Closed') { + $update['updated_by_closed'] = $userId; + if ($stateDescription !== '') { + $update['close_description'] = $stateDescription; + } + if ($actionTaken !== '') { + $update['action_taken'] = $actionTaken; + } + } elseif ($newState === 'Canceled') { + $update['updated_by_canceled'] = $userId; + if ($stateDescription !== '') { + $update['cancel_description'] = $stateDescription; + } + if ($actionTaken !== '') { + $update['action_taken'] = $actionTaken; + } + } + // Update the flag state in the database - if ($currentFlagModel->update($id, ['flag_state' => $newState])) { + if ($currentFlagModel->update($id, $update)) { + if ($newState === 'Closed' || $newState === 'Canceled') { + $flagData = $currentFlagModel->find($id); + if ($flagData) { + return $this->moveToHistory($flagData); + } + } session()->setFlashdata('success', 'Incident state updated successfully!'); } else { $errors = $currentFlagModel->errors(); diff --git a/app/Controllers/View/FrontendController.php b/app/Controllers/View/FrontendController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/GradingController.php b/app/Controllers/View/GradingController.php old mode 100644 new mode 100755 index df61b26..9d385c0 --- a/app/Controllers/View/GradingController.php +++ b/app/Controllers/View/GradingController.php @@ -28,6 +28,7 @@ use App\Models\PlacementLevelModel; use App\Models\PlacementBatchModel; use App\Models\PlacementScoreModel; use App\Models\GradingLockModel; +use App\Services\NavbarService; //use App\Models\ScoreModel; @@ -277,7 +278,7 @@ class GradingController extends Controller $semEsc = $this->db->escape($semester); $yrEsc = $this->db->escape($schoolYear); - $rows = $this->buildGradingRows($semEsc, $yrEsc, $schoolYear); + $rows = $this->buildGradingRows($semEsc, $yrEsc, $schoolYear, $semester); // Preload quiz/homework/project/participation/midterm score counts to distinguish true zeros from empty scores $quizCounts = []; @@ -423,7 +424,7 @@ class GradingController extends Controller } } // Reload rows after refresh - $rows = $this->buildGradingRows($semEsc, $yrEsc, $schoolYear); + $rows = $this->buildGradingRows($semEsc, $yrEsc, $schoolYear, $semester); } // Build structures keyed by BUSINESS section id @@ -1079,19 +1080,22 @@ class GradingController extends Controller $schoolYears = $this->getSchoolYearsForScores($schoolYear); $rows = $this->fetchBelowSixtyRows($schoolYear, $semester); + $canViewGrading = $this->userHasMenuUrl('grading'); + return view('grading/below_sixty', [ 'rows' => $rows, 'semester' => $semester, 'schoolYear' => $schoolYear, 'schoolYears' => $schoolYears, + 'canViewGrading' => $canViewGrading, ]); } - public function sendBelowSixtyEmail() + public function editBelowSixtyEmail() { - $studentId = (int)$this->request->getPost('student_id'); - $semester = trim((string)$this->request->getPost('semester')); - $schoolYear = trim((string)$this->request->getPost('school_year')); + $studentId = (int)$this->request->getGet('student_id'); + $semester = trim((string)$this->request->getGet('semester')); + $schoolYear = trim((string)$this->request->getGet('school_year')); if ($studentId <= 0 || $semester === '' || $schoolYear === '') { return redirect()->back()->with('error', 'Missing student or term.'); @@ -1103,6 +1107,65 @@ class GradingController extends Controller } $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? '')); + $subject = $this->buildBelowSixtySubject($studentName, $semester, $schoolYear); + $parentName = $this->fetchBelowSixtyParentName($studentId); + + $scores = [ + 'homework_avg' => $row['homework_avg'] ?? null, + 'project_avg' => $row['project_avg'] ?? null, + 'participation_score' => $row['participation_score'] ?? null, + 'test_avg' => $row['test_avg'] ?? null, + 'ptap_score' => $row['ptap_score'] ?? null, + 'attendance_score' => $row['attendance_score'] ?? null, + 'midterm_exam_score' => $row['midterm_exam_score'] ?? null, + 'semester_score' => $row['semester_score'] ?? null, + ]; + + $emailData = [ + 'title' => $subject, + 'parent_name' => $parentName, + 'student_name' => $studentName !== '' ? $studentName : 'your student', + 'class_section_name' => $row['class_section_name'] ?? '', + 'semester' => $semester, + 'school_year' => $schoolYear, + 'scores' => $scores, + 'comment' => $row['comment'] ?? '', + 'sent_at' => utc_now(), + ]; + + $html = view('emails/below_sixty_performance', $emailData, ['saveData' => true]); + + return view('grading/below_sixty_email_editor', [ + 'studentId' => $studentId, + 'studentName' => $studentName, + 'semester' => $semester, + 'schoolYear' => $schoolYear, + 'subject' => $subject, + 'html' => $html, + ]); + } + + public function sendBelowSixtyEmail() + { + $studentId = (int)$this->request->getPost('student_id'); + $semester = trim((string)$this->request->getPost('semester')); + $schoolYear = trim((string)$this->request->getPost('school_year')); + $subjectInput = trim((string)$this->request->getPost('subject')); + $htmlInput = (string)($this->request->getPost('html') ?? ''); + + if ($studentId <= 0 || $semester === '' || $schoolYear === '') { + return redirect()->back()->with('error', 'Missing student or term.'); + } + + $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester); + if (empty($row)) { + return redirect()->back()->with('error', 'Student record not found for the selected term.'); + } + + $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? '')); + $subject = $subjectInput !== '' + ? $subjectInput + : $this->buildBelowSixtySubject($studentName, $semester, $schoolYear); $scores = [ 'homework_avg' => $row['homework_avg'] ?? null, 'project_avg' => $row['project_avg'] ?? null, @@ -1122,8 +1185,13 @@ class GradingController extends Controller 'school_year' => $schoolYear, 'scores' => $scores, 'comment' => $row['comment'] ?? '', + 'subject' => $subject, ]; + if (trim($htmlInput) !== '') { + $payload['html'] = $htmlInput; + } + Events::trigger('below60.email', $payload); return redirect()->back()->with('status', 'Email sent to parent(s).'); @@ -1148,6 +1216,14 @@ class GradingController extends Controller $flagModel = new CurrentFlagModel(); $semKey = strtolower(trim($semester)); + $redirectUrl = base_url('grading/below-60'); + $query = http_build_query([ + 'semester' => $semester, + 'school_year' => $schoolYear, + ]); + if ($query !== '') { + $redirectUrl .= '?' . $query; + } $existing = $flagModel ->where('student_id', $studentId) @@ -1158,11 +1234,14 @@ class GradingController extends Controller $userId = (int)(session()->get('user_id') ?? 0) ?: null; $now = utc_now(); + $ok = true; if ($existing) { $data = [ 'flag_state' => $status, 'flag_datetime' => $now, + 'semester' => $semester, + 'school_year' => $schoolYear, 'updated_at' => $now, ]; if ($status === 'Open') { @@ -1178,7 +1257,7 @@ class GradingController extends Controller $data['close_description'] = trim($prev . PHP_EOL . $note); } } - $flagModel->update((int)$existing['id'], $data); + $ok = (bool) $flagModel->update((int)$existing['id'], $data); } else { $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester); $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? '')); @@ -1201,10 +1280,21 @@ class GradingController extends Controller $data['updated_by_closed'] = $userId; if ($note !== '') $data['close_description'] = $note; } - $flagModel->insert($data); + $ok = (bool) $flagModel->insert($data); } - return redirect()->back()->with('status', 'Status updated.'); + if (!$ok) { + log_message('error', 'updateBelowSixtyStatus failed', [ + 'student_id' => $studentId, + 'semester' => $semester, + 'school_year' => $schoolYear, + 'status' => $status, + 'errors' => $flagModel->errors(), + ]); + return redirect()->to($redirectUrl)->with('error', 'Failed to update status.'); + } + + return redirect()->to($redirectUrl)->with('status', 'Status updated.'); } public function scheduleBelowSixty() @@ -1487,7 +1577,7 @@ class GradingController extends Controller * @param string $schoolYear Raw school year value for filtering student_class * @return array */ - private function buildGradingRows(string $semEsc, string $yrEsc, string $schoolYear): array + private function buildGradingRows(string $semEsc, string $yrEsc, string $schoolYear, string $semesterRaw): array { $builder = $this->db->table('student_class sc') ->select([ @@ -1515,6 +1605,7 @@ class GradingController extends Controller 'ss_b.class_section_id AS matched_biz_csid', 'ss_p.class_section_id AS matched_pk_csid' ]) + ->distinct() ->join('`classSection` cs', 'cs.class_section_id = sc.class_section_id', 'left') ->join('students s', 's.id = sc.student_id', 'inner') ->join( @@ -1729,9 +1820,10 @@ class GradingController extends Controller } $statusMap = []; + $noteMap = []; if (!empty($studentIds)) { $flagRows = $this->db->table('current_flag') - ->select('student_id, flag_state') + ->select('student_id, flag_state, open_description, close_description') ->where('flag', 'grade') ->where('school_year', $schoolYear) ->where("LOWER(TRIM(semester))", $semesterKey) @@ -1742,6 +1834,12 @@ class GradingController extends Controller $sid = (int)($row['student_id'] ?? 0); if ($sid <= 0) continue; $statusMap[$sid] = (string)($row['flag_state'] ?? ''); + $openNote = trim((string)($row['open_description'] ?? '')); + $closeNote = trim((string)($row['close_description'] ?? '')); + $noteMap[$sid] = [ + 'open' => $openNote, + 'closed' => $closeNote, + ]; } } @@ -1750,6 +1848,15 @@ class GradingController extends Controller $row['comment'] = $commentMap[$sid] ?? ''; $flagState = strtolower(trim((string)($statusMap[$sid] ?? ''))); $row['status'] = ($flagState === 'closed' || $flagState === 'canceled') ? 'Closed' : 'Open'; + $noteBag = $noteMap[$sid] ?? ['open' => '', 'closed' => '']; + $rawNote = $row['status'] === 'Closed' ? (string)$noteBag['closed'] : (string)$noteBag['open']; + if ($rawNote !== '') { + $lines = preg_split('/\R/', $rawNote); + $lines = array_values(array_filter(array_map('trim', $lines), static fn($val) => $val !== '')); + $row['note'] = $lines ? end($lines) : ''; + } else { + $row['note'] = ''; + } } unset($row); @@ -1799,6 +1906,91 @@ class GradingController extends Controller return $row; } + private function userHasMenuUrl(string $needle): bool + { + $needle = strtolower(trim($needle)); + if ($needle === '') { + return false; + } + + $rawRole = session()->get('role'); + $roles = is_array($rawRole) ? $rawRole : [$rawRole ?? 'guest']; + $roles = array_values(array_filter(array_map('strval', $roles))); + if (empty($roles)) { + return false; + } + + $service = new NavbarService(); + $menu = $service->getMenuForRoles($roles); + if (empty($menu)) { + return false; + } + + $normalize = static function (string $url) use ($needle): string { + $url = strtolower(trim($url)); + if ($url === '') return ''; + $url = preg_replace('#^https?://[^/]+/#i', '', $url); + $url = ltrim($url, '/'); + return $url; + }; + + $target = $normalize($needle); + $stack = $menu; + while (!empty($stack)) { + $node = array_shift($stack); + if (!empty($node['url'])) { + $url = $normalize((string)$node['url']); + if ($url !== '' && $url === $target) { + return true; + } + } + if (!empty($node['children']) && is_array($node['children'])) { + foreach ($node['children'] as $child) { + $stack[] = $child; + } + } + } + + return false; + } + + private function fetchBelowSixtyParentName(int $studentId): string + { + $parentName = 'Parent/Guardian'; + try { + $rows = $this->db->query( + "SELECT u.firstname, u.lastname + FROM family_students fs + JOIN family_guardians fg ON fg.family_id = fs.family_id + JOIN users u ON u.id = fg.user_id + WHERE fs.student_id = ? + ORDER BY fg.is_primary DESC, u.lastname, u.firstname + LIMIT 1", + [$studentId] + )->getResultArray(); + if (!empty($rows[0])) { + $candidate = trim((string)($rows[0]['firstname'] ?? '') . ' ' . (string)($rows[0]['lastname'] ?? '')); + if ($candidate !== '') { + $parentName = $candidate; + } + } + } catch (\Throwable $e) { + } + return $parentName; + } + + private function buildBelowSixtySubject(string $studentName, string $semester, string $schoolYear): string + { + $subject = 'Student Performance Alert'; + if ($studentName !== '') { + $subject .= ' — ' . $studentName; + } + if ($semester !== '' || $schoolYear !== '') { + $subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')'; + } + return $subject; + } + private function fetchBelowSixtyMeetingContext(int $studentId, string $schoolYear, string $semester): array { $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester); diff --git a/app/Controllers/View/HealthController.php b/app/Controllers/View/HealthController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/HomeworkController.php b/app/Controllers/View/HomeworkController.php old mode 100644 new mode 100755 index 47594e7..b0a238d --- a/app/Controllers/View/HomeworkController.php +++ b/app/Controllers/View/HomeworkController.php @@ -482,6 +482,7 @@ class HomeworkController extends Controller // Step 1: Get student IDs from student_class table $studentClassRows = $this->studentClassModel ->select('student_id') + ->distinct() ->where('class_section_id', $classSectionId) ->where('school_year', $schoolYear) ->findAll(); @@ -534,10 +535,7 @@ class HomeworkController extends Controller private function getStudentsWithHomeworkScores($classSectionId, $homeworkHeaders, $semester, $schoolYear) { $semVariants = $this->getSemesterVariants($semester); - $studentClasses = $this->studentClassModel - ->active() - ->where('student_class.class_section_id', $classSectionId) - ->findAll(); + $studentClasses = $this->studentClassModel->getClassStudents($classSectionId, $schoolYear, null); $students = []; foreach ($studentClasses as $sc) { diff --git a/app/Controllers/View/HomeworkTrackingController.php b/app/Controllers/View/HomeworkTrackingController.php old mode 100644 new mode 100755 index 516941f..5d58641 --- a/app/Controllers/View/HomeworkTrackingController.php +++ b/app/Controllers/View/HomeworkTrackingController.php @@ -86,6 +86,7 @@ class HomeworkTrackingController extends BaseController $hasHomework = []; $hwEnteredAt = []; + $homeworkSubmissionCounts = []; foreach ($rows as $r) { $csid = (int)($r['class_section_id'] ?? 0); $hi = (int)($r['homework_index'] ?? 0); @@ -94,6 +95,7 @@ class HomeworkTrackingController extends BaseController $hasHomework[$csid][$hi] = true; $dateStr = substr((string)($r['first_created'] ?? ''), 0, 10); $hwEnteredAt[$csid][$hi] = $dateStr ?: null; + $homeworkSubmissionCounts[$csid] = ($homeworkSubmissionCounts[$csid] ?? 0) + 1; } } @@ -217,6 +219,7 @@ class HomeworkTrackingController extends BaseController 'teachers' => $teachersPage, 'hasHomework' => $hasHomework, 'hwEnteredAt' => $hwEnteredAt, + 'homeworkSubmissionCounts' => $homeworkSubmissionCounts, 'hasHomeworkByDate' => $hasHomeworkByDate, 'hwEnteredAtByDate' => $hwEnteredAtByDate, 'page' => $page, diff --git a/app/Controllers/View/InfoIconController.php b/app/Controllers/View/InfoIconController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/InventoryController.php b/app/Controllers/View/InventoryController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/InvoiceController.php b/app/Controllers/View/InvoiceController.php old mode 100644 new mode 100755 index a3c6829..077571c --- a/app/Controllers/View/InvoiceController.php +++ b/app/Controllers/View/InvoiceController.php @@ -381,18 +381,24 @@ class InvoiceController extends ResourceController return false; } - public function generateInvoice(string $parentId = null) + public function generateInvoice( + string $parentId = null, + ?string $schoolYearOverride = null, + ?string $semesterOverride = null, + bool $recalculateDiscounts = true + ) { $isAjax = $this->request->isAJAX() || str_contains(strtolower($this->request->getHeaderLine('Accept')), 'application/json'); if ($parentId == null) { $parentId = (int)$this->request->getPost('parent_id'); } - $schoolYear = (string) $this->schoolYear; + $schoolYear = (string) ($schoolYearOverride ?: $this->schoolYear); + $semester = (string) ($semesterOverride ?: $this->semester); // Fetch enrolled + withdrawn students $enrollments = $this->enrollmentModel ->where('parent_id', $parentId) - ->where('school_year', $this->schoolYear) + ->where('school_year', $schoolYear) ->findAll(); if (empty($enrollments)) { @@ -444,21 +450,23 @@ class InvoiceController extends ResourceController $tuitionFee = $fees['tuition_fee']; // ✅ Fetch event charges - $eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $this->schoolYear); + $eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear); $eventchargeTotal = array_sum(array_column($eventsList, 'charged')); - $totalDiscount = $this->recalculateAndUpdateDiscount( - $parentId, - $this->schoolYear, - $tuitionFee, - $enrollments - ); + $totalDiscount = 0.0; + if ($recalculateDiscounts) { + $totalDiscount = $this->recalculateAndUpdateDiscount( + $parentId, + $schoolYear, + $tuitionFee, + $enrollments + ); + } - $semester = $this->semester; // ✅ Refunds PAID to the parent for this year (Partial/Paid) - $refundPaid = (float) $this->refundModel->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $this->schoolYear); + $refundPaid = (float) $this->refundModel->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $schoolYear); - $totalPaid = $this->paymentModel->getTotalPaidByParentId($parentId, $this->schoolYear); + $totalPaid = $this->paymentModel->getTotalPaidByParentId($parentId, $schoolYear); $discountedTuition = max(0, $tuitionFee); $totalAmount = $discountedTuition + $eventchargeTotal; @@ -469,17 +477,22 @@ class InvoiceController extends ResourceController - $refundPaid // approved refunds paid to parent - $totalPaid; // payments received - // Your invoice fetch - $existingInvoices = $this->invoiceModel->getInvoicesByParentId($parentId, $this->schoolYear); + // Business rule: single invoice per parent per school year. + // If legacy duplicates exist, prefer the invoice that already has a discount applied, + // otherwise use the latest invoice for the parent/year. + $invoice = $this->selectActiveInvoiceForParentYear((int)$parentId, $schoolYear); $updated = false; - $updatedIds = []; - if (!empty($existingInvoices)) { - foreach ($existingInvoices as $invoice) { - if (!isset($invoice['id'])) { - continue; - } + if (!empty($invoice) && isset($invoice['id'])) { + $paymentExclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled']; + $paymentsHasStatus = false; + $paymentsHasVoid = false; + try { + $paymentsHasStatus = $this->db->fieldExists('status', 'payments'); + $paymentsHasVoid = $this->db->fieldExists('is_void', 'payments'); + } catch (\Throwable $e) { + } // Preserve applied additional charges and recalc this invoice only $extrasSum = 0.0; @@ -487,7 +500,7 @@ class InvoiceController extends ResourceController $rows = $this->db->table('additional_charges') ->select('charge_type, amount') ->where('invoice_id', (int)$invoice['id']) - ->where('school_year', $this->schoolYear) + ->where('school_year', $schoolYear) ->where('status', 'applied') ->get()->getResultArray(); foreach ($rows as $r) { @@ -520,7 +533,7 @@ class InvoiceController extends ResourceController $r = $this->db->table('refunds') ->select('COALESCE(SUM(refund_paid_amount),0) AS tot') ->where('invoice_id', (int)$invoice['id']) - ->where('school_year', $this->schoolYear) + ->where('school_year', $schoolYear) ->whereIn('status', ['Partial','Paid']) ->get()->getRowArray(); $invRefunds = (float)($r['tot'] ?? 0.0); @@ -528,8 +541,33 @@ class InvoiceController extends ResourceController log_message('error', 'refund sum failed for invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage()); } - // Payments recorded on this invoice (denormalized field kept in sync by PaymentController) - $paidOnInv = (float)($invoice['paid_amount'] ?? 0.0); + // Payments recorded on this invoice (sum payments to avoid stale invoice.paid_amount) + $paidOnInv = 0.0; + try { + $qb = $this->db->table('payments') + ->select('COALESCE(SUM(paid_amount),0) AS tot') + ->where('invoice_id', (int)$invoice['id']) + ->where('paid_amount >', 0); + + if ($paymentsHasStatus) { + $qb->groupStart() + ->whereNotIn('status', $paymentExclude) + ->orWhere('status IS NULL', null, false) + ->groupEnd(); + } + if ($paymentsHasVoid) { + $qb->groupStart() + ->where('is_void', 0) + ->orWhere('is_void IS NULL', null, false) + ->groupEnd(); + } + + $row = $qb->get()->getRowArray(); + $paidOnInv = (float)($row['tot'] ?? 0.0); + } catch (\Throwable $e) { + log_message('error', 'payment sum failed for invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage()); + $paidOnInv = (float)($invoice['paid_amount'] ?? 0.0); + } $newBalance = $newTotal - $invDiscount - $invRefunds - $paidOnInv; $newStatus = ($newBalance <= 0.00001) @@ -547,7 +585,6 @@ class InvoiceController extends ResourceController $updatedIds[] = (int)$invoice['id']; log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}."); $updated = true; - } } else { // Generate invoice number $schoolId = $this->userModel->getSchoolIdByUserId($parentId); @@ -578,7 +615,7 @@ class InvoiceController extends ResourceController // Initial balance equals the created total; discounts/refunds/payments will adjust later 'balance' => $totalAmount, 'status' => 'Unpaid', - 'school_year' => $this->schoolYear, + 'school_year' => $schoolYear, 'semester' => $semester, 'issue_date' => $issueUtc, 'due_date' => $dueUtc, @@ -615,6 +652,47 @@ class InvoiceController extends ResourceController ->with('success', $updated ? 'Invoice updated.' : 'Invoice created.'); } + private function selectActiveInvoiceForParentYear(int $parentId, string $schoolYear): ?array + { + if ($parentId <= 0 || $schoolYear === '') { + return null; + } + + // Prefer invoices flagged as having discounts. + $invoice = $this->invoiceModel + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->where('has_discount', 1) + ->orderBy('id', 'DESC') + ->first(); + if (!empty($invoice)) { + return $invoice; + } + + // Fallback: prefer invoices with discount_usages rows. + try { + $row = $this->db->table('invoices i') + ->select('i.*') + ->join('discount_usages du', 'du.invoice_id = i.id', 'inner') + ->where('i.parent_id', $parentId) + ->where('i.school_year', $schoolYear) + ->orderBy('i.id', 'DESC') + ->get() + ->getRowArray(); + if (!empty($row)) { + return $row; + } + } catch (\Throwable $e) { + } + + // Final fallback: latest invoice for parent/year. + return $this->invoiceModel + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->orderBy('id', 'DESC') + ->first(); + } + private function recalculateAndUpdateDiscount( int $parentId, string $schoolYear, @@ -678,7 +756,7 @@ class InvoiceController extends ResourceController log_message('error', 'additional_charges sum failed for discount recalculation invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage()); } - $baseTotal = round($tuitionFee + $eventchargeTotal + $extrasSum, 2); + $baseTotal = round($tuitionFee + $extrasSum, 2); // Recalculate discount if ($discountUsage['discount_type'] === 'percent') { @@ -1266,6 +1344,12 @@ class InvoiceController extends ResourceController } } } + if ($studentName === 'N/A') { + $externalName = trim((string)($event['external_firstname'] ?? '') . ' ' . (string)($event['external_lastname'] ?? '')); + if ($externalName !== '') { + $studentName = $externalName . ' (external)'; + } + } $dt = $toLocal($event['created_at'] ?? null, false); $amount = (float)($event['charged'] ?? 0.0); @@ -1612,12 +1696,24 @@ private function getGradeLevel($grade): array $invoice['last_payment_date'] = $lastPayments[$invoice['id']]['last_payment_date'] ?? null; } + $invoiceEventCharges = []; + foreach ($invoices as $invoice) { + $year = $invoice['school_year'] ?? $this->schoolYear; + $sem = $invoice['semester'] ?? $this->semester; + $invoiceEventCharges[(int)$invoice['id']] = $this->chargesModel->getChargesWithEventInfo( + $invoice['parent_id'], + $year, + $sem + ); + } + return view('/parent/invoice_payment', [ 'invoices' => $invoices, 'schoolYears' => $schoolYears, 'selectedYear' => $selectedYear, 'currentSchoolYear' => $currentSchoolYear, - 'dueDate' => $this->dueDate + 'dueDate' => $this->dueDate, + 'invoiceEventCharges' => $invoiceEventCharges, ]); } diff --git a/app/Controllers/View/IpBanController.php b/app/Controllers/View/IpBanController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/LandingPageController.php b/app/Controllers/View/LandingPageController.php old mode 100644 new mode 100755 index f7a707c..531167e --- a/app/Controllers/View/LandingPageController.php +++ b/app/Controllers/View/LandingPageController.php @@ -95,6 +95,11 @@ class LandingPageController extends BaseController return null; } + // Teacher class sections only apply to the teacher role; other roles have no teacher_class rows. + if (strtolower(trim((string) $this->getUserRole())) !== 'teacher') { + return null; + } + // Get all class assignments for this teacher in the current term $assignments = $this->teacherClassModel->getClassAssignmentsByUserId( (int)$user_id, @@ -106,7 +111,7 @@ class LandingPageController extends BaseController $ids = array_values(array_filter(array_unique($ids))); if (empty($ids)) { - log_message('error', "No class section found for user ID: $user_id"); + log_message('warning', "No class section found for user ID: $user_id"); return null; } @@ -868,8 +873,12 @@ class LandingPageController extends BaseController protected function getUserRole() { - // Assuming you have a session variable storing the user role - return session()->get('user_role', 'guest'); // Default to guest if not set + $active = session()->get('active_role'); + if ($active !== null && $active !== '') { + return (string) $active; + } + + return (string) (session()->get('role') ?? 'guest'); } protected function getUserRoleFromDatabase($user_id) diff --git a/app/Controllers/View/LateSlipLogsController.php b/app/Controllers/View/LateSlipLogsController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/MessagesController.php b/app/Controllers/View/MessagesController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/MidtermController.php b/app/Controllers/View/MidtermController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/NavBuilderController.php b/app/Controllers/View/NavBuilderController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/NotificationsController.php b/app/Controllers/View/NotificationsController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/PageController.php b/app/Controllers/View/PageController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/ParentAttendanceReportController.php b/app/Controllers/View/ParentAttendanceReportController.php old mode 100644 new mode 100755 index 268aa85..ec1119f --- a/app/Controllers/View/ParentAttendanceReportController.php +++ b/app/Controllers/View/ParentAttendanceReportController.php @@ -71,12 +71,15 @@ class ParentAttendanceReportController extends BaseController ->orderBy('s.lastname', 'ASC') ->get()->getResultArray(); + $cutoffThreshold = $this->normalizeCutoffTime((string) $this->configModel->getConfig('parent_report_cutoff')); + return view('parent/report_attendance', [ 'students' => $students, 'today' => local_date(utc_now(), 'Y-m-d'), 'sundays' => $sundays, // array of ['value'=>Y-m-d, 'label'=>...] items 'defaultDate' => $defaultDate, // preselected date (upcoming Sunday) 'myReports' => $previewRows, + 'cutoffThreshold' => $cutoffThreshold, ]); } @@ -141,6 +144,17 @@ class ParentAttendanceReportController extends BaseController // Enforce Sunday-only and future-or-today selection $todayCheck = new \DateTime('today'); + $cutoffThreshold = $this->normalizeCutoffTime((string) $this->configModel->getConfig('parent_report_cutoff')); + $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); + $tz = null; + $nowTz = null; + try { + $tz = new \DateTimeZone($tzName ?: 'UTC'); + $nowTz = new \DateTime('now', $tz); + } catch (\Throwable $e) { + $tz = null; + $nowTz = null; + } $validDates = []; $cap = $this->firstSundayOfJune((string) $this->configModel->getConfig('school_year')); @@ -150,9 +164,11 @@ class ParentAttendanceReportController extends BaseController $lateTodayStr = $todayCheck->format('Y-m-d'); if ($type === 'late') { try { - $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); - $tz = new \DateTimeZone($tzName ?: 'UTC'); - $lateNow = new \DateTime('now', $tz); + if (!$tz) { + $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); + $tz = new \DateTimeZone($tzName ?: 'UTC'); + } + $lateNow = $nowTz ?: new \DateTime('now', $tz); $cm = $this->configModel; $cutoffStr = (string) ($cm->getConfig('late_report_cutoff') ?: $cm->getConfig('late_cutoff_time') @@ -183,6 +199,17 @@ class ParentAttendanceReportController extends BaseController if ($dt < $todayCheck) { return redirect()->back()->withInput()->with('error', 'Past dates are not allowed. Please select today or a future Sunday.'); } + if ($nowTz && $tz) { + try { + $cutoff = new \DateTime($entry . ' ' . $cutoffThreshold . ':00', $tz); + if ($nowTz >= $cutoff) { + $abbr = $cutoff->format('T'); + return redirect()->back()->withInput()->with('error', 'Reporting closes at ' . $cutoffThreshold . ' ' . $abbr . ' on the report date.'); + } + } catch (\Throwable $e) { + // ignore and continue + } + } if ($dt > $cap) { return redirect()->back()->withInput()->with('error', 'The last available date is the first Sunday of June.'); } @@ -800,6 +827,36 @@ class ParentAttendanceReportController extends BaseController $update['reported'] = 1; } $this->attendanceDataModel->update((int) $existing['id'], $update); + return; + } + + if ($existingStatus === '' || $existingStatus === 'present') { + $update = [ + 'status' => $status, + 'reason' => $existing['reason'] ?: $reasonText, + 'updated_at' => utc_now(), + ]; + if ($hasIsReported) { + $update['is_reported'] = 'yes'; + } + if ($hasLegacyReported) { + $update['reported'] = 1; + } + $this->attendanceDataModel->update((int) $existing['id'], $update); + + $classSectionId = (int) ($existing['class_section_id'] ?? $classSectionId ?? 0); + $schoolId = (string) ($existing['school_id'] ?? ''); + if ($classSectionId > 0 && $schoolId !== '') { + $this->adjustAttendanceRecordForStatusChange( + studentId: $studentId, + classSectionId: $classSectionId, + schoolId: $schoolId, + semester: $semester, + schoolYear: $schoolYear, + oldStatus: $existingStatus, + newStatus: $status + ); + } } } @@ -856,6 +913,61 @@ class ParentAttendanceReportController extends BaseController $this->attendanceRecordModel->insert($insert); } + private function adjustAttendanceRecordForStatusChange( + int $studentId, + int $classSectionId, + string $schoolId, + string $semester, + string $schoolYear, + string $oldStatus, + string $newStatus + ): void { + $oldStatus = strtolower($oldStatus); + $newStatus = strtolower($newStatus); + + if ($oldStatus === $newStatus) { + return; + } + + $record = $this->attendanceRecordModel->where([ + 'student_id' => $studentId, + 'semester' => $semester, + 'school_year' => $schoolYear, + ])->first(); + + if (!$record) { + $this->bumpAttendanceRecordFromParent($studentId, $classSectionId, $schoolId, $newStatus, $semester, $schoolYear); + return; + } + + $updates = [ + 'total_presence' => (int) ($record['total_presence'] ?? 0), + 'total_absence' => (int) ($record['total_absence'] ?? 0), + 'total_late' => (int) ($record['total_late'] ?? 0), + 'total_attendance' => (int) ($record['total_attendance'] ?? 0), + 'updated_at' => utc_now(), + 'modified_by' => (int) (session()->get('user_id') ?? 0), + ]; + + if ($oldStatus === 'present') { + $updates['total_presence'] = max(0, $updates['total_presence'] - 1); + } elseif ($oldStatus === 'absent') { + $updates['total_absence'] = max(0, $updates['total_absence'] - 1); + } elseif ($oldStatus === 'late') { + $updates['total_late'] = max(0, $updates['total_late'] - 1); + } + + if ($newStatus === 'present') { + $updates['total_presence']++; + } elseif ($newStatus === 'absent') { + $updates['total_absence']++; + } elseif ($newStatus === 'late') { + $updates['total_late']++; + } + + $this->attendanceRecordModel->update((int) $record['id'], $updates); + } + // GET: staff view to list reports (today & upcoming by default) public function list() { @@ -1503,6 +1615,20 @@ class ParentAttendanceReportController extends BaseController return $userId; // fallback } + private function normalizeCutoffTime(string $raw, string $default = '09:00'): string + { + $raw = trim($raw); + if ($raw === '') { + return $default; + } + if (preg_match('/^(\d{1,2}):(\d{2})$/', $raw, $m)) { + $hh = str_pad((string) ((int) $m[1]), 2, '0', STR_PAD_LEFT); + $mm = $m[2]; + return $hh . ':' . $mm; + } + return $default; + } + /** * Send confirmation/notification emails for parent attendance submissions. */ diff --git a/app/Controllers/View/ParentController.php b/app/Controllers/View/ParentController.php old mode 100644 new mode 100755 index d563aa7..4f91788 --- a/app/Controllers/View/ParentController.php +++ b/app/Controllers/View/ParentController.php @@ -717,6 +717,7 @@ class ParentController extends BaseController // Step 1: Generate a secure token for email verification $token = bin2hex(random_bytes(48)); + $tokenHash = hash('sha256', $token); // Step 2: Determine user type based on relationship $userType = in_array(strtolower($relationToStudent), ['wife', 'husband']) ? 'Secondary' : 'Tertiary'; @@ -776,7 +777,7 @@ class ParentController extends BaseController 'state' => strtoupper($userData['state']), 'zip' => $userData['zip'], 'accept_school_policy' => $userData['accept_school_policy'] ?? 0, - 'token' => $token, + 'token' => $tokenHash, 'is_verified' => 0, 'status' => 'Inactive', 'user_type' => $userType, @@ -1796,16 +1797,34 @@ $existing = $this->studentModel $activeEventCount = is_array($activeEvents) ? count($activeEvents) : 0; // Get charges (participation info) - $chargesList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear); + $chargesList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear, $semester); // Build a map: "studentId:eventId" => [ 'participation' => ..., 'date' => ... ] $charges = []; + $externalParticipantsByEvent = []; foreach ($chargesList as $charge) { - $key = $charge['student_id'] . ':' . $charge['event_id']; - $charges[$key] = [ - 'participation' => $charge['participation'], - 'date' => $charge['updated_at'] ?? $charge['created_at'] // Use updated_at if available - ]; + $studentId = $charge['student_id'] ?? null; + $eventId = (int) ($charge['event_id'] ?? 0); + + if (!empty($studentId)) { + $key = $studentId . ':' . $eventId; + $charges[$key] = [ + 'participation' => $charge['participation'], + 'date' => $charge['updated_at'] ?? $charge['created_at'], // Use updated_at if available + ]; + continue; + } + + $externalName = trim((string) ($charge['external_firstname'] ?? '') . ' ' . (string) ($charge['external_lastname'] ?? '')); + if ($eventId > 0 && $externalName !== '') { + $externalParticipantsByEvent[$eventId][] = [ + 'name' => $externalName, + 'note' => (string) ($charge['external_note'] ?? ''), + 'participation' => (string) ($charge['participation'] ?? ''), + 'event_paid' => !empty($charge['event_paid']), + 'charged' => (float) ($charge['charged'] ?? ($charge['event_amount'] ?? 0)), + ]; + } } // Get enrolled students @@ -1814,6 +1833,7 @@ $existing = $this->studentModel return view('parent/event_participation', [ 'activeEvents' => $activeEvents, 'charges' => $charges, + 'externalParticipantsByEvent' => $externalParticipantsByEvent, 'yourStudents' => $students, 'activeEventCount' => $activeEventCount, ]); diff --git a/app/Controllers/View/ParticipationController.php b/app/Controllers/View/ParticipationController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/PaymentController.php b/app/Controllers/View/PaymentController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/PaymentNotificationController.php b/app/Controllers/View/PaymentNotificationController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/PaymentTransactionController.php b/app/Controllers/View/PaymentTransactionController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/PaypalTransactionsController.php b/app/Controllers/View/PaypalTransactionsController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/PolicyController.php b/app/Controllers/View/PolicyController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/PreferencesController.php b/app/Controllers/View/PreferencesController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/PrintRequests.php b/app/Controllers/View/PrintRequests.php new file mode 100755 index 0000000..60fbafa --- /dev/null +++ b/app/Controllers/View/PrintRequests.php @@ -0,0 +1,571 @@ +printRequestModel = new PrintRequestModel(); + $this->userModel = new UserModel(); + $this->classModel = new ClassModel(); + $this->teacherClassModel = new TeacherClassModel(); + $this->configModel = new ConfigurationModel(); + $this->classSectionModel = new ClassSectionModel(); + $this->adminNotificationSubjectModel = new AdminNotificationSubjectModel(); + $this->notificationModel = new NotificationModel(); + $this->userNotificationModel = new UserNotificationModel(); + helper(['form', 'url']); + } + + public function teacher_index() + { + $teacher_id = session()->get('user_id'); + + $data['print_requests'] = $this->printRequestModel + ->select('print_requests.*, admins.firstname as admin_firstname, admins.lastname as admin_lastname') + ->join('users as admins', 'admins.id = print_requests.admin_id', 'left') + ->where('print_requests.teacher_id', $teacher_id) + ->findAll(); + + $teacher_classes = $this->teacherClassModel->getClassByTeacherId($teacher_id); + $data['class_id'] = !empty($teacher_classes) ? $teacher_classes[0]['class_section_id'] : null; + $dateOptions = $this->buildRequiredByOptions(); + $data['sundays'] = $dateOptions['sundays']; + $data['times'] = $dateOptions['times']; + + return view('print_requests/teacher_index', $data); + } + + public function admin_index() + { + $db = \Config\Database::connect(); + $query = $db->query(" + SELECT + pr.*, + u.firstname, + u.lastname, + cs.class_section_name, + admins.firstname AS admin_firstname, + admins.lastname AS admin_lastname + FROM print_requests pr + LEFT JOIN users u ON u.id = pr.teacher_id + LEFT JOIN classSection cs ON cs.class_section_id = pr.class_id + LEFT JOIN users admins ON admins.id = pr.admin_id + ORDER BY + CASE + WHEN pr.status = 'not_assigned' THEN 1 + WHEN pr.status = 'assigned' THEN 2 + WHEN pr.status = 'done' THEN 3 + WHEN pr.status = 'delivered' THEN 4 + ELSE 5 + END ASC, + pr.required_by ASC + "); + $data['print_requests'] = $query->getResultArray(); + + return view('print_requests/admin_index', $data); + } + + public function create() + { + $validationRules = [ + 'file' => 'uploaded[file]|max_size[file,2048]|ext_in[file,pdf,jpg,png,jpeg,doc,docx,txt]', + 'page_selection' => 'permit_empty|regex_match[/^\\s*\\d+(?:\\s*-\\s*\\d+)?(?:\\s*,\\s*\\d+(?:\\s*-\\s*\\d+)?)*\\s*$/]', + 'num_copies' => 'required|integer|greater_than[0]', + 'required_by' => 'required|valid_date', + 'pickup_method' => 'required' + ]; + + if (!$this->validate($validationRules)) { + return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); + } + + $teacher_id = session()->get('user_id'); + + $file = $this->request->getFile('file'); + $newName = ''; + if ($file->isValid() && !$file->hasMoved()) { + $newName = $file->getRandomName(); + // Moved to WRITEPATH + $file->move($this->uploadPath, $newName); + } + + $data = [ + 'teacher_id' => $teacher_id, + 'class_id' => $this->request->getPost('class_id'), + 'file_path' => $newName, + 'page_selection' => trim((string) $this->request->getPost('page_selection')), + 'num_copies' => $this->request->getPost('num_copies'), + 'required_by' => $this->request->getPost('required_by'), + 'pickup_method' => $this->request->getPost('pickup_method'), + 'status' => 'not_assigned', + ]; + + $printRequestId = (int) $this->printRequestModel->insert($data, true); + if ($printRequestId > 0) { + $this->notifyAdminsForPrintRequest($printRequestId, $data, 'created'); + } + + return redirect()->to('teacher/print-requests')->with('success', 'Print request created successfully.'); + } + + public function update($id) + { + $request = $this->printRequestModel->find($id); + if (!$request) { + return redirect()->back()->with('error', 'Print request not found.'); + } + + // Case 1: Admin status update + if ($this->request->getPost('status')) { + $user_id = session()->get('user_id'); + $current_status = $request['status']; + $new_status = $this->request->getPost('status'); + + $allowed_transitions = [ + 'not_assigned' => ['assigned'], + 'assigned' => ['not_assigned', 'done'], + 'done' => ['delivered'], + 'delivered' => [] + ]; + + if (!isset($allowed_transitions[$current_status]) || !in_array($new_status, $allowed_transitions[$current_status])) { + if ($current_status == $new_status) { + return redirect()->to('admin/print-requests'); + } + return redirect()->to('admin/print-requests')->with('error', 'Invalid status transition.'); + } + + $data = ['status' => $new_status]; + if ($new_status == 'assigned') { + $data['admin_id'] = $user_id; + } elseif ($new_status == 'not_assigned') { + $data['admin_id'] = null; + } + + $this->printRequestModel->update($id, $data); + return redirect()->to('admin/print-requests')->with('success', 'Status updated successfully.'); + } + + // Case 2: Teacher edit + if ($this->request->getPost('num_copies')) { + $user_id = session()->get('user_id'); + if ($request['teacher_id'] != $user_id) { + return redirect()->to('teacher/print-requests')->with('error', 'You are not authorized to edit this request.'); + } + if (!in_array($request['status'], ['not_assigned', 'assigned'])) { + return redirect()->to('teacher/print-requests')->with('error', 'Cannot edit a request that is already being processed.'); + } + + $validationRules = [ + 'num_copies' => 'required|integer|greater_than[0]', + 'required_by' => 'required|valid_date', + 'pickup_method' => 'required|in_list[Self Pickup,Delivered to class]' + ]; + + $pageSelectionRule = 'regex_match[/^\\s*\\d+(?:\\s*-\\s*\\d+)?(?:\\s*,\\s*\\d+(?:\\s*-\\s*\\d+)?)*\\s*$/]'; + $validationRules['page_selection'] = 'permit_empty|' . $pageSelectionRule; + + $hasNewFile = $this->request->getFile('file') && $this->request->getFile('file')->isValid(); + if ($hasNewFile) { + $validationRules['file'] = 'uploaded[file]|max_size[file,2048]|ext_in[file,pdf,jpg,png,jpeg,doc,docx,txt]'; + } + + if (!$this->validate($validationRules)) { + return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); + } + + $data = [ + 'num_copies' => $this->request->getPost('num_copies'), + 'required_by' => $this->request->getPost('required_by'), + 'pickup_method' => $this->request->getPost('pickup_method'), + 'page_selection' => trim((string) $this->request->getPost('page_selection')), + ]; + + $file = $this->request->getFile('file'); + if ($file && $file->isValid() && !$file->hasMoved()) { + // Remove old file from WRITEPATH + if ($request['file_path'] && file_exists($this->uploadPath . $request['file_path'])) { + @unlink($this->uploadPath . $request['file_path']); + } + $newName = $file->getRandomName(); + $file->move($this->uploadPath, $newName); + $data['file_path'] = $newName; + } + + $this->printRequestModel->update($id, $data); + $notifyPayload = array_merge($request, $data, ['id' => $id]); + $this->notifyAdminsForPrintRequest($id, $notifyPayload, 'updated'); + return redirect()->to('teacher/print-requests')->with('success', 'Print request updated successfully.'); + } + + return redirect()->back()->with('error', 'Invalid request data.'); + } + + public function delete($id) + { + $teacher_id = session()->get('user_id'); + $request = $this->printRequestModel->find($id); + + if (!$request) { + return redirect()->to('teacher/print-requests')->with('error', 'Print request not found.'); + } + + if ($request['teacher_id'] != $teacher_id) { + return redirect()->to('teacher/print-requests')->with('error', 'You are not authorized to delete this request.'); + } + + if (!in_array($request['status'], ['not_assigned', 'assigned'])) { + return redirect()->to('teacher/print-requests')->with('error', 'Cannot delete a request that is already being processed.'); + } + + $this->notifyAdminsForPrintRequest($id, $request, 'deleted'); + + // Delete file from WRITEPATH + if ($request['file_path'] && file_exists($this->uploadPath . $request['file_path'])) { + @unlink($this->uploadPath . $request['file_path']); + } + + $this->printRequestModel->delete($id); + + return redirect()->to('teacher/print-requests')->with('success', 'Print request deleted successfully.'); + } + + public function copy($id) + { + $teacher_id = session()->get('user_id'); + $request = $this->printRequestModel->find($id); + + if (!$request) { + return redirect()->to('teacher/print-requests')->with('error', 'Print request not found.'); + } + + if ($request['teacher_id'] != $teacher_id) { + return redirect()->to('teacher/print-requests')->with('error', 'You are not authorized to copy this request.'); + } + + $filePath = trim((string) ($request['file_path'] ?? '')); + $candidates = [ + $this->uploadPath . $filePath, + FCPATH . 'uploads/print_requests/' . $filePath, + ]; + $fileExists = false; + foreach ($candidates as $candidate) { + if ($filePath !== '' && file_exists($candidate)) { + $fileExists = true; + break; + } + } + + if (!$fileExists) { + return redirect()->to('teacher/print-requests')->with('error', 'The original file is unavailable for copying.'); + } + + $data = [ + 'teacher_id' => $teacher_id, + 'class_id' => $request['class_id'], + 'file_path' => $filePath, + 'page_selection' => $request['page_selection'] ?? null, + 'num_copies' => $request['num_copies'], + 'required_by' => $request['required_by'], + 'pickup_method' => $request['pickup_method'], + 'status' => 'not_assigned', + ]; + + $copiedId = (int) $this->printRequestModel->insert($data, true); + if ($copiedId > 0) { + $this->notifyAdminsForPrintRequest($copiedId, $data, 'created'); + } + + return redirect()->to('teacher/print-requests')->with('success', 'Print request copied successfully.'); + } + + public function createCopy() + { + $validationRules = [ + 'num_copies' => 'required|integer|greater_than[0]', + 'required_by' => 'required|valid_date', + 'pickup_method' => 'required' + ]; + + if (!$this->validate($validationRules)) { + return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); + } + + $teacher_id = session()->get('user_id'); + + $data = [ + 'teacher_id' => $teacher_id, + 'class_id' => $this->request->getPost('class_id'), + 'file_path' => '', + 'page_selection' => null, + 'num_copies' => $this->request->getPost('num_copies'), + 'required_by' => $this->request->getPost('required_by'), + 'pickup_method' => $this->request->getPost('pickup_method'), + 'status' => 'not_assigned', + ]; + + $printRequestId = (int) $this->printRequestModel->insert($data, true); + if ($printRequestId > 0) { + $this->notifyAdminsForPrintRequest($printRequestId, $data, 'created'); + } + + return redirect()->to('teacher/print-requests')->with('success', 'Copy request submitted. Please hand the original document to the copy center.'); + } + + private function buildRequiredByOptions(): array + { + $tz = new \DateTimeZone('America/Chicago'); + $currentDate = new \DateTime('now', $tz); + $currentYear = (int)$currentDate->format('Y'); + $endYear = ($currentDate->format('n') > 6) ? $currentYear + 1 : $currentYear; + $endDate = new \DateTime("first Sunday of June {$endYear}", $tz); + $endDate->modify('+1 week'); + + $sundays = []; + $date = new \DateTime('now', $tz); + $date->setTime(0, 0, 0); + if ($date->format('w') !== '0') { + $date->modify('next Sunday'); + } + while ($date < $endDate) { + $sundays[] = $date->format('Y-m-d'); + $date->modify('+1 week'); + } + + $times = []; + $start = new \DateTime('10:00', $tz); + $end = new \DateTime('13:00', $tz); + $interval = new \DateInterval('PT15M'); + $period = new \DatePeriod($start, $interval, $end); + foreach ($period as $time) { + $times[] = $time->format('H:i'); + } + + return [ + 'sundays' => $sundays, + 'times' => $times, + ]; + } + + public function serveFile(string $filename, string $mode = 'inline') + { + $safeName = basename(trim($filename)); + if ($safeName === '') { + throw new PageNotFoundException('File not specified.'); + } + + $candidates = [ + WRITEPATH . 'uploads/print_requests/' . $safeName, + FCPATH . 'uploads/print_requests/' . $safeName, + ]; + + $path = null; + foreach ($candidates as $candidate) { + if (is_file($candidate)) { + $path = $candidate; + break; + } + } + + if (!$path) { + throw new PageNotFoundException('File not found.'); + } + + $mime = mime_content_type($path) ?: 'application/octet-stream'; + $disposition = strtolower(trim($mode)) === 'download' ? 'attachment' : 'inline'; + + return $this->response + ->download($path, null) + ->setFileName($safeName) + ->setHeader('Content-Type', $mime) + ->setHeader('Content-Disposition', sprintf('%s; filename="%s"', $disposition, $safeName)); + } + + private function notifyAdminsForPrintRequest(int $printRequestId, array $requestData, string $event = 'created'): void + { + try { + $db = \Config\Database::connect(); + } catch (\Throwable $e) { + return; + } + + if ( + !$db->tableExists('admin_notification_subjects') || + !$db->tableExists('notifications') || + !$db->tableExists('user_notifications') + ) { + log_message('error', 'Print request notifications skipped: required tables missing.'); + return; + } + + $rows = $this->adminNotificationSubjectModel + ->select('admin_id') + ->where('subject', 'print_requests') + ->findAll(); + + $adminIds = array_values(array_unique(array_map('intval', array_column($rows, 'admin_id')))); + if (empty($adminIds)) { + log_message('info', 'Print request notifications skipped: no admins subscribed to print_requests.'); + return; + } + + $teacherId = (int) ($requestData['teacher_id'] ?? 0); + $teacherName = ''; + if ($teacherId > 0) { + $teacher = $this->userModel->find($teacherId); + $teacherName = trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? '')); + } + if ($teacherName === '') { + $teacherName = $teacherId > 0 ? ('Teacher #' . $teacherId) : 'Teacher'; + } + + $className = ''; + $classId = $requestData['class_id'] ?? null; + if ($classId !== null && $classId !== '') { + $className = (string) ($this->classSectionModel->getClassSectionNameBySectionId($classId) ?? ''); + } + + $event = strtolower(trim($event)); + $eventMap = [ + 'created' => [ + 'title' => 'New Print Request', + 'intro' => 'A new print request has been submitted.', + 'lead' => 'New print request from ', + ], + 'updated' => [ + 'title' => 'Print Request Updated', + 'intro' => 'A print request has been updated.', + 'lead' => 'Updated print request from ', + ], + 'deleted' => [ + 'title' => 'Print Request Removed', + 'intro' => 'A print request has been deleted.', + 'lead' => 'Print request removed for ', + ], + ]; + $eventConfig = $eventMap[$event] ?? $eventMap['created']; + + $filePath = trim((string) ($requestData['file_path'] ?? '')); + $isCopyRequest = $filePath === ''; + $messageParts = [$eventConfig['lead'] . $teacherName]; + if ($className !== '') { + $messageParts[] = 'Class: ' . $className; + } + if (!empty($requestData['num_copies'])) { + $messageParts[] = 'Copies: ' . (int) $requestData['num_copies']; + } + if (!empty($requestData['required_by'])) { + $messageParts[] = 'Needed by: ' . $requestData['required_by']; + } + if (!empty($requestData['page_selection'])) { + $messageParts[] = 'Pages: ' . $requestData['page_selection']; + } + if (!empty($requestData['pickup_method'])) { + $messageParts[] = 'Pickup: ' . $requestData['pickup_method']; + } + $message = implode(' | ', $messageParts); + $actionUrl = site_url('admin/print-requests'); + + $payload = [ + 'title' => $eventConfig['title'], + 'message' => $message, + 'target_group' => 'admin', + 'delivery_channels' => 'in_app,email', + 'priority' => 'normal', + 'status' => 'pending', + 'action_url' => $isCopyRequest ? '' : $actionUrl, + 'scheduled_at' => utc_now(), + 'school_year' => $this->configModel->getConfig('school_year'), + 'semester' => $this->configModel->getConfig('semester'), + ]; + + $notificationFields = $db->getFieldNames('notifications'); + if (is_array($notificationFields) && !empty($notificationFields)) { + $payload = array_intersect_key($payload, array_flip($notificationFields)); + } + + $notificationId = (int) $this->notificationModel->insert($payload, true); + if ($notificationId <= 0) { + return; + } + + $userNotificationFields = $db->getFieldNames('user_notifications'); + $userFieldMap = is_array($userNotificationFields) ? array_flip($userNotificationFields) : []; + + $batch = []; + foreach ($adminIds as $adminId) { + if ($adminId <= 0) { + continue; + } + $row = [ + 'notification_id' => $notificationId, + 'user_id' => $adminId, + 'is_read' => 0, + 'delivered' => 0, + ]; + if (!empty($userFieldMap)) { + $row = array_intersect_key($row, $userFieldMap); + } + $batch[] = $row; + } + + if (!empty($batch)) { + $this->userNotificationModel->insertBatch($batch); + } + + $adminRows = $this->userModel + ->select('id, firstname, lastname, email') + ->whereIn('id', $adminIds) + ->findAll(); + + if (empty($adminRows)) { + return; + } + + $mailer = new \App\Controllers\View\EmailController(); + $subject = $eventConfig['title']; + $body = '

' . esc($eventConfig['intro']) . '

' + . '

From: ' . esc($teacherName) . '

' + . ($className !== '' ? '

Class: ' . esc($className) . '

' : '') + . (!empty($requestData['num_copies']) ? '

Copies: ' . (int) $requestData['num_copies'] . '

' : '') + . (!empty($requestData['required_by']) ? '

Needed by: ' . esc($requestData['required_by']) . '

' : '') + . (!empty($requestData['page_selection']) ? '

Pages: ' . esc($requestData['page_selection']) . '

' : '') + . (!empty($requestData['pickup_method']) ? '

Pickup Method: ' . esc($requestData['pickup_method']) . '

' : '') + . ($isCopyRequest ? '' : '

View print requests

'); + + foreach ($adminRows as $adminRow) { + $email = trim((string) ($adminRow['email'] ?? '')); + if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + continue; + } + $mailer->sendEmail($email, $subject, $body, 'notifications'); + } + } +} diff --git a/app/Controllers/View/PrintablesBaseController.php b/app/Controllers/View/PrintablesBaseController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/ProjectController.php b/app/Controllers/View/ProjectController.php old mode 100644 new mode 100755 index 21eea66..69c2901 --- a/app/Controllers/View/ProjectController.php +++ b/app/Controllers/View/ProjectController.php @@ -438,6 +438,7 @@ class ProjectController extends Controller // Step 1: Get student IDs from student_class table $studentClassRows = $studentClassModel ->select('student_id') + ->distinct() ->where('class_section_id', $classSectionId) ->where('school_year', $schoolYear) ->findAll(); @@ -494,10 +495,7 @@ class ProjectController extends Controller $studentModel = new StudentModel(); $projectModel = new ProjectModel(); - $studentClasses = $studentClassModel - ->active() - ->where('student_class.class_section_id', $classSectionId) - ->findAll(); + $studentClasses = $studentClassModel->getClassStudents($classSectionId, $this->schoolYear, null); $students = []; foreach ($studentClasses as $sc) { diff --git a/app/Controllers/View/PurchaseOrderController.php b/app/Controllers/View/PurchaseOrderController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/QuizController.php b/app/Controllers/View/QuizController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/RFIDController.php b/app/Controllers/View/RFIDController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/RefundController.php b/app/Controllers/View/RefundController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/RegisterController.php b/app/Controllers/View/RegisterController.php old mode 100644 new mode 100755 index fe22344..4970690 --- a/app/Controllers/View/RegisterController.php +++ b/app/Controllers/View/RegisterController.php @@ -173,16 +173,8 @@ class RegisterController extends Controller $existingUser = $this->userModel->where('email', $post['email'])->first(); if ($existingUser) { - // Step 2: Check if the user has a token (i.e., not verified yet) - if (!empty($existingUser['token']) && $existingUser['is_verified'] == 0) { - // User exists and is unverified - return redirect()->back()->withInput()->with('error', - 'This email address is already registered and is pending activation. Please check your email to activate your account.'); - } else { - // User exists and is already active or has no token - return redirect()->back()->withInput()->with('error', - 'The email address you entered is already in use. Please try a different one.'); - } + return redirect()->back()->withInput()->with('error', + 'This email address is already registered. Please check your email or log in.'); } /* ───────────── 6. Determine role ───────────── */ @@ -194,6 +186,7 @@ class RegisterController extends Controller /* ───────────── 7. Build & insert user ───────────── */ $token = bin2hex(random_bytes(48)); + $tokenHash = hash('sha256', $token); $userData = [ 'firstname' => $post['firstname'], 'lastname' => $post['lastname'], @@ -205,7 +198,7 @@ class RegisterController extends Controller 'city' => $post['city'], 'state' => $post['state'], 'zip' => $post['zip'], - 'token' => $token, + 'token' => $tokenHash, 'is_verified'=> 0, 'accept_school_policy' => (int) $post['accept_school_policy'], 'status' => 'Inactive', @@ -357,4 +350,4 @@ class RegisterController extends Controller -} \ No newline at end of file +} diff --git a/app/Controllers/View/ReimbursementController.php b/app/Controllers/View/ReimbursementController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/ReportCardsController.php b/app/Controllers/View/ReportCardsController.php old mode 100644 new mode 100755 index 74b6e1b..7b52691 --- a/app/Controllers/View/ReportCardsController.php +++ b/app/Controllers/View/ReportCardsController.php @@ -4,18 +4,21 @@ namespace App\Controllers\View; use App\Models\CalendarModel; use App\Models\AttendanceDataModel; +use App\Models\ReportCardAcknowledgementModel; use App\Services\SemesterRangeService; class ReportCardsController extends PrintablesBaseController { protected $calendarModel; protected $semesterRangeService; + protected $ackModel; public function __construct() { parent::__construct(); $this->calendarModel = new CalendarModel(); $this->semesterRangeService = new SemesterRangeService($this->configModel); + $this->ackModel = new ReportCardAcknowledgementModel(); } public function report_card() @@ -369,6 +372,24 @@ class ReportCardsController extends PrintablesBaseController $isNumeric = static fn($v) => $v !== null && $v !== '' && is_numeric($v); + $ackMap = []; + try { + $ackRows = $this->ackModel + ->whereIn('student_id', $studentIds) + ->where('school_year', $year) + ->where('semester', $sem) + ->orderBy('updated_at', 'DESC') + ->findAll(); + foreach ($ackRows as $row) { + $sid = (int) ($row['student_id'] ?? 0); + if ($sid > 0 && ! isset($ackMap[$sid])) { + $ackMap[$sid] = $row; + } + } + } catch (\Throwable $e) { + $ackMap = []; + } + $results = []; $completeCount = 0; $warningCount = 0; @@ -471,12 +492,16 @@ class ReportCardsController extends PrintablesBaseController $warningCount++; } + $ack = $sid > 0 ? ($ackMap[$sid] ?? null) : null; $results[] = [ 'id' => $sid, 'name' => $name !== '' ? $name : 'N/A', 'missing' => $missing, 'warnings' => $warnings, 'complete' => $isComplete, + 'viewed_at' => $ack['viewed_at'] ?? null, + 'signed_at' => $ack['signed_at'] ?? null, + 'signed_name' => $ack['signed_name'] ?? null, ]; } @@ -501,6 +526,37 @@ class ReportCardsController extends PrintablesBaseController ]); } + /** + * API: report card parent acknowledgement status + * GET params: student_id, school_year, semester + */ + public function reportCardAcknowledgement() + { + $studentId = (int) ($this->request->getGet('student_id') ?? 0); + if ($studentId <= 0) { + return $this->response->setJSON(['ok' => false, 'error' => 'Missing student_id'])->setStatusCode(400); + } + $schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->schoolYear ?? '')); + $semester = trim((string) ($this->request->getGet('semester') ?? $this->semester ?? '')); + + $row = $this->ackModel + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->where('semester', $semester) + ->orderBy('updated_at', 'DESC') + ->first(); + + return $this->response->setJSON([ + 'ok' => true, + 'student_id' => $studentId, + 'school_year' => $schoolYear, + 'semester' => $semester, + 'viewed_at' => $row['viewed_at'] ?? null, + 'signed_at' => $row['signed_at'] ?? null, + 'signed_name' => $row['signed_name'] ?? null, + ]); + } + protected function fetchStudentsByClass(int $sectionId, ?string $schoolYear) { $b = $this->studentClassModel diff --git a/app/Controllers/View/RolePermissionController.php b/app/Controllers/View/RolePermissionController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/RoleSwitcherController.php b/app/Controllers/View/RoleSwitcherController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/SchoolCalendarController.php b/app/Controllers/View/SchoolCalendarController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/ScoreCommentController.php b/app/Controllers/View/ScoreCommentController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/ScoreController.php b/app/Controllers/View/ScoreController.php old mode 100644 new mode 100755 index 4e05fba..13950e2 --- a/app/Controllers/View/ScoreController.php +++ b/app/Controllers/View/ScoreController.php @@ -1287,14 +1287,14 @@ class ScoreController extends Controller public function viewStudentScore() { $parentId = session()->get('user_id'); - $userType = $_SESSION['user_type']; + $userType = session()->get('user_type') ?? ''; $firstParentId = null; $releaseFall = $this->getParentScoresReleasedForSemester('Fall'); $releaseSpring = $this->getParentScoresReleasedForSemester('Spring'); $releaseAny = $releaseFall || $releaseSpring; // Identify the firstparent based on user type - if ($userType === 'primary') { + if ($userType === 'primary' || $userType === '') { $firstParentId = $parentId; } elseif ($userType === 'secondary') { $parentData = $this->db->table('parents') @@ -1378,6 +1378,7 @@ $students = $this->db->table('students') $releaseScores = (strcasecmp($semester, 'Fall') === 0) ? $releaseFall : ((strcasecmp($semester, 'Spring') === 0) ? $releaseSpring : $releaseAny); $scores[$selectedYear][$semester][$studentId] = [ + 'student_id' => $studentId, 'school_id' => $student['school_id'], 'student_firstname' => $student['firstname'], 'student_lastname' => $student['lastname'], diff --git a/app/Controllers/View/ScorePredictor.php b/app/Controllers/View/ScorePredictor.php old mode 100644 new mode 100755 index a17d1ef..80d54f5 --- a/app/Controllers/View/ScorePredictor.php +++ b/app/Controllers/View/ScorePredictor.php @@ -81,10 +81,10 @@ class ScorePredictor extends Controller s.school_id, s.firstname, s.lastname, - fall.semester_score as fall_score, - spring.semester_score as spring_score'); - // Also select class section for per-class trophy decision - $builder->select('sc.class_section_id as class_section_id'); + MAX(fall.semester_score) as fall_score, + MAX(spring.semester_score) as spring_score'); + // Reduce duplication from restored students while keeping a stable class section. + $builder->select('MAX(sc.class_section_id) as class_section_id'); $yearEsc = $this->db->escape($selectedYear); $builder->join('student_class sc', 'sc.student_id = s.id AND sc.school_year = ' . $yearEsc, 'left'); $builder->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left'); diff --git a/app/Controllers/View/SendSMSController.php b/app/Controllers/View/SendSMSController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/SessionTimeoutController.php b/app/Controllers/View/SessionTimeoutController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/SettingsController.php b/app/Controllers/View/SettingsController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/SlipPrinterController.php b/app/Controllers/View/SlipPrinterController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/StaffController.php b/app/Controllers/View/StaffController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/StatsController.php b/app/Controllers/View/StatsController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/StickersController.php b/app/Controllers/View/StickersController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/StudentController.php b/app/Controllers/View/StudentController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/SubjectCurriculumController.php b/app/Controllers/View/SubjectCurriculumController.php old mode 100644 new mode 100755 index adcb695..2273b72 --- a/app/Controllers/View/SubjectCurriculumController.php +++ b/app/Controllers/View/SubjectCurriculumController.php @@ -99,6 +99,7 @@ class SubjectCurriculumController extends BaseController ->orderBy('classes.class_name', 'ASC') ->orderBy('subject', 'ASC') ->orderBy('unit_number', 'ASC') + ->orderBy("CAST(SUBSTRING_INDEX(subject_curriculum_items.chapter_name, '.', 1) AS UNSIGNED)", 'ASC', false) ->orderBy('chapter_name', 'ASC') ->get() ->getResultArray(); diff --git a/app/Controllers/View/SupplierController.php b/app/Controllers/View/SupplierController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/SupplyCategoryController.php b/app/Controllers/View/SupplyCategoryController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/SupportController.php b/app/Controllers/View/SupportController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/TeacherController.php b/app/Controllers/View/TeacherController.php old mode 100644 new mode 100755 index f4a87dc..4a85a28 --- a/app/Controllers/View/TeacherController.php +++ b/app/Controllers/View/TeacherController.php @@ -297,12 +297,16 @@ class TeacherController extends BaseController $schoolYear = $forYear ?: ((string)($this->schoolYear ?? 'Not Set')); $teachers = $this->teacherModel->getTeachersAndTAs(); - // Prefer class sections for the selected year, fallback to all if none - $classSections = $classSectionModel - ->where('school_year', $schoolYear) - ->orderBy('class_section_name', 'ASC') - ->findAll(); - if (empty($classSections)) { + // Prefer class sections for the selected year if the column exists, otherwise fallback to all. + if ($this->db->fieldExists('school_year', 'classSection')) { + $classSections = $classSectionModel + ->where('school_year', $schoolYear) + ->orderBy('class_section_name', 'ASC') + ->findAll(); + if (empty($classSections)) { + $classSections = $classSectionModel->orderBy('class_section_name', 'ASC')->findAll(); + } + } else { $classSections = $classSectionModel->orderBy('class_section_name', 'ASC')->findAll(); } diff --git a/app/Controllers/View/TestDBController.php b/app/Controllers/View/TestDBController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/UiController.php b/app/Controllers/View/UiController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/View/UserController.php b/app/Controllers/View/UserController.php old mode 100644 new mode 100755 index f8d1c6f..2f7e64e --- a/app/Controllers/View/UserController.php +++ b/app/Controllers/View/UserController.php @@ -21,6 +21,7 @@ require_once APPPATH . 'Helpers/pbkdf2_helper.php'; class UserController extends BaseController { + private const ACTIVATION_TTL_HOURS = 48; protected $userModel; protected $roleModel; protected $userRoleModel; @@ -49,6 +50,37 @@ class UserController extends BaseController $this->resetRequestModel = new PasswordResetRequestModel(); } + private function denyAccess(string $message) + { + if ($this->request->isAJAX() || $this->request->getHeaderLine('Accept') === 'application/json') { + return service('response') + ->setStatusCode(403) + ->setJSON(['status' => 'error', 'message' => $message]); + } + + session()->setFlashdata('error', $message); + return redirect()->to('/access_denied'); + } + + private function requirePermission(string $permission) + { + if (!session()->get('is_logged_in')) { + return redirect()->to('/login'); + } + + $userId = (int) session()->get('user_id'); + if ($userId <= 0 || !has_permission($userId, $permission)) { + return $this->denyAccess("You don't have permission to use this feature."); + } + + return null; + } + + private function hashToken(string $token): string + { + return hash('sha256', $token); + } + // Method to show the home page public function home() { @@ -75,6 +107,10 @@ class UserController extends BaseController public function userList() { + if ($resp = $this->requirePermission('read_user')) { + return $resp; + } + helper('url'); return view('user/user_list', [ @@ -85,6 +121,10 @@ class UserController extends BaseController // Method to show the list of users public function index() { + if ($resp = $this->requirePermission('read_user')) { + return $resp; + } + // Fetch users along with their assigned roles $builder = $this->db->table('users'); $builder->select('users.id, users.firstname, users.lastname, users.email, user_roles.role_id, roles.name as role, users.status, users.updated_at'); @@ -122,6 +162,10 @@ class UserController extends BaseController public function userListData() { + if ($resp = $this->requirePermission('read_user')) { + return $resp; + } + return $this->response->setJSON([ 'users' => $this->buildUsersWithRoles(), ]); @@ -253,6 +297,10 @@ class UserController extends BaseController // Method to store a new user public function store() { + if ($resp = $this->requirePermission('edit_user')) { + return $resp; + } + // Validate input data $validation = \Config\Services::validation(); $validation->setRules([ @@ -315,6 +363,10 @@ class UserController extends BaseController // Method to show the form for editing an existing user public function edit($id) { + if ($resp = $this->requirePermission('edit_user')) { + return $resp; + } + $data['user'] = $this->userModel->find($id); $data['roles'] = $this->roleModel->findAll(); $userRoles = $this->userRoleModel->where('user_id', $id)->findAll(); @@ -327,6 +379,10 @@ class UserController extends BaseController // Method to delete an existing user public function delete($id) { + if ($resp = $this->requirePermission('edit_user')) { + return $resp; + } + $this->userModel->delete($id); // Delete the user's roles from the user_roles table @@ -369,27 +425,21 @@ class UserController extends BaseController $email = strtolower($this->request->getPost('email')); $user = $this->userModel->where('email', $email)->first(); - // --- Handle unknown email --- - if (!$user) { - session()->setFlashdata('error', 'If this email is registered, you will receive a reset link.'); - log_message('info', "Password reset requested for non-existing user {$email}"); - return redirect()->back(); - } - - // --- Handle unverified accounts --- - if ((int) $user['is_verified'] === 0) { - session()->setFlashdata('error', 'Please check your email and complete the account activation process before resetting your password.'); - log_message('info', "Password reset blocked for unverified user {$email}"); + // --- Handle unknown or unverified email --- + if (!$user || (int) $user['is_verified'] === 0) { + session()->setFlashdata('success', 'If this email is registered, you will receive a reset link.'); + log_message('info', "Password reset requested for {$email} (user missing or unverified)."); return redirect()->back(); } // --- Verified user: continue with reset --- $token = bin2hex(random_bytes(48)); + $tokenHash = $this->hashToken($token); $expires_at = Time::now()->addHours(1); $this->passwordResetModel->insert([ 'email' => $email, - 'token' => $token, + 'token' => $tokenHash, 'created_at' => Time::now(), 'expires_at' => $expires_at, ]); @@ -447,7 +497,12 @@ class UserController extends BaseController } // You may want to validate the token here - $resetEntry = $this->passwordResetModel->where('token', $token) + $tokenHash = $this->hashToken($token); + $resetEntry = $this->passwordResetModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() ->where('expires_at >=', Time::now()) ->first(); @@ -462,6 +517,10 @@ class UserController extends BaseController //This function processes the new password submission, validating the token, updating the user's password, and cleaning up the reset entry. public function processResetPassword() { + if (strtolower($this->request->getMethod()) !== 'post') { + return redirect()->to('/')->with('error', 'Invalid request.'); + } + $token = $this->request->getPost('token'); $newPassword = $this->request->getPost('password'); $passConfirm = $this->request->getPost('pass_confirm'); @@ -490,7 +549,12 @@ class UserController extends BaseController } // Find the password reset entry - $resetEntry = $this->passwordResetModel->where('token', $token) + $tokenHash = $this->hashToken($token); + $resetEntry = $this->passwordResetModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() ->where('expires_at >=', Time::now()) ->first(); @@ -519,7 +583,12 @@ class UserController extends BaseController ]); // Delete the used token from the password reset table - $this->passwordResetModel->where('token', $token)->delete(); + $this->passwordResetModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() + ->delete(); // Retrieve the user's IP address from the request $ipAddress = $this->request->getIPAddress(); @@ -546,10 +615,16 @@ class UserController extends BaseController public function confirm($token) { - log_message('info', 'Processing email confirmation with token: ' . $token); + log_message('info', 'Processing email confirmation.'); - - $user = $this->userModel->where('token', $token)->first(); + $tokenHash = $this->hashToken($token); + $user = $this->userModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() + ->where('created_at >=', Time::now()->subHours(self::ACTIVATION_TTL_HOURS)->toDateTimeString()) + ->first(); if (!$user || $user['is_verified'] == 1) { return redirect()->to('/invalid_token'); @@ -570,7 +645,14 @@ class UserController extends BaseController { //echo "Reached setPassword with token: " . esc($token); //echo "Token received: " . $token; - $user = $this->userModel->where('token', $token)->first(); + $tokenHash = $this->hashToken($token); + $user = $this->userModel + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() + ->where('created_at >=', Time::now()->subHours(self::ACTIVATION_TTL_HOURS)->toDateTimeString()) + ->first(); if (!$user || $user['is_verified'] == 1) { return redirect()->to('/invalid_token'); @@ -584,6 +666,10 @@ class UserController extends BaseController public function savePassword() { + if (strtolower($this->request->getMethod()) !== 'post') { + return redirect()->to('/')->with('error', 'Invalid request.'); + } + $validation = \Config\Services::validation(); $validation->setRules([ 'password' => [ @@ -615,9 +701,17 @@ class UserController extends BaseController $token = $this->request->getPost('token'); $password = $this->request->getPost('password'); - $user = $this->userModel->where('id', $userId)->where('token', $token)->first(); + $tokenHash = $this->hashToken($token); + $user = $this->userModel + ->where('id', $userId) + ->groupStart() + ->where('token', $tokenHash) + ->orWhere('token', $token) + ->groupEnd() + ->where('created_at >=', Time::now()->subHours(self::ACTIVATION_TTL_HOURS)->toDateTimeString()) + ->first(); - log_message('debug', "Attempting to set password for user $userId with token $token"); + log_message('debug', "Attempting to set password for user $userId"); if (!$user || $user['is_verified'] == 1) { return redirect()->to('/invalid_token'); @@ -670,20 +764,39 @@ class UserController extends BaseController $roleKey = (string) $this->request->getPost('role'); log_message('info', 'Role selected: ' . $roleKey); - $roleModel = new RoleModel(); - $route = $roleModel->getRouteByNameOrSlug($roleKey); - - if ($route === null) { - log_message('error', 'Invalid or inactive role selected: ' . $roleKey); - return redirect()->back()->with('error', 'Invalid role selected.'); - } - $userId = (int) session()->get('user_id'); log_message('info', 'User ID: ' . $userId); + if ($userId <= 0) { + return $this->denyAccess("You don't have permission to use this feature."); + } + + $roleRow = $this->db->table('user_roles ur') + ->join('roles r', 'r.id = ur.role_id', 'inner') + ->select('r.name, r.slug, r.dashboard_route') + ->where('ur.user_id', $userId) + ->where('r.is_active', 1) + ->groupStart() + ->where('LOWER(r.name)', strtolower($roleKey)) + ->orWhere('LOWER(r.slug)', strtolower($roleKey)) + ->groupEnd() + ->get() + ->getRowArray(); + + if (empty($roleRow)) { + log_message('error', 'Invalid or unassigned role selected: ' . $roleKey); + return redirect()->back()->with('error', 'Invalid role selected.'); + } + + $route = $roleRow['dashboard_route'] ?? null; + if ($route === null) { + log_message('error', 'No dashboard route configured for role: ' . $roleKey); + return redirect()->back()->with('error', 'Invalid role selected.'); + } + // Persist the *exact* role name or slug—choose your convention. - // If you want to store the canonical name, fetch the row and use $row['name']. - $this->userModel->update($userId, ['role' => $roleKey]); + // Store the canonical name to avoid arbitrary role strings. + $this->userModel->update($userId, ['role' => $roleRow['name']]); log_message('info', 'Role updated in database.'); log_message('info', 'Redirecting to role dashboard: ' . $route); @@ -702,6 +815,10 @@ class UserController extends BaseController public function delete_role($roleId) { + if ($resp = $this->requirePermission('edit_user')) { + return $resp; + } + // Fetch the role to be deleted $role = $this->roleModel->find($roleId); if (!$role) { @@ -731,6 +848,10 @@ class UserController extends BaseController public function loginActivity() { + if ($resp = $this->requirePermission('view_login_activity')) { + return $resp; + } + helper('url'); $perPage = (int) ($this->request->getGet('per_page') ?? 25); @@ -743,6 +864,10 @@ class UserController extends BaseController public function loginActivityData() { + if ($resp = $this->requirePermission('view_login_activity')) { + return $resp; + } + $perPage = (int) ($this->request->getGet('per_page') ?? 25); $page = (int) ($this->request->getGet('page') ?? 1); @@ -752,6 +877,10 @@ class UserController extends BaseController // Method to update an existing user public function updateUser() { + if ($resp = $this->requirePermission('edit_user')) { + return $resp; + } + if (strtolower($this->request->getMethod()) !== 'post') { return redirect()->to(site_url('user/user_list'))->with('error', 'Invalid request.'); } @@ -800,9 +929,6 @@ class UserController extends BaseController 'status' => trim((string)$this->request->getPost('status')), 'is_suspended' => $toBool('is_suspended'), 'is_verified' => $toBool('is_verified'), - 'token' => trim((string)$this->request->getPost('token')), - 'updated_at' => $toDT('updated_at'), - 'created_at' => $toDT('created_at'), ]; // Validation diff --git a/app/Controllers/View/WhatsappController.php b/app/Controllers/View/WhatsappController.php old mode 100644 new mode 100755 diff --git a/app/Controllers/WinnersController.php b/app/Controllers/WinnersController.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/.gitkeep b/app/Database/Migrations/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/Database/Migrations/2024-05-26-000000_AddEventCategoryToEvents.php b/app/Database/Migrations/2024-05-26-000000_AddEventCategoryToEvents.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2024-11-05-000700_AddBatchNumberToReimbursements.php b/app/Database/Migrations/2024-11-05-000700_AddBatchNumberToReimbursements.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2024-11-07-000800_AddYearlyBatchNumberToReimbursementBatches.php b/app/Database/Migrations/2024-11-07-000800_AddYearlyBatchNumberToReimbursementBatches.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-09-10-000002_CreateInventoryTables.php b/app/Database/Migrations/2025-09-10-000002_CreateInventoryTables.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-09-11-000010_CreateInventoryMovements.php b/app/Database/Migrations/2025-09-11-000010_CreateInventoryMovements.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-09-11-000300_AddClassroomConditionBuckets.php b/app/Database/Migrations/2025-09-11-000300_AddClassroomConditionBuckets.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-09-11-000400_AddGradeRangeToBookCategories.php b/app/Database/Migrations/2025-09-11-000400_AddGradeRangeToBookCategories.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-09-27-000001_CreateStaffAttendance.php b/app/Database/Migrations/2025-09-27-000001_CreateStaffAttendance.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-10-08-000100_CreateBadgePrintLogs.php b/app/Database/Migrations/2025-10-08-000100_CreateBadgePrintLogs.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-10-16-000200_CreateFamiliesTables.php b/app/Database/Migrations/2025-10-16-000200_CreateFamiliesTables.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-10-17-121500_AddNoSchoolToCalendarEvents.php b/app/Database/Migrations/2025-10-17-121500_AddNoSchoolToCalendarEvents.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-10-17-130000_CreateWhatsappGroupMemberships.php b/app/Database/Migrations/2025-10-17-130000_CreateWhatsappGroupMemberships.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-10-19-000300_CreateLateSlipLogs.php b/app/Database/Migrations/2025-10-19-000300_CreateLateSlipLogs.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-10-19-090000_CreatePaymentNotificationLogs.php b/app/Database/Migrations/2025-10-19-090000_CreatePaymentNotificationLogs.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-10-20-000500_CreateParentAttendanceReports.php b/app/Database/Migrations/2025-10-20-000500_CreateParentAttendanceReports.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-10-21-000600_AddSemesterToLateSlipLogs.php b/app/Database/Migrations/2025-10-21-000600_AddSemesterToLateSlipLogs.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-10-21-110000_CreatePromotionQueue.php b/app/Database/Migrations/2025-10-21-110000_CreatePromotionQueue.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-10-27-000510_CreateEarlyDismissalSignatures.php b/app/Database/Migrations/2025-10-27-000510_CreateEarlyDismissalSignatures.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-11-05-050100_AddTimezoneToPreferencesAndSettings.php b/app/Database/Migrations/2025-11-05-050100_AddTimezoneToPreferencesAndSettings.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-11-23-011228_AddDonationCategoryToExpenses.php b/app/Database/Migrations/2025-11-23-011228_AddDonationCategoryToExpenses.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-12-01-000100_CreateReimbursementBatchAdminFiles.php b/app/Database/Migrations/2025-12-01-000100_CreateReimbursementBatchAdminFiles.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-12-24-190600_AddReviewFieldsToScoreComments.php b/app/Database/Migrations/2025-12-24-190600_AddReviewFieldsToScoreComments.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2025-12-27-050000_CreateAttendanceCommentTemplates.php b/app/Database/Migrations/2025-12-27-050000_CreateAttendanceCommentTemplates.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-05-215636_CreatePrintRequests.php b/app/Database/Migrations/2026-01-05-215636_CreatePrintRequests.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-05-215851_FixPrintRequestsForeignKey.php b/app/Database/Migrations/2026-01-05-215851_FixPrintRequestsForeignKey.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-06-195508_RevertPrintRequestsForeignKey.php b/app/Database/Migrations/2026-01-06-195508_RevertPrintRequestsForeignKey.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-06-200210_DropPrintRequestsForeignKey.php b/app/Database/Migrations/2026-01-06-200210_DropPrintRequestsForeignKey.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-06-201209_FixPrintRequestsForeignKeyOnceAndForAll.php b/app/Database/Migrations/2026-01-06-201209_FixPrintRequestsForeignKeyOnceAndForAll.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-06-205621_FixPrintRequestsForeignKeyAgain.php b/app/Database/Migrations/2026-01-06-205621_FixPrintRequestsForeignKeyAgain.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-10-000001_CreateCompetitions.php b/app/Database/Migrations/2026-01-10-000001_CreateCompetitions.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-10-000002_CreateCompetitionScores.php b/app/Database/Migrations/2026-01-10-000002_CreateCompetitionScores.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-10-000003_CreateCompetitionWinners.php b/app/Database/Migrations/2026-01-10-000003_CreateCompetitionWinners.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-10-000004_AddCompetitionNavItems.php b/app/Database/Migrations/2026-01-10-000004_AddCompetitionNavItems.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-10-000005_AddBelowSixtyNavItem.php b/app/Database/Migrations/2026-01-10-000005_AddBelowSixtyNavItem.php new file mode 100755 index 0000000..7571422 --- /dev/null +++ b/app/Database/Migrations/2026-01-10-000005_AddBelowSixtyNavItem.php @@ -0,0 +1,139 @@ +tableExists('nav_items')) { + return; + } + + $parentColumn = null; + if ($db->fieldExists('menu_parent_id', 'nav_items')) { + $parentColumn = 'menu_parent_id'; + } elseif ($db->fieldExists('parent_id', 'nav_items')) { + $parentColumn = 'parent_id'; + } + + if ($parentColumn === null) { + return; + } + + $navBuilder = $db->table('nav_items'); + $now = date('Y-m-d H:i:s'); + + $communication = $navBuilder + ->select('id') + ->where('label', 'Communication') + ->where($parentColumn, null) + ->get() + ->getRowArray(); + + if ($communication) { + $communicationId = (int) $communication['id']; + } else { + $navBuilder->insert([ + $parentColumn => null, + 'label' => 'Communication', + 'url' => null, + 'sort_order' => 80, + 'is_enabled' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $communicationId = (int) $db->insertID(); + } + + $belowSixtyUrl = 'grading/below-60'; + $existing = $navBuilder->select('id') + ->where('url', $belowSixtyUrl) + ->get() + ->getRowArray(); + + if (!$existing) { + $navBuilder->insert([ + $parentColumn => $communicationId ?: null, + 'label' => 'Below 60 Summary', + 'url' => $belowSixtyUrl, + 'sort_order' => 4, + 'is_enabled' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $belowSixtyId = (int) $db->insertID(); + } else { + $belowSixtyId = (int) $existing['id']; + } + + if (!$db->tableExists('role_nav_items') || !$db->tableExists('roles')) { + return; + } + + if ($belowSixtyId <= 0) { + return; + } + + $roleRows = $db->table('roles') + ->select('id, name') + ->whereIn('name', ['administrator', 'principal', 'vice_principal', 'admin']) + ->get() + ->getResultArray(); + + $roleMap = $db->table('role_nav_items'); + foreach ($roleRows as $role) { + $roleId = (int) ($role['id'] ?? 0); + if ($roleId <= 0) { + continue; + } + + $exists = $roleMap + ->where('role_id', $roleId) + ->where('nav_item_id', $belowSixtyId) + ->get() + ->getRowArray(); + + if ($exists) { + continue; + } + + $roleMap->insert([ + 'role_id' => $roleId, + 'nav_item_id' => $belowSixtyId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + public function down() + { + $db = \Config\Database::connect(); + + if (!$db->tableExists('nav_items')) { + return; + } + + $belowSixtyUrl = 'grading/below-60'; + $row = $db->table('nav_items') + ->select('id') + ->where('url', $belowSixtyUrl) + ->get() + ->getRowArray(); + + if ($row && $db->tableExists('role_nav_items')) { + $db->table('role_nav_items') + ->where('nav_item_id', (int) $row['id']) + ->delete(); + } + + $db->table('nav_items') + ->where('url', $belowSixtyUrl) + ->delete(); + } +} diff --git a/app/Database/Migrations/2026-01-10-000005_CreateCompetitionClassWinners.php b/app/Database/Migrations/2026-01-10-000005_CreateCompetitionClassWinners.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-10-000006_AddQuestionCountToCompetitionClassWinners.php b/app/Database/Migrations/2026-01-10-000006_AddQuestionCountToCompetitionClassWinners.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-10-000007_AddPrizeColumnsToCompetitionClassWinners.php b/app/Database/Migrations/2026-01-10-000007_AddPrizeColumnsToCompetitionClassWinners.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-10-000007_AllowCompetitionWinnerRankTies.php b/app/Database/Migrations/2026-01-10-000007_AllowCompetitionWinnerRankTies.php old mode 100644 new mode 100755 index e7c6498..3729203 --- a/app/Database/Migrations/2026-01-10-000007_AllowCompetitionWinnerRankTies.php +++ b/app/Database/Migrations/2026-01-10-000007_AllowCompetitionWinnerRankTies.php @@ -13,11 +13,11 @@ class AllowCompetitionWinnerRankTies extends Migration return; } - if ($db->indexExists('competition_winners', 'competition_id_class_section_id_rank')) { + if ($this->indexExists($db, 'competition_winners', 'competition_id_class_section_id_rank')) { $db->query('DROP INDEX competition_id_class_section_id_rank ON competition_winners'); } - if (!$db->indexExists('competition_winners', 'competition_id_class_section_id_rank')) { + if (! $this->indexExists($db, 'competition_winners', 'competition_id_class_section_id_rank')) { $db->query( 'CREATE INDEX competition_id_class_section_id_rank ON competition_winners (competition_id, class_section_id, rank)' ); @@ -31,7 +31,7 @@ class AllowCompetitionWinnerRankTies extends Migration return; } - if ($db->indexExists('competition_winners', 'competition_id_class_section_id_rank')) { + if ($this->indexExists($db, 'competition_winners', 'competition_id_class_section_id_rank')) { $db->query('DROP INDEX competition_id_class_section_id_rank ON competition_winners'); } @@ -39,4 +39,23 @@ class AllowCompetitionWinnerRankTies extends Migration 'ALTER TABLE competition_winners ADD UNIQUE KEY competition_id_class_section_id_rank (competition_id, class_section_id, rank)' ); } + + private function indexExists($db, string $table, string $index): bool + { + if (method_exists($db, 'indexExists')) { + return $db->indexExists($table, $index); + } + + $dbName = $db->getDatabase(); + $builder = $db->table('information_schema.STATISTICS'); + $row = $builder + ->select('INDEX_NAME') + ->where('TABLE_SCHEMA', $dbName) + ->where('TABLE_NAME', $table) + ->where('INDEX_NAME', $index) + ->get() + ->getRowArray(); + + return ! empty($row); + } } diff --git a/app/Database/Migrations/2026-01-11-000100_CreateAdminNotificationSubjects.php b/app/Database/Migrations/2026-01-11-000100_CreateAdminNotificationSubjects.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-13-000300_AddCompetitionLockFields.php b/app/Database/Migrations/2026-01-13-000300_AddCompetitionLockFields.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-14-000100_AddStudentActiveFlag.php b/app/Database/Migrations/2026-01-14-000100_AddStudentActiveFlag.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-14-000200_CreateClassProgressReports.php b/app/Database/Migrations/2026-01-14-000200_CreateClassProgressReports.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-20-000000_CreateSubjectCurriculumItems.php b/app/Database/Migrations/2026-01-20-000000_CreateSubjectCurriculumItems.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-25-000000_RemoveClassAssignmentSemester.php b/app/Database/Migrations/2026-01-25-000000_RemoveClassAssignmentSemester.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-26-000100_CreateTeacherSubmissionNotificationHistory.php b/app/Database/Migrations/2026-01-26-000100_CreateTeacherSubmissionNotificationHistory.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-27-092000_AddEventTypeToCalendarEvents.php b/app/Database/Migrations/2026-01-27-092000_AddEventTypeToCalendarEvents.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-27-130500_CreateExamDraftSubmissions.php b/app/Database/Migrations/2026-01-27-130500_CreateExamDraftSubmissions.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-27-200000_AddVersionToExamDrafts.php b/app/Database/Migrations/2026-01-27-200000_AddVersionToExamDrafts.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-27-210500_AddFinalPdfToExamDrafts.php b/app/Database/Migrations/2026-01-27-210500_AddFinalPdfToExamDrafts.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-27-211500_AddIsLegacyToExamDrafts.php b/app/Database/Migrations/2026-01-27-211500_AddIsLegacyToExamDrafts.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-27-220000_AddReviewRevisionToExamDrafts.php b/app/Database/Migrations/2026-01-27-220000_AddReviewRevisionToExamDrafts.php new file mode 100755 index 0000000..52427fe --- /dev/null +++ b/app/Database/Migrations/2026-01-27-220000_AddReviewRevisionToExamDrafts.php @@ -0,0 +1,30 @@ +db->fieldExists('review_revision', 'exam_drafts')) { + $this->forge->addColumn('exam_drafts', [ + 'review_revision' => [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => false, + 'default' => 0, + 'after' => 'version', + ], + ]); + } + } + + public function down() + { + if ($this->db->fieldExists('review_revision', 'exam_drafts')) { + $this->forge->dropColumn('exam_drafts', 'review_revision'); + } + } +} diff --git a/app/Database/Migrations/2026-01-28-120000_AddClassSectionIdToScoreComments.php b/app/Database/Migrations/2026-01-28-120000_AddClassSectionIdToScoreComments.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-01-30-000100_CreateParentMeetingSchedules.php b/app/Database/Migrations/2026-01-30-000100_CreateParentMeetingSchedules.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-02-04-000300_CreateClassProgressAttachments.php b/app/Database/Migrations/2026-02-04-000300_CreateClassProgressAttachments.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-02-04-000400_CreatePlacementLevels.php b/app/Database/Migrations/2026-02-04-000400_CreatePlacementLevels.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-02-04-000410_UpdatePlacementLevelRange.php b/app/Database/Migrations/2026-02-04-000410_UpdatePlacementLevelRange.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-02-04-000420_CreatePlacementBatches.php b/app/Database/Migrations/2026-02-04-000420_CreatePlacementBatches.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-02-04-000430_CreatePlacementScores.php b/app/Database/Migrations/2026-02-04-000430_CreatePlacementScores.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-02-04-000440_AddEventOnlyToStudentClass.php b/app/Database/Migrations/2026-02-04-000440_AddEventOnlyToStudentClass.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-02-05-000100_CreateGradingLocks.php b/app/Database/Migrations/2026-02-05-000100_CreateGradingLocks.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-02-05-000200_CreateMissingScoreOverrides.php b/app/Database/Migrations/2026-02-05-000200_CreateMissingScoreOverrides.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-02-05-000300_AddPageSelectionToPrintRequests.php b/app/Database/Migrations/2026-02-05-000300_AddPageSelectionToPrintRequests.php old mode 100644 new mode 100755 index 8af9a6b..70a47e5 --- a/app/Database/Migrations/2026-02-05-000300_AddPageSelectionToPrintRequests.php +++ b/app/Database/Migrations/2026-02-05-000300_AddPageSelectionToPrintRequests.php @@ -1 +1,37 @@ -Print all \ No newline at end of file +db->tableExists('print_requests')) { + return; + } + + if (! $this->db->fieldExists('page_selection', 'print_requests')) { + $this->forge->addColumn('print_requests', [ + 'page_selection' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + 'after' => 'num_copies', + ], + ]); + } + } + + public function down() + { + if (! $this->db->tableExists('print_requests')) { + return; + } + + if ($this->db->fieldExists('page_selection', 'print_requests')) { + $this->forge->dropColumn('print_requests', 'page_selection'); + } + } +} diff --git a/app/Database/Migrations/2026-02-10-000100_AddStyleToPreferences.php b/app/Database/Migrations/2026-02-10-000100_AddStyleToPreferences.php old mode 100644 new mode 100755 diff --git a/app/Database/Migrations/2026-02-16-000001_AddEventPaidFlagToCharges.php b/app/Database/Migrations/2026-02-16-000001_AddEventPaidFlagToCharges.php new file mode 100755 index 0000000..b957885 --- /dev/null +++ b/app/Database/Migrations/2026-02-16-000001_AddEventPaidFlagToCharges.php @@ -0,0 +1,27 @@ + [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + 'after' => 'charged', + ], + ]; + + $this->forge->addColumn('event_charges', $fields); + } + + public function down() + { + $this->forge->dropColumn('event_charges', 'event_paid'); + } +} diff --git a/app/Database/Migrations/2026-02-27-120000_CreateReportCardAcknowledgements.php b/app/Database/Migrations/2026-02-27-120000_CreateReportCardAcknowledgements.php new file mode 100755 index 0000000..54e49f3 --- /dev/null +++ b/app/Database/Migrations/2026-02-27-120000_CreateReportCardAcknowledgements.php @@ -0,0 +1,77 @@ +db->tableExists('report_card_acknowledgements')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'parent_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'student_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'school_year' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + ], + 'semester' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + ], + 'viewed_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'signed_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'signed_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'signer_ip' => [ + 'type' => 'VARCHAR', + 'constraint' => 45, + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addKey(['parent_id', 'student_id', 'school_year', 'semester'], false, true); + $this->forge->createTable('report_card_acknowledgements'); + } + + public function down() + { + $this->forge->dropTable('report_card_acknowledgements', true); + } +} diff --git a/app/Database/Migrations/2026-04-13-000002_AddClassSectionToEventCharges.php b/app/Database/Migrations/2026-04-13-000002_AddClassSectionToEventCharges.php new file mode 100755 index 0000000..b93d2f5 --- /dev/null +++ b/app/Database/Migrations/2026-04-13-000002_AddClassSectionToEventCharges.php @@ -0,0 +1,32 @@ + [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + 'after' => 'event_paid', + ], + ]; + + if (! $this->db->fieldExists('class_section_id', 'event_charges')) { + $this->forge->addColumn('event_charges', $fields); + } + } + + public function down() + { + if ($this->db->fieldExists('class_section_id', 'event_charges')) { + $this->forge->dropColumn('event_charges', 'class_section_id'); + } + } +} diff --git a/app/Database/Migrations/2026-04-13-000003_AddEventPaymentRefToEventCharges.php b/app/Database/Migrations/2026-04-13-000003_AddEventPaymentRefToEventCharges.php new file mode 100755 index 0000000..8853af3 --- /dev/null +++ b/app/Database/Migrations/2026-04-13-000003_AddEventPaymentRefToEventCharges.php @@ -0,0 +1,32 @@ + [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + 'after' => 'class_section_id', + ], + ]; + + if (! $this->db->fieldExists('event_payment_id', 'event_charges')) { + $this->forge->addColumn('event_charges', $fields); + } + } + + public function down() + { + if ($this->db->fieldExists('event_payment_id', 'event_charges')) { + $this->forge->dropColumn('event_charges', 'event_payment_id'); + } + } +} diff --git a/app/Database/Migrations/2026-04-13-000004_AddExternalParticipantFieldsToEventCharges.php b/app/Database/Migrations/2026-04-13-000004_AddExternalParticipantFieldsToEventCharges.php new file mode 100755 index 0000000..14e346e --- /dev/null +++ b/app/Database/Migrations/2026-04-13-000004_AddExternalParticipantFieldsToEventCharges.php @@ -0,0 +1,43 @@ + [ + 'type' => 'VARCHAR', + 'constraint' => 150, + 'null' => true, + 'after' => 'event_payment_id', + ], + 'external_lastname' => [ + 'type' => 'VARCHAR', + 'constraint' => 150, + 'null' => true, + ], + 'external_note' => [ + 'type' => 'VARCHAR', + 'constraint' => 254, + 'null' => true, + ], + ]; + + if (! $this->db->fieldExists('external_firstname', 'event_charges')) { + $this->forge->addColumn('event_charges', $fields); + } + } + + public function down() + { + foreach (['external_firstname', 'external_lastname', 'external_note'] as $field) { + if ($this->db->fieldExists($field, 'event_charges')) { + $this->forge->dropColumn('event_charges', $field); + } + } + } +} diff --git a/app/Database/Migrations/2026-04-13-000005_AddExternalParentInfoToEventCharges.php b/app/Database/Migrations/2026-04-13-000005_AddExternalParentInfoToEventCharges.php new file mode 100755 index 0000000..6341d01 --- /dev/null +++ b/app/Database/Migrations/2026-04-13-000005_AddExternalParentInfoToEventCharges.php @@ -0,0 +1,43 @@ + [ + 'type' => 'VARCHAR', + 'constraint' => 150, + 'null' => true, + 'after' => 'external_note', + ], + 'external_parent_lastname' => [ + 'type' => 'VARCHAR', + 'constraint' => 150, + 'null' => true, + ], + 'external_parent_phone' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + 'null' => true, + ], + ]; + + if (! $this->db->fieldExists('external_parent_firstname', 'event_charges')) { + $this->forge->addColumn('event_charges', $fields); + } + } + + public function down() + { + foreach (['external_parent_firstname', 'external_parent_lastname', 'external_parent_phone'] as $field) { + if ($this->db->fieldExists($field, 'event_charges')) { + $this->forge->dropColumn('event_charges', $field); + } + } + } +} diff --git a/app/Database/Migrations/2026-04-17-000001_AddExternalParentEmailToEventCharges.php b/app/Database/Migrations/2026-04-17-000001_AddExternalParentEmailToEventCharges.php new file mode 100755 index 0000000..0e09fca --- /dev/null +++ b/app/Database/Migrations/2026-04-17-000001_AddExternalParentEmailToEventCharges.php @@ -0,0 +1,32 @@ + [ + 'type' => 'VARCHAR', + 'constraint' => 254, + 'null' => true, + 'after' => 'external_parent_phone', + ], + ]; + + if (! $this->db->fieldExists('external_parent_email', 'event_charges')) { + $this->forge->addColumn('event_charges', $fields); + } + } + + public function down() + { + if ($this->db->fieldExists('external_parent_email', 'event_charges')) { + $this->forge->dropColumn('event_charges', 'external_parent_email'); + } + } +} + diff --git a/app/Database/Migrations/2026-04-19-000001_AddWaiverSignedToEventCharges.php b/app/Database/Migrations/2026-04-19-000001_AddWaiverSignedToEventCharges.php new file mode 100755 index 0000000..26e5ac4 --- /dev/null +++ b/app/Database/Migrations/2026-04-19-000001_AddWaiverSignedToEventCharges.php @@ -0,0 +1,31 @@ + [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + 'after' => 'charged', + ], + ]; + + if (! $this->db->fieldExists('waiver_signed', 'event_charges')) { + $this->forge->addColumn('event_charges', $fields); + } + } + + public function down() + { + if ($this->db->fieldExists('waiver_signed', 'event_charges')) { + $this->forge->dropColumn('event_charges', 'waiver_signed'); + } + } +} diff --git a/app/Database/Migrations/ExtendStaffAttendanceForTeachers.php b/app/Database/Migrations/ExtendStaffAttendanceForTeachers.php old mode 100644 new mode 100755 diff --git a/app/Database/Seeds/.gitkeep b/app/Database/Seeds/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/Database/Seeds/FamiliesBootstrapSeeder.txt b/app/Database/Seeds/FamiliesBootstrapSeeder.txt old mode 100644 new mode 100755 diff --git a/app/Database/Seeds/FamiliesRebuildSeeder.php b/app/Database/Seeds/FamiliesRebuildSeeder.php old mode 100644 new mode 100755 diff --git a/app/Database/Seeds/NavSeeder.php b/app/Database/Seeds/NavSeeder.php old mode 100644 new mode 100755 diff --git a/app/Database/Seeds/SubjectCurriculumSeeder.php b/app/Database/Seeds/SubjectCurriculumSeeder.php old mode 100644 new mode 100755 diff --git a/app/Events/DeleteUnverifiedUser.php b/app/Events/DeleteUnverifiedUser.php old mode 100644 new mode 100755 diff --git a/app/Filters/.gitkeep b/app/Filters/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/Filters/ApiAuthFilter.php b/app/Filters/ApiAuthFilter.php old mode 100644 new mode 100755 diff --git a/app/Filters/ApiDocsAuthFilter.php b/app/Filters/ApiDocsAuthFilter.php old mode 100644 new mode 100755 diff --git a/app/Filters/AuthFilter.php b/app/Filters/AuthFilter.php old mode 100644 new mode 100755 index f3fcae6..0bc85f3 --- a/app/Filters/AuthFilter.php +++ b/app/Filters/AuthFilter.php @@ -41,7 +41,13 @@ class AuthFilter implements FilterInterface // Must be logged in if (empty($userRoles) || empty($userId)) { - return redirect()->to('/login'); + $target = ltrim($request->getUri()->getPath(), '/'); + $query = $request->getUri()->getQuery(); + if ($query !== '') { + $target .= '?' . $query; + } + + return redirect()->to('/login?redirect_to=' . rawurlencode('/' . ltrim($target, '/'))); } // Enforce 12-hour session lifetime @@ -110,7 +116,14 @@ class AuthFilter implements FilterInterface ]); } - return redirect()->to('/login')->with('error', 'Your session has expired. Please log in again.'); + $target = ltrim($request->getUri()->getPath(), '/'); + $query = $request->getUri()->getQuery(); + if ($query !== '') { + $target .= '?' . $query; + } + + return redirect()->to('/login?redirect_to=' . rawurlencode('/' . ltrim($target, '/'))) + ->with('error', 'Your session has expired. Please log in again.'); } public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) diff --git a/app/Filters/CleanupScheduler.php b/app/Filters/CleanupScheduler.php old mode 100644 new mode 100755 diff --git a/app/Filters/PermissionFilter.php b/app/Filters/PermissionFilter.php old mode 100644 new mode 100755 diff --git a/app/Filters/TimezoneFilter.php b/app/Filters/TimezoneFilter.php old mode 100644 new mode 100755 diff --git a/app/Helpers/.gitkeep b/app/Helpers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/Helpers/AuthHelper.php b/app/Helpers/AuthHelper.php old mode 100644 new mode 100755 diff --git a/app/Helpers/DocumentHelper.php b/app/Helpers/DocumentHelper.php deleted file mode 100644 index e69de29..0000000 diff --git a/app/Helpers/GlobalConfigHelper.php b/app/Helpers/GlobalConfigHelper.php old mode 100644 new mode 100755 diff --git a/app/Helpers/attendance_comment_helper.php b/app/Helpers/attendance_comment_helper.php old mode 100644 new mode 100755 diff --git a/app/Helpers/auth_helper.php b/app/Helpers/auth_helper.php new file mode 100755 index 0000000..0315b27 --- /dev/null +++ b/app/Helpers/auth_helper.php @@ -0,0 +1,3 @@ + utc_now(), ]; - $html = view('emails/below_sixty_performance', $emailData, ['saveData' => true]); + $html = trim((string)($payload['html'] ?? '')); + if ($html === '') { + $html = view('emails/below_sixty_performance', $emailData, ['saveData' => true]); + } $okAny = false; foreach ($emails as $to) { diff --git a/app/Listeners/SchoolEventListener.php b/app/Listeners/SchoolEventListener.php old mode 100644 new mode 100755 diff --git a/app/Listeners/WhatsappInviteListener.php b/app/Listeners/WhatsappInviteListener.php old mode 100644 new mode 100755 diff --git a/app/Models/.gitkeep b/app/Models/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/Models/AdditionalChargeModel.php b/app/Models/AdditionalChargeModel.php old mode 100644 new mode 100755 diff --git a/app/Models/AdminNotificationSubjectModel.php b/app/Models/AdminNotificationSubjectModel.php old mode 100644 new mode 100755 diff --git a/app/Models/AttendanceCommentTemplateModel.php b/app/Models/AttendanceCommentTemplateModel.php old mode 100644 new mode 100755 diff --git a/app/Models/AttendanceDataModel.php b/app/Models/AttendanceDataModel.php old mode 100644 new mode 100755 diff --git a/app/Models/AttendanceDayModel.php b/app/Models/AttendanceDayModel.php old mode 100644 new mode 100755 diff --git a/app/Models/AttendanceEmailTemplateModel.php b/app/Models/AttendanceEmailTemplateModel.php old mode 100644 new mode 100755 diff --git a/app/Models/AttendanceRecordModel.php b/app/Models/AttendanceRecordModel.php old mode 100644 new mode 100755 diff --git a/app/Models/AttendanceTrackingModel.php b/app/Models/AttendanceTrackingModel.php old mode 100644 new mode 100755 diff --git a/app/Models/AuthorizedUserModel.php b/app/Models/AuthorizedUserModel.php old mode 100644 new mode 100755 diff --git a/app/Models/BadgePrintLogModel.php b/app/Models/BadgePrintLogModel.php old mode 100644 new mode 100755 diff --git a/app/Models/CalendarModel.php b/app/Models/CalendarModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ClassModel.php b/app/Models/ClassModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ClassPrepAdjustmentModel.php b/app/Models/ClassPrepAdjustmentModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ClassPreparationLogModel.php b/app/Models/ClassPreparationLogModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ClassProgressAttachmentModel.php b/app/Models/ClassProgressAttachmentModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ClassProgressReportModel.php b/app/Models/ClassProgressReportModel.php old mode 100644 new mode 100755 index f7c0285..d5ad154 --- a/app/Models/ClassProgressReportModel.php +++ b/app/Models/ClassProgressReportModel.php @@ -4,6 +4,15 @@ namespace App\Models; use CodeIgniter\Model; +/** + * Weekly class progress reports (one row per subject per week). + * + * unit_title stores a compact summary of unit/chapter rows: segments joined with " ; ". + * Teacher-entered custom Islamic or Quran topics use the prefix "Custom / {text}" + * (see {@see \App\Controllers\ClassProgressController::CUSTOM_UNIT_ROW_LABEL} and + * {@see \App\Controllers\ClassProgressController::splitUnitTitleForDisplay()}). + * DB column is VARCHAR(120); controller truncates to match. + */ class ClassProgressReportModel extends Model { protected $table = 'class_progress_reports'; @@ -27,7 +36,42 @@ class ClassProgressReportModel extends Model 'flags_json', 'attachment_path', ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; protected $updatedField = 'updated_at'; + + /** + * Validation is performed in {@see \App\Controllers\ClassProgressController} on HTTP input. + * Rules below document schema limits and can be enabled if you set {@see $skipValidation} to false. + */ + protected $skipValidation = true; + + protected $validationRules = [ + 'class_section_id' => 'required|integer', + 'teacher_id' => 'required|integer', + 'week_start' => 'required|valid_date[Y-m-d]', + 'week_end' => 'required|valid_date[Y-m-d]', + 'subject' => 'required|string|max_length[160]', + 'unit_title' => 'permit_empty|string|max_length[120]', + 'covered' => 'permit_empty|string', + 'homework' => 'permit_empty|string', + 'assessment' => 'permit_empty|string', + 'status' => 'permit_empty|in_list[on_track,slightly_behind,behind]', + 'status_notes' => 'permit_empty|string|max_length[200]', + 'class_notes' => 'permit_empty|string', + 'next_week_plan' => 'permit_empty|string', + 'support_needed' => 'permit_empty|string', + 'flags_json' => 'permit_empty|string', + 'attachment_path' => 'permit_empty|string|max_length[255]', + ]; + + protected $validationMessages = [ + 'unit_title' => [ + 'max_length' => 'Unit and chapter summary cannot exceed 120 characters.', + ], + 'subject' => [ + 'max_length' => 'Subject cannot exceed 160 characters.', + ], + ]; } diff --git a/app/Models/ClassSectionModel.php b/app/Models/ClassSectionModel.php old mode 100644 new mode 100755 diff --git a/app/Models/CommunicationLogModel.php b/app/Models/CommunicationLogModel.php old mode 100644 new mode 100755 diff --git a/app/Models/CompetitionClassWinnerModel.php b/app/Models/CompetitionClassWinnerModel.php old mode 100644 new mode 100755 diff --git a/app/Models/CompetitionModel.php b/app/Models/CompetitionModel.php old mode 100644 new mode 100755 diff --git a/app/Models/CompetitionScoreModel.php b/app/Models/CompetitionScoreModel.php old mode 100644 new mode 100755 diff --git a/app/Models/CompetitionWinnerModel.php b/app/Models/CompetitionWinnerModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ConfigurationModel.php b/app/Models/ConfigurationModel.php old mode 100644 new mode 100755 index 86ca89b..0a6463d --- a/app/Models/ConfigurationModel.php +++ b/app/Models/ConfigurationModel.php @@ -22,11 +22,13 @@ class ConfigurationModel extends Model */ public function getConfigValueByKey(string $key) { - // Deterministic read in case historical duplicates exist - $result = $this->where('config_key', $key) + // Use a fresh builder to avoid stale state from shared model builder. + $builder = $this->db->table($this->table); + $result = $builder->where('config_key', $key) ->orderBy('id', 'DESC') - ->first(); - return $result ? $result['config_value'] : null; + ->get(1) + ->getRowArray(); + return $result['config_value'] ?? null; } /** diff --git a/app/Models/ContactUsModel.php b/app/Models/ContactUsModel.php old mode 100644 new mode 100755 diff --git a/app/Models/CurrentFlagModel.php b/app/Models/CurrentFlagModel.php old mode 100644 new mode 100755 diff --git a/app/Models/DiscountUsageModel.php b/app/Models/DiscountUsageModel.php old mode 100644 new mode 100755 diff --git a/app/Models/DiscountVoucherModel.php b/app/Models/DiscountVoucherModel.php old mode 100644 new mode 100755 diff --git a/app/Models/EarlyDismissalSignatureModel.php b/app/Models/EarlyDismissalSignatureModel.php old mode 100644 new mode 100755 diff --git a/app/Models/EmailTemplateModel.php b/app/Models/EmailTemplateModel.php old mode 100644 new mode 100755 diff --git a/app/Models/EmergencyContactModel.php b/app/Models/EmergencyContactModel.php old mode 100644 new mode 100755 diff --git a/app/Models/EnrollmentModel.php b/app/Models/EnrollmentModel.php old mode 100644 new mode 100755 diff --git a/app/Models/EventChargesModel.php b/app/Models/EventChargesModel.php old mode 100644 new mode 100755 index 096c903..e0a61fc --- a/app/Models/EventChargesModel.php +++ b/app/Models/EventChargesModel.php @@ -15,8 +15,20 @@ class EventChargesModel extends Model 'student_id', 'participation', 'charged', + 'waiver_signed', + 'event_paid', + 'event_payment_id', + 'class_section_id', + 'external_firstname', + 'external_lastname', + 'external_note', + 'external_parent_firstname', + 'external_parent_lastname', + 'external_parent_phone', + 'external_parent_email', 'semester', 'school_year', + 'created_by', 'updated_by', 'created_at', 'updated_at' @@ -30,7 +42,7 @@ class EventChargesModel extends Model public function getChargesWithEventInfo($parentId = null, $schoolYear = null, $semester = null) { - $builder = $this->select('event_charges.*, events.event_name, events.amount') + $builder = $this->select('event_charges.*, events.event_name, events.amount AS event_amount, events.description AS event_description') ->join('events', 'events.id = event_charges.event_id', 'left'); if ($parentId) { diff --git a/app/Models/EventModel.php b/app/Models/EventModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ExamDraftModel.php b/app/Models/ExamDraftModel.php old mode 100644 new mode 100755 index 1044245..bf96c84 --- a/app/Models/ExamDraftModel.php +++ b/app/Models/ExamDraftModel.php @@ -8,19 +8,51 @@ class ExamDraftModel extends Model { protected $table = 'exam_drafts'; protected $primaryKey = 'id'; + protected $returnType = 'array'; + protected $useTimestamps = true; + protected $createdField = 'created_at'; + protected $updatedField = 'updated_at'; + protected bool $updateOnlyChanged = false; + + /** @var list Stored in `status` column */ + public const STATUSES = [ + 'submitted', + 'accepted', + 'review needed', + 'rejected', + 'canceled', + 'under review', + 'legacy', + ]; + + /** @var list Stored in `acceptance_type` when status is finalized */ + public const ACCEPTANCE_TYPES = [ + 'as_is', + 'minor_edits', + ]; + protected $allowedFields = [ 'teacher_id', + 'author_id', 'class_section_id', 'semester', 'school_year', 'exam_type', 'draft_title', + 'author_comment', 'description', 'teacher_file', 'teacher_filename', + 'author_file', + 'author_filename', 'status', + 'acceptance_type', + 'review_revision', + 'reviewer_id', 'admin_id', 'is_legacy', + 'reviewer_comment', + 'reviewer_comments', 'admin_comments', 'reviewed_at', 'final_file', @@ -29,9 +61,40 @@ class ExamDraftModel extends Model 'version', 'previous_draft_id', ]; - protected $returnType = 'array'; - protected $useTimestamps = true; - protected $createdField = 'created_at'; - protected $updatedField = 'updated_at'; - protected bool $updateOnlyChanged = false; // force updates even if CI thinks nothing changed + + /** + * Applied on insert/update when validation runs (e.g. $model->insert($data, true)). + * Uses if_exist so partial updates still validate only keys present in $data. + */ + protected $validationRules = [ + 'status' => 'if_exist|in_list[submitted,accepted,review needed,rejected,canceled,under review,legacy]', + 'acceptance_type' => 'if_exist|permit_empty|in_list[as_is,minor_edits]', + 'author_id' => 'if_exist|is_natural_no_zero', + 'class_section_id' => 'if_exist|is_natural_no_zero', + 'version' => 'if_exist|is_natural_no_zero', + ]; + + protected $validationMessages = [ + 'status' => [ + 'in_list' => 'Invalid exam draft status.', + ], + 'acceptance_type' => [ + 'in_list' => 'Acceptance must be as_is or minor_edits.', + ], + ]; + + /** + * @var array + */ + protected array $casts = [ + 'teacher_id' => 'int', + 'author_id' => 'int', + 'class_section_id' => 'int', + 'reviewer_id' => '?int', + 'admin_id' => '?int', + 'review_revision' => 'int', + 'version' => 'int', + 'previous_draft_id' => '?int', + 'is_legacy' => 'boolean', + ]; } diff --git a/app/Models/ExamModel.php b/app/Models/ExamModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ExpenseModel.php b/app/Models/ExpenseModel.php old mode 100644 new mode 100755 diff --git a/app/Models/FamilyCommPrefModel.php b/app/Models/FamilyCommPrefModel.php old mode 100644 new mode 100755 diff --git a/app/Models/FamilyGuardianModel.php b/app/Models/FamilyGuardianModel.php old mode 100644 new mode 100755 diff --git a/app/Models/FamilyModel.php b/app/Models/FamilyModel.php old mode 100644 new mode 100755 diff --git a/app/Models/FamilyStudentModel.php b/app/Models/FamilyStudentModel.php old mode 100644 new mode 100755 diff --git a/app/Models/FinalExamModel.php b/app/Models/FinalExamModel.php old mode 100644 new mode 100755 diff --git a/app/Models/FinalScoreModel.php b/app/Models/FinalScoreModel.php old mode 100644 new mode 100755 diff --git a/app/Models/FlagModel.php b/app/Models/FlagModel.php old mode 100644 new mode 100755 diff --git a/app/Models/GradingLockModel.php b/app/Models/GradingLockModel.php old mode 100644 new mode 100755 diff --git a/app/Models/HomeworkModel.php b/app/Models/HomeworkModel.php old mode 100644 new mode 100755 diff --git a/app/Models/InventoryCategoryModel.php b/app/Models/InventoryCategoryModel.php old mode 100644 new mode 100755 diff --git a/app/Models/InventoryItemModel.php b/app/Models/InventoryItemModel.php old mode 100644 new mode 100755 diff --git a/app/Models/InventoryMovementModel.php b/app/Models/InventoryMovementModel.php old mode 100644 new mode 100755 diff --git a/app/Models/InvoiceEventModel.php b/app/Models/InvoiceEventModel.php old mode 100644 new mode 100755 diff --git a/app/Models/InvoiceModel.php b/app/Models/InvoiceModel.php old mode 100644 new mode 100755 diff --git a/app/Models/InvoiceStudentListModel.php b/app/Models/InvoiceStudentListModel.php old mode 100644 new mode 100755 diff --git a/app/Models/IpAttemptModel.php b/app/Models/IpAttemptModel.php old mode 100644 new mode 100755 diff --git a/app/Models/LateSlipLogModel.php b/app/Models/LateSlipLogModel.php old mode 100644 new mode 100755 diff --git a/app/Models/LoginActivityModel.php b/app/Models/LoginActivityModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ManualPaymentModel.php b/app/Models/ManualPaymentModel.php old mode 100644 new mode 100755 diff --git a/app/Models/MessageModel.php b/app/Models/MessageModel.php old mode 100644 new mode 100755 diff --git a/app/Models/MidtermExamModel.php b/app/Models/MidtermExamModel.php old mode 100644 new mode 100755 diff --git a/app/Models/MissingScoreOverrideModel.php b/app/Models/MissingScoreOverrideModel.php old mode 100644 new mode 100755 diff --git a/app/Models/NavItemModel.php b/app/Models/NavItemModel.php old mode 100644 new mode 100755 diff --git a/app/Models/NotificationModel.php b/app/Models/NotificationModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ParentAttendanceReportModel.php b/app/Models/ParentAttendanceReportModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ParentMeetingScheduleModel.php b/app/Models/ParentMeetingScheduleModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ParentModel.php b/app/Models/ParentModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ParentNotificationModel.php b/app/Models/ParentNotificationModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ParticipationModel.php b/app/Models/ParticipationModel.php old mode 100644 new mode 100755 diff --git a/app/Models/PasswordResetModel.php b/app/Models/PasswordResetModel.php old mode 100644 new mode 100755 diff --git a/app/Models/PasswordResetRequestModel.php b/app/Models/PasswordResetRequestModel.php old mode 100644 new mode 100755 diff --git a/app/Models/PayPalPaymentModel.php b/app/Models/PayPalPaymentModel.php old mode 100644 new mode 100755 diff --git a/app/Models/PaymentErrorModel.php b/app/Models/PaymentErrorModel.php old mode 100644 new mode 100755 diff --git a/app/Models/PaymentModel.php b/app/Models/PaymentModel.php old mode 100644 new mode 100755 diff --git a/app/Models/PaymentNotificationLogModel.php b/app/Models/PaymentNotificationLogModel.php old mode 100644 new mode 100755 diff --git a/app/Models/PaymentTransactionModel.php b/app/Models/PaymentTransactionModel.php old mode 100644 new mode 100755 diff --git a/app/Models/PaypalTransactionModel.php b/app/Models/PaypalTransactionModel.php old mode 100644 new mode 100755 diff --git a/app/Models/PermissionModel.php b/app/Models/PermissionModel.php old mode 100644 new mode 100755 diff --git a/app/Models/PlacementBatchModel.php b/app/Models/PlacementBatchModel.php old mode 100644 new mode 100755 diff --git a/app/Models/PlacementLevelModel.php b/app/Models/PlacementLevelModel.php old mode 100644 new mode 100755 diff --git a/app/Models/PlacementScoreModel.php b/app/Models/PlacementScoreModel.php old mode 100644 new mode 100755 diff --git a/app/Models/PreferencesModel.php b/app/Models/PreferencesModel.php old mode 100644 new mode 100755 diff --git a/app/Models/PrintRequestModel.php b/app/Models/PrintRequestModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ProjectModel.php b/app/Models/ProjectModel.php old mode 100644 new mode 100755 diff --git a/app/Models/PromotionQueueModel.php b/app/Models/PromotionQueueModel.php old mode 100644 new mode 100755 diff --git a/app/Models/PurchaseOrderItemModel.php b/app/Models/PurchaseOrderItemModel.php old mode 100644 new mode 100755 diff --git a/app/Models/PurchaseOrderModel.php b/app/Models/PurchaseOrderModel.php old mode 100644 new mode 100755 diff --git a/app/Models/QuizModel.php b/app/Models/QuizModel.php old mode 100644 new mode 100755 diff --git a/app/Models/RefundModel.php b/app/Models/RefundModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ReimbursementBatchAdminFileModel.php b/app/Models/ReimbursementBatchAdminFileModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ReimbursementBatchItemModel.php b/app/Models/ReimbursementBatchItemModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ReimbursementBatchModel.php b/app/Models/ReimbursementBatchModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ReimbursementModel.php b/app/Models/ReimbursementModel.php old mode 100644 new mode 100755 diff --git a/app/Models/ReportCardAcknowledgementModel.php b/app/Models/ReportCardAcknowledgementModel.php new file mode 100755 index 0000000..0778bc1 --- /dev/null +++ b/app/Models/ReportCardAcknowledgementModel.php @@ -0,0 +1,27 @@ +where('class_id', $classId) ->where('subject', $subject) ->orderBy('unit_number', 'ASC') + ->orderBy("CAST(SUBSTRING_INDEX(chapter_name, '.', 1) AS UNSIGNED)", 'ASC', false) ->orderBy('chapter_name', 'ASC') ->findAll(); } diff --git a/app/Models/SupplyCategoryModel.php b/app/Models/SupplyCategoryModel.php deleted file mode 100644 index e69de29..0000000 diff --git a/app/Models/TeacherClassModel.php b/app/Models/TeacherClassModel.php old mode 100644 new mode 100755 index d9be96b..d80db34 --- a/app/Models/TeacherClassModel.php +++ b/app/Models/TeacherClassModel.php @@ -71,7 +71,7 @@ class TeacherClassModel extends Model $builder = $this->db->table('teacher_class tc') ->select([ 'cs.class_section_name', - 'cs.id AS class_section_pk', + 'cs.class_section_id AS class_section_pk', 'cs.class_section_id', 'c.id AS class_id', 'c.class_name', @@ -181,10 +181,10 @@ class TeacherClassModel extends Model { $db = \Config\Database::connect(); - // Adjust table/column names if yours differ (e.g., cs.id vs cs.class_section_id) + // Adjust table/column names if yours differ (e.g., cs.class_section_id vs cs.id) $row = $db->table('classSection cs') ->select('u.id AS teacher_id, u.firstname, u.lastname') - ->join('teacher_class tc', 'tc.class_section_id = cs.id', 'inner') + ->join('teacher_class tc', 'tc.class_section_id = cs.class_section_id', 'inner') ->join('users u', 'u.id = tc.teacher_id', 'inner') ->where('cs.class_section_name', $classSectionName) ->where('tc.school_year', $schoolYear) diff --git a/app/Models/TeacherModel.php b/app/Models/TeacherModel.php old mode 100644 new mode 100755 index 5194edd..7fb7520 --- a/app/Models/TeacherModel.php +++ b/app/Models/TeacherModel.php @@ -34,11 +34,11 @@ class TeacherModel extends Model public function getTeachersAndTAs(): array { return $this->db->table('users u') - ->select('u.id, u.firstname, u.lastname, u.email, u.cellphone, r.name as role') + ->select('u.id, u.firstname, u.lastname, u.email, u.cellphone, MIN(r.name) as role') ->join('user_roles ur', 'ur.user_id = u.id') ->join('roles r', 'r.id = ur.role_id') ->whereIn('r.name', ['teacher', 'teacher_assistant']) // ✅ Filter only relevant roles - ->groupBy('u.id') + ->groupBy('u.id, u.firstname, u.lastname, u.email, u.cellphone') ->orderBy('u.lastname', 'ASC') ->get() ->getResultArray(); @@ -72,4 +72,4 @@ public function getTeachersAndTAs(): array ->get() ->getResultArray(); } -} \ No newline at end of file +} diff --git a/app/Models/TeacherSubmissionNotificationHistoryModel.php b/app/Models/TeacherSubmissionNotificationHistoryModel.php old mode 100644 new mode 100755 diff --git a/app/Models/UserModel.php b/app/Models/UserModel.php old mode 100644 new mode 100755 diff --git a/app/Models/UserNotificationModel.php b/app/Models/UserNotificationModel.php old mode 100644 new mode 100755 diff --git a/app/Models/UserRoleModel.php b/app/Models/UserRoleModel.php old mode 100644 new mode 100755 diff --git a/app/Models/WhatsappGroupLinkModel.php b/app/Models/WhatsappGroupLinkModel.php old mode 100644 new mode 100755 diff --git a/app/Models/WhatsappGroupMembershipModel.php b/app/Models/WhatsappGroupMembershipModel.php old mode 100644 new mode 100755 diff --git a/app/Models/WhatsappInviteLogModel.php b/app/Models/WhatsappInviteLogModel.php old mode 100644 new mode 100755 diff --git a/app/Services/ApiClient.php b/app/Services/ApiClient.php old mode 100644 new mode 100755 diff --git a/app/Services/Calculators/AttendanceCalculator.php b/app/Services/Calculators/AttendanceCalculator.php old mode 100644 new mode 100755 diff --git a/app/Services/Calculators/HomeworkCalculator.php b/app/Services/Calculators/HomeworkCalculator.php old mode 100644 new mode 100755 diff --git a/app/Services/Calculators/ProjectCalculator.php b/app/Services/Calculators/ProjectCalculator.php old mode 100644 new mode 100755 diff --git a/app/Services/Calculators/QuizCalculator.php b/app/Services/Calculators/QuizCalculator.php old mode 100644 new mode 100755 diff --git a/app/Services/EmailService.php b/app/Services/EmailService.php old mode 100644 new mode 100755 index 3a06d43..c37ae2b --- a/app/Services/EmailService.php +++ b/app/Services/EmailService.php @@ -8,6 +8,7 @@ class EmailService { protected array $senders; protected EmailController $mailer; + protected int $lastCcBatchCount = 0; public function __construct() { @@ -73,6 +74,45 @@ class EmailService return $allOk; } + /** + * Send one email with all recipients in CC. + */ + public function sendCc(array $ccList, string $subject, string $message, string $fromKey = 'general', array $attachments = [], array $headers = []): bool + { + $this->lastCcBatchCount = 0; + + if (empty($ccList)) { + return true; + } + + $sender = $this->getSenderDetails($fromKey); + $to = trim((string) ($sender['email'] ?? '')); + if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) { + $fallback = trim((string) (getenv('SMTP_USER') ?: '')); + $to = filter_var($fallback, FILTER_VALIDATE_EMAIL) ? $fallback : 'no-reply@alrahmaisgl.org'; + } + + $attachmentsPayload = $this->normalizeAttachments($attachments); + $chunks = array_chunk(array_values($ccList), 10); + $this->lastCcBatchCount = count($chunks); + $allOk = true; + + foreach ($chunks as $index => $chunk) { + $ok = $this->mailer->sendEmailWithCc($to, $chunk, $subject, $message, $fromKey, null, null, $attachmentsPayload); + if (!$ok) { + $allOk = false; + log_message('error', 'EmailService::sendCc failed for batch ' . ($index + 1) . ' of ' . count($chunks)); + } + } + + return $allOk; + } + + public function getLastCcBatchCount(): int + { + return $this->lastCcBatchCount; + } + /** * Normalize various attachment inputs to the format expected by EmailController. */ diff --git a/app/Services/FeeCalculationService.php b/app/Services/FeeCalculationService.php old mode 100644 new mode 100755 diff --git a/app/Services/NavbarService.php b/app/Services/NavbarService.php old mode 100644 new mode 100755 diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php old mode 100644 new mode 100755 diff --git a/app/Services/PhoneFormatterService.php b/app/Services/PhoneFormatterService.php old mode 100644 new mode 100755 diff --git a/app/Services/RoleService.php b/app/Services/RoleService.php old mode 100644 new mode 100755 diff --git a/app/Services/SchoolIdService.php b/app/Services/SchoolIdService.php old mode 100644 new mode 100755 diff --git a/app/Services/SemesterRangeService.php b/app/Services/SemesterRangeService.php old mode 100644 new mode 100755 diff --git a/app/Services/SemesterScoreService.php b/app/Services/SemesterScoreService.php old mode 100644 new mode 100755 diff --git a/app/Services/TimeService.php b/app/Services/TimeService.php old mode 100644 new mode 100755 index d805bbb..15cd02c --- a/app/Services/TimeService.php +++ b/app/Services/TimeService.php @@ -167,8 +167,13 @@ class TimeService return null; } - $sourceTz = $sourceTz ?: $this->serverTimezone; $targetTz = $targetTz ?: $this->userTimezone(); + if ($sourceTz === null && $this->isDateOnlyString($value)) { + // Date-only strings should not shift across timezones. + $sourceTz = $targetTz; + } else { + $sourceTz = $sourceTz ?: $this->serverTimezone; + } try { if ($value instanceof Time) { @@ -204,4 +209,14 @@ class TimeService { return (string) ($this->toUTC($value, $fromTz, $format) ?? ''); } + + private function isDateOnlyString($value): bool + { + if (!is_string($value)) { + return false; + } + + $value = trim($value); + return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $value); + } } diff --git a/app/Services/UserEventService.php b/app/Services/UserEventService.php old mode 100644 new mode 100755 diff --git a/app/Services/UserService.php b/app/Services/UserService.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/.gitkeep b/app/ThirdParty/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/ThirdParty/fpdf/FAQ.htm b/app/ThirdParty/fpdf/FAQ.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/changelog.htm b/app/ThirdParty/fpdf/changelog.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/__construct.htm b/app/ThirdParty/fpdf/doc/__construct.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/acceptpagebreak.htm b/app/ThirdParty/fpdf/doc/acceptpagebreak.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/addfont.htm b/app/ThirdParty/fpdf/doc/addfont.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/addlink.htm b/app/ThirdParty/fpdf/doc/addlink.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/addpage.htm b/app/ThirdParty/fpdf/doc/addpage.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/aliasnbpages.htm b/app/ThirdParty/fpdf/doc/aliasnbpages.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/cell.htm b/app/ThirdParty/fpdf/doc/cell.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/close.htm b/app/ThirdParty/fpdf/doc/close.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/error.htm b/app/ThirdParty/fpdf/doc/error.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/footer.htm b/app/ThirdParty/fpdf/doc/footer.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/getpageheight.htm b/app/ThirdParty/fpdf/doc/getpageheight.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/getpagewidth.htm b/app/ThirdParty/fpdf/doc/getpagewidth.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/getstringwidth.htm b/app/ThirdParty/fpdf/doc/getstringwidth.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/getx.htm b/app/ThirdParty/fpdf/doc/getx.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/gety.htm b/app/ThirdParty/fpdf/doc/gety.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/header.htm b/app/ThirdParty/fpdf/doc/header.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/image.htm b/app/ThirdParty/fpdf/doc/image.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/index.htm b/app/ThirdParty/fpdf/doc/index.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/line.htm b/app/ThirdParty/fpdf/doc/line.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/link.htm b/app/ThirdParty/fpdf/doc/link.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/ln.htm b/app/ThirdParty/fpdf/doc/ln.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/multicell.htm b/app/ThirdParty/fpdf/doc/multicell.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/output.htm b/app/ThirdParty/fpdf/doc/output.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/pageno.htm b/app/ThirdParty/fpdf/doc/pageno.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/rect.htm b/app/ThirdParty/fpdf/doc/rect.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/setauthor.htm b/app/ThirdParty/fpdf/doc/setauthor.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/setautopagebreak.htm b/app/ThirdParty/fpdf/doc/setautopagebreak.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/setcompression.htm b/app/ThirdParty/fpdf/doc/setcompression.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/setcreator.htm b/app/ThirdParty/fpdf/doc/setcreator.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/setdisplaymode.htm b/app/ThirdParty/fpdf/doc/setdisplaymode.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/setdrawcolor.htm b/app/ThirdParty/fpdf/doc/setdrawcolor.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/setfillcolor.htm b/app/ThirdParty/fpdf/doc/setfillcolor.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/setfont.htm b/app/ThirdParty/fpdf/doc/setfont.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/setfontsize.htm b/app/ThirdParty/fpdf/doc/setfontsize.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/setkeywords.htm b/app/ThirdParty/fpdf/doc/setkeywords.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/setleftmargin.htm b/app/ThirdParty/fpdf/doc/setleftmargin.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/setlinewidth.htm b/app/ThirdParty/fpdf/doc/setlinewidth.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/setlink.htm b/app/ThirdParty/fpdf/doc/setlink.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/setmargins.htm b/app/ThirdParty/fpdf/doc/setmargins.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/setrightmargin.htm b/app/ThirdParty/fpdf/doc/setrightmargin.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/setsubject.htm b/app/ThirdParty/fpdf/doc/setsubject.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/settextcolor.htm b/app/ThirdParty/fpdf/doc/settextcolor.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/settitle.htm b/app/ThirdParty/fpdf/doc/settitle.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/settopmargin.htm b/app/ThirdParty/fpdf/doc/settopmargin.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/setx.htm b/app/ThirdParty/fpdf/doc/setx.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/setxy.htm b/app/ThirdParty/fpdf/doc/setxy.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/sety.htm b/app/ThirdParty/fpdf/doc/sety.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/text.htm b/app/ThirdParty/fpdf/doc/text.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/doc/write.htm b/app/ThirdParty/fpdf/doc/write.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/font/courier.php b/app/ThirdParty/fpdf/font/courier.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/font/courierb.php b/app/ThirdParty/fpdf/font/courierb.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/font/courierbi.php b/app/ThirdParty/fpdf/font/courierbi.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/font/courieri.php b/app/ThirdParty/fpdf/font/courieri.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/font/helvetica.php b/app/ThirdParty/fpdf/font/helvetica.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/font/helveticab.php b/app/ThirdParty/fpdf/font/helveticab.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/font/helveticabi.php b/app/ThirdParty/fpdf/font/helveticabi.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/font/helveticai.php b/app/ThirdParty/fpdf/font/helveticai.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/font/symbol.php b/app/ThirdParty/fpdf/font/symbol.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/font/times.php b/app/ThirdParty/fpdf/font/times.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/font/timesb.php b/app/ThirdParty/fpdf/font/timesb.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/font/timesbi.php b/app/ThirdParty/fpdf/font/timesbi.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/font/timesi.php b/app/ThirdParty/fpdf/font/timesi.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/font/zapfdingbats.php b/app/ThirdParty/fpdf/font/zapfdingbats.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/fpdf.css b/app/ThirdParty/fpdf/fpdf.css old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/fpdf.php b/app/ThirdParty/fpdf/fpdf.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/install.txt b/app/ThirdParty/fpdf/install.txt old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/license.txt b/app/ThirdParty/fpdf/license.txt old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/makefont/cp1250.map b/app/ThirdParty/fpdf/makefont/cp1250.map old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/makefont/cp1251.map b/app/ThirdParty/fpdf/makefont/cp1251.map old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/makefont/cp1252.map b/app/ThirdParty/fpdf/makefont/cp1252.map old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/makefont/cp1253.map b/app/ThirdParty/fpdf/makefont/cp1253.map old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/makefont/cp1254.map b/app/ThirdParty/fpdf/makefont/cp1254.map old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/makefont/cp1255.map b/app/ThirdParty/fpdf/makefont/cp1255.map old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/makefont/cp1257.map b/app/ThirdParty/fpdf/makefont/cp1257.map old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/makefont/cp1258.map b/app/ThirdParty/fpdf/makefont/cp1258.map old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/makefont/cp874.map b/app/ThirdParty/fpdf/makefont/cp874.map old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/makefont/iso-8859-1.map b/app/ThirdParty/fpdf/makefont/iso-8859-1.map old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/makefont/iso-8859-11.map b/app/ThirdParty/fpdf/makefont/iso-8859-11.map old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/makefont/iso-8859-15.map b/app/ThirdParty/fpdf/makefont/iso-8859-15.map old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/makefont/iso-8859-16.map b/app/ThirdParty/fpdf/makefont/iso-8859-16.map old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/makefont/iso-8859-2.map b/app/ThirdParty/fpdf/makefont/iso-8859-2.map old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/makefont/iso-8859-4.map b/app/ThirdParty/fpdf/makefont/iso-8859-4.map old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/makefont/iso-8859-5.map b/app/ThirdParty/fpdf/makefont/iso-8859-5.map old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/makefont/iso-8859-7.map b/app/ThirdParty/fpdf/makefont/iso-8859-7.map old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/makefont/iso-8859-9.map b/app/ThirdParty/fpdf/makefont/iso-8859-9.map old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/makefont/koi8-r.map b/app/ThirdParty/fpdf/makefont/koi8-r.map old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/makefont/koi8-u.map b/app/ThirdParty/fpdf/makefont/koi8-u.map old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/makefont/makefont.php b/app/ThirdParty/fpdf/makefont/makefont.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/makefont/ttfparser.php b/app/ThirdParty/fpdf/makefont/ttfparser.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/20k_c1.txt b/app/ThirdParty/fpdf/tutorial/20k_c1.txt old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/20k_c2.txt b/app/ThirdParty/fpdf/tutorial/20k_c2.txt old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/CevicheOne-Regular-Licence.txt b/app/ThirdParty/fpdf/tutorial/CevicheOne-Regular-Licence.txt old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/CevicheOne-Regular.php b/app/ThirdParty/fpdf/tutorial/CevicheOne-Regular.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/CevicheOne-Regular.ttf b/app/ThirdParty/fpdf/tutorial/CevicheOne-Regular.ttf old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/CevicheOne-Regular.z b/app/ThirdParty/fpdf/tutorial/CevicheOne-Regular.z old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/countries.txt b/app/ThirdParty/fpdf/tutorial/countries.txt old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/index.htm b/app/ThirdParty/fpdf/tutorial/index.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/logo.png b/app/ThirdParty/fpdf/tutorial/logo.png old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/makefont.php b/app/ThirdParty/fpdf/tutorial/makefont.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/tuto1.htm b/app/ThirdParty/fpdf/tutorial/tuto1.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/tuto1.php b/app/ThirdParty/fpdf/tutorial/tuto1.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/tuto2.htm b/app/ThirdParty/fpdf/tutorial/tuto2.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/tuto2.php b/app/ThirdParty/fpdf/tutorial/tuto2.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/tuto3.htm b/app/ThirdParty/fpdf/tutorial/tuto3.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/tuto3.php b/app/ThirdParty/fpdf/tutorial/tuto3.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/tuto4.htm b/app/ThirdParty/fpdf/tutorial/tuto4.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/tuto4.php b/app/ThirdParty/fpdf/tutorial/tuto4.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/tuto5.htm b/app/ThirdParty/fpdf/tutorial/tuto5.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/tuto5.php b/app/ThirdParty/fpdf/tutorial/tuto5.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/tuto6.htm b/app/ThirdParty/fpdf/tutorial/tuto6.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/tuto6.php b/app/ThirdParty/fpdf/tutorial/tuto6.php old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/tuto7.htm b/app/ThirdParty/fpdf/tutorial/tuto7.htm old mode 100644 new mode 100755 diff --git a/app/ThirdParty/fpdf/tutorial/tuto7.php b/app/ThirdParty/fpdf/tutorial/tuto7.php old mode 100644 new mode 100755 diff --git a/app/Views/404.html b/app/Views/404.html old mode 100644 new mode 100755 index 4902872..8722123 --- a/app/Views/404.html +++ b/app/Views/404.html @@ -212,42 +212,42 @@
diff --git a/app/Views/about.php b/app/Views/about.php old mode 100644 new mode 100755 index c19584a..fc3cb8f --- a/app/Views/about.php +++ b/app/Views/about.php @@ -170,22 +170,22 @@

Photo Gallery

- " alt=""> +
- " alt=""> +
- " alt=""> +
- " alt=""> +
- " alt=""> +
- " alt=""> +
@@ -236,4 +236,4 @@ - \ No newline at end of file + diff --git a/app/Views/account/choose_account.php b/app/Views/account/choose_account.php old mode 100644 new mode 100755 diff --git a/app/Views/activate_account.php b/app/Views/activate_account.php old mode 100644 new mode 100755 diff --git a/app/Views/admin/class_progress_list.php b/app/Views/admin/class_progress_list.php old mode 100644 new mode 100755 index 89337aa..a6fd88c --- a/app/Views/admin/class_progress_list.php +++ b/app/Views/admin/class_progress_list.php @@ -1,8 +1,21 @@ extend('layout/management_layout') ?> section('content') ?>
@@ -12,6 +25,23 @@

Class Progress Reports

Filter by week, class, and status
+ @@ -56,79 +86,167 @@
-
-
- - - - - - - - - - +
+ + +
No class sections found.
+ +
+ $cs): ?> 0, + 'expected' => 0, + 'percent' => 0, + 'badgeClass' => 'bg-secondary', + 'labelClass' => 'text-muted', + ]; + } else { + $stat = [ + 'submitted' => 0, + 'expected' => $expectedDays, + 'percent' => 0, + 'badgeClass' => 'bg-danger', + 'labelClass' => 'text-danger', + ]; + } + } + $percentLabel = $expectedDays > 0 ? number_format((float) $stat['percent'], 1) . '%' : 'N/A'; + $submissionLabel = $expectedDays > 0 + ? ('Submitted: ' . (int) $stat['submitted'] . ' / ' . (int) $expectedDays . ' (' . $percentLabel . ')') + : 'Submitted: N/A'; + $subjectLabel = 'Units: ' . $subjectCount; ?> - -
- - - - - - - - - - - - -
WeekSubjectsTeacherAction
No reports found.
-
- -
Class:
- -
-
- $section): ?> - -
-
- - -
-
-
- +
+

+ +

+
+
+ +
No reports found for this section.
+ +
+ + + + + + + + + + + + $teacherLatest[$name])) { + $teacherLatest[$name] = $stamp; + } + } + $teacherLabel = '-'; + if (!empty($teacherCounts)) { + $bestName = ''; + $bestCount = -1; + $bestStamp = ''; + foreach ($teacherCounts as $name => $count) { + $stamp = $teacherLatest[$name] ?? ''; + if ($count > $bestCount || ($count === $bestCount && $stamp > $bestStamp)) { + $bestName = $name; + $bestCount = $count; + $bestStamp = $stamp; + } + } + $teacherLabel = $bestName ?: '-'; + } + $exampleId = $reports ? reset($reports)['id'] : null; + ?> + + + + + + + + +
WeekSubjectsTeacherAction
+
+ $section): ?> + +
+
+ + +
+
+ + (string) ($report['unit_title'] ?? ''), + 'isQuran' => ($subjectName === 'Quran/Arabic'), + 'compact' => true, + ]) ?> + + No submission + +
+
+ +
+
+ + View + +
-
- - View - -
-
+ +
+
+ + + + diff --git a/app/Views/admin/class_progress_view.php b/app/Views/admin/class_progress_view.php old mode 100644 new mode 100755 index 62aabeb..f370adc --- a/app/Views/admin/class_progress_view.php +++ b/app/Views/admin/class_progress_view.php @@ -39,7 +39,6 @@ @@ -51,7 +50,13 @@
No entry submitted for this subject this week.
-
:
+
+ (string) ($report['unit_title'] ?? ''), + 'isQuran' => $isQuran, + 'compact' => false, + ]) ?> +
Materials:
diff --git a/app/Views/admin/competition_winners/form.php b/app/Views/admin/competition_winners/form.php old mode 100644 new mode 100755 diff --git a/app/Views/admin/competition_winners/index.php b/app/Views/admin/competition_winners/index.php old mode 100644 new mode 100755 diff --git a/app/Views/admin/competition_winners/preview.php b/app/Views/admin/competition_winners/preview.php old mode 100644 new mode 100755 diff --git a/app/Views/admin/competition_winners/scores.php b/app/Views/admin/competition_winners/scores.php old mode 100644 new mode 100755 diff --git a/app/Views/admin/competition_winners/winners.php b/app/Views/admin/competition_winners/winners.php old mode 100644 new mode 100755 diff --git a/app/Views/admin/partials/class_progress_unit_display.php b/app/Views/admin/partials/class_progress_unit_display.php new file mode 100755 index 0000000..16c2755 --- /dev/null +++ b/app/Views/admin/partials/class_progress_unit_display.php @@ -0,0 +1,48 @@ + + + + - + + +
+ +
+ + +
+ + +
+ + + +
:
+ + +
:
+ + +
:
+ + diff --git a/app/Views/admin/student_score_card.php b/app/Views/admin/student_score_card.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/absence_vacation.php b/app/Views/administrator/absence_vacation.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/academic_performance.php b/app/Views/administrator/academic_performance.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/admin_attendance_records.php b/app/Views/administrator/admin_attendance_records.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/admin_profiles.php b/app/Views/administrator/admin_profiles.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/administratordashboard.php b/app/Views/administrator/administratordashboard.php old mode 100644 new mode 100755 index 4e01d3e..7d1f6e2 --- a/app/Views/administrator/administratordashboard.php +++ b/app/Views/administrator/administratordashboard.php @@ -517,37 +517,28 @@

Statistics

-
-
+
Students
- -
-
+
Teachers
- -
-
+
-
Teachers' Assistants
+
TAs
- -
-
+
Admins
- -
-
+
Parents
diff --git a/app/Views/administrator/attendance.php b/app/Views/administrator/attendance.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/attendance_records.php b/app/Views/administrator/attendance_records.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/behavior_reports.php b/app/Views/administrator/behavior_reports.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/broadcast_email.php b/app/Views/administrator/broadcast_email.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/budget_reports.php b/app/Views/administrator/budget_reports.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/calendar.php b/app/Views/administrator/calendar.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/calendar_add_event.php b/app/Views/administrator/calendar_add_event.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/calendar_edit.php b/app/Views/administrator/calendar_edit.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/calendar_view.php b/app/Views/administrator/calendar_view.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/class_assignment.php b/app/Views/administrator/class_assignment.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/class_section.php b/app/Views/administrator/class_section.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/class_warning_modal.php b/app/Views/administrator/class_warning_modal.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/communication_logs.php b/app/Views/administrator/communication_logs.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/course_materials.php b/app/Views/administrator/course_materials.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/create_parent.php b/app/Views/administrator/create_parent.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/daily_attendance.php b/app/Views/administrator/daily_attendance.php old mode 100644 new mode 100755 index c80d250..97e1485 --- a/app/Views/administrator/daily_attendance.php +++ b/app/Views/administrator/daily_attendance.php @@ -696,7 +696,17 @@ - + + endSection() ?> @@ -239,6 +250,54 @@ $analysisSectionTotals[] = (int)($s['total_students'] ?? 0); } + // ---- Student absences/late list ---- + $sectionLabelByKey = []; + foreach ($grades as $classId => $sections) { + foreach ($sections as $section) { + $sectionKey = (string)($section['class_section_id'] ?? ($section['id'] ?? '')); + if ($sectionKey === '') continue; + $secNameRaw = trim((string)($section['class_section_name'] ?? '')); + $sectionLabelByKey[$sectionKey] = $secNameRaw !== '' ? $secNameRaw : ('Section ' . $sectionKey); + } + } + + $studentIssueRows = []; + foreach ($studentsBySection as $sectionKey => $students) { + foreach ($students as $stu) { + $sid = (int)($stu['id'] ?? 0); + if ($sid <= 0) continue; + $entries = $attendanceData[$sectionKey][$sid] ?? []; + if (!is_array($entries)) continue; + $abs = 0; + $late = 0; + foreach ($entries as $e) { + $d = substr((string)($e['date'] ?? ''), 0, 10); + if ($d === '') continue; + if ($filterStart !== '' && $d < $filterStart) continue; + if ($filterEnd !== '' && $d > $filterEnd) continue; + $st = strtolower(trim((string)($e['status'] ?? ''))); + if ($st === 'absent') { + $abs++; + } elseif ($st === 'late') { + $late++; + } + } + if (($abs + $late) <= 0) continue; + $studentIssueRows[] = [ + 'name' => trim((string)($stu['firstname'] ?? '') . ' ' . (string)($stu['lastname'] ?? '')), + 'section' => $sectionLabelByKey[(string)$sectionKey] ?? ('Section ' . $sectionKey), + 'absent' => $abs, + 'late' => $late, + ]; + } + } + + usort($studentIssueRows, static function ($a, $b) { + $sec = strcmp($a['section'], $b['section']); + if ($sec !== 0) return $sec; + return strcmp($a['name'], $b['name']); + }); + $totalDaysForPercent = 0; if ($filterStart === '' && $filterEnd === '' && !empty($totalPassedDays)) { $totalDaysForPercent = (int)$totalPassedDays; @@ -291,7 +350,9 @@
Overall Distribution
- +
+ +
@@ -360,6 +421,37 @@
+
+
Students With Absences / Late
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Student NameClass SectionNbr of ABSNbr of LATE
No absences or late records in the selected range.
+
+
@@ -415,6 +507,8 @@ }] }, options: { + responsive: true, + maintainAspectRatio: false, plugins: { tooltip: { callbacks: { @@ -465,6 +559,13 @@ info: false, order: [[0, 'asc']] }); + $('#studentIssueTable').DataTable({ + paging: true, + searching: true, + info: true, + order: [[1, 'asc'], [0, 'asc']], + pageLength: 25 + }); } }); diff --git a/app/Views/administrator/edit_parent.php b/app/Views/administrator/edit_parent.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/edit_teacher.php b/app/Views/administrator/edit_teacher.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/emergency_contact/edit.php b/app/Views/administrator/emergency_contact/edit.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/emergency_contact/index.php b/app/Views/administrator/emergency_contact/index.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/events/create_event.php b/app/Views/administrator/events/create_event.php old mode 100644 new mode 100755 index e26f73b..62d9193 --- a/app/Views/administrator/events/create_event.php +++ b/app/Views/administrator/events/create_event.php @@ -4,6 +4,11 @@

Create Event

+
@@ -15,7 +20,7 @@ @@ -23,7 +28,7 @@
- +
@@ -51,9 +56,89 @@
+
+
School Calendar
+
+
+ + +
+ + +
+
+ +
+
Parent Email Broadcast
+
+
+ + +
+
The email is sent once. The school sender address stays in To, and all parent emails are placed in CC.
+
+
+ Cancel
endSection() ?> + +section('scripts') ?> + + +endSection() ?> diff --git a/app/Views/administrator/events/edit_event.php b/app/Views/administrator/events/edit_event.php old mode 100644 new mode 100755 index 7f544f0..e86b8f3 --- a/app/Views/administrator/events/edit_event.php +++ b/app/Views/administrator/events/edit_event.php @@ -4,6 +4,12 @@

Edit Event

+ + getFlashdata('error')): ?>
getFlashdata('error') ?>
@@ -23,7 +29,7 @@ +
@@ -44,7 +50,7 @@

- +
No flyer uploaded.
@@ -70,9 +76,89 @@
+
+
School Calendar
+
+
+ + +
+ + +
+
+ +
+
Parent Email Broadcast
+
+
+ + +
+
Use this when you want to rebroadcast the updated event to all parents in a single email.
+
+
+ Cancel
endSection() ?> + +section('scripts') ?> + + +endSection() ?> diff --git a/app/Views/administrator/events/event_charges.php b/app/Views/administrator/events/event_charges.php old mode 100644 new mode 100755 index d1dc45f..fcf2652 --- a/app/Views/administrator/events/event_charges.php +++ b/app/Views/administrator/events/event_charges.php @@ -3,6 +3,54 @@

Event Charges

+ getFlashdata('success')): ?>
getFlashdata('success') ?>
@@ -11,106 +59,404 @@
getFlashdata('error') ?>
- -
- -
-
- - -
+ -
- - -
- - + +
+
+ + 0): ?> + + + 0): ?> + + +
+ + +
+
+ + +
+
+ +
+
+
- - Event List - -
+ +
+
+
+ + + +
+
+ + +
+ + +
+
+
+
+ + $ fee + +
+

+ +

+ + + Expires: + + +
+
+ + +
+ + +
+
+ + + +
+ get('role') ?? '')), ['parent', 'teacher', 'teacher_assistant'], true)): ?> + + +
Enrolled students are saved when you click Submit. Non-school participants are saved immediately and will then appear in all browsers.
+
+ +
+ + Event List +
+
+
+
$charge['event_name'] ?? 'N/A', + 'rows' => [], + ]; + } + $grouped[$eventId]['rows'][] = $charge; } ?>
No charges found.
- $rows): ?> -
-
- +
+

Click a column header to sort the participants list.

+ +
+
+ $data): ?> + + +
+
+ +
+ +
+
- +
- - - - - - - - - - + + + + + + + + + + + + + + - - - + + + - - + + + + + + + + - - - + + + + + + + +
IDParent NameStudent NameCharged AmountIs ParticipatingSemesterYearCreated
Actions
- + + + + + $ - Yes' - : 'No' ?> + + +
+ + +
+ + + + +
+ + $ +
+ + + + + onchange="this.form.waiver_signed.value = this.checked ? 1 : 0; this.form.submit();"> + +
+
+ + Paid + + Unpaid + + +
+ + + + + onchange="this.form.paid.value = this.checked ? 1 : 0; this.form.submit();"> + +
+
+
+ + Edit + +
+ + + +
+
Totals$ + + participating + +
- - + +
+
+get('role') ?? '')), ['parent', 'teacher', 'teacher_assistant'], true)): ?> + + + endSection() ?> section('scripts') ?> @@ -156,7 +502,309 @@ function loadStudentsWithCharges() { } } -$('#parent_id').on('change', loadStudentsWithCharges); -$('#event_id').on('change', loadStudentsWithCharges); + const capitalizeName = (value) => { + if (!value) return ''; + return value + .split(' ') + .map(part => { + const trimmed = part.trim(); + if (!trimmed) return ''; + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1).toLowerCase(); + }) + .filter(Boolean) + .join(' '); + }; + + $(function() { + $('#parent_id').on('change', loadStudentsWithCharges); + + $('#event_id').on('change', function() { + let eventId = $(this).val(); + let url = new URL(window.location.href); + if (eventId) { + url.searchParams.set('event_id', eventId); + } else { + url.searchParams.delete('event_id'); + } + window.location.href = url.toString(); + }); + + if ($('#event_id').val() && $('#parent_id').val()) { + loadStudentsWithCharges(); + } + + const $externalSaveButton = $('#externalParticipantSave'); + + const modalElement = document.getElementById('externalParticipantModal'); + const modalInstance = modalElement ? new bootstrap.Modal(modalElement) : null; + + function clearModalInputs() { + $('#modalExternalFirstName').val(''); + $('#modalExternalLastName').val(''); + $('#modalExternalNote').val(''); + $('#modalParentFirstName').val(''); + $('#modalParentLastName').val(''); + $('#modalParentPhone').val(''); + $('#modalParentEmail').val(''); + } + + function appendHiddenField(form, name, value) { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = name; + input.value = value; + form.appendChild(input); + } + + function escapeHtml(value) { + const s = String(value ?? ''); + return s.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function submitExternalParticipant(payload) { + const form = document.createElement('form'); + form.method = 'post'; + form.action = ''; + + appendHiddenField(form, '', ''); + appendHiddenField(form, 'school_year', ''); + appendHiddenField(form, 'semester', ''); + appendHiddenField(form, 'event_id', payload.eventId); + appendHiddenField(form, 'external_participants[new][firstname]', payload.firstname); + appendHiddenField(form, 'external_participants[new][lastname]', payload.lastname); + appendHiddenField(form, 'external_participants[new][note]', payload.note); + appendHiddenField(form, 'external_participants[new][parent_firstname]', payload.parentFirstname); + appendHiddenField(form, 'external_participants[new][parent_lastname]', payload.parentLastname); + appendHiddenField(form, 'external_participants[new][parent_phone]', payload.parentPhone); + appendHiddenField(form, 'external_participants[new][parent_email]', payload.parentEmail); + appendHiddenField(form, 'external_participants[new][waiver_signed]', '0'); + appendHiddenField(form, 'external_participants[new][paid]', '0'); + + document.body.appendChild(form); + form.submit(); + } + + function getSortValue(row, columnIndex, sortType) { + const cell = row.children[columnIndex]; + if (!cell) { + return sortType === 'number' ? 0 : ''; + } + const rawValue = cell.getAttribute('data-sort-value'); + const fallbackValue = (cell.textContent || '').trim(); + const value = rawValue !== null ? rawValue : fallbackValue; + if (sortType === 'number') { + const parsed = parseFloat(String(value).replace(/[^0-9.-]/g, '')); + return Number.isNaN(parsed) ? 0 : parsed; + } + if (sortType === 'date') { + const time = Date.parse(value); + return Number.isNaN(time) ? 0 : time; + } + return String(value).toLowerCase(); + } + + function updateSortIndicators(table, columnIndex, direction) { + $(table).find('.sortable-header').each(function() { + const isCurrent = Number($(this).data('sortIndex')) === Number(columnIndex); + $(this) + .toggleClass('sorted-asc', isCurrent && direction === 'asc') + .toggleClass('sorted-desc', isCurrent && direction === 'desc') + .attr('aria-sort', isCurrent ? direction : 'none'); + }); + } + + function sortEventTable(table, columnIndex, sortType, direction) { + if (!table || !table.tBodies.length) { + return; + } + const tbody = table.tBodies[0]; + const rows = Array.from(tbody.rows); + rows.sort((a, b) => { + const first = getSortValue(a, columnIndex, sortType); + const second = getSortValue(b, columnIndex, sortType); + if (first < second) { + return direction === 'asc' ? -1 : 1; + } + if (first > second) { + return direction === 'asc' ? 1 : -1; + } + return 0; + }); + rows.forEach((row) => tbody.appendChild(row)); + table.dataset.sortColumn = String(columnIndex); + table.dataset.sortType = sortType; + table.dataset.sortDirection = direction; + updateSortIndicators(table, columnIndex, direction); + } + + function applyActiveSort(table) { + if (!table || !table.dataset || !table.dataset.sortColumn) { + return; + } + sortEventTable( + table, + Number(table.dataset.sortColumn), + table.dataset.sortType || 'text', + table.dataset.sortDirection || 'asc' + ); + } + + function buildPrintableCard(card) { + const clone = card.cloneNode(true); + clone.querySelectorAll('.no-print').forEach((node) => node.remove()); + clone.querySelectorAll('.sortable-header').forEach((button) => { + const headerText = button.textContent || ''; + const textNode = document.createTextNode(headerText); + button.parentNode.replaceChild(textNode, button); + }); + clone.querySelectorAll('form').forEach((form) => { + const cell = form.closest('td'); + if (!cell) { + form.remove(); + return; + } + const label = form.querySelector('.form-check-label'); + const checkbox = form.querySelector('input[type="checkbox"]'); + const text = label + ? label.textContent.trim() + : (checkbox && checkbox.checked ? 'Paid' : 'Unpaid'); + cell.textContent = text || '—'; + }); + clone.querySelectorAll('tr').forEach((row) => { + if (row.children.length > 10) { + row.removeChild(row.lastElementChild); + } + }); + return clone.outerHTML; + } + + function openPrintWindow(title, contentHtml) { + const printWindow = window.open('', '_blank', 'noopener,noreferrer,width=1200,height=900'); + if (!printWindow) { + alert('Unable to open the print preview window. Please allow pop-ups for this site.'); + return; + } + const style = ` + + `; + printWindow.document.open(); + printWindow.document.write(` + + + + + ${escapeHtml(title)} + ${style} + + +

${escapeHtml(title)}

+

Generated on ${escapeHtml(new Date().toLocaleString())}

+ ${contentHtml} + endSection() ?> diff --git a/app/Views/administrator/events/event_list.php b/app/Views/administrator/events/event_list.php old mode 100644 new mode 100755 index e31419b..189afb3 --- a/app/Views/administrator/events/event_list.php +++ b/app/Views/administrator/events/event_list.php @@ -12,6 +12,14 @@
getFlashdata('error') ?>
+ getFlashdata('email_status')): ?> +
getFlashdata('email_status') ?>
+ + + getFlashdata('email_error')): ?> +
getFlashdata('email_error') ?>
+ +
No events found.
@@ -21,7 +29,8 @@ Event Name Category - Amount + Description + Event Fees Expiration Date Semester School Year @@ -33,7 +42,8 @@ - + + $ diff --git a/app/Views/administrator/exam_drafts.php b/app/Views/administrator/exam_drafts.php old mode 100644 new mode 100755 index 7450dec..402cc5e --- a/app/Views/administrator/exam_drafts.php +++ b/app/Views/administrator/exam_drafts.php @@ -1,11 +1,36 @@ extend('layout/management_layout') ?> section('content') ?> + $status, 'class' => 'bg-secondary text-white']; + $style = !empty($b['style']) ? ' style="' . esc($b['style']) . '"' : ''; + return '' . esc($b['label']) . ''; +}; + +$fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensions)); +?>
-

Exam Draft Submissions

+

Exam draft submissions

- + review portal ·

@@ -15,13 +40,9 @@
getFlashdata('success')): ?> -
- getFlashdata('success')) ?> -
+
getFlashdata('success')) ?>
getFlashdata('error')): ?> -
- getFlashdata('error')) ?> -
+
getFlashdata('error')) ?>
+
- -

No exam drafts have been submitted yet.

+ +

No classes with enrolled students are available for this term.

-
- - - - - - - - - - - - - - - - - 'Unknown', 'class' => 'bg-secondary text-white']; - $adminName = trim(($draft['admin_first'] ?? '') . ' ' . ($draft['admin_last'] ?? '')); - ?> - - - - - - - - - - +
TeacherClassTitle / TypeVersionSubmittedStatusFilesPDFReview
- -
- -
- -
v - - - - -
- Reviewed - - by +
+ $classInfo): ?> + + +
+

+

- -
- - - -
- - No teacher file - - -
- - - -
- -
- - - - PDF - - - - - -
- - -
- -
-
- - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - -
- - -
- - - - - - -
TeacherClassTitle & AuthorNoteVersionDate-TimeStatusFilesReviewer ActionFinal version
+ +
+ +
+ Author: + +
+ + +
+ Reviewer: + +
+ +
v + $st, 'class' => 'bg-secondary text-white']; + $badgeStyle = !empty($badgeData['style']) ? ' style="' . esc($badgeData['style']) . '"' : ''; + ?> + > + + +
+ + + +
+ +
+ Reviewed + + by + +
+ +
+ + + +
+ + + +
+ + No teacher file + + +
+ + + +
+ + +
+ ' . esc('Ver' . $revNumber . '_' . $rev . ' ' . $name) . ''; + } + } + ?> + +
+ +
+ + + +
+
+ 'vstack gap-2', 'id' => $formId]) ?> + + + + +
+ As is / Minor edits + +
+ > + +
+
+ > + +
+
+ +
+ +
+ +
+ + +
+ View + Download +
+ +
+
+
+ +
+
+
+
+
- Upload Old / Legacy Exam -
Store historic exams as finalized records.
+ Upload old / legacy exam +
Store historic exams as accepted records.
Admin only
-
+ 'row g-3']) ?>
- - - + +
Hold Ctrl (Windows) or Command (Mac) to select multiple sections.
- +
- +
- +
- - + +
Allowed: • Max MB
- +
-
+
@@ -231,20 +328,21 @@
- -
+ + +
-
+
+
-
+
- - Download - + View + Download File missing @@ -260,6 +358,208 @@
+ endSection() ?> section('styles') ?> @@ -270,13 +570,15 @@ word-break: break-word; } .exam-drafts-table th { - min-width: 120px; + min-width: 0; + white-space: nowrap; } .exam-drafts-table td { - max-width: 220px; + max-width: none; } .exam-drafts-table { table-layout: auto; + width: max-content; } .exam-drafts-table thead th { background-color: #f8f9fa; diff --git a/app/Views/administrator/exam_management.php b/app/Views/administrator/exam_management.php deleted file mode 100644 index e69de29..0000000 diff --git a/app/Views/administrator/expense_management.php b/app/Views/administrator/expense_management.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/fee_collection.php b/app/Views/administrator/fee_collection.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/fee_payment_records.php b/app/Views/administrator/fee_payment_records.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/feedback_complaints.php b/app/Views/administrator/feedback_complaints.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/grades.php b/app/Views/administrator/grades.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/grading_management.php b/app/Views/administrator/grading_management.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/health_records.php b/app/Views/administrator/health_records.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/index.php b/app/Views/administrator/index.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/ip_bans.php b/app/Views/administrator/ip_bans.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/late_slip_logs.php b/app/Views/administrator/late_slip_logs.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/manage_users.php b/app/Views/administrator/manage_users.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/notifications_alerts.php b/app/Views/administrator/notifications_alerts.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/parent.php b/app/Views/administrator/parent.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/parent_profile.php b/app/Views/administrator/parent_profile.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/paypal_transactions.php b/app/Views/administrator/paypal_transactions.php old mode 100644 new mode 100755 index c3fc791..0a00eff --- a/app/Views/administrator/paypal_transactions.php +++ b/app/Views/administrator/paypal_transactions.php @@ -13,17 +13,19 @@
- + + --> diff --git a/app/Views/administrator/payroll_management.php b/app/Views/administrator/payroll_management.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/performance_reviews.php b/app/Views/administrator/performance_reviews.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/print_notification_admins.php b/app/Views/administrator/print_notification_admins.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/removed_students.php b/app/Views/administrator/removed_students.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/scholarship_information.php b/app/Views/administrator/scholarship_information.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/search_results.php b/app/Views/administrator/search_results.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/sections_auto_distribute.php b/app/Views/administrator/sections_auto_distribute.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/settings.php b/app/Views/administrator/settings.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/student_class_assignment.php b/app/Views/administrator/student_class_assignment.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/student_profiles.php b/app/Views/administrator/student_profiles.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/subject_curriculum.php b/app/Views/administrator/subject_curriculum.php old mode 100644 new mode 100755 index 412a138..8896b5b --- a/app/Views/administrator/subject_curriculum.php +++ b/app/Views/administrator/subject_curriculum.php @@ -144,58 +144,86 @@ record
-
-
- - - - - - - - - - - - - - - - - - - format('M d, Y H:i'); - } catch (\Exception $e) { - $updatedDisplay = $updatedAt; - } - } - ?> - - - - - - - - - - - - -
ClassSubjectUnitUnit titleChapter / SurahUpdatedActions
No curriculum records yet.
- Edit -
- - -
-
-
+ +
No curriculum records yet.
+ + +
+ + $classEntries): ?> + +
+

+ +

+
+
+
+ + + + + + + + + + + + + + format('M d, Y H:i'); + } catch (\Exception $e) { + $updatedDisplay = $updatedAt; + } + } + ?> + + + + + + + + + + +
SubjectUnitUnit titleChapter / SurahUpdatedActions
+ Edit +
+ + +
+
+
+
+
+
+ +
+
diff --git a/app/Views/administrator/teacher_class_assignment.php b/app/Views/administrator/teacher_class_assignment.php old mode 100644 new mode 100755 index b140603..4425f75 --- a/app/Views/administrator/teacher_class_assignment.php +++ b/app/Views/administrator/teacher_class_assignment.php @@ -63,7 +63,15 @@ - Loading... + Loading... + + + + + + + + @@ -198,10 +206,12 @@ document.addEventListener('DOMContentLoaded', function () { if (!teachers || teachers.length === 0) { var emptyRow = document.createElement('tr'); var emptyCell = document.createElement('td'); - emptyCell.colSpan = 9; emptyCell.className = 'text-center text-muted'; emptyCell.textContent = 'No teachers found.'; emptyRow.appendChild(emptyCell); + for (var i = 0; i < 8; i++) { + emptyRow.appendChild(document.createElement('td')); + } tbody.appendChild(emptyRow); } else { teachers.forEach(function (teacher, index) { @@ -312,7 +322,10 @@ document.addEventListener('DOMContentLoaded', function () { return; } - tbody.innerHTML = 'Loading...'; + tbody.innerHTML = '' + + 'Loading...' + + '' + + ''; var url = apiList + (selectedYear ? ('?schoolYear=' + encodeURIComponent(selectedYear)) : ''); fetch(url, { diff --git a/app/Views/administrator/teacher_profiles.php b/app/Views/administrator/teacher_profiles.php deleted file mode 100644 index e69de29..0000000 diff --git a/app/Views/administrator/teacher_submissions.php b/app/Views/administrator/teacher_submissions.php old mode 100644 new mode 100755 index f0f87e0..6f463e7 --- a/app/Views/administrator/teacher_submissions.php +++ b/app/Views/administrator/teacher_submissions.php @@ -21,140 +21,329 @@ $totalItems = max(0, (int)($summary['total_items'] ?? 0)); ?>
-
-
-
-
Submission completion
-
%
-
-
+ 0) { + $pageNotifications[] = [ + 'level' => 'danger', + 'message' => "{$missingItemsCount} missing item" . ($missingItemsCount === 1 ? '' : 's') . " awaiting teacher uploads.", + ]; + } + if ($completionPercent < 70) { + $pageNotifications[] = [ + 'level' => 'warning', + 'message' => "Submission completion is below 70% — follow up with remaining teachers.", + ]; + } + if ($flaggedClasses > 0) { + $pageNotifications[] = [ + 'level' => 'warning', + 'message' => "Progress is under 50% for {$flaggedClasses} class section" . ($flaggedClasses === 1 ? '' : 's') . ".", + ]; + } + if (empty($rows)) { + $pageNotifications[] = [ + 'level' => 'info', + 'message' => 'No class-section assignments submitted yet; encourage teachers to upload drafts.', + ]; + } + if (empty($pageNotifications)) { + $pageNotifications[] = [ + 'level' => 'info', + 'message' => 'All tracked classes are current. Use the controls below to send reminders.', + ]; + } + $examDraftDeadlineConfig = $examDraftDeadlineConfig ?? ''; + $examDraftDeadlineFormatted = $examDraftDeadlineFormatted ?? ''; + ?> + getFlashdata('success')): ?> +
+ getFlashdata('success')) ?> +
+ getFlashdata('warning')): ?> +
+ getFlashdata('warning')) ?> +
+ getFlashdata('info')): ?> +
+ getFlashdata('info')) ?> +
+ + $row) { + $classKey = (string) ($row['class_section_id'] ?? $row['class_section'] ?? 'class_' . $idx); + $groupedRows[$classKey][] = $row; + } + ?> +
+
+
+
+
+
+
Completion
+
%
+
+
+
+
+
+
+
+
+
+
Missing items
+
+
submitted / total
+
+
+
+
+
+
+
Submissions
+
+
teachers tracked
+
+
+
+
+
+
+
Flagged
+
+
Sections under 50% complete
+
+
-
-
Missing items
-
-
-
- submitted / total items +
+
+
+
+ Page notifications + alerts +
+
+
    + +
  • + + +
  • + +
+
-
- - - - - - - - - - - - - - - - - - - - 'N/A', 'badge' => 'bg-secondary']; ?> - - - - - + +

No teacher-class assignments found for this term.

+ +
+
+ + + + + + + +
+
+ Exam draft deadline
+ + + + + -
- - + Not set - -
Class SectionTeacherMidterm ScoreMidterm CommentParticipationPTAP CommentNotifications
- - -
- - - Unassigned - -
- - - - -
- -
- - - - - -
- - -
- - - Last on - by - - 1): ?> -
History: entries
- - - No notifications sent yet - -
-
- - - No teacher assigned - - -
- Outstanding: -
- -
No teacher-class assignments found for this term.
-
-
- -
+
+
+
+ $sectionRows): ?> + +
+

+ +

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + 'N/A', 'badge' => 'bg-secondary']; ?> + + + 'N/A', 'badge' => 'bg-secondary']; ?> + + + + + +
Class-SectionTeachers Name Score CommentParticipationPTAP CommentClass ProgressExam DraftHomeworkNotifications
+ + +
+ + + Unassigned + +
+ + + + +
+ +
+ + + + +
+ +
+ + + + + +
+ + +
+ + + Last on + by + + 1): ?> +
History: entries
+ + + No notifications sent yet + +
+
+ + + No teacher assigned + + +
+ Outstanding: +
+ +
+
+
+
+
+ +
+
+ +
+
@@ -186,5 +375,77 @@ .card-body .table-responsive .teacher-submissions-table td { white-space: normal; } + + .summary-card { + background: var(--bs-white); + border-radius: 0.75rem; + } + .summary-card-body { + padding: 1.25rem; + } + .summary-label { + font-size: 0.7rem; + letter-spacing: 0.08em; + color: #6c757d; + } + .summary-value { + font-size: 1.75rem; + font-weight: 600; + margin-top: 0.35rem; + } + .summary-note { + font-size: 0.85rem; + color: #6c757d; + } + .summary-progress { + height: 6px; + margin-top: 0.65rem; + } + .summary-progress .progress-bar { + background: var(--bs-primary); + } + .page-notifications-card { + border-radius: 0.75rem; + } + .page-notifications-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + .page-notification-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + } + .page-notification-info .notification-indicator { + background: #0dcaf0; + } + .page-notification-warning .notification-indicator { + background: #ffc107; + } + .page-notification-danger .notification-indicator { + background: #dc3545; + } + .notification-indicator { + width: 0.75rem; + height: 0.75rem; + border-radius: 50%; + display: inline-block; + } + .table-control-bar { + border: 1px solid rgba(0,0,0,0.08); + border-radius: 0.75rem; + padding: 0.75rem 1rem; + background: #f8f9fa; + } + .table-control-bar .form-check-label { + font-size: 0.8rem; + margin-left: 0.15rem; + text-transform: none; + } endSection() ?> diff --git a/app/Views/administrator/teachers.php b/app/Views/administrator/teachers.php old mode 100644 new mode 100755 diff --git a/app/Views/administrator/teachers.php.bak b/app/Views/administrator/teachers.php.bak old mode 100644 new mode 100755 diff --git a/app/Views/appointment.php b/app/Views/appointment.php old mode 100644 new mode 100755 index 35d8925..52d01e0 --- a/app/Views/appointment.php +++ b/app/Views/appointment.php @@ -195,22 +195,22 @@

Photo Gallery

- +
- +
- +
- +
- +
- +
@@ -267,4 +267,4 @@ - \ No newline at end of file + diff --git a/app/Views/attendance/admins_attendance_form.php b/app/Views/attendance/admins_attendance_form.php old mode 100644 new mode 100755 diff --git a/app/Views/attendance/attendance_tracking.php b/app/Views/attendance/attendance_tracking.php old mode 100644 new mode 100755 diff --git a/app/Views/attendance/compose_email.php b/app/Views/attendance/compose_email.php old mode 100644 new mode 100755 diff --git a/app/Views/attendance/early_dismissals.php b/app/Views/attendance/early_dismissals.php old mode 100644 new mode 100755 diff --git a/app/Views/attendance/early_dismissals_add.php b/app/Views/attendance/early_dismissals_add.php old mode 100644 new mode 100755 diff --git a/app/Views/attendance/edit_modal.php b/app/Views/attendance/edit_modal.php old mode 100644 new mode 100755 diff --git a/app/Views/attendance/parent_reports.php b/app/Views/attendance/parent_reports.php old mode 100644 new mode 100755 diff --git a/app/Views/attendance/teacher_attendance_form.php b/app/Views/attendance/teacher_attendance_form.php old mode 100644 new mode 100755 diff --git a/app/Views/attendance/teacher_attendance_month.php b/app/Views/attendance/teacher_attendance_month.php old mode 100644 new mode 100755 diff --git a/app/Views/attendance/view.php b/app/Views/attendance/view.php old mode 100644 new mode 100755 diff --git a/app/Views/attendance/violations_notified.php b/app/Views/attendance/violations_notified.php old mode 100644 new mode 100755 diff --git a/app/Views/attendance/violations_pending.php b/app/Views/attendance/violations_pending.php old mode 100644 new mode 100755 diff --git a/app/Views/attendance_templates/index.php b/app/Views/attendance_templates/index.php old mode 100644 new mode 100755 diff --git a/app/Views/auth/select_role.php b/app/Views/auth/select_role.php old mode 100644 new mode 100755 index fc89aec..3d7b95a --- a/app/Views/auth/select_role.php +++ b/app/Views/auth/select_role.php @@ -25,6 +25,7 @@
+
@@ -68,4 +69,4 @@ tooltipTriggerList.forEach(el => new bootstrap.Tooltip(el)); }); -endSection() ?> \ No newline at end of file +endSection() ?> diff --git a/app/Views/calendar.php b/app/Views/calendar.php old mode 100644 new mode 100755 diff --git a/app/Views/call_to_action.php b/app/Views/call_to_action.php old mode 100644 new mode 100755 index 77b8b04..0832eb4 --- a/app/Views/call_to_action.php +++ b/app/Views/call_to_action.php @@ -155,22 +155,22 @@

Photo Gallery

- +
- +
- +
- +
- +
- +
@@ -227,4 +227,4 @@ - \ No newline at end of file + diff --git a/app/Views/class_prep/list.php b/app/Views/class_prep/list.php old mode 100644 new mode 100755 diff --git a/app/Views/class_prep/print.php b/app/Views/class_prep/print.php old mode 100644 new mode 100755 diff --git a/app/Views/classes.php b/app/Views/classes.php old mode 100644 new mode 100755 index 75143aa..2c40a46 --- a/app/Views/classes.php +++ b/app/Views/classes.php @@ -93,7 +93,7 @@
- +
Quran Learning @@ -104,7 +104,7 @@
- +
Islamic Studies @@ -114,7 +114,7 @@
- +
Arabic Learning @@ -160,22 +160,22 @@

Photo Gallery

- " alt=""> +
- " alt=""> +
- " alt=""> +
- " alt=""> +
- " alt=""> +
- " alt=""> +
@@ -228,4 +228,4 @@ - \ No newline at end of file + diff --git a/app/Views/communications/index.php b/app/Views/communications/index.php old mode 100644 new mode 100755 diff --git a/app/Views/configuration/configuration_view.php b/app/Views/configuration/configuration_view.php old mode 100644 new mode 100755 diff --git a/app/Views/contact.php b/app/Views/contact.php old mode 100644 new mode 100755 index 577ae3e..6e36932 --- a/app/Views/contact.php +++ b/app/Views/contact.php @@ -195,22 +195,22 @@

Photo Gallery

- " alt=""> +
- " alt=""> +
- " alt=""> +
- " alt=""> +
- " alt=""> +
- " alt=""> +
@@ -280,4 +280,4 @@ - \ No newline at end of file + diff --git a/app/Views/contact_process.php b/app/Views/contact_process.php old mode 100644 new mode 100755 diff --git a/app/Views/discounts/apply_voucher.php b/app/Views/discounts/apply_voucher.php old mode 100644 new mode 100755 diff --git a/app/Views/discounts/create.php b/app/Views/discounts/create.php old mode 100644 new mode 100755 diff --git a/app/Views/discounts/list.php b/app/Views/discounts/list.php old mode 100644 new mode 100755 diff --git a/app/Views/discounts/reverse_discount.php b/app/Views/discounts/reverse_discount.php deleted file mode 100644 index e69de29..0000000 diff --git a/app/Views/docs/index.php b/app/Views/docs/index.php old mode 100644 new mode 100755 diff --git a/app/Views/docs/swagger_ui.php b/app/Views/docs/swagger_ui.php old mode 100644 new mode 100755 diff --git a/app/Views/docs/swagger_ui_public.php b/app/Views/docs/swagger_ui_public.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/_wrap_layout.php b/app/Views/emails/_wrap_layout.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/admin_student_registered.php b/app/Views/emails/admin_student_registered.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/below_sixty_performance.php b/app/Views/emails/below_sixty_performance.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/broadcast_wrapper.php b/app/Views/emails/broadcast_wrapper.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/calendar_notification.php b/app/Views/emails/calendar_notification.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/custom_html.php b/app/Views/emails/custom_html.php new file mode 100755 index 0000000..f9105c1 --- /dev/null +++ b/app/Views/emails/custom_html.php @@ -0,0 +1,4 @@ +extend('layout/email_layout') ?> +section('content') ?> + +endSection() ?> diff --git a/app/Views/emails/dismissal.php b/app/Views/emails/dismissal.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/event_broadcast.php b/app/Views/emails/event_broadcast.php new file mode 100755 index 0000000..3e96bdf --- /dev/null +++ b/app/Views/emails/event_broadcast.php @@ -0,0 +1,38 @@ + +
+

+

Date:

+ +

Category:

+ + +

Amount: $

+ + +
+ + +
+ Event flyer for <?= esc($eventName) ?> +
+

+ Flyer: + +

+ +

For more details, please contact the school office or review the parent event page.

+

+ +

+
diff --git a/app/Views/emails/extra_charge_notice.php b/app/Views/emails/extra_charge_notice.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/final_warning.php b/app/Views/emails/final_warning.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/follow_up.php b/app/Views/emails/follow_up.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/parent_attendance_admin.php b/app/Views/emails/parent_attendance_admin.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/parent_attendance_parent.php b/app/Views/emails/parent_attendance_parent.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/parent_email_extractor.php b/app/Views/emails/parent_email_extractor.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/payment_receipt.php b/app/Views/emails/payment_receipt.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/reset_password.php b/app/Views/emails/reset_password.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/status_admission_review.php b/app/Views/emails/status_admission_review.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/status_denied.php b/app/Views/emails/status_denied.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/status_enrolled.php b/app/Views/emails/status_enrolled.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/status_not_enrolled.php b/app/Views/emails/status_not_enrolled.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/status_payment_pending.php b/app/Views/emails/status_payment_pending.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/status_refund_pending.php b/app/Views/emails/status_refund_pending.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/status_waitlist.php b/app/Views/emails/status_waitlist.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/status_withdraw_review.php b/app/Views/emails/status_withdraw_review.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/status_withdrawn.php b/app/Views/emails/status_withdrawn.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/student_removed.php b/app/Views/emails/student_removed.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/support_new_account.php b/app/Views/emails/support_new_account.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/welcome_parent.php b/app/Views/emails/welcome_parent.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/welcome_staff.php b/app/Views/emails/welcome_staff.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/welcome_user.php b/app/Views/emails/welcome_user.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/whatsapp_group_invitation.php b/app/Views/emails/whatsapp_group_invitation.php old mode 100644 new mode 100755 diff --git a/app/Views/emails/whatsapp_invite.php b/app/Views/emails/whatsapp_invite.php old mode 100644 new mode 100755 diff --git a/app/Views/enroll_withdraw/enrollment_withdrawal.php b/app/Views/enroll_withdraw/enrollment_withdrawal.php old mode 100644 new mode 100755 diff --git a/app/Views/enroll_withdraw/new-students.php b/app/Views/enroll_withdraw/new-students.php old mode 100644 new mode 100755 diff --git a/app/Views/errors/access_denied.php b/app/Views/errors/access_denied.php old mode 100644 new mode 100755 diff --git a/app/Views/errors/cli/error_404.php b/app/Views/errors/cli/error_404.php old mode 100644 new mode 100755 diff --git a/app/Views/errors/cli/error_exception.php b/app/Views/errors/cli/error_exception.php old mode 100644 new mode 100755 diff --git a/app/Views/errors/cli/production.php b/app/Views/errors/cli/production.php old mode 100644 new mode 100755 diff --git a/app/Views/errors/custom/blocked.php b/app/Views/errors/custom/blocked.php old mode 100644 new mode 100755 diff --git a/app/Views/errors/html/debug.css b/app/Views/errors/html/debug.css old mode 100644 new mode 100755 diff --git a/app/Views/errors/html/debug.js b/app/Views/errors/html/debug.js old mode 100644 new mode 100755 diff --git a/app/Views/errors/html/error_404.php b/app/Views/errors/html/error_404.php old mode 100644 new mode 100755 diff --git a/app/Views/errors/html/error_500.php b/app/Views/errors/html/error_500.php old mode 100644 new mode 100755 diff --git a/app/Views/errors/html/error_exception.php b/app/Views/errors/html/error_exception.php old mode 100644 new mode 100755 diff --git a/app/Views/errors/html/error_general.php b/app/Views/errors/html/error_general.php old mode 100644 new mode 100755 diff --git a/app/Views/errors/html/production.php b/app/Views/errors/html/production.php old mode 100644 new mode 100755 diff --git a/app/Views/errors/invalid_token.php b/app/Views/errors/invalid_token.php old mode 100644 new mode 100755 diff --git a/app/Views/expenses/create.php b/app/Views/expenses/create.php old mode 100644 new mode 100755 diff --git a/app/Views/expenses/edit.php b/app/Views/expenses/edit.php old mode 100644 new mode 100755 diff --git a/app/Views/expenses/index.php b/app/Views/expenses/index.php old mode 100644 new mode 100755 index a292a79..ad98fc7 --- a/app/Views/expenses/index.php +++ b/app/Views/expenses/index.php @@ -80,10 +80,10 @@ - + - + View receipt N/A diff --git a/app/Views/facility.php b/app/Views/facility.php old mode 100644 new mode 100755 diff --git a/app/Views/family/card.php b/app/Views/family/card.php old mode 100644 new mode 100755 index c8e278a..c325df5 --- a/app/Views/family/card.php +++ b/app/Views/family/card.php @@ -22,6 +22,10 @@ if ($title === '' || preg_match('/^\s*Family\s+of\s+User\s*\d+\s*$/i', $title)) } $title = $lastName !== '' ? ('Family of ' . $lastName) : 'Family'; } +$returnUrl = trim((string)service('request')->getServer('HTTP_REFERER')); +if ($returnUrl === '') { + $returnUrl = site_url('family'); +} ?>
@@ -187,7 +191,14 @@ if ($title === '' || preg_match('/^\s*Family\s+of\s+User\s*\d+\s*$/i', $title))
- '.esc($g['email']).'') : '' ?> + + ' . esc($gEmail) . '') : '' ?>
diff --git a/app/Views/family/compose_email.php b/app/Views/family/compose_email.php new file mode 100755 index 0000000..78af349 --- /dev/null +++ b/app/Views/family/compose_email.php @@ -0,0 +1,87 @@ +extend('layout/management_layout') ?> +section('content') ?> + +
+
+
+

Compose Email

+ +
+ +
+ Back +
+ +
+ + + + +
+
+ + +
+
+ + +
+
+ + +
Use the toolbar to format your message before sending.
+ +
+
+ +
+
+
+
+ +endSection() ?> + +section('scripts') ?> + + +endSection() ?> diff --git a/app/Views/family/index.php b/app/Views/family/index.php old mode 100644 new mode 100755 diff --git a/app/Views/flags/flags_management.php b/app/Views/flags/flags_management.php old mode 100644 new mode 100755 index 6911fdc..3c5877a --- a/app/Views/flags/flags_management.php +++ b/app/Views/flags/flags_management.php @@ -107,7 +107,10 @@ -
+ - +
-
- - - - - + + Send Email - - +
+ diff --git a/app/Views/grading/below_sixty_email_editor.php b/app/Views/grading/below_sixty_email_editor.php new file mode 100755 index 0000000..dbd1f5b --- /dev/null +++ b/app/Views/grading/below_sixty_email_editor.php @@ -0,0 +1,96 @@ +extend('layout/management_layout') ?> +section('content') ?> + +
+
+
+
+

Edit Email

+
+ +
+
+ + Back to Below 60 + +
+ +
+ + + + + + +
+
+ + +
+
+ + +
Use the toolbar to format the message before sending.
+ +
+
+ + Cancel + + +
+
+
+
+
+ +endSection() ?> + +section('scripts') ?> + + +endSection() ?> diff --git a/app/Views/grading/comments.php b/app/Views/grading/comments.php old mode 100644 new mode 100755 diff --git a/app/Views/grading/final.php b/app/Views/grading/final.php old mode 100644 new mode 100755 diff --git a/app/Views/grading/grading_main.php b/app/Views/grading/grading_main.php old mode 100644 new mode 100755 diff --git a/app/Views/grading/homework.php b/app/Views/grading/homework.php old mode 100644 new mode 100755 diff --git a/app/Views/grading/homework_tracking.php b/app/Views/grading/homework_tracking.php old mode 100644 new mode 100755 index 26a524d..9e2f32d --- a/app/Views/grading/homework_tracking.php +++ b/app/Views/grading/homework_tracking.php @@ -70,7 +70,8 @@ $todayYmd = local_date(utc_now(), 'Y-m-d'); Grade Teacher & TAs - $label): $ymd = $sundays[$i] ?? ''; ?> + HW Submitted + $label): $ymd = $sundays[$i] ?? ''; ?> $todayYmd); ?> @@ -100,6 +101,9 @@ $todayYmd = local_date(utc_now(), 'Y-m-d');
TA:
+ + + — diff --git a/app/Views/grading/midterm.php b/app/Views/grading/midterm.php old mode 100644 new mode 100755 diff --git a/app/Views/grading/participation.php b/app/Views/grading/participation.php old mode 100644 new mode 100755 diff --git a/app/Views/grading/placement.php b/app/Views/grading/placement.php old mode 100644 new mode 100755 diff --git a/app/Views/grading/placement_batch.php b/app/Views/grading/placement_batch.php old mode 100644 new mode 100755 diff --git a/app/Views/grading/placement_index.php b/app/Views/grading/placement_index.php old mode 100644 new mode 100755 diff --git a/app/Views/grading/project.php b/app/Views/grading/project.php old mode 100644 new mode 100755 diff --git a/app/Views/grading/quiz.php b/app/Views/grading/quiz.php old mode 100644 new mode 100755 diff --git a/app/Views/grading/schedule_meeting.php b/app/Views/grading/schedule_meeting.php old mode 100644 new mode 100755 diff --git a/app/Views/grading/test.php b/app/Views/grading/test.php old mode 100644 new mode 100755 diff --git a/app/Views/header.php b/app/Views/header.php old mode 100644 new mode 100755 diff --git a/app/Views/home.php b/app/Views/home.php old mode 100644 new mode 100755 diff --git a/app/Views/index.php b/app/Views/index.php old mode 100644 new mode 100755 index ec6547d..430b25a --- a/app/Views/index.php +++ b/app/Views/index.php @@ -322,6 +322,72 @@ min-height: 400px; } + .home-stats { + background: #ffffff; + border-radius: 16px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08); + padding: 2.5rem 1.5rem; + } + + .home-stats .stats-row { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + justify-content: center; + } + + .home-stats .stat-circle { + width: 190px; + height: 190px; + border-radius: 50%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + color: #ffffff; + font-weight: 700; + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.12); + } + + .home-stats .stat-value { + font-size: 1.8rem; + line-height: 1; + } + + .home-stats .stat-icon { + font-size: 2rem; + margin-bottom: .2rem; + opacity: .9; + } + + .home-stats .stat-title { + font-size: 1.1rem; + margin-top: .25rem; + font-weight: 500; + text-align: center; + line-height: 1.2; + } + + .home-stats .circle-primary { + background: radial-gradient(circle at 30% 30%, #0d6efd, #0044aa); + } + + .home-stats .circle-success { + background: radial-gradient(circle at 30% 30%, #198754, #0c4f2c); + } + + .home-stats .circle-warning { + background: radial-gradient(circle at 30% 30%, #daa544ff, #a66f00); + } + + .home-stats .circle-info { + background: radial-gradient(circle at 30% 30%, #0dcaf0, #047e9c); + } + + .home-stats .circle-light { + background: radial-gradient(circle at 30% 30%, #63af4cff, #00eb7dff); + } + @media (max-width: 992px) { .image-column { min-height: 300px; @@ -332,6 +398,13 @@ border-radius: 0 0 10px 10px !important; } } + + @media (max-width: 768px) { + .home-stats .stat-circle { + width: 160px; + height: 160px; + } + } @@ -448,8 +521,43 @@

Join Al Rahma family today! Click here for a quick guide on how to create an account.

- - + +
+
+
+

Active Participants

+
+
+
+
+
+
Students
+
+
+
+
+
Teachers
+
+
+
+
+
TAs
+
+
+
+
+
Admins
+
+
+
+
+
Parents
+
+
+
+
+
+
@@ -461,18 +569,18 @@

Under the umbrella of ISGL, Al Rahma Sunday School has been serving the community for over thirty years. Throughout the decades, it has provided students with a strong foundation in Islamic Studies and Quran, fostering both knowledge and character. With its dedicated teachers and well-rounded academic program, the school continues to guide generations of Muslim youth in their faith, values and practice of Islam.

For more information, click below to visit ISGL official website.

- - - -
- - ISGL Official Website - -
-
+ + + + ISGL Official Website + + + + +
@@ -677,6 +785,57 @@ } }); + diff --git a/app/Views/inventory/adjust_form.php b/app/Views/inventory/adjust_form.php old mode 100644 new mode 100755 diff --git a/app/Views/inventory/book/form.php b/app/Views/inventory/book/form.php old mode 100644 new mode 100755 diff --git a/app/Views/inventory/book/index.php b/app/Views/inventory/book/index.php old mode 100644 new mode 100755 diff --git a/app/Views/inventory/classroom/audit_form.php b/app/Views/inventory/classroom/audit_form.php old mode 100644 new mode 100755 diff --git a/app/Views/inventory/classroom/form.php b/app/Views/inventory/classroom/form.php old mode 100644 new mode 100755 diff --git a/app/Views/inventory/classroom/index.php b/app/Views/inventory/classroom/index.php old mode 100644 new mode 100755 diff --git a/app/Views/inventory/kitchen/form.php b/app/Views/inventory/kitchen/form.php old mode 100644 new mode 100755 diff --git a/app/Views/inventory/kitchen/index.php b/app/Views/inventory/kitchen/index.php old mode 100644 new mode 100755 diff --git a/app/Views/inventory/movements/edit.php b/app/Views/inventory/movements/edit.php old mode 100644 new mode 100755 diff --git a/app/Views/inventory/movements/form.php b/app/Views/inventory/movements/form.php old mode 100644 new mode 100755 diff --git a/app/Views/inventory/movements/index.php b/app/Views/inventory/movements/index.php old mode 100644 new mode 100755 diff --git a/app/Views/inventory/office/form.php b/app/Views/inventory/office/form.php old mode 100644 new mode 100755 diff --git a/app/Views/inventory/office/index.php b/app/Views/inventory/office/index.php old mode 100644 new mode 100755 diff --git a/app/Views/inventory/summary.php b/app/Views/inventory/summary.php old mode 100644 new mode 100755 diff --git a/app/Views/inventory/teacher_distribute.php b/app/Views/inventory/teacher_distribute.php old mode 100644 new mode 100755 diff --git a/app/Views/invoice_payment/invoice_management.php b/app/Views/invoice_payment/invoice_management.php old mode 100644 new mode 100755 diff --git a/app/Views/invoice_payment/pdf_template.php b/app/Views/invoice_payment/pdf_template.php old mode 100644 new mode 100755 diff --git a/app/Views/landing_page/admin_dashboard.php b/app/Views/landing_page/admin_dashboard.php old mode 100644 new mode 100755 diff --git a/app/Views/landing_page/guest_dashboard.php b/app/Views/landing_page/guest_dashboard.php old mode 100644 new mode 100755 diff --git a/app/Views/landing_page/parent_dashboard.php b/app/Views/landing_page/parent_dashboard.php old mode 100644 new mode 100755 index 851c488..aa29e9b --- a/app/Views/landing_page/parent_dashboard.php +++ b/app/Views/landing_page/parent_dashboard.php @@ -121,6 +121,15 @@ + + +
@@ -131,7 +140,7 @@ Enrollment deadline:
- +
    diff --git a/app/Views/landing_page/student_dashboard.php b/app/Views/landing_page/student_dashboard.php old mode 100644 new mode 100755 diff --git a/app/Views/landing_page/teacher_dashboard.php b/app/Views/landing_page/teacher_dashboard.php old mode 100644 new mode 100755 diff --git a/app/Views/layout/email_layout.php b/app/Views/layout/email_layout.php old mode 100644 new mode 100755 diff --git a/app/Views/layout/main.php b/app/Views/layout/main.php old mode 100644 new mode 100755 diff --git a/app/Views/layout/main_layout.php b/app/Views/layout/main_layout.php old mode 100644 new mode 100755 index c242c9b..113453e --- a/app/Views/layout/main_layout.php +++ b/app/Views/layout/main_layout.php @@ -2,7 +2,7 @@ - + Al Rahma Sunday School diff --git a/app/Views/layout/management_layout.php b/app/Views/layout/management_layout.php old mode 100644 new mode 100755 index fe72df7..7a7162a --- a/app/Views/layout/management_layout.php +++ b/app/Views/layout/management_layout.php @@ -2,7 +2,7 @@ - + diff --git a/app/Views/layout/register_layout.php b/app/Views/layout/register_layout.php old mode 100644 new mode 100755 diff --git a/app/Views/nav_builder/edit.php b/app/Views/nav_builder/edit.php old mode 100644 new mode 100755 diff --git a/app/Views/nav_builder/index.php b/app/Views/nav_builder/index.php old mode 100644 new mode 100755 diff --git a/app/Views/navbar.php b/app/Views/navbar.php old mode 100644 new mode 100755 diff --git a/app/Views/notifications/list_active.php b/app/Views/notifications/list_active.php old mode 100644 new mode 100755 diff --git a/app/Views/notifications/list_deleted.php b/app/Views/notifications/list_deleted.php old mode 100644 new mode 100755 diff --git a/app/Views/pagination/custom_pagination.php b/app/Views/pagination/custom_pagination.php old mode 100644 new mode 100755 diff --git a/app/Views/parent/add_second_parent.php b/app/Views/parent/add_second_parent.php old mode 100644 new mode 100755 diff --git a/app/Views/parent/assignments.php b/app/Views/parent/assignments.php old mode 100644 new mode 100755 diff --git a/app/Views/parent/attendance.php b/app/Views/parent/attendance.php old mode 100644 new mode 100755 diff --git a/app/Views/parent/calendar.php b/app/Views/parent/calendar.php old mode 100644 new mode 100755 diff --git a/app/Views/parent/class_progress_list.php b/app/Views/parent/class_progress_list.php old mode 100644 new mode 100755 index 4baba69..de73821 --- a/app/Views/parent/class_progress_list.php +++ b/app/Views/parent/class_progress_list.php @@ -7,79 +7,105 @@
    Review the weekly reports your child’s teachers submit.
- Reports are grouped by Sunday; click any row to read the full details. + Reports are grouped by student; click a name to expand weekly details.
- +
We couldn’t find any current enrollment for your account. Once your child is assigned to a Sunday class, their teacher’s progress reports will appear here.
- -
No reports submitted yet.
- -
-
- - - - - - - - - - - - - - - - - - -
WeekSubjectsDetails
-
- -
Class:
- -
-
- $section): ?> - -
-
- - -
-
- -
-
- -
-
- - View Weekly Details - -
-
+ +
+ $student): ?> + +
+

+ +

+
+
+ +
No reports submitted yet.
+ +
+ + + + + + + + + + + + + + + + + + +
WeekSubjectsDetails
+
+
+
+ $section): ?> + +
+
+ + +
+
+ +
+
+ +
+
+ + View Weekly Details + +
+
+ +
+
+
+
diff --git a/app/Views/parent/class_progress_view.php b/app/Views/parent/class_progress_view.php old mode 100644 new mode 100755 diff --git a/app/Views/parent/classes.php b/app/Views/parent/classes.php old mode 100644 new mode 100755 diff --git a/app/Views/parent/contact.php b/app/Views/parent/contact.php old mode 100644 new mode 100755 diff --git a/app/Views/parent/edit_emergency_contact.php b/app/Views/parent/edit_emergency_contact.php old mode 100644 new mode 100755 diff --git a/app/Views/parent/edit_student_modal.php b/app/Views/parent/edit_student_modal.php old mode 100644 new mode 100755 diff --git a/app/Views/parent/enroll_classes.php b/app/Views/parent/enroll_classes.php old mode 100644 new mode 100755 diff --git a/app/Views/parent/enroll_failure.php b/app/Views/parent/enroll_failure.php old mode 100644 new mode 100755 diff --git a/app/Views/parent/enroll_success.php b/app/Views/parent/enroll_success.php old mode 100644 new mode 100755 diff --git a/app/Views/parent/event_participation.php b/app/Views/parent/event_participation.php old mode 100644 new mode 100755 index 0eb8963..7ac509d --- a/app/Views/parent/event_participation.php +++ b/app/Views/parent/event_participation.php @@ -24,6 +24,7 @@ +
@@ -42,8 +43,52 @@
- -

+
+
Description
+ +
+ +

No description

+ +
+ + +
+
External Participants
+
+ + + + + + + + + + + + + + + + + + +
Student NameStatusFee
+ (external) + + + + + + + + $
+
+
+
@@ -53,11 +98,10 @@
- - - - - + + + + @@ -66,8 +110,7 @@ $current = $charges[$key]['participation'] ?? ''; ?> - - +
Student First NameStudent Last NameParticipate
Student NameParticipate
diff --git a/app/Views/parent/invoice_payment.php b/app/Views/parent/invoice_payment.php old mode 100644 new mode 100755 index 7cc2563..927d573 --- a/app/Views/parent/invoice_payment.php +++ b/app/Views/parent/invoice_payment.php @@ -143,12 +143,67 @@ $deadline = parseDbDateTime($dueDate, 'UTC', $displayTz); // format either DATE
+ + + + + + +
+
+ Event charges for Invoice # +
+
+
+ + + + + + + + + + + + + + + + + + + + +
EventStudentFeeStatus
$ + + + +
+
+
+
+ +
No invoice found for the selected school year.
+ + endSection() ?> section('scripts') ?> diff --git a/app/Views/parent/no_kids_registred.php b/app/Views/parent/no_kids_registred.php old mode 100644 new mode 100755 diff --git a/app/Views/parent/parent_message.php b/app/Views/parent/parent_message.php old mode 100644 new mode 100755 diff --git a/app/Views/parent/payment_success.php b/app/Views/parent/payment_success.php old mode 100644 new mode 100755 diff --git a/app/Views/parent/payment_view.php b/app/Views/parent/payment_view.php old mode 100644 new mode 100755 diff --git a/app/Views/parent/register_student.php b/app/Views/parent/register_student.php old mode 100644 new mode 100755 index 5084d37..1a40f68 --- a/app/Views/parent/register_student.php +++ b/app/Views/parent/register_student.php @@ -11,7 +11,7 @@ $disableEmergencyBtn = count($emergencies) >= $maxEmergency;
You've reached the maximum number of students ().
- + getFlashdata('error')): ?>
diff --git a/app/Views/parent/report_attendance.php b/app/Views/parent/report_attendance.php old mode 100644 new mode 100755 index 6582594..024b39e --- a/app/Views/parent/report_attendance.php +++ b/app/Views/parent/report_attendance.php @@ -122,15 +122,40 @@
+ attendance['timezone'] ?? user_timezone()); + $tz = new DateTimeZone($tzName ?: 'UTC'); + $nowTz = new DateTime('now', $tz); + $cutoffDate = $nowTz->format('Y-m-d'); + $cutoffTime = $nowTz->format('H:i'); + $cutoffTzAbbr = $nowTz->format('T'); + } catch (\Throwable $e) { + $cutoffDate = ''; + $cutoffTime = ''; + $cutoffTzAbbr = ''; + } + ?> + data-check-url="" + data-cutoff-date="" + data-cutoff-time="" + data-cutoff-threshold="" + data-cutoff-blocked="0"> +
@@ -352,7 +377,10 @@ const dates = selectedDates(); if (!checkUrl || !dateSel || !typeSel) return; if (!ids.length || !dates.length) { - if (submitBtn) submitBtn.disabled = false; + if (submitBtn) { + const cutoffBlocked = form.getAttribute('data-cutoff-blocked') === '1'; + submitBtn.disabled = cutoffBlocked; + } return; } @@ -395,7 +423,10 @@ } if (!data || !data.ok) { - if (submitBtn) submitBtn.disabled = false; + if (submitBtn) { + const cutoffBlocked = form.getAttribute('data-cutoff-blocked') === '1'; + submitBtn.disabled = cutoffBlocked; + } return; } @@ -454,10 +485,16 @@ setAlert('', ''); } - if (submitBtn) submitBtn.disabled = (selectedIds().length === 0); + if (submitBtn) { + const cutoffBlocked = form.getAttribute('data-cutoff-blocked') === '1'; + submitBtn.disabled = cutoffBlocked || (selectedIds().length === 0); + } } catch (e) { setAlert('warn', 'Could not verify submissions right now. You may proceed; server will validate.'); - if (submitBtn) submitBtn.disabled = false; + if (submitBtn) { + const cutoffBlocked = form.getAttribute('data-cutoff-blocked') === '1'; + submitBtn.disabled = cutoffBlocked; + } } } @@ -472,4 +509,55 @@ runCheck(); }); + endSection() ?> diff --git a/app/Views/parent/report_cards.php b/app/Views/parent/report_cards.php new file mode 100755 index 0000000..40dd404 --- /dev/null +++ b/app/Views/parent/report_cards.php @@ -0,0 +1,72 @@ +extend('layout/main_layout') ?> +section('content') ?> +
+
+
+

Report Cards

+
+ +
+
+
+ + getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ + getFlashdata('success')): ?> +
getFlashdata('success')) ?>
+ + + +
No students available for report cards.
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
StudentClass SectionViewedSignatureAction
+ +
+ + + Not signed + +
+ View Report + + + + + + + +
+
+ +
+endSection() ?> diff --git a/app/Views/parent/scores.php b/app/Views/parent/scores.php old mode 100644 new mode 100755 index c515b36..f7cdd0e --- a/app/Views/parent/scores.php +++ b/app/Views/parent/scores.php @@ -1,7 +1,9 @@ extend('layout/main_layout') ?> section('content') ?>
-

Scores

+
+

Scores

+
@@ -73,6 +75,7 @@ $showExamScoresBySemester = $showExamScoresBySemester ?? []; Exam Semester Score + Report Card @@ -96,6 +99,32 @@ $showExamScoresBySemester = $showExamScoresBySemester ?? []; + + + 0) { + $reportYear = (string) ($selectedYear ?? ''); + $reportSemester = (string) ($semester ?? ''); + $reportDate = date('Y-m-d'); + $reportUrl = base_url('parent/report-cards/view/' . $reportStudentId); + $query = http_build_query([ + 'school_year' => $reportYear, + 'semester' => $reportSemester, + 'report_date' => $reportDate, + ]); + if ($query) { + $reportUrl .= '?' . $query; + } + } + ?> + 0): ?> + View + + Unavailable + + + diff --git a/app/Views/parent/success_message.php b/app/Views/parent/success_message.php old mode 100644 new mode 100755 diff --git a/app/Views/parent/withdraw_success.php b/app/Views/parent/withdraw_success.php old mode 100644 new mode 100755 diff --git a/app/Views/partials/academic_filter.php b/app/Views/partials/academic_filter.php old mode 100644 new mode 100755 diff --git a/app/Views/partials/assign_class_student_modal.php b/app/Views/partials/assign_class_student_modal.php old mode 100644 new mode 100755 diff --git a/app/Views/partials/assign_class_teacher_modal.php b/app/Views/partials/assign_class_teacher_modal.php old mode 100644 new mode 100755 diff --git a/app/Views/partials/attendance_notification_modal.php b/app/Views/partials/attendance_notification_modal.php old mode 100644 new mode 100755 diff --git a/app/Views/partials/avatar_menu.php b/app/Views/partials/avatar_menu.php old mode 100644 new mode 100755 diff --git a/app/Views/partials/emergencycontact_form.php b/app/Views/partials/emergencycontact_form.php old mode 100644 new mode 100755 diff --git a/app/Views/partials/flash_messages.php b/app/Views/partials/flash_messages.php old mode 100644 new mode 100755 diff --git a/app/Views/partials/footer.php b/app/Views/partials/footer.php old mode 100644 new mode 100755 diff --git a/app/Views/partials/footer_back.php b/app/Views/partials/footer_back.php old mode 100644 new mode 100755 diff --git a/app/Views/partials/head.php b/app/Views/partials/head.php old mode 100644 new mode 100755 diff --git a/app/Views/partials/head_back.php b/app/Views/partials/head_back.php old mode 100644 new mode 100755 diff --git a/app/Views/partials/header.php b/app/Views/partials/header.php old mode 100644 new mode 100755 diff --git a/app/Views/partials/header_back.php b/app/Views/partials/header_back.php old mode 100644 new mode 100755 diff --git a/app/Views/partials/navbar.php b/app/Views/partials/navbar.php old mode 100644 new mode 100755 index a6cb47c..942ec2a --- a/app/Views/partials/navbar.php +++ b/app/Views/partials/navbar.php @@ -334,6 +334,12 @@ switch ($role) { 'label' => 'Scores', 'title' => 'Review progressive academic score for the year (homeworks, projects and exams).', ], + [ + 'href' => base_url('/parent/report-cards'), + 'icon' => 'bi-file-earmark-text', + 'label' => 'Report Cards', + 'title' => 'View and acknowledge student report cards.', + ], [ 'href' => base_url('/parent/progress'), 'icon' => 'bi-journal-check', diff --git a/app/Views/partials/navbar_back.php b/app/Views/partials/navbar_back.php old mode 100644 new mode 100755 diff --git a/app/Views/partials/navbar_dynamic.php b/app/Views/partials/navbar_dynamic.php old mode 100644 new mode 100755 diff --git a/app/Views/partials/no_invoice_found.php b/app/Views/partials/no_invoice_found.php old mode 100644 new mode 100755 diff --git a/app/Views/partials/sidebar.php b/app/Views/partials/sidebar.php old mode 100644 new mode 100755 diff --git a/app/Views/partials/standard_table.php b/app/Views/partials/standard_table.php old mode 100644 new mode 100755 diff --git a/app/Views/partials/student_form.php b/app/Views/partials/student_form.php old mode 100644 new mode 100755 diff --git a/app/Views/payment/_list_modal.php b/app/Views/payment/_list_modal.php old mode 100644 new mode 100755 diff --git a/app/Views/payment/discount.php b/app/Views/payment/discount.php old mode 100644 new mode 100755 diff --git a/app/Views/payment/extra_charges.php b/app/Views/payment/extra_charges.php old mode 100644 new mode 100755 diff --git a/app/Views/payment/financial_report.php b/app/Views/payment/financial_report.php old mode 100644 new mode 100755 index d9fc296..f2d93ea --- a/app/Views/payment/financial_report.php +++ b/app/Views/payment/financial_report.php @@ -54,6 +54,11 @@ Display Summary Report
+ +
+ Event fees total: $ +
+
@@ -95,8 +100,16 @@ $balanceCalc = (float)$totalAmount - (float)$discounted - (float)$paid - (float)$refunded; if ($balanceCalc < 0) $balanceCalc = 0.0; $balance = $balanceCalc; - $status = ($balanceCalc === 0.0) ? 'Paid' : 'Unpaid'; - $statusClass = ($status === 'Paid') ? 'bg-success' : 'bg-danger'; + if ($balanceCalc <= 0.00001) { + $status = 'Paid'; + $statusClass = 'bg-success'; + } elseif ($paid > 0.00001) { + $status = 'Partially Paid'; + $statusClass = 'bg-warning text-dark'; + } else { + $status = 'Unpaid'; + $statusClass = 'bg-danger'; + } ?> @@ -371,8 +384,15 @@ const total = Number(inv.total_amount || 0); let balance = total - paid - discounted - refunded; if (!Number.isFinite(balance) || balance < 0) balance = 0; - const status = balance === 0 ? 'Paid' : 'Unpaid'; - const statusClass = balance === 0 ? 'bg-success' : 'bg-danger'; + let status = 'Unpaid'; + let statusClass = 'bg-danger'; + if (balance <= 0.00001) { + status = 'Paid'; + statusClass = 'bg-success'; + } else if (paid > 0.00001) { + status = 'Partially Paid'; + statusClass = 'bg-warning text-dark'; + } const tr = document.createElement('tr'); const parentHtml = (inv.parent_id && Number(inv.parent_id) > 0) ? `${inv.parent_name||''}` : diff --git a/app/Views/payment/financial_report_summary.php b/app/Views/payment/financial_report_summary.php old mode 100644 new mode 100755 index bd0b67a..405315f --- a/app/Views/payment/financial_report_summary.php +++ b/app/Views/payment/financial_report_summary.php @@ -32,6 +32,7 @@ + @@ -98,6 +99,9 @@ function loadSummary(){ if (!d || d.ok !== true) return; document.getElementById('summaryPeriod').textContent = 'Report for School Year: ' + (d.schoolYear||''); document.getElementById('sumCharges').textContent = fmt(d.totalCharges); + if (document.getElementById('sumEventFees')) { + document.getElementById('sumEventFees').textContent = fmt(d.totalEventFees || 0); + } if (document.getElementById('sumExtraCharges')) { document.getElementById('sumExtraCharges').textContent = fmt(d.totalExtraCharges || 0); } @@ -127,10 +131,10 @@ function renderCharts(d){ const summaryCtx = document.getElementById('summaryChart').getContext('2d'); window._summaryChart = new Chart(summaryCtx, { type: 'bar', - data: { labels: ['Charges','Paid','Unpaid','Discounts','Refunds','Expenses','Reimbursements','Net'], + data: { labels: ['Charges','Event Fees','Paid','Unpaid','Discounts','Refunds','Expenses','Reimbursements','Net'], datasets: [{ label:'Amount (USD)', data:[ - d.totalCharges||0, d.totalPaid||0, d.totalUnpaid||0, d.totalDiscounts||0, d.totalRefunds||0, d.totalExpenses||0, d.totalReimbursements||0, d.netAmount||0 - ], backgroundColor: ['#007bff','#28a745','#ffc107','#17a2b8','#ffc107','#dc3545','#6f42c1','#20c997']}] }, + d.totalCharges||0, d.totalEventFees||0, d.totalPaid||0, d.totalUnpaid||0, d.totalDiscounts||0, d.totalRefunds||0, d.totalExpenses||0, d.totalReimbursements||0, d.netAmount||0 + ], backgroundColor: ['#007bff','#6610f2','#28a745','#ffc107','#17a2b8','#ffc107','#dc3545','#6f42c1','#20c997']}] }, options: { responsive:true, scales:{ y:{ beginAtZero:true }}} }); @@ -177,11 +181,12 @@ document.addEventListener("DOMContentLoaded", function() { window._summaryChart = new Chart(summaryCtx, { type: 'bar', data: { - labels: ['Charges', 'Paid', 'Unpaid', 'Discounts', 'Refunds', 'Expenses', 'Reimbursements', 'Net'], + labels: ['Charges', 'Event Fees', 'Paid', 'Unpaid', 'Discounts', 'Refunds', 'Expenses', 'Reimbursements', 'Net'], datasets: [{ label: 'Amount (USD)', data: [ , + , , , , @@ -191,7 +196,7 @@ document.addEventListener("DOMContentLoaded", function() { ], backgroundColor: [ - '#007bff', '#28a745', '#ffc107', '#17a2b8', '#ffc107', '#dc3545', '#6f42c1', '#20c997' + '#007bff', '#6610f2', '#28a745', '#ffc107', '#17a2b8', '#ffc107', '#dc3545', '#6f42c1', '#20c997' ] }] }, diff --git a/app/Views/payment/manual_pay.php b/app/Views/payment/manual_pay.php old mode 100644 new mode 100755 diff --git a/app/Views/payment/manual_payment.php b/app/Views/payment/manual_payment.php old mode 100644 new mode 100755 diff --git a/app/Views/payment/notification_management.php b/app/Views/payment/notification_management.php old mode 100644 new mode 100755 diff --git a/app/Views/payment/payment_redirect.php b/app/Views/payment/payment_redirect.php old mode 100644 new mode 100755 diff --git a/app/Views/payment/unpaid_parents.php b/app/Views/payment/unpaid_parents.php old mode 100644 new mode 100755 index 7e88f66..297b796 --- a/app/Views/payment/unpaid_parents.php +++ b/app/Views/payment/unpaid_parents.php @@ -1,7 +1,7 @@ $schoolYears */ -/** @var array $rows */ +/** @var array $rows */ ?> extend('layout/management_layout') ?> @@ -15,8 +15,6 @@ .table thead th { background: var(--mgmt-thead-bg, #f1f3f5); } .actions { white-space: nowrap; } .actions .btn { --bs-btn-padding-y: .25rem; --bs-btn-padding-x: .5rem; } - .email-cell { max-width: 280px; overflow: hidden; text-overflow: ellipsis; } - @media (max-width: 576px){ .email-cell { max-width: 180px; } } /* Disable sticky header for this table to avoid overlap */ table.no-mgmt-sticky thead th { position: static !important; } @@ -49,13 +47,15 @@ +
Total Charges$0.00
Event Fees$0.00
Total Extra Charges$0.00
Total Discounts$0.00
Total Refunds$0.00
- + + @@ -69,7 +69,7 @@ - + # - + - - + + no payment + + installment + + + + + - + + @@ -131,6 +133,7 @@ + diff --git a/app/Views/payment_list.php b/app/Views/payment_list.php old mode 100644 new mode 100755 diff --git a/app/Views/policy/picture_policy.php b/app/Views/policy/picture_policy.php old mode 100644 new mode 100755 diff --git a/app/Views/policy/picture_policy_partial.php b/app/Views/policy/picture_policy_partial.php old mode 100644 new mode 100755 diff --git a/app/Views/policy/school_policy.php b/app/Views/policy/school_policy.php old mode 100644 new mode 100755 diff --git a/app/Views/policy/school_policy_partial.php b/app/Views/policy/school_policy_partial.php old mode 100644 new mode 100755 diff --git a/app/Views/preferences.php b/app/Views/preferences.php old mode 100644 new mode 100755 diff --git a/app/Views/print_requests/admin_index.php b/app/Views/print_requests/admin_index.php old mode 100644 new mode 100755 diff --git a/app/Views/print_requests/teacher_index.php b/app/Views/print_requests/teacher_index.php old mode 100644 new mode 100755 diff --git a/app/Views/printables_reports/badge_form.php b/app/Views/printables_reports/badge_form.php old mode 100644 new mode 100755 diff --git a/app/Views/printables_reports/certificates.php b/app/Views/printables_reports/certificates.php deleted file mode 100644 index e69de29..0000000 diff --git a/app/Views/printables_reports/report_card.php b/app/Views/printables_reports/report_card.php old mode 100644 new mode 100755 index d6cf4b7..3377849 --- a/app/Views/printables_reports/report_card.php +++ b/app/Views/printables_reports/report_card.php @@ -48,6 +48,7 @@ Download +
@@ -67,10 +68,12 @@
ParentEmailNbr of Installements TypeEvent Fees Invoice Amount Applied Discount Paid Amount
No parents with outstanding balance.
No parents with outstanding balance.
- - no payment - - installment - - $$$ -$ $
Totals:$ $ -$ $
- + + + @@ -91,6 +94,7 @@ document.addEventListener('DOMContentLoaded', function(){ const API = ; const COMPLETENESS_API = ; + const ACK_API = ; // add cache-busting cb param const withCB = (u) => u + (u.includes('?') ? '&' : '?') + 'cb=' + Date.now(); @@ -119,6 +123,7 @@ document.addEventListener('DOMContentLoaded', function(){ const $completenessSummary = document.getElementById('completenessSummary'); const $completenessWrap = document.getElementById('completenessTableWrap'); const $completenessBody = document.getElementById('completenessBody'); + const $ackStatus = document.getElementById('ackStatus'); function opt(el, val, label) { const o = document.createElement('option'); o.value = String(val); o.textContent = label; el.appendChild(o); @@ -157,10 +162,40 @@ document.addEventListener('DOMContentLoaded', function(){ // Classes $class.innerHTML = ''; (data.classSections || []).forEach(c => opt($class, c.class_section_id || c.id, c.class_section_name)); + + updateAckStatus(); + } + + async function updateAckStatus() { + if (! $ackStatus) return; + const sid = $student.value; + const y = $year.value || ''; + const s = $sem.value || ''; + if (!sid) { + $ackStatus.textContent = ''; + return; + } + try { + const url = `${ACK_API}?student_id=${encodeURIComponent(sid)}&school_year=${encodeURIComponent(y)}&semester=${encodeURIComponent(s)}`; + const res = await fetch(url, { credentials: 'same-origin' }); + const data = await res.json(); + if (!res.ok || data?.ok === false) { + $ackStatus.textContent = 'Parent acknowledgement: unavailable'; + return; + } + const viewed = data.viewed_at ? `Viewed: ${data.viewed_at}` : 'Viewed: not yet'; + const signed = data.signed_at + ? `Signed: ${data.signed_at} (${data.signed_name || 'name missing'})` + : 'Signed: not yet'; + $ackStatus.textContent = `Parent acknowledgement — ${viewed} · ${signed}`; + } catch (err) { + $ackStatus.textContent = 'Parent acknowledgement: unavailable'; + } } $year.addEventListener('change', function(){ loadMeta(this.value, $sem.value).catch(console.error); }); $sem.addEventListener('change', function(){ loadMeta($year.value, this.value).catch(console.error); }); + $student.addEventListener('change', function(){ updateAckStatus(); }); $viewS.addEventListener('click', function(){ const sid = $student.value; const y = $year.value; const s=$sem.value; @@ -254,10 +289,18 @@ document.addEventListener('DOMContentLoaded', function(){ tdMissing.textContent = missing.length ? missing.join(', ') : 'None'; const tdWarnings = document.createElement('td'); tdWarnings.textContent = warningsList.length ? warningsList.join(', ') : 'None'; + const tdViewed = document.createElement('td'); + tdViewed.textContent = row.viewed_at ? row.viewed_at : 'Not viewed'; + const tdSigned = document.createElement('td'); + tdSigned.textContent = row.signed_at + ? `${row.signed_at}${row.signed_name ? ` (${row.signed_name})` : ''}` + : 'Not signed'; tr.appendChild(tdName); tr.appendChild(tdStatus); tr.appendChild(tdMissing); tr.appendChild(tdWarnings); + tr.appendChild(tdViewed); + tr.appendChild(tdSigned); $completenessBody.appendChild(tr); }); $completenessWrap.hidden = false; diff --git a/app/Views/printables_reports/sticker_form.php b/app/Views/printables_reports/sticker_form.php old mode 100644 new mode 100755 diff --git a/app/Views/profile.php b/app/Views/profile.php old mode 100644 new mode 100755 diff --git a/app/Views/refunds/list.php b/app/Views/refunds/list.php old mode 100644 new mode 100755 diff --git a/app/Views/reimbursements/create.php b/app/Views/reimbursements/create.php old mode 100644 new mode 100755 diff --git a/app/Views/reimbursements/edit.php b/app/Views/reimbursements/edit.php old mode 100644 new mode 100755 diff --git a/app/Views/reimbursements/index.php b/app/Views/reimbursements/index.php old mode 100644 new mode 100755 diff --git a/app/Views/reimbursements/under_processing.php b/app/Views/reimbursements/under_processing.php old mode 100644 new mode 100755 diff --git a/app/Views/rfid/rfid_coming_soon.php b/app/Views/rfid/rfid_coming_soon.php old mode 100644 new mode 100755 diff --git a/app/Views/rfid/rfid_log_view.php b/app/Views/rfid/rfid_log_view.php old mode 100644 new mode 100755 diff --git a/app/Views/rolepermission/add_permission.php b/app/Views/rolepermission/add_permission.php old mode 100644 new mode 100755 diff --git a/app/Views/rolepermission/add_role.php b/app/Views/rolepermission/add_role.php old mode 100644 new mode 100755 diff --git a/app/Views/rolepermission/assign_role.php b/app/Views/rolepermission/assign_role.php old mode 100644 new mode 100755 index 9c295fa..af8e1a9 --- a/app/Views/rolepermission/assign_role.php +++ b/app/Views/rolepermission/assign_role.php @@ -250,6 +250,30 @@ modalInstance.show(); }; + const renderNameCell = (user) => { + const td = document.createElement('td'); + const fullName = `${user?.firstname ?? ''} ${user?.lastname ?? ''}`.trim(); + const label = fullName !== '' ? fullName : '—'; + const roleList = Array.isArray(user?.roles) + ? user.roles.map((role) => (role || '').toString().toLowerCase()) + : []; + const isParent = roleList.includes('parent'); + const uid = Number(user?.id || 0); + + if (isParent && uid > 0) { + const link = document.createElement('a'); + link.href = '#'; + link.className = 'text-decoration-none'; + link.setAttribute('data-family-guardian-id', String(uid)); + link.textContent = label; + td.appendChild(link); + return td; + } + + td.textContent = label; + return td; + }; + const renderTable = () => { if (!tableBody) return; @@ -280,9 +304,7 @@ accountCell.textContent = user.account_id ?? ''; row.appendChild(accountCell); - const nameCell = document.createElement('td'); - nameCell.textContent = `${user.firstname ?? ''} ${user.lastname ?? ''}`.trim(); - row.appendChild(nameCell); + row.appendChild(renderNameCell(user)); const emailCell = document.createElement('td'); emailCell.textContent = user.email ?? ''; diff --git a/app/Views/rolepermission/edit_permission.php b/app/Views/rolepermission/edit_permission.php old mode 100644 new mode 100755 diff --git a/app/Views/rolepermission/edit_role.php b/app/Views/rolepermission/edit_role.php old mode 100644 new mode 100755 diff --git a/app/Views/rolepermission/edit_role_permissions.php b/app/Views/rolepermission/edit_role_permissions.php old mode 100644 new mode 100755 diff --git a/app/Views/rolepermission/list_roles.php b/app/Views/rolepermission/list_roles.php old mode 100644 new mode 100755 diff --git a/app/Views/rolepermission/permissionList.php b/app/Views/rolepermission/permissionList.php old mode 100644 new mode 100755 diff --git a/app/Views/score_analysis/score_prediction.php b/app/Views/score_analysis/score_prediction.php old mode 100644 new mode 100755 diff --git a/app/Views/slips/preview_list.php b/app/Views/slips/preview_list.php old mode 100644 new mode 100755 diff --git a/app/Views/slips/slip_pdf.php b/app/Views/slips/slip_pdf.php old mode 100644 new mode 100755 diff --git a/app/Views/spinner.php b/app/Views/spinner.php old mode 100644 new mode 100755 diff --git a/app/Views/staff/create.php b/app/Views/staff/create.php old mode 100644 new mode 100755 diff --git a/app/Views/staff/edit.php b/app/Views/staff/edit.php old mode 100644 new mode 100755 index 49ed6d9..79aefa8 --- a/app/Views/staff/edit.php +++ b/app/Views/staff/edit.php @@ -22,16 +22,18 @@ - + + --> diff --git a/app/Views/staff/index.php b/app/Views/staff/index.php old mode 100644 new mode 100755 index 14d81d6..edb5b83 --- a/app/Views/staff/index.php +++ b/app/Views/staff/index.php @@ -11,7 +11,7 @@ - +
StudentStudent Status Missing WarningsViewedSigned
diff --git a/app/Views/student/score_card.php b/app/Views/student/score_card.php old mode 100644 new mode 100755 diff --git a/app/Views/student/score_card_list.php b/app/Views/student/score_card_list.php old mode 100644 new mode 100755 diff --git a/app/Views/success.php b/app/Views/success.php old mode 100644 new mode 100755 diff --git a/app/Views/support.php b/app/Views/support.php old mode 100644 new mode 100755 diff --git a/app/Views/support_requests.php b/app/Views/support_requests.php old mode 100644 new mode 100755 diff --git a/app/Views/swagger_ui.php b/app/Views/swagger_ui.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/absence_vacation.php b/app/Views/teacher/absence_vacation.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/add_final_exam.php b/app/Views/teacher/add_final_exam.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/add_homework.php b/app/Views/teacher/add_homework.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/add_midterm_exam.php b/app/Views/teacher/add_midterm_exam.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/add_participation.php b/app/Views/teacher/add_participation.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/add_project.php b/app/Views/teacher/add_project.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/add_quiz.php b/app/Views/teacher/add_quiz.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/add_quiz_column_form.php b/app/Views/teacher/add_quiz_column_form.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/calendar.php b/app/Views/teacher/calendar.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/class_progress_history.php b/app/Views/teacher/class_progress_history.php old mode 100644 new mode 100755 index 4ec2740..3630835 --- a/app/Views/teacher/class_progress_history.php +++ b/app/Views/teacher/class_progress_history.php @@ -3,7 +3,7 @@
-

My Progress Reports

+

Class Progress Reports

Review weekly submissions and compare Islamic Studies with Quran/Arabic.
Submit New Report @@ -78,6 +78,11 @@
+ +
+ Submitted by: +
+
@@ -85,6 +90,7 @@ diff --git a/app/Views/teacher/class_progress_submit.php b/app/Views/teacher/class_progress_submit.php old mode 100644 new mode 100755 index 8133e7d..3a909cc --- a/app/Views/teacher/class_progress_submit.php +++ b/app/Views/teacher/class_progress_submit.php @@ -1,12 +1,16 @@ extend('layout/main_layout') ?> section('content') ?>

- + + + + +

-
Submit weekly progress for a single subject
+
+ +
My Submissions @@ -41,6 +51,10 @@ getFlashdata('success')): ?>
getFlashdata('success')) ?>
+ getFlashdata('confirm_overwrite'); ?> + getFlashdata('warning') && ! $overwritePrompt): ?> +
getFlashdata('warning')) ?>
+ getFlashdata('error')): ?>
getFlashdata('error')) ?>
@@ -54,16 +68,17 @@ - + +
Date Selection
- > $section): ?>
@@ -166,6 +183,14 @@
Add a custom Surah or Arabic target.
+ +
+
+ + +
+
Add a subject or unit not listed above (e.g. Seerah, Fiqh, Akhlaq).
+
@@ -175,21 +200,24 @@
- +
- + +
+
+
- +
- +
@@ -204,7 +232,7 @@
- +
You are not assigned to a class. Contact the administrator to submit progress. @@ -221,12 +249,78 @@ endSection() ?> section('scripts') ?> +getFlashdata('success'); + $submitError = session()->getFlashdata('error'); + $overwriteWarning = session()->getFlashdata('warning'); + $overwritePrompt = session()->getFlashdata('confirm_overwrite'); +?> + + + + + + endSection() ?> diff --git a/app/Views/teacher/class_progress_view.php b/app/Views/teacher/class_progress_view.php old mode 100644 new mode 100755 index 57dbfc8..670c0d8 --- a/app/Views/teacher/class_progress_view.php +++ b/app/Views/teacher/class_progress_view.php @@ -37,6 +37,7 @@
No entry submitted for this subject this week.
+
Submitted by:
:
Activities
diff --git a/app/Views/teacher/class_view.php b/app/Views/teacher/class_view.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/competition_scores/index.php b/app/Views/teacher/competition_scores/index.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/competition_scores/scores.php b/app/Views/teacher/competition_scores/scores.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/drafts.php b/app/Views/teacher/drafts.php old mode 100644 new mode 100755 index a479ce9..2c71fe2 --- a/app/Views/teacher/drafts.php +++ b/app/Views/teacher/drafts.php @@ -1,34 +1,312 @@ extend('layout/main_layout') ?> section('content') ?> -
+ + +
-

Drafts

- +
+

Exam drafts

+ + + +
+ + getFlashdata('success')): ?> +
getFlashdata('success')) ?>
+ + getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ + +
+
+ 'row g-3', 'id' => 'examDraftForm']) ?> + +
+ + +
+
+ + +
+
+ + +
Max MB.
+
+
+ +
+ + +

School year: · Semester:

+ +
+
+ + $status, 'class' => 'bg-secondary text-white']; + $style = !empty($b['style']) ? ' style="' . esc($b['style']) . '"' : ''; + return '' . esc($b['label']) . ''; + }; + ?> + +

Visible submissions

+

Includes your uploads and submitted exam drafts from teachers assigned to the same class-section.

+
-
View Weekly Details + Edit
+
- - - + + + + + + + + - - - - + + + + + + + + + + +
SubjectDateActionsTeacherClassTitle / typeVer.StatusFile linkReviewer commentLast Update
- Edit | - Delete + + +
+ + + +
+ + +
+ + + +
+ + +
+ ' . esc('Ver' . $revNumber . '_' . $rev . ' ' . $name) . ''; + } + } + ?> + +
+ + + — +
-

No drafts found.

+

No submissions yet.

+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TeacherClassTitleVer.StatusSchool yearSemesterExam typeUpdatedActions
+ + View + +
+
+
+endSection() ?> + +section('scripts') ?> + endSection() ?> diff --git a/app/Views/teacher/exam_drafts.php b/app/Views/teacher/exam_drafts.php old mode 100644 new mode 100755 index 95b5b13..cddb642 --- a/app/Views/teacher/exam_drafts.php +++ b/app/Views/teacher/exam_drafts.php @@ -137,6 +137,9 @@ + + View +
No file uploaded
@@ -152,6 +155,9 @@ + + View +
@@ -199,6 +205,9 @@ Download + + View + File missing diff --git a/app/Views/teacher/homework_list.php b/app/Views/teacher/homework_list.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/inbox.php b/app/Views/teacher/inbox.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/no_classes.php b/app/Views/teacher/no_classes.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/scores.php b/app/Views/teacher/scores.php old mode 100644 new mode 100755 index d36cfb3..b4a62a0 --- a/app/Views/teacher/scores.php +++ b/app/Views/teacher/scores.php @@ -308,6 +308,7 @@ id="submitScoresLockBtn" formaction="" formmethod="post" + data-confirm-message="Once you submit, you cannot add any scores or comments. Do you want to continue?" > @@ -430,6 +431,14 @@ form.addEventListener('submit', function(event) { syncCsrfToken(); + const submitter = event.submitter; + if (submitter && submitter.id === 'submitScoresLockBtn') { + const message = submitter.dataset.confirmMessage || 'Submit semester scores?'; + if (!confirm(message)) { + event.preventDefault(); + return; + } + } const errors = []; form.querySelectorAll('textarea[data-first-name]').forEach(function(field) { const value = field.value.trim(); diff --git a/app/Views/teacher/select_semester.php b/app/Views/teacher/select_semester.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/sent.php b/app/Views/teacher/sent.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/showupdate_attendance.php b/app/Views/teacher/showupdate_attendance.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/teacher_assignment.php b/app/Views/teacher/teacher_assignment.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/teacher_contactus.php b/app/Views/teacher/teacher_contactus.php old mode 100644 new mode 100755 index 214f4f0..bea57a9 --- a/app/Views/teacher/teacher_contactus.php +++ b/app/Views/teacher/teacher_contactus.php @@ -194,22 +194,22 @@

Photo Gallery

- " alt=""> +
- " alt=""> +
- " alt=""> +
- " alt=""> +
- " alt=""> +
- " alt=""> +
@@ -279,4 +279,4 @@ - \ No newline at end of file + diff --git a/app/Views/teacher/teacher_message.php b/app/Views/teacher/teacher_message.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/teacher_navbar.php b/app/Views/teacher/teacher_navbar.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/teacher_support.php b/app/Views/teacher/teacher_support.php old mode 100644 new mode 100755 diff --git a/app/Views/teacher/trash.php b/app/Views/teacher/trash.php old mode 100644 new mode 100755 diff --git a/app/Views/team.php b/app/Views/team.php old mode 100644 new mode 100755 index 392b41b..26c8230 --- a/app/Views/team.php +++ b/app/Views/team.php @@ -128,22 +128,22 @@

Photo Gallery

- +
- +
- +
- +
- +
- +
@@ -200,4 +200,4 @@ - \ No newline at end of file + diff --git a/app/Views/testimonial.php b/app/Views/testimonial.php old mode 100644 new mode 100755 index 5263eeb..31597b3 --- a/app/Views/testimonial.php +++ b/app/Views/testimonial.php @@ -180,22 +180,22 @@

Photo Gallery

- +
- +
- +
- +
- +
- +
@@ -252,4 +252,4 @@ - \ No newline at end of file + diff --git a/app/Views/user/create.php b/app/Views/user/create.php old mode 100644 new mode 100755 diff --git a/app/Views/user/edit.php b/app/Views/user/edit.php old mode 100644 new mode 100755 diff --git a/app/Views/user/forgot_password.php b/app/Views/user/forgot_password.php old mode 100644 new mode 100755 diff --git a/app/Views/user/login.php b/app/Views/user/login.php old mode 100644 new mode 100755 index 3b45f8d..5818426 --- a/app/Views/user/login.php +++ b/app/Views/user/login.php @@ -1,25 +1,10 @@ -get('isLoggedIn')) { - header('Location: ' . base_url('/dashboard')); - exit(); -} -?> - extend('layout/register_layout') ?> section('content') ?>
+ - getFlashdata('error')): ?> + getFlashdata('error')): ?> @@ -125,4 +110,4 @@ if ($session->get('isLoggedIn')) { } }); -endSection() ?> \ No newline at end of file +endSection() ?> diff --git a/app/Views/user/login_activity.php b/app/Views/user/login_activity.php old mode 100644 new mode 100755 diff --git a/app/Views/user/password_reset_confirmation.php b/app/Views/user/password_reset_confirmation.php old mode 100644 new mode 100755 diff --git a/app/Views/user/password_set_success.php b/app/Views/user/password_set_success.php old mode 100644 new mode 100755 diff --git a/app/Views/user/register.php b/app/Views/user/register.php old mode 100644 new mode 100755 diff --git a/app/Views/user/reset_password.php b/app/Views/user/reset_password.php old mode 100644 new mode 100755 diff --git a/app/Views/user/set_authorized_user_password.php b/app/Views/user/set_authorized_user_password.php new file mode 100755 index 0000000..9fddda8 --- /dev/null +++ b/app/Views/user/set_authorized_user_password.php @@ -0,0 +1,142 @@ +extend('layout/register_layout') ?> +section('content') ?> + +
+ + + + +

Create Your Password

+
+ + + +
+
+ + + + +
+ + Password must be at least 8 characters long, contain a number, an uppercase letter, a lowercase letter, and one special character: @, -, =, +, *, #, $, %, &, ! + + + Copy and paste are disabled for security reasons. + +
+ +
+
+ + + + +
+ + Passwords do not match. + + + Copy and paste are disabled for security reasons. + +
+ +
+ +
+ +
+ +endSection() ?> + +section('scripts') ?> + +endSection() ?> diff --git a/app/Views/user/set_password.php b/app/Views/user/set_password.php old mode 100644 new mode 100755 diff --git a/app/Views/user/user_list.php b/app/Views/user/user_list.php old mode 100644 new mode 100755 diff --git a/app/Views/user/welcome_back.php b/app/Views/user/welcome_back.php old mode 100644 new mode 100755 diff --git a/app/Views/welcome_message.php b/app/Views/welcome_message.php old mode 100644 new mode 100755 diff --git a/app/Views/whatsapp/manage_links.php b/app/Views/whatsapp/manage_links.php old mode 100644 new mode 100755 diff --git a/app/Views/whatsapp/parent_contacts.php b/app/Views/whatsapp/parent_contacts.php old mode 100644 new mode 100755 diff --git a/app/Views/whatsapp/parent_contacts_by_class.php b/app/Views/whatsapp/parent_contacts_by_class.php old mode 100644 new mode 100755 index f5da0d2..7beb482 --- a/app/Views/whatsapp/parent_contacts_by_class.php +++ b/app/Views/whatsapp/parent_contacts_by_class.php @@ -38,9 +38,9 @@
- - - + + +
@@ -92,13 +92,15 @@ - + + + --> @@ -109,13 +111,15 @@ - + diff --git a/app/Views/winners/competitions/index.php b/app/Views/winners/competitions/index.php old mode 100644 new mode 100755 diff --git a/app/Views/winners/competitions/show.php b/app/Views/winners/competitions/show.php old mode 100644 new mode 100755 diff --git a/app/db_connection.php b/app/db_connection.php old mode 100644 new mode 100755 diff --git a/app/index.html b/app/index.html old mode 100644 new mode 100755 diff --git a/app/old.htaccess.old b/app/old.htaccess.old old mode 100644 new mode 100755 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fba46a4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + db: + image: mysql:8.0 + container_name: mysql_db + restart: unless-stopped + command: + - --max_allowed_packet=${MYSQL_MAX_ALLOWED_PACKET:-512M} + - --innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-1G} + - --tmp_table_size=${MYSQL_TMP_TABLE_SIZE:-256M} + - --max_heap_table_size=${MYSQL_MAX_HEAP_TABLE_SIZE:-256M} + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root} + MYSQL_DATABASE: ${MYSQL_DATABASE:-school} + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + + phpmyadmin: + image: phpmyadmin/phpmyadmin + container_name: phpmyadmin + restart: unless-stopped + platform: linux/amd64 + environment: + PMA_HOST: db + PMA_PORT: 3306 + PMA_USER: root + PMA_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root} + UPLOAD_LIMIT: ${PHPMYADMIN_UPLOAD_LIMIT:-1G} + ports: + - "8081:80" # access phpMyAdmin on http://localhost:8081 + depends_on: + - db + +volumes: + mysql_data: diff --git a/env b/env index da227d3..df0426d 100644 --- a/env +++ b/env @@ -143,7 +143,7 @@ app.forceGlobalSecureRequests = false database.default.hostname = 127.0.0.1 database.default.database = school database.default.username = root -database.default.password = +database.default.password = root database.default.DBDriver = MySQLi database.default.DBPrefix = database.default.port = 3306 diff --git a/env (copy 1) b/env_copy similarity index 100% rename from env (copy 1) rename to env_copy diff --git a/writable/cache/navbar_cd1336c7da8c3f064f0b3b9e0c0a2232 b/writable/cache/navbar_cd1336c7da8c3f064f0b3b9e0c0a2232 index 6614a96..5607b37 100644 --- a/writable/cache/navbar_cd1336c7da8c3f064f0b3b9e0c0a2232 +++ b/writable/cache/navbar_cd1336c7da8c3f064f0b3b9e0c0a2232 @@ -1 +1 @@ -a:3:{s:4:"time";i:1770708429;s:3:"ttl";i:300;s:4:"data";a:19:{i:0;a:12:{s:2:"id";s:2:"63";s:14:"menu_parent_id";N;s:5:"label";s:6:"Family";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-09 15:22:56";s:10:"updated_at";s:19:"2025-10-26 06:33:11";s:10:"deleted_at";N;s:8:"children";a:2:{i:0;a:12:{s:2:"id";s:2:"64";s:14:"menu_parent_id";s:2:"63";s:5:"label";s:16:"Family Managment";s:3:"url";s:12:"family/index";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-09 15:23:49";s:10:"updated_at";s:19:"2025-10-26 06:28:34";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"74";s:14:"menu_parent_id";s:2:"63";s:5:"label";s:13:"Family Update";s:3:"url";s:22:"families/import-legacy";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-10-26 06:29:11";s:10:"updated_at";s:19:"2025-10-26 06:29:11";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:1;a:12:{s:2:"id";s:2:"67";s:14:"menu_parent_id";N;s:5:"label";s:9:"Inventory";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-11 06:24:37";s:10:"updated_at";s:19:"2025-09-16 16:48:58";s:10:"deleted_at";N;s:8:"children";a:6:{i:0;a:12:{s:2:"id";s:2:"56";s:14:"menu_parent_id";s:2:"67";s:5:"label";s:5:"Books";s:3:"url";s:14:"inventory/book";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-08 19:13:48";s:10:"updated_at";s:19:"2025-10-05 03:43:38";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"72";s:14:"menu_parent_id";s:2:"67";s:5:"label";s:26:"Update Inventory Movements";s:3:"url";s:19:"inventory/movements";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-10-02 06:07:53";s:10:"updated_at";s:19:"2025-10-05 03:42:08";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:2;a:12:{s:2:"id";s:2:"55";s:14:"menu_parent_id";s:2:"67";s:5:"label";s:19:"Classroom Equipment";s:3:"url";s:9:"inventory";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"2";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-08 18:56:08";s:10:"updated_at";s:19:"2025-09-16 16:47:32";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:3;a:12:{s:2:"id";s:2:"57";s:14:"menu_parent_id";s:2:"67";s:5:"label";s:16:"Kitchen Supplies";s:3:"url";s:17:"inventory/kitchen";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"3";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-08 19:14:24";s:10:"updated_at";s:19:"2025-09-16 16:48:23";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:4;a:12:{s:2:"id";s:2:"58";s:14:"menu_parent_id";s:2:"67";s:5:"label";s:15:"Office Supplies";s:3:"url";s:16:"inventory/office";s:10:"icon_class";N;s:6:"target";s:6:"_blank";s:10:"sort_order";s:1:"4";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-08 19:14:53";s:10:"updated_at";s:19:"2025-09-10 22:17:05";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:5;a:12:{s:2:"id";s:2:"66";s:14:"menu_parent_id";s:2:"67";s:5:"label";s:7:"Summary";s:3:"url";s:21:"inventory/summary-all";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"4";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-11 05:56:10";s:10:"updated_at";s:19:"2025-09-11 06:28:41";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:2;a:12:{s:2:"id";s:2:"83";s:14:"menu_parent_id";N;s:5:"label";s:15:"TimeOff Request";s:3:"url";s:22:"/administrator/absence";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-11-01 20:58:55";s:10:"updated_at";s:19:"2025-11-01 20:58:55";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:3;a:12:{s:2:"id";s:2:"86";s:14:"menu_parent_id";N;s:5:"label";s:11:"Competition";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-11 07:40:32";s:10:"updated_at";s:19:"2026-01-11 07:40:32";s:10:"deleted_at";N;s:8:"children";a:1:{i:0;a:12:{s:2:"id";s:2:"87";s:14:"menu_parent_id";s:2:"86";s:5:"label";s:17:"Competition Setup";s:3:"url";s:19:"competition-winners";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-11 07:41:19";s:10:"updated_at";s:19:"2026-01-11 07:41:19";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:4;a:12:{s:2:"id";s:2:"88";s:14:"menu_parent_id";N;s:5:"label";s:23:"Notification Management";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-13 19:48:58";s:10:"updated_at";s:19:"2026-01-13 19:48:58";s:10:"deleted_at";N;s:8:"children";a:1:{i:0;a:12:{s:2:"id";s:2:"89";s:14:"menu_parent_id";s:2:"88";s:5:"label";s:18:"Notification Setup";s:3:"url";s:35:"/administrator/notifications_alerts";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-13 19:49:35";s:10:"updated_at";s:19:"2026-01-13 19:49:35";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:5;a:12:{s:2:"id";s:2:"92";s:14:"menu_parent_id";N;s:5:"label";s:10:"Curriculum";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-18 07:23:26";s:10:"updated_at";s:19:"2026-01-18 07:23:26";s:10:"deleted_at";N;s:8:"children";a:2:{i:0;a:12:{s:2:"id";s:2:"91";s:14:"menu_parent_id";s:2:"92";s:5:"label";s:14:"Class Progress";s:3:"url";s:14:"admin/progress";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-15 04:38:48";s:10:"updated_at";s:19:"2026-01-18 07:24:37";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"93";s:14:"menu_parent_id";s:2:"92";s:5:"label";s:18:"Curriculum Subject";s:3:"url";s:32:"administrator/subject-curriculum";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-18 07:25:26";s:10:"updated_at";s:19:"2026-01-18 07:25:26";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:6;a:12:{s:2:"id";s:2:"94";s:14:"menu_parent_id";N;s:5:"label";s:19:"Teacher Submissions";s:3:"url";s:33:"administrator/teacher-submissions";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-25 22:20:24";s:10:"updated_at";s:19:"2026-01-25 22:20:24";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:7;a:12:{s:2:"id";s:2:"95";s:14:"menu_parent_id";N;s:5:"label";s:5:"Exams";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-29 01:51:29";s:10:"updated_at";s:19:"2026-01-29 01:51:29";s:10:"deleted_at";N;s:8:"children";a:1:{i:0;a:12:{s:2:"id";s:2:"96";s:14:"menu_parent_id";s:2:"95";s:5:"label";s:12:"Exams Drafts";s:3:"url";s:26:"/administrator/exam-drafts";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-29 01:52:20";s:10:"updated_at";s:19:"2026-01-29 01:52:20";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:8;a:12:{s:2:"id";s:1:"1";s:14:"menu_parent_id";N;s:5:"label";s:5:"Users";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"10";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:6:{i:0;a:12:{s:2:"id";s:2:"12";s:14:"menu_parent_id";s:1:"1";s:5:"label";s:20:"Active Notifications";s:3:"url";s:20:"notifications/active";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"13";s:14:"menu_parent_id";s:1:"1";s:5:"label";s:21:"Deleted Notifications";s:3:"url";s:21:"notifications/deleted";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"2";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}i:2;a:12:{s:2:"id";s:2:"14";s:14:"menu_parent_id";s:1:"1";s:5:"label";s:14:"Login Activity";s:3:"url";s:19:"user/login_activity";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"3";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 02:55:24";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:3;a:12:{s:2:"id";s:2:"15";s:14:"menu_parent_id";s:1:"1";s:5:"label";s:9:"User List";s:3:"url";s:14:"user/user_list";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"4";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}i:4;a:12:{s:2:"id";s:2:"52";s:14:"menu_parent_id";s:1:"1";s:5:"label";s:14:"Navbar Builder";s:3:"url";s:11:"nav-builder";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"5";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:34:01";s:10:"updated_at";s:19:"2025-09-05 21:57:09";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:5;a:12:{s:2:"id";s:2:"76";s:14:"menu_parent_id";s:1:"1";s:5:"label";s:13:"IP Management";s:3:"url";s:21:"administrator/ip_bans";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"5";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-10-26 06:34:35";s:10:"updated_at";s:19:"2025-10-26 06:34:35";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:9;a:12:{s:2:"id";s:1:"2";s:14:"menu_parent_id";N;s:5:"label";s:5:"Roles";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"20";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 02:56:34";s:10:"deleted_at";N;s:8:"children";a:3:{i:0;a:12:{s:2:"id";s:2:"16";s:14:"menu_parent_id";s:1:"2";s:5:"label";s:16:"Assign User Role";s:3:"url";s:26:"rolepermission/assign_role";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 03:11:09";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"17";s:14:"menu_parent_id";s:1:"2";s:5:"label";s:15:"Role Management";s:3:"url";s:20:"rolepermission/roles";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"2";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 02:55:43";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:2;a:12:{s:2:"id";s:2:"53";s:14:"menu_parent_id";s:1:"2";s:5:"label";s:21:"Permission Management";s:3:"url";s:31:"rolepermission/list_permissions";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"3";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-08 05:25:02";s:10:"updated_at";s:19:"2025-09-12 07:24:13";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:10;a:12:{s:2:"id";s:1:"3";s:14:"menu_parent_id";N;s:5:"label";s:13:"Configuration";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"30";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 02:54:57";s:10:"deleted_at";N;s:8:"children";a:2:{i:0;a:12:{s:2:"id";s:2:"84";s:14:"menu_parent_id";s:1:"3";s:5:"label";s:24:"Edit attendance Comments";s:3:"url";s:34:"administrator/attendance-templates";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-04 04:17:02";s:10:"updated_at";s:19:"2026-01-04 04:17:02";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"18";s:14:"menu_parent_id";s:1:"3";s:5:"label";s:22:"Add/Edit Configuration";s:3:"url";s:32:"configuration/configuration_view";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 02:52:20";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:11;a:12:{s:2:"id";s:1:"4";s:14:"menu_parent_id";N;s:5:"label";s:8:"Staffing";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"40";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:3:{i:0;a:12:{s:2:"id";s:2:"71";s:14:"menu_parent_id";s:1:"4";s:5:"label";s:16:"Staff Attendance";s:3:"url";s:30:"admin/teacher-attendance/month";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-27 04:29:24";s:10:"updated_at";s:19:"2025-09-28 05:39:08";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"19";s:14:"menu_parent_id";s:1:"4";s:5:"label";s:13:"Staff Profile";s:3:"url";s:11:"staff/index";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}i:2;a:12:{s:2:"id";s:2:"20";s:14:"menu_parent_id";s:1:"4";s:5:"label";s:24:"Teacher Class Assignment";s:3:"url";s:38:"administrator/teacher_class_assignment";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"2";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:12;a:12:{s:2:"id";s:1:"5";s:14:"menu_parent_id";N;s:5:"label";s:7:"Parents";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"50";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:1:{i:0;a:12:{s:2:"id";s:2:"21";s:14:"menu_parent_id";s:1:"5";s:5:"label";s:14:"Parent Profile";s:3:"url";s:29:"administrator/parent_profiles";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:13;a:12:{s:2:"id";s:1:"6";s:14:"menu_parent_id";N;s:5:"label";s:15:"Student-Affairs";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"60";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:16:{i:0;a:12:{s:2:"id";s:2:"90";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:14:"Remove Student";s:3:"url";s:31:"/administrator/removed_students";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-14 03:53:43";s:10:"updated_at";s:19:"2026-01-14 03:53:43";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"97";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:14:"Placement Test";s:3:"url";s:17:"grading/placement";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-02-06 03:08:29";s:10:"updated_at";s:19:"2026-02-06 03:08:29";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:2;a:12:{s:2:"id";s:2:"22";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:21:"Attendance Management";s:3:"url";s:30:"administrator/daily_attendance";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-27 05:10:47";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:3;a:12:{s:2:"id";s:2:"23";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:26:"Attendance Tracking System";s:3:"url";s:21:"attendance/violations";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"2";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-10-12 15:13:51";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:4;a:12:{s:2:"id";s:2:"24";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:16:"Attendance Scans";s:3:"url";s:16:"rfid_coming_soon";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"3";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}i:5;a:12:{s:2:"id";s:2:"25";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:12:"Classes List";s:3:"url";s:30:"administrator/class_assignment";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"4";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}i:6;a:12:{s:2:"id";s:2:"78";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:16:"Early Dismissals";s:3:"url";s:27:"attendance/early-dismissals";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"4";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-10-26 06:40:01";s:10:"updated_at";s:19:"2025-10-26 14:57:41";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:7;a:12:{s:2:"id";s:2:"26";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:17:"Emergency Contact";s:3:"url";s:31:"administrator/emergency_contact";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"5";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 02:56:05";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:8;a:12:{s:2:"id";s:2:"27";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:21:"Enrollment-Withdrawal";s:3:"url";s:37:"enroll_withdraw/enrollment_withdrawal";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"6";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-19 06:23:25";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:9;a:12:{s:2:"id";s:2:"28";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:20:"Incidents Management";s:3:"url";s:22:"flags/flags_management";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"7";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2026-01-29 04:26:10";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:10;a:12:{s:2:"id";s:2:"29";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:15:"School Calendar";s:3:"url";s:27:"administrator/calendar_view";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"8";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}i:11;a:12:{s:2:"id";s:2:"77";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:18:"Late Slips History";s:3:"url";s:13:"slips/preview";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"8";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-10-26 06:38:42";s:10:"updated_at";s:19:"2025-10-26 06:38:42";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:12;a:12:{s:2:"id";s:2:"30";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:14:"Score Analysis";s:3:"url";s:15:"report/combined";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"9";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2026-01-26 05:12:17";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:13;a:12:{s:2:"id";s:2:"31";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:16:"Score Management";s:3:"url";s:7:"grading";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"10";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2026-01-26 05:11:48";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:14;a:12:{s:2:"id";s:2:"32";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:24:"Student Class Assignment";s:3:"url";s:38:"administrator/student_class_assignment";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"11";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}i:15;a:12:{s:2:"id";s:2:"33";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:15:"Student Profile";s:3:"url";s:30:"administrator/student_profiles";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"12";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:14;a:12:{s:2:"id";s:1:"7";s:14:"menu_parent_id";N;s:5:"label";s:7:"Classes";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"70";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:3:{i:0;a:12:{s:2:"id";s:2:"79";s:14:"menu_parent_id";s:1:"7";s:5:"label";s:16:"Class Distribute";s:3:"url";s:38:"administrator/sections/auto-distribute";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-10-26 06:41:13";s:10:"updated_at";s:19:"2025-10-26 06:41:13";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"34";s:14:"menu_parent_id";s:1:"7";s:5:"label";s:12:"Classes List";s:3:"url";s:30:"administrator/class_assignment";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 03:08:21";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:2;a:12:{s:2:"id";s:2:"80";s:14:"menu_parent_id";s:1:"7";s:5:"label";s:22:"Section Auto Generator";s:3:"url";s:39:"administrator/sections/promotion-totals";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"3";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-10-26 06:42:02";s:10:"updated_at";s:19:"2025-10-26 06:42:02";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:15;a:12:{s:2:"id";s:1:"8";s:14:"menu_parent_id";N;s:5:"label";s:13:"Communication";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"80";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-16 16:46:08";s:10:"deleted_at";N;s:8:"children";a:6:{i:0;a:12:{s:2:"id";s:2:"65";s:14:"menu_parent_id";s:1:"8";s:5:"label";s:12:"New Students";s:3:"url";s:29:"admin/enrollment/new-students";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-11 03:44:37";s:10:"updated_at";s:19:"2025-09-16 16:45:27";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"73";s:14:"menu_parent_id";s:1:"8";s:5:"label";s:24:"Whatsapp Link Management";s:3:"url";s:9:"whatsapp/";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-10-11 22:58:29";s:10:"updated_at";s:19:"2025-10-11 22:58:29";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:2;a:12:{s:2:"id";s:2:"35";s:14:"menu_parent_id";s:1:"8";s:5:"label";s:22:"Parent Email Extractor";s:3:"url";s:29:"emails/parent_email_extractor";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 03:08:44";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:3;a:12:{s:2:"id";s:2:"36";s:14:"menu_parent_id";s:1:"8";s:5:"label";s:14:"Parent Profile";s:3:"url";s:29:"administrator/parent_profiles";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"2";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-16 16:44:58";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:4;a:12:{s:2:"id";s:2:"37";s:14:"menu_parent_id";s:1:"8";s:5:"label";s:15:"Student Profile";s:3:"url";s:30:"administrator/student_profiles";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"3";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 03:09:19";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:5;a:12:{s:2:"id";s:2:"81";s:14:"menu_parent_id";s:1:"8";s:5:"label";s:16:"Grading Tracking";s:3:"url";s:25:"grading/homework-tracking";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"3";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-10-26 06:43:11";s:10:"updated_at";s:19:"2025-10-26 14:35:52";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:16;a:12:{s:2:"id";s:1:"9";s:14:"menu_parent_id";N;s:5:"label";s:9:"Financial";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"90";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-16 16:56:16";s:10:"deleted_at";N;s:8:"children";a:11:{i:0;a:12:{s:2:"id";s:2:"68";s:14:"menu_parent_id";s:1:"9";s:5:"label";s:17:"Add Extra Charges";s:3:"url";s:13:"admin/charges";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-21 05:05:12";s:10:"updated_at";s:19:"2025-09-21 05:05:12";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"69";s:14:"menu_parent_id";s:1:"9";s:5:"label";s:14:"Manual Payment";s:3:"url";s:18:"payment/manual_pay";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-21 05:06:53";s:10:"updated_at";s:19:"2025-09-21 05:06:53";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:2;a:12:{s:2:"id";s:2:"82";s:14:"menu_parent_id";s:1:"9";s:5:"label";s:16:"Payment Tracking";s:3:"url";s:22:"payment/unpaid-parents";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-11-01 16:23:39";s:10:"updated_at";s:19:"2025-12-07 16:19:30";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:3;a:12:{s:2:"id";s:2:"38";s:14:"menu_parent_id";s:1:"9";s:5:"label";s:19:"Discount Management";s:3:"url";s:14:"discounts/list";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 02:52:38";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:4;a:12:{s:2:"id";s:2:"39";s:14:"menu_parent_id";s:1:"9";s:5:"label";s:19:"Expenses Management";s:3:"url";s:14:"expenses/index";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"2";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-16 16:57:45";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:5;a:12:{s:2:"id";s:2:"40";s:14:"menu_parent_id";s:1:"9";s:5:"label";s:16:"Financial Report";s:3:"url";s:24:"payment/financial_report";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"3";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-16 16:58:15";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:6;a:12:{s:2:"id";s:2:"41";s:14:"menu_parent_id";s:1:"9";s:5:"label";s:19:"Invoices Management";s:3:"url";s:34:"invoice_payment/invoice_management";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"4";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-16 16:57:07";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:7;a:12:{s:2:"id";s:2:"42";s:14:"menu_parent_id";s:1:"9";s:5:"label";s:14:"Manual Payment";s:3:"url";s:18:"payment/manual_pay";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"5";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-16 16:58:41";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:8;a:12:{s:2:"id";s:2:"43";s:14:"menu_parent_id";s:1:"9";s:5:"label";s:18:"PaypalTransactions";s:3:"url";s:33:"administrator/paypal_transactions";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"6";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}i:9;a:12:{s:2:"id";s:2:"44";s:14:"menu_parent_id";s:1:"9";s:5:"label";s:17:"Refund Management";s:3:"url";s:12:"refunds/list";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"7";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-16 17:02:17";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:10;a:12:{s:2:"id";s:2:"45";s:14:"menu_parent_id";s:1:"9";s:5:"label";s:24:"Reimbursement Management";s:3:"url";s:20:"reimbursements/index";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"8";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-16 17:01:41";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:17;a:12:{s:2:"id";s:2:"10";s:14:"menu_parent_id";N;s:5:"label";s:10:"Printables";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:3:"100";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:5:{i:0;a:12:{s:2:"id";s:2:"85";s:14:"menu_parent_id";s:2:"10";s:5:"label";s:17:"Print for teacher";s:3:"url";s:20:"admin/print-requests";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-07 04:29:16";s:10:"updated_at";s:19:"2026-01-07 04:29:16";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"46";s:14:"menu_parent_id";s:2:"10";s:5:"label";s:6:"Badges";s:3:"url";s:29:"printables_reports/badge_form";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 03:18:24";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:2;a:12:{s:2:"id";s:2:"47";s:14:"menu_parent_id";s:2:"10";s:5:"label";s:10:"Class Prep";s:3:"url";s:10:"class-prep";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"2";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-27 04:31:03";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:3;a:12:{s:2:"id";s:2:"48";s:14:"menu_parent_id";s:2:"10";s:5:"label";s:12:"Report Cards";s:3:"url";s:30:"printables_reports/report_card";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"3";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}i:4;a:12:{s:2:"id";s:2:"49";s:14:"menu_parent_id";s:2:"10";s:5:"label";s:8:"Stickers";s:3:"url";s:27:"printables_reports/stickers";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"4";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:18;a:12:{s:2:"id";s:2:"11";s:14:"menu_parent_id";N;s:5:"label";s:16:"Event Management";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:3:"110";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:2:{i:0;a:12:{s:2:"id";s:2:"50";s:14:"menu_parent_id";s:2:"11";s:5:"label";s:8:"Calendar";s:3:"url";s:22:"administrator/calendar";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 03:20:09";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"51";s:14:"menu_parent_id";s:2:"11";s:5:"label";s:6:"Events";s:3:"url";s:20:"administrator/events";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"2";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}}}}} \ No newline at end of file +a:3:{s:4:"time";i:1778953398;s:3:"ttl";i:300;s:4:"data";a:18:{i:0;a:12:{s:2:"id";s:2:"63";s:14:"menu_parent_id";N;s:5:"label";s:6:"Family";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-09 15:22:56";s:10:"updated_at";s:19:"2025-10-26 06:33:11";s:10:"deleted_at";N;s:8:"children";a:2:{i:0;a:12:{s:2:"id";s:2:"64";s:14:"menu_parent_id";s:2:"63";s:5:"label";s:16:"Family Managment";s:3:"url";s:12:"family/index";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-09 15:23:49";s:10:"updated_at";s:19:"2025-10-26 06:28:34";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"74";s:14:"menu_parent_id";s:2:"63";s:5:"label";s:13:"Family Update";s:3:"url";s:22:"families/import-legacy";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-10-26 06:29:11";s:10:"updated_at";s:19:"2025-10-26 06:29:11";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:1;a:12:{s:2:"id";s:2:"67";s:14:"menu_parent_id";N;s:5:"label";s:9:"Inventory";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-11 06:24:37";s:10:"updated_at";s:19:"2025-09-16 16:48:58";s:10:"deleted_at";N;s:8:"children";a:6:{i:0;a:12:{s:2:"id";s:2:"56";s:14:"menu_parent_id";s:2:"67";s:5:"label";s:5:"Books";s:3:"url";s:14:"inventory/book";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-08 19:13:48";s:10:"updated_at";s:19:"2025-10-05 03:43:38";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"72";s:14:"menu_parent_id";s:2:"67";s:5:"label";s:26:"Update Inventory Movements";s:3:"url";s:19:"inventory/movements";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-10-02 06:07:53";s:10:"updated_at";s:19:"2025-10-05 03:42:08";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:2;a:12:{s:2:"id";s:2:"55";s:14:"menu_parent_id";s:2:"67";s:5:"label";s:19:"Classroom Equipment";s:3:"url";s:9:"inventory";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"2";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-08 18:56:08";s:10:"updated_at";s:19:"2025-09-16 16:47:32";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:3;a:12:{s:2:"id";s:2:"57";s:14:"menu_parent_id";s:2:"67";s:5:"label";s:16:"Kitchen Supplies";s:3:"url";s:17:"inventory/kitchen";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"3";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-08 19:14:24";s:10:"updated_at";s:19:"2025-09-16 16:48:23";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:4;a:12:{s:2:"id";s:2:"58";s:14:"menu_parent_id";s:2:"67";s:5:"label";s:15:"Office Supplies";s:3:"url";s:16:"inventory/office";s:10:"icon_class";N;s:6:"target";s:6:"_blank";s:10:"sort_order";s:1:"4";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-08 19:14:53";s:10:"updated_at";s:19:"2025-09-10 22:17:05";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:5;a:12:{s:2:"id";s:2:"66";s:14:"menu_parent_id";s:2:"67";s:5:"label";s:7:"Summary";s:3:"url";s:21:"inventory/summary-all";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"4";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-11 05:56:10";s:10:"updated_at";s:19:"2025-09-11 06:28:41";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:2;a:12:{s:2:"id";s:2:"83";s:14:"menu_parent_id";N;s:5:"label";s:15:"TimeOff Request";s:3:"url";s:22:"/administrator/absence";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-11-01 20:58:55";s:10:"updated_at";s:19:"2025-11-01 20:58:55";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:3;a:12:{s:2:"id";s:2:"88";s:14:"menu_parent_id";N;s:5:"label";s:23:"Notification Management";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-13 19:48:58";s:10:"updated_at";s:19:"2026-01-13 19:48:58";s:10:"deleted_at";N;s:8:"children";a:1:{i:0;a:12:{s:2:"id";s:2:"89";s:14:"menu_parent_id";s:2:"88";s:5:"label";s:18:"Notification Setup";s:3:"url";s:35:"/administrator/notifications_alerts";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-13 19:49:35";s:10:"updated_at";s:19:"2026-01-13 19:49:35";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:4;a:12:{s:2:"id";s:2:"92";s:14:"menu_parent_id";N;s:5:"label";s:10:"Curriculum";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-18 07:23:26";s:10:"updated_at";s:19:"2026-01-18 07:23:26";s:10:"deleted_at";N;s:8:"children";a:3:{i:0;a:12:{s:2:"id";s:2:"94";s:14:"menu_parent_id";s:2:"92";s:5:"label";s:19:"Teacher Submissions";s:3:"url";s:33:"administrator/teacher-submissions";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-25 22:20:24";s:10:"updated_at";s:19:"2026-03-29 15:58:33";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"93";s:14:"menu_parent_id";s:2:"92";s:5:"label";s:18:"Curriculum Subject";s:3:"url";s:32:"administrator/subject-curriculum";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-18 07:25:26";s:10:"updated_at";s:19:"2026-01-18 07:25:26";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:2;a:12:{s:2:"id";s:2:"91";s:14:"menu_parent_id";s:2:"92";s:5:"label";s:14:"Class Progress";s:3:"url";s:14:"admin/progress";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-15 04:38:48";s:10:"updated_at";s:19:"2026-01-18 07:24:37";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:5;a:12:{s:2:"id";s:2:"95";s:14:"menu_parent_id";N;s:5:"label";s:5:"Exams";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-29 01:51:29";s:10:"updated_at";s:19:"2026-01-29 01:51:29";s:10:"deleted_at";N;s:8:"children";a:1:{i:0;a:12:{s:2:"id";s:2:"96";s:14:"menu_parent_id";s:2:"95";s:5:"label";s:12:"Exams Drafts";s:3:"url";s:26:"/administrator/exam-drafts";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-29 01:52:20";s:10:"updated_at";s:19:"2026-01-29 01:52:20";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:6;a:12:{s:2:"id";s:2:"86";s:14:"menu_parent_id";N;s:5:"label";s:11:"Competition";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-11 07:40:32";s:10:"updated_at";s:19:"2026-01-11 07:40:32";s:10:"deleted_at";N;s:8:"children";a:1:{i:0;a:12:{s:2:"id";s:2:"87";s:14:"menu_parent_id";s:2:"86";s:5:"label";s:17:"Competition Setup";s:3:"url";s:19:"competition-winners";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-11 07:41:19";s:10:"updated_at";s:19:"2026-01-11 07:41:19";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:7;a:12:{s:2:"id";s:1:"1";s:14:"menu_parent_id";N;s:5:"label";s:5:"Users";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"10";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:6:{i:0;a:12:{s:2:"id";s:2:"12";s:14:"menu_parent_id";s:1:"1";s:5:"label";s:20:"Active Notifications";s:3:"url";s:20:"notifications/active";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"13";s:14:"menu_parent_id";s:1:"1";s:5:"label";s:21:"Deleted Notifications";s:3:"url";s:21:"notifications/deleted";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"2";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}i:2;a:12:{s:2:"id";s:2:"14";s:14:"menu_parent_id";s:1:"1";s:5:"label";s:14:"Login Activity";s:3:"url";s:19:"user/login_activity";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"3";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 02:55:24";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:3;a:12:{s:2:"id";s:2:"15";s:14:"menu_parent_id";s:1:"1";s:5:"label";s:9:"User List";s:3:"url";s:14:"user/user_list";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"4";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}i:4;a:12:{s:2:"id";s:2:"52";s:14:"menu_parent_id";s:1:"1";s:5:"label";s:14:"Navbar Builder";s:3:"url";s:11:"nav-builder";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"5";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:34:01";s:10:"updated_at";s:19:"2025-09-05 21:57:09";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:5;a:12:{s:2:"id";s:2:"76";s:14:"menu_parent_id";s:1:"1";s:5:"label";s:13:"IP Management";s:3:"url";s:21:"administrator/ip_bans";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"5";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-10-26 06:34:35";s:10:"updated_at";s:19:"2025-10-26 06:34:35";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:8;a:12:{s:2:"id";s:1:"2";s:14:"menu_parent_id";N;s:5:"label";s:5:"Roles";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"20";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 02:56:34";s:10:"deleted_at";N;s:8:"children";a:3:{i:0;a:12:{s:2:"id";s:2:"16";s:14:"menu_parent_id";s:1:"2";s:5:"label";s:16:"Assign User Role";s:3:"url";s:26:"rolepermission/assign_role";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 03:11:09";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"17";s:14:"menu_parent_id";s:1:"2";s:5:"label";s:15:"Role Management";s:3:"url";s:20:"rolepermission/roles";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"2";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 02:55:43";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:2;a:12:{s:2:"id";s:2:"53";s:14:"menu_parent_id";s:1:"2";s:5:"label";s:21:"Permission Management";s:3:"url";s:31:"rolepermission/list_permissions";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"3";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-08 05:25:02";s:10:"updated_at";s:19:"2025-09-12 07:24:13";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:9;a:12:{s:2:"id";s:1:"3";s:14:"menu_parent_id";N;s:5:"label";s:13:"Configuration";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"30";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 02:54:57";s:10:"deleted_at";N;s:8:"children";a:2:{i:0;a:12:{s:2:"id";s:2:"84";s:14:"menu_parent_id";s:1:"3";s:5:"label";s:24:"Edit attendance Comments";s:3:"url";s:34:"administrator/attendance-templates";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-04 04:17:02";s:10:"updated_at";s:19:"2026-01-04 04:17:02";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"18";s:14:"menu_parent_id";s:1:"3";s:5:"label";s:22:"Add/Edit Configuration";s:3:"url";s:32:"configuration/configuration_view";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 02:52:20";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:10;a:12:{s:2:"id";s:1:"4";s:14:"menu_parent_id";N;s:5:"label";s:8:"Staffing";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"40";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:3:{i:0;a:12:{s:2:"id";s:2:"71";s:14:"menu_parent_id";s:1:"4";s:5:"label";s:16:"Staff Attendance";s:3:"url";s:30:"admin/teacher-attendance/month";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-27 04:29:24";s:10:"updated_at";s:19:"2025-09-28 05:39:08";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"19";s:14:"menu_parent_id";s:1:"4";s:5:"label";s:13:"Staff Profile";s:3:"url";s:11:"staff/index";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}i:2;a:12:{s:2:"id";s:2:"20";s:14:"menu_parent_id";s:1:"4";s:5:"label";s:24:"Teacher Class Assignment";s:3:"url";s:38:"administrator/teacher_class_assignment";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"2";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:11;a:12:{s:2:"id";s:1:"5";s:14:"menu_parent_id";N;s:5:"label";s:7:"Parents";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"50";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:1:{i:0;a:12:{s:2:"id";s:2:"21";s:14:"menu_parent_id";s:1:"5";s:5:"label";s:14:"Parent Profile";s:3:"url";s:29:"administrator/parent_profiles";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:12;a:12:{s:2:"id";s:1:"6";s:14:"menu_parent_id";N;s:5:"label";s:15:"Student-Affairs";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"60";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:16:{i:0;a:12:{s:2:"id";s:2:"90";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:14:"Remove Student";s:3:"url";s:31:"/administrator/removed_students";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-14 03:53:43";s:10:"updated_at";s:19:"2026-01-14 03:53:43";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"97";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:14:"Placement Test";s:3:"url";s:17:"grading/placement";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-02-06 03:08:29";s:10:"updated_at";s:19:"2026-02-06 03:08:29";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:2;a:12:{s:2:"id";s:2:"22";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:21:"Attendance Management";s:3:"url";s:30:"administrator/daily_attendance";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-27 05:10:47";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:3;a:12:{s:2:"id";s:2:"23";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:26:"Attendance Tracking System";s:3:"url";s:21:"attendance/violations";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"2";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-10-12 15:13:51";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:4;a:12:{s:2:"id";s:2:"24";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:16:"Attendance Scans";s:3:"url";s:16:"rfid_coming_soon";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"3";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}i:5;a:12:{s:2:"id";s:2:"25";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:12:"Classes List";s:3:"url";s:30:"administrator/class_assignment";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"4";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}i:6;a:12:{s:2:"id";s:2:"78";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:16:"Early Dismissals";s:3:"url";s:27:"attendance/early-dismissals";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"4";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-10-26 06:40:01";s:10:"updated_at";s:19:"2025-10-26 14:57:41";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:7;a:12:{s:2:"id";s:2:"26";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:17:"Emergency Contact";s:3:"url";s:31:"administrator/emergency_contact";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"5";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 02:56:05";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:8;a:12:{s:2:"id";s:2:"27";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:21:"Enrollment-Withdrawal";s:3:"url";s:37:"enroll_withdraw/enrollment_withdrawal";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"6";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-19 06:23:25";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:9;a:12:{s:2:"id";s:2:"28";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:20:"Incidents Management";s:3:"url";s:22:"flags/flags_management";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"7";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2026-01-29 04:26:10";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:10;a:12:{s:2:"id";s:2:"29";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:15:"School Calendar";s:3:"url";s:27:"administrator/calendar_view";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"8";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}i:11;a:12:{s:2:"id";s:2:"77";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:18:"Late Slips History";s:3:"url";s:13:"slips/preview";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"8";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-10-26 06:38:42";s:10:"updated_at";s:19:"2025-10-26 06:38:42";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:12;a:12:{s:2:"id";s:2:"30";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:14:"Score Analysis";s:3:"url";s:15:"report/combined";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"9";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2026-01-26 05:12:17";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:13;a:12:{s:2:"id";s:2:"31";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:16:"Score Management";s:3:"url";s:7:"grading";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"10";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2026-01-26 05:11:48";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:14;a:12:{s:2:"id";s:2:"32";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:24:"Student Class Assignment";s:3:"url";s:38:"administrator/student_class_assignment";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"11";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}i:15;a:12:{s:2:"id";s:2:"33";s:14:"menu_parent_id";s:1:"6";s:5:"label";s:15:"Student Profile";s:3:"url";s:30:"administrator/student_profiles";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"12";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:13;a:12:{s:2:"id";s:1:"7";s:14:"menu_parent_id";N;s:5:"label";s:7:"Classes";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"70";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:3:{i:0;a:12:{s:2:"id";s:2:"79";s:14:"menu_parent_id";s:1:"7";s:5:"label";s:16:"Class Distribute";s:3:"url";s:38:"administrator/sections/auto-distribute";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-10-26 06:41:13";s:10:"updated_at";s:19:"2025-10-26 06:41:13";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"34";s:14:"menu_parent_id";s:1:"7";s:5:"label";s:12:"Classes List";s:3:"url";s:30:"administrator/class_assignment";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 03:08:21";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:2;a:12:{s:2:"id";s:2:"80";s:14:"menu_parent_id";s:1:"7";s:5:"label";s:22:"Section Auto Generator";s:3:"url";s:39:"administrator/sections/promotion-totals";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"3";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-10-26 06:42:02";s:10:"updated_at";s:19:"2025-10-26 06:42:02";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:14;a:12:{s:2:"id";s:1:"8";s:14:"menu_parent_id";N;s:5:"label";s:13:"Communication";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"80";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-16 16:46:08";s:10:"deleted_at";N;s:8:"children";a:7:{i:0;a:12:{s:2:"id";s:2:"98";s:14:"menu_parent_id";s:1:"8";s:5:"label";s:16:"Below 60 Summary";s:3:"url";s:16:"grading/below-60";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-03-01 21:50:13";s:10:"updated_at";s:19:"2026-03-01 21:50:13";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"65";s:14:"menu_parent_id";s:1:"8";s:5:"label";s:12:"New Students";s:3:"url";s:29:"admin/enrollment/new-students";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-11 03:44:37";s:10:"updated_at";s:19:"2025-09-16 16:45:27";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:2;a:12:{s:2:"id";s:2:"73";s:14:"menu_parent_id";s:1:"8";s:5:"label";s:24:"Whatsapp Link Management";s:3:"url";s:9:"whatsapp/";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-10-11 22:58:29";s:10:"updated_at";s:19:"2025-10-11 22:58:29";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:3;a:12:{s:2:"id";s:2:"35";s:14:"menu_parent_id";s:1:"8";s:5:"label";s:22:"Parent Email Extractor";s:3:"url";s:29:"emails/parent_email_extractor";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 03:08:44";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:4;a:12:{s:2:"id";s:2:"36";s:14:"menu_parent_id";s:1:"8";s:5:"label";s:14:"Parent Profile";s:3:"url";s:29:"administrator/parent_profiles";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"2";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-16 16:44:58";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:5;a:12:{s:2:"id";s:2:"37";s:14:"menu_parent_id";s:1:"8";s:5:"label";s:15:"Student Profile";s:3:"url";s:30:"administrator/student_profiles";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"3";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 03:09:19";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:6;a:12:{s:2:"id";s:2:"81";s:14:"menu_parent_id";s:1:"8";s:5:"label";s:16:"Grading Tracking";s:3:"url";s:25:"grading/homework-tracking";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"3";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-10-26 06:43:11";s:10:"updated_at";s:19:"2025-10-26 14:35:52";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:15;a:12:{s:2:"id";s:1:"9";s:14:"menu_parent_id";N;s:5:"label";s:9:"Financial";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:2:"90";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-16 16:56:16";s:10:"deleted_at";N;s:8:"children";a:11:{i:0;a:12:{s:2:"id";s:2:"68";s:14:"menu_parent_id";s:1:"9";s:5:"label";s:17:"Add Extra Charges";s:3:"url";s:13:"admin/charges";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-21 05:05:12";s:10:"updated_at";s:19:"2025-09-21 05:05:12";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"69";s:14:"menu_parent_id";s:1:"9";s:5:"label";s:14:"Manual Payment";s:3:"url";s:18:"payment/manual_pay";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-21 05:06:53";s:10:"updated_at";s:19:"2025-09-21 05:06:53";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:2;a:12:{s:2:"id";s:2:"82";s:14:"menu_parent_id";s:1:"9";s:5:"label";s:16:"Payment Tracking";s:3:"url";s:22:"payment/unpaid-parents";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-11-01 16:23:39";s:10:"updated_at";s:19:"2025-12-07 16:19:30";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:3;a:12:{s:2:"id";s:2:"38";s:14:"menu_parent_id";s:1:"9";s:5:"label";s:19:"Discount Management";s:3:"url";s:14:"discounts/list";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 02:52:38";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:4;a:12:{s:2:"id";s:2:"39";s:14:"menu_parent_id";s:1:"9";s:5:"label";s:19:"Expenses Management";s:3:"url";s:14:"expenses/index";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"2";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-16 16:57:45";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:5;a:12:{s:2:"id";s:2:"40";s:14:"menu_parent_id";s:1:"9";s:5:"label";s:16:"Financial Report";s:3:"url";s:24:"payment/financial_report";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"3";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-16 16:58:15";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:6;a:12:{s:2:"id";s:2:"41";s:14:"menu_parent_id";s:1:"9";s:5:"label";s:19:"Invoices Management";s:3:"url";s:34:"invoice_payment/invoice_management";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"4";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-16 16:57:07";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:7;a:12:{s:2:"id";s:2:"42";s:14:"menu_parent_id";s:1:"9";s:5:"label";s:14:"Manual Payment";s:3:"url";s:18:"payment/manual_pay";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"5";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-16 16:58:41";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:8;a:12:{s:2:"id";s:2:"43";s:14:"menu_parent_id";s:1:"9";s:5:"label";s:18:"PaypalTransactions";s:3:"url";s:33:"administrator/paypal_transactions";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"6";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}i:9;a:12:{s:2:"id";s:2:"44";s:14:"menu_parent_id";s:1:"9";s:5:"label";s:17:"Refund Management";s:3:"url";s:12:"refunds/list";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"7";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-16 17:02:17";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:10;a:12:{s:2:"id";s:2:"45";s:14:"menu_parent_id";s:1:"9";s:5:"label";s:24:"Reimbursement Management";s:3:"url";s:20:"reimbursements/index";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"8";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-16 17:01:41";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:16;a:12:{s:2:"id";s:2:"10";s:14:"menu_parent_id";N;s:5:"label";s:10:"Printables";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:3:"100";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:5:{i:0;a:12:{s:2:"id";s:2:"85";s:14:"menu_parent_id";s:2:"10";s:5:"label";s:17:"Print for teacher";s:3:"url";s:20:"admin/print-requests";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"0";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2026-01-07 04:29:16";s:10:"updated_at";s:19:"2026-01-07 04:29:16";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"46";s:14:"menu_parent_id";s:2:"10";s:5:"label";s:6:"Badges";s:3:"url";s:29:"printables_reports/badge_form";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 03:18:24";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:2;a:12:{s:2:"id";s:2:"47";s:14:"menu_parent_id";s:2:"10";s:5:"label";s:10:"Class Prep";s:3:"url";s:10:"class-prep";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"2";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-27 04:31:03";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:3;a:12:{s:2:"id";s:2:"48";s:14:"menu_parent_id";s:2:"10";s:5:"label";s:12:"Report Cards";s:3:"url";s:30:"printables_reports/report_card";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"3";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}i:4;a:12:{s:2:"id";s:2:"49";s:14:"menu_parent_id";s:2:"10";s:5:"label";s:8:"Stickers";s:3:"url";s:27:"printables_reports/stickers";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"4";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:0:{}}}}i:17;a:12:{s:2:"id";s:2:"11";s:14:"menu_parent_id";N;s:5:"label";s:16:"Event Management";s:3:"url";N;s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:3:"110";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";N;s:10:"deleted_at";N;s:8:"children";a:2:{i:0;a:12:{s:2:"id";s:2:"50";s:14:"menu_parent_id";s:2:"11";s:5:"label";s:8:"Calendar";s:3:"url";s:22:"administrator/calendar";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"1";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2025-09-09 03:20:09";s:10:"deleted_at";N;s:8:"children";a:0:{}}i:1;a:12:{s:2:"id";s:2:"51";s:14:"menu_parent_id";s:2:"11";s:5:"label";s:6:"Events";s:3:"url";s:20:"administrator/events";s:10:"icon_class";N;s:6:"target";N;s:10:"sort_order";s:1:"2";s:10:"is_enabled";s:1:"1";s:10:"created_at";s:19:"2025-09-05 17:23:56";s:10:"updated_at";s:19:"2026-04-19 14:58:10";s:10:"deleted_at";N;s:8:"children";a:0:{}}}}}} \ No newline at end of file diff --git a/writable/logs/log-2026-05-16.log b/writable/logs/log-2026-05-16.log new file mode 100644 index 0000000..5d18c18 --- /dev/null +++ b/writable/logs/log-2026-05-16.log @@ -0,0 +1,228 @@ +ERROR - 2026-05-16 17:09:28 --> Error connecting to the database: mysqli_sql_exception: No such file or directory in /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/MySQLi/Connection.php:201 +Stack trace: +#0 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/MySQLi/Connection.php(201): mysqli->real_connect('localhost', 'u280815660_mela...', Object(SensitiveParameterValue), 'u280815660_scho...', 3306, '', 0) +#1 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/BaseConnection.php(419): CodeIgniter\Database\MySQLi\Connection->connect(false) +#2 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/BaseConnection.php(614): CodeIgniter\Database\BaseConnection->initialize() +#3 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/BaseBuilder.php(1649): CodeIgniter\Database\BaseConnection->query('SELECT *\nFROM `...', Array, false) +#4 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Model.php(330): CodeIgniter\Database\BaseBuilder->get() +#5 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/BaseModel.php(715): CodeIgniter\Model->doFirst() +#6 /Users/larbi/Documents/alrahma_sunday_school/app/Models/ConfigurationModel.php(28): CodeIgniter\BaseModel->first() +#7 /Users/larbi/Documents/alrahma_sunday_school/app/Models/ConfigurationModel.php(89): App\Models\ConfigurationModel->getConfigValueByKey('fall_semester_s...') +#8 /Users/larbi/Documents/alrahma_sunday_school/app/Services/SemesterRangeService.php(98): App\Models\ConfigurationModel->getConfig('fall_semester_s...') +#9 /Users/larbi/Documents/alrahma_sunday_school/app/Models/ConfigurationModel.php(80): App\Services\SemesterRangeService->getSemesterForDate() +#10 /Users/larbi/Documents/alrahma_sunday_school/app/Commands/SyncPaypalPayments.php(40): App\Models\ConfigurationModel->getConfig('semester') +#11 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/CLI/Commands.php(130): App\Commands\SyncPaypalPayments->__construct(Object(CodeIgniter\Log\Logger), Object(CodeIgniter\CLI\Commands)) +#12 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/CLI/Commands.php(51): CodeIgniter\CLI\Commands->discoverCommands() +#13 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/Services.php(178): CodeIgniter\CLI\Commands->__construct() +#14 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(312): CodeIgniter\Config\Services::commands(false) +#15 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(251): CodeIgniter\Config\BaseService::__callStatic('commands', Array) +#16 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/Services.php(175): CodeIgniter\Config\BaseService::getSharedInstance('commands') +#17 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(321): CodeIgniter\Config\Services::commands() +#18 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(202): CodeIgniter\Config\BaseService::__callStatic('commands', Array) +#19 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Common.php(995): CodeIgniter\Config\BaseService::get('commands') +#20 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/CLI/Console.php(47): service('commands') +#21 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Boot.php(388): CodeIgniter\CLI\Console->run() +#22 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Boot.php(133): CodeIgniter\Boot::runCommand(Object(CodeIgniter\CLI\Console)) +#23 /Users/larbi/Documents/alrahma_sunday_school/spark(84): CodeIgniter\Boot::bootSpark(Object(Config\Paths)) +#24 {main} + +Next CodeIgniter\Database\Exceptions\DatabaseException: No such file or directory in /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/MySQLi/Connection.php:246 +Stack trace: +#0 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/BaseConnection.php(419): CodeIgniter\Database\MySQLi\Connection->connect(false) +#1 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/BaseConnection.php(614): CodeIgniter\Database\BaseConnection->initialize() +#2 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/BaseBuilder.php(1649): CodeIgniter\Database\BaseConnection->query('SELECT *\nFROM `...', Array, false) +#3 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Model.php(330): CodeIgniter\Database\BaseBuilder->get() +#4 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/BaseModel.php(715): CodeIgniter\Model->doFirst() +#5 /Users/larbi/Documents/alrahma_sunday_school/app/Models/ConfigurationModel.php(28): CodeIgniter\BaseModel->first() +#6 /Users/larbi/Documents/alrahma_sunday_school/app/Models/ConfigurationModel.php(89): App\Models\ConfigurationModel->getConfigValueByKey('fall_semester_s...') +#7 /Users/larbi/Documents/alrahma_sunday_school/app/Services/SemesterRangeService.php(98): App\Models\ConfigurationModel->getConfig('fall_semester_s...') +#8 /Users/larbi/Documents/alrahma_sunday_school/app/Models/ConfigurationModel.php(80): App\Services\SemesterRangeService->getSemesterForDate() +#9 /Users/larbi/Documents/alrahma_sunday_school/app/Commands/SyncPaypalPayments.php(40): App\Models\ConfigurationModel->getConfig('semester') +#10 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/CLI/Commands.php(130): App\Commands\SyncPaypalPayments->__construct(Object(CodeIgniter\Log\Logger), Object(CodeIgniter\CLI\Commands)) +#11 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/CLI/Commands.php(51): CodeIgniter\CLI\Commands->discoverCommands() +#12 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/Services.php(178): CodeIgniter\CLI\Commands->__construct() +#13 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(312): CodeIgniter\Config\Services::commands(false) +#14 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(251): CodeIgniter\Config\BaseService::__callStatic('commands', Array) +#15 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/Services.php(175): CodeIgniter\Config\BaseService::getSharedInstance('commands') +#16 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(321): CodeIgniter\Config\Services::commands() +#17 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(202): CodeIgniter\Config\BaseService::__callStatic('commands', Array) +#18 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Common.php(995): CodeIgniter\Config\BaseService::get('commands') +#19 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/CLI/Console.php(47): service('commands') +#20 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Boot.php(388): CodeIgniter\CLI\Console->run() +#21 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Boot.php(133): CodeIgniter\Boot::runCommand(Object(CodeIgniter\CLI\Console)) +#22 /Users/larbi/Documents/alrahma_sunday_school/spark(84): CodeIgniter\Boot::bootSpark(Object(Config\Paths)) +#23 {main} +CRITICAL - 2026-05-16 17:09:28 --> TypeError: array_merge(): Argument #1 must be of type array, string given +[Method: CLI, Route: /] +in SYSTEMPATH/Database/BaseBuilder.php on line 1506. + 1 SYSTEMPATH/Database/BaseBuilder.php(1506): array_merge(' +ORDER BY `id` DESC', [...]) + 2 SYSTEMPATH/Model.php(925): CodeIgniter\Database\BaseBuilder->orderBy('id', ' DESC') + 3 APPPATH/Models/ConfigurationModel.php(27): CodeIgniter\Model->__call('orderBy', [...]) + 4 APPPATH/Models/ConfigurationModel.php(89): App\Models\ConfigurationModel->getConfigValueByKey('semester') + 5 APPPATH/Commands/SyncPaypalPayments.php(40): App\Models\ConfigurationModel->getConfig('semester') + 6 SYSTEMPATH/CLI/Commands.php(130): App\Commands\SyncPaypalPayments->__construct(Object(CodeIgniter\Log\Logger), Object(CodeIgniter\CLI\Commands)) + 7 SYSTEMPATH/CLI/Commands.php(51): CodeIgniter\CLI\Commands->discoverCommands() + 8 SYSTEMPATH/Config/Services.php(178): CodeIgniter\CLI\Commands->__construct() + 9 SYSTEMPATH/Config/BaseService.php(312): CodeIgniter\Config\Services::commands(false) +10 SYSTEMPATH/Config/BaseService.php(251): CodeIgniter\Config\BaseService::__callStatic('commands', [...]) +11 SYSTEMPATH/Config/Services.php(175): CodeIgniter\Config\BaseService::getSharedInstance('commands') +12 SYSTEMPATH/Config/BaseService.php(321): CodeIgniter\Config\Services::commands() +13 SYSTEMPATH/Config/BaseService.php(202): CodeIgniter\Config\BaseService::__callStatic('commands', []) +14 SYSTEMPATH/Common.php(995): CodeIgniter\Config\BaseService::get('commands') +15 SYSTEMPATH/CLI/Console.php(47): service('commands') +16 SYSTEMPATH/Boot.php(388): CodeIgniter\CLI\Console->run() +17 SYSTEMPATH/Boot.php(133): CodeIgniter\Boot::runCommand(Object(CodeIgniter\CLI\Console)) +18 ROOTPATH/spark(84): CodeIgniter\Boot::bootSpark(Object(Config\Paths)) +ERROR - 2026-05-16 17:09:45 --> Error connecting to the database: mysqli_sql_exception: No such file or directory in /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/MySQLi/Connection.php:201 +Stack trace: +#0 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/MySQLi/Connection.php(201): mysqli->real_connect('localhost', 'u280815660_mela...', Object(SensitiveParameterValue), 'u280815660_scho...', 3306, '', 0) +#1 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/BaseConnection.php(419): CodeIgniter\Database\MySQLi\Connection->connect(false) +#2 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/BaseConnection.php(614): CodeIgniter\Database\BaseConnection->initialize() +#3 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/BaseBuilder.php(1649): CodeIgniter\Database\BaseConnection->query('SELECT *\nFROM `...', Array, false) +#4 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Model.php(330): CodeIgniter\Database\BaseBuilder->get() +#5 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/BaseModel.php(715): CodeIgniter\Model->doFirst() +#6 /Users/larbi/Documents/alrahma_sunday_school/app/Models/ConfigurationModel.php(28): CodeIgniter\BaseModel->first() +#7 /Users/larbi/Documents/alrahma_sunday_school/app/Models/ConfigurationModel.php(89): App\Models\ConfigurationModel->getConfigValueByKey('fall_semester_s...') +#8 /Users/larbi/Documents/alrahma_sunday_school/app/Services/SemesterRangeService.php(98): App\Models\ConfigurationModel->getConfig('fall_semester_s...') +#9 /Users/larbi/Documents/alrahma_sunday_school/app/Models/ConfigurationModel.php(80): App\Services\SemesterRangeService->getSemesterForDate() +#10 /Users/larbi/Documents/alrahma_sunday_school/app/Commands/SyncPaypalPayments.php(40): App\Models\ConfigurationModel->getConfig('semester') +#11 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/CLI/Commands.php(130): App\Commands\SyncPaypalPayments->__construct(Object(CodeIgniter\Log\Logger), Object(CodeIgniter\CLI\Commands)) +#12 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/CLI/Commands.php(51): CodeIgniter\CLI\Commands->discoverCommands() +#13 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/Services.php(178): CodeIgniter\CLI\Commands->__construct() +#14 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(312): CodeIgniter\Config\Services::commands(false) +#15 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(251): CodeIgniter\Config\BaseService::__callStatic('commands', Array) +#16 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/Services.php(175): CodeIgniter\Config\BaseService::getSharedInstance('commands') +#17 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(321): CodeIgniter\Config\Services::commands() +#18 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(202): CodeIgniter\Config\BaseService::__callStatic('commands', Array) +#19 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Common.php(995): CodeIgniter\Config\BaseService::get('commands') +#20 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/CLI/Console.php(47): service('commands') +#21 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Boot.php(388): CodeIgniter\CLI\Console->run() +#22 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Boot.php(133): CodeIgniter\Boot::runCommand(Object(CodeIgniter\CLI\Console)) +#23 /Users/larbi/Documents/alrahma_sunday_school/spark(84): CodeIgniter\Boot::bootSpark(Object(Config\Paths)) +#24 {main} + +Next CodeIgniter\Database\Exceptions\DatabaseException: No such file or directory in /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/MySQLi/Connection.php:246 +Stack trace: +#0 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/BaseConnection.php(419): CodeIgniter\Database\MySQLi\Connection->connect(false) +#1 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/BaseConnection.php(614): CodeIgniter\Database\BaseConnection->initialize() +#2 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/BaseBuilder.php(1649): CodeIgniter\Database\BaseConnection->query('SELECT *\nFROM `...', Array, false) +#3 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Model.php(330): CodeIgniter\Database\BaseBuilder->get() +#4 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/BaseModel.php(715): CodeIgniter\Model->doFirst() +#5 /Users/larbi/Documents/alrahma_sunday_school/app/Models/ConfigurationModel.php(28): CodeIgniter\BaseModel->first() +#6 /Users/larbi/Documents/alrahma_sunday_school/app/Models/ConfigurationModel.php(89): App\Models\ConfigurationModel->getConfigValueByKey('fall_semester_s...') +#7 /Users/larbi/Documents/alrahma_sunday_school/app/Services/SemesterRangeService.php(98): App\Models\ConfigurationModel->getConfig('fall_semester_s...') +#8 /Users/larbi/Documents/alrahma_sunday_school/app/Models/ConfigurationModel.php(80): App\Services\SemesterRangeService->getSemesterForDate() +#9 /Users/larbi/Documents/alrahma_sunday_school/app/Commands/SyncPaypalPayments.php(40): App\Models\ConfigurationModel->getConfig('semester') +#10 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/CLI/Commands.php(130): App\Commands\SyncPaypalPayments->__construct(Object(CodeIgniter\Log\Logger), Object(CodeIgniter\CLI\Commands)) +#11 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/CLI/Commands.php(51): CodeIgniter\CLI\Commands->discoverCommands() +#12 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/Services.php(178): CodeIgniter\CLI\Commands->__construct() +#13 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(312): CodeIgniter\Config\Services::commands(false) +#14 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(251): CodeIgniter\Config\BaseService::__callStatic('commands', Array) +#15 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/Services.php(175): CodeIgniter\Config\BaseService::getSharedInstance('commands') +#16 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(321): CodeIgniter\Config\Services::commands() +#17 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(202): CodeIgniter\Config\BaseService::__callStatic('commands', Array) +#18 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Common.php(995): CodeIgniter\Config\BaseService::get('commands') +#19 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/CLI/Console.php(47): service('commands') +#20 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Boot.php(388): CodeIgniter\CLI\Console->run() +#21 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Boot.php(133): CodeIgniter\Boot::runCommand(Object(CodeIgniter\CLI\Console)) +#22 /Users/larbi/Documents/alrahma_sunday_school/spark(84): CodeIgniter\Boot::bootSpark(Object(Config\Paths)) +#23 {main} +CRITICAL - 2026-05-16 17:09:45 --> TypeError: array_merge(): Argument #1 must be of type array, string given +[Method: CLI, Route: serve] +in SYSTEMPATH/Database/BaseBuilder.php on line 1506. + 1 SYSTEMPATH/Database/BaseBuilder.php(1506): array_merge(' +ORDER BY `id` DESC', [...]) + 2 SYSTEMPATH/Model.php(925): CodeIgniter\Database\BaseBuilder->orderBy('id', ' DESC') + 3 APPPATH/Models/ConfigurationModel.php(27): CodeIgniter\Model->__call('orderBy', [...]) + 4 APPPATH/Models/ConfigurationModel.php(89): App\Models\ConfigurationModel->getConfigValueByKey('semester') + 5 APPPATH/Commands/SyncPaypalPayments.php(40): App\Models\ConfigurationModel->getConfig('semester') + 6 SYSTEMPATH/CLI/Commands.php(130): App\Commands\SyncPaypalPayments->__construct(Object(CodeIgniter\Log\Logger), Object(CodeIgniter\CLI\Commands)) + 7 SYSTEMPATH/CLI/Commands.php(51): CodeIgniter\CLI\Commands->discoverCommands() + 8 SYSTEMPATH/Config/Services.php(178): CodeIgniter\CLI\Commands->__construct() + 9 SYSTEMPATH/Config/BaseService.php(312): CodeIgniter\Config\Services::commands(false) +10 SYSTEMPATH/Config/BaseService.php(251): CodeIgniter\Config\BaseService::__callStatic('commands', [...]) +11 SYSTEMPATH/Config/Services.php(175): CodeIgniter\Config\BaseService::getSharedInstance('commands') +12 SYSTEMPATH/Config/BaseService.php(321): CodeIgniter\Config\Services::commands() +13 SYSTEMPATH/Config/BaseService.php(202): CodeIgniter\Config\BaseService::__callStatic('commands', []) +14 SYSTEMPATH/Common.php(995): CodeIgniter\Config\BaseService::get('commands') +15 SYSTEMPATH/CLI/Console.php(47): service('commands') +16 SYSTEMPATH/Boot.php(388): CodeIgniter\CLI\Console->run() +17 SYSTEMPATH/Boot.php(133): CodeIgniter\Boot::runCommand(Object(CodeIgniter\CLI\Console)) +18 ROOTPATH/spark(84): CodeIgniter\Boot::bootSpark(Object(Config\Paths)) +ERROR - 2026-05-16 17:10:12 --> Error connecting to the database: mysqli_sql_exception: No such file or directory in /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/MySQLi/Connection.php:201 +Stack trace: +#0 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/MySQLi/Connection.php(201): mysqli->real_connect('localhost', 'u280815660_mela...', Object(SensitiveParameterValue), 'u280815660_scho...', 3306, '', 0) +#1 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/BaseConnection.php(419): CodeIgniter\Database\MySQLi\Connection->connect(false) +#2 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/BaseConnection.php(614): CodeIgniter\Database\BaseConnection->initialize() +#3 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/BaseBuilder.php(1649): CodeIgniter\Database\BaseConnection->query('SELECT *\nFROM `...', Array, false) +#4 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Model.php(330): CodeIgniter\Database\BaseBuilder->get() +#5 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/BaseModel.php(715): CodeIgniter\Model->doFirst() +#6 /Users/larbi/Documents/alrahma_sunday_school/app/Models/ConfigurationModel.php(28): CodeIgniter\BaseModel->first() +#7 /Users/larbi/Documents/alrahma_sunday_school/app/Models/ConfigurationModel.php(89): App\Models\ConfigurationModel->getConfigValueByKey('fall_semester_s...') +#8 /Users/larbi/Documents/alrahma_sunday_school/app/Services/SemesterRangeService.php(98): App\Models\ConfigurationModel->getConfig('fall_semester_s...') +#9 /Users/larbi/Documents/alrahma_sunday_school/app/Models/ConfigurationModel.php(80): App\Services\SemesterRangeService->getSemesterForDate() +#10 /Users/larbi/Documents/alrahma_sunday_school/app/Commands/SyncPaypalPayments.php(40): App\Models\ConfigurationModel->getConfig('semester') +#11 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/CLI/Commands.php(130): App\Commands\SyncPaypalPayments->__construct(Object(CodeIgniter\Log\Logger), Object(CodeIgniter\CLI\Commands)) +#12 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/CLI/Commands.php(51): CodeIgniter\CLI\Commands->discoverCommands() +#13 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/Services.php(178): CodeIgniter\CLI\Commands->__construct() +#14 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(312): CodeIgniter\Config\Services::commands(false) +#15 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(251): CodeIgniter\Config\BaseService::__callStatic('commands', Array) +#16 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/Services.php(175): CodeIgniter\Config\BaseService::getSharedInstance('commands') +#17 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(321): CodeIgniter\Config\Services::commands() +#18 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(202): CodeIgniter\Config\BaseService::__callStatic('commands', Array) +#19 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Common.php(995): CodeIgniter\Config\BaseService::get('commands') +#20 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/CLI/Console.php(47): service('commands') +#21 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Boot.php(388): CodeIgniter\CLI\Console->run() +#22 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Boot.php(133): CodeIgniter\Boot::runCommand(Object(CodeIgniter\CLI\Console)) +#23 /Users/larbi/Documents/alrahma_sunday_school/spark(84): CodeIgniter\Boot::bootSpark(Object(Config\Paths)) +#24 {main} + +Next CodeIgniter\Database\Exceptions\DatabaseException: No such file or directory in /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/MySQLi/Connection.php:246 +Stack trace: +#0 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/BaseConnection.php(419): CodeIgniter\Database\MySQLi\Connection->connect(false) +#1 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/BaseConnection.php(614): CodeIgniter\Database\BaseConnection->initialize() +#2 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Database/BaseBuilder.php(1649): CodeIgniter\Database\BaseConnection->query('SELECT *\nFROM `...', Array, false) +#3 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Model.php(330): CodeIgniter\Database\BaseBuilder->get() +#4 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/BaseModel.php(715): CodeIgniter\Model->doFirst() +#5 /Users/larbi/Documents/alrahma_sunday_school/app/Models/ConfigurationModel.php(28): CodeIgniter\BaseModel->first() +#6 /Users/larbi/Documents/alrahma_sunday_school/app/Models/ConfigurationModel.php(89): App\Models\ConfigurationModel->getConfigValueByKey('fall_semester_s...') +#7 /Users/larbi/Documents/alrahma_sunday_school/app/Services/SemesterRangeService.php(98): App\Models\ConfigurationModel->getConfig('fall_semester_s...') +#8 /Users/larbi/Documents/alrahma_sunday_school/app/Models/ConfigurationModel.php(80): App\Services\SemesterRangeService->getSemesterForDate() +#9 /Users/larbi/Documents/alrahma_sunday_school/app/Commands/SyncPaypalPayments.php(40): App\Models\ConfigurationModel->getConfig('semester') +#10 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/CLI/Commands.php(130): App\Commands\SyncPaypalPayments->__construct(Object(CodeIgniter\Log\Logger), Object(CodeIgniter\CLI\Commands)) +#11 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/CLI/Commands.php(51): CodeIgniter\CLI\Commands->discoverCommands() +#12 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/Services.php(178): CodeIgniter\CLI\Commands->__construct() +#13 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(312): CodeIgniter\Config\Services::commands(false) +#14 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(251): CodeIgniter\Config\BaseService::__callStatic('commands', Array) +#15 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/Services.php(175): CodeIgniter\Config\BaseService::getSharedInstance('commands') +#16 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(321): CodeIgniter\Config\Services::commands() +#17 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Config/BaseService.php(202): CodeIgniter\Config\BaseService::__callStatic('commands', Array) +#18 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Common.php(995): CodeIgniter\Config\BaseService::get('commands') +#19 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/CLI/Console.php(47): service('commands') +#20 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Boot.php(388): CodeIgniter\CLI\Console->run() +#21 /Users/larbi/Documents/alrahma_sunday_school/vendor/codeigniter4/framework/system/Boot.php(133): CodeIgniter\Boot::runCommand(Object(CodeIgniter\CLI\Console)) +#22 /Users/larbi/Documents/alrahma_sunday_school/spark(84): CodeIgniter\Boot::bootSpark(Object(Config\Paths)) +#23 {main} +CRITICAL - 2026-05-16 17:10:12 --> TypeError: array_merge(): Argument #1 must be of type array, string given +[Method: CLI, Route: serve] +in SYSTEMPATH/Database/BaseBuilder.php on line 1506. + 1 SYSTEMPATH/Database/BaseBuilder.php(1506): array_merge(' +ORDER BY `id` DESC', [...]) + 2 SYSTEMPATH/Model.php(925): CodeIgniter\Database\BaseBuilder->orderBy('id', ' DESC') + 3 APPPATH/Models/ConfigurationModel.php(27): CodeIgniter\Model->__call('orderBy', [...]) + 4 APPPATH/Models/ConfigurationModel.php(89): App\Models\ConfigurationModel->getConfigValueByKey('semester') + 5 APPPATH/Commands/SyncPaypalPayments.php(40): App\Models\ConfigurationModel->getConfig('semester') + 6 SYSTEMPATH/CLI/Commands.php(130): App\Commands\SyncPaypalPayments->__construct(Object(CodeIgniter\Log\Logger), Object(CodeIgniter\CLI\Commands)) + 7 SYSTEMPATH/CLI/Commands.php(51): CodeIgniter\CLI\Commands->discoverCommands() + 8 SYSTEMPATH/Config/Services.php(178): CodeIgniter\CLI\Commands->__construct() + 9 SYSTEMPATH/Config/BaseService.php(312): CodeIgniter\Config\Services::commands(false) +10 SYSTEMPATH/Config/BaseService.php(251): CodeIgniter\Config\BaseService::__callStatic('commands', [...]) +11 SYSTEMPATH/Config/Services.php(175): CodeIgniter\Config\BaseService::getSharedInstance('commands') +12 SYSTEMPATH/Config/BaseService.php(321): CodeIgniter\Config\Services::commands() +13 SYSTEMPATH/Config/BaseService.php(202): CodeIgniter\Config\BaseService::__callStatic('commands', []) +14 SYSTEMPATH/Common.php(995): CodeIgniter\Config\BaseService::get('commands') +15 SYSTEMPATH/CLI/Console.php(47): service('commands') +16 SYSTEMPATH/Boot.php(388): CodeIgniter\CLI\Console->run() +17 SYSTEMPATH/Boot.php(133): CodeIgniter\Boot::runCommand(Object(CodeIgniter\CLI\Console)) +18 ROOTPATH/spark(84): CodeIgniter\Boot::bootSpark(Object(Config\Paths)) diff --git a/writable/session/ci_session173e6f4352f712df14f8baf579c5a50b b/writable/session/ci_session173e6f4352f712df14f8baf579c5a50b new file mode 100644 index 0000000..e9545ad --- /dev/null +++ b/writable/session/ci_session173e6f4352f712df14f8baf579c5a50b @@ -0,0 +1 @@ +__ci_last_regenerate|i:1778953049;_ci_previous_url|s:22:"http://localhost:8080/"; \ No newline at end of file diff --git a/writable/session/ci_session504a14eddce8394e82d35d85f67cf7f7 b/writable/session/ci_session504a14eddce8394e82d35d85f67cf7f7 new file mode 100644 index 0000000..5b01839 --- /dev/null +++ b/writable/session/ci_session504a14eddce8394e82d35d85f67cf7f7 @@ -0,0 +1 @@ +__ci_last_regenerate|i:1778953398;_ci_previous_url|s:60:"http://localhost:8080/administrator/event-charges?event_id=1";user_id|s:1:"1";user_email|s:23:"larbi.elabidi@gmail.com";user_name|s:20:"Moulay Larbi Elabidi";user_type|s:7:"primary";is_logged_in|b:1;login_time|i:1778953073;roles|a:20:{i:0;s:5:"admin";i:1;s:13:"administrator";i:2;s:15:"csm_contributor";i:3;s:15:"fes_contributor";i:4;s:21:"financial_contributor";i:5;s:14:"fs_contributor";i:6;s:11:"head of csm";i:7;s:28:"head of department education";i:8;s:10:"head of fa";i:9;s:11:"head of fes";i:10;s:10:"head of fs";i:11;s:12:"head of itpd";i:12;s:16:"itpd contributor";i:13;s:6:"parent";i:14;s:9:"principal";i:15;s:15:"saf_contributor";i:16;s:10:"substitute";i:17;s:7:"teacher";i:18;s:17:"teacher_assistant";i:19;s:14:"vice principal";}semester|s:6:"Spring";school_year|s:9:"2025-2026";role|s:13:"administrator";__ci_vars|a:0:{} \ No newline at end of file diff --git a/writable/session/ci_session703454fd156d6cd0866f08203284eed0 b/writable/session/ci_session703454fd156d6cd0866f08203284eed0 new file mode 100644 index 0000000..8ed986d --- /dev/null +++ b/writable/session/ci_session703454fd156d6cd0866f08203284eed0 @@ -0,0 +1 @@ +__ci_last_regenerate|i:1778953398;_ci_previous_url|s:49:"http://localhost:8080/administrator/event-charges";user_id|s:1:"1";user_email|s:23:"larbi.elabidi@gmail.com";user_name|s:20:"Moulay Larbi Elabidi";user_type|s:7:"primary";is_logged_in|b:1;login_time|i:1778953073;roles|a:20:{i:0;s:5:"admin";i:1;s:13:"administrator";i:2;s:15:"csm_contributor";i:3;s:15:"fes_contributor";i:4;s:21:"financial_contributor";i:5;s:14:"fs_contributor";i:6;s:11:"head of csm";i:7;s:28:"head of department education";i:8;s:10:"head of fa";i:9;s:11:"head of fes";i:10;s:10:"head of fs";i:11;s:12:"head of itpd";i:12;s:16:"itpd contributor";i:13;s:6:"parent";i:14;s:9:"principal";i:15;s:15:"saf_contributor";i:16;s:10:"substitute";i:17;s:7:"teacher";i:18;s:17:"teacher_assistant";i:19;s:14:"vice principal";}semester|s:6:"Spring";school_year|s:9:"2025-2026";role|s:13:"administrator";__ci_vars|a:0:{} \ No newline at end of file