From b32fb853f67b882187a4dfc87ea0000f1595127e Mon Sep 17 00:00:00 2001
From: root
Date: Sat, 16 May 2026 13:44:12 -0400
Subject: [PATCH] merge prod to main
---
.DS_Store | Bin 0 -> 10244 bytes
.env | 180 +++
app/Commands/AttendanceAutoPublishCommand.php | 0
app/Commands/CheckMissedPayments.php | 0
app/Commands/CleanupExpiredNotifications.php | 0
app/Commands/CleanupPasswordResets.php | 0
app/Commands/ConfigUpdate.php | 0
app/Commands/DeleteInactiveUsers.php | 0
app/Commands/RecalculateAttendance.php | 0
app/Commands/SendAbsenteesSummary.php | 0
.../SendExamDraftDeadlineReminders.php | 197 +++
app/Commands/SendLatesSummary.php | 0
.../SendMonthlyPaymentNotifications.php | 0
app/Commands/SendTestPaymentNotification.php | 0
app/Commands/SyncPaypalPayments.php | 0
app/Common.php | 0
app/Config/Api.php | 0
app/Config/App.php | 2 +-
app/Config/Autoload.php | 0
app/Config/Boot/development.php | 0
app/Config/Boot/production.php | 0
app/Config/Boot/testing.php | 0
app/Config/CURLRequest.php | 0
app/Config/Cache.php | 0
app/Config/Commands.php | 0
app/Config/Constants.php | 0
app/Config/ContentSecurityPolicy.php | 0
app/Config/Cookie.php | 0
app/Config/Cors.php | 0
app/Config/Database.php | 84 +-
app/Config/DocTypes.php | 0
app/Config/Email.php | 0
app/Config/Encryption.php | 0
app/Config/Events.php | 0
app/Config/Exceptions.php | 0
app/Config/Feature.php | 0
app/Config/Filters.php | 0
app/Config/ForeignCharacters.php | 0
app/Config/Format.php | 0
app/Config/Generators.php | 0
app/Config/Honeypot.php | 0
app/Config/Images.php | 0
app/Config/Kint.php | 0
app/Config/Logger.php | 0
app/Config/Migrations.php | 0
app/Config/Mimes.php | 0
app/Config/Modules.php | 0
app/Config/Optimize.php | 0
app/Config/Pager.php | 0
app/Config/Paths.php | 0
app/Config/PaypalConfig.php | 0
app/Config/Publisher.php | 0
app/Config/Roles.php | 0
app/Config/Routes.php | 26 +-
app/Config/Routing.php | 0
app/Config/School.php | 0
app/Config/Security.php | 0
app/Config/Services.php | 0
app/Config/Session.php | 0
app/Config/SessionTimeout.php | 0
app/Config/Style.php | 0
app/Config/Toolbar.php | 0
app/Config/UserAgents.php | 0
app/Config/Validation.php | 0
app/Config/View.php | 0
.../Admin/CompetitionWinnersController.php | 0
app/Controllers/AdminProgressController.php | 441 +++++-
app/Controllers/ApiDocsController.php | 0
app/Controllers/AuthController.php | 82 +-
app/Controllers/BaseController.php | 0
app/Controllers/ClassProgressController.php | 516 ++++++-
app/Controllers/DocsController.php | 0
app/Controllers/ErrorController.php | 0
app/Controllers/Home.php | 0
.../InitializeRolesPermissions.php | 0
app/Controllers/ParentProgressController.php | 80 +-
.../ParentReportCardController.php | 186 +++
app/Controllers/PrintRequests.php | 0
app/Controllers/ProofreadController.php | 0
.../TimeOffNotificationController.php | 0
.../View/AdministratorController.php | 587 +++++++-
app/Controllers/View/AssignmentController.php | 6 +
.../AttendanceCommentTemplateController.php | 0
app/Controllers/View/AttendanceController.php | 38 +-
.../View/AttendanceTrackingController.php | 56 +-
.../View/AuthorizedUsersController.php | 158 ++-
app/Controllers/View/BadgesController.php | 0
.../View/BroadcastEmailController.php | 0
app/Controllers/View/ClassController.php | 0
app/Controllers/View/ClassPrepController.php | 0
.../View/ClassPreparationController.php | 52 +-
.../View/CommunicationController.php | 0
.../View/CompetitionScoresController.php | 0
.../View/ConfigurationController.php | 0
app/Controllers/View/ContactController.php | 0
.../View/DashboardRedirectController.php | 0
app/Controllers/View/DiscountController.php | 7 +-
app/Controllers/View/DocsController.php | 0
app/Controllers/View/EmailController.php | 157 +++
.../View/EmailExtractorController.php | 0
.../View/EmergencyContactController.php | 0
app/Controllers/View/EventController.php | 956 ++++++++++++-
app/Controllers/View/ExamDraftController.php | 1184 +++++++++++++++--
app/Controllers/View/ExpenseController.php | 0
.../View/ExtraChargesController.php | 0
.../View/FamilyAdminController.php | 78 ++
app/Controllers/View/FamilyController.php | 0
app/Controllers/View/FilesController.php | 126 +-
app/Controllers/View/FinalController.php | 0
app/Controllers/View/FinancialController.php | 125 +-
app/Controllers/View/FlagController.php | 30 +-
app/Controllers/View/FrontendController.php | 0
app/Controllers/View/GradingController.php | 214 ++-
app/Controllers/View/HealthController.php | 0
app/Controllers/View/HomeworkController.php | 6 +-
.../View/HomeworkTrackingController.php | 3 +
app/Controllers/View/InfoIconController.php | 0
app/Controllers/View/InventoryController.php | 0
app/Controllers/View/InvoiceController.php | 154 ++-
app/Controllers/View/IpBanController.php | 0
.../View/LandingPageController.php | 15 +-
.../View/LateSlipLogsController.php | 0
app/Controllers/View/MessagesController.php | 0
app/Controllers/View/MidtermController.php | 0
app/Controllers/View/NavBuilderController.php | 0
.../View/NotificationsController.php | 0
app/Controllers/View/PageController.php | 0
.../View/ParentAttendanceReportController.php | 132 +-
app/Controllers/View/ParentController.php | 34 +-
.../View/ParticipationController.php | 0
app/Controllers/View/PaymentController.php | 0
.../View/PaymentNotificationController.php | 0
.../View/PaymentTransactionController.php | 0
.../View/PaypalTransactionsController.php | 0
app/Controllers/View/PolicyController.php | 0
.../View/PreferencesController.php | 0
app/Controllers/View/PrintRequests.php | 571 ++++++++
.../View/PrintablesBaseController.php | 0
app/Controllers/View/ProjectController.php | 6 +-
.../View/PurchaseOrderController.php | 0
app/Controllers/View/QuizController.php | 0
app/Controllers/View/RFIDController.php | 0
app/Controllers/View/RefundController.php | 0
app/Controllers/View/RegisterController.php | 17 +-
.../View/ReimbursementController.php | 0
.../View/ReportCardsController.php | 56 +
.../View/RolePermissionController.php | 0
.../View/RoleSwitcherController.php | 0
.../View/SchoolCalendarController.php | 0
.../View/ScoreCommentController.php | 0
app/Controllers/View/ScoreController.php | 5 +-
app/Controllers/View/ScorePredictor.php | 8 +-
app/Controllers/View/SendSMSController.php | 0
.../View/SessionTimeoutController.php | 0
app/Controllers/View/SettingsController.php | 0
.../View/SlipPrinterController.php | 0
app/Controllers/View/StaffController.php | 0
app/Controllers/View/StatsController.php | 0
app/Controllers/View/StickersController.php | 0
app/Controllers/View/StudentController.php | 0
.../View/SubjectCurriculumController.php | 1 +
app/Controllers/View/SupplierController.php | 0
.../View/SupplyCategoryController.php | 0
app/Controllers/View/SupportController.php | 0
app/Controllers/View/TeacherController.php | 16 +-
app/Controllers/View/TestDBController.php | 0
app/Controllers/View/UiController.php | 0
app/Controllers/View/UserController.php | 194 ++-
app/Controllers/View/WhatsappController.php | 0
app/Controllers/WinnersController.php | 0
app/Database/Migrations/.gitkeep | 0
...-05-26-000000_AddEventCategoryToEvents.php | 0
...-000700_AddBatchNumberToReimbursements.php | 0
...earlyBatchNumberToReimbursementBatches.php | 0
...025-09-10-000002_CreateInventoryTables.php | 0
...-09-11-000010_CreateInventoryMovements.php | 0
...11-000300_AddClassroomConditionBuckets.php | 0
...1-000400_AddGradeRangeToBookCategories.php | 0
...025-09-27-000001_CreateStaffAttendance.php | 0
...2025-10-08-000100_CreateBadgePrintLogs.php | 0
...2025-10-16-000200_CreateFamiliesTables.php | 0
...-17-121500_AddNoSchoolToCalendarEvents.php | 0
...-130000_CreateWhatsappGroupMemberships.php | 0
.../2025-10-19-000300_CreateLateSlipLogs.php | 0
...9-090000_CreatePaymentNotificationLogs.php | 0
...0-000500_CreateParentAttendanceReports.php | 0
...10-21-000600_AddSemesterToLateSlipLogs.php | 0
...2025-10-21-110000_CreatePromotionQueue.php | 0
...-000510_CreateEarlyDismissalSignatures.php | 0
...00_AddTimezoneToPreferencesAndSettings.php | 0
...3-011228_AddDonationCategoryToExpenses.php | 0
...100_CreateReimbursementBatchAdminFiles.php | 0
...-190600_AddReviewFieldsToScoreComments.php | 0
...50000_CreateAttendanceCommentTemplates.php | 0
.../2026-01-05-215636_CreatePrintRequests.php | 0
...1-05-215851_FixPrintRequestsForeignKey.php | 0
...6-195508_RevertPrintRequestsForeignKey.php | 0
...-06-200210_DropPrintRequestsForeignKey.php | 0
...ixPrintRequestsForeignKeyOnceAndForAll.php | 0
...205621_FixPrintRequestsForeignKeyAgain.php | 0
.../2026-01-10-000001_CreateCompetitions.php | 0
...6-01-10-000002_CreateCompetitionScores.php | 0
...-01-10-000003_CreateCompetitionWinners.php | 0
...26-01-10-000004_AddCompetitionNavItems.php | 0
...2026-01-10-000005_AddBelowSixtyNavItem.php | 139 ++
...0-000005_CreateCompetitionClassWinners.php | 0
...QuestionCountToCompetitionClassWinners.php | 0
...dPrizeColumnsToCompetitionClassWinners.php | 0
...-000007_AllowCompetitionWinnerRankTies.php | 25 +-
...000100_CreateAdminNotificationSubjects.php | 0
...-01-13-000300_AddCompetitionLockFields.php | 0
...2026-01-14-000100_AddStudentActiveFlag.php | 0
...1-14-000200_CreateClassProgressReports.php | 0
...20-000000_CreateSubjectCurriculumItems.php | 0
...5-000000_RemoveClassAssignmentSemester.php | 0
...teTeacherSubmissionNotificationHistory.php | 0
...27-092000_AddEventTypeToCalendarEvents.php | 0
...1-27-130500_CreateExamDraftSubmissions.php | 0
...26-01-27-200000_AddVersionToExamDrafts.php | 0
...6-01-27-210500_AddFinalPdfToExamDrafts.php | 0
...6-01-27-211500_AddIsLegacyToExamDrafts.php | 0
...7-220000_AddReviewRevisionToExamDrafts.php | 30 +
...20000_AddClassSectionIdToScoreComments.php | 0
...30-000100_CreateParentMeetingSchedules.php | 0
...-000300_CreateClassProgressAttachments.php | 0
...026-02-04-000400_CreatePlacementLevels.php | 0
...02-04-000410_UpdatePlacementLevelRange.php | 0
...26-02-04-000420_CreatePlacementBatches.php | 0
...026-02-04-000430_CreatePlacementScores.php | 0
...2-04-000440_AddEventOnlyToStudentClass.php | 0
.../2026-02-05-000100_CreateGradingLocks.php | 0
...-05-000200_CreateMissingScoreOverrides.php | 0
...000300_AddPageSelectionToPrintRequests.php | 38 +-
...026-02-10-000100_AddStyleToPreferences.php | 0
...02-16-000001_AddEventPaidFlagToCharges.php | 27 +
...20000_CreateReportCardAcknowledgements.php | 77 ++
...3-000002_AddClassSectionToEventCharges.php | 32 +
...00003_AddEventPaymentRefToEventCharges.php | 32 +
...xternalParticipantFieldsToEventCharges.php | 43 +
...05_AddExternalParentInfoToEventCharges.php | 43 +
...1_AddExternalParentEmailToEventCharges.php | 32 +
...9-000001_AddWaiverSignedToEventCharges.php | 31 +
.../ExtendStaffAttendanceForTeachers.php | 0
app/Database/Seeds/.gitkeep | 0
.../Seeds/FamiliesBootstrapSeeder.txt | 0
app/Database/Seeds/FamiliesRebuildSeeder.php | 0
app/Database/Seeds/NavSeeder.php | 0
.../Seeds/SubjectCurriculumSeeder.php | 0
app/Events/DeleteUnverifiedUser.php | 0
app/Filters/.gitkeep | 0
app/Filters/ApiAuthFilter.php | 0
app/Filters/ApiDocsAuthFilter.php | 0
app/Filters/AuthFilter.php | 17 +-
app/Filters/CleanupScheduler.php | 0
app/Filters/PermissionFilter.php | 0
app/Filters/TimezoneFilter.php | 0
app/Helpers/.gitkeep | 0
app/Helpers/AuthHelper.php | 0
app/Helpers/DocumentHelper.php | 0
app/Helpers/GlobalConfigHelper.php | 0
app/Helpers/attendance_comment_helper.php | 0
app/Helpers/auth_helper.php | 3 +
app/Helpers/jwt_helper.php | 0
app/Helpers/pbkdf2_helper.php | 0
app/Helpers/semester_selection_helper.php | 0
app/Helpers/time_helper.php | 0
app/Interfaces/ScoreCalculatorInterface.php | 0
app/Language/.gitkeep | 0
app/Language/en/Validation.php | 0
app/Libraries/.gitkeep | 0
app/Libraries/AttendanceAutoPublish.php | 0
app/Libraries/AttendancePolicy.php | 0
app/Libraries/StaffTimeOffLinkService.php | 0
.../AttendanceConsequenceListener.php | 0
app/Listeners/BelowSixtyEmailListener.php | 5 +-
app/Listeners/SchoolEventListener.php | 0
app/Listeners/WhatsappInviteListener.php | 0
app/Models/.gitkeep | 0
app/Models/AdditionalChargeModel.php | 0
app/Models/AdminNotificationSubjectModel.php | 0
app/Models/AttendanceCommentTemplateModel.php | 0
app/Models/AttendanceDataModel.php | 0
app/Models/AttendanceDayModel.php | 0
app/Models/AttendanceEmailTemplateModel.php | 0
app/Models/AttendanceRecordModel.php | 0
app/Models/AttendanceTrackingModel.php | 0
app/Models/AuthorizedUserModel.php | 0
app/Models/BadgePrintLogModel.php | 0
app/Models/CalendarModel.php | 0
app/Models/ClassModel.php | 0
app/Models/ClassPrepAdjustmentModel.php | 0
app/Models/ClassPreparationLogModel.php | 0
app/Models/ClassProgressAttachmentModel.php | 0
app/Models/ClassProgressReportModel.php | 44 +
app/Models/ClassSectionModel.php | 0
app/Models/CommunicationLogModel.php | 0
app/Models/CompetitionClassWinnerModel.php | 0
app/Models/CompetitionModel.php | 0
app/Models/CompetitionScoreModel.php | 0
app/Models/CompetitionWinnerModel.php | 0
app/Models/ConfigurationModel.php | 10 +-
app/Models/ContactUsModel.php | 0
app/Models/CurrentFlagModel.php | 0
app/Models/DiscountUsageModel.php | 0
app/Models/DiscountVoucherModel.php | 0
app/Models/EarlyDismissalSignatureModel.php | 0
app/Models/EmailTemplateModel.php | 0
app/Models/EmergencyContactModel.php | 0
app/Models/EnrollmentModel.php | 0
app/Models/EventChargesModel.php | 14 +-
app/Models/EventModel.php | 0
app/Models/ExamDraftModel.php | 73 +-
app/Models/ExamModel.php | 0
app/Models/ExpenseModel.php | 0
app/Models/FamilyCommPrefModel.php | 0
app/Models/FamilyGuardianModel.php | 0
app/Models/FamilyModel.php | 0
app/Models/FamilyStudentModel.php | 0
app/Models/FinalExamModel.php | 0
app/Models/FinalScoreModel.php | 0
app/Models/FlagModel.php | 0
app/Models/GradingLockModel.php | 0
app/Models/HomeworkModel.php | 0
app/Models/InventoryCategoryModel.php | 0
app/Models/InventoryItemModel.php | 0
app/Models/InventoryMovementModel.php | 0
app/Models/InvoiceEventModel.php | 0
app/Models/InvoiceModel.php | 0
app/Models/InvoiceStudentListModel.php | 0
app/Models/IpAttemptModel.php | 0
app/Models/LateSlipLogModel.php | 0
app/Models/LoginActivityModel.php | 0
app/Models/ManualPaymentModel.php | 0
app/Models/MessageModel.php | 0
app/Models/MidtermExamModel.php | 0
app/Models/MissingScoreOverrideModel.php | 0
app/Models/NavItemModel.php | 0
app/Models/NotificationModel.php | 0
app/Models/ParentAttendanceReportModel.php | 0
app/Models/ParentMeetingScheduleModel.php | 0
app/Models/ParentModel.php | 0
app/Models/ParentNotificationModel.php | 0
app/Models/ParticipationModel.php | 0
app/Models/PasswordResetModel.php | 0
app/Models/PasswordResetRequestModel.php | 0
app/Models/PayPalPaymentModel.php | 0
app/Models/PaymentErrorModel.php | 0
app/Models/PaymentModel.php | 0
app/Models/PaymentNotificationLogModel.php | 0
app/Models/PaymentTransactionModel.php | 0
app/Models/PaypalTransactionModel.php | 0
app/Models/PermissionModel.php | 0
app/Models/PlacementBatchModel.php | 0
app/Models/PlacementLevelModel.php | 0
app/Models/PlacementScoreModel.php | 0
app/Models/PreferencesModel.php | 0
app/Models/PrintRequestModel.php | 0
app/Models/ProjectModel.php | 0
app/Models/PromotionQueueModel.php | 0
app/Models/PurchaseOrderItemModel.php | 0
app/Models/PurchaseOrderModel.php | 0
app/Models/QuizModel.php | 0
app/Models/RefundModel.php | 0
.../ReimbursementBatchAdminFileModel.php | 0
app/Models/ReimbursementBatchItemModel.php | 0
app/Models/ReimbursementBatchModel.php | 0
app/Models/ReimbursementModel.php | 0
app/Models/ReportCardAcknowledgementModel.php | 27 +
app/Models/RoleModel.php | 0
app/Models/RoleNavItemModel.php | 0
app/Models/RolePermissionModel.php | 0
app/Models/ScoreCommentModel.php | 0
app/Models/SectionModel.php | 0
app/Models/SemesterScoreModel.php | 0
app/Models/SettingsModel.php | 0
app/Models/StaffAttendanceModel.php | 0
app/Models/StaffModel.php | 0
app/Models/StatsModel.php | 0
app/Models/StudentAllergyModel.php | 0
app/Models/StudentClassModel.php | 0
app/Models/StudentMedicalConditionModel.php | 0
app/Models/StudentModel.php | 0
app/Models/SubjectCurriculumModel.php | 1 +
app/Models/SupplyCategoryModel.php | 0
app/Models/TeacherClassModel.php | 6 +-
app/Models/TeacherModel.php | 6 +-
...cherSubmissionNotificationHistoryModel.php | 0
app/Models/UserModel.php | 0
app/Models/UserNotificationModel.php | 0
app/Models/UserRoleModel.php | 0
app/Models/WhatsappGroupLinkModel.php | 0
app/Models/WhatsappGroupMembershipModel.php | 0
app/Models/WhatsappInviteLogModel.php | 0
app/Services/ApiClient.php | 0
.../Calculators/AttendanceCalculator.php | 0
.../Calculators/HomeworkCalculator.php | 0
.../Calculators/ProjectCalculator.php | 0
app/Services/Calculators/QuizCalculator.php | 0
app/Services/EmailService.php | 40 +
app/Services/FeeCalculationService.php | 0
app/Services/NavbarService.php | 0
app/Services/NotificationService.php | 0
app/Services/PhoneFormatterService.php | 0
app/Services/RoleService.php | 0
app/Services/SchoolIdService.php | 0
app/Services/SemesterRangeService.php | 0
app/Services/SemesterScoreService.php | 0
app/Services/TimeService.php | 17 +-
app/Services/UserEventService.php | 0
app/Services/UserService.php | 0
app/ThirdParty/.gitkeep | 0
app/ThirdParty/fpdf/FAQ.htm | 0
app/ThirdParty/fpdf/changelog.htm | 0
app/ThirdParty/fpdf/doc/__construct.htm | 0
app/ThirdParty/fpdf/doc/acceptpagebreak.htm | 0
app/ThirdParty/fpdf/doc/addfont.htm | 0
app/ThirdParty/fpdf/doc/addlink.htm | 0
app/ThirdParty/fpdf/doc/addpage.htm | 0
app/ThirdParty/fpdf/doc/aliasnbpages.htm | 0
app/ThirdParty/fpdf/doc/cell.htm | 0
app/ThirdParty/fpdf/doc/close.htm | 0
app/ThirdParty/fpdf/doc/error.htm | 0
app/ThirdParty/fpdf/doc/footer.htm | 0
app/ThirdParty/fpdf/doc/getpageheight.htm | 0
app/ThirdParty/fpdf/doc/getpagewidth.htm | 0
app/ThirdParty/fpdf/doc/getstringwidth.htm | 0
app/ThirdParty/fpdf/doc/getx.htm | 0
app/ThirdParty/fpdf/doc/gety.htm | 0
app/ThirdParty/fpdf/doc/header.htm | 0
app/ThirdParty/fpdf/doc/image.htm | 0
app/ThirdParty/fpdf/doc/index.htm | 0
app/ThirdParty/fpdf/doc/line.htm | 0
app/ThirdParty/fpdf/doc/link.htm | 0
app/ThirdParty/fpdf/doc/ln.htm | 0
app/ThirdParty/fpdf/doc/multicell.htm | 0
app/ThirdParty/fpdf/doc/output.htm | 0
app/ThirdParty/fpdf/doc/pageno.htm | 0
app/ThirdParty/fpdf/doc/rect.htm | 0
app/ThirdParty/fpdf/doc/setauthor.htm | 0
app/ThirdParty/fpdf/doc/setautopagebreak.htm | 0
app/ThirdParty/fpdf/doc/setcompression.htm | 0
app/ThirdParty/fpdf/doc/setcreator.htm | 0
app/ThirdParty/fpdf/doc/setdisplaymode.htm | 0
app/ThirdParty/fpdf/doc/setdrawcolor.htm | 0
app/ThirdParty/fpdf/doc/setfillcolor.htm | 0
app/ThirdParty/fpdf/doc/setfont.htm | 0
app/ThirdParty/fpdf/doc/setfontsize.htm | 0
app/ThirdParty/fpdf/doc/setkeywords.htm | 0
app/ThirdParty/fpdf/doc/setleftmargin.htm | 0
app/ThirdParty/fpdf/doc/setlinewidth.htm | 0
app/ThirdParty/fpdf/doc/setlink.htm | 0
app/ThirdParty/fpdf/doc/setmargins.htm | 0
app/ThirdParty/fpdf/doc/setrightmargin.htm | 0
app/ThirdParty/fpdf/doc/setsubject.htm | 0
app/ThirdParty/fpdf/doc/settextcolor.htm | 0
app/ThirdParty/fpdf/doc/settitle.htm | 0
app/ThirdParty/fpdf/doc/settopmargin.htm | 0
app/ThirdParty/fpdf/doc/setx.htm | 0
app/ThirdParty/fpdf/doc/setxy.htm | 0
app/ThirdParty/fpdf/doc/sety.htm | 0
app/ThirdParty/fpdf/doc/text.htm | 0
app/ThirdParty/fpdf/doc/write.htm | 0
app/ThirdParty/fpdf/font/courier.php | 0
app/ThirdParty/fpdf/font/courierb.php | 0
app/ThirdParty/fpdf/font/courierbi.php | 0
app/ThirdParty/fpdf/font/courieri.php | 0
app/ThirdParty/fpdf/font/helvetica.php | 0
app/ThirdParty/fpdf/font/helveticab.php | 0
app/ThirdParty/fpdf/font/helveticabi.php | 0
app/ThirdParty/fpdf/font/helveticai.php | 0
app/ThirdParty/fpdf/font/symbol.php | 0
app/ThirdParty/fpdf/font/times.php | 0
app/ThirdParty/fpdf/font/timesb.php | 0
app/ThirdParty/fpdf/font/timesbi.php | 0
app/ThirdParty/fpdf/font/timesi.php | 0
app/ThirdParty/fpdf/font/zapfdingbats.php | 0
app/ThirdParty/fpdf/fpdf.css | 0
app/ThirdParty/fpdf/fpdf.php | 0
app/ThirdParty/fpdf/install.txt | 0
app/ThirdParty/fpdf/license.txt | 0
app/ThirdParty/fpdf/makefont/cp1250.map | 0
app/ThirdParty/fpdf/makefont/cp1251.map | 0
app/ThirdParty/fpdf/makefont/cp1252.map | 0
app/ThirdParty/fpdf/makefont/cp1253.map | 0
app/ThirdParty/fpdf/makefont/cp1254.map | 0
app/ThirdParty/fpdf/makefont/cp1255.map | 0
app/ThirdParty/fpdf/makefont/cp1257.map | 0
app/ThirdParty/fpdf/makefont/cp1258.map | 0
app/ThirdParty/fpdf/makefont/cp874.map | 0
app/ThirdParty/fpdf/makefont/iso-8859-1.map | 0
app/ThirdParty/fpdf/makefont/iso-8859-11.map | 0
app/ThirdParty/fpdf/makefont/iso-8859-15.map | 0
app/ThirdParty/fpdf/makefont/iso-8859-16.map | 0
app/ThirdParty/fpdf/makefont/iso-8859-2.map | 0
app/ThirdParty/fpdf/makefont/iso-8859-4.map | 0
app/ThirdParty/fpdf/makefont/iso-8859-5.map | 0
app/ThirdParty/fpdf/makefont/iso-8859-7.map | 0
app/ThirdParty/fpdf/makefont/iso-8859-9.map | 0
app/ThirdParty/fpdf/makefont/koi8-r.map | 0
app/ThirdParty/fpdf/makefont/koi8-u.map | 0
app/ThirdParty/fpdf/makefont/makefont.php | 0
app/ThirdParty/fpdf/makefont/ttfparser.php | 0
app/ThirdParty/fpdf/tutorial/20k_c1.txt | 0
app/ThirdParty/fpdf/tutorial/20k_c2.txt | 0
.../tutorial/CevicheOne-Regular-Licence.txt | 0
.../fpdf/tutorial/CevicheOne-Regular.php | 0
.../fpdf/tutorial/CevicheOne-Regular.ttf | Bin
.../fpdf/tutorial/CevicheOne-Regular.z | Bin
app/ThirdParty/fpdf/tutorial/countries.txt | 0
app/ThirdParty/fpdf/tutorial/index.htm | 0
app/ThirdParty/fpdf/tutorial/logo.png | Bin
app/ThirdParty/fpdf/tutorial/makefont.php | 0
app/ThirdParty/fpdf/tutorial/tuto1.htm | 0
app/ThirdParty/fpdf/tutorial/tuto1.php | 0
app/ThirdParty/fpdf/tutorial/tuto2.htm | 0
app/ThirdParty/fpdf/tutorial/tuto2.php | 0
app/ThirdParty/fpdf/tutorial/tuto3.htm | 0
app/ThirdParty/fpdf/tutorial/tuto3.php | 0
app/ThirdParty/fpdf/tutorial/tuto4.htm | 0
app/ThirdParty/fpdf/tutorial/tuto4.php | 0
app/ThirdParty/fpdf/tutorial/tuto5.htm | 0
app/ThirdParty/fpdf/tutorial/tuto5.php | 0
app/ThirdParty/fpdf/tutorial/tuto6.htm | 0
app/ThirdParty/fpdf/tutorial/tuto6.php | 0
app/ThirdParty/fpdf/tutorial/tuto7.htm | 0
app/ThirdParty/fpdf/tutorial/tuto7.php | 0
app/Views/404.html | 12 +-
app/Views/about.php | 14 +-
app/Views/account/choose_account.php | 0
app/Views/activate_account.php | 0
app/Views/admin/class_progress_list.php | 260 +++-
app/Views/admin/class_progress_view.php | 9 +-
app/Views/admin/competition_winners/form.php | 0
app/Views/admin/competition_winners/index.php | 0
.../admin/competition_winners/preview.php | 0
.../admin/competition_winners/scores.php | 0
.../admin/competition_winners/winners.php | 0
.../partials/class_progress_unit_display.php | 48 +
app/Views/admin/student_score_card.php | 0
app/Views/administrator/absence_vacation.php | 0
.../administrator/academic_performance.php | 0
.../admin_attendance_records.php | 0
app/Views/administrator/admin_profiles.php | 0
.../administrator/administratordashboard.php | 21 +-
app/Views/administrator/attendance.php | 0
.../administrator/attendance_records.php | 0
app/Views/administrator/behavior_reports.php | 0
app/Views/administrator/broadcast_email.php | 0
app/Views/administrator/budget_reports.php | 0
app/Views/administrator/calendar.php | 0
.../administrator/calendar_add_event.php | 0
app/Views/administrator/calendar_edit.php | 0
app/Views/administrator/calendar_view.php | 0
app/Views/administrator/class_assignment.php | 0
app/Views/administrator/class_section.php | 0
.../administrator/class_warning_modal.php | 0
.../administrator/communication_logs.php | 0
app/Views/administrator/course_materials.php | 0
app/Views/administrator/create_parent.php | 0
app/Views/administrator/daily_attendance.php | 12 +-
.../daily_attendance_analysis.php | 103 +-
app/Views/administrator/edit_parent.php | 0
app/Views/administrator/edit_teacher.php | 0
.../administrator/emergency_contact/edit.php | 0
.../administrator/emergency_contact/index.php | 0
.../administrator/events/create_event.php | 89 +-
app/Views/administrator/events/edit_event.php | 92 +-
.../administrator/events/event_charges.php | 788 ++++++++++-
app/Views/administrator/events/event_list.php | 14 +-
app/Views/administrator/exam_drafts.php | 612 ++++++---
app/Views/administrator/exam_management.php | 0
.../administrator/expense_management.php | 0
app/Views/administrator/fee_collection.php | 0
.../administrator/fee_payment_records.php | 0
.../administrator/feedback_complaints.php | 0
app/Views/administrator/grades.php | 0
.../administrator/grading_management.php | 0
app/Views/administrator/health_records.php | 0
app/Views/administrator/index.php | 0
app/Views/administrator/ip_bans.php | 0
app/Views/administrator/late_slip_logs.php | 0
app/Views/administrator/manage_users.php | 0
.../administrator/notifications_alerts.php | 0
app/Views/administrator/parent.php | 0
app/Views/administrator/parent_profile.php | 0
.../administrator/paypal_transactions.php | 16 +-
.../administrator/payroll_management.php | 0
.../administrator/performance_reviews.php | 0
.../print_notification_admins.php | 0
app/Views/administrator/removed_students.php | 0
.../administrator/scholarship_information.php | 0
app/Views/administrator/search_results.php | 0
.../sections_auto_distribute.php | 0
app/Views/administrator/settings.php | 0
.../student_class_assignment.php | 0
app/Views/administrator/student_profiles.php | 0
.../administrator/subject_curriculum.php | 132 +-
.../teacher_class_assignment.php | 19 +-
app/Views/administrator/teacher_profiles.php | 0
.../administrator/teacher_submissions.php | 511 +++++--
app/Views/administrator/teachers.php | 0
app/Views/administrator/teachers.php.bak | 0
app/Views/appointment.php | 14 +-
.../attendance/admins_attendance_form.php | 0
app/Views/attendance/attendance_tracking.php | 0
app/Views/attendance/compose_email.php | 0
app/Views/attendance/early_dismissals.php | 0
app/Views/attendance/early_dismissals_add.php | 0
app/Views/attendance/edit_modal.php | 0
app/Views/attendance/parent_reports.php | 0
.../attendance/teacher_attendance_form.php | 0
.../attendance/teacher_attendance_month.php | 0
app/Views/attendance/view.php | 0
app/Views/attendance/violations_notified.php | 0
app/Views/attendance/violations_pending.php | 0
app/Views/attendance_templates/index.php | 0
app/Views/auth/select_role.php | 3 +-
app/Views/calendar.php | 0
app/Views/call_to_action.php | 14 +-
app/Views/class_prep/list.php | 0
app/Views/class_prep/print.php | 0
app/Views/classes.php | 20 +-
app/Views/communications/index.php | 0
.../configuration/configuration_view.php | 0
app/Views/contact.php | 14 +-
app/Views/contact_process.php | 0
app/Views/discounts/apply_voucher.php | 0
app/Views/discounts/create.php | 0
app/Views/discounts/list.php | 0
app/Views/discounts/reverse_discount.php | 0
app/Views/docs/index.php | 0
app/Views/docs/swagger_ui.php | 0
app/Views/docs/swagger_ui_public.php | 0
app/Views/emails/_wrap_layout.php | 0
app/Views/emails/admin_student_registered.php | 0
app/Views/emails/below_sixty_performance.php | 0
app/Views/emails/broadcast_wrapper.php | 0
app/Views/emails/calendar_notification.php | 0
app/Views/emails/custom_html.php | 4 +
app/Views/emails/dismissal.php | 0
app/Views/emails/event_broadcast.php | 38 +
app/Views/emails/extra_charge_notice.php | 0
app/Views/emails/final_warning.php | 0
app/Views/emails/follow_up.php | 0
app/Views/emails/parent_attendance_admin.php | 0
app/Views/emails/parent_attendance_parent.php | 0
app/Views/emails/parent_email_extractor.php | 0
app/Views/emails/payment_receipt.php | 0
app/Views/emails/reset_password.php | 0
app/Views/emails/status_admission_review.php | 0
app/Views/emails/status_denied.php | 0
app/Views/emails/status_enrolled.php | 0
app/Views/emails/status_not_enrolled.php | 0
app/Views/emails/status_payment_pending.php | 0
app/Views/emails/status_refund_pending.php | 0
app/Views/emails/status_waitlist.php | 0
app/Views/emails/status_withdraw_review.php | 0
app/Views/emails/status_withdrawn.php | 0
app/Views/emails/student_removed.php | 0
app/Views/emails/support_new_account.php | 0
app/Views/emails/welcome_parent.php | 0
app/Views/emails/welcome_staff.php | 0
app/Views/emails/welcome_user.php | 0
.../emails/whatsapp_group_invitation.php | 0
app/Views/emails/whatsapp_invite.php | 0
.../enroll_withdraw/enrollment_withdrawal.php | 0
app/Views/enroll_withdraw/new-students.php | 0
app/Views/errors/access_denied.php | 0
app/Views/errors/cli/error_404.php | 0
app/Views/errors/cli/error_exception.php | 0
app/Views/errors/cli/production.php | 0
app/Views/errors/custom/blocked.php | 0
app/Views/errors/html/debug.css | 0
app/Views/errors/html/debug.js | 0
app/Views/errors/html/error_404.php | 0
app/Views/errors/html/error_500.php | 0
app/Views/errors/html/error_exception.php | 0
app/Views/errors/html/error_general.php | 0
app/Views/errors/html/production.php | 0
app/Views/errors/invalid_token.php | 0
app/Views/expenses/create.php | 0
app/Views/expenses/edit.php | 0
app/Views/expenses/index.php | 4 +-
app/Views/facility.php | 0
app/Views/family/card.php | 13 +-
app/Views/family/compose_email.php | 87 ++
app/Views/family/index.php | 0
app/Views/flags/flags_management.php | 13 +-
app/Views/flags/incident_analysis.php | 0
app/Views/flags/processed_flags.php | 0
app/Views/footer.php | 12 +-
app/Views/grading/below_sixty.php | 29 +-
.../grading/below_sixty_email_editor.php | 96 ++
app/Views/grading/comments.php | 0
app/Views/grading/final.php | 0
app/Views/grading/grading_main.php | 0
app/Views/grading/homework.php | 0
app/Views/grading/homework_tracking.php | 6 +-
app/Views/grading/midterm.php | 0
app/Views/grading/participation.php | 0
app/Views/grading/placement.php | 0
app/Views/grading/placement_batch.php | 0
app/Views/grading/placement_index.php | 0
app/Views/grading/project.php | 0
app/Views/grading/quiz.php | 0
app/Views/grading/schedule_meeting.php | 0
app/Views/grading/test.php | 0
app/Views/header.php | 0
app/Views/home.php | 0
app/Views/index.php | 187 ++-
app/Views/inventory/adjust_form.php | 0
app/Views/inventory/book/form.php | 0
app/Views/inventory/book/index.php | 0
app/Views/inventory/classroom/audit_form.php | 0
app/Views/inventory/classroom/form.php | 0
app/Views/inventory/classroom/index.php | 0
app/Views/inventory/kitchen/form.php | 0
app/Views/inventory/kitchen/index.php | 0
app/Views/inventory/movements/edit.php | 0
app/Views/inventory/movements/form.php | 0
app/Views/inventory/movements/index.php | 0
app/Views/inventory/office/form.php | 0
app/Views/inventory/office/index.php | 0
app/Views/inventory/summary.php | 0
app/Views/inventory/teacher_distribute.php | 0
.../invoice_payment/invoice_management.php | 0
app/Views/invoice_payment/pdf_template.php | 0
app/Views/landing_page/admin_dashboard.php | 0
app/Views/landing_page/guest_dashboard.php | 0
app/Views/landing_page/parent_dashboard.php | 11 +-
app/Views/landing_page/student_dashboard.php | 0
app/Views/landing_page/teacher_dashboard.php | 0
app/Views/layout/email_layout.php | 0
app/Views/layout/main.php | 0
app/Views/layout/main_layout.php | 2 +-
app/Views/layout/management_layout.php | 2 +-
app/Views/layout/register_layout.php | 0
app/Views/nav_builder/edit.php | 0
app/Views/nav_builder/index.php | 0
app/Views/navbar.php | 0
app/Views/notifications/list_active.php | 0
app/Views/notifications/list_deleted.php | 0
app/Views/pagination/custom_pagination.php | 0
app/Views/parent/add_second_parent.php | 0
app/Views/parent/assignments.php | 0
app/Views/parent/attendance.php | 0
app/Views/parent/calendar.php | 0
app/Views/parent/class_progress_list.php | 156 ++-
app/Views/parent/class_progress_view.php | 0
app/Views/parent/classes.php | 0
app/Views/parent/contact.php | 0
app/Views/parent/edit_emergency_contact.php | 0
app/Views/parent/edit_student_modal.php | 0
app/Views/parent/enroll_classes.php | 0
app/Views/parent/enroll_failure.php | 0
app/Views/parent/enroll_success.php | 0
app/Views/parent/event_participation.php | 61 +-
app/Views/parent/invoice_payment.php | 55 +
app/Views/parent/no_kids_registred.php | 0
app/Views/parent/parent_message.php | 0
app/Views/parent/payment_success.php | 0
app/Views/parent/payment_view.php | 0
app/Views/parent/register_student.php | 2 +-
app/Views/parent/report_attendance.php | 98 +-
app/Views/parent/report_cards.php | 72 +
app/Views/parent/scores.php | 31 +-
app/Views/parent/success_message.php | 0
app/Views/parent/withdraw_success.php | 0
app/Views/partials/academic_filter.php | 0
.../partials/assign_class_student_modal.php | 0
.../partials/assign_class_teacher_modal.php | 0
.../attendance_notification_modal.php | 0
app/Views/partials/avatar_menu.php | 0
app/Views/partials/emergencycontact_form.php | 0
app/Views/partials/flash_messages.php | 0
app/Views/partials/footer.php | 0
app/Views/partials/footer_back.php | 0
app/Views/partials/head.php | 0
app/Views/partials/head_back.php | 0
app/Views/partials/header.php | 0
app/Views/partials/header_back.php | 0
app/Views/partials/navbar.php | 6 +
app/Views/partials/navbar_back.php | 0
app/Views/partials/navbar_dynamic.php | 0
app/Views/partials/no_invoice_found.php | 0
app/Views/partials/sidebar.php | 0
app/Views/partials/standard_table.php | 0
app/Views/partials/student_form.php | 0
app/Views/payment/_list_modal.php | 0
app/Views/payment/discount.php | 0
app/Views/payment/extra_charges.php | 0
app/Views/payment/financial_report.php | 28 +-
.../payment/financial_report_summary.php | 15 +-
app/Views/payment/manual_pay.php | 0
app/Views/payment/manual_payment.php | 0
app/Views/payment/notification_management.php | 0
app/Views/payment/payment_redirect.php | 0
app/Views/payment/unpaid_parents.php | 33 +-
app/Views/payment_list.php | 0
app/Views/policy/picture_policy.php | 0
app/Views/policy/picture_policy_partial.php | 0
app/Views/policy/school_policy.php | 0
app/Views/policy/school_policy_partial.php | 0
app/Views/preferences.php | 0
app/Views/print_requests/admin_index.php | 0
app/Views/print_requests/teacher_index.php | 0
app/Views/printables_reports/badge_form.php | 0
app/Views/printables_reports/certificates.php | 0
app/Views/printables_reports/report_card.php | 45 +-
app/Views/printables_reports/sticker_form.php | 0
app/Views/profile.php | 0
app/Views/refunds/list.php | 0
app/Views/reimbursements/create.php | 0
app/Views/reimbursements/edit.php | 0
app/Views/reimbursements/index.php | 0
app/Views/reimbursements/under_processing.php | 0
app/Views/rfid/rfid_coming_soon.php | 0
app/Views/rfid/rfid_log_view.php | 0
app/Views/rolepermission/add_permission.php | 0
app/Views/rolepermission/add_role.php | 0
app/Views/rolepermission/assign_role.php | 28 +-
app/Views/rolepermission/edit_permission.php | 0
app/Views/rolepermission/edit_role.php | 0
.../rolepermission/edit_role_permissions.php | 0
app/Views/rolepermission/list_roles.php | 0
app/Views/rolepermission/permissionList.php | 0
app/Views/score_analysis/score_prediction.php | 0
app/Views/slips/preview_list.php | 0
app/Views/slips/slip_pdf.php | 0
app/Views/spinner.php | 0
app/Views/staff/create.php | 0
app/Views/staff/edit.php | 14 +-
app/Views/staff/index.php | 2 +-
app/Views/student/score_card.php | 0
app/Views/student/score_card_list.php | 0
app/Views/success.php | 0
app/Views/support.php | 0
app/Views/support_requests.php | 0
app/Views/swagger_ui.php | 0
app/Views/teacher/absence_vacation.php | 0
app/Views/teacher/add_final_exam.php | 0
app/Views/teacher/add_homework.php | 0
app/Views/teacher/add_midterm_exam.php | 0
app/Views/teacher/add_participation.php | 0
app/Views/teacher/add_project.php | 0
app/Views/teacher/add_quiz.php | 0
app/Views/teacher/add_quiz_column_form.php | 0
app/Views/teacher/calendar.php | 0
app/Views/teacher/class_progress_history.php | 8 +-
app/Views/teacher/class_progress_submit.php | 169 ++-
app/Views/teacher/class_progress_view.php | 1 +
app/Views/teacher/class_view.php | 0
.../teacher/competition_scores/index.php | 0
.../teacher/competition_scores/scores.php | 0
app/Views/teacher/drafts.php | 306 ++++-
app/Views/teacher/exam_drafts.php | 9 +
app/Views/teacher/homework_list.php | 0
app/Views/teacher/inbox.php | 0
app/Views/teacher/no_classes.php | 0
app/Views/teacher/scores.php | 9 +
app/Views/teacher/select_semester.php | 0
app/Views/teacher/sent.php | 0
app/Views/teacher/showupdate_attendance.php | 0
app/Views/teacher/teacher_assignment.php | 0
app/Views/teacher/teacher_contactus.php | 14 +-
app/Views/teacher/teacher_message.php | 0
app/Views/teacher/teacher_navbar.php | 0
app/Views/teacher/teacher_support.php | 0
app/Views/teacher/trash.php | 0
app/Views/team.php | 14 +-
app/Views/testimonial.php | 14 +-
app/Views/user/create.php | 0
app/Views/user/edit.php | 0
app/Views/user/forgot_password.php | 0
app/Views/user/login.php | 23 +-
app/Views/user/login_activity.php | 0
.../user/password_reset_confirmation.php | 0
app/Views/user/password_set_success.php | 0
app/Views/user/register.php | 0
app/Views/user/reset_password.php | 0
.../user/set_authorized_user_password.php | 142 ++
app/Views/user/set_password.php | 0
app/Views/user/user_list.php | 0
app/Views/user/welcome_back.php | 0
app/Views/welcome_message.php | 0
app/Views/whatsapp/manage_links.php | 0
app/Views/whatsapp/parent_contacts.php | 0
.../whatsapp/parent_contacts_by_class.php | 36 +-
app/Views/winners/competitions/index.php | 0
app/Views/winners/competitions/show.php | 0
app/db_connection.php | 0
app/index.html | 0
app/old.htaccess.old | 0
docker-compose.yml | 36 +
env | 2 +-
env (copy 1) => env_copy | 0
.../navbar_cd1336c7da8c3f064f0b3b9e0c0a2232 | 2 +-
writable/logs/log-2026-05-16.log | 228 ++++
...ci_session173e6f4352f712df14f8baf579c5a50b | 1 +
...ci_session504a14eddce8394e82d35d85f67cf7f7 | 1 +
...ci_session703454fd156d6cd0866f08203284eed0 | 1 +
901 files changed, 11241 insertions(+), 1340 deletions(-)
create mode 100644 .DS_Store
create mode 100644 .env
mode change 100644 => 100755 app/Commands/AttendanceAutoPublishCommand.php
mode change 100644 => 100755 app/Commands/CheckMissedPayments.php
mode change 100644 => 100755 app/Commands/CleanupExpiredNotifications.php
mode change 100644 => 100755 app/Commands/CleanupPasswordResets.php
mode change 100644 => 100755 app/Commands/ConfigUpdate.php
mode change 100644 => 100755 app/Commands/DeleteInactiveUsers.php
mode change 100644 => 100755 app/Commands/RecalculateAttendance.php
mode change 100644 => 100755 app/Commands/SendAbsenteesSummary.php
create mode 100755 app/Commands/SendExamDraftDeadlineReminders.php
mode change 100644 => 100755 app/Commands/SendLatesSummary.php
mode change 100644 => 100755 app/Commands/SendMonthlyPaymentNotifications.php
mode change 100644 => 100755 app/Commands/SendTestPaymentNotification.php
mode change 100644 => 100755 app/Commands/SyncPaypalPayments.php
mode change 100644 => 100755 app/Common.php
mode change 100644 => 100755 app/Config/Api.php
mode change 100644 => 100755 app/Config/App.php
mode change 100644 => 100755 app/Config/Autoload.php
mode change 100644 => 100755 app/Config/Boot/development.php
mode change 100644 => 100755 app/Config/Boot/production.php
mode change 100644 => 100755 app/Config/Boot/testing.php
mode change 100644 => 100755 app/Config/CURLRequest.php
mode change 100644 => 100755 app/Config/Cache.php
mode change 100644 => 100755 app/Config/Commands.php
mode change 100644 => 100755 app/Config/Constants.php
mode change 100644 => 100755 app/Config/ContentSecurityPolicy.php
mode change 100644 => 100755 app/Config/Cookie.php
mode change 100644 => 100755 app/Config/Cors.php
mode change 100644 => 100755 app/Config/Database.php
mode change 100644 => 100755 app/Config/DocTypes.php
mode change 100644 => 100755 app/Config/Email.php
mode change 100644 => 100755 app/Config/Encryption.php
mode change 100644 => 100755 app/Config/Events.php
mode change 100644 => 100755 app/Config/Exceptions.php
mode change 100644 => 100755 app/Config/Feature.php
mode change 100644 => 100755 app/Config/Filters.php
mode change 100644 => 100755 app/Config/ForeignCharacters.php
mode change 100644 => 100755 app/Config/Format.php
mode change 100644 => 100755 app/Config/Generators.php
mode change 100644 => 100755 app/Config/Honeypot.php
mode change 100644 => 100755 app/Config/Images.php
mode change 100644 => 100755 app/Config/Kint.php
mode change 100644 => 100755 app/Config/Logger.php
mode change 100644 => 100755 app/Config/Migrations.php
mode change 100644 => 100755 app/Config/Mimes.php
mode change 100644 => 100755 app/Config/Modules.php
mode change 100644 => 100755 app/Config/Optimize.php
mode change 100644 => 100755 app/Config/Pager.php
mode change 100644 => 100755 app/Config/Paths.php
mode change 100644 => 100755 app/Config/PaypalConfig.php
mode change 100644 => 100755 app/Config/Publisher.php
mode change 100644 => 100755 app/Config/Roles.php
mode change 100644 => 100755 app/Config/Routes.php
mode change 100644 => 100755 app/Config/Routing.php
mode change 100644 => 100755 app/Config/School.php
mode change 100644 => 100755 app/Config/Security.php
mode change 100644 => 100755 app/Config/Services.php
mode change 100644 => 100755 app/Config/Session.php
mode change 100644 => 100755 app/Config/SessionTimeout.php
mode change 100644 => 100755 app/Config/Style.php
mode change 100644 => 100755 app/Config/Toolbar.php
mode change 100644 => 100755 app/Config/UserAgents.php
mode change 100644 => 100755 app/Config/Validation.php
mode change 100644 => 100755 app/Config/View.php
mode change 100644 => 100755 app/Controllers/Admin/CompetitionWinnersController.php
mode change 100644 => 100755 app/Controllers/AdminProgressController.php
mode change 100644 => 100755 app/Controllers/ApiDocsController.php
mode change 100644 => 100755 app/Controllers/AuthController.php
mode change 100644 => 100755 app/Controllers/BaseController.php
mode change 100644 => 100755 app/Controllers/ClassProgressController.php
mode change 100644 => 100755 app/Controllers/DocsController.php
mode change 100644 => 100755 app/Controllers/ErrorController.php
mode change 100644 => 100755 app/Controllers/Home.php
mode change 100644 => 100755 app/Controllers/InitializeRolesPermissions.php
mode change 100644 => 100755 app/Controllers/ParentProgressController.php
create mode 100755 app/Controllers/ParentReportCardController.php
mode change 100644 => 100755 app/Controllers/PrintRequests.php
mode change 100644 => 100755 app/Controllers/ProofreadController.php
mode change 100644 => 100755 app/Controllers/TimeOffNotificationController.php
mode change 100644 => 100755 app/Controllers/View/AdministratorController.php
mode change 100644 => 100755 app/Controllers/View/AssignmentController.php
mode change 100644 => 100755 app/Controllers/View/AttendanceCommentTemplateController.php
mode change 100644 => 100755 app/Controllers/View/AttendanceController.php
mode change 100644 => 100755 app/Controllers/View/AttendanceTrackingController.php
mode change 100644 => 100755 app/Controllers/View/AuthorizedUsersController.php
mode change 100644 => 100755 app/Controllers/View/BadgesController.php
mode change 100644 => 100755 app/Controllers/View/BroadcastEmailController.php
mode change 100644 => 100755 app/Controllers/View/ClassController.php
mode change 100644 => 100755 app/Controllers/View/ClassPrepController.php
mode change 100644 => 100755 app/Controllers/View/ClassPreparationController.php
mode change 100644 => 100755 app/Controllers/View/CommunicationController.php
mode change 100644 => 100755 app/Controllers/View/CompetitionScoresController.php
mode change 100644 => 100755 app/Controllers/View/ConfigurationController.php
mode change 100644 => 100755 app/Controllers/View/ContactController.php
mode change 100644 => 100755 app/Controllers/View/DashboardRedirectController.php
mode change 100644 => 100755 app/Controllers/View/DiscountController.php
mode change 100644 => 100755 app/Controllers/View/DocsController.php
mode change 100644 => 100755 app/Controllers/View/EmailController.php
mode change 100644 => 100755 app/Controllers/View/EmailExtractorController.php
mode change 100644 => 100755 app/Controllers/View/EmergencyContactController.php
mode change 100644 => 100755 app/Controllers/View/EventController.php
mode change 100644 => 100755 app/Controllers/View/ExamDraftController.php
mode change 100644 => 100755 app/Controllers/View/ExpenseController.php
mode change 100644 => 100755 app/Controllers/View/ExtraChargesController.php
mode change 100644 => 100755 app/Controllers/View/FamilyAdminController.php
mode change 100644 => 100755 app/Controllers/View/FamilyController.php
mode change 100644 => 100755 app/Controllers/View/FilesController.php
mode change 100644 => 100755 app/Controllers/View/FinalController.php
mode change 100644 => 100755 app/Controllers/View/FinancialController.php
mode change 100644 => 100755 app/Controllers/View/FlagController.php
mode change 100644 => 100755 app/Controllers/View/FrontendController.php
mode change 100644 => 100755 app/Controllers/View/GradingController.php
mode change 100644 => 100755 app/Controllers/View/HealthController.php
mode change 100644 => 100755 app/Controllers/View/HomeworkController.php
mode change 100644 => 100755 app/Controllers/View/HomeworkTrackingController.php
mode change 100644 => 100755 app/Controllers/View/InfoIconController.php
mode change 100644 => 100755 app/Controllers/View/InventoryController.php
mode change 100644 => 100755 app/Controllers/View/InvoiceController.php
mode change 100644 => 100755 app/Controllers/View/IpBanController.php
mode change 100644 => 100755 app/Controllers/View/LandingPageController.php
mode change 100644 => 100755 app/Controllers/View/LateSlipLogsController.php
mode change 100644 => 100755 app/Controllers/View/MessagesController.php
mode change 100644 => 100755 app/Controllers/View/MidtermController.php
mode change 100644 => 100755 app/Controllers/View/NavBuilderController.php
mode change 100644 => 100755 app/Controllers/View/NotificationsController.php
mode change 100644 => 100755 app/Controllers/View/PageController.php
mode change 100644 => 100755 app/Controllers/View/ParentAttendanceReportController.php
mode change 100644 => 100755 app/Controllers/View/ParentController.php
mode change 100644 => 100755 app/Controllers/View/ParticipationController.php
mode change 100644 => 100755 app/Controllers/View/PaymentController.php
mode change 100644 => 100755 app/Controllers/View/PaymentNotificationController.php
mode change 100644 => 100755 app/Controllers/View/PaymentTransactionController.php
mode change 100644 => 100755 app/Controllers/View/PaypalTransactionsController.php
mode change 100644 => 100755 app/Controllers/View/PolicyController.php
mode change 100644 => 100755 app/Controllers/View/PreferencesController.php
create mode 100755 app/Controllers/View/PrintRequests.php
mode change 100644 => 100755 app/Controllers/View/PrintablesBaseController.php
mode change 100644 => 100755 app/Controllers/View/ProjectController.php
mode change 100644 => 100755 app/Controllers/View/PurchaseOrderController.php
mode change 100644 => 100755 app/Controllers/View/QuizController.php
mode change 100644 => 100755 app/Controllers/View/RFIDController.php
mode change 100644 => 100755 app/Controllers/View/RefundController.php
mode change 100644 => 100755 app/Controllers/View/RegisterController.php
mode change 100644 => 100755 app/Controllers/View/ReimbursementController.php
mode change 100644 => 100755 app/Controllers/View/ReportCardsController.php
mode change 100644 => 100755 app/Controllers/View/RolePermissionController.php
mode change 100644 => 100755 app/Controllers/View/RoleSwitcherController.php
mode change 100644 => 100755 app/Controllers/View/SchoolCalendarController.php
mode change 100644 => 100755 app/Controllers/View/ScoreCommentController.php
mode change 100644 => 100755 app/Controllers/View/ScoreController.php
mode change 100644 => 100755 app/Controllers/View/ScorePredictor.php
mode change 100644 => 100755 app/Controllers/View/SendSMSController.php
mode change 100644 => 100755 app/Controllers/View/SessionTimeoutController.php
mode change 100644 => 100755 app/Controllers/View/SettingsController.php
mode change 100644 => 100755 app/Controllers/View/SlipPrinterController.php
mode change 100644 => 100755 app/Controllers/View/StaffController.php
mode change 100644 => 100755 app/Controllers/View/StatsController.php
mode change 100644 => 100755 app/Controllers/View/StickersController.php
mode change 100644 => 100755 app/Controllers/View/StudentController.php
mode change 100644 => 100755 app/Controllers/View/SubjectCurriculumController.php
mode change 100644 => 100755 app/Controllers/View/SupplierController.php
mode change 100644 => 100755 app/Controllers/View/SupplyCategoryController.php
mode change 100644 => 100755 app/Controllers/View/SupportController.php
mode change 100644 => 100755 app/Controllers/View/TeacherController.php
mode change 100644 => 100755 app/Controllers/View/TestDBController.php
mode change 100644 => 100755 app/Controllers/View/UiController.php
mode change 100644 => 100755 app/Controllers/View/UserController.php
mode change 100644 => 100755 app/Controllers/View/WhatsappController.php
mode change 100644 => 100755 app/Controllers/WinnersController.php
delete mode 100644 app/Database/Migrations/.gitkeep
mode change 100644 => 100755 app/Database/Migrations/2024-05-26-000000_AddEventCategoryToEvents.php
mode change 100644 => 100755 app/Database/Migrations/2024-11-05-000700_AddBatchNumberToReimbursements.php
mode change 100644 => 100755 app/Database/Migrations/2024-11-07-000800_AddYearlyBatchNumberToReimbursementBatches.php
mode change 100644 => 100755 app/Database/Migrations/2025-09-10-000002_CreateInventoryTables.php
mode change 100644 => 100755 app/Database/Migrations/2025-09-11-000010_CreateInventoryMovements.php
mode change 100644 => 100755 app/Database/Migrations/2025-09-11-000300_AddClassroomConditionBuckets.php
mode change 100644 => 100755 app/Database/Migrations/2025-09-11-000400_AddGradeRangeToBookCategories.php
mode change 100644 => 100755 app/Database/Migrations/2025-09-27-000001_CreateStaffAttendance.php
mode change 100644 => 100755 app/Database/Migrations/2025-10-08-000100_CreateBadgePrintLogs.php
mode change 100644 => 100755 app/Database/Migrations/2025-10-16-000200_CreateFamiliesTables.php
mode change 100644 => 100755 app/Database/Migrations/2025-10-17-121500_AddNoSchoolToCalendarEvents.php
mode change 100644 => 100755 app/Database/Migrations/2025-10-17-130000_CreateWhatsappGroupMemberships.php
mode change 100644 => 100755 app/Database/Migrations/2025-10-19-000300_CreateLateSlipLogs.php
mode change 100644 => 100755 app/Database/Migrations/2025-10-19-090000_CreatePaymentNotificationLogs.php
mode change 100644 => 100755 app/Database/Migrations/2025-10-20-000500_CreateParentAttendanceReports.php
mode change 100644 => 100755 app/Database/Migrations/2025-10-21-000600_AddSemesterToLateSlipLogs.php
mode change 100644 => 100755 app/Database/Migrations/2025-10-21-110000_CreatePromotionQueue.php
mode change 100644 => 100755 app/Database/Migrations/2025-10-27-000510_CreateEarlyDismissalSignatures.php
mode change 100644 => 100755 app/Database/Migrations/2025-11-05-050100_AddTimezoneToPreferencesAndSettings.php
mode change 100644 => 100755 app/Database/Migrations/2025-11-23-011228_AddDonationCategoryToExpenses.php
mode change 100644 => 100755 app/Database/Migrations/2025-12-01-000100_CreateReimbursementBatchAdminFiles.php
mode change 100644 => 100755 app/Database/Migrations/2025-12-24-190600_AddReviewFieldsToScoreComments.php
mode change 100644 => 100755 app/Database/Migrations/2025-12-27-050000_CreateAttendanceCommentTemplates.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-05-215636_CreatePrintRequests.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-05-215851_FixPrintRequestsForeignKey.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-06-195508_RevertPrintRequestsForeignKey.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-06-200210_DropPrintRequestsForeignKey.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-06-201209_FixPrintRequestsForeignKeyOnceAndForAll.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-06-205621_FixPrintRequestsForeignKeyAgain.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-10-000001_CreateCompetitions.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-10-000002_CreateCompetitionScores.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-10-000003_CreateCompetitionWinners.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-10-000004_AddCompetitionNavItems.php
create mode 100755 app/Database/Migrations/2026-01-10-000005_AddBelowSixtyNavItem.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-10-000005_CreateCompetitionClassWinners.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-10-000006_AddQuestionCountToCompetitionClassWinners.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-10-000007_AddPrizeColumnsToCompetitionClassWinners.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-10-000007_AllowCompetitionWinnerRankTies.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-11-000100_CreateAdminNotificationSubjects.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-13-000300_AddCompetitionLockFields.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-14-000100_AddStudentActiveFlag.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-14-000200_CreateClassProgressReports.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-20-000000_CreateSubjectCurriculumItems.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-25-000000_RemoveClassAssignmentSemester.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-26-000100_CreateTeacherSubmissionNotificationHistory.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-27-092000_AddEventTypeToCalendarEvents.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-27-130500_CreateExamDraftSubmissions.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-27-200000_AddVersionToExamDrafts.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-27-210500_AddFinalPdfToExamDrafts.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-27-211500_AddIsLegacyToExamDrafts.php
create mode 100755 app/Database/Migrations/2026-01-27-220000_AddReviewRevisionToExamDrafts.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-28-120000_AddClassSectionIdToScoreComments.php
mode change 100644 => 100755 app/Database/Migrations/2026-01-30-000100_CreateParentMeetingSchedules.php
mode change 100644 => 100755 app/Database/Migrations/2026-02-04-000300_CreateClassProgressAttachments.php
mode change 100644 => 100755 app/Database/Migrations/2026-02-04-000400_CreatePlacementLevels.php
mode change 100644 => 100755 app/Database/Migrations/2026-02-04-000410_UpdatePlacementLevelRange.php
mode change 100644 => 100755 app/Database/Migrations/2026-02-04-000420_CreatePlacementBatches.php
mode change 100644 => 100755 app/Database/Migrations/2026-02-04-000430_CreatePlacementScores.php
mode change 100644 => 100755 app/Database/Migrations/2026-02-04-000440_AddEventOnlyToStudentClass.php
mode change 100644 => 100755 app/Database/Migrations/2026-02-05-000100_CreateGradingLocks.php
mode change 100644 => 100755 app/Database/Migrations/2026-02-05-000200_CreateMissingScoreOverrides.php
mode change 100644 => 100755 app/Database/Migrations/2026-02-05-000300_AddPageSelectionToPrintRequests.php
mode change 100644 => 100755 app/Database/Migrations/2026-02-10-000100_AddStyleToPreferences.php
create mode 100755 app/Database/Migrations/2026-02-16-000001_AddEventPaidFlagToCharges.php
create mode 100755 app/Database/Migrations/2026-02-27-120000_CreateReportCardAcknowledgements.php
create mode 100755 app/Database/Migrations/2026-04-13-000002_AddClassSectionToEventCharges.php
create mode 100755 app/Database/Migrations/2026-04-13-000003_AddEventPaymentRefToEventCharges.php
create mode 100755 app/Database/Migrations/2026-04-13-000004_AddExternalParticipantFieldsToEventCharges.php
create mode 100755 app/Database/Migrations/2026-04-13-000005_AddExternalParentInfoToEventCharges.php
create mode 100755 app/Database/Migrations/2026-04-17-000001_AddExternalParentEmailToEventCharges.php
create mode 100755 app/Database/Migrations/2026-04-19-000001_AddWaiverSignedToEventCharges.php
mode change 100644 => 100755 app/Database/Migrations/ExtendStaffAttendanceForTeachers.php
delete mode 100644 app/Database/Seeds/.gitkeep
mode change 100644 => 100755 app/Database/Seeds/FamiliesBootstrapSeeder.txt
mode change 100644 => 100755 app/Database/Seeds/FamiliesRebuildSeeder.php
mode change 100644 => 100755 app/Database/Seeds/NavSeeder.php
mode change 100644 => 100755 app/Database/Seeds/SubjectCurriculumSeeder.php
mode change 100644 => 100755 app/Events/DeleteUnverifiedUser.php
delete mode 100644 app/Filters/.gitkeep
mode change 100644 => 100755 app/Filters/ApiAuthFilter.php
mode change 100644 => 100755 app/Filters/ApiDocsAuthFilter.php
mode change 100644 => 100755 app/Filters/AuthFilter.php
mode change 100644 => 100755 app/Filters/CleanupScheduler.php
mode change 100644 => 100755 app/Filters/PermissionFilter.php
mode change 100644 => 100755 app/Filters/TimezoneFilter.php
delete mode 100644 app/Helpers/.gitkeep
mode change 100644 => 100755 app/Helpers/AuthHelper.php
delete mode 100644 app/Helpers/DocumentHelper.php
mode change 100644 => 100755 app/Helpers/GlobalConfigHelper.php
mode change 100644 => 100755 app/Helpers/attendance_comment_helper.php
create mode 100755 app/Helpers/auth_helper.php
mode change 100644 => 100755 app/Helpers/jwt_helper.php
mode change 100644 => 100755 app/Helpers/pbkdf2_helper.php
mode change 100644 => 100755 app/Helpers/semester_selection_helper.php
mode change 100644 => 100755 app/Helpers/time_helper.php
mode change 100644 => 100755 app/Interfaces/ScoreCalculatorInterface.php
delete mode 100644 app/Language/.gitkeep
mode change 100644 => 100755 app/Language/en/Validation.php
delete mode 100644 app/Libraries/.gitkeep
mode change 100644 => 100755 app/Libraries/AttendanceAutoPublish.php
mode change 100644 => 100755 app/Libraries/AttendancePolicy.php
mode change 100644 => 100755 app/Libraries/StaffTimeOffLinkService.php
mode change 100644 => 100755 app/Listeners/AttendanceConsequenceListener.php
mode change 100644 => 100755 app/Listeners/BelowSixtyEmailListener.php
mode change 100644 => 100755 app/Listeners/SchoolEventListener.php
mode change 100644 => 100755 app/Listeners/WhatsappInviteListener.php
delete mode 100644 app/Models/.gitkeep
mode change 100644 => 100755 app/Models/AdditionalChargeModel.php
mode change 100644 => 100755 app/Models/AdminNotificationSubjectModel.php
mode change 100644 => 100755 app/Models/AttendanceCommentTemplateModel.php
mode change 100644 => 100755 app/Models/AttendanceDataModel.php
mode change 100644 => 100755 app/Models/AttendanceDayModel.php
mode change 100644 => 100755 app/Models/AttendanceEmailTemplateModel.php
mode change 100644 => 100755 app/Models/AttendanceRecordModel.php
mode change 100644 => 100755 app/Models/AttendanceTrackingModel.php
mode change 100644 => 100755 app/Models/AuthorizedUserModel.php
mode change 100644 => 100755 app/Models/BadgePrintLogModel.php
mode change 100644 => 100755 app/Models/CalendarModel.php
mode change 100644 => 100755 app/Models/ClassModel.php
mode change 100644 => 100755 app/Models/ClassPrepAdjustmentModel.php
mode change 100644 => 100755 app/Models/ClassPreparationLogModel.php
mode change 100644 => 100755 app/Models/ClassProgressAttachmentModel.php
mode change 100644 => 100755 app/Models/ClassProgressReportModel.php
mode change 100644 => 100755 app/Models/ClassSectionModel.php
mode change 100644 => 100755 app/Models/CommunicationLogModel.php
mode change 100644 => 100755 app/Models/CompetitionClassWinnerModel.php
mode change 100644 => 100755 app/Models/CompetitionModel.php
mode change 100644 => 100755 app/Models/CompetitionScoreModel.php
mode change 100644 => 100755 app/Models/CompetitionWinnerModel.php
mode change 100644 => 100755 app/Models/ConfigurationModel.php
mode change 100644 => 100755 app/Models/ContactUsModel.php
mode change 100644 => 100755 app/Models/CurrentFlagModel.php
mode change 100644 => 100755 app/Models/DiscountUsageModel.php
mode change 100644 => 100755 app/Models/DiscountVoucherModel.php
mode change 100644 => 100755 app/Models/EarlyDismissalSignatureModel.php
mode change 100644 => 100755 app/Models/EmailTemplateModel.php
mode change 100644 => 100755 app/Models/EmergencyContactModel.php
mode change 100644 => 100755 app/Models/EnrollmentModel.php
mode change 100644 => 100755 app/Models/EventChargesModel.php
mode change 100644 => 100755 app/Models/EventModel.php
mode change 100644 => 100755 app/Models/ExamDraftModel.php
mode change 100644 => 100755 app/Models/ExamModel.php
mode change 100644 => 100755 app/Models/ExpenseModel.php
mode change 100644 => 100755 app/Models/FamilyCommPrefModel.php
mode change 100644 => 100755 app/Models/FamilyGuardianModel.php
mode change 100644 => 100755 app/Models/FamilyModel.php
mode change 100644 => 100755 app/Models/FamilyStudentModel.php
mode change 100644 => 100755 app/Models/FinalExamModel.php
mode change 100644 => 100755 app/Models/FinalScoreModel.php
mode change 100644 => 100755 app/Models/FlagModel.php
mode change 100644 => 100755 app/Models/GradingLockModel.php
mode change 100644 => 100755 app/Models/HomeworkModel.php
mode change 100644 => 100755 app/Models/InventoryCategoryModel.php
mode change 100644 => 100755 app/Models/InventoryItemModel.php
mode change 100644 => 100755 app/Models/InventoryMovementModel.php
mode change 100644 => 100755 app/Models/InvoiceEventModel.php
mode change 100644 => 100755 app/Models/InvoiceModel.php
mode change 100644 => 100755 app/Models/InvoiceStudentListModel.php
mode change 100644 => 100755 app/Models/IpAttemptModel.php
mode change 100644 => 100755 app/Models/LateSlipLogModel.php
mode change 100644 => 100755 app/Models/LoginActivityModel.php
mode change 100644 => 100755 app/Models/ManualPaymentModel.php
mode change 100644 => 100755 app/Models/MessageModel.php
mode change 100644 => 100755 app/Models/MidtermExamModel.php
mode change 100644 => 100755 app/Models/MissingScoreOverrideModel.php
mode change 100644 => 100755 app/Models/NavItemModel.php
mode change 100644 => 100755 app/Models/NotificationModel.php
mode change 100644 => 100755 app/Models/ParentAttendanceReportModel.php
mode change 100644 => 100755 app/Models/ParentMeetingScheduleModel.php
mode change 100644 => 100755 app/Models/ParentModel.php
mode change 100644 => 100755 app/Models/ParentNotificationModel.php
mode change 100644 => 100755 app/Models/ParticipationModel.php
mode change 100644 => 100755 app/Models/PasswordResetModel.php
mode change 100644 => 100755 app/Models/PasswordResetRequestModel.php
mode change 100644 => 100755 app/Models/PayPalPaymentModel.php
mode change 100644 => 100755 app/Models/PaymentErrorModel.php
mode change 100644 => 100755 app/Models/PaymentModel.php
mode change 100644 => 100755 app/Models/PaymentNotificationLogModel.php
mode change 100644 => 100755 app/Models/PaymentTransactionModel.php
mode change 100644 => 100755 app/Models/PaypalTransactionModel.php
mode change 100644 => 100755 app/Models/PermissionModel.php
mode change 100644 => 100755 app/Models/PlacementBatchModel.php
mode change 100644 => 100755 app/Models/PlacementLevelModel.php
mode change 100644 => 100755 app/Models/PlacementScoreModel.php
mode change 100644 => 100755 app/Models/PreferencesModel.php
mode change 100644 => 100755 app/Models/PrintRequestModel.php
mode change 100644 => 100755 app/Models/ProjectModel.php
mode change 100644 => 100755 app/Models/PromotionQueueModel.php
mode change 100644 => 100755 app/Models/PurchaseOrderItemModel.php
mode change 100644 => 100755 app/Models/PurchaseOrderModel.php
mode change 100644 => 100755 app/Models/QuizModel.php
mode change 100644 => 100755 app/Models/RefundModel.php
mode change 100644 => 100755 app/Models/ReimbursementBatchAdminFileModel.php
mode change 100644 => 100755 app/Models/ReimbursementBatchItemModel.php
mode change 100644 => 100755 app/Models/ReimbursementBatchModel.php
mode change 100644 => 100755 app/Models/ReimbursementModel.php
create mode 100755 app/Models/ReportCardAcknowledgementModel.php
mode change 100644 => 100755 app/Models/RoleModel.php
mode change 100644 => 100755 app/Models/RoleNavItemModel.php
mode change 100644 => 100755 app/Models/RolePermissionModel.php
mode change 100644 => 100755 app/Models/ScoreCommentModel.php
mode change 100644 => 100755 app/Models/SectionModel.php
mode change 100644 => 100755 app/Models/SemesterScoreModel.php
mode change 100644 => 100755 app/Models/SettingsModel.php
mode change 100644 => 100755 app/Models/StaffAttendanceModel.php
mode change 100644 => 100755 app/Models/StaffModel.php
mode change 100644 => 100755 app/Models/StatsModel.php
mode change 100644 => 100755 app/Models/StudentAllergyModel.php
mode change 100644 => 100755 app/Models/StudentClassModel.php
mode change 100644 => 100755 app/Models/StudentMedicalConditionModel.php
mode change 100644 => 100755 app/Models/StudentModel.php
mode change 100644 => 100755 app/Models/SubjectCurriculumModel.php
delete mode 100644 app/Models/SupplyCategoryModel.php
mode change 100644 => 100755 app/Models/TeacherClassModel.php
mode change 100644 => 100755 app/Models/TeacherModel.php
mode change 100644 => 100755 app/Models/TeacherSubmissionNotificationHistoryModel.php
mode change 100644 => 100755 app/Models/UserModel.php
mode change 100644 => 100755 app/Models/UserNotificationModel.php
mode change 100644 => 100755 app/Models/UserRoleModel.php
mode change 100644 => 100755 app/Models/WhatsappGroupLinkModel.php
mode change 100644 => 100755 app/Models/WhatsappGroupMembershipModel.php
mode change 100644 => 100755 app/Models/WhatsappInviteLogModel.php
mode change 100644 => 100755 app/Services/ApiClient.php
mode change 100644 => 100755 app/Services/Calculators/AttendanceCalculator.php
mode change 100644 => 100755 app/Services/Calculators/HomeworkCalculator.php
mode change 100644 => 100755 app/Services/Calculators/ProjectCalculator.php
mode change 100644 => 100755 app/Services/Calculators/QuizCalculator.php
mode change 100644 => 100755 app/Services/EmailService.php
mode change 100644 => 100755 app/Services/FeeCalculationService.php
mode change 100644 => 100755 app/Services/NavbarService.php
mode change 100644 => 100755 app/Services/NotificationService.php
mode change 100644 => 100755 app/Services/PhoneFormatterService.php
mode change 100644 => 100755 app/Services/RoleService.php
mode change 100644 => 100755 app/Services/SchoolIdService.php
mode change 100644 => 100755 app/Services/SemesterRangeService.php
mode change 100644 => 100755 app/Services/SemesterScoreService.php
mode change 100644 => 100755 app/Services/TimeService.php
mode change 100644 => 100755 app/Services/UserEventService.php
mode change 100644 => 100755 app/Services/UserService.php
delete mode 100644 app/ThirdParty/.gitkeep
mode change 100644 => 100755 app/ThirdParty/fpdf/FAQ.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/changelog.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/__construct.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/acceptpagebreak.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/addfont.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/addlink.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/addpage.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/aliasnbpages.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/cell.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/close.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/error.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/footer.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/getpageheight.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/getpagewidth.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/getstringwidth.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/getx.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/gety.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/header.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/image.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/index.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/line.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/link.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/ln.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/multicell.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/output.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/pageno.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/rect.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/setauthor.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/setautopagebreak.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/setcompression.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/setcreator.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/setdisplaymode.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/setdrawcolor.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/setfillcolor.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/setfont.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/setfontsize.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/setkeywords.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/setleftmargin.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/setlinewidth.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/setlink.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/setmargins.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/setrightmargin.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/setsubject.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/settextcolor.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/settitle.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/settopmargin.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/setx.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/setxy.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/sety.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/text.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/doc/write.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/font/courier.php
mode change 100644 => 100755 app/ThirdParty/fpdf/font/courierb.php
mode change 100644 => 100755 app/ThirdParty/fpdf/font/courierbi.php
mode change 100644 => 100755 app/ThirdParty/fpdf/font/courieri.php
mode change 100644 => 100755 app/ThirdParty/fpdf/font/helvetica.php
mode change 100644 => 100755 app/ThirdParty/fpdf/font/helveticab.php
mode change 100644 => 100755 app/ThirdParty/fpdf/font/helveticabi.php
mode change 100644 => 100755 app/ThirdParty/fpdf/font/helveticai.php
mode change 100644 => 100755 app/ThirdParty/fpdf/font/symbol.php
mode change 100644 => 100755 app/ThirdParty/fpdf/font/times.php
mode change 100644 => 100755 app/ThirdParty/fpdf/font/timesb.php
mode change 100644 => 100755 app/ThirdParty/fpdf/font/timesbi.php
mode change 100644 => 100755 app/ThirdParty/fpdf/font/timesi.php
mode change 100644 => 100755 app/ThirdParty/fpdf/font/zapfdingbats.php
mode change 100644 => 100755 app/ThirdParty/fpdf/fpdf.css
mode change 100644 => 100755 app/ThirdParty/fpdf/fpdf.php
mode change 100644 => 100755 app/ThirdParty/fpdf/install.txt
mode change 100644 => 100755 app/ThirdParty/fpdf/license.txt
mode change 100644 => 100755 app/ThirdParty/fpdf/makefont/cp1250.map
mode change 100644 => 100755 app/ThirdParty/fpdf/makefont/cp1251.map
mode change 100644 => 100755 app/ThirdParty/fpdf/makefont/cp1252.map
mode change 100644 => 100755 app/ThirdParty/fpdf/makefont/cp1253.map
mode change 100644 => 100755 app/ThirdParty/fpdf/makefont/cp1254.map
mode change 100644 => 100755 app/ThirdParty/fpdf/makefont/cp1255.map
mode change 100644 => 100755 app/ThirdParty/fpdf/makefont/cp1257.map
mode change 100644 => 100755 app/ThirdParty/fpdf/makefont/cp1258.map
mode change 100644 => 100755 app/ThirdParty/fpdf/makefont/cp874.map
mode change 100644 => 100755 app/ThirdParty/fpdf/makefont/iso-8859-1.map
mode change 100644 => 100755 app/ThirdParty/fpdf/makefont/iso-8859-11.map
mode change 100644 => 100755 app/ThirdParty/fpdf/makefont/iso-8859-15.map
mode change 100644 => 100755 app/ThirdParty/fpdf/makefont/iso-8859-16.map
mode change 100644 => 100755 app/ThirdParty/fpdf/makefont/iso-8859-2.map
mode change 100644 => 100755 app/ThirdParty/fpdf/makefont/iso-8859-4.map
mode change 100644 => 100755 app/ThirdParty/fpdf/makefont/iso-8859-5.map
mode change 100644 => 100755 app/ThirdParty/fpdf/makefont/iso-8859-7.map
mode change 100644 => 100755 app/ThirdParty/fpdf/makefont/iso-8859-9.map
mode change 100644 => 100755 app/ThirdParty/fpdf/makefont/koi8-r.map
mode change 100644 => 100755 app/ThirdParty/fpdf/makefont/koi8-u.map
mode change 100644 => 100755 app/ThirdParty/fpdf/makefont/makefont.php
mode change 100644 => 100755 app/ThirdParty/fpdf/makefont/ttfparser.php
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/20k_c1.txt
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/20k_c2.txt
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/CevicheOne-Regular-Licence.txt
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/CevicheOne-Regular.php
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/CevicheOne-Regular.ttf
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/CevicheOne-Regular.z
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/countries.txt
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/index.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/logo.png
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/makefont.php
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/tuto1.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/tuto1.php
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/tuto2.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/tuto2.php
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/tuto3.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/tuto3.php
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/tuto4.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/tuto4.php
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/tuto5.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/tuto5.php
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/tuto6.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/tuto6.php
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/tuto7.htm
mode change 100644 => 100755 app/ThirdParty/fpdf/tutorial/tuto7.php
mode change 100644 => 100755 app/Views/404.html
mode change 100644 => 100755 app/Views/about.php
mode change 100644 => 100755 app/Views/account/choose_account.php
mode change 100644 => 100755 app/Views/activate_account.php
mode change 100644 => 100755 app/Views/admin/class_progress_list.php
mode change 100644 => 100755 app/Views/admin/class_progress_view.php
mode change 100644 => 100755 app/Views/admin/competition_winners/form.php
mode change 100644 => 100755 app/Views/admin/competition_winners/index.php
mode change 100644 => 100755 app/Views/admin/competition_winners/preview.php
mode change 100644 => 100755 app/Views/admin/competition_winners/scores.php
mode change 100644 => 100755 app/Views/admin/competition_winners/winners.php
create mode 100755 app/Views/admin/partials/class_progress_unit_display.php
mode change 100644 => 100755 app/Views/admin/student_score_card.php
mode change 100644 => 100755 app/Views/administrator/absence_vacation.php
mode change 100644 => 100755 app/Views/administrator/academic_performance.php
mode change 100644 => 100755 app/Views/administrator/admin_attendance_records.php
mode change 100644 => 100755 app/Views/administrator/admin_profiles.php
mode change 100644 => 100755 app/Views/administrator/administratordashboard.php
mode change 100644 => 100755 app/Views/administrator/attendance.php
mode change 100644 => 100755 app/Views/administrator/attendance_records.php
mode change 100644 => 100755 app/Views/administrator/behavior_reports.php
mode change 100644 => 100755 app/Views/administrator/broadcast_email.php
mode change 100644 => 100755 app/Views/administrator/budget_reports.php
mode change 100644 => 100755 app/Views/administrator/calendar.php
mode change 100644 => 100755 app/Views/administrator/calendar_add_event.php
mode change 100644 => 100755 app/Views/administrator/calendar_edit.php
mode change 100644 => 100755 app/Views/administrator/calendar_view.php
mode change 100644 => 100755 app/Views/administrator/class_assignment.php
mode change 100644 => 100755 app/Views/administrator/class_section.php
mode change 100644 => 100755 app/Views/administrator/class_warning_modal.php
mode change 100644 => 100755 app/Views/administrator/communication_logs.php
mode change 100644 => 100755 app/Views/administrator/course_materials.php
mode change 100644 => 100755 app/Views/administrator/create_parent.php
mode change 100644 => 100755 app/Views/administrator/daily_attendance.php
mode change 100644 => 100755 app/Views/administrator/daily_attendance_analysis.php
mode change 100644 => 100755 app/Views/administrator/edit_parent.php
mode change 100644 => 100755 app/Views/administrator/edit_teacher.php
mode change 100644 => 100755 app/Views/administrator/emergency_contact/edit.php
mode change 100644 => 100755 app/Views/administrator/emergency_contact/index.php
mode change 100644 => 100755 app/Views/administrator/events/create_event.php
mode change 100644 => 100755 app/Views/administrator/events/edit_event.php
mode change 100644 => 100755 app/Views/administrator/events/event_charges.php
mode change 100644 => 100755 app/Views/administrator/events/event_list.php
mode change 100644 => 100755 app/Views/administrator/exam_drafts.php
delete mode 100644 app/Views/administrator/exam_management.php
mode change 100644 => 100755 app/Views/administrator/expense_management.php
mode change 100644 => 100755 app/Views/administrator/fee_collection.php
mode change 100644 => 100755 app/Views/administrator/fee_payment_records.php
mode change 100644 => 100755 app/Views/administrator/feedback_complaints.php
mode change 100644 => 100755 app/Views/administrator/grades.php
mode change 100644 => 100755 app/Views/administrator/grading_management.php
mode change 100644 => 100755 app/Views/administrator/health_records.php
mode change 100644 => 100755 app/Views/administrator/index.php
mode change 100644 => 100755 app/Views/administrator/ip_bans.php
mode change 100644 => 100755 app/Views/administrator/late_slip_logs.php
mode change 100644 => 100755 app/Views/administrator/manage_users.php
mode change 100644 => 100755 app/Views/administrator/notifications_alerts.php
mode change 100644 => 100755 app/Views/administrator/parent.php
mode change 100644 => 100755 app/Views/administrator/parent_profile.php
mode change 100644 => 100755 app/Views/administrator/paypal_transactions.php
mode change 100644 => 100755 app/Views/administrator/payroll_management.php
mode change 100644 => 100755 app/Views/administrator/performance_reviews.php
mode change 100644 => 100755 app/Views/administrator/print_notification_admins.php
mode change 100644 => 100755 app/Views/administrator/removed_students.php
mode change 100644 => 100755 app/Views/administrator/scholarship_information.php
mode change 100644 => 100755 app/Views/administrator/search_results.php
mode change 100644 => 100755 app/Views/administrator/sections_auto_distribute.php
mode change 100644 => 100755 app/Views/administrator/settings.php
mode change 100644 => 100755 app/Views/administrator/student_class_assignment.php
mode change 100644 => 100755 app/Views/administrator/student_profiles.php
mode change 100644 => 100755 app/Views/administrator/subject_curriculum.php
mode change 100644 => 100755 app/Views/administrator/teacher_class_assignment.php
delete mode 100644 app/Views/administrator/teacher_profiles.php
mode change 100644 => 100755 app/Views/administrator/teacher_submissions.php
mode change 100644 => 100755 app/Views/administrator/teachers.php
mode change 100644 => 100755 app/Views/administrator/teachers.php.bak
mode change 100644 => 100755 app/Views/appointment.php
mode change 100644 => 100755 app/Views/attendance/admins_attendance_form.php
mode change 100644 => 100755 app/Views/attendance/attendance_tracking.php
mode change 100644 => 100755 app/Views/attendance/compose_email.php
mode change 100644 => 100755 app/Views/attendance/early_dismissals.php
mode change 100644 => 100755 app/Views/attendance/early_dismissals_add.php
mode change 100644 => 100755 app/Views/attendance/edit_modal.php
mode change 100644 => 100755 app/Views/attendance/parent_reports.php
mode change 100644 => 100755 app/Views/attendance/teacher_attendance_form.php
mode change 100644 => 100755 app/Views/attendance/teacher_attendance_month.php
mode change 100644 => 100755 app/Views/attendance/view.php
mode change 100644 => 100755 app/Views/attendance/violations_notified.php
mode change 100644 => 100755 app/Views/attendance/violations_pending.php
mode change 100644 => 100755 app/Views/attendance_templates/index.php
mode change 100644 => 100755 app/Views/auth/select_role.php
mode change 100644 => 100755 app/Views/calendar.php
mode change 100644 => 100755 app/Views/call_to_action.php
mode change 100644 => 100755 app/Views/class_prep/list.php
mode change 100644 => 100755 app/Views/class_prep/print.php
mode change 100644 => 100755 app/Views/classes.php
mode change 100644 => 100755 app/Views/communications/index.php
mode change 100644 => 100755 app/Views/configuration/configuration_view.php
mode change 100644 => 100755 app/Views/contact.php
mode change 100644 => 100755 app/Views/contact_process.php
mode change 100644 => 100755 app/Views/discounts/apply_voucher.php
mode change 100644 => 100755 app/Views/discounts/create.php
mode change 100644 => 100755 app/Views/discounts/list.php
delete mode 100644 app/Views/discounts/reverse_discount.php
mode change 100644 => 100755 app/Views/docs/index.php
mode change 100644 => 100755 app/Views/docs/swagger_ui.php
mode change 100644 => 100755 app/Views/docs/swagger_ui_public.php
mode change 100644 => 100755 app/Views/emails/_wrap_layout.php
mode change 100644 => 100755 app/Views/emails/admin_student_registered.php
mode change 100644 => 100755 app/Views/emails/below_sixty_performance.php
mode change 100644 => 100755 app/Views/emails/broadcast_wrapper.php
mode change 100644 => 100755 app/Views/emails/calendar_notification.php
create mode 100755 app/Views/emails/custom_html.php
mode change 100644 => 100755 app/Views/emails/dismissal.php
create mode 100755 app/Views/emails/event_broadcast.php
mode change 100644 => 100755 app/Views/emails/extra_charge_notice.php
mode change 100644 => 100755 app/Views/emails/final_warning.php
mode change 100644 => 100755 app/Views/emails/follow_up.php
mode change 100644 => 100755 app/Views/emails/parent_attendance_admin.php
mode change 100644 => 100755 app/Views/emails/parent_attendance_parent.php
mode change 100644 => 100755 app/Views/emails/parent_email_extractor.php
mode change 100644 => 100755 app/Views/emails/payment_receipt.php
mode change 100644 => 100755 app/Views/emails/reset_password.php
mode change 100644 => 100755 app/Views/emails/status_admission_review.php
mode change 100644 => 100755 app/Views/emails/status_denied.php
mode change 100644 => 100755 app/Views/emails/status_enrolled.php
mode change 100644 => 100755 app/Views/emails/status_not_enrolled.php
mode change 100644 => 100755 app/Views/emails/status_payment_pending.php
mode change 100644 => 100755 app/Views/emails/status_refund_pending.php
mode change 100644 => 100755 app/Views/emails/status_waitlist.php
mode change 100644 => 100755 app/Views/emails/status_withdraw_review.php
mode change 100644 => 100755 app/Views/emails/status_withdrawn.php
mode change 100644 => 100755 app/Views/emails/student_removed.php
mode change 100644 => 100755 app/Views/emails/support_new_account.php
mode change 100644 => 100755 app/Views/emails/welcome_parent.php
mode change 100644 => 100755 app/Views/emails/welcome_staff.php
mode change 100644 => 100755 app/Views/emails/welcome_user.php
mode change 100644 => 100755 app/Views/emails/whatsapp_group_invitation.php
mode change 100644 => 100755 app/Views/emails/whatsapp_invite.php
mode change 100644 => 100755 app/Views/enroll_withdraw/enrollment_withdrawal.php
mode change 100644 => 100755 app/Views/enroll_withdraw/new-students.php
mode change 100644 => 100755 app/Views/errors/access_denied.php
mode change 100644 => 100755 app/Views/errors/cli/error_404.php
mode change 100644 => 100755 app/Views/errors/cli/error_exception.php
mode change 100644 => 100755 app/Views/errors/cli/production.php
mode change 100644 => 100755 app/Views/errors/custom/blocked.php
mode change 100644 => 100755 app/Views/errors/html/debug.css
mode change 100644 => 100755 app/Views/errors/html/debug.js
mode change 100644 => 100755 app/Views/errors/html/error_404.php
mode change 100644 => 100755 app/Views/errors/html/error_500.php
mode change 100644 => 100755 app/Views/errors/html/error_exception.php
mode change 100644 => 100755 app/Views/errors/html/error_general.php
mode change 100644 => 100755 app/Views/errors/html/production.php
mode change 100644 => 100755 app/Views/errors/invalid_token.php
mode change 100644 => 100755 app/Views/expenses/create.php
mode change 100644 => 100755 app/Views/expenses/edit.php
mode change 100644 => 100755 app/Views/expenses/index.php
mode change 100644 => 100755 app/Views/facility.php
mode change 100644 => 100755 app/Views/family/card.php
create mode 100755 app/Views/family/compose_email.php
mode change 100644 => 100755 app/Views/family/index.php
mode change 100644 => 100755 app/Views/flags/flags_management.php
mode change 100644 => 100755 app/Views/flags/incident_analysis.php
mode change 100644 => 100755 app/Views/flags/processed_flags.php
mode change 100644 => 100755 app/Views/footer.php
mode change 100644 => 100755 app/Views/grading/below_sixty.php
create mode 100755 app/Views/grading/below_sixty_email_editor.php
mode change 100644 => 100755 app/Views/grading/comments.php
mode change 100644 => 100755 app/Views/grading/final.php
mode change 100644 => 100755 app/Views/grading/grading_main.php
mode change 100644 => 100755 app/Views/grading/homework.php
mode change 100644 => 100755 app/Views/grading/homework_tracking.php
mode change 100644 => 100755 app/Views/grading/midterm.php
mode change 100644 => 100755 app/Views/grading/participation.php
mode change 100644 => 100755 app/Views/grading/placement.php
mode change 100644 => 100755 app/Views/grading/placement_batch.php
mode change 100644 => 100755 app/Views/grading/placement_index.php
mode change 100644 => 100755 app/Views/grading/project.php
mode change 100644 => 100755 app/Views/grading/quiz.php
mode change 100644 => 100755 app/Views/grading/schedule_meeting.php
mode change 100644 => 100755 app/Views/grading/test.php
mode change 100644 => 100755 app/Views/header.php
mode change 100644 => 100755 app/Views/home.php
mode change 100644 => 100755 app/Views/index.php
mode change 100644 => 100755 app/Views/inventory/adjust_form.php
mode change 100644 => 100755 app/Views/inventory/book/form.php
mode change 100644 => 100755 app/Views/inventory/book/index.php
mode change 100644 => 100755 app/Views/inventory/classroom/audit_form.php
mode change 100644 => 100755 app/Views/inventory/classroom/form.php
mode change 100644 => 100755 app/Views/inventory/classroom/index.php
mode change 100644 => 100755 app/Views/inventory/kitchen/form.php
mode change 100644 => 100755 app/Views/inventory/kitchen/index.php
mode change 100644 => 100755 app/Views/inventory/movements/edit.php
mode change 100644 => 100755 app/Views/inventory/movements/form.php
mode change 100644 => 100755 app/Views/inventory/movements/index.php
mode change 100644 => 100755 app/Views/inventory/office/form.php
mode change 100644 => 100755 app/Views/inventory/office/index.php
mode change 100644 => 100755 app/Views/inventory/summary.php
mode change 100644 => 100755 app/Views/inventory/teacher_distribute.php
mode change 100644 => 100755 app/Views/invoice_payment/invoice_management.php
mode change 100644 => 100755 app/Views/invoice_payment/pdf_template.php
mode change 100644 => 100755 app/Views/landing_page/admin_dashboard.php
mode change 100644 => 100755 app/Views/landing_page/guest_dashboard.php
mode change 100644 => 100755 app/Views/landing_page/parent_dashboard.php
mode change 100644 => 100755 app/Views/landing_page/student_dashboard.php
mode change 100644 => 100755 app/Views/landing_page/teacher_dashboard.php
mode change 100644 => 100755 app/Views/layout/email_layout.php
mode change 100644 => 100755 app/Views/layout/main.php
mode change 100644 => 100755 app/Views/layout/main_layout.php
mode change 100644 => 100755 app/Views/layout/management_layout.php
mode change 100644 => 100755 app/Views/layout/register_layout.php
mode change 100644 => 100755 app/Views/nav_builder/edit.php
mode change 100644 => 100755 app/Views/nav_builder/index.php
mode change 100644 => 100755 app/Views/navbar.php
mode change 100644 => 100755 app/Views/notifications/list_active.php
mode change 100644 => 100755 app/Views/notifications/list_deleted.php
mode change 100644 => 100755 app/Views/pagination/custom_pagination.php
mode change 100644 => 100755 app/Views/parent/add_second_parent.php
mode change 100644 => 100755 app/Views/parent/assignments.php
mode change 100644 => 100755 app/Views/parent/attendance.php
mode change 100644 => 100755 app/Views/parent/calendar.php
mode change 100644 => 100755 app/Views/parent/class_progress_list.php
mode change 100644 => 100755 app/Views/parent/class_progress_view.php
mode change 100644 => 100755 app/Views/parent/classes.php
mode change 100644 => 100755 app/Views/parent/contact.php
mode change 100644 => 100755 app/Views/parent/edit_emergency_contact.php
mode change 100644 => 100755 app/Views/parent/edit_student_modal.php
mode change 100644 => 100755 app/Views/parent/enroll_classes.php
mode change 100644 => 100755 app/Views/parent/enroll_failure.php
mode change 100644 => 100755 app/Views/parent/enroll_success.php
mode change 100644 => 100755 app/Views/parent/event_participation.php
mode change 100644 => 100755 app/Views/parent/invoice_payment.php
mode change 100644 => 100755 app/Views/parent/no_kids_registred.php
mode change 100644 => 100755 app/Views/parent/parent_message.php
mode change 100644 => 100755 app/Views/parent/payment_success.php
mode change 100644 => 100755 app/Views/parent/payment_view.php
mode change 100644 => 100755 app/Views/parent/register_student.php
mode change 100644 => 100755 app/Views/parent/report_attendance.php
create mode 100755 app/Views/parent/report_cards.php
mode change 100644 => 100755 app/Views/parent/scores.php
mode change 100644 => 100755 app/Views/parent/success_message.php
mode change 100644 => 100755 app/Views/parent/withdraw_success.php
mode change 100644 => 100755 app/Views/partials/academic_filter.php
mode change 100644 => 100755 app/Views/partials/assign_class_student_modal.php
mode change 100644 => 100755 app/Views/partials/assign_class_teacher_modal.php
mode change 100644 => 100755 app/Views/partials/attendance_notification_modal.php
mode change 100644 => 100755 app/Views/partials/avatar_menu.php
mode change 100644 => 100755 app/Views/partials/emergencycontact_form.php
mode change 100644 => 100755 app/Views/partials/flash_messages.php
mode change 100644 => 100755 app/Views/partials/footer.php
mode change 100644 => 100755 app/Views/partials/footer_back.php
mode change 100644 => 100755 app/Views/partials/head.php
mode change 100644 => 100755 app/Views/partials/head_back.php
mode change 100644 => 100755 app/Views/partials/header.php
mode change 100644 => 100755 app/Views/partials/header_back.php
mode change 100644 => 100755 app/Views/partials/navbar.php
mode change 100644 => 100755 app/Views/partials/navbar_back.php
mode change 100644 => 100755 app/Views/partials/navbar_dynamic.php
mode change 100644 => 100755 app/Views/partials/no_invoice_found.php
mode change 100644 => 100755 app/Views/partials/sidebar.php
mode change 100644 => 100755 app/Views/partials/standard_table.php
mode change 100644 => 100755 app/Views/partials/student_form.php
mode change 100644 => 100755 app/Views/payment/_list_modal.php
mode change 100644 => 100755 app/Views/payment/discount.php
mode change 100644 => 100755 app/Views/payment/extra_charges.php
mode change 100644 => 100755 app/Views/payment/financial_report.php
mode change 100644 => 100755 app/Views/payment/financial_report_summary.php
mode change 100644 => 100755 app/Views/payment/manual_pay.php
mode change 100644 => 100755 app/Views/payment/manual_payment.php
mode change 100644 => 100755 app/Views/payment/notification_management.php
mode change 100644 => 100755 app/Views/payment/payment_redirect.php
mode change 100644 => 100755 app/Views/payment/unpaid_parents.php
mode change 100644 => 100755 app/Views/payment_list.php
mode change 100644 => 100755 app/Views/policy/picture_policy.php
mode change 100644 => 100755 app/Views/policy/picture_policy_partial.php
mode change 100644 => 100755 app/Views/policy/school_policy.php
mode change 100644 => 100755 app/Views/policy/school_policy_partial.php
mode change 100644 => 100755 app/Views/preferences.php
mode change 100644 => 100755 app/Views/print_requests/admin_index.php
mode change 100644 => 100755 app/Views/print_requests/teacher_index.php
mode change 100644 => 100755 app/Views/printables_reports/badge_form.php
delete mode 100644 app/Views/printables_reports/certificates.php
mode change 100644 => 100755 app/Views/printables_reports/report_card.php
mode change 100644 => 100755 app/Views/printables_reports/sticker_form.php
mode change 100644 => 100755 app/Views/profile.php
mode change 100644 => 100755 app/Views/refunds/list.php
mode change 100644 => 100755 app/Views/reimbursements/create.php
mode change 100644 => 100755 app/Views/reimbursements/edit.php
mode change 100644 => 100755 app/Views/reimbursements/index.php
mode change 100644 => 100755 app/Views/reimbursements/under_processing.php
mode change 100644 => 100755 app/Views/rfid/rfid_coming_soon.php
mode change 100644 => 100755 app/Views/rfid/rfid_log_view.php
mode change 100644 => 100755 app/Views/rolepermission/add_permission.php
mode change 100644 => 100755 app/Views/rolepermission/add_role.php
mode change 100644 => 100755 app/Views/rolepermission/assign_role.php
mode change 100644 => 100755 app/Views/rolepermission/edit_permission.php
mode change 100644 => 100755 app/Views/rolepermission/edit_role.php
mode change 100644 => 100755 app/Views/rolepermission/edit_role_permissions.php
mode change 100644 => 100755 app/Views/rolepermission/list_roles.php
mode change 100644 => 100755 app/Views/rolepermission/permissionList.php
mode change 100644 => 100755 app/Views/score_analysis/score_prediction.php
mode change 100644 => 100755 app/Views/slips/preview_list.php
mode change 100644 => 100755 app/Views/slips/slip_pdf.php
mode change 100644 => 100755 app/Views/spinner.php
mode change 100644 => 100755 app/Views/staff/create.php
mode change 100644 => 100755 app/Views/staff/edit.php
mode change 100644 => 100755 app/Views/staff/index.php
mode change 100644 => 100755 app/Views/student/score_card.php
mode change 100644 => 100755 app/Views/student/score_card_list.php
mode change 100644 => 100755 app/Views/success.php
mode change 100644 => 100755 app/Views/support.php
mode change 100644 => 100755 app/Views/support_requests.php
mode change 100644 => 100755 app/Views/swagger_ui.php
mode change 100644 => 100755 app/Views/teacher/absence_vacation.php
mode change 100644 => 100755 app/Views/teacher/add_final_exam.php
mode change 100644 => 100755 app/Views/teacher/add_homework.php
mode change 100644 => 100755 app/Views/teacher/add_midterm_exam.php
mode change 100644 => 100755 app/Views/teacher/add_participation.php
mode change 100644 => 100755 app/Views/teacher/add_project.php
mode change 100644 => 100755 app/Views/teacher/add_quiz.php
mode change 100644 => 100755 app/Views/teacher/add_quiz_column_form.php
mode change 100644 => 100755 app/Views/teacher/calendar.php
mode change 100644 => 100755 app/Views/teacher/class_progress_history.php
mode change 100644 => 100755 app/Views/teacher/class_progress_submit.php
mode change 100644 => 100755 app/Views/teacher/class_progress_view.php
mode change 100644 => 100755 app/Views/teacher/class_view.php
mode change 100644 => 100755 app/Views/teacher/competition_scores/index.php
mode change 100644 => 100755 app/Views/teacher/competition_scores/scores.php
mode change 100644 => 100755 app/Views/teacher/drafts.php
mode change 100644 => 100755 app/Views/teacher/exam_drafts.php
mode change 100644 => 100755 app/Views/teacher/homework_list.php
mode change 100644 => 100755 app/Views/teacher/inbox.php
mode change 100644 => 100755 app/Views/teacher/no_classes.php
mode change 100644 => 100755 app/Views/teacher/scores.php
mode change 100644 => 100755 app/Views/teacher/select_semester.php
mode change 100644 => 100755 app/Views/teacher/sent.php
mode change 100644 => 100755 app/Views/teacher/showupdate_attendance.php
mode change 100644 => 100755 app/Views/teacher/teacher_assignment.php
mode change 100644 => 100755 app/Views/teacher/teacher_contactus.php
mode change 100644 => 100755 app/Views/teacher/teacher_message.php
mode change 100644 => 100755 app/Views/teacher/teacher_navbar.php
mode change 100644 => 100755 app/Views/teacher/teacher_support.php
mode change 100644 => 100755 app/Views/teacher/trash.php
mode change 100644 => 100755 app/Views/team.php
mode change 100644 => 100755 app/Views/testimonial.php
mode change 100644 => 100755 app/Views/user/create.php
mode change 100644 => 100755 app/Views/user/edit.php
mode change 100644 => 100755 app/Views/user/forgot_password.php
mode change 100644 => 100755 app/Views/user/login.php
mode change 100644 => 100755 app/Views/user/login_activity.php
mode change 100644 => 100755 app/Views/user/password_reset_confirmation.php
mode change 100644 => 100755 app/Views/user/password_set_success.php
mode change 100644 => 100755 app/Views/user/register.php
mode change 100644 => 100755 app/Views/user/reset_password.php
create mode 100755 app/Views/user/set_authorized_user_password.php
mode change 100644 => 100755 app/Views/user/set_password.php
mode change 100644 => 100755 app/Views/user/user_list.php
mode change 100644 => 100755 app/Views/user/welcome_back.php
mode change 100644 => 100755 app/Views/welcome_message.php
mode change 100644 => 100755 app/Views/whatsapp/manage_links.php
mode change 100644 => 100755 app/Views/whatsapp/parent_contacts.php
mode change 100644 => 100755 app/Views/whatsapp/parent_contacts_by_class.php
mode change 100644 => 100755 app/Views/winners/competitions/index.php
mode change 100644 => 100755 app/Views/winners/competitions/show.php
mode change 100644 => 100755 app/db_connection.php
mode change 100644 => 100755 app/index.html
mode change 100644 => 100755 app/old.htaccess.old
create mode 100644 docker-compose.yml
rename env (copy 1) => env_copy (100%)
create mode 100644 writable/logs/log-2026-05-16.log
create mode 100644 writable/session/ci_session173e6f4352f712df14f8baf579c5a50b
create mode 100644 writable/session/ci_session504a14eddce8394e82d35d85f67cf7f7
create mode 100644 writable/session/ci_session703454fd156d6cd0866f08203284eed0
diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000000000000000000000000000000000000..1267ea55031ce95387ac14dfb95c4f34ca8b2cf6
GIT binary patch
literal 10244
zcmeHM&2k$>5bl-#q}WdEV19B!1s4vEkP|3EQAHWYrpiIf0*hqHwy?arXeIyP
zGUYp*@&G(UZoC9X9)nka@9SAPGwU_vKvAw@R;8KQ+3ue1uYX3f5|LnK*j^H;ibxq>
z_H#363NAk9_rg`mX6onz@QbXl<
zw;lnHfJfjQ0oI2qzU-H?{>=1RI?(750R1?o^}-nE0OhG>{c_fynO<2T)0`fHj5WxP
z7=dBO^D4>FFK7Lk=?%LChFyYeVty0z8}dkMvM=rRUcA-n#VznK%~tTO{izp5B4g5&M3NlU^J1#jVVY#Jer+Ya
zZaS#fe-BDy;}erp<*D*?`Rm4ReApN?2g7uw+5d`nn{nK0jmFK^7v0WbIJa;=jt0$c
z)affjx7`8B(=WSGJ3d^AhfzCK-iCQlu9Pd`+^v(7_wUvht4nv@J6)`vEYzL{F9m`K~{5Q*4&@BKVy^`ebDGfy*S#z45_CK6oBmq>E2n473~dXm)np(?GRW~UC~Ur2gkBSrP4F~TTL$Bh+Pj>Joz3~C
zv|(EU>|!|veFtj~5Xz})0_^qfKor9f$snbNX#q)dxxg
z%%o^*ShI(jrtAX4=H*^<nKH!
zF|!FB5@?gdfap3>=4BgRjo_#7+`2AoN`YSoGE&)fQ7Hj=M?zJ0l#?0480
z_a7F7(!@*fte3-Eai%1{n9miIIYc5t5<%oOK~o7VH3oEfD*
z#=0^1+E~4?Mk?m^`g0~a5oHlpW(FO>4r)(JkW;SJinz@`4gS93JzDp&Xt%j0!8KH0
z04;X~#nq<0g%}-b3`anP`C#&!{%xUhg;|8Neir(bkzD%5!n}e!UBi!pRgJQup9DbH
z;kcILDDW(ib$G6WhZUU_YJezeqNiUHwA(qG5qS>pYoL9>%XlI$E5i}S+zU+<7s#TA
z99cJ^#|Hk)V10Vo>V(>olfj*L0PGWJG1?oMTioUL)T)B2UHZ*JeFBt>>HdY?muTCR
z6;QG+h3a|Mvz%1u_%6usBHLJIy
z@dT*M4wU~jLnQ5WDxi#^TP$~qWiwjGKDKb1Qfo$eos%!X&Y_;v>c@Qqapy)EQFCa^
zs5yPj`m8SI=%3~2vDN#_Bj6G62wWuu#*8vmzW>Mn^_Ttsf0b1Bx_AUU0{=1s!T4%p
z6~CVJ%~g?X{A$zJ@!w_-`G)HZU-TTZAqjhZ9giEnj(;bc<2y#Z@O+sA^k=5W2-ko9
WXMjKd19bZg?$7_DbHe}S{QnofLe78y
literal 0
HcmV?d00001
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 = '{label} '
+# 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 @@