From 20ee70d153d4edd8dc9ea3044bba8bf4d5008241 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 10 Mar 2026 00:48:32 -0400 Subject: [PATCH] add more controller --- .../EmergencyContactController.php | 47 + .../TeacherClassAssignmentController.php | 58 + .../Api/Exams/ExamDraftController.php | 115 + .../ParentAttendanceReportController.php | 108 + .../Api/Parents/ParentController.php | 284 +++ .../Api/Reports/SlipPrinterController.php | 73 + .../Settings/ConfigurationAdminController.php | 61 + .../Api/Staff/TeacherController.php | 143 ++ .../Api/Students/StudentController.php | 156 ++ .../Subjects/SubjectCurriculumController.php | 78 + .../Api/System/SchoolIdController.php | 64 + .../EmergencyContactUpdateRequest.php | 18 + .../Exams/ExamDraftAdminLegacyRequest.php | 19 + .../Exams/ExamDraftAdminReviewRequest.php | 18 + .../Exams/ExamDraftTeacherStoreRequest.php | 18 + .../ParentAttendanceReportCheckRequest.php | 20 + .../ParentAttendanceReportListRequest.php | 18 + .../ParentAttendanceReportSubmitRequest.php | 23 + .../ParentAttendanceReportUpdateRequest.php | 17 + .../Parents/ParentAttendanceRequest.php | 15 + .../Parents/ParentEmergencyContactRequest.php | 18 + .../Parents/ParentEnrollmentActionRequest.php | 18 + .../Parents/ParentEnrollmentRequest.php | 15 + .../ParentEventParticipationRequest.php | 15 + .../Requests/Parents/ParentInvoiceRequest.php | 15 + .../Parents/ParentProfileUpdateRequest.php | 21 + .../Parents/ParentRegistrationRequest.php | 29 + .../Parents/ParentStudentUpdateRequest.php | 22 + .../Requests/Reports/SlipLogListRequest.php | 16 + .../Requests/Reports/SlipPreviewRequest.php | 21 + .../Requests/Reports/SlipPrintRequest.php | 28 + .../Requests/Reports/SlipReprintRequest.php | 15 + .../SchoolIds/SchoolIdAssignRequest.php | 15 + .../Settings/ConfigurationStoreRequest.php | 16 + .../Settings/ConfigurationUpdateRequest.php | 16 + .../Students/StudentAssignClassRequest.php | 27 + .../Students/StudentAssignmentListRequest.php | 15 + .../Students/StudentAutoDistributeRequest.php | 18 + .../StudentPromotionTotalsRequest.php | 15 + .../Students/StudentRemoveClassRequest.php | 16 + .../Students/StudentSetActiveRequest.php | 16 + .../Students/StudentUpdateRequest.php | 33 + .../SubjectCurriculumStoreRequest.php | 19 + .../SubjectCurriculumUpdateRequest.php | 19 + .../Teachers/TeacherAbsenceSubmitRequest.php | 18 + .../TeacherAssignmentDeleteRequest.php | 18 + .../Teachers/TeacherAssignmentListRequest.php | 15 + .../TeacherAssignmentStoreRequest.php | 18 + .../Teachers/TeacherClassViewRequest.php | 15 + .../Teachers/TeacherSwitchClassRequest.php | 15 + .../EmergencyContactGroupResource.php | 19 + .../EmergencyContactResource.php | 22 + .../EmergencyContactStudentResource.php | 18 + .../Resources/Exams/ExamDraftResource.php | 39 + .../ParentAttendanceReportResource.php | 29 + .../Parents/ParentAttendanceResource.php | 19 + .../ParentEmergencyContactResource.php | 21 + .../Parents/ParentStudentResource.php | 28 + .../Resources/Reports/LateSlipLogResource.php | 24 + .../Resources/SchoolIds/SchoolIdResource.php | 16 + .../Settings/ConfigurationResource.php | 17 + .../Students/StudentAssignmentResource.php | 27 + .../Students/StudentClassSectionResource.php | 18 + .../Students/StudentRemovedResource.php | 22 + .../Students/StudentScoreCardRowResource.php | 28 + .../Subjects/SubjectClassResource.php | 17 + .../Subjects/SubjectCurriculumResource.php | 21 + .../Teachers/TeacherAbsenceResource.php | 21 + .../Teachers/TeacherAssignmentResource.php | 24 + .../Teachers/TeacherClassResource.php | 21 + .../Teachers/TeacherStudentResource.php | 27 + app/Models/ExamDraft.php | 3 +- .../EmergencyContactCrudService.php | 34 + .../EmergencyContactDirectoryService.php | 87 + app/Services/Exams/ExamDraftAdminService.php | 225 ++ app/Services/Exams/ExamDraftConfigService.php | 45 + app/Services/Exams/ExamDraftFileService.php | 107 + .../Exams/ExamDraftTeacherService.php | 133 ++ .../ParentAttendanceReportCalendarService.php | 127 ++ .../Parents/ParentAttendanceReportService.php | 509 +++++ .../Parents/ParentAttendanceService.php | 42 + app/Services/Parents/ParentConfigService.php | 22 + .../Parents/ParentEmergencyContactService.php | 57 + .../Parents/ParentEnrollmentService.php | 206 ++ .../ParentEventParticipationService.php | 85 + app/Services/Parents/ParentInvoiceService.php | 19 + app/Services/Parents/ParentProfileService.php | 30 + .../Parents/ParentRegistrationService.php | 228 ++ .../Reports/SlipPrinterConfigService.php | 52 + .../Reports/SlipPrinterFormatterService.php | 80 + .../Reports/SlipPrinterPdfService.php | 88 + app/Services/Reports/SlipPrinterService.php | 191 ++ .../SchoolIds/SchoolIdAssignmentService.php | 17 + .../SchoolIds/SchoolIdGenerationService.php | 22 + .../Settings/ConfigurationService.php | 49 + .../Students/StudentAssignmentService.php | 316 +++ .../StudentAutoDistributionService.php | 256 +++ .../Students/StudentConfigService.php | 16 + .../Students/StudentDirectoryService.php | 164 ++ .../Students/StudentProfileService.php | 135 ++ .../Students/StudentScoreCardService.php | 113 + .../Students/StudentStatusService.php | 76 + .../Subjects/SubjectCurriculumService.php | 79 + .../Teachers/TeacherAbsenceService.php | 290 +++ .../Teachers/TeacherAssignmentService.php | 186 ++ .../Teachers/TeacherConfigService.php | 16 + .../Teachers/TeacherDashboardService.php | 130 ++ app/old/ConfigurationController.php | 96 - app/old/EmergencyContactController.php | 135 -- app/old/ExamDraftController.php | 605 ------ app/old/ParentAttendanceReportController.php | 1833 ---------------- app/old/ParentController.php | 1866 ----------------- app/old/PurchaseOrderController.php | 227 -- app/old/SchoolIdService.php | 74 - app/old/SlipPrinterController.php | 691 ------ app/old/StudentController.php | 1724 --------------- app/old/SubjectCurriculumController.php | 143 -- app/old/TeacherController.php | 1012 --------- routes/api.php | 93 + school_api | Bin 622592 -> 622592 bytes .../EmergencyContactControllerTest.php | 124 ++ .../TeacherClassAssignmentControllerTest.php | 141 ++ .../Api/V1/Exams/ExamDraftControllerTest.php | 153 ++ .../ParentAttendanceReportControllerTest.php | 198 ++ .../Api/V1/Parents/ParentControllerTest.php | 125 ++ .../V1/Reports/SlipPrinterControllerTest.php | 108 + .../Settings/ConfigurationControllerTest.php | 66 + .../Api/V1/Students/StudentControllerTest.php | 128 ++ .../SubjectCurriculumControllerTest.php | 95 + .../Api/V1/System/SchoolIdControllerTest.php | 61 + .../Api/V1/Teachers/TeacherControllerTest.php | 166 ++ .../EmergencyContactDirectoryServiceTest.php | 68 + .../Exams/ExamDraftTeacherServiceTest.php | 93 + .../ParentAttendanceReportServiceTest.php | 85 + .../Parents/ParentAttendanceServiceTest.php | 74 + .../Parents/ParentConfigServiceTest.php | 35 + .../ParentEmergencyContactServiceTest.php | 60 + .../Parents/ParentEnrollmentServiceTest.php | 84 + .../Parents/ParentInvoiceServiceTest.php | 63 + .../Parents/ParentRegistrationServiceTest.php | 78 + .../SlipPrinterFormatterServiceTest.php | 21 + .../SchoolIdAssignmentServiceTest.php | 38 + .../SchoolIdGenerationServiceTest.php | 32 + .../Settings/ConfigurationServiceTest.php | 27 + .../Students/StudentAssignmentServiceTest.php | 95 + .../Students/StudentProfileServiceTest.php | 61 + .../Students/StudentScoreCardServiceTest.php | 71 + .../Subjects/SubjectCurriculumServiceTest.php | 73 + .../Teachers/TeacherAbsenceServiceTest.php | 108 + .../Teachers/TeacherAssignmentServiceTest.php | 117 ++ .../Teachers/TeacherDashboardServiceTest.php | 118 ++ 151 files changed, 9481 insertions(+), 8407 deletions(-) create mode 100644 app/Http/Controllers/Api/Administrator/EmergencyContactController.php create mode 100644 app/Http/Controllers/Api/Administrator/TeacherClassAssignmentController.php create mode 100644 app/Http/Controllers/Api/Exams/ExamDraftController.php create mode 100644 app/Http/Controllers/Api/Parents/ParentAttendanceReportController.php create mode 100644 app/Http/Controllers/Api/Parents/ParentController.php create mode 100644 app/Http/Controllers/Api/Reports/SlipPrinterController.php create mode 100644 app/Http/Controllers/Api/Settings/ConfigurationAdminController.php create mode 100644 app/Http/Controllers/Api/Staff/TeacherController.php create mode 100644 app/Http/Controllers/Api/Students/StudentController.php create mode 100644 app/Http/Controllers/Api/Subjects/SubjectCurriculumController.php create mode 100644 app/Http/Controllers/Api/System/SchoolIdController.php create mode 100644 app/Http/Requests/EmergencyContacts/EmergencyContactUpdateRequest.php create mode 100644 app/Http/Requests/Exams/ExamDraftAdminLegacyRequest.php create mode 100644 app/Http/Requests/Exams/ExamDraftAdminReviewRequest.php create mode 100644 app/Http/Requests/Exams/ExamDraftTeacherStoreRequest.php create mode 100644 app/Http/Requests/Parents/ParentAttendanceReportCheckRequest.php create mode 100644 app/Http/Requests/Parents/ParentAttendanceReportListRequest.php create mode 100644 app/Http/Requests/Parents/ParentAttendanceReportSubmitRequest.php create mode 100644 app/Http/Requests/Parents/ParentAttendanceReportUpdateRequest.php create mode 100644 app/Http/Requests/Parents/ParentAttendanceRequest.php create mode 100644 app/Http/Requests/Parents/ParentEmergencyContactRequest.php create mode 100644 app/Http/Requests/Parents/ParentEnrollmentActionRequest.php create mode 100644 app/Http/Requests/Parents/ParentEnrollmentRequest.php create mode 100644 app/Http/Requests/Parents/ParentEventParticipationRequest.php create mode 100644 app/Http/Requests/Parents/ParentInvoiceRequest.php create mode 100644 app/Http/Requests/Parents/ParentProfileUpdateRequest.php create mode 100644 app/Http/Requests/Parents/ParentRegistrationRequest.php create mode 100644 app/Http/Requests/Parents/ParentStudentUpdateRequest.php create mode 100644 app/Http/Requests/Reports/SlipLogListRequest.php create mode 100644 app/Http/Requests/Reports/SlipPreviewRequest.php create mode 100644 app/Http/Requests/Reports/SlipPrintRequest.php create mode 100644 app/Http/Requests/Reports/SlipReprintRequest.php create mode 100644 app/Http/Requests/SchoolIds/SchoolIdAssignRequest.php create mode 100644 app/Http/Requests/Settings/ConfigurationStoreRequest.php create mode 100644 app/Http/Requests/Settings/ConfigurationUpdateRequest.php create mode 100644 app/Http/Requests/Students/StudentAssignClassRequest.php create mode 100644 app/Http/Requests/Students/StudentAssignmentListRequest.php create mode 100644 app/Http/Requests/Students/StudentAutoDistributeRequest.php create mode 100644 app/Http/Requests/Students/StudentPromotionTotalsRequest.php create mode 100644 app/Http/Requests/Students/StudentRemoveClassRequest.php create mode 100644 app/Http/Requests/Students/StudentSetActiveRequest.php create mode 100644 app/Http/Requests/Students/StudentUpdateRequest.php create mode 100644 app/Http/Requests/Subjects/SubjectCurriculumStoreRequest.php create mode 100644 app/Http/Requests/Subjects/SubjectCurriculumUpdateRequest.php create mode 100644 app/Http/Requests/Teachers/TeacherAbsenceSubmitRequest.php create mode 100644 app/Http/Requests/Teachers/TeacherAssignmentDeleteRequest.php create mode 100644 app/Http/Requests/Teachers/TeacherAssignmentListRequest.php create mode 100644 app/Http/Requests/Teachers/TeacherAssignmentStoreRequest.php create mode 100644 app/Http/Requests/Teachers/TeacherClassViewRequest.php create mode 100644 app/Http/Requests/Teachers/TeacherSwitchClassRequest.php create mode 100644 app/Http/Resources/EmergencyContacts/EmergencyContactGroupResource.php create mode 100644 app/Http/Resources/EmergencyContacts/EmergencyContactResource.php create mode 100644 app/Http/Resources/EmergencyContacts/EmergencyContactStudentResource.php create mode 100644 app/Http/Resources/Exams/ExamDraftResource.php create mode 100644 app/Http/Resources/Parents/ParentAttendanceReportResource.php create mode 100644 app/Http/Resources/Parents/ParentAttendanceResource.php create mode 100644 app/Http/Resources/Parents/ParentEmergencyContactResource.php create mode 100644 app/Http/Resources/Parents/ParentStudentResource.php create mode 100644 app/Http/Resources/Reports/LateSlipLogResource.php create mode 100644 app/Http/Resources/SchoolIds/SchoolIdResource.php create mode 100644 app/Http/Resources/Settings/ConfigurationResource.php create mode 100644 app/Http/Resources/Students/StudentAssignmentResource.php create mode 100644 app/Http/Resources/Students/StudentClassSectionResource.php create mode 100644 app/Http/Resources/Students/StudentRemovedResource.php create mode 100644 app/Http/Resources/Students/StudentScoreCardRowResource.php create mode 100644 app/Http/Resources/Subjects/SubjectClassResource.php create mode 100644 app/Http/Resources/Subjects/SubjectCurriculumResource.php create mode 100644 app/Http/Resources/Teachers/TeacherAbsenceResource.php create mode 100644 app/Http/Resources/Teachers/TeacherAssignmentResource.php create mode 100644 app/Http/Resources/Teachers/TeacherClassResource.php create mode 100644 app/Http/Resources/Teachers/TeacherStudentResource.php create mode 100644 app/Services/EmergencyContacts/EmergencyContactCrudService.php create mode 100644 app/Services/EmergencyContacts/EmergencyContactDirectoryService.php create mode 100644 app/Services/Exams/ExamDraftAdminService.php create mode 100644 app/Services/Exams/ExamDraftConfigService.php create mode 100644 app/Services/Exams/ExamDraftFileService.php create mode 100644 app/Services/Exams/ExamDraftTeacherService.php create mode 100644 app/Services/Parents/ParentAttendanceReportCalendarService.php create mode 100644 app/Services/Parents/ParentAttendanceReportService.php create mode 100644 app/Services/Parents/ParentAttendanceService.php create mode 100644 app/Services/Parents/ParentConfigService.php create mode 100644 app/Services/Parents/ParentEmergencyContactService.php create mode 100644 app/Services/Parents/ParentEnrollmentService.php create mode 100644 app/Services/Parents/ParentEventParticipationService.php create mode 100644 app/Services/Parents/ParentInvoiceService.php create mode 100644 app/Services/Parents/ParentProfileService.php create mode 100644 app/Services/Parents/ParentRegistrationService.php create mode 100644 app/Services/Reports/SlipPrinterConfigService.php create mode 100644 app/Services/Reports/SlipPrinterFormatterService.php create mode 100644 app/Services/Reports/SlipPrinterPdfService.php create mode 100644 app/Services/Reports/SlipPrinterService.php create mode 100644 app/Services/SchoolIds/SchoolIdAssignmentService.php create mode 100644 app/Services/SchoolIds/SchoolIdGenerationService.php create mode 100644 app/Services/Settings/ConfigurationService.php create mode 100644 app/Services/Students/StudentAssignmentService.php create mode 100644 app/Services/Students/StudentAutoDistributionService.php create mode 100644 app/Services/Students/StudentConfigService.php create mode 100644 app/Services/Students/StudentDirectoryService.php create mode 100644 app/Services/Students/StudentProfileService.php create mode 100644 app/Services/Students/StudentScoreCardService.php create mode 100644 app/Services/Students/StudentStatusService.php create mode 100644 app/Services/Subjects/SubjectCurriculumService.php create mode 100644 app/Services/Teachers/TeacherAbsenceService.php create mode 100644 app/Services/Teachers/TeacherAssignmentService.php create mode 100644 app/Services/Teachers/TeacherConfigService.php create mode 100644 app/Services/Teachers/TeacherDashboardService.php delete mode 100644 app/old/ConfigurationController.php delete mode 100644 app/old/EmergencyContactController.php delete mode 100644 app/old/ExamDraftController.php delete mode 100644 app/old/ParentAttendanceReportController.php delete mode 100644 app/old/ParentController.php delete mode 100644 app/old/PurchaseOrderController.php delete mode 100644 app/old/SchoolIdService.php delete mode 100644 app/old/SlipPrinterController.php delete mode 100644 app/old/StudentController.php delete mode 100644 app/old/SubjectCurriculumController.php delete mode 100644 app/old/TeacherController.php create mode 100644 tests/Feature/Api/V1/Administrator/EmergencyContactControllerTest.php create mode 100644 tests/Feature/Api/V1/Administrator/TeacherClassAssignmentControllerTest.php create mode 100644 tests/Feature/Api/V1/Exams/ExamDraftControllerTest.php create mode 100644 tests/Feature/Api/V1/Parents/ParentAttendanceReportControllerTest.php create mode 100644 tests/Feature/Api/V1/Parents/ParentControllerTest.php create mode 100644 tests/Feature/Api/V1/Reports/SlipPrinterControllerTest.php create mode 100644 tests/Feature/Api/V1/Settings/ConfigurationControllerTest.php create mode 100644 tests/Feature/Api/V1/Students/StudentControllerTest.php create mode 100644 tests/Feature/Api/V1/Subjects/SubjectCurriculumControllerTest.php create mode 100644 tests/Feature/Api/V1/System/SchoolIdControllerTest.php create mode 100644 tests/Feature/Api/V1/Teachers/TeacherControllerTest.php create mode 100644 tests/Unit/Services/EmergencyContacts/EmergencyContactDirectoryServiceTest.php create mode 100644 tests/Unit/Services/Exams/ExamDraftTeacherServiceTest.php create mode 100644 tests/Unit/Services/Parents/ParentAttendanceReportServiceTest.php create mode 100644 tests/Unit/Services/Parents/ParentAttendanceServiceTest.php create mode 100644 tests/Unit/Services/Parents/ParentConfigServiceTest.php create mode 100644 tests/Unit/Services/Parents/ParentEmergencyContactServiceTest.php create mode 100644 tests/Unit/Services/Parents/ParentEnrollmentServiceTest.php create mode 100644 tests/Unit/Services/Parents/ParentInvoiceServiceTest.php create mode 100644 tests/Unit/Services/Parents/ParentRegistrationServiceTest.php create mode 100644 tests/Unit/Services/Reports/SlipPrinterFormatterServiceTest.php create mode 100644 tests/Unit/Services/SchoolIds/SchoolIdAssignmentServiceTest.php create mode 100644 tests/Unit/Services/SchoolIds/SchoolIdGenerationServiceTest.php create mode 100644 tests/Unit/Services/Settings/ConfigurationServiceTest.php create mode 100644 tests/Unit/Services/Students/StudentAssignmentServiceTest.php create mode 100644 tests/Unit/Services/Students/StudentProfileServiceTest.php create mode 100644 tests/Unit/Services/Students/StudentScoreCardServiceTest.php create mode 100644 tests/Unit/Services/Subjects/SubjectCurriculumServiceTest.php create mode 100644 tests/Unit/Services/Teachers/TeacherAbsenceServiceTest.php create mode 100644 tests/Unit/Services/Teachers/TeacherAssignmentServiceTest.php create mode 100644 tests/Unit/Services/Teachers/TeacherDashboardServiceTest.php diff --git a/app/Http/Controllers/Api/Administrator/EmergencyContactController.php b/app/Http/Controllers/Api/Administrator/EmergencyContactController.php new file mode 100644 index 00000000..94d63154 --- /dev/null +++ b/app/Http/Controllers/Api/Administrator/EmergencyContactController.php @@ -0,0 +1,47 @@ +directoryService->groups(); + + return response()->json([ + 'ok' => true, + 'groups' => EmergencyContactGroupResource::collection($groups), + ]); + } + + public function update(EmergencyContactUpdateRequest $request, int $contactId): JsonResponse + { + $contact = $this->crudService->update($contactId, $request->validated()); + + return response()->json([ + 'ok' => true, + 'contact' => new EmergencyContactResource($contact), + ]); + } + + public function destroy(int $contactId): JsonResponse + { + $this->crudService->delete($contactId); + + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/Api/Administrator/TeacherClassAssignmentController.php b/app/Http/Controllers/Api/Administrator/TeacherClassAssignmentController.php new file mode 100644 index 00000000..1f6ba3f4 --- /dev/null +++ b/app/Http/Controllers/Api/Administrator/TeacherClassAssignmentController.php @@ -0,0 +1,58 @@ +assignmentService->listAssignments($request->validated()['school_year'] ?? null); + + return response()->json([ + 'ok' => true, + 'school_year' => $data['school_year'], + 'teachers' => TeacherAssignmentResource::collection($data['teachers']), + 'classes' => TeacherClassResource::collection($data['classes']), + ]); + } + + public function store(TeacherAssignmentStoreRequest $request): JsonResponse + { + $payload = $request->validated(); + $payload['updated_by'] = (int) (auth()->id() ?? 0); + + $result = $this->assignmentService->assign($payload); + $status = $result['ok'] ? 200 : 422; + + return response()->json([ + 'ok' => (bool) $result['ok'], + 'message' => $result['message'] ?? '', + 'position' => $result['position'] ?? null, + ], $status); + } + + public function destroy(TeacherAssignmentDeleteRequest $request): JsonResponse + { + $result = $this->assignmentService->delete($request->validated()); + $status = $result['ok'] ? 200 : 422; + + return response()->json([ + 'ok' => (bool) $result['ok'], + 'message' => $result['message'] ?? '', + ], $status); + } +} diff --git a/app/Http/Controllers/Api/Exams/ExamDraftController.php b/app/Http/Controllers/Api/Exams/ExamDraftController.php new file mode 100644 index 00000000..b465226e --- /dev/null +++ b/app/Http/Controllers/Api/Exams/ExamDraftController.php @@ -0,0 +1,115 @@ +id() ?? 0); + if ($teacherId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $data = $this->teacherService->listForTeacher($teacherId); + + return response()->json([ + 'ok' => true, + 'assignments' => $data['assignments'], + 'drafts' => ExamDraftResource::collection($data['drafts']), + 'legacy_exams' => ExamDraftResource::collection($data['legacyExams']), + 'exam_types' => $data['examTypes'], + 'status_options' => $data['statusOptions'], + 'school_year' => $data['schoolYear'], + 'semester' => $data['semester'], + ]); + } + + public function teacherStore(ExamDraftTeacherStoreRequest $request): JsonResponse + { + $teacherId = (int) (auth()->id() ?? 0); + if ($teacherId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $result = $this->teacherService->store($teacherId, $request->validated(), $request->file('draft_file')); + if (!$result['ok']) { + return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to save.'], 422); + } + + return response()->json([ + 'ok' => true, + 'draft' => new ExamDraftResource($result['draft']), + ], 201); + } + + public function adminIndex(): JsonResponse + { + $data = $this->adminService->list(); + + return response()->json([ + 'ok' => true, + 'drafts' => ExamDraftResource::collection($data['drafts']), + 'class_sections' => $data['classSections'], + 'legacy_by_class' => $data['legacyByClass'], + 'exam_types' => $data['examTypes'], + 'status_options' => $data['statusOptions'], + 'school_year' => $data['schoolYear'], + 'semester' => $data['semester'], + 'allowed_extensions' => $data['allowedExtensions'], + 'max_upload_bytes' => $data['maxUploadBytes'], + ]); + } + + public function adminUploadLegacy(ExamDraftAdminLegacyRequest $request): JsonResponse + { + $adminId = (int) (auth()->id() ?? 0); + if ($adminId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $result = $this->adminService->uploadLegacy($adminId, $request->validated(), $request->file('old_exam_file')); + if (!$result['ok']) { + return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to save.'], 422); + } + + return response()->json([ + 'ok' => true, + 'draft' => new ExamDraftResource($result['draft']), + ], 201); + } + + public function adminReview(ExamDraftAdminReviewRequest $request): JsonResponse + { + $adminId = (int) (auth()->id() ?? 0); + if ($adminId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $result = $this->adminService->review($adminId, $request->validated(), $request->file('final_file')); + if (!$result['ok']) { + return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to save.'], 422); + } + + return response()->json([ + 'ok' => true, + 'draft' => new ExamDraftResource($result['draft']), + ]); + } +} diff --git a/app/Http/Controllers/Api/Parents/ParentAttendanceReportController.php b/app/Http/Controllers/Api/Parents/ParentAttendanceReportController.php new file mode 100644 index 00000000..ca5f4c1b --- /dev/null +++ b/app/Http/Controllers/Api/Parents/ParentAttendanceReportController.php @@ -0,0 +1,108 @@ +id() ?? 0); + if ($parentId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $data = $this->reportService->formData($parentId); + + return response()->json([ + 'ok' => true, + 'students' => $data['students'], + 'today' => $data['today'], + 'sundays' => $data['sundays'], + 'defaultDate' => $data['defaultDate'], + 'myReports' => ParentAttendanceReportResource::collection($data['myReports']), + 'cutoffThreshold' => $data['cutoffThreshold'], + ]); + } + + public function submit(ParentAttendanceReportSubmitRequest $request): JsonResponse + { + $parentId = (int) (auth()->id() ?? 0); + if ($parentId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + try { + $result = $this->reportService->submit($parentId, $request->validated()); + } catch (\RuntimeException $e) { + return response()->json(['ok' => false, 'message' => $e->getMessage()], 422); + } + + return response()->json([ + 'ok' => true, + 'inserted' => $result['inserted'], + 'blocked' => $result['blocked'], + 'students' => $result['students'], + 'dates' => $result['dates'], + ], 201); + } + + public function list(ParentAttendanceReportListRequest $request): JsonResponse + { + $payload = $request->validated(); + $start = $payload['start'] ?? now()->toDateString(); + $end = $payload['end'] ?? null; + $schoolYear = $payload['school_year'] ?? null; + $semester = $payload['semester'] ?? null; + + $rows = $this->reportService->listReports($start, $end, $schoolYear, $semester); + + return response()->json([ + 'ok' => true, + 'reports' => ParentAttendanceReportResource::collection($rows), + ]); + } + + public function checkExisting(ParentAttendanceReportCheckRequest $request): JsonResponse + { + try { + $rows = $this->reportService->checkExisting($request->validated()); + } catch (\RuntimeException $e) { + return response()->json(['ok' => false, 'message' => $e->getMessage()], 422); + } + + return response()->json([ + 'ok' => true, + 'students' => $rows, + ]); + } + + public function update(ParentAttendanceReportUpdateRequest $request, int $reportId): JsonResponse + { + $parentId = (int) (auth()->id() ?? 0); + if ($parentId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + try { + $this->reportService->updateReport($parentId, $reportId, $request->validated()); + } catch (\RuntimeException $e) { + return response()->json(['ok' => false, 'message' => $e->getMessage()], 422); + } + + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/Api/Parents/ParentController.php b/app/Http/Controllers/Api/Parents/ParentController.php new file mode 100644 index 00000000..8337d71a --- /dev/null +++ b/app/Http/Controllers/Api/Parents/ParentController.php @@ -0,0 +1,284 @@ +id() ?? 0); + if ($parentId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $data = $this->attendanceService->listAttendance($parentId, $request->validated()['school_year'] ?? null); + + return response()->json([ + 'ok' => true, + 'attendance' => ParentAttendanceResource::collection($data['attendance']), + 'schoolYears' => $data['schoolYears'], + 'selectedYear' => $data['selectedYear'], + ]); + } + + public function invoices(ParentInvoiceRequest $request): JsonResponse + { + $parentId = (int) (auth()->id() ?? 0); + if ($parentId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $schoolYear = $request->validated()['school_year'] ?? null; + $rows = $this->invoiceService->listInvoices($parentId, $schoolYear); + + return response()->json([ + 'ok' => true, + 'invoices' => InvoiceResource::collection($rows), + ]); + } + + public function enrollments(ParentEnrollmentRequest $request): JsonResponse + { + $parentId = (int) (auth()->id() ?? 0); + if ($parentId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $data = $this->enrollmentService->overview($parentId, $request->validated()['school_year'] ?? null); + + return response()->json([ + 'ok' => true, + 'students' => ParentStudentResource::collection($data['students']), + 'schoolYears' => $data['schoolYears'], + 'selectedYear' => $data['selectedYear'], + 'withdrawalDeadline' => $data['withdrawalDeadline'], + 'lastDayOfRegistration' => $data['lastDayOfRegistration'], + 'schoolStartDate' => $data['schoolStartDate'], + ]); + } + + public function updateEnrollments(ParentEnrollmentActionRequest $request): JsonResponse + { + $parentId = (int) (auth()->id() ?? 0); + if ($parentId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $payload = $request->validated(); + $result = $this->enrollmentService->updateEnrollment( + $parentId, + $payload['enroll'] ?? [], + $payload['withdraw'] ?? [] + ); + + return response()->json([ + 'ok' => true, + 'enrolled' => $result['enrolled'], + 'withdrawn' => $result['withdrawn'], + ]); + } + + public function registration(): JsonResponse + { + $parentId = (int) (auth()->id() ?? 0); + if ($parentId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $data = $this->registrationService->overview($parentId); + + return response()->json([ + 'ok' => true, + 'parent' => $data['parent'], + 'existingKids' => ParentStudentResource::collection($data['existingKids']), + 'emergencies' => ParentEmergencyContactResource::collection($data['emergencies']), + 'maxChilds' => $data['maxChilds'], + 'maxEmergency' => $data['maxEmergency'], + 'lastDayOfRegistration' => $data['lastDayOfRegistration'], + 'registrationAgeDeadline' => $data['registrationAgeDeadline'], + ]); + } + + public function storeRegistration(ParentRegistrationRequest $request): JsonResponse + { + $parentId = (int) (auth()->id() ?? 0); + if ($parentId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $payload = $request->validated(); + $result = $this->registrationService->register( + $parentId, + $payload['students'] ?? [], + $payload['emergency_contacts'] ?? [] + ); + + return response()->json([ + 'ok' => true, + 'created_student_ids' => $result['created_student_ids'], + ], 201); + } + + public function updateStudent(ParentStudentUpdateRequest $request, int $studentId): JsonResponse + { + $parentId = (int) (auth()->id() ?? 0); + if ($parentId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $this->registrationService->updateStudent($parentId, $studentId, $request->validated()); + + return response()->json(['ok' => true]); + } + + public function deleteStudent(int $studentId): JsonResponse + { + $parentId = (int) (auth()->id() ?? 0); + if ($parentId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $this->registrationService->deleteStudent($parentId, $studentId); + + return response()->json(['ok' => true]); + } + + public function emergencyContacts(): JsonResponse + { + $parentId = (int) (auth()->id() ?? 0); + if ($parentId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $rows = $this->emergencyContactService->list($parentId); + + return response()->json([ + 'ok' => true, + 'contacts' => ParentEmergencyContactResource::collection($rows), + ]); + } + + public function storeEmergencyContact(ParentEmergencyContactRequest $request): JsonResponse + { + $parentId = (int) (auth()->id() ?? 0); + if ($parentId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $contact = $this->emergencyContactService->store($parentId, $request->validated()); + + return response()->json([ + 'ok' => true, + 'contact' => new ParentEmergencyContactResource($contact), + ], 201); + } + + public function updateEmergencyContact(ParentEmergencyContactRequest $request, int $contactId): JsonResponse + { + $parentId = (int) (auth()->id() ?? 0); + if ($parentId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $contact = $this->emergencyContactService->update($parentId, $contactId, $request->validated()); + + return response()->json([ + 'ok' => true, + 'contact' => new ParentEmergencyContactResource($contact), + ]); + } + + public function profile(): JsonResponse + { + $parentId = (int) (auth()->id() ?? 0); + if ($parentId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $profile = $this->profileService->getProfile($parentId); + + return response()->json([ + 'ok' => true, + 'profile' => $profile, + ]); + } + + public function updateProfile(ParentProfileUpdateRequest $request): JsonResponse + { + $parentId = (int) (auth()->id() ?? 0); + if ($parentId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $profile = $this->profileService->updateProfile($parentId, $request->validated()); + + return response()->json([ + 'ok' => true, + 'profile' => $profile, + ]); + } + + public function events(): JsonResponse + { + $parentId = (int) (auth()->id() ?? 0); + if ($parentId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $data = $this->eventService->overview($parentId); + + return response()->json([ + 'ok' => true, + 'activeEvents' => $data['activeEvents'], + 'charges' => $data['charges'], + 'yourStudents' => $data['yourStudents'], + ]); + } + + public function updateParticipation(ParentEventParticipationRequest $request): JsonResponse + { + $parentId = (int) (auth()->id() ?? 0); + if ($parentId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $this->eventService->updateParticipation($parentId, $request->validated()['participation'] ?? []); + + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/Api/Reports/SlipPrinterController.php b/app/Http/Controllers/Api/Reports/SlipPrinterController.php new file mode 100644 index 00000000..f4de6ac6 --- /dev/null +++ b/app/Http/Controllers/Api/Reports/SlipPrinterController.php @@ -0,0 +1,73 @@ +id() ?? 0); + $result = $this->service->print($request->validated(), $userId); + + if (!$result['ok']) { + return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Print failed.'], 422); + } + + return response($result['pdf'], 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="' . $result['filename'] . '"', + ]); + } + + public function preview(SlipPreviewRequest $request): JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + $result = $this->service->preview($request->validated(), $userId); + + return response()->json([ + 'ok' => true, + 'text' => $result['text'], + 'width' => $result['width'], + ]); + } + + public function logs(SlipLogListRequest $request): JsonResponse + { + $payload = $request->validated(); + $rows = $this->service->logs($payload['school_year'] ?? null, $payload['semester'] ?? null); + + return response()->json([ + 'ok' => true, + 'logs' => LateSlipLogResource::collection($rows), + ]); + } + + public function reprint(SlipReprintRequest $request) + { + $userId = (int) (auth()->id() ?? 0); + $result = $this->service->reprint((int) $request->validated()['id'], $userId); + + if (!$result['ok']) { + return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Slip not found.'], 404); + } + + return response($result['pdf'], 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="' . $result['filename'] . '"', + ]); + } +} diff --git a/app/Http/Controllers/Api/Settings/ConfigurationAdminController.php b/app/Http/Controllers/Api/Settings/ConfigurationAdminController.php new file mode 100644 index 00000000..a0b3259d --- /dev/null +++ b/app/Http/Controllers/Api/Settings/ConfigurationAdminController.php @@ -0,0 +1,61 @@ +service->list(); + + return response()->json([ + 'ok' => true, + 'configs' => ConfigurationResource::collection($configs), + ]); + } + + public function store(ConfigurationStoreRequest $request): JsonResponse + { + $config = $this->service->store($request->validated()); + + return response()->json([ + 'ok' => true, + 'config' => new ConfigurationResource($config->toArray()), + ], 201); + } + + public function update(ConfigurationUpdateRequest $request, int $id): JsonResponse + { + $config = $this->service->update($id, $request->validated()); + if (!$config) { + return response()->json(['ok' => false, 'message' => 'Configuration not found.'], 404); + } + + return response()->json([ + 'ok' => true, + 'config' => new ConfigurationResource($config->toArray()), + ]); + } + + public function destroy(int $id): JsonResponse + { + $deleted = $this->service->delete($id); + if (!$deleted) { + return response()->json(['ok' => false, 'message' => 'Configuration not found.'], 404); + } + + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/Api/Staff/TeacherController.php b/app/Http/Controllers/Api/Staff/TeacherController.php new file mode 100644 index 00000000..df8b840c --- /dev/null +++ b/app/Http/Controllers/Api/Staff/TeacherController.php @@ -0,0 +1,143 @@ +id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + try { + $data = $this->dashboardService->classView( + $userId, + $request->validated()['class_section_id'] ?? null + ); + } catch (\RuntimeException $e) { + return response()->json(['ok' => false, 'message' => $e->getMessage()], 403); + } + + $teacher = $this->teacherPayload($data['teacher'] ?? null, $userId); + $studentsBySection = []; + + foreach (($data['studentsBySection'] ?? []) as $sectionId => $students) { + $studentsBySection[(string) $sectionId] = TeacherStudentResource::collection($students); + } + + return response()->json([ + 'ok' => true, + 'teacher' => $teacher, + 'classes' => TeacherClassResource::collection($data['classes'] ?? []), + 'students' => TeacherStudentResource::collection($data['students'] ?? []), + 'students_by_section' => $studentsBySection, + 'assigned_names' => $data['assignedNames'] ?? [], + 'active_class_section_id' => $data['active_class_section_id'] ?? null, + ]); + } + + public function switchClass(TeacherSwitchClassRequest $request): JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $requested = (int) $request->validated()['class_section_id']; + + try { + $data = $this->dashboardService->classView($userId, $requested); + } catch (\RuntimeException $e) { + return response()->json(['ok' => false, 'message' => $e->getMessage()], 403); + } + + if (($data['active_class_section_id'] ?? null) !== $requested) { + return response()->json([ + 'ok' => false, + 'message' => 'You are not assigned to that class.', + ], 422); + } + + return response()->json([ + 'ok' => true, + 'active_class_section_id' => $requested, + ]); + } + + public function absenceForm(): JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $data = $this->absenceService->formData($userId); + + return response()->json([ + 'ok' => true, + 'teacher_name' => $data['teacher_name'], + 'semester' => $data['semester'], + 'school_year' => $data['school_year'], + 'existing' => TeacherAbsenceResource::collection($data['existing'] ?? []), + 'available_dates' => $data['available_dates'] ?? [], + ]); + } + + public function submitAbsence(TeacherAbsenceSubmitRequest $request): JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + if ($userId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $result = $this->absenceService->submit($userId, $request->validated()); + + return response()->json([ + 'ok' => (bool) ($result['ok'] ?? false), + 'message' => $result['message'] ?? '', + 'saved' => $result['saved'] ?? 0, + 'dates' => $result['dates'] ?? [], + ], (int) ($result['status'] ?? 200)); + } + + private function teacherPayload(?array $teacher, int $userId): ?array + { + if (!$teacher) { + $model = User::query()->find($userId); + $teacher = $model?->toArray(); + } + + if (!$teacher) { + return null; + } + + return [ + 'id' => (int) ($teacher['id'] ?? 0), + 'firstname' => (string) ($teacher['firstname'] ?? ''), + 'lastname' => (string) ($teacher['lastname'] ?? ''), + 'email' => $teacher['email'] ?? null, + 'role' => User::getUserRoleName($userId), + ]; + } +} diff --git a/app/Http/Controllers/Api/Students/StudentController.php b/app/Http/Controllers/Api/Students/StudentController.php new file mode 100644 index 00000000..a1329e0c --- /dev/null +++ b/app/Http/Controllers/Api/Students/StudentController.php @@ -0,0 +1,156 @@ +directoryService->assignmentData($request->validated()['school_year'] ?? null); + + return response()->json([ + 'ok' => true, + 'students' => StudentAssignmentResource::collection($data['students']), + 'classes' => StudentClassSectionResource::collection($data['classes']), + 'schoolYears' => $data['schoolYears'], + 'selectedYear' => $data['selectedYear'], + 'currentYear' => $data['currentYear'], + 'isCurrentYear' => $data['isCurrentYear'], + ]); + } + + public function assignClass(StudentAssignClassRequest $request): JsonResponse + { + $payload = $request->validated(); + $result = $this->assignmentService->assignClasses( + (int) $payload['student_id'], + $payload['class_section_id'], + (bool) ($payload['is_event_only'] ?? false), + (int) (auth()->id() ?? 0) + ); + + $status = $result['ok'] ? 200 : 422; + + return response()->json($result + ['ok' => (bool) $result['ok']], $status); + } + + public function removeClass(StudentRemoveClassRequest $request): JsonResponse + { + $payload = $request->validated(); + $result = $this->assignmentService->removeClass( + (int) $payload['student_id'], + (int) $payload['class_section_id'], + (int) (auth()->id() ?? 0) + ); + + $status = $result['ok'] ? 200 : 422; + + return response()->json($result + ['ok' => (bool) $result['ok']], $status); + } + + public function removed(StudentAssignmentListRequest $request): JsonResponse + { + $data = $this->directoryService->removedStudents($request->validated()['school_year'] ?? null); + + return response()->json([ + 'ok' => true, + 'active_students' => StudentRemovedResource::collection($data['active_students']), + 'removed_students' => StudentRemovedResource::collection($data['removed_students']), + 'school_year' => $data['school_year'], + ]); + } + + public function setActive(StudentSetActiveRequest $request): JsonResponse + { + $payload = $request->validated(); + $result = $this->statusService->setActive( + (int) $payload['student_id'], + (bool) $payload['is_active'], + (int) (auth()->id() ?? 0) + ); + + $status = $result['ok'] ? 200 : 422; + + return response()->json($result + ['ok' => (bool) $result['ok']], $status); + } + + public function autoDistribute(StudentAutoDistributeRequest $request): JsonResponse + { + $payload = $request->validated(); + $result = $this->autoDistributionService->autoDistribute( + (int) ($payload['class_id'] ?? 0), + (int) $payload['students_per_section'], + $payload['school_year'] ?? null, + (int) ($payload['class_section_id'] ?? 0), + (int) (auth()->id() ?? 0) + ); + + $status = $result['ok'] ? 200 : 422; + + return response()->json($result + ['ok' => (bool) $result['ok']], $status); + } + + public function promotionTotals(StudentPromotionTotalsRequest $request): JsonResponse + { + $data = $this->autoDistributionService->promotionTotals($request->validated()['school_year'] ?? null); + + return response()->json([ + 'ok' => true, + 'year' => $data['year'], + 'rows' => $data['rows'], + ]); + } + + public function update(StudentUpdateRequest $request, int $studentId): JsonResponse + { + $result = $this->profileService->updateStudent($studentId, $request->validated()); + $status = $result['ok'] ? 200 : 422; + + return response()->json($result + ['ok' => (bool) $result['ok']], $status); + } + + public function scoreCard(int $studentId): JsonResponse + { + $result = $this->scoreCardService->scoreCard($studentId); + if (!$result['ok']) { + return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Not found.'], 404); + } + + return response()->json([ + 'ok' => true, + 'student' => $result['student'], + 'rows' => StudentScoreCardRowResource::collection($result['rows']), + ]); + } +} diff --git a/app/Http/Controllers/Api/Subjects/SubjectCurriculumController.php b/app/Http/Controllers/Api/Subjects/SubjectCurriculumController.php new file mode 100644 index 00000000..be205df1 --- /dev/null +++ b/app/Http/Controllers/Api/Subjects/SubjectCurriculumController.php @@ -0,0 +1,78 @@ +service->list(); + + return response()->json([ + 'ok' => true, + 'classes' => SubjectClassResource::collection($data['classes']), + 'entries' => SubjectCurriculumResource::collection($data['entries']), + 'subject_labels' => $data['subject_labels'], + ]); + } + + public function show(int $id): JsonResponse + { + $entry = SubjectCurriculum::query()->find($id); + if (!$entry) { + return response()->json(['ok' => false, 'message' => 'Curriculum entry not found.'], 404); + } + + return response()->json([ + 'ok' => true, + 'entry' => new SubjectCurriculumResource($entry->toArray()), + ]); + } + + public function store(SubjectCurriculumStoreRequest $request): JsonResponse + { + $entry = $this->service->store($request->validated()); + + return response()->json([ + 'ok' => true, + 'entry' => new SubjectCurriculumResource($entry->toArray()), + ], 201); + } + + public function update(SubjectCurriculumUpdateRequest $request, int $id): JsonResponse + { + $entry = $this->service->update($id, $request->validated()); + if (!$entry) { + return response()->json(['ok' => false, 'message' => 'Curriculum entry not found.'], 404); + } + + return response()->json([ + 'ok' => true, + 'entry' => new SubjectCurriculumResource($entry->toArray()), + ]); + } + + public function destroy(int $id): JsonResponse + { + $deleted = $this->service->delete($id); + if (!$deleted) { + return response()->json(['ok' => false, 'message' => 'Curriculum entry not found.'], 404); + } + + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/Api/System/SchoolIdController.php b/app/Http/Controllers/Api/System/SchoolIdController.php new file mode 100644 index 00000000..9c076ac0 --- /dev/null +++ b/app/Http/Controllers/Api/System/SchoolIdController.php @@ -0,0 +1,64 @@ +validated(); + $schoolId = $this->assignmentService->assignToUser((int) $payload['user_id']); + + if ($schoolId === null) { + return response()->json(['ok' => false, 'message' => 'User not found.'], 404); + } + + return response()->json([ + 'ok' => true, + 'result' => new SchoolIdResource([ + 'type' => 'user', + 'school_id' => $schoolId, + ]), + ]); + } + + public function generateStudent(): JsonResponse + { + $schoolId = $this->generationService->generateStudentId(); + + return response()->json([ + 'ok' => true, + 'result' => new SchoolIdResource([ + 'type' => 'student', + 'school_id' => $schoolId, + ]), + ]); + } + + public function generateUser(): JsonResponse + { + $schoolId = $this->generationService->generateUserId(); + + return response()->json([ + 'ok' => true, + 'result' => new SchoolIdResource([ + 'type' => 'user', + 'school_id' => $schoolId, + ]), + ]); + } +} diff --git a/app/Http/Requests/EmergencyContacts/EmergencyContactUpdateRequest.php b/app/Http/Requests/EmergencyContacts/EmergencyContactUpdateRequest.php new file mode 100644 index 00000000..857b2084 --- /dev/null +++ b/app/Http/Requests/EmergencyContacts/EmergencyContactUpdateRequest.php @@ -0,0 +1,18 @@ + ['required', 'string', 'max:150'], + 'cellphone' => ['required', 'string', 'max:30'], + 'email' => ['nullable', 'email', 'max:150'], + 'relation' => ['nullable', 'string', 'max:80'], + ]; + } +} diff --git a/app/Http/Requests/Exams/ExamDraftAdminLegacyRequest.php b/app/Http/Requests/Exams/ExamDraftAdminLegacyRequest.php new file mode 100644 index 00000000..03396aa7 --- /dev/null +++ b/app/Http/Requests/Exams/ExamDraftAdminLegacyRequest.php @@ -0,0 +1,19 @@ + ['required', 'integer', 'min:1'], + 'school_year' => ['required', 'string'], + 'semester' => ['required', 'string'], + 'exam_type' => ['nullable', 'string', 'max:50'], + 'old_exam_file' => ['required', 'file', 'mimes:doc,docx,pdf', 'max:12288'], + ]; + } +} diff --git a/app/Http/Requests/Exams/ExamDraftAdminReviewRequest.php b/app/Http/Requests/Exams/ExamDraftAdminReviewRequest.php new file mode 100644 index 00000000..c8ac16f8 --- /dev/null +++ b/app/Http/Requests/Exams/ExamDraftAdminReviewRequest.php @@ -0,0 +1,18 @@ + ['required', 'integer', 'min:1'], + 'review_status' => ['nullable', 'in:draft,submitted,reviewed,finalized,rejected,pending,final,approved'], + 'admin_comments' => ['nullable', 'string'], + 'final_file' => ['nullable', 'file', 'mimes:doc,docx,pdf', 'max:12288'], + ]; + } +} diff --git a/app/Http/Requests/Exams/ExamDraftTeacherStoreRequest.php b/app/Http/Requests/Exams/ExamDraftTeacherStoreRequest.php new file mode 100644 index 00000000..42e9ec71 --- /dev/null +++ b/app/Http/Requests/Exams/ExamDraftTeacherStoreRequest.php @@ -0,0 +1,18 @@ + ['required', 'integer', 'min:1'], + 'exam_type' => ['nullable', 'string', 'max:50'], + 'description' => ['nullable', 'string'], + 'draft_file' => ['nullable', 'file', 'mimes:doc,docx', 'max:12288'], + ]; + } +} diff --git a/app/Http/Requests/Parents/ParentAttendanceReportCheckRequest.php b/app/Http/Requests/Parents/ParentAttendanceReportCheckRequest.php new file mode 100644 index 00000000..f99dbdb6 --- /dev/null +++ b/app/Http/Requests/Parents/ParentAttendanceReportCheckRequest.php @@ -0,0 +1,20 @@ + ['nullable', 'date'], + 'dates' => ['nullable', 'array'], + 'dates.*' => ['date'], + 'type' => ['required', 'in:absent,late,early_dismissal'], + 'student_ids' => ['required', 'array'], + 'student_ids.*' => ['integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/Parents/ParentAttendanceReportListRequest.php b/app/Http/Requests/Parents/ParentAttendanceReportListRequest.php new file mode 100644 index 00000000..f8618179 --- /dev/null +++ b/app/Http/Requests/Parents/ParentAttendanceReportListRequest.php @@ -0,0 +1,18 @@ + ['nullable', 'date'], + 'end' => ['nullable', 'date'], + 'school_year' => ['nullable', 'string', 'max:20'], + 'semester' => ['nullable', 'string', 'max:20'], + ]; + } +} diff --git a/app/Http/Requests/Parents/ParentAttendanceReportSubmitRequest.php b/app/Http/Requests/Parents/ParentAttendanceReportSubmitRequest.php new file mode 100644 index 00000000..da478091 --- /dev/null +++ b/app/Http/Requests/Parents/ParentAttendanceReportSubmitRequest.php @@ -0,0 +1,23 @@ + ['required', 'array'], + 'student_ids.*' => ['integer', 'min:1'], + 'date' => ['nullable', 'date'], + 'dates' => ['nullable', 'array'], + 'dates.*' => ['date'], + 'type' => ['required', 'in:absent,late,early_dismissal'], + 'arrival_time' => ['nullable', 'string'], + 'dismiss_time' => ['nullable', 'string'], + 'reason' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Parents/ParentAttendanceReportUpdateRequest.php b/app/Http/Requests/Parents/ParentAttendanceReportUpdateRequest.php new file mode 100644 index 00000000..d4d19d1a --- /dev/null +++ b/app/Http/Requests/Parents/ParentAttendanceReportUpdateRequest.php @@ -0,0 +1,17 @@ + ['nullable', 'string'], + 'arrival_time' => ['nullable', 'string'], + 'dismiss_time' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Parents/ParentAttendanceRequest.php b/app/Http/Requests/Parents/ParentAttendanceRequest.php new file mode 100644 index 00000000..f12c2da1 --- /dev/null +++ b/app/Http/Requests/Parents/ParentAttendanceRequest.php @@ -0,0 +1,15 @@ + ['nullable', 'string', 'max:20'], + ]; + } +} diff --git a/app/Http/Requests/Parents/ParentEmergencyContactRequest.php b/app/Http/Requests/Parents/ParentEmergencyContactRequest.php new file mode 100644 index 00000000..682e5e56 --- /dev/null +++ b/app/Http/Requests/Parents/ParentEmergencyContactRequest.php @@ -0,0 +1,18 @@ + ['required', 'string', 'max:150'], + 'cellphone' => ['required', 'string', 'max:30'], + 'email' => ['nullable', 'email', 'max:150'], + 'relation' => ['nullable', 'string', 'max:80'], + ]; + } +} diff --git a/app/Http/Requests/Parents/ParentEnrollmentActionRequest.php b/app/Http/Requests/Parents/ParentEnrollmentActionRequest.php new file mode 100644 index 00000000..41cdd88c --- /dev/null +++ b/app/Http/Requests/Parents/ParentEnrollmentActionRequest.php @@ -0,0 +1,18 @@ + ['nullable', 'array'], + 'enroll.*' => ['integer', 'min:1'], + 'withdraw' => ['nullable', 'array'], + 'withdraw.*' => ['integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/Parents/ParentEnrollmentRequest.php b/app/Http/Requests/Parents/ParentEnrollmentRequest.php new file mode 100644 index 00000000..5e9f671b --- /dev/null +++ b/app/Http/Requests/Parents/ParentEnrollmentRequest.php @@ -0,0 +1,15 @@ + ['nullable', 'string', 'max:20'], + ]; + } +} diff --git a/app/Http/Requests/Parents/ParentEventParticipationRequest.php b/app/Http/Requests/Parents/ParentEventParticipationRequest.php new file mode 100644 index 00000000..525e6970 --- /dev/null +++ b/app/Http/Requests/Parents/ParentEventParticipationRequest.php @@ -0,0 +1,15 @@ + ['required', 'array'], + ]; + } +} diff --git a/app/Http/Requests/Parents/ParentInvoiceRequest.php b/app/Http/Requests/Parents/ParentInvoiceRequest.php new file mode 100644 index 00000000..b6514a2b --- /dev/null +++ b/app/Http/Requests/Parents/ParentInvoiceRequest.php @@ -0,0 +1,15 @@ + ['nullable', 'string', 'max:20'], + ]; + } +} diff --git a/app/Http/Requests/Parents/ParentProfileUpdateRequest.php b/app/Http/Requests/Parents/ParentProfileUpdateRequest.php new file mode 100644 index 00000000..e02be5b2 --- /dev/null +++ b/app/Http/Requests/Parents/ParentProfileUpdateRequest.php @@ -0,0 +1,21 @@ + ['required', 'string', 'max:255'], + 'lastname' => ['required', 'string', 'max:255'], + 'cellphone' => ['required', 'string', 'max:25'], + 'address_street' => ['required', 'string', 'max:255'], + 'city' => ['required', 'string', 'max:255'], + 'state' => ['required', 'string', 'max:25'], + 'zip' => ['required', 'string', 'max:25'], + ]; + } +} diff --git a/app/Http/Requests/Parents/ParentRegistrationRequest.php b/app/Http/Requests/Parents/ParentRegistrationRequest.php new file mode 100644 index 00000000..4e8c4420 --- /dev/null +++ b/app/Http/Requests/Parents/ParentRegistrationRequest.php @@ -0,0 +1,29 @@ + ['nullable', 'array'], + 'students.*.firstname' => ['required_with:students', 'string', 'max:255'], + 'students.*.lastname' => ['required_with:students', 'string', 'max:255'], + 'students.*.dob' => ['required_with:students', 'date'], + 'students.*.gender' => ['required_with:students', 'string', 'max:10'], + 'students.*.registration_grade' => ['nullable', 'string', 'max:50'], + 'students.*.photo_consent' => ['nullable', 'boolean'], + 'students.*.is_new' => ['nullable', 'boolean'], + 'students.*.allergies' => ['nullable', 'array'], + 'students.*.medical_conditions' => ['nullable', 'array'], + 'emergency_contacts' => ['nullable', 'array'], + 'emergency_contacts.*.name' => ['required_with:emergency_contacts', 'string', 'max:150'], + 'emergency_contacts.*.cellphone' => ['required_with:emergency_contacts', 'string', 'max:30'], + 'emergency_contacts.*.email' => ['nullable', 'email', 'max:150'], + 'emergency_contacts.*.relation' => ['nullable', 'string', 'max:80'], + ]; + } +} diff --git a/app/Http/Requests/Parents/ParentStudentUpdateRequest.php b/app/Http/Requests/Parents/ParentStudentUpdateRequest.php new file mode 100644 index 00000000..01919db3 --- /dev/null +++ b/app/Http/Requests/Parents/ParentStudentUpdateRequest.php @@ -0,0 +1,22 @@ + ['required', 'string', 'max:255'], + 'lastname' => ['required', 'string', 'max:255'], + 'dob' => ['required', 'date'], + 'gender' => ['required', 'string', 'max:10'], + 'photo_consent' => ['nullable', 'boolean'], + 'registration_grade' => ['nullable', 'string', 'max:50'], + 'allergies' => ['nullable', 'array'], + 'medical_conditions' => ['nullable', 'array'], + ]; + } +} diff --git a/app/Http/Requests/Reports/SlipLogListRequest.php b/app/Http/Requests/Reports/SlipLogListRequest.php new file mode 100644 index 00000000..0b953813 --- /dev/null +++ b/app/Http/Requests/Reports/SlipLogListRequest.php @@ -0,0 +1,16 @@ + ['nullable', 'string'], + 'semester' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Reports/SlipPreviewRequest.php b/app/Http/Requests/Reports/SlipPreviewRequest.php new file mode 100644 index 00000000..7876c69d --- /dev/null +++ b/app/Http/Requests/Reports/SlipPreviewRequest.php @@ -0,0 +1,21 @@ + ['nullable', 'string'], + 'student_name' => ['nullable', 'string'], + 'date' => ['nullable', 'string'], + 'time_in' => ['nullable', 'string'], + 'grade' => ['nullable', 'string'], + 'reason' => ['nullable', 'string'], + 'admin_name' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Reports/SlipPrintRequest.php b/app/Http/Requests/Reports/SlipPrintRequest.php new file mode 100644 index 00000000..25e92e30 --- /dev/null +++ b/app/Http/Requests/Reports/SlipPrintRequest.php @@ -0,0 +1,28 @@ + ['nullable', 'string'], + 'student_name' => ['required', 'string'], + 'date' => ['nullable', 'string'], + 'time_in' => ['nullable', 'string'], + 'grade' => ['nullable', 'string'], + 'reason' => ['nullable', 'string'], + 'admin_name' => ['nullable', 'string'], + 'paper' => ['nullable', 'string'], + 'font_pt' => ['nullable', 'numeric'], + 'line_h' => ['nullable', 'numeric'], + 'rows' => ['nullable', 'integer'], + 'height_mm' => ['nullable', 'numeric'], + 'h' => ['nullable', 'numeric'], + 'fudge_mm' => ['nullable', 'numeric'], + ]; + } +} diff --git a/app/Http/Requests/Reports/SlipReprintRequest.php b/app/Http/Requests/Reports/SlipReprintRequest.php new file mode 100644 index 00000000..2dd5803e --- /dev/null +++ b/app/Http/Requests/Reports/SlipReprintRequest.php @@ -0,0 +1,15 @@ + ['required', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/SchoolIds/SchoolIdAssignRequest.php b/app/Http/Requests/SchoolIds/SchoolIdAssignRequest.php new file mode 100644 index 00000000..254d2882 --- /dev/null +++ b/app/Http/Requests/SchoolIds/SchoolIdAssignRequest.php @@ -0,0 +1,15 @@ + ['required', 'integer', 'min:1', 'exists:users,id'], + ]; + } +} diff --git a/app/Http/Requests/Settings/ConfigurationStoreRequest.php b/app/Http/Requests/Settings/ConfigurationStoreRequest.php new file mode 100644 index 00000000..74cd9f0f --- /dev/null +++ b/app/Http/Requests/Settings/ConfigurationStoreRequest.php @@ -0,0 +1,16 @@ + ['required', 'string', 'max:255'], + 'config_value' => ['required', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Settings/ConfigurationUpdateRequest.php b/app/Http/Requests/Settings/ConfigurationUpdateRequest.php new file mode 100644 index 00000000..8d0a3563 --- /dev/null +++ b/app/Http/Requests/Settings/ConfigurationUpdateRequest.php @@ -0,0 +1,16 @@ + ['required', 'string', 'max:255'], + 'config_value' => ['required', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Students/StudentAssignClassRequest.php b/app/Http/Requests/Students/StudentAssignClassRequest.php new file mode 100644 index 00000000..dd4b9f4d --- /dev/null +++ b/app/Http/Requests/Students/StudentAssignClassRequest.php @@ -0,0 +1,27 @@ +input('class_section_id'); + if (is_string($value)) { + $parts = preg_split('/[,\s]+/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: []; + $this->merge(['class_section_id' => $parts]); + } + } + + public function rules(): array + { + return [ + 'student_id' => ['required', 'integer', 'min:1'], + 'class_section_id' => ['required', 'array', 'min:1'], + 'class_section_id.*' => ['integer', 'min:1'], + 'is_event_only' => ['nullable', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/Students/StudentAssignmentListRequest.php b/app/Http/Requests/Students/StudentAssignmentListRequest.php new file mode 100644 index 00000000..42a79083 --- /dev/null +++ b/app/Http/Requests/Students/StudentAssignmentListRequest.php @@ -0,0 +1,15 @@ + ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Students/StudentAutoDistributeRequest.php b/app/Http/Requests/Students/StudentAutoDistributeRequest.php new file mode 100644 index 00000000..05297296 --- /dev/null +++ b/app/Http/Requests/Students/StudentAutoDistributeRequest.php @@ -0,0 +1,18 @@ + ['nullable', 'integer', 'min:1'], + 'class_section_id' => ['nullable', 'integer', 'min:1'], + 'students_per_section' => ['required', 'integer', 'min:1'], + 'school_year' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Students/StudentPromotionTotalsRequest.php b/app/Http/Requests/Students/StudentPromotionTotalsRequest.php new file mode 100644 index 00000000..f5407fee --- /dev/null +++ b/app/Http/Requests/Students/StudentPromotionTotalsRequest.php @@ -0,0 +1,15 @@ + ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Students/StudentRemoveClassRequest.php b/app/Http/Requests/Students/StudentRemoveClassRequest.php new file mode 100644 index 00000000..fb9e501d --- /dev/null +++ b/app/Http/Requests/Students/StudentRemoveClassRequest.php @@ -0,0 +1,16 @@ + ['required', 'integer', 'min:1'], + 'class_section_id' => ['required', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/Students/StudentSetActiveRequest.php b/app/Http/Requests/Students/StudentSetActiveRequest.php new file mode 100644 index 00000000..4a74e14d --- /dev/null +++ b/app/Http/Requests/Students/StudentSetActiveRequest.php @@ -0,0 +1,16 @@ + ['required', 'integer', 'min:1'], + 'is_active' => ['required', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/Students/StudentUpdateRequest.php b/app/Http/Requests/Students/StudentUpdateRequest.php new file mode 100644 index 00000000..4cb30ea3 --- /dev/null +++ b/app/Http/Requests/Students/StudentUpdateRequest.php @@ -0,0 +1,33 @@ + ['nullable', 'string', 'max:100'], + 'firstname' => ['required', 'string', 'max:255'], + 'lastname' => ['required', 'string', 'max:255'], + 'dob' => ['required', 'date'], + 'age' => ['nullable', 'integer'], + 'gender' => ['required', 'in:Male,Female,Other'], + 'registration_grade' => ['required', 'string', 'max:50'], + 'photo_consent' => ['nullable', 'boolean'], + 'parent_id' => ['required', 'integer', 'min:1'], + 'registration_date' => ['nullable', 'date'], + 'tuition_paid' => ['nullable', 'boolean'], + 'year_of_registration' => ['nullable', 'string'], + 'school_year' => ['nullable', 'string'], + 'rfid_tag' => ['nullable', 'string', 'max:100'], + 'semester' => ['nullable', 'string'], + 'is_new' => ['required', 'boolean'], + 'is_active' => ['nullable', 'boolean'], + 'medical_conditions' => ['nullable', 'string'], + 'allergies' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Subjects/SubjectCurriculumStoreRequest.php b/app/Http/Requests/Subjects/SubjectCurriculumStoreRequest.php new file mode 100644 index 00000000..dcaee03a --- /dev/null +++ b/app/Http/Requests/Subjects/SubjectCurriculumStoreRequest.php @@ -0,0 +1,19 @@ + ['required', 'integer', 'min:1'], + 'subject' => ['required', 'in:islamic,quran'], + 'chapter_name' => ['required', 'string', 'max:255'], + 'unit_number' => ['nullable', 'integer'], + 'unit_title' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/Subjects/SubjectCurriculumUpdateRequest.php b/app/Http/Requests/Subjects/SubjectCurriculumUpdateRequest.php new file mode 100644 index 00000000..3b4dec72 --- /dev/null +++ b/app/Http/Requests/Subjects/SubjectCurriculumUpdateRequest.php @@ -0,0 +1,19 @@ + ['required', 'integer', 'min:1'], + 'subject' => ['required', 'in:islamic,quran'], + 'chapter_name' => ['required', 'string', 'max:255'], + 'unit_number' => ['nullable', 'integer'], + 'unit_title' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/Teachers/TeacherAbsenceSubmitRequest.php b/app/Http/Requests/Teachers/TeacherAbsenceSubmitRequest.php new file mode 100644 index 00000000..c2fc2c23 --- /dev/null +++ b/app/Http/Requests/Teachers/TeacherAbsenceSubmitRequest.php @@ -0,0 +1,18 @@ + ['required', 'array', 'min:1'], + 'dates.*' => ['date'], + 'reason' => ['required', 'string'], + 'reason_type' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Teachers/TeacherAssignmentDeleteRequest.php b/app/Http/Requests/Teachers/TeacherAssignmentDeleteRequest.php new file mode 100644 index 00000000..4f263307 --- /dev/null +++ b/app/Http/Requests/Teachers/TeacherAssignmentDeleteRequest.php @@ -0,0 +1,18 @@ + ['required', 'integer', 'min:1'], + 'class_section_id' => ['required', 'integer', 'min:1'], + 'position' => ['required', 'in:main,ta'], + 'school_year' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Teachers/TeacherAssignmentListRequest.php b/app/Http/Requests/Teachers/TeacherAssignmentListRequest.php new file mode 100644 index 00000000..439da7e7 --- /dev/null +++ b/app/Http/Requests/Teachers/TeacherAssignmentListRequest.php @@ -0,0 +1,15 @@ + ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Teachers/TeacherAssignmentStoreRequest.php b/app/Http/Requests/Teachers/TeacherAssignmentStoreRequest.php new file mode 100644 index 00000000..117d0ba1 --- /dev/null +++ b/app/Http/Requests/Teachers/TeacherAssignmentStoreRequest.php @@ -0,0 +1,18 @@ + ['required', 'integer', 'min:1'], + 'class_section_id' => ['required', 'integer', 'min:1'], + 'teacher_role' => ['nullable', 'string'], + 'school_year' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Teachers/TeacherClassViewRequest.php b/app/Http/Requests/Teachers/TeacherClassViewRequest.php new file mode 100644 index 00000000..85e3d558 --- /dev/null +++ b/app/Http/Requests/Teachers/TeacherClassViewRequest.php @@ -0,0 +1,15 @@ + ['nullable', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/Teachers/TeacherSwitchClassRequest.php b/app/Http/Requests/Teachers/TeacherSwitchClassRequest.php new file mode 100644 index 00000000..e649eba3 --- /dev/null +++ b/app/Http/Requests/Teachers/TeacherSwitchClassRequest.php @@ -0,0 +1,15 @@ + ['required', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Resources/EmergencyContacts/EmergencyContactGroupResource.php b/app/Http/Resources/EmergencyContacts/EmergencyContactGroupResource.php new file mode 100644 index 00000000..d665a914 --- /dev/null +++ b/app/Http/Resources/EmergencyContacts/EmergencyContactGroupResource.php @@ -0,0 +1,19 @@ + (int) ($this['parent_id'] ?? 0), + 'parent_name' => (string) ($this['parent_name'] ?? ''), + 'parent_phones' => array_values($this['parent_phones'] ?? []), + 'students' => EmergencyContactStudentResource::collection($this['students'] ?? []), + 'contacts' => EmergencyContactResource::collection($this['contacts'] ?? []), + ]; + } +} diff --git a/app/Http/Resources/EmergencyContacts/EmergencyContactResource.php b/app/Http/Resources/EmergencyContacts/EmergencyContactResource.php new file mode 100644 index 00000000..cca74d0a --- /dev/null +++ b/app/Http/Resources/EmergencyContacts/EmergencyContactResource.php @@ -0,0 +1,22 @@ + (int) ($this['id'] ?? 0), + 'parent_id' => (int) ($this['parent_id'] ?? 0), + 'name' => (string) ($this['emergency_contact_name'] ?? ''), + 'cellphone' => (string) ($this['cellphone'] ?? ''), + 'email' => $this['email'] ?? null, + 'relation' => $this['relation'] ?? null, + 'semester' => $this['semester'] ?? null, + 'school_year' => $this['school_year'] ?? null, + ]; + } +} diff --git a/app/Http/Resources/EmergencyContacts/EmergencyContactStudentResource.php b/app/Http/Resources/EmergencyContacts/EmergencyContactStudentResource.php new file mode 100644 index 00000000..c6fa67aa --- /dev/null +++ b/app/Http/Resources/EmergencyContacts/EmergencyContactStudentResource.php @@ -0,0 +1,18 @@ + (int) ($this['id'] ?? 0), + 'firstname' => (string) ($this['firstname'] ?? ''), + 'lastname' => (string) ($this['lastname'] ?? ''), + 'school_id' => $this['school_id'] ?? null, + ]; + } +} diff --git a/app/Http/Resources/Exams/ExamDraftResource.php b/app/Http/Resources/Exams/ExamDraftResource.php new file mode 100644 index 00000000..49caa433 --- /dev/null +++ b/app/Http/Resources/Exams/ExamDraftResource.php @@ -0,0 +1,39 @@ + (int) ($this['id'] ?? 0), + 'teacher_id' => (int) ($this['teacher_id'] ?? 0), + 'class_section_id' => (int) ($this['class_section_id'] ?? 0), + 'class_section_name' => $this['class_section_name'] ?? null, + 'semester' => $this['semester'] ?? null, + 'school_year' => $this['school_year'] ?? null, + 'exam_type' => $this['exam_type'] ?? null, + 'draft_title' => $this['draft_title'] ?? null, + 'description' => $this['description'] ?? null, + 'teacher_file' => $this['teacher_file'] ?? null, + 'teacher_filename' => $this['teacher_filename'] ?? null, + 'status' => $this['status'] ?? null, + 'admin_id' => $this['admin_id'] ?? null, + 'admin_comments' => $this['admin_comments'] ?? null, + 'reviewed_at' => $this['reviewed_at'] ?? null, + 'final_file' => $this['final_file'] ?? null, + 'final_filename' => $this['final_filename'] ?? null, + 'final_pdf_file' => $this['final_pdf_file'] ?? null, + 'version' => $this['version'] ?? null, + 'previous_draft_id' => $this['previous_draft_id'] ?? null, + 'is_legacy' => $this['is_legacy'] ?? null, + 'teacher_first' => $this['teacher_first'] ?? null, + 'teacher_last' => $this['teacher_last'] ?? null, + 'admin_first' => $this['admin_first'] ?? null, + 'admin_last' => $this['admin_last'] ?? null, + ]; + } +} diff --git a/app/Http/Resources/Parents/ParentAttendanceReportResource.php b/app/Http/Resources/Parents/ParentAttendanceReportResource.php new file mode 100644 index 00000000..a82f9169 --- /dev/null +++ b/app/Http/Resources/Parents/ParentAttendanceReportResource.php @@ -0,0 +1,29 @@ + (int) ($this['id'] ?? 0), + 'parent_id' => (int) ($this['parent_id'] ?? 0), + 'student_id' => (int) ($this['student_id'] ?? 0), + 'class_section_id' => $this['class_section_id'] ?? null, + 'report_date' => (string) ($this['report_date'] ?? ''), + 'type' => (string) ($this['type'] ?? ''), + 'arrival_time' => $this['arrival_time'] ?? null, + 'dismiss_time' => $this['dismiss_time'] ?? null, + 'reason' => $this['reason'] ?? null, + 'semester' => $this['semester'] ?? null, + 'school_year' => $this['school_year'] ?? null, + 'status' => $this['status'] ?? null, + 'firstname' => $this['firstname'] ?? null, + 'lastname' => $this['lastname'] ?? null, + 'class_section_name' => $this['class_section_name'] ?? null, + ]; + } +} diff --git a/app/Http/Resources/Parents/ParentAttendanceResource.php b/app/Http/Resources/Parents/ParentAttendanceResource.php new file mode 100644 index 00000000..261aff54 --- /dev/null +++ b/app/Http/Resources/Parents/ParentAttendanceResource.php @@ -0,0 +1,19 @@ + (string) ($this['firstname'] ?? ''), + 'lastname' => (string) ($this['lastname'] ?? ''), + 'date' => (string) ($this['date'] ?? ''), + 'status' => (string) ($this['status'] ?? ''), + 'reason' => $this['reason'] ?? null, + ]; + } +} diff --git a/app/Http/Resources/Parents/ParentEmergencyContactResource.php b/app/Http/Resources/Parents/ParentEmergencyContactResource.php new file mode 100644 index 00000000..f9379e30 --- /dev/null +++ b/app/Http/Resources/Parents/ParentEmergencyContactResource.php @@ -0,0 +1,21 @@ + (int) ($this['id'] ?? 0), + 'name' => (string) ($this['emergency_contact_name'] ?? ''), + 'cellphone' => (string) ($this['cellphone'] ?? ''), + 'email' => $this['email'] ?? null, + 'relation' => $this['relation'] ?? null, + 'semester' => $this['semester'] ?? null, + 'school_year' => $this['school_year'] ?? null, + ]; + } +} diff --git a/app/Http/Resources/Parents/ParentStudentResource.php b/app/Http/Resources/Parents/ParentStudentResource.php new file mode 100644 index 00000000..8e3644b2 --- /dev/null +++ b/app/Http/Resources/Parents/ParentStudentResource.php @@ -0,0 +1,28 @@ + (int) ($this['id'] ?? 0), + 'firstname' => (string) ($this['firstname'] ?? ''), + 'lastname' => (string) ($this['lastname'] ?? ''), + 'dob' => $this['dob'] ?? null, + 'gender' => $this['gender'] ?? null, + 'registration_grade' => $this['registration_grade'] ?? null, + 'school_year' => $this['school_year'] ?? null, + 'semester' => $this['semester'] ?? null, + 'class_section' => $this['class_section'] ?? null, + 'enrollment_status' => $this['enrollment_status'] ?? null, + 'admission_status' => $this['admission_status'] ?? null, + 'disable_enroll' => (bool) ($this['disable_enroll'] ?? false), + 'allergies' => $this['allergies'] ?? [], + 'medical_conditions' => $this['medical_conditions'] ?? [], + ]; + } +} diff --git a/app/Http/Resources/Reports/LateSlipLogResource.php b/app/Http/Resources/Reports/LateSlipLogResource.php new file mode 100644 index 00000000..07c150df --- /dev/null +++ b/app/Http/Resources/Reports/LateSlipLogResource.php @@ -0,0 +1,24 @@ + (int) ($this['id'] ?? 0), + 'school_year' => (string) ($this['school_year'] ?? ''), + 'semester' => (string) ($this['semester'] ?? ''), + 'student_name' => (string) ($this['student_name'] ?? ''), + 'date' => $this['date'] ?? null, + 'time_in' => $this['time_in'] ?? null, + 'grade' => $this['grade'] ?? null, + 'reason' => $this['reason'] ?? null, + 'admin_name' => $this['admin_name'] ?? null, + 'printed_at' => $this['printed_at'] ?? null, + ]; + } +} diff --git a/app/Http/Resources/SchoolIds/SchoolIdResource.php b/app/Http/Resources/SchoolIds/SchoolIdResource.php new file mode 100644 index 00000000..cb9e6012 --- /dev/null +++ b/app/Http/Resources/SchoolIds/SchoolIdResource.php @@ -0,0 +1,16 @@ + $this['type'] ?? null, + 'school_id' => $this['school_id'] ?? null, + ]; + } +} diff --git a/app/Http/Resources/Settings/ConfigurationResource.php b/app/Http/Resources/Settings/ConfigurationResource.php new file mode 100644 index 00000000..7d40942a --- /dev/null +++ b/app/Http/Resources/Settings/ConfigurationResource.php @@ -0,0 +1,17 @@ + (int) ($this['id'] ?? 0), + 'config_key' => (string) ($this['config_key'] ?? ''), + 'config_value' => (string) ($this['config_value'] ?? ''), + ]; + } +} diff --git a/app/Http/Resources/Students/StudentAssignmentResource.php b/app/Http/Resources/Students/StudentAssignmentResource.php new file mode 100644 index 00000000..74d7c13b --- /dev/null +++ b/app/Http/Resources/Students/StudentAssignmentResource.php @@ -0,0 +1,27 @@ + (int) ($this['student_id'] ?? 0), + 'name' => (string) ($this['name'] ?? ''), + 'age' => $this['age'] ?? null, + 'email' => $this['email'] ?? null, + 'phone' => $this['phone'] ?? null, + 'registration_grade' => $this['registration_grade'] ?? null, + 'class_section_name' => $this['class_section_name'] ?? null, + 'class_section_names' => $this['class_section_names'] ?? [], + 'class_section_ids' => $this['class_section_ids'] ?? [], + 'new_student' => $this['new_student'] ?? null, + 'registration_date' => $this['registration_date'] ?? null, + 'school_year' => $this['school_year'] ?? null, + 'semester' => $this['semester'] ?? null, + ]; + } +} diff --git a/app/Http/Resources/Students/StudentClassSectionResource.php b/app/Http/Resources/Students/StudentClassSectionResource.php new file mode 100644 index 00000000..8d62ae62 --- /dev/null +++ b/app/Http/Resources/Students/StudentClassSectionResource.php @@ -0,0 +1,18 @@ + (int) ($this['id'] ?? 0), + 'class_section_id' => (int) ($this['class_section_id'] ?? 0), + 'class_section_name' => (string) ($this['class_section_name'] ?? ''), + 'school_year' => $this['school_year'] ?? null, + ]; + } +} diff --git a/app/Http/Resources/Students/StudentRemovedResource.php b/app/Http/Resources/Students/StudentRemovedResource.php new file mode 100644 index 00000000..19f7f48d --- /dev/null +++ b/app/Http/Resources/Students/StudentRemovedResource.php @@ -0,0 +1,22 @@ + (int) ($this['id'] ?? 0), + 'school_id' => $this['school_id'] ?? null, + 'firstname' => $this['firstname'] ?? null, + 'lastname' => $this['lastname'] ?? null, + 'gender' => $this['gender'] ?? null, + 'age' => $this['age'] ?? null, + 'class_sections' => $this['class_sections'] ?? [], + 'class_section_name' => $this['class_section_name'] ?? null, + ]; + } +} diff --git a/app/Http/Resources/Students/StudentScoreCardRowResource.php b/app/Http/Resources/Students/StudentScoreCardRowResource.php new file mode 100644 index 00000000..f571826d --- /dev/null +++ b/app/Http/Resources/Students/StudentScoreCardRowResource.php @@ -0,0 +1,28 @@ + $this['school_year'] ?? null, + 'semester' => $this['semester'] ?? null, + 'homework_avg' => $this['homework_avg'] ?? null, + 'project_avg' => $this['project_avg'] ?? null, + 'participation_score' => $this['participation_score'] ?? null, + 'quiz_avg' => $this['quiz_avg'] ?? null, + 'test_avg' => $this['test_avg'] ?? null, + 'attendance_score' => $this['attendance_score'] ?? null, + 'ptap_score' => $this['ptap_score'] ?? null, + 'midterm_exam_score' => $this['midterm_exam_score'] ?? null, + 'semester_score' => $this['semester_score'] ?? null, + 'class_section_name' => $this['class_section_name'] ?? null, + 'comments' => $this['comments'] ?? [], + 'year_score' => $this['year_score'] ?? null, + ]; + } +} diff --git a/app/Http/Resources/Subjects/SubjectClassResource.php b/app/Http/Resources/Subjects/SubjectClassResource.php new file mode 100644 index 00000000..554636ae --- /dev/null +++ b/app/Http/Resources/Subjects/SubjectClassResource.php @@ -0,0 +1,17 @@ + (int) ($this['id'] ?? 0), + 'class_name' => (string) ($this['class_name'] ?? ''), + 'school_year' => $this['school_year'] ?? null, + ]; + } +} diff --git a/app/Http/Resources/Subjects/SubjectCurriculumResource.php b/app/Http/Resources/Subjects/SubjectCurriculumResource.php new file mode 100644 index 00000000..41ee71e8 --- /dev/null +++ b/app/Http/Resources/Subjects/SubjectCurriculumResource.php @@ -0,0 +1,21 @@ + (int) ($this['id'] ?? 0), + 'class_id' => (int) ($this['class_id'] ?? 0), + 'class_name' => $this['class_name'] ?? null, + 'subject' => (string) ($this['subject'] ?? ''), + 'unit_number' => $this['unit_number'] ?? null, + 'unit_title' => $this['unit_title'] ?? null, + 'chapter_name' => (string) ($this['chapter_name'] ?? ''), + ]; + } +} diff --git a/app/Http/Resources/Teachers/TeacherAbsenceResource.php b/app/Http/Resources/Teachers/TeacherAbsenceResource.php new file mode 100644 index 00000000..646d0aba --- /dev/null +++ b/app/Http/Resources/Teachers/TeacherAbsenceResource.php @@ -0,0 +1,21 @@ + (int) ($this['id'] ?? 0), + 'user_id' => (int) ($this['user_id'] ?? 0), + 'date' => (string) ($this['date'] ?? ''), + 'semester' => $this['semester'] ?? null, + 'school_year' => $this['school_year'] ?? null, + 'status' => $this['status'] ?? null, + 'reason' => $this['reason'] ?? null, + ]; + } +} diff --git a/app/Http/Resources/Teachers/TeacherAssignmentResource.php b/app/Http/Resources/Teachers/TeacherAssignmentResource.php new file mode 100644 index 00000000..bd60bb04 --- /dev/null +++ b/app/Http/Resources/Teachers/TeacherAssignmentResource.php @@ -0,0 +1,24 @@ + (int) ($this['teacher_id'] ?? 0), + 'firstname' => (string) ($this['firstname'] ?? ''), + 'lastname' => (string) ($this['lastname'] ?? ''), + 'name' => (string) ($this['name'] ?? ''), + 'email' => $this['email'] ?? null, + 'cellphone' => $this['cellphone'] ?? null, + 'role' => $this['role'] ?? null, + 'school_year' => $this['school_year'] ?? null, + 'main_assignments' => $this['main_assignments'] ?? [], + 'ta_assignments' => $this['ta_assignments'] ?? [], + ]; + } +} diff --git a/app/Http/Resources/Teachers/TeacherClassResource.php b/app/Http/Resources/Teachers/TeacherClassResource.php new file mode 100644 index 00000000..4071f5de --- /dev/null +++ b/app/Http/Resources/Teachers/TeacherClassResource.php @@ -0,0 +1,21 @@ + (int) ($this['class_section_id'] ?? 0), + 'class_section_name' => (string) ($this['class_section_name'] ?? ''), + 'class_id' => $this['class_id'] ?? null, + 'class_name' => $this['class_name'] ?? null, + 'teacher_role' => $this['teacher_role'] ?? null, + 'school_year' => $this['school_year'] ?? null, + 'semester' => $this['semester'] ?? null, + ]; + } +} diff --git a/app/Http/Resources/Teachers/TeacherStudentResource.php b/app/Http/Resources/Teachers/TeacherStudentResource.php new file mode 100644 index 00000000..d15d0c8f --- /dev/null +++ b/app/Http/Resources/Teachers/TeacherStudentResource.php @@ -0,0 +1,27 @@ + (int) ($this['student_id'] ?? 0), + 'firstname' => (string) ($this['firstname'] ?? ''), + 'lastname' => (string) ($this['lastname'] ?? ''), + 'school_id' => $this['school_id'] ?? null, + 'is_new' => $this['is_new'] ?? null, + 'photo_consent' => $this['photo_consent'] ?? null, + 'age' => $this['age'] ?? null, + 'school_year' => $this['school_year'] ?? null, + 'class_section_id' => $this['class_section_id'] ?? null, + 'medical_conditions' => $this['medical_conditions'] ?? [], + 'allergies' => $this['allergies'] ?? [], + 'medical_conditions_text' => $this['medical_conditions_text'] ?? '', + 'allergies_text' => $this['allergies_text'] ?? '', + ]; + } +} diff --git a/app/Models/ExamDraft.php b/app/Models/ExamDraft.php index 381ae6fd..259a96c1 100644 --- a/app/Models/ExamDraft.php +++ b/app/Models/ExamDraft.php @@ -27,6 +27,7 @@ class ExamDraft extends BaseModel 'reviewed_at', 'final_file', 'final_filename', + 'final_pdf_file', 'version', 'previous_draft_id', 'is_legacy', @@ -87,4 +88,4 @@ class ExamDraft extends BaseModel { return $this->belongsTo(self::class, 'previous_draft_id'); } -} \ No newline at end of file +} diff --git a/app/Services/EmergencyContacts/EmergencyContactCrudService.php b/app/Services/EmergencyContacts/EmergencyContactCrudService.php new file mode 100644 index 00000000..8b943acd --- /dev/null +++ b/app/Services/EmergencyContacts/EmergencyContactCrudService.php @@ -0,0 +1,34 @@ +findOrFail($contactId); + + $contact->update([ + 'emergency_contact_name' => $payload['name'], + 'cellphone' => $this->phoneFormatter->formatPhoneNumber($payload['cellphone']), + 'email' => $payload['email'] ?? null, + 'relation' => $payload['relation'] ?? null, + ]); + + return $contact->toArray(); + } + + public function delete(int $contactId): void + { + $contact = EmergencyContact::query()->findOrFail($contactId); + $contact->delete(); + } +} diff --git a/app/Services/EmergencyContacts/EmergencyContactDirectoryService.php b/app/Services/EmergencyContacts/EmergencyContactDirectoryService.php new file mode 100644 index 00000000..10d60b4d --- /dev/null +++ b/app/Services/EmergencyContacts/EmergencyContactDirectoryService.php @@ -0,0 +1,87 @@ +distinct() + ->pluck('parent_id') + ->filter(static fn ($id) => (int) $id > 0) + ->values() + ->all(); + + $groups = []; + foreach ($parentIds as $parentId) { + $parentId = (int) $parentId; + $parent = User::query()->find($parentId); + + $parentName = $parent + ? trim((string) $parent->firstname . ' ' . (string) $parent->lastname) + : 'Unknown Parent'; + if ($parentName === '') { + $parentName = 'Unknown Parent'; + } + + $parentPhone = $parent ? (string) ($parent->cellphone ?? '') : ''; + $secondPhone = $this->secondParentPhone($parentId); + + $students = Student::query() + ->select(['id', 'firstname', 'lastname', 'school_id']) + ->where('parent_id', $parentId) + ->get() + ->toArray(); + + $contacts = EmergencyContact::query() + ->where('parent_id', $parentId) + ->orderBy('updated_at', 'DESC') + ->get() + ->toArray(); + + $groups[] = [ + 'parent_id' => $parentId, + 'parent_name' => $parentName, + 'parent_phones' => array_values(array_filter([ + $parentPhone, + $secondPhone, + ], static fn ($value) => (string) $value !== '')), + 'students' => $students, + 'contacts' => $contacts, + ]; + } + + return $groups; + } + + private function secondParentPhone(int $parentId): string + { + try { + if (!Schema::hasTable('parents')) { + return ''; + } + + if (!Schema::hasColumn('parents', 'secondparent_phone')) { + return ''; + } + + $phone = DB::table('parents') + ->where('firstparent_id', $parentId) + ->orderByDesc('updated_at') + ->value('secondparent_phone'); + + return $phone ? (string) $phone : ''; + } catch (\Throwable $e) { + Log::debug('EmergencyContactDirectoryService: could not load second parent phone: ' . $e->getMessage()); + return ''; + } + } +} diff --git a/app/Services/Exams/ExamDraftAdminService.php b/app/Services/Exams/ExamDraftAdminService.php new file mode 100644 index 00000000..5bbade1e --- /dev/null +++ b/app/Services/Exams/ExamDraftAdminService.php @@ -0,0 +1,225 @@ +configService->context(); + + $drafts = DB::table('exam_drafts as ed') + ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ed.class_section_id') + ->leftJoin('users as u', 'u.id', '=', 'ed.teacher_id') + ->leftJoin('users as a', 'a.id', '=', 'ed.admin_id') + ->select( + 'ed.*', + 'cs.class_section_name', + 'u.firstname as teacher_first', + 'u.lastname as teacher_last', + 'a.firstname as admin_first', + 'a.lastname as admin_last' + ) + ->orderByDesc('ed.created_at') + ->get() + ->map(fn ($r) => (array) $r) + ->all(); + + $classSections = ClassSection::query() + ->select('class_section_id', 'class_section_name') + ->orderBy('class_section_name') + ->get() + ->toArray(); + + $legacyByClass = []; + if ($context['has_legacy'] ?? false) { + foreach ($drafts as $row) { + $isLegacy = !empty($row['is_legacy']); + if (!$isLegacy) { + continue; + } + $cid = (int) ($row['class_section_id'] ?? 0); + if (!isset($legacyByClass[$cid])) { + $legacyByClass[$cid] = [ + 'class_section_id' => $cid, + 'class_section_name' => $row['class_section_name'] ?? 'Class ' . $cid, + 'items' => [], + ]; + } + $legacyByClass[$cid]['items'][] = $row; + } + } + + return [ + 'drafts' => $drafts, + 'classSections' => $classSections, + 'legacyByClass' => $legacyByClass, + 'examTypes' => ExamDraftConfigService::EXAM_TYPES, + 'statusOptions' => ExamDraftConfigService::STATUS_OPTIONS, + 'schoolYear' => (string) ($context['school_year'] ?? ''), + 'semester' => (string) ($context['semester'] ?? ''), + 'allowedExtensions' => ExamDraftConfigService::ADMIN_ALLOWED_EXTENSIONS, + 'maxUploadBytes' => ExamDraftConfigService::MAX_UPLOAD_BYTES, + ]; + } + + public function uploadLegacy(int $adminId, array $payload, \Illuminate\Http\UploadedFile $file): array + { + $context = $this->configService->context(); + $classSectionId = (int) ($payload['class_section_id'] ?? 0); + $schoolYear = trim((string) ($payload['school_year'] ?? $context['school_year'] ?? '')); + $semester = trim((string) ($payload['semester'] ?? $context['semester'] ?? '')); + $examType = trim((string) ($payload['exam_type'] ?? '')); + + if ($classSectionId <= 0 || $schoolYear === '' || $semester === '') { + return ['ok' => false, 'message' => 'Select class section, school year, and semester.']; + } + + $stored = $this->fileService->storeUploadedFile( + $file, + ExamDraftConfigService::FINAL_UPLOAD_DIR, + ExamDraftConfigService::ADMIN_ALLOWED_EXTENSIONS, + ExamDraftConfigService::MAX_UPLOAD_BYTES + ); + if ($stored === null) { + return ['ok' => false, 'message' => 'File type not allowed or upload failed.']; + } + + $payload = [ + 'teacher_id' => $adminId, + 'class_section_id' => $classSectionId, + 'semester' => ucfirst(strtolower($semester)), + 'school_year' => $schoolYear, + 'exam_type' => $examType !== '' ? $examType : null, + 'draft_title' => $examType !== '' ? $examType : 'Legacy Exam', + 'final_file' => $stored['stored'], + 'final_filename' => $stored['original'], + 'status' => 'finalized', + 'admin_id' => $adminId, + 'reviewed_at' => now(), + 'version' => 1, + ]; + + if ($context['has_legacy'] ?? false) { + $payload['is_legacy'] = 1; + } + + $pdfName = null; + if ($stored['extension'] === 'pdf') { + $pdfName = $stored['stored']; + } else { + $pdfName = $this->fileService->ensurePdfExists( + $stored['stored'], + $stored['extension'], + ExamDraftConfigService::FINAL_UPLOAD_DIR + ); + } + if ($pdfName !== null && ($context['has_final_pdf'] ?? false)) { + $payload['final_pdf_file'] = $pdfName; + } + + $draft = ExamDraft::query()->create($payload); + + return ['ok' => true, 'draft' => $draft->toArray()]; + } + + public function review(int $adminId, array $payload, ?\Illuminate\Http\UploadedFile $file): array + { + $draftId = (int) ($payload['draft_id'] ?? 0); + if ($draftId <= 0) { + return ['ok' => false, 'message' => 'Invalid submission selected.']; + } + + $draft = ExamDraft::query()->find($draftId); + if (!$draft) { + return ['ok' => false, 'message' => 'Submission not found.']; + } + + $comments = trim((string) ($payload['admin_comments'] ?? '')); + $statusInput = trim((string) ($payload['review_status'] ?? '')); + $currentStatus = (string) ($draft->status ?? 'draft'); + $status = $this->normalizeStatus($statusInput !== '' ? $statusInput : $currentStatus, $currentStatus); + if (!in_array($status, ExamDraftConfigService::STATUS_OPTIONS, true)) { + $status = 'reviewed'; + } + + $update = [ + 'status' => $status, + 'admin_comments' => $comments !== '' ? $comments : null, + 'admin_id' => $adminId, + 'reviewed_at' => now(), + ]; + + if ($file) { + $stored = $this->fileService->storeUploadedFile( + $file, + ExamDraftConfigService::FINAL_UPLOAD_DIR, + ExamDraftConfigService::ADMIN_ALLOWED_EXTENSIONS, + ExamDraftConfigService::MAX_UPLOAD_BYTES + ); + if ($stored === null) { + return ['ok' => false, 'message' => 'Unable to store the final draft.']; + } + $update['final_file'] = $stored['stored']; + $update['final_filename'] = $stored['original']; + $pdfName = $this->fileService->ensurePdfExists( + $stored['stored'], + $stored['extension'], + ExamDraftConfigService::FINAL_UPLOAD_DIR + ); + if ($pdfName !== null && ($this->configService->context()['has_final_pdf'] ?? false)) { + $update['final_pdf_file'] = $pdfName; + } + } elseif ($status === 'finalized' && !empty($draft->teacher_file)) { + $copied = $this->fileService->copyDraftToFinal( + (string) $draft->teacher_file, + ExamDraftConfigService::TEACHER_UPLOAD_DIR, + ExamDraftConfigService::FINAL_UPLOAD_DIR + ); + if ($copied !== null) { + $update['final_file'] = $copied; + $update['final_filename'] = $draft->teacher_filename ?? $draft->teacher_file; + $pdfName = $this->fileService->ensurePdfExists( + $copied, + pathinfo($copied, PATHINFO_EXTENSION), + ExamDraftConfigService::FINAL_UPLOAD_DIR + ); + if ($pdfName !== null && ($this->configService->context()['has_final_pdf'] ?? false)) { + $update['final_pdf_file'] = $pdfName; + } + } + } + + if ($status === 'finalized' && ($this->configService->context()['has_legacy'] ?? false)) { + $update['is_legacy'] = 1; + } + + $draft->update($update); + + return ['ok' => true, 'draft' => $draft->fresh()->toArray()]; + } + + private function normalizeStatus(string $input, string $default): string + { + $input = strtolower(trim($input)); + $aliases = [ + 'pending' => 'submitted', + 'final' => 'finalized', + 'approved' => 'finalized', + ]; + if (isset($aliases[$input])) { + $input = $aliases[$input]; + } + return in_array($input, ExamDraftConfigService::STATUS_OPTIONS, true) ? $input : $default; + } +} diff --git a/app/Services/Exams/ExamDraftConfigService.php b/app/Services/Exams/ExamDraftConfigService.php new file mode 100644 index 00000000..d4269311 --- /dev/null +++ b/app/Services/Exams/ExamDraftConfigService.php @@ -0,0 +1,45 @@ + (string) (Configuration::getConfig('school_year') ?? ''), + 'semester' => (string) (Configuration::getConfig('semester') ?? ''), + 'has_final_pdf' => $this->hasColumn('exam_drafts', 'final_pdf_file'), + 'has_legacy' => $this->hasColumn('exam_drafts', 'is_legacy'), + ]; + } + + private function hasColumn(string $table, string $column): bool + { + try { + return Schema::hasColumn($table, $column); + } catch (\Throwable) { + return false; + } + } +} diff --git a/app/Services/Exams/ExamDraftFileService.php b/app/Services/Exams/ExamDraftFileService.php new file mode 100644 index 00000000..d75a74e9 --- /dev/null +++ b/app/Services/Exams/ExamDraftFileService.php @@ -0,0 +1,107 @@ +getClientOriginalExtension()); + if (!in_array($ext, $allowedExtensions, true)) { + return null; + } + if ($file->getSize() > $maxBytes) { + return null; + } + + $destination = storage_path('uploads/' . trim($subdir, '/')); + if (!is_dir($destination)) { + mkdir($destination, 0755, true); + } + + $filename = uniqid('exam_', true) . '.' . $ext; + try { + $file->move($destination, $filename); + return [ + 'stored' => $filename, + 'original' => $file->getClientOriginalName(), + 'extension' => $ext, + ]; + } catch (\Throwable) { + return null; + } + } + + public function ensurePdfExists(string $finalFilename, ?string $originalExt, string $subdir): ?string + { + $pdfNeighbor = $this->neighborPdfIfExists($finalFilename, $subdir); + if ($pdfNeighbor !== null) { + return $pdfNeighbor; + } + $ext = strtolower((string) $originalExt); + if ($ext === 'pdf') { + $path = $this->fullUploadPath($subdir, $finalFilename); + return is_file($path) ? $finalFilename : null; + } + return $this->convertDocToPdf($this->fullUploadPath($subdir, $finalFilename), $subdir); + } + + public function copyDraftToFinal(string $draftFilename, string $sourceDir, string $targetDir): ?string + { + $source = $this->fullUploadPath($sourceDir, $draftFilename); + if (!is_file($source)) { + return null; + } + $destinationDir = storage_path('uploads/' . trim($targetDir, '/')); + if (!is_dir($destinationDir)) { + mkdir($destinationDir, 0755, true); + } + $ext = pathinfo($draftFilename, PATHINFO_EXTENSION); + $destName = uniqid('final_', true) . '.' . $ext; + $destPath = $destinationDir . '/' . $destName; + if (!@copy($source, $destPath)) { + return null; + } + return $destName; + } + + private function convertDocToPdf(string $sourcePath, string $targetSubdir): ?string + { + $ext = strtolower(pathinfo($sourcePath, PATHINFO_EXTENSION)); + if (!in_array($ext, ['doc', 'docx'], true)) { + return null; + } + + $targetDir = storage_path('uploads/' . trim($targetSubdir, '/')); + if (!is_dir($targetDir)) { + mkdir($targetDir, 0755, true); + } + + $base = pathinfo($sourcePath, PATHINFO_FILENAME); + $targetPath = $targetDir . '/' . $base . '.pdf'; + + $cmd = 'soffice --headless --convert-to pdf --outdir ' . escapeshellarg($targetDir) . ' ' . escapeshellarg($sourcePath) . ' 2>/dev/null'; + @exec($cmd); + + return is_file($targetPath) ? basename($targetPath) : null; + } + + private function neighborPdfIfExists(string $filename, string $subdir): ?string + { + if ($filename === '') { + return null; + } + $path = $this->fullUploadPath($subdir, $filename); + $base = pathinfo($path, PATHINFO_FILENAME); + $dir = pathinfo($path, PATHINFO_DIRNAME); + $pdfPath = $dir . '/' . $base . '.pdf'; + return is_file($pdfPath) ? basename($pdfPath) : null; + } + + private function fullUploadPath(string $subdir, string $filename): string + { + return storage_path('uploads/' . trim($subdir, '/') . '/' . $filename); + } +} diff --git a/app/Services/Exams/ExamDraftTeacherService.php b/app/Services/Exams/ExamDraftTeacherService.php new file mode 100644 index 00000000..9ba079a1 --- /dev/null +++ b/app/Services/Exams/ExamDraftTeacherService.php @@ -0,0 +1,133 @@ +configService->context(); + $schoolYear = (string) ($context['school_year'] ?? ''); + + $assignments = TeacherClass::getClassAssignmentsByUserId($teacherId, $schoolYear); + $classSectionIds = array_map(static fn ($a) => (int) $a['class_section_id'], $assignments); + + $drafts = ExamDraft::query() + ->where('teacher_id', $teacherId) + ->orderByDesc('created_at') + ->get() + ->toArray(); + + $legacyExams = []; + if (($context['has_legacy'] ?? false) && !empty($classSectionIds)) { + $legacyExams = DB::table('exam_drafts as ed') + ->select('ed.*', 'cs.class_section_name') + ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ed.class_section_id') + ->whereIn('ed.class_section_id', $classSectionIds) + ->where('ed.is_legacy', 1) + ->whereNotNull('ed.final_file') + ->orderBy('cs.class_section_name') + ->orderByDesc('ed.created_at') + ->get() + ->map(fn ($r) => (array) $r) + ->all(); + } + + return [ + 'assignments' => $assignments, + 'drafts' => $drafts, + 'legacyExams' => $legacyExams, + 'examTypes' => ExamDraftConfigService::EXAM_TYPES, + 'statusOptions' => ExamDraftConfigService::STATUS_OPTIONS, + 'schoolYear' => $schoolYear, + 'semester' => (string) ($context['semester'] ?? ''), + ]; + } + + public function store(int $teacherId, array $payload, ?\Illuminate\Http\UploadedFile $file): array + { + $context = $this->configService->context(); + $schoolYear = (string) ($context['school_year'] ?? ''); + $semester = (string) ($context['semester'] ?? ''); + + $classSectionId = (int) ($payload['class_section_id'] ?? 0); + $examType = trim((string) ($payload['exam_type'] ?? '')); + $description = trim((string) ($payload['description'] ?? '')); + + if ($classSectionId <= 0) { + return ['ok' => false, 'message' => 'Select a class section before submitting.']; + } + + $assignment = TeacherClass::query() + ->where('teacher_id', $teacherId) + ->where('class_section_id', $classSectionId) + ->first(); + + if (!$assignment) { + return ['ok' => false, 'message' => 'You are not assigned to the selected class section.']; + } + + $teacherFile = null; + $teacherFilename = null; + if ($file) { + $stored = $this->fileService->storeUploadedFile( + $file, + ExamDraftConfigService::TEACHER_UPLOAD_DIR, + ExamDraftConfigService::ALLOWED_EXTENSIONS, + ExamDraftConfigService::MAX_UPLOAD_BYTES + ); + if ($stored === null) { + return ['ok' => false, 'message' => 'Failed to store the uploaded file.']; + } + $teacherFile = $stored['stored']; + $teacherFilename = $stored['original']; + } + + $existingDraft = ExamDraft::query() + ->where('teacher_id', $teacherId) + ->where('class_section_id', $classSectionId) + ->where('semester', $semester) + ->where('school_year', $schoolYear) + ->orderByDesc('version') + ->first(); + + $nextVersion = 1; + $previousId = null; + if ($existingDraft) { + $nextVersion = ((int) ($existingDraft->version ?? 1)) + 1; + $previousId = (int) ($existingDraft->id ?? 0) ?: null; + } + + $title = $examType !== '' ? $examType : 'Exam Draft'; + + $draft = ExamDraft::query()->create([ + 'teacher_id' => $teacherId, + 'class_section_id' => $classSectionId, + 'semester' => $semester, + 'school_year' => $schoolYear, + 'exam_type' => $examType !== '' ? $examType : null, + 'draft_title' => $title, + 'description' => $description !== '' ? $description : null, + 'teacher_file' => $teacherFile, + 'teacher_filename' => $teacherFilename, + 'status' => 'submitted', + 'version' => $nextVersion, + 'previous_draft_id' => $previousId, + ]); + + return [ + 'ok' => true, + 'draft' => $draft->toArray(), + ]; + } +} diff --git a/app/Services/Parents/ParentAttendanceReportCalendarService.php b/app/Services/Parents/ParentAttendanceReportCalendarService.php new file mode 100644 index 00000000..1f5a443c --- /dev/null +++ b/app/Services/Parents/ParentAttendanceReportCalendarService.php @@ -0,0 +1,127 @@ +, 1: string} + */ + public function computeSundays(int $weeksBefore = 4, int $weeksAfter = 26): array + { + $today = new \DateTime('today'); + $dow = (int) $today->format('w'); + + $thisSunday = clone $today; + if ($dow !== 0) { + $thisSunday->modify('last sunday'); + } + + $default = clone $today; + if ($dow !== 0) { + $default->modify('next sunday'); + } + + $schoolYear = (string) ($this->configService->context()['school_year'] ?? ''); + $cap = $this->firstSundayOfJune($schoolYear); + + $dates = []; + for ($i = $weeksBefore; $i >= 1; $i--) { + $dates[] = (clone $thisSunday)->modify('-' . $i . ' week'); + } + + $cursor = clone $thisSunday; + while ($cursor <= $cap) { + $dates[] = clone $cursor; + $cursor->modify('+1 week'); + } + + $rangeStart = reset($dates); + $rangeEnd = end($dates); + $rangeStartStr = $rangeStart instanceof \DateTime ? $rangeStart->format('Y-m-d') : null; + $rangeEndStr = $rangeEnd instanceof \DateTime ? $rangeEnd->format('Y-m-d') : null; + + $noSchool = []; + if ($rangeStartStr && $rangeEndStr) { + $rows = DB::table('calendar_events') + ->select('date') + ->where('no_school', 1) + ->groupStart() + ->where('school_year', $schoolYear) + ->orWhere('school_year IS NULL', null, false) + ->groupEnd() + ->where('date >=', $rangeStartStr) + ->where('date <=', $rangeEndStr) + ->get() + ->toArray(); + + foreach ($rows as $row) { + $d = substr((string) ($row->date ?? ''), 0, 10); + if ($d !== '') { + $noSchool[$d] = true; + } + } + } + + $filtered = []; + foreach ($dates as $d) { + $ymd = $d->format('Y-m-d'); + if (!isset($noSchool[$ymd]) && $d >= $today && $d <= $cap) { + $filtered[] = $d; + } + } + + $defaultYmd = $default->format('Y-m-d'); + if (!empty($filtered)) { + $defaultYmd = $filtered[0]->format('Y-m-d'); + } + + $out = []; + foreach ($filtered as $d) { + $out[] = [ + 'value' => $d->format('Y-m-d'), + 'label' => $d->format('m-d-Y'), + ]; + } + + return [$out, $defaultYmd]; + } + + public 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); + return $hh . ':' . $m[2]; + } + return $default; + } + + public function firstSundayOfJune(string $schoolYear): \DateTime + { + $today = new \DateTime('today'); + $endYear = null; + if (preg_match('/^(\\d{4})\\D(\\d{4})$/', $schoolYear, $m)) { + $endYear = (int) $m[2]; + } + if ($endYear === null) { + $y = (int) $today->format('Y'); + $endYear = ((int) $today->format('n') > 6) ? ($y + 1) : $y; + } + + $juneFirst = new \DateTime(sprintf('%04d-06-01', $endYear)); + if ((int) $juneFirst->format('w') !== 0) { + $juneFirst->modify('next sunday'); + } + return $juneFirst; + } +} diff --git a/app/Services/Parents/ParentAttendanceReportService.php b/app/Services/Parents/ParentAttendanceReportService.php new file mode 100644 index 00000000..6bf87f45 --- /dev/null +++ b/app/Services/Parents/ParentAttendanceReportService.php @@ -0,0 +1,509 @@ +configService->context(); + $students = Student::query() + ->where('parent_id', $parentId) + ->orderBy('firstname') + ->orderBy('lastname') + ->get() + ->toArray(); + + [$sundays, $defaultDate] = $this->calendarService->computeSundays(); + + $today = now()->toDateString(); + $schoolYear = (string) ($context['school_year'] ?? ''); + $previewRows = DB::table('parent_attendance_reports as par') + ->select('par.*', 's.firstname', 's.lastname') + ->leftJoin('students as s', 's.id', '=', 'par.student_id') + ->where('par.parent_id', $parentId) + ->where('par.school_year', $schoolYear) + ->where('par.report_date', '>=', $today) + ->orderBy('par.report_date') + ->orderBy('s.firstname') + ->orderBy('s.lastname') + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + + $cutoffThreshold = $this->calendarService->normalizeCutoffTime( + (string) ($context['parent_report_cutoff'] ?? ''), + '09:00' + ); + + return [ + 'students' => $students, + 'today' => $today, + 'sundays' => $sundays, + 'defaultDate' => $defaultDate, + 'myReports' => $previewRows, + 'cutoffThreshold' => $cutoffThreshold, + ]; + } + + public function submit(int $parentId, array $payload): array + { + $context = $this->configService->context(); + $schoolYear = (string) ($context['school_year'] ?? ''); + $semester = (string) ($context['semester'] ?? ''); + + $studentIds = array_values(array_filter(array_map('intval', (array) ($payload['student_ids'] ?? [])))); + if (empty($studentIds)) { + throw new \RuntimeException('At least select one student.'); + } + + $datesInput = $payload['dates'] ?? ($payload['date'] ?? null); + $rawDates = []; + if (is_array($datesInput)) { + $rawDates = $datesInput; + } elseif ($datesInput !== null) { + $rawDates = [$datesInput]; + } + $rawDates = array_values(array_unique(array_filter(array_map(static function ($val) { + return substr((string) $val, 0, 10); + }, $rawDates)))); + + if (empty($rawDates)) { + throw new \RuntimeException('Please select at least one Sunday date.'); + } + + $type = (string) ($payload['type'] ?? ''); + if (!in_array($type, ['absent', 'late', 'early_dismissal'], true)) { + throw new \RuntimeException('Invalid report type.'); + } + + $arrivalTime = $payload['arrival_time'] ?? null; + $dismissTime = $payload['dismiss_time'] ?? null; + $reason = isset($payload['reason']) ? trim((string) $payload['reason']) : null; + + $todayCheck = new \DateTime('today'); + $cap = $this->calendarService->firstSundayOfJune($schoolYear); + $validDates = []; + + foreach ($rawDates as $entry) { + $dt = \DateTime::createFromFormat('Y-m-d', $entry); + if (!$dt) { + throw new \RuntimeException('Invalid date selected: ' . $entry); + } + if ((int) $dt->format('w') !== 0) { + throw new \RuntimeException('Please select Sunday dates only.'); + } + if ($dt < $todayCheck) { + throw new \RuntimeException('Past dates are not allowed. Please select today or a future Sunday.'); + } + if ($dt > $cap) { + throw new \RuntimeException('The last available date is the first Sunday of June.'); + } + $validDates[$entry] = $dt; + } + + if ($type === 'late' && (!is_string($arrivalTime) || $arrivalTime === '')) { + throw new \RuntimeException('Please provide an expected arrival time for late reporting.'); + } + if ($type === 'early_dismissal' && (!is_string($dismissTime) || $dismissTime === '')) { + throw new \RuntimeException('Please provide a dismissal time for early dismissal.'); + } + if ($type !== 'early_dismissal') { + if ($reason === null || $reason === '') { + throw new \RuntimeException('Please provide a reason for absence or late arrival.'); + } + } + + $inserted = 0; + $blocked = []; + $successDetails = []; + + foreach ($studentIds as $sid) { + if ($sid <= 0) { + continue; + } + $sectionRow = DB::table('student_class') + ->select('class_section_id') + ->where('student_id', $sid) + ->where('school_year', $schoolYear) + ->orderByDesc('updated_at') + ->first(); + $classSectionId = $sectionRow->class_section_id ?? null; + $student = Student::query()->select('firstname', 'lastname')->find($sid); + $studentName = $student ? trim($student->firstname . ' ' . $student->lastname) : ('Student #' . $sid); + $classLabel = $this->resolveClassSectionLabel($classSectionId); + + foreach ($validDates as $reportDate => $_dt) { + $semesterForDate = $this->semesterRangeService->getSemesterForDate($reportDate) ?: $semester; + $saved = false; + + $qb = ParentAttendanceReport::query() + ->where('student_id', $sid) + ->where('report_date', $reportDate); + + if ($type === 'early_dismissal') { + $existingAL = (clone $qb) + ->whereIn('type', ['absent', 'late']) + ->first(); + if ($existingAL) { + $blocked[] = $studentName . ' (' . $reportDate . ')'; + } else { + $existingED = (clone $qb)->where('type', 'early_dismissal')->first(); + if ($existingED) { + $existingED->update([ + 'dismiss_time' => $dismissTime ?: $existingED->dismiss_time, + 'status' => 'new', + ]); + $saved = true; + $inserted++; + } else { + ParentAttendanceReport::query()->create([ + 'parent_id' => $parentId, + 'student_id' => $sid, + 'class_section_id' => $classSectionId, + 'report_date' => $reportDate, + 'type' => 'early_dismissal', + 'dismiss_time' => $dismissTime ?: null, + 'reason' => $reason ?: null, + 'semester' => $semesterForDate, + 'school_year' => $schoolYear, + 'status' => 'new', + ]); + $saved = true; + $inserted++; + } + } + } else { + $existingAny = (clone $qb) + ->whereIn('type', ['absent', 'late', 'early_dismissal']) + ->first(); + if ($existingAny) { + $blocked[] = $studentName . ' (' . $reportDate . ')'; + } else { + ParentAttendanceReport::query()->create([ + 'parent_id' => $parentId, + 'student_id' => $sid, + 'class_section_id' => $classSectionId, + 'report_date' => $reportDate, + 'type' => $type, + 'arrival_time' => ($type === 'late') ? ($arrivalTime ?: null) : null, + 'reason' => $reason ?: null, + 'semester' => $semesterForDate, + 'school_year' => $schoolYear, + 'status' => 'new', + ]); + $saved = true; + $inserted++; + } + } + + if ($saved && in_array($type, ['absent', 'late'], true)) { + $this->reflectToAttendanceData( + studentId: $sid, + reportDate: $reportDate, + type: $type, + semester: $semesterForDate, + schoolYear: $schoolYear, + classSectionId: $classSectionId, + reason: $reason, + arrivalTime: $arrivalTime + ); + } + + if ($saved) { + $successDetails[] = [ + 'student_id' => $sid, + 'name' => $studentName, + 'class_label' => $classLabel, + 'type' => $type, + 'arrival_time' => $arrivalTime, + 'dismiss_time' => $dismissTime, + 'date' => $reportDate, + ]; + } + } + } + + if ($inserted <= 0) { + $err = !empty($blocked) + ? ('Already submitted and cannot be changed for: ' . implode(', ', array_slice($blocked, 0, 5)) . (count($blocked) > 5 ? '…' : '')) + : 'Could not save your report. Please try again.'; + throw new \RuntimeException($err); + } + + return [ + 'inserted' => $inserted, + 'blocked' => $blocked, + 'students' => $successDetails, + 'dates' => array_keys($validDates), + ]; + } + + public function listReports(string $start, ?string $end, ?string $schoolYear, ?string $semester): array + { + return ParentAttendanceReport::listForDateRange($start, $end, $schoolYear, $semester); + } + + public function checkExisting(array $payload): array + { + $datesPost = $payload['dates'] ?? null; + $dateSingle = $payload['date'] ?? null; + $dateValues = []; + if (is_array($datesPost)) { + foreach ($datesPost as $d) { + $dateValues[] = substr((string) $d, 0, 10); + } + } elseif ($dateSingle !== null) { + $dateValues[] = substr((string) $dateSingle, 0, 10); + } + $dateValues = array_values(array_unique(array_filter($dateValues, static function ($d) { + return preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $d); + }))); + + $type = (string) ($payload['type'] ?? ''); + if (empty($dateValues) || !in_array($type, ['absent', 'late', 'early_dismissal'], true)) { + throw new \RuntimeException('Invalid date or type.'); + } + + $studentIds = array_values(array_unique(array_map('intval', (array) ($payload['student_ids'] ?? [])))); + $studentIds = array_filter($studentIds, static fn ($v) => $v > 0); + if (empty($studentIds)) { + return []; + } + + $rows = DB::table('parent_attendance_reports as par') + ->select('par.student_id', 'par.type', 'par.report_date', 's.firstname', 's.lastname') + ->leftJoin('students as s', 's.id', '=', 'par.student_id') + ->whereIn('par.report_date', $dateValues) + ->whereIn('par.student_id', $studentIds) + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + + $map = []; + foreach ($rows as $row) { + $sid = (int) ($row['student_id'] ?? 0); + if ($sid <= 0) { + continue; + } + if (!isset($map[$sid])) { + $map[$sid] = [ + 'student_id' => $sid, + 'firstname' => (string) ($row['firstname'] ?? ''), + 'lastname' => (string) ($row['lastname'] ?? ''), + 'dates' => [], + ]; + } + $dVal = substr((string) ($row['report_date'] ?? ''), 0, 10); + if ($dVal === '') { + continue; + } + $map[$sid]['dates'][$dVal] = $map[$sid]['dates'][$dVal] ?? []; + $t = (string) ($row['type'] ?? ''); + if ($t !== '' && !in_array($t, $map[$sid]['dates'][$dVal], true)) { + $map[$sid]['dates'][$dVal][] = $t; + } + } + + return array_values($map); + } + + public function updateReport(int $parentId, int $reportId, array $payload): void + { + $row = ParentAttendanceReport::query()->find($reportId); + if (!$row || (int) $row->parent_id !== $parentId) { + throw new \RuntimeException('Report not found.'); + } + + if ((string) ($row->status ?? '') !== 'new') { + throw new \RuntimeException('This report can no longer be edited.'); + } + + $tzName = (string) (config('School')->attendance['timezone'] ?? 'UTC'); + $tz = new \DateTimeZone($tzName ?: 'UTC'); + $cutoff = new \DateTime($row->report_date . ' 09:00:00', $tz); + $now = new \DateTime('now', $tz); + if ($now >= $cutoff) { + throw new \RuntimeException('Editing window closed at 9:00 AM on the report date.'); + } + + $type = (string) $row->type; + $reason = trim((string) ($payload['reason'] ?? '')); + $arrival = (string) ($payload['arrival_time'] ?? ''); + $dismiss = (string) ($payload['dismiss_time'] ?? ''); + + $update = [ + 'reason' => $reason !== '' ? $reason : null, + ]; + + if ($type === 'late' && $arrival !== '') { + if (!preg_match('/^\\d{2}:\\d{2}(:\\d{2})?$/', $arrival)) { + throw new \RuntimeException('Invalid arrival time format.'); + } + $update['arrival_time'] = $arrival; + } + if ($type === 'early_dismissal' && $dismiss !== '') { + if (!preg_match('/^\\d{2}:\\d{2}(:\\d{2})?$/', $dismiss)) { + throw new \RuntimeException('Invalid dismissal time format.'); + } + $update['dismiss_time'] = $dismiss; + } + + if (empty($update)) { + throw new \RuntimeException('Nothing to update.'); + } + + $row->update($update); + } + + private function resolveClassSectionLabel(?int $classSectionId): string + { + $cid = (int) ($classSectionId ?? 0); + if ($cid <= 0) { + return 'Not Assigned'; + } + + $name = ClassSection::query() + ->where('class_section_id', $cid) + ->value('class_section_name'); + + return $name ?: 'Not Assigned'; + } + + private function reflectToAttendanceData( + int $studentId, + string $reportDate, + string $type, + string $semester, + string $schoolYear, + ?int $classSectionId, + ?string $reason = null, + ?string $arrivalTime = null + ): void { + $status = ($type === 'late') ? 'late' : (($type === 'absent') ? 'absent' : null); + if ($status === null) { + return; + } + + if (!$classSectionId) { + $row = DB::table('student_class') + ->select('class_section_id') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->orderByDesc('updated_at') + ->first(); + $classSectionId = (int) ($row->class_section_id ?? 0); + } + if ($classSectionId <= 0) { + return; + } + + $existing = AttendanceData::query() + ->where('student_id', $studentId) + ->where('class_section_id', $classSectionId) + ->where('date', $reportDate) + ->where('semester', $semester) + ->where('school_year', $schoolYear) + ->first(); + + $reasonParts = ['Parent-reported']; + if ($type === 'late' && $arrivalTime) { + $reasonParts[] = 'ETA ' . substr($arrivalTime, 0, 5); + } + if (!empty($reason)) { + $reasonParts[] = trim($reason); + } + $reasonText = implode(' — ', $reasonParts); + + if (!$existing) { + $classId = ClassSection::query() + ->where('class_section_id', $classSectionId) + ->value('class_id'); + $schoolId = Student::query()->whereKey($studentId)->value('school_id'); + if (!$classId || !$schoolId) { + return; + } + AttendanceData::query()->create([ + 'class_id' => $classId, + 'class_section_id' => $classSectionId, + 'student_id' => $studentId, + 'school_id' => $schoolId, + 'date' => $reportDate, + 'status' => $status, + 'reason' => $reasonText, + 'semester' => $semester, + 'school_year' => $schoolYear, + ]); + $this->bumpAttendanceRecord($studentId, $classSectionId, $schoolId, $status, $semester, $schoolYear); + return; + } + + $existingStatus = strtolower((string) ($existing->status ?? '')); + if ($existingStatus === 'present' || $existingStatus === '') { + $existing->update([ + 'status' => $status, + 'reason' => $existing->reason ?: $reasonText, + ]); + } + } + + private function bumpAttendanceRecord( + int $studentId, + int $classSectionId, + string $schoolId, + string $status, + string $semester, + string $schoolYear + ): void { + $record = AttendanceRecord::query() + ->where('student_id', $studentId) + ->where('semester', $semester) + ->where('school_year', $schoolYear) + ->first(); + + if ($record) { + $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) + 1, + ]; + if ($status === 'present') { + $updates['total_presence']++; + } elseif ($status === 'absent') { + $updates['total_absence']++; + } elseif ($status === 'late') { + $updates['total_late']++; + } + $record->update($updates); + return; + } + + AttendanceRecord::query()->create([ + 'class_section_id' => $classSectionId, + 'student_id' => $studentId, + 'school_id' => $schoolId, + 'semester' => $semester, + 'school_year' => $schoolYear, + 'total_presence' => ($status === 'present') ? 1 : 0, + 'total_absence' => ($status === 'absent') ? 1 : 0, + 'total_late' => ($status === 'late') ? 1 : 0, + 'total_attendance' => 1, + ]); + } +} diff --git a/app/Services/Parents/ParentAttendanceService.php b/app/Services/Parents/ParentAttendanceService.php new file mode 100644 index 00000000..9bf02154 --- /dev/null +++ b/app/Services/Parents/ParentAttendanceService.php @@ -0,0 +1,42 @@ +configService->context(); + $selectedYear = $schoolYear ?: $context['school_year']; + + $rows = DB::table('attendance_data') + ->select('students.firstname', 'students.lastname', 'attendance_data.date', 'attendance_data.status', 'attendance_data.reason') + ->join('students', 'students.id', '=', 'attendance_data.student_id') + ->where('students.parent_id', $parentId) + ->where('attendance_data.school_year', $selectedYear) + ->orderBy('attendance_data.date', 'desc') + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + + $schoolYears = DB::table('attendance_data') + ->select('school_year') + ->distinct() + ->orderBy('school_year', 'desc') + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + + return [ + 'attendance' => $rows, + 'schoolYears' => $schoolYears, + 'selectedYear' => $selectedYear, + ]; + } +} diff --git a/app/Services/Parents/ParentConfigService.php b/app/Services/Parents/ParentConfigService.php new file mode 100644 index 00000000..cc52af7a --- /dev/null +++ b/app/Services/Parents/ParentConfigService.php @@ -0,0 +1,22 @@ + Configuration::getConfig('date_age_reference'), + 'enrollment_deadline' => Configuration::getConfig('enrollment_deadline'), + 'fall_semester_start' => Configuration::getConfig('fall_semester_start'), + 'refund_deadline' => Configuration::getConfig('refund_deadline'), + 'school_year' => Configuration::getConfig('school_year'), + 'semester' => Configuration::getConfig('semester'), + 'max_kids' => (int) (Configuration::getConfig('max_kids') ?? 0), + 'max_emergency' => (int) (Configuration::getConfig('max_emergency') ?? 0), + ]; + } +} diff --git a/app/Services/Parents/ParentEmergencyContactService.php b/app/Services/Parents/ParentEmergencyContactService.php new file mode 100644 index 00000000..c42d29b5 --- /dev/null +++ b/app/Services/Parents/ParentEmergencyContactService.php @@ -0,0 +1,57 @@ +where('parent_id', $parentId) + ->orderBy('emergency_contact_name') + ->get() + ->toArray(); + } + + public function store(int $parentId, array $payload): array + { + $context = $this->configService->context(); + $contact = EmergencyContact::query()->create([ + 'parent_id' => $parentId, + 'emergency_contact_name' => $payload['name'], + 'cellphone' => $this->phoneFormatter->formatPhoneNumber($payload['cellphone']), + 'email' => $payload['email'] ?? null, + 'relation' => $payload['relation'] ?? null, + 'semester' => $context['semester'], + 'school_year' => $context['school_year'], + ]); + + return $contact->toArray(); + } + + public function update(int $parentId, int $contactId, array $payload): array + { + $contact = EmergencyContact::query() + ->where('id', $contactId) + ->where('parent_id', $parentId) + ->firstOrFail(); + + $contact->update([ + 'emergency_contact_name' => $payload['name'], + 'cellphone' => $this->phoneFormatter->formatPhoneNumber($payload['cellphone']), + 'email' => $payload['email'] ?? null, + 'relation' => $payload['relation'] ?? null, + ]); + + return $contact->toArray(); + } +} diff --git a/app/Services/Parents/ParentEnrollmentService.php b/app/Services/Parents/ParentEnrollmentService.php new file mode 100644 index 00000000..13dcf6e2 --- /dev/null +++ b/app/Services/Parents/ParentEnrollmentService.php @@ -0,0 +1,206 @@ +configService->context(); + $selectedYear = $schoolYear ?: $context['school_year']; + + $students = Student::query() + ->where('parent_id', $parentId) + ->orderBy('lastname') + ->orderBy('firstname') + ->get() + ->map(function ($student) use ($selectedYear) { + $classSections = StudentClass::getClassSectionsByStudentId($student->id, $selectedYear, true); + $student->class_section = !empty($classSections) + ? implode(', ', $classSections) + : 'Class not Assigned'; + + $isArabicClass = !empty($classSections) && array_reduce( + $classSections, + static fn ($carry, $name) => $carry || (is_string($name) && stripos($name, 'arabic') === 0), + false + ); + + $enrollment = Enrollment::query() + ->select('enrollment_status', 'admission_status') + ->where('student_id', $student->id) + ->where('school_year', $selectedYear) + ->first(); + + $statusMap = [ + 'admission under review' => 'admission under review', + 'payment pending' => 'payment pending', + 'enrolled' => 'enrolled', + 'withdraw under review' => 'withdraw under review', + 'refund pending' => 'refund pending', + 'withdrawn' => 'withdrawn', + 'denied' => 'denied', + 'waitlist' => 'waitlist', + ]; + + if ($enrollment && isset($enrollment->admission_status)) { + $student->admission_status = $enrollment->admission_status; + if ($enrollment->admission_status === 'denied') { + $student->enrollment_status = 'denied'; + } else { + $student->enrollment_status = $statusMap[$enrollment->enrollment_status] ?? 'not enrolled'; + } + } else { + $student->admission_status = null; + $student->enrollment_status = 'not enrolled'; + } + + if ($student->enrollment_status === 'not enrolled' && $isArabicClass) { + $student->enrollment_status = 'enrolled'; + } + + $student->disable_enroll = in_array( + $student->enrollment_status, + ['admission under review', 'payment pending', 'enrolled', 'withdraw under review', 'denied'], + true + ); + + return $student->toArray(); + }) + ->all(); + + $schoolYears = DB::table('enrollments') + ->select('school_year') + ->distinct() + ->orderBy('school_year', 'desc') + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + + if (empty($schoolYears)) { + $schoolYears[] = ['school_year' => $selectedYear]; + } + + return [ + 'students' => $students, + 'schoolYears' => $schoolYears, + 'selectedYear' => $selectedYear, + 'withdrawalDeadline' => $context['refund_deadline'], + 'lastDayOfRegistration' => $context['enrollment_deadline'], + 'schoolStartDate' => $context['fall_semester_start'], + ]; + } + + public function updateEnrollment(int $parentId, array $enrollIds, array $withdrawIds): array + { + $context = $this->configService->context(); + $schoolYear = $context['school_year']; + $semester = $context['semester']; + + $enrolled = []; + $withdrawn = []; + + foreach ($enrollIds as $studentId) { + $existing = Enrollment::query() + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->where('semester', $semester) + ->first(); + + if ($existing) { + if ((int) $existing->is_withdrawn === 1) { + $existing->update([ + 'is_withdrawn' => 0, + 'withdrawal_date' => null, + 'enrollment_status' => 'payment pending', + 'updated_at' => now(), + ]); + } + } else { + Enrollment::query()->create([ + 'student_id' => $studentId, + 'parent_id' => $parentId, + 'school_year' => $schoolYear, + 'semester' => $semester, + 'enrollment_date' => now()->toDateString(), + 'is_withdrawn' => 0, + 'enrollment_status' => 'admission under review', + 'admission_status' => 'pending', + 'created_at' => now(), + ]); + } + + $enrolled[] = (int) $studentId; + } + + foreach ($withdrawIds as $studentId) { + $enrollment = Enrollment::query() + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->where('semester', $semester) + ->where('is_withdrawn', 0) + ->first(); + + if (!$enrollment) { + continue; + } + + $enrollment->update([ + 'withdrawal_date' => now()->toDateString(), + 'enrollment_status' => 'withdraw under review', + 'updated_at' => now(), + ]); + + $invoice = DB::table('invoices') + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->orderByDesc('created_at') + ->first(); + + if ($invoice) { + $refund = Refund::query() + ->where('parent_id', $parentId) + ->where('invoice_id', $invoice->id) + ->where('school_year', $schoolYear) + ->first(); + + if ($refund) { + $refund->update([ + 'reason' => 'Withdrawal under review for student ID ' . $studentId, + 'note' => null, + 'updated_by' => $parentId, + ]); + } else { + Refund::query()->create([ + 'parent_id' => $parentId, + 'invoice_id' => $invoice->id, + 'requested_at' => now(), + 'school_year' => $schoolYear, + 'status' => Refund::STATUS_PENDING, + 'reason' => 'Withdrawal under review for student ID ' . $studentId, + 'request' => 'new', + 'semester' => $semester, + 'refund_paid_amount' => 0.0, + ]); + } + } + + $withdrawn[] = (int) $studentId; + } + + return [ + 'enrolled' => $enrolled, + 'withdrawn' => $withdrawn, + ]; + } +} diff --git a/app/Services/Parents/ParentEventParticipationService.php b/app/Services/Parents/ParentEventParticipationService.php new file mode 100644 index 00000000..4e90a896 --- /dev/null +++ b/app/Services/Parents/ParentEventParticipationService.php @@ -0,0 +1,85 @@ +configService->context(); + $schoolYear = $context['school_year']; + $semester = $context['semester']; + + $activeEvents = Event::getActiveEvents($schoolYear, $semester) ?? []; + $chargesList = EventCharges::getChargesWithEventInfo($parentId, $schoolYear) ?? []; + + $charges = []; + foreach ($chargesList as $charge) { + $key = $charge['student_id'] . ':' . $charge['event_id']; + $charges[$key] = [ + 'participation' => $charge['participation'], + 'date' => $charge['updated_at'] ?? $charge['created_at'], + ]; + } + + $students = Enrollment::getEnrolledStudents($parentId, $schoolYear); + + return [ + 'activeEvents' => $activeEvents, + 'charges' => $charges, + 'yourStudents' => $students, + ]; + } + + public function updateParticipation(int $parentId, array $participations): void + { + $context = $this->configService->context(); + $schoolYear = $context['school_year']; + $semester = $context['semester']; + + foreach ($participations as $key => $value) { + [$studentId, $eventId] = array_map('intval', explode(':', (string) $key)); + $existing = EventCharges::query() + ->where('parent_id', $parentId) + ->where('student_id', $studentId) + ->where('event_id', $eventId) + ->first(); + + if ($value === 'no') { + if ($existing) { + $existing->delete(); + } + continue; + } + + if ($existing) { + $existing->update(['participation' => $value]); + } else { + $event = Event::getEvent($eventId, $schoolYear); + EventCharges::query()->create([ + 'parent_id' => $parentId, + 'student_id' => $studentId, + 'event_id' => $eventId, + 'participation' => $value, + 'charged' => $event['amount'] ?? 0, + 'school_year' => $schoolYear, + 'semester' => $semester, + 'updated_by' => $parentId, + ]); + } + } + + $this->invoiceGeneration->generateInvoice($parentId, $schoolYear); + } +} diff --git a/app/Services/Parents/ParentInvoiceService.php b/app/Services/Parents/ParentInvoiceService.php new file mode 100644 index 00000000..f67b9e1f --- /dev/null +++ b/app/Services/Parents/ParentInvoiceService.php @@ -0,0 +1,19 @@ +where('parent_id', $parentId) + ->when($schoolYear, fn ($q) => $q->where('school_year', $schoolYear)) + ->orderByDesc('created_at') + ->get(); + + return $rows->toArray(); + } +} diff --git a/app/Services/Parents/ParentProfileService.php b/app/Services/Parents/ParentProfileService.php new file mode 100644 index 00000000..a04e231f --- /dev/null +++ b/app/Services/Parents/ParentProfileService.php @@ -0,0 +1,30 @@ +find($parentId); + return $user ? $user->toArray() : null; + } + + public function updateProfile(int $parentId, array $payload): array + { + $user = User::query()->findOrFail($parentId); + $user->update([ + 'firstname' => $payload['firstname'], + 'lastname' => $payload['lastname'], + 'cellphone' => $payload['cellphone'], + 'address_street' => $payload['address_street'], + 'city' => $payload['city'], + 'state' => $payload['state'], + 'zip' => $payload['zip'], + ]); + + return $user->toArray(); + } +} diff --git a/app/Services/Parents/ParentRegistrationService.php b/app/Services/Parents/ParentRegistrationService.php new file mode 100644 index 00000000..9b63a564 --- /dev/null +++ b/app/Services/Parents/ParentRegistrationService.php @@ -0,0 +1,228 @@ +configService->context(); + $parent = User::query()->find($parentId); + + $kids = Student::query()->where('parent_id', $parentId)->get(); + $kidIds = $kids->pluck('id')->all(); + + $allergies = StudentAllergy::query() + ->whereIn('student_id', $kidIds) + ->get() + ->groupBy('student_id'); + + $conditions = StudentMedicalCondition::query() + ->whereIn('student_id', $kidIds) + ->get() + ->groupBy('student_id'); + + $kids = $kids->map(function ($kid) use ($allergies, $conditions) { + $kid->allergies = ($allergies[$kid->id] ?? collect())->pluck('allergy')->all(); + $kid->medical_conditions = ($conditions[$kid->id] ?? collect())->pluck('condition_name')->all(); + return $kid->toArray(); + })->all(); + + $emergencies = EmergencyContact::query()->where('parent_id', $parentId)->get()->toArray(); + + return [ + 'parent' => $parent ? $parent->toArray() : null, + 'existingKids' => $kids, + 'emergencies' => $emergencies, + 'maxChilds' => $context['max_kids'], + 'maxEmergency' => $context['max_emergency'], + 'lastDayOfRegistration' => $context['enrollment_deadline'], + 'registrationAgeDeadline' => $context['date_age_reference'], + ]; + } + + public function register(int $parentId, array $students, array $emergencyContacts): array + { + $context = $this->configService->context(); + $schoolYear = $context['school_year']; + $semester = $context['semester']; + $ageReference = $context['date_age_reference'] ?: now()->toDateString(); + + $existingKidsCount = Student::query()->where('parent_id', $parentId)->count(); + $existingECCount = EmergencyContact::query()->where('parent_id', $parentId)->count(); + + if ($existingKidsCount + count($students) > $context['max_kids']) { + throw new \RuntimeException('Student limit exceeded.'); + } + + if ($existingECCount + count($emergencyContacts) > $context['max_emergency']) { + throw new \RuntimeException('Emergency contact limit exceeded.'); + } + + $createdStudentIds = []; + + DB::transaction(function () use ( + $parentId, + $students, + $emergencyContacts, + $schoolYear, + $semester, + &$createdStudentIds + ) { + foreach ($students as $student) { + $exists = Student::query() + ->where('school_year', $schoolYear) + ->where('dob', $student['dob']) + ->where('firstname', $student['firstname']) + ->where('lastname', $student['lastname']) + ->first(); + + if ($exists) { + continue; + } + + $row = Student::query()->create([ + 'firstname' => $student['firstname'], + 'lastname' => $student['lastname'], + 'dob' => $student['dob'], + 'age' => $this->calculateAge($student['dob'], $ageReference), + 'gender' => $student['gender'], + 'registration_grade' => $student['registration_grade'] ?? null, + 'photo_consent' => !empty($student['photo_consent']) ? 1 : 0, + 'parent_id' => $parentId, + 'year_of_registration' => (string) date('Y'), + 'school_year' => $schoolYear, + 'semester' => $semester, + 'is_new' => isset($student['is_new']) ? (int) (bool) $student['is_new'] : null, + 'registration_date' => now(), + 'tuition_paid' => 0, + 'school_id' => $this->schoolIdService->generateStudentSchoolId(), + ]); + + $createdStudentIds[] = (int) $row->id; + + foreach (($student['medical_conditions'] ?? []) as $condition) { + $condition = trim((string) $condition); + if ($condition === '') { + continue; + } + StudentMedicalCondition::query()->create([ + 'student_id' => $row->id, + 'condition_name' => $condition, + ]); + } + + foreach (($student['allergies'] ?? []) as $allergy) { + $allergy = trim((string) $allergy); + if ($allergy === '') { + continue; + } + StudentAllergy::query()->create([ + 'student_id' => $row->id, + 'allergy' => $allergy, + ]); + } + } + + foreach ($emergencyContacts as $contact) { + EmergencyContact::query()->create([ + 'parent_id' => $parentId, + 'emergency_contact_name' => $contact['name'], + 'cellphone' => $contact['cellphone'], + 'email' => $contact['email'] ?? null, + 'relation' => $contact['relation'] ?? null, + 'semester' => $semester, + 'school_year' => $schoolYear, + ]); + } + }); + + return [ + 'created_student_ids' => $createdStudentIds, + ]; + } + + public function updateStudent(int $parentId, int $studentId, array $payload): void + { + $student = Student::query() + ->where('id', $studentId) + ->where('parent_id', $parentId) + ->firstOrFail(); + + $ageReference = $this->configService->context()['date_age_reference'] ?: now()->toDateString(); + + $student->update([ + 'firstname' => $payload['firstname'], + 'lastname' => $payload['lastname'], + 'dob' => $payload['dob'], + 'age' => $this->calculateAge($payload['dob'], $ageReference), + 'gender' => $payload['gender'], + 'registration_grade' => $payload['registration_grade'] ?? null, + 'photo_consent' => !empty($payload['photo_consent']) ? 1 : 0, + ]); + + StudentMedicalCondition::query()->where('student_id', $studentId)->delete(); + foreach (($payload['medical_conditions'] ?? []) as $condition) { + $condition = trim((string) $condition); + if ($condition === '') { + continue; + } + StudentMedicalCondition::query()->create([ + 'student_id' => $studentId, + 'condition_name' => $condition, + ]); + } + + StudentAllergy::query()->where('student_id', $studentId)->delete(); + foreach (($payload['allergies'] ?? []) as $allergy) { + $allergy = trim((string) $allergy); + if ($allergy === '') { + continue; + } + StudentAllergy::query()->create([ + 'student_id' => $studentId, + 'allergy' => $allergy, + ]); + } + } + + public function deleteStudent(int $parentId, int $studentId): void + { + $student = Student::query() + ->where('id', $studentId) + ->where('parent_id', $parentId) + ->firstOrFail(); + + $student->delete(); + + $remaining = Student::query()->where('parent_id', $parentId)->count(); + if ($remaining === 0) { + EmergencyContact::query()->where('parent_id', $parentId)->delete(); + } + } + + private function calculateAge(string $dob, string $referenceDate): int + { + try { + $dobObj = new \DateTimeImmutable($dob); + $refObj = new \DateTimeImmutable($referenceDate); + return (int) $dobObj->diff($refObj)->y; + } catch (\Throwable $e) { + return 0; + } + } +} diff --git a/app/Services/Reports/SlipPrinterConfigService.php b/app/Services/Reports/SlipPrinterConfigService.php new file mode 100644 index 00000000..3bed6d80 --- /dev/null +++ b/app/Services/Reports/SlipPrinterConfigService.php @@ -0,0 +1,52 @@ + Configuration::getConfig('school_year'), + 'semester' => Configuration::getConfig('semester'), + ]; + } + + public function currentAdminName(?int $userId): string + { + if ($userId && $userId > 0) { + $user = User::query()->select('firstname', 'lastname')->find($userId); + if ($user) { + $full = trim(($user->firstname ?? '') . ' ' . ($user->lastname ?? '')); + if ($full !== '') { + return $full; + } + } + } + + return ''; + } + + public function printerConfig(): array + { + $get = function ($keys, $default = null) { + $keys = is_array($keys) ? $keys : [$keys]; + foreach ($keys as $key) { + $val = env($key); + if ($val !== null && $val !== '') { + return $val; + } + } + return $default; + }; + + return [ + 'chars_per_line' => (int) $get(['printer.chars_per_line', 'PRINTER_CHARS_PER_LINE'], 48), + 'feed_lines' => (int) $get(['printer.feed_lines', 'PRINTER_FEED_LINES'], 3), + 'paper' => (string) $get(['printer.paper', 'SLIP_PAPER'], 'card'), + ]; + } +} diff --git a/app/Services/Reports/SlipPrinterFormatterService.php b/app/Services/Reports/SlipPrinterFormatterService.php new file mode 100644 index 00000000..fee957c4 --- /dev/null +++ b/app/Services/Reports/SlipPrinterFormatterService.php @@ -0,0 +1,80 @@ +format('Y-m-d') : null; + } + if (preg_match('/^\d{1,2}-\d{1,2}-\d{4}$/', $value)) { + $dt = \DateTime::createFromFormat('!m-d-Y', $value); + return $dt ? $dt->format('Y-m-d') : null; + } + $ts = strtotime($value); + return $ts ? date('Y-m-d', $ts) : null; + } + + public function toDbTime(?string $value): ?string + { + $value = trim((string) $value); + if ($value === '') { + return null; + } + $ts = strtotime($value); + return $ts ? date('H:i:s', $ts) : null; + } + + public function formatDisplayDate(?string $value): string + { + $value = trim((string) $value); + if ($value === '' || $value === '0000-00-00') { + return ''; + } + $ts = strtotime($value); + return $ts ? date('m/d/Y', $ts) : ''; + } + + public function formatDisplayTime(?string $value): string + { + $value = trim((string) $value); + if ($value === '') { + return ''; + } + $ts = strtotime($value); + if ($ts === false) { + $ts = strtotime('today ' . $value); + } + return $ts ? date('h:i A', $ts) : ''; + } + + public function fitLine(string $line, int $width): string + { + $line = (string) $line; + if (strlen($line) > $width) { + return substr($line, 0, $width); + } + return $line . str_repeat(' ', $width - strlen($line)); + } + + public function buildSlipLines(array $data, int $width): array + { + $lines = []; + $lines[] = $this->fitLine('Student Name: ' . (string) ($data['student_name'] ?? ''), $width); + $lines[] = $this->fitLine('Date: ' . (string) ($data['date'] ?? '') . ' Time In: ' . (string) ($data['time_in'] ?? ''), $width); + $lines[] = $this->fitLine('Grade: ' . (string) ($data['grade'] ?? ''), $width); + $lines[] = $this->fitLine('Reason: ' . (string) ($data['reason'] ?? ''), $width); + $lines[] = $this->fitLine('Admin: ' . (string) ($data['admin_name'] ?? ''), $width); + return $lines; + } +} diff --git a/app/Services/Reports/SlipPrinterPdfService.php b/app/Services/Reports/SlipPrinterPdfService.php new file mode 100644 index 00000000..2331131c --- /dev/null +++ b/app/Services/Reports/SlipPrinterPdfService.php @@ -0,0 +1,88 @@ +configService->printerConfig()['paper'] ?? 'card')); + $fontPt = (float) ($options['font_pt'] ?? env('SLIP_FONT_PT', 13.0)); + $lineH = (float) ($options['line_h'] ?? env('SLIP_LINE_H', 2.0)); + $linesCount = (int) ($options['rows'] ?? count($lines)); + if ($linesCount < 1) { + $linesCount = count($lines); + } + + $ptPerLine = $fontPt * $lineH; + $mmPerPt = 25.4 / 72.0; + $contentMm = $ptPerLine * $linesCount * $mmPerPt; + $pageMarginsMm = 6.0; + $fudgeMm = (float) ($options['fudge_mm'] ?? env('SLIP_FUDGE_MM', 12.0)); + $dynHeightMm = $contentMm + $pageMarginsMm + $fudgeMm; + + switch ($paper) { + case 'cardp': + case 'card-portrait': + $wMm = 53.98; + $hMm = max($dynHeightMm, 40.0); + break; + case 'receipt80': + $wMm = 72.0; + $hMm = 90.0; + break; + case 'receipt58': + $wMm = 58.0; + $hMm = 80.0; + break; + case 'card': + default: + $wMm = 85.60; + $hMm = max($dynHeightMm, 40.0); + } + + $hOverride = $options['height_mm'] ?? $options['h'] ?? null; + if (is_numeric($hOverride)) { + $hMm = max(30.0, min(200.0, (float) $hOverride)); + } + + if ($hMm < $dynHeightMm) { + $hMm = $dynHeightMm; + } + + $html = view('slips/slip_pdf', [ + 'entry' => $data, + 'lines' => $lines, + 'page' => ['w_mm' => $wMm, 'h_mm' => $hMm], + ]); + + $optionsObj = new Options(); + $optionsObj->set('isRemoteEnabled', true); + $optionsObj->set('defaultFont', 'Courier'); + + $dompdf = new Dompdf($optionsObj); + $dompdf->loadHtml($html, 'UTF-8'); + + $mmToPt = 72 / 25.4; + $wPt = $wMm * $mmToPt; + $hPt = $hMm * $mmToPt; + $dompdf->setPaper([0, 0, $wPt, $hPt]); + $dompdf->render(); + + $filename = 'LateSlip_' . preg_replace('/[^A-Za-z0-9]+/', '_', (string) ($data['student_name'] ?? 'slip')) . '_' . date('Ymd_His') . '.pdf'; + + return [ + 'content' => $dompdf->output(), + 'filename' => $filename, + 'width_mm' => $wMm, + 'height_mm' => $hMm, + ]; + } +} diff --git a/app/Services/Reports/SlipPrinterService.php b/app/Services/Reports/SlipPrinterService.php new file mode 100644 index 00000000..553e72b0 --- /dev/null +++ b/app/Services/Reports/SlipPrinterService.php @@ -0,0 +1,191 @@ +buildData($input, $userId); + if ($data['student_name'] === '') { + return ['ok' => false, 'message' => 'Student name is required.']; + } + + $this->logSlip($data, $userId); + + $lines = $this->formatter->buildSlipLines($data, $this->lineWidth()); + $pdf = $this->pdfService->render($data, $lines, $this->paperOptions($input)); + + return [ + 'ok' => true, + 'data' => $data, + 'pdf' => $pdf['content'], + 'filename' => $pdf['filename'], + ]; + } + + public function preview(array $input, ?int $userId): array + { + $data = $this->buildData($input, $userId); + $width = $this->lineWidth(); + $feedLines = $this->configService->printerConfig()['feed_lines'] ?? 3; + + $header = "Al Rahma School at ISGL\nSchool Year: {$data['school_year']}\n\n"; + $body = implode("\n", $this->formatter->buildSlipLines($data, $width)); + $footer = str_repeat("\n", (int) $feedLines); + + return [ + 'ok' => true, + 'text' => $header . $body . $footer, + 'width' => $width, + ]; + } + + public function logs(?string $schoolYear = null, ?string $semester = null): array + { + $query = LateSlipLog::query(); + if ($schoolYear !== null && trim($schoolYear) !== '') { + $query->where('school_year', trim($schoolYear)); + } + + $semester = strtolower(trim((string) $semester)); + if ($semester !== '') { + if ($semester === 'fall') { + $query->whereIn('semester', ['fall', '1', 1]); + } elseif ($semester === 'spring') { + $query->whereIn('semester', ['spring', '2', 2]); + } + } + + $rows = $query->orderByDesc('id')->limit(50)->get()->toArray(); + + $out = []; + foreach ($rows as $row) { + $dispDate = $this->formatter->formatDisplayDate($row['slip_date'] ?? ''); + if ($dispDate === '') { + $dispDate = $this->formatter->formatDisplayDate($row['printed_at'] ?? ''); + } + + $dispTime = $this->formatter->formatDisplayTime($row['time_in'] ?? ''); + + $out[] = [ + 'id' => (int) ($row['id'] ?? 0), + 'school_year' => (string) ($row['school_year'] ?? ''), + 'semester' => (string) ($row['semester'] ?? ''), + 'student_name' => (string) ($row['student_name'] ?? ''), + 'date' => $dispDate, + 'time_in' => $dispTime, + 'grade' => (string) ($row['grade'] ?? ''), + 'reason' => (string) ($row['reason'] ?? ''), + 'admin_name' => (string) ($row['admin_name'] ?? ''), + 'printed_at' => (string) ($row['printed_at'] ?? ''), + ]; + } + + return $out; + } + + public function reprint(int $id, ?int $userId): array + { + $row = LateSlipLog::query()->find($id); + if (!$row) { + return ['ok' => false, 'message' => 'Slip not found.']; + } + + $data = [ + 'school_year' => (string) ($row->school_year ?? ''), + 'student_name' => (string) ($row->student_name ?? ''), + 'date' => $this->formatter->formatDisplayDate($row->slip_date ?? '') ?: date('m/d/Y'), + 'time_in' => $this->formatter->formatDisplayTime($row->time_in ?? '') ?: date('h:i A'), + 'grade' => (string) ($row->grade ?? ''), + 'reason' => (string) ($row->reason ?? ''), + 'admin_name' => (string) ($row->admin_name ?? ''), + ]; + + $adminFromDb = $this->configService->currentAdminName($userId); + if ($adminFromDb !== '') { + $data['admin_name'] = $adminFromDb; + } + + $lines = $this->formatter->buildSlipLines($data, $this->lineWidth()); + $pdf = $this->pdfService->render($data, $lines, []); + + $this->logSlip($data, $userId); + + return [ + 'ok' => true, + 'data' => $data, + 'pdf' => $pdf['content'], + 'filename' => $pdf['filename'], + ]; + } + + private function buildData(array $input, ?int $userId): array + { + $context = $this->configService->context(); + $schoolYear = trim((string) ($input['school_year'] ?? $context['school_year'] ?? '')); + + $data = [ + 'school_year' => $schoolYear, + 'student_name' => trim((string) ($input['student_name'] ?? '')), + 'date' => trim((string) ($input['date'] ?? date('m/d/Y'))), + 'time_in' => trim((string) ($input['time_in'] ?? date('h:i A'))), + 'grade' => trim((string) ($input['grade'] ?? '')), + 'reason' => trim((string) ($input['reason'] ?? '')), + 'admin_name' => trim((string) ($input['admin_name'] ?? '')), + ]; + + $adminFromDb = $this->configService->currentAdminName($userId); + if ($adminFromDb !== '') { + $data['admin_name'] = $adminFromDb; + } + + return $data; + } + + private function logSlip(array $data, ?int $userId): void + { + $context = $this->configService->context(); + $logData = [ + 'school_year' => $data['school_year'], + 'semester' => (string) ($context['semester'] ?? ''), + 'student_name' => $data['student_name'], + 'slip_date' => $this->formatter->toDbDate($data['date']), + 'time_in' => $this->formatter->toDbTime($data['time_in']), + 'grade' => $data['grade'], + 'reason' => $data['reason'], + 'admin_name' => $data['admin_name'], + ]; + + LateSlipLog::logSlip($logData, $userId); + } + + private function lineWidth(): int + { + $cfg = $this->configService->printerConfig(); + $width = (int) ($cfg['chars_per_line'] ?? 48); + return $width > 0 ? $width : 48; + } + + private function paperOptions(array $input): array + { + return [ + 'paper' => $input['paper'] ?? null, + 'font_pt' => $input['font_pt'] ?? null, + 'line_h' => $input['line_h'] ?? null, + 'rows' => $input['rows'] ?? null, + 'height_mm' => $input['height_mm'] ?? null, + 'h' => $input['h'] ?? null, + 'fudge_mm' => $input['fudge_mm'] ?? null, + ]; + } +} diff --git a/app/Services/SchoolIds/SchoolIdAssignmentService.php b/app/Services/SchoolIds/SchoolIdAssignmentService.php new file mode 100644 index 00000000..c0faaba3 --- /dev/null +++ b/app/Services/SchoolIds/SchoolIdAssignmentService.php @@ -0,0 +1,17 @@ +schoolIdService->assignSchoolIdToUser($userId); + } +} diff --git a/app/Services/SchoolIds/SchoolIdGenerationService.php b/app/Services/SchoolIds/SchoolIdGenerationService.php new file mode 100644 index 00000000..79a02e11 --- /dev/null +++ b/app/Services/SchoolIds/SchoolIdGenerationService.php @@ -0,0 +1,22 @@ +schoolIdService->generateStudentSchoolId(); + } + + public function generateUserId(): string + { + return $this->schoolIdService->generateUserSchoolId(); + } +} diff --git a/app/Services/Settings/ConfigurationService.php b/app/Services/Settings/ConfigurationService.php new file mode 100644 index 00000000..3a3f5630 --- /dev/null +++ b/app/Services/Settings/ConfigurationService.php @@ -0,0 +1,49 @@ +orderBy('id') + ->get() + ->toArray(); + } + + public function store(array $payload): Configuration + { + return Configuration::query()->create([ + 'config_key' => (string) $payload['config_key'], + 'config_value' => (string) $payload['config_value'], + ]); + } + + public function update(int $id, array $payload): ?Configuration + { + $config = Configuration::query()->find($id); + if (!$config) { + return null; + } + + $config->update([ + 'config_key' => (string) $payload['config_key'], + 'config_value' => (string) $payload['config_value'], + ]); + + return $config; + } + + public function delete(int $id): bool + { + $config = Configuration::query()->find($id); + if (!$config) { + return false; + } + + return (bool) $config->delete(); + } +} diff --git a/app/Services/Students/StudentAssignmentService.php b/app/Services/Students/StudentAssignmentService.php new file mode 100644 index 00000000..998ff955 --- /dev/null +++ b/app/Services/Students/StudentAssignmentService.php @@ -0,0 +1,316 @@ +configService->context(); + $semester = (string) ($context['semester'] ?? ''); + $schoolYear = (string) ($context['school_year'] ?? ''); + + $classSectionIds = $this->normalizeIds($classSectionIds); + if ($studentId <= 0 || empty($classSectionIds) || $schoolYear === '') { + return ['ok' => false, 'message' => 'Missing required data (student/class section).']; + } + + $student = Student::query()->find($studentId); + if (!$student) { + return ['ok' => false, 'message' => 'Student not found.']; + } + + $sections = ClassSection::query() + ->whereIn('class_section_id', $classSectionIds) + ->orWhereIn('id', $classSectionIds) + ->get() + ->toArray(); + + if (empty($sections)) { + return ['ok' => false, 'message' => 'Class/Section not found.']; + } + + $sectionMap = []; + foreach ($sections as $section) { + $sectionMap[(int) ($section['class_section_id'] ?? 0)] = $section; + } + + $missing = array_diff($classSectionIds, array_keys($sectionMap)); + if (!empty($missing)) { + return ['ok' => false, 'message' => 'One or more selected classes do not exist.']; + } + + $existing = StudentClass::query() + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->get() + ->keyBy(fn ($row) => (int) ($row->class_section_id ?? 0)); + + $primarySectionId = $classSectionIds[0]; + $primarySection = $sectionMap[$primarySectionId] ?? reset($sectionMap); + $parentClassId = (int) ($primarySection['class_id'] ?? 0); + if ($parentClassId <= 0) { + $parentClassId = (int) (ClassSection::getClassId($primarySectionId) ?? 0); + } + + $attendanceStats = []; + $scoreStats = []; + + DB::transaction(function () use ( + $studentId, + $schoolYear, + $classSectionIds, + $isEventOnly, + $userId, + $existing, + $semester, + $primarySectionId, + $parentClassId, + &$attendanceStats, + &$scoreStats + ): void { + foreach ($classSectionIds as $classSectionId) { + $eventFlag = $isEventOnly ? 1 : 0; + if ($existing->has($classSectionId)) { + $eventFlag = (int) ($existing[$classSectionId]->is_event_only ?? 0); + } + + $payload = [ + 'student_id' => $studentId, + 'class_section_id' => $classSectionId, + 'school_year' => $schoolYear, + 'is_event_only' => $eventFlag, + 'updated_by' => $userId, + 'updated_at' => now(), + ]; + + if ($existing->has($classSectionId)) { + $existing[$classSectionId]->fill($payload)->save(); + } else { + StudentClass::query()->create($payload + [ + 'created_at' => now(), + ]); + } + } + + if (!$isEventOnly) { + $enrollment = Enrollment::query() + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->when($semester !== '', fn ($q) => $q->where('semester', $semester)) + ->first(); + + if ($enrollment) { + $enrollment->update([ + 'class_section_id' => $primarySectionId, + 'enrollment_status' => 'payment pending', + 'admission_status' => 'accepted', + 'updated_at' => now(), + ]); + } + + $attendanceStats = $this->updateAttendance( + $studentId, + $primarySectionId, + $parentClassId, + $semester, + $schoolYear, + $userId + ); + + $scoreStats = $this->updateScores( + $studentId, + $primarySectionId, + $semester, + $schoolYear, + $userId + ); + } + }); + + $displayNames = []; + foreach ($classSectionIds as $classSectionId) { + $section = $sectionMap[$classSectionId] ?? null; + if (!$section) { + continue; + } + $name = (string) ($section['class_section_name'] ?? ''); + if ($name === '') { + $name = 'Section #' . $classSectionId; + } + $displayNames[] = $name; + } + + return [ + 'ok' => true, + 'student_id' => $studentId, + 'class_section_id' => $primarySectionId, + 'class_section_ids' => $classSectionIds, + 'class_section_names' => $displayNames, + 'attendance_updates' => $attendanceStats, + 'score_updates' => $scoreStats, + ]; + } + + public function removeClass(int $studentId, int $classSectionId, ?int $userId = null): array + { + $context = $this->configService->context(); + $semester = (string) ($context['semester'] ?? ''); + $schoolYear = (string) ($context['school_year'] ?? ''); + + if ($studentId <= 0 || $classSectionId <= 0 || $schoolYear === '') { + return ['ok' => false, 'message' => 'Missing required data (student/class section).']; + } + + $row = StudentClass::query() + ->where('student_id', $studentId) + ->where('class_section_id', $classSectionId) + ->where('school_year', $schoolYear) + ->first(); + + if (!$row) { + return ['ok' => false, 'message' => 'Assignment not found for this student/class.']; + } + + $remainingIds = []; + $remainingNames = []; + + DB::transaction(function () use ( + $studentId, + $classSectionId, + $schoolYear, + $semester, + $userId, + &$remainingIds, + &$remainingNames + ): void { + StudentClass::query() + ->where('student_id', $studentId) + ->where('class_section_id', $classSectionId) + ->where('school_year', $schoolYear) + ->delete(); + + $remainingIds = StudentClass::getClassSectionIdsByStudentId($studentId, $schoolYear); + $remainingNames = StudentClass::getClassSectionsByStudentId($studentId, $schoolYear, true); + + $enrollment = Enrollment::query() + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->when($semester !== '', fn ($q) => $q->where('semester', $semester)) + ->first(); + + if ($enrollment) { + $newClassId = !empty($remainingIds) ? $remainingIds[0] : null; + if ((int) $enrollment->class_section_id === $classSectionId || $newClassId !== null) { + $enrollment->update([ + 'class_section_id' => $newClassId, + 'updated_at' => now(), + 'updated_by' => $userId, + ]); + } + } + }); + + return [ + 'ok' => true, + 'student_id' => $studentId, + 'removed_class_id' => $classSectionId, + 'remaining_ids' => $remainingIds, + 'remaining_names' => $remainingNames, + ]; + } + + private function updateAttendance( + int $studentId, + int $classSectionId, + int $classId, + string $semester, + string $schoolYear, + ?int $modifiedBy + ): array { + $now = now()->toDateTimeString(); + + $attendanceUpdated = DB::table('attendance_data') + ->where('student_id', $studentId) + ->where('semester', $semester) + ->where('school_year', $schoolYear) + ->update([ + 'class_section_id' => $classSectionId, + 'class_id' => $classId, + 'updated_at' => $now, + 'modified_by' => $modifiedBy, + ]); + + $recordUpdated = DB::table('attendance_record') + ->where('student_id', $studentId) + ->where('semester', $semester) + ->where('school_year', $schoolYear) + ->update([ + 'class_section_id' => $classSectionId, + 'updated_at' => $now, + 'modified_by' => $modifiedBy, + ]); + + return [ + 'attendance_data_updated' => $attendanceUpdated, + 'attendance_record_updated' => $recordUpdated, + ]; + } + + private function updateScores( + int $studentId, + int $classSectionId, + string $semester, + string $schoolYear, + ?int $modifiedBy + ): array { + $now = now()->toDateTimeString(); + $tables = [ + 'homework', + 'quiz', + 'project', + 'participation', + 'midterm_exam', + 'final_exam', + 'final_score', + 'semester_scores', + ]; + + $results = []; + foreach ($tables as $table) { + $payload = [ + 'class_section_id' => $classSectionId, + 'updated_at' => $now, + ]; + if ($modifiedBy !== null && Schema::hasColumn($table, 'updated_by')) { + $payload['updated_by'] = $modifiedBy; + } + + $results[$table . '_updated'] = DB::table($table) + ->where('student_id', $studentId) + ->where('semester', $semester) + ->where('school_year', $schoolYear) + ->update($payload); + } + + return $results; + } + + private function normalizeIds(array $ids): array + { + $values = array_map('intval', $ids); + $values = array_values(array_unique(array_filter($values, static fn ($v) => $v > 0))); + return $values; + } +} diff --git a/app/Services/Students/StudentAutoDistributionService.php b/app/Services/Students/StudentAutoDistributionService.php new file mode 100644 index 00000000..0f857b60 --- /dev/null +++ b/app/Services/Students/StudentAutoDistributionService.php @@ -0,0 +1,256 @@ +configService->context(); + $year = $schoolYear ?: (string) ($context['school_year'] ?? ''); + + $bases = ClassSection::query() + ->whereRaw("class_section_name NOT LIKE '%-%'") + ->orderBy('class_id') + ->get() + ->toArray(); + + $wanted = []; + foreach ($bases as $row) { + $nameRaw = (string) ($row['class_section_name'] ?? ''); + $name = strtolower($nameRaw); + if ($name === 'kg' || $name === 'youth') { + $wanted[] = $row; + continue; + } + if (ctype_digit($name)) { + $num = (int) $name; + if ($num >= 1 && $num <= 9) { + $wanted[] = $row; + } + } + } + + $rows = []; + foreach ($wanted as $row) { + $classId = (int) ($row['class_id'] ?? 0); + if ($classId <= 0) { + continue; + } + + $cands = DB::table('promotion_queue as pq') + ->select('pq.student_id') + ->join('enrollments as e', function ($join) use ($year) { + $join->on('e.student_id', '=', 'pq.student_id') + ->where('e.school_year', '=', $year); + }) + ->where('pq.to_class_id', $classId) + ->where('pq.school_year_to', $year) + ->whereIn('pq.status', ['queued', 'assigned', 'applied']) + ->whereIn('e.enrollment_status', ['payment pending', 'enrolled']) + ->groupBy('pq.student_id') + ->get(); + + $rows[] = [ + 'class_id' => $classId, + 'class_section_id' => (int) ($row['class_section_id'] ?? 0), + 'class_section_name' => (string) ($row['class_section_name'] ?? ''), + 'total' => $cands->count(), + ]; + } + + return [ + 'year' => $year, + 'rows' => $rows, + ]; + } + + public function autoDistribute(int $classId, int $studentsPerSection, ?string $schoolYear = null, ?int $classSectionId = null, ?int $userId = null): array + { + $context = $this->configService->context(); + $year = $schoolYear ?: (string) ($context['school_year'] ?? ''); + $semester = (string) ($context['semester'] ?? ''); + + if ($classId <= 0 && $classSectionId > 0) { + $classId = (int) (ClassSection::getClassId($classSectionId) ?? 0); + } + + if ($classId <= 0 || $studentsPerSection <= 0) { + return ['ok' => false, 'message' => 'Invalid class_id or students_per_section.']; + } + + $cands = DB::table('promotion_queue as pq') + ->select('pq.*', 'students.gender') + ->join('students', 'students.id', '=', 'pq.student_id') + ->where('pq.to_class_id', $classId) + ->where('pq.school_year_to', $year) + ->whereIn('pq.status', ['queued', 'assigned']) + ->get() + ->map(fn ($r) => (array) $r) + ->all(); + + if (empty($cands)) { + return ['ok' => false, 'message' => 'No students found in promotion queue for selected class/year.']; + } + + $studentIds = array_map(static fn ($r) => (int) $r['student_id'], $cands); + $enrolledIds = DB::table('enrollments') + ->select('student_id') + ->whereIn('student_id', $studentIds) + ->where('school_year', $year) + ->whereIn('enrollment_status', ['payment pending', 'enrolled']) + ->groupBy('student_id') + ->pluck('student_id') + ->map(fn ($v) => (int) $v) + ->all(); + + $cands = array_values(array_filter($cands, static function ($row) use ($enrolledIds) { + return in_array((int) $row['student_id'], $enrolledIds, true); + })); + + if (empty($cands)) { + return ['ok' => false, 'message' => 'No eligible enrolled students found to distribute.']; + } + + $sectionsNeeded = (int) ceil(count($cands) / $studentsPerSection); + $letters = ClassSection::getLetterSectionsByClassId($classId); + + if (empty($letters)) { + return ['ok' => false, 'message' => 'No lettered sections found for the selected class.']; + } + + if (count($letters) < $sectionsNeeded) { + return ['ok' => false, 'message' => 'Not enough sections available.']; + } + + $letters = array_slice($letters, 0, $sectionsNeeded); + $buckets = []; + foreach ($letters as $idx => $section) { + $buckets[$idx] = [ + 'class_section_id' => (int) ($section['class_section_id'] ?? 0), + 'assigned' => [], + 'male' => 0, + 'female' => 0, + ]; + } + + $males = []; + $females = []; + foreach ($cands as $row) { + $gender = (string) ($row['gender'] ?? ''); + if (strcasecmp($gender, 'Female') === 0) { + $females[] = $row; + } else { + $males[] = $row; + } + } + + $pickBucket = function (string $gender) use (&$buckets, $studentsPerSection): ?int { + $bestIdx = null; + $bestCnt = PHP_INT_MAX; + foreach ($buckets as $idx => $bucket) { + if (count($bucket['assigned']) >= $studentsPerSection) { + continue; + } + $cnt = $gender === 'female' ? $bucket['female'] : $bucket['male']; + if ($cnt < $bestCnt) { + $bestCnt = $cnt; + $bestIdx = $idx; + } + } + return $bestIdx; + }; + + foreach ($males as $row) { + $idx = $pickBucket('male'); + if ($idx === null) { + break; + } + $buckets[$idx]['assigned'][] = (int) $row['student_id']; + $buckets[$idx]['male']++; + } + + foreach ($females as $row) { + $idx = $pickBucket('female'); + if ($idx === null) { + break; + } + $buckets[$idx]['assigned'][] = (int) $row['student_id']; + $buckets[$idx]['female']++; + } + + $promoIdsByStudent = []; + foreach ($cands as $row) { + $promoIdsByStudent[(int) $row['student_id']] = (int) $row['id']; + } + + DB::transaction(function () use ($buckets, $promoIdsByStudent, $year, $semester, $userId): void { + foreach ($buckets as $bucket) { + $sectionId = (int) $bucket['class_section_id']; + foreach ($bucket['assigned'] as $studentId) { + if (isset($promoIdsByStudent[$studentId])) { + PromotionQueue::query()->where('id', $promoIdsByStudent[$studentId])->update([ + 'to_class_section_id' => $sectionId, + 'status' => 'assigned', + 'updated_by' => $userId, + 'updated_at' => now(), + ]); + } + + $exists = StudentClass::query() + ->where('student_id', $studentId) + ->where('school_year', $year) + ->when($semester !== '', fn ($q) => $q->where('semester', $semester)) + ->first(); + + $payload = [ + 'student_id' => $studentId, + 'class_section_id' => $sectionId, + 'school_year' => $year, + 'semester' => $semester, + 'updated_by' => $userId, + 'updated_at' => now(), + ]; + + if ($exists) { + $exists->update($payload); + } else { + StudentClass::query()->create($payload + ['created_at' => now()]); + } + } + } + }); + + $nameById = []; + foreach ($letters as $row) { + $nameById[(int) $row['class_section_id']] = (string) ($row['class_section_name'] ?? ''); + } + + $summary = []; + foreach ($buckets as $bucket) { + $sectionId = (int) $bucket['class_section_id']; + $summary[] = [ + 'class_section_id' => $sectionId, + 'class_section_name' => $nameById[$sectionId] ?? (string) $sectionId, + 'total' => count($bucket['assigned']), + 'male' => $bucket['male'], + 'female' => $bucket['female'], + ]; + } + + return [ + 'ok' => true, + 'message' => 'Auto distribution completed.', + 'sections' => $summary, + ]; + } +} diff --git a/app/Services/Students/StudentConfigService.php b/app/Services/Students/StudentConfigService.php new file mode 100644 index 00000000..ce766839 --- /dev/null +++ b/app/Services/Students/StudentConfigService.php @@ -0,0 +1,16 @@ + Configuration::getConfig('school_year'), + 'semester' => Configuration::getConfig('semester'), + ]; + } +} diff --git a/app/Services/Students/StudentDirectoryService.php b/app/Services/Students/StudentDirectoryService.php new file mode 100644 index 00000000..883ef6c5 --- /dev/null +++ b/app/Services/Students/StudentDirectoryService.php @@ -0,0 +1,164 @@ +configService->context(); + $schoolYear = $schoolYear ?: (string) ($context['school_year'] ?? ''); + $semester = (string) ($context['semester'] ?? ''); + + $yearsRows = DB::table('enrollments') + ->selectRaw('DISTINCT school_year') + ->orderByDesc('school_year') + ->get() + ->map(fn ($r) => (array) $r) + ->all(); + + $schoolYears = array_values(array_filter(array_map(static function ($r) { + return isset($r['school_year']) ? (string) $r['school_year'] : null; + }, $yearsRows))); + + $students = Student::query() + ->select('students.id', 'students.firstname', 'students.lastname', 'students.registration_date', 'students.is_new', 'students.age', 'students.parent_id', 'students.registration_grade') + ->join('enrollments as e', 'e.student_id', '=', 'students.id') + ->when($schoolYear !== '', fn ($q) => $q->where('e.school_year', $schoolYear)) + ->groupBy('students.id') + ->orderBy('students.lastname') + ->orderBy('students.firstname') + ->get() + ->toArray(); + + if (empty($students)) { + $students = Student::query() + ->select('students.id', 'students.firstname', 'students.lastname', 'students.registration_date', 'students.is_new', 'students.age', 'students.parent_id', 'students.registration_grade') + ->orderBy('students.lastname') + ->orderBy('students.firstname') + ->get() + ->toArray(); + } + + $studentData = []; + foreach ($students as $student) { + $studentId = (int) ($student['id'] ?? 0); + $sectionNames = StudentClass::getClassSectionsByStudentIdWithFlags($studentId, $schoolYear, true); + $sectionIds = StudentClass::getClassSectionIdsByStudentId($studentId, $schoolYear); + $sectionDisplay = !empty($sectionNames) ? implode(', ', $sectionNames) : ''; + + $parentId = (int) ($student['parent_id'] ?? 0); + $emergencyInfo = $parentId > 0 + ? (EmergencyContact::getEmergencyContactByParentId($parentId) ?? []) + : []; + + $studentData[] = [ + 'student_id' => $studentId, + 'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')), + 'age' => $student['age'] ?? 'N/A', + 'email' => $emergencyInfo['emergency_contact_name'] ?? '', + 'phone' => $emergencyInfo['cellphone'] ?? '', + 'registration_grade' => $student['registration_grade'] ?? 'N/A', + 'class_section_name' => $sectionDisplay !== '' ? $sectionDisplay : 'No class assigned', + 'class_section_names' => $sectionNames, + 'class_section_ids' => $sectionIds, + 'new_student' => ((int) ($student['is_new'] ?? 0) === 1) ? 'Yes' : 'No', + 'registration_date' => $student['registration_date'] ?? '', + 'school_year' => $schoolYear, + 'semester' => $semester, + ]; + } + + $classes = ClassSection::query() + ->select('id', 'class_section_id', 'class_section_name', 'school_year') + ->when($schoolYear !== '', fn ($q) => $q->where('school_year', $schoolYear)) + ->orderBy('class_section_name') + ->get() + ->toArray(); + + if (empty($classes)) { + $classes = ClassSection::query() + ->select('id', 'class_section_id', 'class_section_name') + ->orderBy('class_section_name') + ->get() + ->toArray(); + } + + return [ + 'students' => $studentData, + 'classes' => $classes, + 'schoolYears' => $schoolYears, + 'selectedYear' => $schoolYear, + 'currentYear' => (string) ($context['school_year'] ?? ''), + 'isCurrentYear' => $schoolYear !== '' && $schoolYear === (string) ($context['school_year'] ?? ''), + ]; + } + + public function removedStudents(?string $schoolYear = null): array + { + $context = $this->configService->context(); + $schoolYear = $schoolYear ?: (string) ($context['school_year'] ?? ''); + + $activeStudents = Student::query() + ->select('id', 'school_id', 'firstname', 'lastname', 'gender', 'age') + ->where('is_active', 1) + ->orderBy('lastname') + ->orderBy('firstname') + ->get() + ->toArray(); + + $removedStudents = Student::query() + ->select('id', 'school_id', 'firstname', 'lastname', 'gender', 'age') + ->where('is_active', 0) + ->orderBy('lastname') + ->orderBy('firstname') + ->get() + ->toArray(); + + $classRows = DB::table('student_class as sc') + ->select('sc.student_id', 'cs.class_section_name') + ->join('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id') + ->when($schoolYear !== '', fn ($q) => $q->where('sc.school_year', $schoolYear)) + ->get() + ->map(fn ($r) => (array) $r) + ->all(); + + $classMap = []; + foreach ($classRows as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + $name = trim((string) ($row['class_section_name'] ?? '')); + if ($studentId <= 0 || $name === '') { + continue; + } + $classMap[$studentId][] = $name; + } + + $attachClasses = static function (array $students) use ($classMap): array { + foreach ($students as &$student) { + $studentId = (int) ($student['id'] ?? 0); + $names = $classMap[$studentId] ?? []; + $names = array_values(array_unique(array_filter($names))); + $student['class_sections'] = $names; + $student['class_section_name'] = !empty($names) ? implode(', ', $names) : 'No class assigned'; + } + unset($student); + return $students; + }; + + return [ + 'active_students' => $attachClasses($activeStudents), + 'removed_students' => $attachClasses($removedStudents), + 'school_year' => $schoolYear, + ]; + } +} diff --git a/app/Services/Students/StudentProfileService.php b/app/Services/Students/StudentProfileService.php new file mode 100644 index 00000000..2bff3008 --- /dev/null +++ b/app/Services/Students/StudentProfileService.php @@ -0,0 +1,135 @@ +find($studentId); + if (!$student) { + return ['ok' => false, 'message' => 'Student not found.']; + } + + $studentData = array_filter([ + 'school_id' => $payload['school_id'] ?? null, + 'firstname' => $payload['firstname'] ?? null, + 'lastname' => $payload['lastname'] ?? null, + 'dob' => $payload['dob'] ?? null, + 'age' => $payload['age'] ?? null, + 'gender' => $payload['gender'] ?? null, + 'registration_grade' => $payload['registration_grade'] ?? null, + 'photo_consent' => $payload['photo_consent'] ?? null, + 'parent_id' => $payload['parent_id'] ?? null, + 'registration_date' => $payload['registration_date'] ?? null, + 'tuition_paid' => $payload['tuition_paid'] ?? null, + 'year_of_registration' => $payload['year_of_registration'] ?? null, + 'school_year' => $payload['school_year'] ?? null, + 'rfid_tag' => $payload['rfid_tag'] ?? null, + 'semester' => $payload['semester'] ?? null, + 'is_new' => $payload['is_new'] ?? null, + 'is_active' => $payload['is_active'] ?? null, + ], static fn ($value) => $value !== null); + + $conditionsInput = (string) ($payload['medical_conditions'] ?? ''); + $allergiesInput = (string) ($payload['allergies'] ?? ''); + $conditions = $this->normalizeHealthList($conditionsInput); + $allergies = $this->normalizeHealthList($allergiesInput); + $shouldSyncConditions = array_key_exists('medical_conditions', $payload); + $shouldSyncAllergies = array_key_exists('allergies', $payload); + + DB::transaction(function () use ($student, $studentData, $conditions, $allergies): void { + if (!empty($studentData)) { + $student->update($studentData); + } + + if ($shouldSyncConditions) { + $this->syncHealthList($student->id, StudentMedicalCondition::class, 'condition_name', $conditions); + } + + if ($shouldSyncAllergies) { + $this->syncHealthList($student->id, StudentAllergy::class, 'allergy', $allergies); + } + }); + + return ['ok' => true, 'message' => 'Student updated successfully.']; + } + + private function normalizeHealthList(string $value, int $maxLen = 100): array + { + $value = trim($value); + if ($value === '') { + return []; + } + + $parts = preg_split('/[,\n;]+/u', $value, -1, PREG_SPLIT_NO_EMPTY) ?: []; + $out = []; + foreach ($parts as $part) { + $item = trim(preg_replace('/\s+/u', ' ', $part)); + if ($item === '') { + continue; + } + if (preg_match('/^(none|n\/a|na|null|nil|no)$/i', $item)) { + continue; + } + $out[mb_strtolower($item, 'UTF-8')] = mb_substr($item, 0, $maxLen, 'UTF-8'); + } + + return array_values($out); + } + + private function syncHealthList(int $studentId, string $modelClass, string $field, array $newValues): void + { + $model = new $modelClass(); + $rows = $model->newQuery()->where('student_id', $studentId)->get(['id', $field])->toArray(); + + $current = []; + $byId = []; + foreach ($rows as $row) { + $val = (string) ($row[$field] ?? ''); + $key = mb_strtolower(trim($val), 'UTF-8'); + if ($key === '') { + continue; + } + $current[$key] = $val; + $byId[$key] = (int) ($row['id'] ?? 0); + } + + $incoming = []; + foreach ($newValues as $value) { + $key = mb_strtolower(trim($value), 'UTF-8'); + if ($key === '') { + continue; + } + $incoming[$key] = $value; + } + + $toDeleteKeys = array_diff(array_keys($current), array_keys($incoming)); + $toInsertKeys = array_diff(array_keys($incoming), array_keys($current)); + + if (!empty($toDeleteKeys)) { + $ids = array_map(fn ($key) => $byId[$key], $toDeleteKeys); + if (!empty($ids)) { + $model->newQuery()->whereIn('id', $ids)->delete(); + } + } + + if (!empty($toInsertKeys)) { + $batch = []; + foreach ($toInsertKeys as $key) { + $batch[] = [ + 'student_id' => $studentId, + $field => $incoming[$key], + ]; + } + if (!empty($batch)) { + $model->newQuery()->insert($batch); + } + } + } +} diff --git a/app/Services/Students/StudentScoreCardService.php b/app/Services/Students/StudentScoreCardService.php new file mode 100644 index 00000000..0f706ae6 --- /dev/null +++ b/app/Services/Students/StudentScoreCardService.php @@ -0,0 +1,113 @@ +select('id', 'firstname', 'lastname', 'school_id') + ->where('id', $studentId) + ->first(); + + if (!$student) { + return ['ok' => false, 'message' => 'Student not found.']; + } + + $rows = DB::table('semester_scores as ss') + ->select([ + 'ss.school_year', + 'ss.semester', + 'ss.homework_avg', + 'ss.project_avg', + 'ss.participation_score', + 'ss.quiz_avg', + 'ss.test_avg', + 'ss.attendance_score', + 'ss.ptap_score', + 'ss.midterm_exam_score', + 'ss.semester_score', + 'cs.class_section_name', + ]) + ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id') + ->where('ss.student_id', $studentId) + ->get() + ->map(fn ($r) => (array) $r) + ->all(); + + usort($rows, static function (array $a, array $b): int { + $ay = (string) ($a['school_year'] ?? ''); + $by = (string) ($b['school_year'] ?? ''); + if ($ay !== $by) { + return strcmp($by, $ay); + } + return strcmp((string) ($a['semester'] ?? ''), (string) ($b['semester'] ?? '')); + }); + + $yearScoreMap = []; + foreach ($rows as $row) { + $year = (string) ($row['school_year'] ?? ''); + $score = $row['semester_score'] ?? null; + if ($year === '' || !is_numeric($score)) { + continue; + } + $yearScoreMap[$year][] = (float) $score; + } + + $yearAvg = []; + foreach ($yearScoreMap as $year => $scores) { + $yearAvg[$year] = round(array_sum($scores) / count($scores), 1); + } + + $commentRows = DB::table('score_comments') + ->select('school_year', 'semester', 'score_type', 'comment', 'comment_review') + ->where('student_id', $studentId) + ->orderByDesc('created_at') + ->get() + ->map(fn ($r) => (array) $r) + ->all(); + + $commentMap = []; + foreach ($commentRows as $row) { + $year = (string) ($row['school_year'] ?? ''); + $semester = strtolower(trim((string) ($row['semester'] ?? ''))); + $type = strtolower(trim((string) ($row['score_type'] ?? ''))); + if ($year === '' || $type === '') { + continue; + } + if ($type === 'attendance_comment') { + $type = 'attendance'; + } + $commentVal = trim((string) ($row['comment'] ?? '')); + $reviewVal = trim((string) ($row['comment_review'] ?? '')); + if (in_array($type, ['midterm', 'final', 'ptap'], true)) { + $commentVal = $reviewVal !== '' ? $reviewVal : $commentVal; + } elseif ($type === 'attendance' && $commentVal === '' && $reviewVal !== '') { + $commentVal = $reviewVal; + } + if ($commentVal === '') { + continue; + } + $key = $year . '|' . $semester; + $commentMap[$key][$type] = $commentVal; + } + + foreach ($rows as &$row) { + $year = (string) ($row['school_year'] ?? ''); + $semester = strtolower(trim((string) ($row['semester'] ?? ''))); + $row['comments'] = $commentMap[$year . '|' . $semester] ?? []; + $row['year_score'] = $yearAvg[$year] ?? null; + } + unset($row); + + return [ + 'ok' => true, + 'student' => $student->toArray(), + 'rows' => $rows, + ]; + } +} diff --git a/app/Services/Students/StudentStatusService.php b/app/Services/Students/StudentStatusService.php new file mode 100644 index 00000000..86b272f6 --- /dev/null +++ b/app/Services/Students/StudentStatusService.php @@ -0,0 +1,76 @@ +configService->context(); + $semester = (string) ($context['semester'] ?? ''); + $schoolYear = (string) ($context['school_year'] ?? ''); + + $student = Student::query()->find($studentId); + if (!$student) { + return ['ok' => false, 'message' => 'Student not found.']; + } + + $student->update([ + 'is_active' => $isActive ? 1 : 0, + ]); + + $message = $isActive ? 'Student restored successfully.' : 'Student removed successfully.'; + + if ($isActive) { + $hasCurrentClass = StudentClass::query() + ->where('student_id', $studentId) + ->when($semester !== '', fn ($q) => $q->where('semester', $semester)) + ->when($schoolYear !== '', fn ($q) => $q->where('school_year', $schoolYear)) + ->first(); + + if (!$hasCurrentClass) { + $lastClass = StudentClass::query() + ->where('student_id', $studentId) + ->orderByDesc('updated_at') + ->orderByDesc('created_at') + ->orderByDesc('id') + ->first(); + + $restoreClassId = (int) ($lastClass?->class_section_id ?? 0); + if ($restoreClassId > 0) { + StudentClass::query()->create([ + 'student_id' => $studentId, + 'class_section_id' => $restoreClassId, + 'semester' => $semester, + 'school_year' => $schoolYear, + 'description' => $lastClass?->description, + 'updated_by' => $userId, + 'updated_at' => now(), + 'created_at' => now(), + ]); + + $classLabel = ClassSection::getClassSectionNameBySectionId($restoreClassId); + if ($classLabel) { + $message .= ' Class assignment restored to ' . $classLabel . '.'; + } + } else { + $message .= ' No class assignment found for the current term.'; + } + } + } + + return [ + 'ok' => true, + 'message' => $message, + 'is_active' => $isActive, + ]; + } +} diff --git a/app/Services/Subjects/SubjectCurriculumService.php b/app/Services/Subjects/SubjectCurriculumService.php new file mode 100644 index 00000000..9e76c63c --- /dev/null +++ b/app/Services/Subjects/SubjectCurriculumService.php @@ -0,0 +1,79 @@ + 'Islamic Studies', + 'quran' => 'Quran/Arabic', + ]; + + public function list(): array + { + $classes = SchoolClass::query() + ->orderBy('class_name') + ->get() + ->toArray(); + + $entries = DB::table('subject_curriculum_items as sci') + ->leftJoin('classes', 'classes.id', '=', 'sci.class_id') + ->select('sci.*', 'classes.class_name') + ->orderBy('classes.class_name') + ->orderBy('sci.subject') + ->orderBy('sci.unit_number') + ->orderBy('sci.chapter_name') + ->get() + ->map(fn ($r) => (array) $r) + ->all(); + + return [ + 'classes' => $classes, + 'entries' => $entries, + 'subject_labels' => self::SUBJECT_OPTIONS, + ]; + } + + public function store(array $payload): SubjectCurriculum + { + return SubjectCurriculum::query()->create([ + 'class_id' => (int) $payload['class_id'], + 'subject' => (string) $payload['subject'], + 'unit_number' => $payload['unit_number'] !== null ? (int) $payload['unit_number'] : null, + 'unit_title' => $payload['unit_title'] !== '' ? $payload['unit_title'] : null, + 'chapter_name' => (string) $payload['chapter_name'], + ]); + } + + public function update(int $id, array $payload): ?SubjectCurriculum + { + $entry = SubjectCurriculum::query()->find($id); + if (!$entry) { + return null; + } + + $entry->update([ + 'class_id' => (int) $payload['class_id'], + 'subject' => (string) $payload['subject'], + 'unit_number' => $payload['unit_number'] !== null ? (int) $payload['unit_number'] : null, + 'unit_title' => $payload['unit_title'] !== '' ? $payload['unit_title'] : null, + 'chapter_name' => (string) $payload['chapter_name'], + ]); + + return $entry; + } + + public function delete(int $id): bool + { + $entry = SubjectCurriculum::query()->find($id); + if (!$entry) { + return false; + } + + return (bool) $entry->delete(); + } +} diff --git a/app/Services/Teachers/TeacherAbsenceService.php b/app/Services/Teachers/TeacherAbsenceService.php new file mode 100644 index 00000000..b7328f8a --- /dev/null +++ b/app/Services/Teachers/TeacherAbsenceService.php @@ -0,0 +1,290 @@ +configService->context(); + $semester = (string) ($context['semester'] ?? ''); + $schoolYear = (string) ($context['school_year'] ?? ''); + + $teacher = User::query()->find($userId); + $displayName = $teacher + ? trim(($teacher->firstname ?? '') . ' ' . ($teacher->lastname ?? '')) + : 'Teacher'; + + $existing = StaffAttendance::query() + ->where('user_id', $userId) + ->when($semester !== '', fn ($q) => $q->where('semester', $semester)) + ->when($schoolYear !== '', fn ($q) => $q->where('school_year', $schoolYear)) + ->orderByDesc('date') + ->get() + ->toArray(); + + return [ + 'teacher_name' => $displayName, + 'semester' => $semester, + 'school_year' => $schoolYear, + 'existing' => $existing, + 'available_dates' => $this->allowedAbsenceDates($schoolYear), + ]; + } + + public function submit(int $userId, array $payload): array + { + $context = $this->configService->context(); + $semester = (string) ($context['semester'] ?? ''); + $schoolYear = (string) ($context['school_year'] ?? ''); + + if ($schoolYear === '') { + return [ + 'ok' => false, + 'message' => 'Semester or school year not configured.', + 'status' => 422, + ]; + } + + $dates = (array) ($payload['dates'] ?? []); + $reasonType = trim((string) ($payload['reason_type'] ?? '')); + $reasonText = trim((string) ($payload['reason'] ?? '')); + + if ($reasonText === '') { + return [ + 'ok' => false, + 'message' => 'Reason is required.', + 'status' => 422, + ]; + } + + $reasonBase = $reasonType !== '' ? strtolower($reasonType) : ''; + $reason = $reasonBase !== '' ? ($reasonBase . ': ' . $reasonText) : $reasonText; + + $allowedSet = array_fill_keys($this->allowedAbsenceDates($schoolYear), true); + $roleName = User::getUserRoleName($userId) ?: null; + + $saved = 0; + $invalid = []; + $savedDates = []; + + $dates = array_values(array_unique(array_map('strval', $dates))); + + foreach ($dates as $date) { + $date = trim($date); + if ($date === '') { + continue; + } + + try { + $parsed = Carbon::createFromFormat('Y-m-d', $date); + if ($parsed->format('Y-m-d') !== $date || empty($allowedSet[$date])) { + $invalid[] = $date; + continue; + } + } catch (\Throwable) { + $invalid[] = $date; + continue; + } + + $semesterForDate = $this->semesterRangeService->getSemesterForDate($date); + if ($semesterForDate === '') { + $semesterForDate = $semester; + } + + $record = StaffAttendance::upsertOne( + userId: $userId, + roleName: $roleName, + date: $date, + semester: $semesterForDate, + schoolYear: $schoolYear, + status: StaffAttendance::STATUS_ABSENT, + reason: $reason, + editorId: $userId + ); + + if ($record) { + $saved++; + $savedDates[] = $date; + } + } + + if (!empty($invalid)) { + return [ + 'ok' => false, + 'message' => 'Invalid dates: ' . implode(', ', $invalid), + 'status' => 422, + ]; + } + + $this->sendPrincipalNotification( + userId: $userId, + roleName: $roleName, + semester: $semester, + schoolYear: $schoolYear, + reasonType: $reasonType, + reasonText: $reasonText, + dates: $dates, + savedDates: $savedDates + ); + + return [ + 'ok' => true, + 'message' => $saved . ' day(s) saved as absent.', + 'saved' => $saved, + 'dates' => $savedDates, + 'status' => 200, + ]; + } + + private function allowedAbsenceDates(string $schoolYear): array + { + $today = Carbon::today(); + $startYear = null; + $endYear = null; + + if (preg_match('/^(\d{4})\D+(\d{4})$/', $schoolYear, $m)) { + $startYear = (int) $m[1]; + $endYear = (int) $m[2]; + } else { + $cy = (int) now()->format('Y'); + $cm = (int) now()->format('n'); + if ($cm >= 9) { + $startYear = $cy; + $endYear = $cy + 1; + } else { + $startYear = $cy - 1; + $endYear = $cy; + } + } + + try { + $start = Carbon::create($startYear, 9, 1)->startOfDay(); + $end = Carbon::create($endYear, 5, 31)->startOfDay(); + } catch (\Throwable) { + return []; + } + + if ($start->lt($today)) { + $start = $today->copy(); + } + + $dates = []; + $cursor = $start->copy(); + + while ($cursor->lte($end)) { + if ($cursor->dayOfWeek === Carbon::SUNDAY) { + $dates[] = $cursor->format('Y-m-d'); + } + $cursor->addDay(); + } + + return $dates; + } + + private function sendPrincipalNotification( + int $userId, + ?string $roleName, + string $semester, + string $schoolYear, + string $reasonType, + string $reasonText, + array $dates, + array $savedDates + ): void { + try { + $user = User::query()->find($userId); + $fullName = trim(($user->firstname ?? '') . ' ' . ($user->lastname ?? '')) ?: 'Teacher'; + $userEmail = $user->email ?? ''; + $role = $roleName ?: 'teacher'; + $dateList = !empty($savedDates) ? implode(', ', $savedDates) : implode(', ', $dates); + + $subject = sprintf( + 'TimeOff Request: %s (%s) — %s', + $fullName, + ucfirst((string) $role), + $dateList ?: 'No dates' + ); + + $submittedAt = now()->toDateTimeString(); + $assignedText = $this->formatAssignedClasses($userId, $schoolYear, $semester); + + $body = '
' + . '

New TimeOff Request

' + . '

A staff time-off request was submitted from the teacher portal.

' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '
Name' . e($fullName) . '
Email' . e($userEmail) . '
Role' . e((string) $role) . '
Semester' . e($semester) . '
School Year' . e($schoolYear) . '
Reason Type' . e($reasonType ?: '-') . '
Reason' . e($reasonText) . '
Dates' . e($dateList ?: '-') . '
Assigned Class(es)' . e($assignedText) . '
Submitted At' . e($submittedAt) . '
' + . '
'; + + $token = $this->staffTimeOffLinkService->createToken([ + 'uid' => $userId, + 'email' => $userEmail, + 'name' => $fullName, + 'role' => $role, + 'dates' => $dateList ?: '-', + 'reason' => $reasonText, + 'reason_type' => $reasonType, + 'submitted_at' => $submittedAt, + 'origin' => 'teacher portal', + ]); + + $notifyUrl = url('/timeoff/notify/' . rawurlencode($token)); + $body .= '

Click Send confirmation email to ' + . e($fullName) + . ' so the staff member is notified automatically. This link expires in 14 days.

'; + + $principalEmail = env('PRINCIPAL_EMAIL', 'principal@alrahmaisgl.org'); + + Mail::html($body, function ($message) use ($principalEmail, $subject) { + $message->to($principalEmail) + ->subject($subject); + }); + } catch (\Throwable) { + // Email failures should not block absence submissions. + } + } + + private function formatAssignedClasses(int $userId, string $schoolYear, string $semester): string + { + $assignments = TeacherClass::getClassAssignmentsByUserId($userId, $schoolYear, $semester); + if (empty($assignments)) { + return 'No assigned class'; + } + + $parts = []; + foreach ($assignments as $assignment) { + $name = (string) ($assignment['class_section_name'] ?? ''); + $role = (string) ($assignment['teacher_role'] ?? ''); + if ($name !== '') { + $parts[] = $role !== '' ? ($name . ' (' . $role . ')') : $name; + } + } + + return !empty($parts) ? implode(', ', $parts) : 'No assigned class'; + } +} diff --git a/app/Services/Teachers/TeacherAssignmentService.php b/app/Services/Teachers/TeacherAssignmentService.php new file mode 100644 index 00000000..e3276e34 --- /dev/null +++ b/app/Services/Teachers/TeacherAssignmentService.php @@ -0,0 +1,186 @@ +configService->context(); + $schoolYear = $schoolYear ?: (string) ($context['school_year'] ?? ''); + + $teachers = User::getTeachersAndTAs(); + $classSections = ClassSection::query() + ->when($schoolYear !== '', fn ($q) => $q->where('school_year', $schoolYear)) + ->orderBy('class_section_name') + ->get() + ->toArray(); + + if (empty($classSections)) { + $classSections = ClassSection::query()->orderBy('class_section_name')->get()->toArray(); + } + + $classNames = []; + foreach ($classSections as $section) { + $sectionId = (int) ($section['class_section_id'] ?? $section['id'] ?? 0); + if ($sectionId <= 0) { + continue; + } + $classNames[$sectionId] = $section['class_section_name'] ?? 'N/A'; + } + + $assignments = TeacherClass::query() + ->when($schoolYear !== '', fn ($q) => $q->where('school_year', $schoolYear)) + ->get() + ->toArray(); + + $assignmentMap = []; + foreach ($assignments as $assign) { + $teacherId = (int) ($assign['teacher_id'] ?? 0); + $sectionId = (int) ($assign['class_section_id'] ?? 0); + if ($teacherId <= 0 || $sectionId <= 0) { + continue; + } + + $role = strtolower((string) ($assign['position'] ?? '')); + if (!in_array($role, ['main', 'ta'], true)) { + continue; + } + + $assignmentMap[$teacherId][$role][] = [ + 'section_id' => $sectionId, + 'class_section_name' => $classNames[$sectionId] ?? 'N/A', + 'role' => $role, + 'semester' => (string) ($assign['semester'] ?? ''), + 'school_year' => (string) ($assign['school_year'] ?? $schoolYear), + ]; + } + + $teacherData = []; + foreach ($teachers as $teacher) { + $tid = (int) ($teacher['id'] ?? 0); + if ($tid <= 0) { + continue; + } + $assigned = $assignmentMap[$tid] ?? ['main' => [], 'ta' => []]; + $name = trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? '')); + if ($name === '') { + $name = 'User#' . $tid; + } + + $teacherData[] = [ + 'teacher_id' => $tid, + 'firstname' => $teacher['firstname'] ?? '', + 'lastname' => $teacher['lastname'] ?? '', + 'name' => $name, + 'email' => $teacher['email'] ?? '', + 'cellphone' => $teacher['cellphone'] ?? '', + 'role' => $teacher['role'] ?? '', + 'school_year' => $schoolYear, + 'main_assignments' => $assigned['main'] ?? [], + 'ta_assignments' => $assigned['ta'] ?? [], + ]; + } + + $classesPayload = []; + foreach ($classSections as $section) { + $sectionId = (int) ($section['class_section_id'] ?? $section['id'] ?? 0); + if ($sectionId <= 0) { + continue; + } + $classesPayload[] = [ + 'class_section_id' => $sectionId, + 'class_section_name' => $section['class_section_name'] ?? 'N/A', + ]; + } + + return [ + 'school_year' => $schoolYear, + 'teachers' => $teacherData, + 'classes' => $classesPayload, + ]; + } + + public function assign(array $input): array + { + $context = $this->configService->context(); + $schoolYear = (string) ($input['school_year'] ?? $context['school_year'] ?? ''); + $teacherId = (int) ($input['teacher_id'] ?? 0); + $classSectionId = (int) ($input['class_section_id'] ?? 0); + $teacherRole = (string) ($input['teacher_role'] ?? ''); + $loggedInUserId = (int) ($input['updated_by'] ?? 0); + + if ($teacherId <= 0 || $classSectionId <= 0 || $schoolYear === '') { + return ['ok' => false, 'message' => 'Missing required parameters for assignment.']; + } + + if ($teacherRole === '') { + $teacherRole = (string) (User::getUserRoleName($teacherId) ?? ''); + } + $isAssistant = strtolower($teacherRole) === 'teacher_assistant'; + $position = $isAssistant ? 'ta' : 'main'; + + $alreadyAssigned = TeacherClass::query() + ->where('teacher_id', $teacherId) + ->where('class_section_id', $classSectionId) + ->where('position', $position) + ->where('school_year', $schoolYear) + ->first(); + + if ($alreadyAssigned) { + return ['ok' => false, 'message' => 'This teacher is already assigned to this class with the same role.']; + } + + TeacherClass::query()->create([ + 'teacher_id' => $teacherId, + 'class_section_id' => $classSectionId, + 'position' => $position, + 'school_year' => $schoolYear, + 'created_at' => now(), + 'updated_at' => now(), + 'updated_by' => $loggedInUserId ?: null, + ]); + + return [ + 'ok' => true, + 'message' => 'Class assigned successfully.', + 'position' => $position, + ]; + } + + public function delete(array $input): array + { + $context = $this->configService->context(); + $teacherId = (int) ($input['teacher_id'] ?? 0); + $classSectionId = (int) ($input['class_section_id'] ?? 0); + $position = strtolower((string) ($input['position'] ?? '')); + $schoolYear = trim((string) ($input['school_year'] ?? $context['school_year'] ?? '')); + + if ($teacherId <= 0 || $classSectionId <= 0 || !in_array($position, ['main', 'ta'], true) || $schoolYear === '') { + return ['ok' => false, 'message' => 'Missing or invalid data for deletion.']; + } + + $assignment = TeacherClass::query() + ->where('teacher_id', $teacherId) + ->where('class_section_id', $classSectionId) + ->where('school_year', $schoolYear) + ->where('position', $position) + ->first(); + + if (!$assignment) { + return ['ok' => false, 'message' => 'Assignment not found.']; + } + + $assignment->delete(); + + return ['ok' => true, 'message' => ucfirst($position) . ' assignment removed successfully.']; + } +} diff --git a/app/Services/Teachers/TeacherConfigService.php b/app/Services/Teachers/TeacherConfigService.php new file mode 100644 index 00000000..6cfb1816 --- /dev/null +++ b/app/Services/Teachers/TeacherConfigService.php @@ -0,0 +1,16 @@ + Configuration::getConfig('school_year'), + 'semester' => Configuration::getConfig('semester'), + ]; + } +} diff --git a/app/Services/Teachers/TeacherDashboardService.php b/app/Services/Teachers/TeacherDashboardService.php new file mode 100644 index 00000000..36350055 --- /dev/null +++ b/app/Services/Teachers/TeacherDashboardService.php @@ -0,0 +1,130 @@ +configService->context(); + $schoolYear = (string) ($context['school_year'] ?? ''); + $semester = (string) ($context['semester'] ?? ''); + + $roles = DB::table('user_roles as ur') + ->join('roles as r', 'r.id', '=', 'ur.role_id') + ->where('ur.user_id', $userId) + ->whereNull('ur.deleted_at') + ->pluck('r.name') + ->map(fn ($r) => strtolower((string) $r)) + ->all(); + + if (!array_intersect($roles, ['teacher', 'teacher_assistant'])) { + throw new \RuntimeException('Access denied.'); + } + + $classes = TeacherClass::getClassAssignmentsByUserId($userId, $schoolYear, $semester); + if (empty($classes)) { + return [ + 'teacher' => null, + 'classes' => [], + 'students' => [], + 'studentsBySection' => [], + 'active_class_section_id' => null, + 'assignedNames' => [], + ]; + } + + $classSectionIds = array_values(array_unique(array_map('intval', array_column($classes, 'class_section_id')))); + $activeClassSectionId = null; + if ($requestedClassSectionId && in_array($requestedClassSectionId, $classSectionIds, true)) { + $activeClassSectionId = $requestedClassSectionId; + } elseif (!empty($classSectionIds)) { + $activeClassSectionId = $classSectionIds[0]; + } + + $studentsBySection = []; + if (!empty($classSectionIds)) { + $students = StudentClass::getStudentsByClassSectionIds($classSectionIds); + if (!empty($students)) { + $studentIds = array_values(array_unique(array_map( + static fn (array $row) => (int) ($row['student_id'] ?? 0), + $students + ))); + $studentIds = array_values(array_filter($studentIds)); + + $conditionsByStudent = !empty($studentIds) + ? StudentMedicalCondition::getMedicalByStudentIds($studentIds) + : []; + $allergiesByStudent = !empty($studentIds) + ? StudentAllergy::getAllergiesByStudentIds($studentIds) + : []; + + foreach ($students as $student) { + $sid = (int) ($student['student_id'] ?? 0); + $cid = (int) ($student['class_section_id'] ?? 0); + + $medList = array_column($conditionsByStudent[$sid] ?? [], 'condition_name'); + $algList = array_column($allergiesByStudent[$sid] ?? [], 'allergy'); + + $student['medical_conditions'] = $medList; + $student['allergies'] = $algList; + $student['medical_conditions_text'] = implode(', ', $medList); + $student['allergies_text'] = implode(', ', $algList); + + $studentsBySection[$cid][] = $student; + } + } + } + + $assignedNames = []; + if (!empty($classSectionIds)) { + $rows = DB::table('teacher_class as tc') + ->select('tc.class_section_id', 'tc.position', 'u.firstname', 'u.lastname') + ->join('users as u', 'u.id', '=', 'tc.teacher_id') + ->whereIn('tc.class_section_id', $classSectionIds) + ->where('tc.school_year', $schoolYear) + ->orderBy('tc.class_section_id') + ->orderByRaw("FIELD(tc.position,'main','ta')") + ->orderBy('u.firstname') + ->get(); + + foreach ($rows as $row) { + $sid = (int) ($row->class_section_id ?? 0); + $pos = strtolower((string) ($row->position ?? '')); + $name = trim(($row->firstname ?? '') . ' ' . ($row->lastname ?? '')); + if ($sid === 0 || $name === '') { + continue; + } + if (in_array($pos, ['main', 'teacher'], true)) { + $assignedNames[$sid]['mains'][] = $name; + } elseif (in_array($pos, ['ta', 'assistant', 'teacher_assistant'], true)) { + $assignedNames[$sid]['tas'][] = $name; + } else { + $assignedNames[$sid]['others'][] = $name; + } + } + } + + $teacher = User::query()->find($userId)?->toArray(); + + return [ + 'teacher' => $teacher, + 'classes' => $classes, + 'students' => $activeClassSectionId ? ($studentsBySection[$activeClassSectionId] ?? []) : [], + 'studentsBySection' => $studentsBySection, + 'active_class_section_id' => $activeClassSectionId, + 'assignedNames' => $assignedNames, + ]; + } +} diff --git a/app/old/ConfigurationController.php b/app/old/ConfigurationController.php deleted file mode 100644 index 4d05c59f..00000000 --- a/app/old/ConfigurationController.php +++ /dev/null @@ -1,96 +0,0 @@ -configModel = new ConfigurationModel(); - } - - // Method to load configuration management page - public function index() - { - helper('url'); - return view('configuration/configuration_view', [ - 'configEndpoint' => site_url('api/configuration'), - ]); - } - - public function addConfig() - { - // Retrieve POST data - $configKey = $this->request->getPost('config_key'); - $configValue = $this->request->getPost('config_value'); - - // Validate inputs (optional) - if ($configKey && $configValue) { - // Save to the database - $this->configModel->save([ - 'config_key' => $configKey, - 'config_value' => $configValue, - ]); - - // Redirect to the configuration view page - return redirect()->to('/configuration/configuration_view')->with('success', 'Configuration added.'); - } - - // If validation fails, reload the form with input - return redirect()->back()->withInput()->with('error', 'Please fill in all required fields.'); - } - - - // Method to edit an existing configuration - public function editConfig($id) - { - $config = $this->configModel->find($id); - - if (strtolower($this->request->getMethod()) === 'post') { - $key = trim((string) $this->request->getPost('config_key')); - $value = trim((string) $this->request->getPost('config_value')); - - $this->configModel->update($id, [ - 'config_key' => $key, - 'config_value' => $value - ]); - return redirect()->to('/configuration/configuration_view')->with('success', 'Configuration updated.'); - } - return view('configuration/configuration_edit', ['config' => $config]); - } - - public function deleteConfig($id) - { - if ($this->configModel->find($id)) { - $this->configModel->delete($id); - return redirect()->to('/configuration/configuration_view')->with('success', 'Configuration deleted successfully.'); - } - - return redirect()->to('/configuration/configuration_view')->with('error', 'Configuration not found.'); - } - - public function listData() - { - $rows = $this->configModel - ->orderBy('id', 'ASC') - ->findAll(); - - $configs = array_map(static function ($row) { - if (!is_array($row)) { - return []; - } - return [ - 'id' => (int) ($row['id'] ?? 0), - 'config_key' => (string) ($row['config_key'] ?? ''), - 'config_value' => (string) ($row['config_value'] ?? ''), - ]; - }, $rows ?? []); - - return $this->response->setJSON(['configs' => $configs]); - } -} diff --git a/app/old/EmergencyContactController.php b/app/old/EmergencyContactController.php deleted file mode 100644 index 4a1c48aa..00000000 --- a/app/old/EmergencyContactController.php +++ /dev/null @@ -1,135 +0,0 @@ -contactModel = new EmergencyContactModel(); - $this->studentModel = new StudentModel(); - $this->userModel = new UserModel(); // Add this - } - - public function index() - { - $db = \Config\Database::connect(); - $parentIds = $this->contactModel - ->distinct() - ->select('parent_id') - ->findAll(); - - $data = []; - - foreach ($parentIds as $row) { - $parentId = $row['parent_id']; - - $parent = $this->userModel->find($parentId); // Get parent info - $parentName = $parent ? $parent['firstname'] . ' ' . $parent['lastname'] : 'Unknown Parent'; - $parentPhone = is_array($parent) ? (string)($parent['cellphone'] ?? '') : ''; - - // Try to load second parent phone from parents table (if available) - $secondPhone = ''; - try { - $row = $db->table('parents') - ->select('secondparent_phone') - ->where('firstparent_id', (int) $parentId) - ->orderBy('updated_at', 'DESC') - ->get()->getRowArray(); - if ($row && !empty($row['secondparent_phone'])) { - $secondPhone = (string) $row['secondparent_phone']; - } - } catch (\Throwable $e) { - log_message('debug', 'EmergencyContactController: could not load second parent phone: ' . $e->getMessage()); - } - - $students = $this->studentModel - ->where('parent_id', $parentId) - ->findAll(); - - $contacts = $this->contactModel - ->getEmergencyContactsByParentId($parentId); - - $data[] = [ - 'parent_id' => $parentId, - 'parent_name' => $parentName, - 'students' => $students, - 'contacts' => $contacts, - 'parent_phones' => array_values(array_filter([$parentPhone, $secondPhone], static fn($v) => (string)$v !== '')), - ]; - } - - return view('administrator/emergency_contact/index', ['groups' => $data]); - } - - - public function edit($id) - { - $contact = $this->contactModel->find($id); - return view('administrator/emergency_contact/edit', ['contact' => $contact]); - } - -public function update($id) -{ - dd("Update was called with ID: $id", $this->request->getPost()); -} - - - - public function delete($id) - { - $this->contactModel->delete($id); - return redirect()->to('/administrator/emergency_contact')->with('success', 'Contact deleted.'); - } - - // API: JSON payload for emergency contacts grouped by parent - public function data() - { - // Build groups similarly to index(), but return JSON - $parentRows = $this->contactModel - ->distinct() - ->select('parent_id') - ->findAll(); - - $groups = []; - foreach ($parentRows as $row) { - $parentId = (int)($row['parent_id'] ?? 0); - if ($parentId <= 0) continue; - - $parent = $this->userModel->find($parentId) ?: []; - $parentName = trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? '')) ?: 'Unknown Parent'; - - $students = $this->studentModel - ->select('id, firstname, lastname, school_id') - ->where('parent_id', $parentId) - ->findAll(); - - $contacts = $this->contactModel - ->where('parent_id', $parentId) - ->orderBy('updated_at', 'DESC') - ->findAll(); - - $groups[] = [ - 'parent_id' => $parentId, - 'parent_name' => $parentName, - 'students' => $students, - 'contacts' => $contacts, - ]; - } - - return $this->response->setJSON([ - 'groups' => $groups, - 'csrfHash' => csrf_hash(), - ]); - } -} diff --git a/app/old/ExamDraftController.php b/app/old/ExamDraftController.php deleted file mode 100644 index f95e7a73..00000000 --- a/app/old/ExamDraftController.php +++ /dev/null @@ -1,605 +0,0 @@ -examDraftModel = new ExamDraftModel(); - $this->teacherClassModel = new TeacherClassModel(); - $this->classSectionModel = new ClassSectionModel(); - $this->userModel = new UserModel(); - $this->configModel = new ConfigurationModel(); - $this->db = Database::connect(); - - $this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? ''); - $this->semester = (string) ($this->configModel->getConfig('semester') ?? ''); - $this->hasFinalPdfColumn = $this->schemaHasColumn('exam_drafts', 'final_pdf_file'); - $this->hasIsLegacyColumn = $this->schemaHasColumn('exam_drafts', 'is_legacy'); - - helper(['form', 'url', 'date']); - } - - public function teacherIndex() - { - $teacherId = (int) (session()->get('user_id') ?? 0); - if ($teacherId <= 0) { - return redirect()->to('/login'); - } - - $assignments = $this->teacherClassModel->getClassAssignmentsByUserId($teacherId, $this->schoolYear); - $selectedClass = $this->resolveSelectedClassSection($assignments); - if ($selectedClass > 0) { - session()->set('class_section_id', $selectedClass); - } - - $allDrafts = $this->examDraftModel - ->where('teacher_id', $teacherId) - ->orderBy('created_at', 'DESC') - ->findAll(); - - foreach ($allDrafts as &$row) { - if (empty($row['final_pdf_file'])) { - $pdf = $this->ensurePdfExists($row['final_file'] ?? '', pathinfo($row['final_file'] ?? '', PATHINFO_EXTENSION)); - if ($pdf !== null) { - $row['final_pdf_file'] = $pdf; - } - } - } - 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; - - 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(); - } - } else { - // Legacy column absent, show all drafts and skip legacy tab query - $drafts = $allDrafts; - } - - $validation = session()->getFlashdata('validation') ?? $this->validator; - - return view('teacher/exam_drafts', [ - 'assignments' => $assignments, - 'selectedClassSection' => $selectedClass, - 'drafts' => $drafts, - 'legacyExams' => $legacyExams, - 'examTypes' => $this->examTypes, - 'statusBadges' => $this->statusBadgeMap(), - 'schoolYear' => $this->schoolYear, - 'semester' => $this->semester, - 'maxUploadBytes' => self::MAX_UPLOAD_BYTES, - 'validation' => $validation, - ]); - } - - public function teacherStore() - { - $teacherId = (int) (session()->get('user_id') ?? 0); - if ($teacherId <= 0) { - return redirect()->to('/login'); - } - - $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')); - - if ($classSectionId <= 0) { - return redirect()->back()->withInput()->with('error', 'Select a class section before submitting.'); - } - - $assignment = $this->teacherClassModel - ->where('teacher_id', $teacherId) - ->where('class_section_id', $classSectionId) - ->first(); - - if (empty($assignment)) { - return redirect()->back()->withInput()->with('error', 'You are not assigned to the selected class section.'); - } - - 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.'); - } - } - - $file = $this->request->getFile('draft_file'); - $teacherFile = null; - $teacherFilename = null; - if ($file && $file->isValid() && !$file->hasMoved()) { - $stored = $this->storeUploadedFile($file, self::TEACHER_UPLOAD_DIR); - if ($stored === null) { - return redirect()->back()->withInput()->with('error', 'Failed to store the uploaded file.'); - } - $teacherFile = $stored; - $teacherFilename = $file->getClientName(); - } elseif ($file && $file->getError() !== UPLOAD_ERR_NO_FILE) { - return redirect()->back()->withInput()->with('error', 'Upload failed. Please try again.'); - } - - $existingDraft = $this->examDraftModel - ->where('teacher_id', $teacherId) - ->where('class_section_id', $classSectionId) - ->where('semester', $this->semester) - ->where('school_year', $this->schoolYear) - ->orderBy('version', 'DESC') - ->first(); - - $nextVersion = 1; - $previousId = null; - if (!empty($existingDraft)) { - $nextVersion = ((int) ($existingDraft['version'] ?? 1)) + 1; - $previousId = (int) ($existingDraft['id'] ?? 0) ?: null; - } - - $title = $examType ?: 'Exam Draft'; - - $payload = [ - 'teacher_id' => $teacherId, - 'class_section_id' => $classSectionId, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - 'exam_type' => $examType ?: null, - 'draft_title' => $title, - 'description' => $description === '' ? null : $description, - 'teacher_file' => $teacherFile, - 'teacher_filename' => $teacherFilename, - 'status' => 'submitted', - 'version' => $nextVersion, - 'previous_draft_id' => $previousId, - ]; - - if ($this->examDraftModel->insert($payload)) { - return redirect()->to('/teacher/exam-drafts')->with('success', 'Exam draft submitted for review.'); - } - - return redirect()->back()->withInput()->with('error', 'Unable to save the exam draft.'); - } - - 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(); - - foreach ($allDrafts as &$row) { - if (empty($row['final_pdf_file'])) { - $pdf = $this->ensurePdfExists($row['final_file'] ?? '', pathinfo($row['final_file'] ?? '', PATHINFO_EXTENSION)); - if ($pdf !== null) { - $row['final_pdf_file'] = $pdf; - } - } - } - unset($row); - - $classSections = $this->classSectionModel - ->select('class_section_id, class_section_name') - ->orderBy('class_section_name', 'ASC') - ->findAll(); - - // Group legacy uploads (admin-uploaded finalized 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; - - foreach ($allDrafts as $d) { - $isLegacy = !empty($d['is_legacy']); - if (!$isLegacy) { - 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; - } - } else { - // Column missing: keep behavior simple and avoid legacy tab - $drafts = $allDrafts; - } - - return view('administrator/exam_drafts', [ - 'drafts' => $drafts, - 'statusBadges' => $this->statusBadgeMap(), - 'statusOptions' => $this->statusOptions(), - 'schoolYear' => $this->schoolYear, - 'semester' => $this->semester, - 'allowedExtensions' => self::ADMIN_ALLOWED_EXTENSIONS, - 'maxUploadBytes' => self::MAX_UPLOAD_BYTES, - 'examTypes' => $this->examTypes, - 'classSections' => $classSections, - 'legacyByClass' => $legacyByClass, - ]); - } - - public function adminUploadLegacy() - { - $adminId = (int) (session()->get('user_id') ?? 0); - if ($adminId <= 0) { - return redirect()->to('/login'); - } - - $classSectionId = (int) ($this->request->getPost('class_section_id') ?? 0); - $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')); - - if ($classSectionId <= 0) { - return redirect()->back()->withInput()->with('error', 'Select a class section.'); - } - if ($schoolYear === '') { - return redirect()->back()->withInput()->with('error', 'School year is required.'); - } - if ($semester === '') { - return redirect()->back()->withInput()->with('error', 'Semester is required.'); - } - - $file = $this->request->getFile('old_exam_file'); - if (!$file || !$file->isValid()) { - return redirect()->back()->withInput()->with('error', 'A valid file is required.'); - } - - $stored = $this->storeUploadedFile($file, self::FINAL_UPLOAD_DIR, self::ADMIN_ALLOWED_EXTENSIONS); - if ($stored === null) { - 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; - } else { - $pdfName = $this->convertDocToPdf( - $this->fullUploadPath(self::FINAL_UPLOAD_DIR, $stored), - self::FINAL_UPLOAD_DIR - ); - } - if ($pdfName !== null && $this->hasFinalPdfColumn) { - $payload['final_pdf_file'] = $pdfName; - } - - if ($this->examDraftModel->insert($payload)) { - return redirect()->to('/administrator/exam-drafts')->with('success', 'Old exam uploaded successfully.'); - } - - return redirect()->back()->withInput()->with('error', 'Unable to save the old exam.'); - } - - public function adminReview() - { - $draftId = (int) ($this->request->getPost('draft_id') ?? 0); - if ($draftId <= 0) { - return redirect()->back()->with('error', 'Invalid submission selected.'); - } - - $draft = $this->examDraftModel->find($draftId); - if (empty($draft)) { - return redirect()->back()->with('error', 'Submission not found.'); - } - - $comments = trim((string) $this->request->getPost('admin_comments')); - $statusInput = trim((string) $this->request->getPost('review_status')); - $currentStatus = (string) ($draft['status'] ?? 'draft'); - $status = $this->normalizeStatus( - $statusInput !== '' ? $statusInput : $currentStatus, - $currentStatus - ); - $status = strtolower($status); - if (!in_array($status, $this->statusOptions(), true)) { - $status = 'reviewed'; - } - $finalFile = null; - $finalFilename = null; - - $file = $this->request->getFile('final_file'); - if ($file && $file->isValid() && !$file->hasMoved()) { - $stored = $this->storeUploadedFile($file, self::FINAL_UPLOAD_DIR); - 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'; - } - } elseif ($file && $file->getError() !== UPLOAD_ERR_NO_FILE) { - return redirect()->back()->with('error', 'Final file upload failed.'); - } - - $update = [ - 'status' => $status, - 'admin_comments' => $comments === '' ? null : $comments, - 'admin_id' => (int) (session()->get('user_id') ?? 0), - 'reviewed_at' => utc_now(), - ]; - - if ($finalFile !== null) { - $update['final_file'] = $finalFile; - $update['final_filename'] = $finalFilename; - $pdfName = $this->ensurePdfExists($finalFile, $file ? $file->getClientExtension() : null); - if ($pdfName !== null && $this->hasFinalPdfColumn) { - $update['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 (strtolower($status) === 'finalized' && $this->hasIsLegacyColumn) { - $update['is_legacy'] = 1; // finalized exams move to Previous Exams - } - - if ($this->hasIsLegacyColumn) { - // Keep existing legacy flag, do not set legacy automatically here - } - - $updated = $this->examDraftModel - ->set($update) - ->where('id', $draftId) - ->update(); - - if ($updated) { - return redirect()->back()->with('success', 'Review saved successfully.'); - } - - return redirect()->back()->with('error', 'Unable to save the review.'); - } - - private function resolveSelectedClassSection(array $assignments): int - { - $candidate = (int) ($this->request->getGet('class_section_id') ?? session()->get('class_section_id') ?? 0); - $validIds = array_map('intval', array_column($assignments, 'class_section_id')); - if ($candidate > 0 && in_array($candidate, $validIds, true)) { - return $candidate; - } - return $validIds[0] ?? 0; - } - - 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', - 'class' => 'bg-success text-white', - ], - 'rejected' => [ - 'label' => 'Rejected', - 'class' => 'bg-danger text-white', - ], - ]; - } - - private function statusOptions(): array - { - return ['draft', 'submitted', 'reviewed', 'finalized', 'rejected']; - } - - private function normalizeStatus(string $input, string $default): string - { - $input = strtolower(trim($input)); - $aliases = [ - 'pending' => 'submitted', // legacy value - 'final' => 'finalized', - 'approved' => 'finalized', // legacy value - ]; - if (isset($aliases[$input])) { - $input = $aliases[$input]; - } - return in_array($input, $this->statusOptions(), true) ? $input : $default; - } - - private function storeUploadedFile(UploadedFile $file, string $subdir, array $allowedExtensions = self::ALLOWED_EXTENSIONS): ?string - { - $ext = strtolower($file->getClientExtension()); - if (!in_array($ext, $allowedExtensions, true)) { - return null; - } - if ($file->getSize() > self::MAX_UPLOAD_BYTES) { - return null; - } - - $destination = WRITEPATH . 'uploads/' . trim($subdir, '/'); - if (!is_dir($destination)) { - mkdir($destination, 0755, true); - } - - $filename = $file->getRandomName(); - try { - $file->move($destination, $filename); - return $filename; - } catch (\Throwable $e) { - log_message('error', 'ExamDraftController::storeUploadedFile error: ' . $e->getMessage()); - return null; - } - } - - private function convertDocToPdf(string $sourcePath, string $targetSubdir): ?string - { - // Only attempt conversion for doc/docx - $ext = strtolower(pathinfo($sourcePath, PATHINFO_EXTENSION)); - if (!in_array($ext, ['doc', 'docx'], true)) { - return null; - } - - $targetDir = WRITEPATH . 'uploads/' . trim($targetSubdir, '/'); - if (!is_dir($targetDir)) { - mkdir($targetDir, 0755, true); - } - - $base = pathinfo($sourcePath, PATHINFO_FILENAME); - $targetPath = $targetDir . '/' . $base . '.pdf'; - - // Attempt conversion via LibreOffice if available - $cmd = 'soffice --headless --convert-to pdf --outdir ' . escapeshellarg($targetDir) . ' ' . escapeshellarg($sourcePath) . ' 2>/dev/null'; - @exec($cmd); - - return is_file($targetPath) ? basename($targetPath) : null; - } - - private function schemaHasColumn(string $table, string $column): bool - { - try { - $fields = $this->db->getFieldNames($table); - return in_array($column, $fields, true); - } catch (\Throwable $e) { - log_message('error', "Schema check failed for {$table}.{$column}: " . $e->getMessage()); - return false; - } - } - - private function fullUploadPath(string $subdir, string $filename): string - { - return WRITEPATH . 'uploads/' . trim($subdir, '/') . '/' . $filename; - } - - private function neighborPdfIfExists(?string $filename, string $subdir): ?string - { - if (empty($filename)) return null; - $path = $this->fullUploadPath($subdir, $filename); - $base = pathinfo($path, PATHINFO_FILENAME); - $dir = pathinfo($path, PATHINFO_DIRNAME); - $pdfPath = $dir . '/' . $base . '.pdf'; - return is_file($pdfPath) ? basename($pdfPath) : null; - } - - private function ensurePdfExists(string $finalFilename, ?string $originalExt): ?string - { - $pdfNeighbor = $this->neighborPdfIfExists($finalFilename, self::FINAL_UPLOAD_DIR); - if ($pdfNeighbor !== null) { - return $pdfNeighbor; - } - $ext = strtolower((string)$originalExt); - if ($ext === 'pdf') { - // final file itself is already pdf - $path = $this->fullUploadPath(self::FINAL_UPLOAD_DIR, $finalFilename); - return is_file($path) ? $finalFilename : null; - } - return $this->convertDocToPdf( - $this->fullUploadPath(self::FINAL_UPLOAD_DIR, $finalFilename), - self::FINAL_UPLOAD_DIR - ); - } - - private function copyDraftToFinal(string $draftFilename): ?string - { - $source = $this->fullUploadPath(self::TEACHER_UPLOAD_DIR, $draftFilename); - if (!is_file($source)) { - return null; - } - $destinationDir = WRITEPATH . 'uploads/' . trim(self::FINAL_UPLOAD_DIR, '/'); - if (!is_dir($destinationDir)) { - mkdir($destinationDir, 0755, true); - } - $ext = pathinfo($draftFilename, PATHINFO_EXTENSION); - $destName = uniqid('final_', true) . '.' . $ext; - $destPath = $destinationDir . '/' . $destName; - if (!@copy($source, $destPath)) { - return null; - } - return $destName; - } -} diff --git a/app/old/ParentAttendanceReportController.php b/app/old/ParentAttendanceReportController.php deleted file mode 100644 index ec1119f1..00000000 --- a/app/old/ParentAttendanceReportController.php +++ /dev/null @@ -1,1833 +0,0 @@ -db = \Config\Database::connect(); - $this->configModel = new ConfigurationModel(); - $this->reportModel = new ParentAttendanceReportModel(); - $this->studentModel = new StudentModel(); - $this->studentClassModel = new StudentClassModel(); - $this->classSectionModel = new ClassSectionModel(); - $this->attendanceDataModel = new AttendanceDataModel(); - $this->attendanceRecordModel = new AttendanceRecordModel(); - $this->earlyDismissalSignatureModel = new EarlyDismissalSignatureModel(); - helper(['form', 'url']); - } - - // GET: parent form to report absence/late/early dismissal - public function form() - { - $parentId = $this->resolvePrimaryParentIdFromSession(); - if (!$parentId) { - return redirect()->to('/login'); - } - - $students = $this->db->table('students') - ->where('parent_id', $parentId) - ->orderBy('firstname', 'ASC') - ->orderBy('lastname', 'ASC') - ->get()->getResultArray(); - - // Build a list of Sundays (last few + upcoming weeks) - [$sundays, $defaultDate] = $this->computeSundays(); - - // Load upcoming reports for preview/edit (today and forward within this school year) - $schoolYear = (string) $this->configModel->getConfig('school_year'); - $todayYmd = local_date(utc_now(), 'Y-m-d'); - $previewRows = $this->reportModel->builder() - ->select('parent_attendance_reports.*, s.firstname, s.lastname') - ->join('students s', 's.id = parent_attendance_reports.student_id', 'left') - ->where('parent_attendance_reports.parent_id', (int) $parentId) - ->where('parent_attendance_reports.school_year', $schoolYear) - ->where('parent_attendance_reports.report_date >=', $todayYmd) - ->orderBy('parent_attendance_reports.report_date', 'ASC') - ->orderBy('s.firstname', 'ASC') - ->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, - ]); - } - - // POST: save parent report - public function submit() - { - $parentId = $this->resolvePrimaryParentIdFromSession(); - if (!$parentId) { - return redirect()->to('/login'); - } - - $post = $this->request->getPost(); - - $rules = [ - 'student_ids' => [ - 'rules' => 'required', - 'errors' => [ - 'required' => 'At least select one student', - ], - ], - 'date' => 'permit_empty|valid_date[Y-m-d]', - 'type' => 'required|in_list[absent,late,early_dismissal]', - 'arrival_time' => 'permit_empty|regex_match[/^\d{2}:\d{2}(:\d{2})?$/]', - 'dismiss_time' => 'permit_empty|regex_match[/^\d{2}:\d{2}(:\d{2})?$/]', - 'reason' => 'permit_empty|string', - ]; - - if (! $this->validate($rules)) { - return redirect()->back()->withInput()->with('error', implode(' ', $this->validator->getErrors())); - } - - $studentIdsRaw = (array) ($post['student_ids'] ?? []); - $studentIds = []; - foreach ($studentIdsRaw as $sidRaw) { - $sid = (int) $sidRaw; - if ($sid > 0) { - $studentIds[] = $sid; - } - } - - $datesInput = $post['dates'] ?? ($post['date'] ?? null); - $rawDates = []; - if (is_array($datesInput)) { - $rawDates = $datesInput; - } elseif ($datesInput !== null) { - $rawDates = [$datesInput]; - } - $rawDates = array_values(array_filter(array_map(function ($val) { - return substr((string) $val, 0, 10); - }, $rawDates))); - $rawDates = array_values(array_unique(array_filter($rawDates))); - - if (empty($rawDates)) { - return redirect()->back()->withInput()->with('error', 'Please select at least one Sunday date.'); - } - - $type = (string) $post['type']; - $arrivalTime = $post['arrival_time'] ?? null; - $dismissTime = $post['dismiss_time'] ?? null; - $reasonRaw = $post['reason'] ?? null; - $reason = is_string($reasonRaw) ? trim($reasonRaw) : null; - - // 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')); - - $lateCutoff = null; - $lateNow = null; - $lateTodayStr = $todayCheck->format('Y-m-d'); - if ($type === 'late') { - try { - 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') - ?: '10:10'); - if (preg_match('/^(\d{1,2}):(\d{2})/', $cutoffStr, $m)) { - $hh = str_pad((string) ((int) $m[1]), 2, '0', STR_PAD_LEFT); - $mm = str_pad($m[2], 2, '0', STR_PAD_LEFT); - $cutoffStr = $hh . ':' . $mm; - } else { - $cutoffStr = '10:10'; - } - $lateCutoff = new \DateTime($lateTodayStr . ' ' . $cutoffStr . ':00', $tz); - } catch (\Throwable $e) { - log_message('warning', 'Late cutoff prep failed: ' . $e->getMessage()); - $lateCutoff = null; - $lateNow = null; - } - } - - foreach ($rawDates as $entry) { - $dt = \DateTime::createFromFormat('Y-m-d', $entry); - if (!$dt) { - return redirect()->back()->withInput()->with('error', 'Invalid date selected: ' . $entry); - } - if ((int)$dt->format('w') !== 0) { - return redirect()->back()->withInput()->with('error', 'Please select Sunday dates only.'); - } - 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.'); - } - if ($type === 'late' && $lateCutoff && $lateNow && $entry === $lateTodayStr && $lateNow > $lateCutoff) { - return redirect()->back()->withInput()->with('error', 'Late reporting is closed for today. Please contact the office if needed.'); - } - $validDates[$entry] = $dt; - } - - // Enforce required time fields for specific types - if ($type === 'late' && (!is_string($arrivalTime) || $arrivalTime === '')) { - return redirect()->back()->withInput()->with('error', 'Please provide an expected arrival time for late reporting.'); - } - if ($type === 'early_dismissal' && (!is_string($dismissTime) || $dismissTime === '')) { - return redirect()->back()->withInput()->with('error', 'Please provide a dismissal time for early dismissal.'); - } - if ($type !== 'early_dismissal') { - if ($reason === null || $reason === '') { - return redirect()->back()->withInput()->with('error', 'Please provide a reason for absence or late arrival.'); - } - } - $reason = ($reason !== null && $reason !== '') ? $reason : null; - - // Upper bound already enforced by $validDates building - - $semester = (string) $this->configModel->getConfig('semester'); - $schoolYear = (string) $this->configModel->getConfig('school_year'); - $semesterResolver = new SemesterRangeService($this->configModel); - - $inserted = 0; - $blocked = []; - $successNames = []; - $successDetails = []; - $submitterUserId = (int) (session()->get('user_id') ?? 0); - $selectedDateList = array_keys($validDates); - - foreach ($studentIds as $sid) { - if ($sid <= 0) continue; - - $sectionRow = $this->db->table('student_class') - ->select('class_section_id') - ->where('student_id', $sid) - ->where('school_year', $schoolYear) - ->orderBy('updated_at', 'DESC') - ->get()->getRowArray(); - $classSectionId = $sectionRow['class_section_id'] ?? null; - $classLabel = $this->resolveClassSectionLabel($classSectionId); - $stu = $this->studentModel->select('firstname, lastname')->find($sid); - $studentName = $stu ? trim(($stu['firstname'] ?? '') . ' ' . ($stu['lastname'] ?? '')) : ('Student #' . $sid); - - foreach ($validDates as $reportDate => $_dt) { - $semesterForDate = $semesterResolver->getSemesterForDate($reportDate); - if ($semesterForDate === '') { - $semesterForDate = $semester; - } - $saved = false; - - try { - $qb = $this->reportModel->builder(); - $qb->where('student_id', $sid) - ->where('report_date', $reportDate); - - if ($type === 'early_dismissal') { - $existingALAny = (clone $qb) - ->groupStart() - ->where('type', 'absent') - ->orWhere('type', 'late') - ->groupEnd() - ->get()->getRowArray(); - - if ($existingALAny) { - $blocked[] = $studentName . ' (' . $reportDate . ')'; - } else { - $existingED = (clone $qb)->where('type', 'early_dismissal')->get()->getFirstRow('array'); - if ($existingED) { - $this->reportModel->update((int)$existingED['id'], [ - 'dismiss_time' => $dismissTime ?: $existingED['dismiss_time'], - 'status' => 'new', - ]); - $inserted++; - $saved = true; - $dismissTimeUsed = $dismissTime ?: ($existingED['dismiss_time'] ?? null); - $dismissDisplay = $this->formatTimeDisplay($dismissTimeUsed); - $successNames[] = [ - 'name' => $studentName, - 'type' => 'Early Dismissal', - 'time' => $dismissDisplay ?: 'N/A', - 'date' => $reportDate, - 'date_label' => $this->formatReportDate($reportDate) ?? $reportDate, - ]; - $successDetails[] = [ - 'student_id' => $sid, - 'name' => $studentName, - 'class_label' => $classLabel, - 'type' => $type, - 'type_label' => $this->formatParentReportType($type), - 'arrival_time' => null, - 'dismiss_time' => $dismissDisplay, - 'date' => $reportDate, - 'date_label' => $this->formatReportDate($reportDate) ?? $reportDate, - ]; - } else { - $ok = $this->reportModel->insert([ - 'parent_id' => $parentId, - 'student_id' => $sid, - 'class_section_id' => $classSectionId, - 'report_date' => $reportDate, - 'type' => 'early_dismissal', - 'dismiss_time' => $dismissTime ?: null, - 'reason' => $reason ?: null, - 'semester' => $semesterForDate, - 'school_year' => $schoolYear, - 'status' => 'new', - ], false); - if ($ok) { - $inserted++; - $saved = true; - $dismissTimeFmt = $this->formatTimeDisplay($dismissTime); - $successNames[] = [ - 'name' => $studentName, - 'type' => 'Early Dismissal', - 'time' => $dismissTimeFmt ?: 'N/A', - 'date' => $reportDate, - 'date_label' => $this->formatReportDate($reportDate) ?? $reportDate, - ]; - $successDetails[] = [ - 'student_id' => $sid, - 'name' => $studentName, - 'class_label' => $classLabel, - 'type' => $type, - 'type_label' => $this->formatParentReportType($type), - 'arrival_time' => null, - 'dismiss_time' => $dismissTimeFmt, - 'date' => $reportDate, - 'date_label' => $this->formatReportDate($reportDate) ?? $reportDate, - ]; - } - } - } - } else { - $existingAL = (clone $qb) - ->groupStart() - ->where('type', 'absent') - ->orWhere('type', 'late') - ->groupEnd() - ->get()->getRowArray(); - - $existingED = (clone $qb)->where('type', 'early_dismissal')->get()->getFirstRow('array'); - - if ($existingAL || $existingED) { - $blocked[] = $studentName . ' (' . $reportDate . ')'; - } else { - $ok = $this->reportModel->insert([ - 'parent_id' => $parentId, - 'student_id' => $sid, - 'class_section_id' => $classSectionId, - 'report_date' => $reportDate, - 'type' => $type, - 'arrival_time' => ($type === 'late') ? ($arrivalTime ?: null) : null, - 'reason' => $reason ?: null, - 'semester' => $semesterForDate, - 'school_year' => $schoolYear, - 'status' => 'new', - ], false); - if ($ok) { - $inserted++; - $saved = true; - $arrivalFmt = $this->formatTimeDisplay($arrivalTime); - $successNames[] = [ - 'name' => $studentName, - 'type' => ucfirst($type), - 'time' => ($type === 'late') ? ($arrivalFmt ?: 'N/A') : 'N/A', - 'date' => $reportDate, - 'date_label' => $this->formatReportDate($reportDate) ?? $reportDate, - ]; - $successDetails[] = [ - 'student_id' => $sid, - 'name' => $studentName, - 'class_label' => $classLabel, - 'type' => $type, - 'type_label' => $this->formatParentReportType($type), - 'arrival_time' => ($type === 'late') ? $arrivalFmt : null, - 'dismiss_time' => null, - 'date' => $reportDate, - 'date_label' => $this->formatReportDate($reportDate) ?? $reportDate, - ]; - } - } - } - } catch (\Throwable $e) { - log_message('error', 'Parent report save failed: ' . $e->getMessage()); - } - - if ($saved && in_array($type, ['absent', 'late'], true)) { - try { - $this->reflectToAttendanceData( - studentId: $sid, - reportDate: $reportDate, - type: $type, - semester: $semesterForDate, - schoolYear: $schoolYear, - classSectionId: $classSectionId, - reason: $reason, - arrivalTime: $arrivalTime - ); - } catch (\Throwable $e) { - log_message('error', 'Unable to reflect parent report to attendance_data: ' . $e->getMessage()); - } - } - } - } - - // --- Final message output --- - if ($inserted <= 0) { - $err = !empty($blocked) - ? ('Already submitted and cannot be changed for: ' . implode(', ', array_slice($blocked, 0, 5)) . (count($blocked) > 5 ? '…' : '')) - : 'Could not save your report. Please try again.'; - return redirect()->back()->withInput()->with('error', $err); - } - - // ✅ Build formatted success message - $msg = '✅ Submission received successfully for ' . count($successNames) . ' item' . (count($successNames) > 1 ? 's' : '') . '.
'; - $msg .= ''; - $dateSummary = $this->formatDateList($selectedDateList); - if ($dateSummary !== '') { - $msg .= 'Date(s): ' . esc($dateSummary); - } - - if (!empty($blocked)) { - $msg .= '
Note: Already submitted and unchanged for ' . count($blocked) . ' student' . (count($blocked) > 1 ? 's' : '') . '.'; - } - - if (!empty($successDetails)) { - try { - $this->sendParentReportNotifications([ - 'parent_id' => $parentId, - 'submitter_id' => $submitterUserId > 0 ? $submitterUserId : $parentId, - 'students' => $successDetails, - 'type' => $type, - 'dates' => $selectedDateList, - 'report_date' => $selectedDateList[0] ?? null, - 'reason' => $reason, - 'semester' => $semester, - 'school_year' => $schoolYear, - ]); - } catch (\Throwable $e) { - log_message('error', 'Parent report confirmation email failed: ' . $e->getMessage()); - } - } - - return redirect()->to(site_url('parent/report-attendance'))->with('message', $msg); - } - - /** - * Returns an array of Sunday dates and a recommended default selection. - * @return array{0: array, 1: string} - */ - private function computeSundays(int $weeksBefore = 4, int $weeksAfter = 26): array - { - $today = new \DateTime('today'); - $dow = (int)$today->format('w'); // 0=Sun - - // This Sunday (today if Sunday, else last Sunday) - $thisSunday = clone $today; - if ($dow !== 0) { - $thisSunday->modify('last sunday'); - } - - // Default selection: upcoming Sunday (today if Sunday) - $default = clone $today; - if ($dow !== 0) { - $default->modify('next sunday'); - } - - // Upper bound: first Sunday of June (school year end) - $schoolYear = (string) $this->configModel->getConfig('school_year'); - $cap = $this->firstSundayOfJune($schoolYear); - - $dates = []; - // Backward Sundays (limited) - for ($i = $weeksBefore; $i >= 1; $i--) { - $d = (clone $thisSunday)->modify('-' . $i . ' week'); - $dates[] = $d; - } - // Forward Sundays from this Sunday up to the cap (inclusive) - $cursor = clone $thisSunday; // this Sunday - while ($cursor <= $cap) { - $dates[] = clone $cursor; - $cursor->modify('+1 week'); - } - - // Determine date range for calendar query - $rangeStart = reset($dates); - $rangeEnd = end($dates); - $rangeStartStr = $rangeStart instanceof \DateTime ? $rangeStart->format('Y-m-d') : null; - $rangeEndStr = $rangeEnd instanceof \DateTime ? $rangeEnd->format('Y-m-d') : null; - - // Fetch No School dates from calendar_events within range - $noSchool = []; - if ($rangeStartStr && $rangeEndStr) { - try { - $builder = $this->db->table('calendar_events') - ->select('date') - ->where('no_school', 1) - ->groupStart() - ->where('school_year', $schoolYear) - ->orWhere('school_year IS NULL', null, false) - ->groupEnd() - ->where('date >=', $rangeStartStr) - ->where('date <=', $rangeEndStr); - $rows = $builder->get()->getResultArray(); - foreach ($rows as $r) { - $d = substr((string)($r['date'] ?? ''), 0, 10); - if ($d !== '') $noSchool[$d] = true; - } - } catch (\Throwable $e) { - // ignore, fallback below - } - } - - // Fallback using CalendarModel heuristics if none found - if (empty($noSchool) && $rangeStartStr && $rangeEndStr) { - try { - /** @var \App\Models\CalendarModel $cal */ - $cal = model(\App\Models\CalendarModel::class); - $events = (array) ($cal->getEventsBySchoolYear($schoolYear) ?? []); - foreach ($events as $ev) { - $d = substr((string)($ev['date'] ?? ''), 0, 10); - $ns = (int)($ev['no_school'] ?? 0); - if ($ns === 1 && $d >= $rangeStartStr && $d <= $rangeEndStr) { - $noSchool[$d] = true; - } - } - } catch (\Throwable $e) { - // ignore - } - } - - // Filter out no-school Sundays - $filtered = []; - foreach ($dates as $d) { - $ymd = $d->format('Y-m-d'); - if (!isset($noSchool[$ymd])) { - $filtered[] = $d; - } - } - - // Keep only today or future Sundays (no past dates displayed) - $filteredFuture = []; - foreach ($filtered as $d) { - if ($d >= $today) { - $filteredFuture[] = $d; - } - } - - $filteredCapped = []; - foreach ($filteredFuture as $d) { - if ($d <= $cap) { - $filteredCapped[] = $d; - } - } - - // Choose a sensible default (nearest allowed Sunday on/after today) - $defaultYmd = $default->format('Y-m-d'); - $needFind = isset($noSchool[$defaultYmd]) || (new \DateTime($defaultYmd) < $today) || (new \DateTime($defaultYmd) > $cap); - if ($needFind) { - $found = null; - foreach ($filteredCapped as $d) { - $found = $d; - break; - } - if ($found instanceof \DateTime) { - $defaultYmd = $found->format('Y-m-d'); - } - } - - // Build value/label pairs - $out = []; - foreach ($filteredCapped as $d) { - $out[] = [ - 'value' => $d->format('Y-m-d'), - 'label' => $d->format('m-d-Y'), - ]; - } - - return [$out, $defaultYmd]; - } - - /** - * First Sunday of June for the given school year. - * If school_year is like "2024-2025", we use 2025-06 (end year). - * Fallback: if parsing fails, use upcoming June based on today. - */ - private function firstSundayOfJune(string $schoolYear): \DateTime - { - $today = new \DateTime('today'); - $endYear = null; - if (preg_match('/^(\d{4})\D(\d{4})$/', $schoolYear, $m)) { - $endYear = (int)$m[2]; - } - if ($endYear === null) { - $y = (int)$today->format('Y'); - // If we're past June, cap to next year's June; otherwise this year's June - $endYear = ((int)$today->format('n') > 6) ? ($y + 1) : $y; - } - - $juneFirst = new \DateTime(sprintf('%04d-06-01', $endYear)); - if ((int)$juneFirst->format('w') !== 0) { - $juneFirst->modify('next sunday'); - } - return $juneFirst; // DateTime (midnight) - } - - /** - * Insert or update attendance_data for admin daily view based on parent report. - * - For 'absent': upsert an ABSENT row and mark is_reported='yes'. - * - For 'late': if no row exists, insert LATE and mark is_reported='yes'. - * Teachers are allowed to override parent-created late entries (see AttendanceController guard). - */ - private function reflectToAttendanceData( - int $studentId, - string $reportDate, - string $type, - string $semester, - string $schoolYear, - ?int $classSectionId, - ?string $reason = null, - ?string $arrivalTime = null - ): void { - if ($classSectionId === null || $classSectionId <= 0) { - // Try resolve from student_class for current year, then fallback to last known assignment - $row = $this->db->table('student_class') - ->select('class_section_id') - ->where('student_id', $studentId) - ->where('school_year', $schoolYear) - ->orderBy('updated_at', 'DESC') - ->get()->getRowArray(); - $classSectionId = (int) ($row['class_section_id'] ?? 0); - if ($classSectionId <= 0) { - $row = $this->db->table('student_class') - ->select('class_section_id') - ->where('student_id', $studentId) - ->orderBy('updated_at', 'DESC') - ->get()->getRowArray(); - $classSectionId = (int) ($row['class_section_id'] ?? 0); - } - } - - // Existing attendance for that date/term (strict, then relaxed) - $existing = $this->attendanceDataModel - ->where('student_id', $studentId) - ->where('class_section_id', $classSectionId) - ->where('date', $reportDate) - ->where('semester', $semester) - ->where('school_year', $schoolYear) - ->first(); - if (!$existing) { - $existing = $this->attendanceDataModel - ->where('student_id', $studentId) - ->where('date', $reportDate) - ->where('semester', $semester) - ->where('school_year', $schoolYear) - ->first(); - } - if (!$existing) { - $existing = $this->attendanceDataModel->builder() - ->where('student_id', $studentId) - ->where('semester', $semester) - ->where('school_year', $schoolYear) - ->where("DATE(`date`)", $reportDate, false) - ->get()->getFirstRow('array'); - } - - if (($classSectionId === null || $classSectionId <= 0) && !empty($existing['class_section_id'])) { - $classSectionId = (int) $existing['class_section_id']; - } - - $hasIsReported = $this->db->fieldExists('is_reported', 'attendance_data'); - $hasLegacyReported = $this->db->fieldExists('reported', 'attendance_data'); - $hasIsNotified = $this->db->fieldExists('is_notified', 'attendance_data'); - - $status = ($type === 'late') ? 'late' : (($type === 'absent') ? 'absent' : null); - if ($status === null) return; - - // Build parent-reported reason - $reasonParts = ['Parent-reported']; - if ($type === 'late' && $arrivalTime) { - $reasonParts[] = 'ETA ' . substr($arrivalTime, 0, 5); - } - if (!empty($reason)) { - $reasonParts[] = trim($reason); - } - $reasonText = implode(' — ', $reasonParts); - - if (!$existing) { - if ($classSectionId === null || $classSectionId <= 0) { - // Try last known attendance_data entry for class/section - $fallback = $this->attendanceDataModel->builder() - ->select('class_section_id, class_id, school_id') - ->where('student_id', $studentId) - ->where('school_year', $schoolYear) - ->orderBy('date', 'DESC') - ->limit(1) - ->get()->getFirstRow('array'); - if (!$fallback) { - $fallback = $this->attendanceDataModel->builder() - ->select('class_section_id, class_id, school_id') - ->where('student_id', $studentId) - ->orderBy('date', 'DESC') - ->limit(1) - ->get()->getFirstRow('array'); - } - if (!empty($fallback['class_section_id'])) { - $classSectionId = (int) $fallback['class_section_id']; - } - } - - if ($classSectionId === null || $classSectionId <= 0) { - return; // cannot insert without a class section - } - - // class_id required by attendance_data validation - $classId = $this->classSectionModel->getClassId($classSectionId); - if (!$classId || $classId <= 0) { - $fallback = $this->attendanceDataModel->builder() - ->select('class_id') - ->where('student_id', $studentId) - ->where('school_year', $schoolYear) - ->orderBy('date', 'DESC') - ->limit(1) - ->get()->getFirstRow('array'); - if (!$fallback) { - $fallback = $this->attendanceDataModel->builder() - ->select('class_id') - ->where('student_id', $studentId) - ->orderBy('date', 'DESC') - ->limit(1) - ->get()->getFirstRow('array'); - } - $classId = (int) ($fallback['class_id'] ?? 0); - } - if (!$classId || $classId <= 0) return; - - // Student school_id - $stu = $this->studentModel->select('school_id')->find($studentId); - $schoolId = (string) ($stu['school_id'] ?? ''); - if ($schoolId === '') { - $fallback = $this->attendanceDataModel->builder() - ->select('school_id') - ->where('student_id', $studentId) - ->where('school_year', $schoolYear) - ->orderBy('date', 'DESC') - ->limit(1) - ->get()->getFirstRow('array'); - if (!$fallback) { - $fallback = $this->attendanceDataModel->builder() - ->select('school_id') - ->where('student_id', $studentId) - ->orderBy('date', 'DESC') - ->limit(1) - ->get()->getFirstRow('array'); - } - $schoolId = (string) ($fallback['school_id'] ?? ''); - } - if ($schoolId === '') return; - - // Insert a new row - $row = [ - 'class_id' => $classId, - 'class_section_id' => $classSectionId, - 'student_id' => $studentId, - 'school_id' => $schoolId, - 'date' => $reportDate, - 'status' => $status, - 'reason' => $reasonText, - 'semester' => $semester, - 'school_year' => $schoolYear, - 'modified_by' => (int) (session()->get('user_id') ?? 0), - 'created_at' => utc_now(), - 'updated_at' => utc_now(), - ]; - if ($hasIsReported) { - $row['is_reported'] = 'yes'; - } - if ($hasLegacyReported) { - $row['reported'] = 1; - } - if ($hasIsNotified) { - $row['is_notified'] = 'no'; - } - $this->attendanceDataModel->insert($row, false); - $this->bumpAttendanceRecordFromParent($studentId, $classSectionId, $schoolId, $status, $semester, $schoolYear); - return; - } - - // If already exists: only mark reported for matching status; do not override teacher-marked present - $existingStatus = strtolower((string) ($existing['status'] ?? '')); - if ($existingStatus === $status) { - $update = [ - '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); - 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 - ); - } - } - } - - private function bumpAttendanceRecordFromParent( - int $studentId, - int $classSectionId, - string $schoolId, - string $status, - string $semester, - string $schoolYear - ): void { - $status = strtolower($status); - $record = $this->attendanceRecordModel->where([ - 'student_id' => $studentId, - 'semester' => $semester, - 'school_year' => $schoolYear, - ])->first(); - - $now = utc_now(); - if ($record) { - $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) + 1, - 'updated_at' => $now, - 'modified_by' => (int) (session()->get('user_id') ?? 0), - ]; - if ($status === 'present') { - $updates['total_presence']++; - } elseif ($status === 'absent') { - $updates['total_absence']++; - } elseif ($status === 'late') { - $updates['total_late']++; - } - $this->attendanceRecordModel->update((int) $record['id'], $updates); - return; - } - - $insert = [ - 'class_section_id' => $classSectionId, - 'student_id' => $studentId, - 'school_id' => $schoolId, - 'semester' => $semester, - 'school_year' => $schoolYear, - 'created_at' => $now, - 'updated_at' => $now, - 'modified_by' => (int) (session()->get('user_id') ?? 0), - 'total_presence' => ($status === 'present') ? 1 : 0, - 'total_absence' => ($status === 'absent') ? 1 : 0, - 'total_late' => ($status === 'late') ? 1 : 0, - 'total_attendance' => 1, - ]; - $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() - { - $role = strtolower((string) (session()->get('role') ?? '')); - if (!in_array($role, ['teacher', 'teacher_assistant', 'administrator', 'admin'], true)) { - return redirect()->to('/access_denied'); - } - $start = (string)($this->request->getGet('start') ?: local_date(utc_now(), 'Y-m-d')); - $end = (string)($this->request->getGet('end') ?: ''); // open-ended - - $schoolYearParam = $this->request->getGet('school_year'); - $semesterParam = $this->request->getGet('semester'); - $schoolYear = (is_string($schoolYearParam) && $schoolYearParam !== '') - ? (string)$schoolYearParam - : (string)$this->configModel->getConfig('school_year'); - $semester = (is_string($semesterParam) && $semesterParam !== '') - ? (string)$semesterParam - : (string)$this->configModel->getConfig('semester'); - - $rows = $this->reportModel->listForDateRange($start, $end ?: null, $schoolYear, $semester); - - return view('attendance/parent_reports', [ - 'rows' => $rows, - 'start' => $start, - 'end' => $end ?: null, - 'schoolYear' => $schoolYear, - 'semester' => $semester, - ]); - } - - private function canAccessEarlyDismissalsNav(): bool - { - $sessionRole = session()->get('role'); - $roleNames = is_array($sessionRole) ? $sessionRole : [$sessionRole]; - $roleNames = array_values(array_filter(array_map('strval', $roleNames))); - - if (empty($roleNames)) { - return false; - } - - $roleIdRows = $this->db->table('roles') - ->select('id') - ->whereIn('name', $roleNames) - ->get() - ->getResultArray(); - $roleIds = array_map('intval', array_column($roleIdRows, 'id')); - - if (empty($roleIds)) { - return false; - } - - $allowed = $this->db->table('role_nav_items AS rni') - ->select('1') - ->join('nav_items AS ni', 'ni.id = rni.nav_item_id') - ->where('ni.url', 'attendance/early-dismissals') - ->whereIn('rni.role_id', $roleIds) - ->get(1) - ->getFirstRow(); - - return (bool) $allowed; - } - - private function canManageEarlyDismissals(): bool - { - $allowedRoles = ['teacher', 'teacher_assistant', 'administrator', 'admin']; - $sessionRole = session()->get('role'); - $roles = is_array($sessionRole) ? $sessionRole : [$sessionRole]; - - foreach ($roles as $role) { - $role = strtolower((string) $role); - if ($role !== '' && in_array($role, $allowedRoles, true)) { - return true; - } - } - - return false; - } - - // GET: staff page listing early dismissals by month - public function earlyDismissals() - { - // Authorize via role->nav mapping instead of hardcoded roles - if (! $this->canAccessEarlyDismissalsNav()) { - return redirect()->to('/access_denied'); - } - - // Get filters - $schoolYearParam = $this->request->getGet('school_year'); - $semesterParam = $this->request->getGet('semester'); - - // Default to config values - $schoolYear = (is_string($schoolYearParam) && $schoolYearParam !== '') - ? trim($schoolYearParam) - : (string)$this->configModel->getConfig('school_year'); - - $semester = (is_string($semesterParam) && $semesterParam !== '') - ? trim($semesterParam) - : ''; - - // Query early dismissal reports - $b = $this->reportModel->builder(); - $b->select('parent_attendance_reports.*, s.firstname, s.lastname, cs.class_section_name') - ->join('students s', 's.id = parent_attendance_reports.student_id', 'left') - ->join('classSection cs', 'cs.class_section_id = parent_attendance_reports.class_section_id', 'left') - ->where('parent_attendance_reports.type', 'early_dismissal'); - - // Always filter by school year - if ($schoolYear !== '') { - $b->where('parent_attendance_reports.school_year', $schoolYear); - } - - // Filter by semester only if chosen - if ($semester !== '') { - $b->where('parent_attendance_reports.semester', $semester); - } - - $rows = $b->orderBy('parent_attendance_reports.report_date', 'DESC') - ->orderBy('parent_attendance_reports.dismiss_time', 'ASC') - ->get() - ->getResultArray(); - - // Group results by date - $groups = []; - foreach ($rows as $r) { - $d = substr((string)($r['report_date'] ?? ''), 0, 10); - if ($d === '') { - $d = 'Unknown'; - } - $groups[$d][] = $r; - } - uksort($groups, static function ($a, $b) { - if ($a === 'Unknown' && $b === 'Unknown') { - return 0; - } - if ($a === 'Unknown') { - return 1; - } - if ($b === 'Unknown') { - return -1; - } - return strcmp($b, $a); // newest date first (Y-m-d sorts lexicographically) - }); - - $signatureByDate = []; - if (!empty($groups)) { - $dates = array_values(array_filter(array_keys($groups), static function ($d) { - return preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $d); - })); - if (!empty($dates)) { - $sigQ = $this->earlyDismissalSignatureModel - ->whereIn('report_date', $dates); - - if ($schoolYear !== '') { - $sigQ->where('school_year', $schoolYear); - } - if ($semester !== '') { - $sigQ->where('semester', $semester); - } - - $sigRows = $sigQ->orderBy('report_date', 'ASC')->findAll(); - foreach ($sigRows as $sig) { - $d = substr((string) ($sig['report_date'] ?? ''), 0, 10); - if ($d !== '') { - $signatureByDate[$d] = $sig; - } - } - } - } - - return view('attendance/early_dismissals', [ - 'groups' => $groups, - 'schoolYear' => $schoolYear, - 'semester' => $semester, - 'signatureByDate' => $signatureByDate, - ]); - } - - private function ensureUploadSubdir(string $subdir): string - { - $base = rtrim(WRITEPATH, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'uploads'; - $dir = $base . DIRECTORY_SEPARATOR . trim($subdir, '/\\'); - - if (!is_dir($dir)) { - if (!@mkdir($dir, 0755, true) && !is_dir($dir)) { - log_message('error', 'Failed to create upload directory: ' . $dir); - throw new \RuntimeException('Upload directory is not available.'); - } - } - if (!is_writable($dir)) { - log_message('error', 'Upload directory not writable: ' . $dir); - throw new \RuntimeException('Upload directory is not writable.'); - } - - return $dir; - } - - // POST: upload parent signature document for a specific early dismissal date - public function uploadEarlyDismissalSignature() - { - if (! $this->canAccessEarlyDismissalsNav() || ! $this->canManageEarlyDismissals()) { - return redirect()->to('/access_denied'); - } - if (! $this->request->is('post')) { - return redirect()->to(site_url('attendance/early-dismissals')); - } - - $rules = [ - 'report_date' => 'required|valid_date[Y-m-d]', - 'signature_file' => 'uploaded[signature_file]' - . '|max_size[signature_file,4096]' - . '|ext_in[signature_file,jpg,jpeg,png,webp,gif,pdf]' - . '|mime_in[signature_file,image/jpg,image/jpeg,image/png,image/webp,image/gif,application/pdf]', - 'school_year' => 'permit_empty|string', - 'semester' => 'permit_empty|string', - 'return_url' => 'permit_empty|string', - ]; - - $messages = [ - 'signature_file' => [ - 'uploaded' => 'Signature file is required.', - 'max_size' => 'Maximum file size is 4MB.', - 'ext_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.', - 'mime_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.', - ], - ]; - - if (! $this->validate($rules, $messages)) { - return redirect()->back()->withInput()->with('error', $this->validator->listErrors()); - } - - $reportDate = substr((string) $this->request->getPost('report_date'), 0, 10); - $returnUrl = (string) ($this->request->getPost('return_url') ?? site_url('attendance/early-dismissals')); - $schoolYear = (string) ($this->request->getPost('school_year') ?? $this->configModel->getConfig('school_year')); - $semesterRaw = (string) ($this->request->getPost('semester') ?? ''); - $hasSemesterFilter = ($semesterRaw !== ''); - $semester = $hasSemesterFilter ? $semesterRaw : null; - - $dt = \DateTime::createFromFormat('Y-m-d', $reportDate); - if (!$dt || (int) $dt->format('w') !== 0) { - return redirect()->to($returnUrl)->with('error', 'Please select a Sunday date.'); - } - - $existsQ = $this->reportModel->builder() - ->select('id') - ->where('type', 'early_dismissal') - ->where('report_date', $reportDate); - if ($schoolYear !== '') { - $existsQ->where('school_year', $schoolYear); - } - if ($hasSemesterFilter) { - $existsQ->where('semester', $semester); - } - $exists = $existsQ->get(1)->getRowArray(); - if (!$exists) { - return redirect()->to($returnUrl)->with('error', 'No early dismissal records found for that date.'); - } - - $file = $this->request->getFile('signature_file'); - if (! $file || ! $file->isValid() || $file->hasMoved()) { - return redirect()->to($returnUrl)->with('error', 'Invalid signature file.'); - } - - try { - $this->ensureUploadSubdir('early_dismissal_signatures'); - $stored = $file->store('early_dismissal_signatures'); - } catch (\Throwable $e) { - log_message('error', 'Signature upload failed: ' . $e->getMessage()); - return redirect()->to($returnUrl)->with('error', 'Unable to save the signature file.'); - } - - if (!$stored) { - return redirect()->to($returnUrl)->with('error', 'Unable to save the signature file.'); - } - - $filename = basename($stored); - $payload = [ - 'report_date' => $reportDate, - 'school_year' => $schoolYear !== '' ? $schoolYear : null, - 'semester' => $semester, - 'filename' => $filename, - 'original_name' => $file->getClientName(), - 'mime_type' => $file->getClientMimeType(), - 'file_size' => (int) ($file->getSize() ?? 0), - 'uploaded_by' => (int) (session()->get('user_id') ?? 0), - ]; - - $existingQ = $this->earlyDismissalSignatureModel - ->where('report_date', $reportDate); - if ($schoolYear !== '') { - $existingQ->where('school_year', $schoolYear); - } - if ($hasSemesterFilter) { - $existingQ->where('semester', $semester); - } - $existing = $existingQ->first(); - - try { - if ($existing && !empty($existing['id'])) { - $oldFile = (string) ($existing['filename'] ?? ''); - $this->earlyDismissalSignatureModel->update((int) $existing['id'], $payload); - if ($oldFile !== '' && $oldFile !== $filename) { - $oldPath = WRITEPATH . 'uploads/early_dismissal_signatures/' . $oldFile; - if (is_file($oldPath)) { - @unlink($oldPath); - } - } - } else { - $this->earlyDismissalSignatureModel->insert($payload); - } - } catch (\Throwable $e) { - log_message('error', 'Signature DB save failed: ' . $e->getMessage()); - return redirect()->to($returnUrl)->with('error', 'Unable to record the signature file.'); - } - - return redirect()->to($returnUrl)->with('message', 'Signature uploaded for ' . esc($reportDate) . '.'); - } - - - // GET: form for staff to add an early dismissal entry - public function addEarlyDismissalForm() - { - $role = strtolower((string) (session()->get('role') ?? '')); - if (!in_array($role, ['teacher', 'teacher_assistant', 'administrator', 'admin'], true)) { - return redirect()->to('/access_denied'); - } - - $schoolYear = (string)$this->configModel->getConfig('school_year'); - $semester = (string)$this->configModel->getConfig('semester'); - - // Fetch students with their current-year class section (if any) - try { - $students = $this->db->table('students s') - ->select('s.id, s.firstname, s.lastname, s.parent_id, cs.class_section_name, sc.class_section_id') - ->join('student_class sc', 'sc.student_id = s.id AND sc.school_year = ' . $this->db->escape($schoolYear), 'left') - ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') - ->orderBy('s.lastname', 'ASC') - ->orderBy('s.firstname', 'ASC') - ->get()->getResultArray(); - } catch (\Throwable $e) { - $students = []; - } - - // Suggest upcoming Sunday (or today if Sunday) - [$_, $defaultDate] = $this->computeSundays(0, 26); - - return view('attendance/early_dismissals_add', [ - 'students' => $students, - 'defaultDate' => $defaultDate, - 'schoolYear' => $schoolYear, - 'semester' => $semester, - ]); - } - - // POST: save staff-created early dismissal - public function saveEarlyDismissal() - { - $role = strtolower((string) (session()->get('role') ?? '')); - if (!in_array($role, ['teacher', 'teacher_assistant', 'administrator', 'admin'], true)) { - return redirect()->to('/access_denied'); - } - - $rules = [ - 'student_id' => 'required|is_natural_no_zero', - 'date' => 'required|valid_date[Y-m-d]', - 'dismiss_time' => 'required|regex_match[/^\d{2}:\d{2}(:\d{2})?$/]', - 'reason' => 'permit_empty|string', - ]; - if (! $this->validate($rules)) { - return redirect()->back()->withInput()->with('error', implode(' ', $this->validator->getErrors())); - } - - $studentId = (int) $this->request->getPost('student_id'); - $reportDate = substr((string) $this->request->getPost('date'), 0, 10); - $dismissTime = (string) $this->request->getPost('dismiss_time'); - $reason = (string) ($this->request->getPost('reason') ?? ''); - - // Sunday-only enforcement (staff can enter past Sundays if needed) - $dt = \DateTime::createFromFormat('Y-m-d', $reportDate); - if (!$dt || (int)$dt->format('w') !== 0) { - return redirect()->back()->withInput()->with('error', 'Please select a Sunday date.'); - } - - $schoolYear = (string)$this->configModel->getConfig('school_year'); - $semester = (string)$this->configModel->getConfig('semester'); - - // Resolve parent_id of the student - $stu = $this->studentModel->select('id, parent_id, firstname, lastname')->find($studentId); - if (!$stu || empty($stu['parent_id'])) { - return redirect()->back()->withInput()->with('error', 'Selected student not found or has no primary parent assigned.'); - } - $parentId = (int) $stu['parent_id']; - - // Resolve class_section_id for current school year - $sectionRow = $this->db->table('student_class') - ->select('class_section_id') - ->where('student_id', $studentId) - ->where('school_year', $schoolYear) - ->orderBy('updated_at', 'DESC') - ->limit(1) - ->get()->getRowArray(); - $classSectionId = isset($sectionRow['class_section_id']) ? (int)$sectionRow['class_section_id'] : null; - - try { - // Block if Absent/Late exists for this date - $qb = $this->reportModel->builder(); - $qb->where('student_id', $studentId) - ->where('report_date', $reportDate) - ->where('school_year', $schoolYear); - - $existingAL = (clone $qb) - ->groupStart() - ->where('type', 'absent') - ->orWhere('type', 'late') - ->groupEnd() - ->get()->getRowArray(); - - if ($existingAL) { - return redirect()->back()->withInput()->with('error', 'Absent/Late already submitted for this student on the selected date.'); - } - - // If early dismissal already exists, update time; else insert new - $existingED = (clone $qb)->where('type', 'early_dismissal')->get()->getFirstRow('array'); - if ($existingED) { - $this->reportModel->update((int)$existingED['id'], [ - 'dismiss_time' => $dismissTime ?: $existingED['dismiss_time'], - 'reason' => $reason ?: ($existingED['reason'] ?? null), - 'status' => 'new', - ]); - } else { - $this->reportModel->insert([ - 'parent_id' => $parentId, - 'student_id' => $studentId, - 'class_section_id' => $classSectionId, - 'report_date' => $reportDate, - 'type' => 'early_dismissal', - 'dismiss_time' => $dismissTime, - 'reason' => ($reason !== '') ? $reason : null, - 'semester' => $semester, - 'school_year' => $schoolYear, - 'status' => 'new', - ], false); - } - } catch (\Throwable $e) { - log_message('error', 'Admin add early dismissal failed: ' . $e->getMessage()); - return redirect()->back()->withInput()->with('error', 'Could not save early dismissal. Please try again.'); - } - - $name = trim(($stu['firstname'] ?? '') . ' ' . ($stu['lastname'] ?? '')); - return redirect()->to(site_url('attendance/early-dismissals')) - ->with('message', 'Early dismissal saved for ' . esc($name) . ' on ' . esc($reportDate) . ' at ' . esc(substr($dismissTime, 0, 5)) . '.'); - } - - - - // POST (AJAX): check existing submissions for given date and student IDs - public function checkExisting() - { - if (! $this->request->isAJAX()) { - return $this->response->setStatusCode(405)->setJSON(['ok' => false, 'error' => 'Method not allowed']); - } - - $datesPost = $this->request->getPost('dates'); - $dateSingle = $this->request->getPost('date'); - $dateValues = []; - if (is_array($datesPost)) { - foreach ($datesPost as $d) { - $dateValues[] = substr((string) $d, 0, 10); - } - } elseif ($dateSingle !== null) { - $dateValues[] = substr((string) $dateSingle, 0, 10); - } - $dateValues = array_values(array_unique(array_filter($dateValues, static function ($d) { - return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d); - }))); - $type = (string) $this->request->getPost('type'); - $ids = (array) $this->request->getPost('student_ids'); - - // Basic validation - if (empty($dateValues)) { - return $this->response->setStatusCode(400)->setJSON(['ok' => false, 'error' => 'Invalid date']); - } - if (!in_array($type, ['absent', 'late', 'early_dismissal'], true)) { - return $this->response->setStatusCode(400)->setJSON(['ok' => false, 'error' => 'Invalid type']); - } - $studentIds = array_values(array_unique(array_map('intval', $ids))); - $studentIds = array_filter($studentIds, static fn($v) => $v > 0); - if (empty($studentIds)) { - return $this->response->setJSON(['ok' => true, 'students' => []]); - } - - // Fetch existing submissions for these students on this date - $rows = $this->reportModel->builder() - ->select('parent_attendance_reports.student_id, parent_attendance_reports.type, parent_attendance_reports.report_date, s.firstname, s.lastname') - ->join('students s', 's.id = parent_attendance_reports.student_id', 'left') - ->whereIn('parent_attendance_reports.report_date', $dateValues) - ->whereIn('parent_attendance_reports.student_id', $studentIds) - ->get()->getResultArray(); - - $map = []; - foreach ($rows as $r) { - $sid = (int) ($r['student_id'] ?? 0); - if ($sid <= 0) continue; - if (!isset($map[$sid])) { - $map[$sid] = [ - 'student_id' => $sid, - 'firstname' => (string) ($r['firstname'] ?? ''), - 'lastname' => (string) ($r['lastname'] ?? ''), - 'dates' => [], - ]; - } - $dVal = substr((string) ($r['report_date'] ?? ''), 0, 10); - if ($dVal === '') { - continue; - } - if (!isset($map[$sid]['dates'][$dVal])) { - $map[$sid]['dates'][$dVal] = []; - } - $t = (string) ($r['type'] ?? ''); - if ($t !== '' && !in_array($t, $map[$sid]['dates'][$dVal], true)) { - $map[$sid]['dates'][$dVal][] = $t; - } - } - - return $this->response->setJSON([ - 'ok' => true, - 'students' => array_values($map), - ]); - } - - // POST: update an existing parent report (limited fields) before 9:00 AM on report date - public function update() - { - $parentId = $this->resolvePrimaryParentIdFromSession(); - if (!$parentId) { - return redirect()->to('/login'); - } - - $id = (int) ($this->request->getPost('id') ?? 0); - if ($id <= 0) { - return redirect()->back()->with('error', 'Invalid report.'); - } - - $row = $this->reportModel->find($id); - if (!$row || (int)($row['parent_id'] ?? 0) !== (int)$parentId) { - return redirect()->back()->with('error', 'Report not found.'); - } - - // Only allow editing for 'new' status entries - $status = (string) ($row['status'] ?? ''); - if ($status !== 'new') { - return redirect()->back()->with('error', 'This report can no longer be edited.'); - } - - // Enforce 9:00 AM cutoff on the report_date (school timezone) - try { - $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); - $tz = new \DateTimeZone($tzName ?: 'UTC'); - $cutoff = new \DateTime(($row['report_date'] ?? '') . ' 09:00:00', $tz); - $now = new \DateTime('now', $tz); - if ($now >= $cutoff) { - return redirect()->back()->with('error', 'Editing window closed at 9:00 AM on the report date.'); - } - } catch (\Throwable $e) { - log_message('warning', 'Edit cutoff check failed: ' . $e->getMessage()); - // Fail-safe: do not allow edit if cutoff cannot be computed reliably - return redirect()->back()->with('error', 'Editing is unavailable at this time.'); - } - - // Collect permissible fields - $type = (string) ($row['type'] ?? ''); - $reason = trim((string) ($this->request->getPost('reason') ?? '')); - $arrival = (string) ($this->request->getPost('arrival_time') ?? ''); - $dismiss = (string) ($this->request->getPost('dismiss_time') ?? ''); - - $update = []; - if ($reason !== '') { - $update['reason'] = $reason; - } else { - $update['reason'] = null; // allow clearing - } - - if ($type === 'late') { - if ($arrival !== '') { - if (!preg_match('/^\d{2}:\d{2}(:\d{2})?$/', $arrival)) { - return redirect()->back()->with('error', 'Invalid arrival time format.'); - } - $update['arrival_time'] = $arrival; - } - } elseif ($type === 'early_dismissal') { - if ($dismiss !== '') { - if (!preg_match('/^\d{2}:\d{2}(:\d{2})?$/', $dismiss)) { - return redirect()->back()->with('error', 'Invalid dismissal time format.'); - } - $update['dismiss_time'] = $dismiss; - } - } - - if (empty($update)) { - return redirect()->back()->with('error', 'Nothing to update.'); - } - - try { - $this->reportModel->update($id, $update); - } catch (\Throwable $e) { - log_message('error', 'Parent report update failed: ' . $e->getMessage()); - return redirect()->back()->with('error', 'Could not save changes.'); - } - - return redirect()->to(site_url('parent/report-attendance'))->with('message', 'Changes saved.'); - } - private function resolvePrimaryParentIdFromSession(): ?int - { - $userId = (int) (session()->get('user_id') ?? 0); - $userType = (string) (session()->get('user_type') ?? ''); - if ($userId <= 0) return null; - - if ($userType === 'primary' || $userType === '') { - return $userId; - } - - if ($userType === 'secondary') { - $parentData = $this->db->table('parents') - ->select('parent_id') - ->where('secondparent_user_id', $userId) - ->orderBy('updated_at', 'DESC') - ->limit(1) - ->get()->getRowArray(); - if (!empty($parentData['parent_id'])) { - return (int) $parentData['parent_id']; - } - } - - if ($userType === 'tertiary') { - $authUserData = $this->db->table('authorized_users') - ->select('user_id as parent_id') - ->where('authorized_user_id', $userId) - ->orderBy('updated_at', 'DESC') - ->limit(1) - ->get()->getRowArray(); - if (!empty($authUserData['parent_id'])) { - return (int) $authUserData['parent_id']; - } - } - - 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. - */ - private function sendParentReportNotifications(array $context): void - { - $parentId = (int) ($context['parent_id'] ?? 0); - $submitterId = (int) ($context['submitter_id'] ?? 0); - $students = $context['students'] ?? []; - if ($parentId <= 0 || empty($students) || !is_array($students)) { - return; - } - - $primaryParent = $this->getUserContactRow($parentId); - $submitter = $submitterId > 0 ? $this->getUserContactRow($submitterId) : null; - - if (!$primaryParent && !$submitter) { - return; - } - if (!$submitter) { - $submitter = $primaryParent; - } - - $type = (string) ($context['type'] ?? 'absent'); - $typeLabel = $this->formatParentReportType($type); - $reportDate = (string) ($context['report_date'] ?? ''); - $dateCodesRaw = (array) ($context['dates'] ?? []); - $dateCodes = []; - foreach ($dateCodesRaw as $code) { - $ymd = substr((string) $code, 0, 10); - if ($ymd === '' || isset($dateCodes[$ymd])) { - continue; - } - $dateCodes[$ymd] = $ymd; - } - if (empty($dateCodes) && $reportDate !== '') { - $dateCodes[$reportDate] = $reportDate; - } - $dateCodes = array_values($dateCodes); - $effectiveDate = $dateCodes[0] ?? $reportDate; - $reportDateFmt = $this->formatReportDate($effectiveDate); - $dateLabels = array_map(function ($d) { - return $this->formatReportDate($d) ?? $d; - }, $dateCodes); - $reason = trim((string) ($context['reason'] ?? '')); - $semester = (string) ($context['semester'] ?? ''); - $schoolYear = (string) ($context['school_year'] ?? ''); - - $payload = [ - 'recipient_parent' => $submitter, - 'primary_parent' => $primaryParent, - 'students' => $students, - 'student_count' => count($students), - 'type' => $type, - 'type_label' => $typeLabel, - 'report_date' => $effectiveDate, - 'report_date_fmt' => $reportDateFmt, - 'dates' => $dateCodes, - 'date_labels' => $dateLabels, - 'date_summary' => $this->formatDateList($dateCodes), - 'reason' => $reason, - 'semester' => $semester, - 'school_year' => $schoolYear, - 'submitted_at' => local_date(utc_now(), 'm-d-Y g:i A'), - ]; - - $mailer = new EmailController(); - - $recipientEmail = trim((string) ($submitter['email'] ?? '')); - if ($recipientEmail === '' && $primaryParent && $primaryParent !== $submitter) { - $recipientEmail = trim((string) ($primaryParent['email'] ?? '')); - } - - $subjectType = $typeLabel ?: 'Attendance'; - $subjectTypeClean = ucfirst(strtolower($subjectType)); - $subjectDateSuffix = $reportDateFmt ?: ($effectiveDate ?: ''); - - if ($recipientEmail !== '') { - $subjectParent = $subjectTypeClean . ' Request Received'; - $parentBody = view('emails/parent_attendance_parent', $payload + ['subject' => $subjectParent]); - if (! $mailer->sendEmail($recipientEmail, $subjectParent, $parentBody)) { - log_message('error', 'Failed to send parent attendance confirmation to ' . $recipientEmail); - } - } - - $subjectAdmin = $subjectTypeClean . ' Request'; - $adminBody = view('emails/parent_attendance_admin', $payload + ['subject' => $subjectAdmin]); - $adminRecipient = $this->adminParentReportEmail(); - if ($adminRecipient === '' || ! $mailer->sendEmail($adminRecipient, $subjectAdmin, $adminBody)) { - log_message('error', 'Failed to send parent attendance alert to admin inbox.'); - } - } - - private function getUserContactRow(?int $userId): ?array - { - $uid = (int) ($userId ?? 0); - if ($uid <= 0) { - return null; - } - - $row = $this->db->table('users') - ->select('id, firstname, lastname, email, cellphone') - ->where('id', $uid) - ->limit(1) - ->get() - ->getRowArray(); - - return $row ?: null; - } - - private function resolveClassSectionLabel(?int $classSectionId): string - { - $cid = (int) ($classSectionId ?? 0); - if ($cid <= 0) { - return 'Not Assigned'; - } - try { - $name = $this->classSectionModel->getClassSectionNameBySectionId($cid); - if (is_string($name) && $name !== '') { - return $name; - } - } catch (\Throwable $e) { - log_message('debug', 'Failed to resolve class section label: ' . $e->getMessage()); - } - return 'Not Assigned'; - } - - private function formatParentReportType(string $type): string - { - return match ($type) { - 'late' => 'Late Arrival', - 'early_dismissal' => 'Early Dismissal', - default => 'Absence', - }; - } - - private function formatReportDate(?string $ymd): ?string - { - if (!$ymd) { - return null; - } - try { - $dt = new \DateTime($ymd); - return $dt->format('m-d-Y'); - } catch (\Throwable $e) { - return $ymd; - } - } - - private function formatTimeDisplay(?string $time): ?string - { - $t = trim((string) ($time ?? '')); - if ($t === '') { - return null; - } - $dt = \DateTime::createFromFormat('H:i:s', $t) - ?: \DateTime::createFromFormat('H:i', $t); - if ($dt instanceof \DateTime) { - return $dt->format('g:i A'); - } - return $t; - } - - private function formatDateList(array $dates): string - { - $seen = []; - $labels = []; - foreach ($dates as $d) { - $ymd = substr((string) $d, 0, 10); - if ($ymd === '' || isset($seen[$ymd])) { - continue; - } - $seen[$ymd] = true; - $labels[] = $this->formatReportDate($ymd) ?? $ymd; - } - return implode(', ', array_filter($labels, static function ($v) { - return $v !== null && $v !== ''; - })); - } - - private function adminParentReportEmail(): string - { - $candidates = [ - (string) env('MAIL_PARENT_REPORT_TO', ''), - (string) env('MAIL_DEFAULT_REPLY_TO', ''), - (string) env('MAIL_FROM_ADDRESS', ''), - ]; - - foreach ($candidates as $value) { - $trimmed = trim($value); - if ($trimmed !== '') { - return $trimmed; - } - } - - $emailConfig = config('Email'); - if ($emailConfig && !empty($emailConfig->fromEmail)) { - return $emailConfig->fromEmail; - } - - return ''; - } -} diff --git a/app/old/ParentController.php b/app/old/ParentController.php deleted file mode 100644 index d563aa76..00000000 --- a/app/old/ParentController.php +++ /dev/null @@ -1,1866 +0,0 @@ -db = \Config\Database::connect(); - $this->userModel = new UserModel(); - $this->studentModel = new StudentModel(); - $this->classSectionModel = new ClassSectionModel(); - $this->studentClassModel = new StudentClassModel(); - $this->configModel = new ConfigurationModel(); - $this->enrollmentModel = new EnrollmentModel(); - $this->chargesModel = new EventChargesModel(); - $this->eventModel = new EventModel(); - $this->eventController = new \App\Controllers\View\InvoiceController(); - $this->emergencyContactModel = new EmergencyContactModel(); - $this->medicalConditionModel = new StudentMedicalConditionModel(); - $this->allergyModel = new StudentAllergyModel(); - $this->authorizedUsersModel = new AuthorizedUserModel(); - - $this->ageDateRefernce = $this->configModel->getConfig('date_age_reference'); - $this->lastDayOfRegistration = $this->configModel->getConfig('enrollment_deadline'); - $this->schoolStartDate = $this->configModel->getConfig('fall_semester_start'); - $this->withdrawalDeadline = $this->configModel->getConfig('refund_deadline'); - $this->schoolYear = $this->configModel->getConfig('school_year'); - $this->semester = $this->configModel->getConfig('semester'); - $this->dateAgeReference = $this->configModel->getConfig('date_age_reference'); - $this->maxChilds = (int) $this->configModel->getConfig('max_kids') ?? 0; - $this->maxEmergency = (int) $this->configModel->getConfig('max_emergency') ?? 0; - - helper(['url', 'form']); - - if (!session()->get('is_logged_in')) { - return redirect()->to('/login'); - } - - // Add more role-specific checks if needed - if (session()->get('role') !== 'parent') { - return redirect()->to('/access_denied'); - } - } - - public function index() - { - // Retrieve all parents from the database where role is parent - $parents = $this->userModel->where('role', 'parent')->findAll(); - // Pass the parents to the view - return view('administrator/parent', ['parents' => $parents]); - } - - public function create() - { - // Show the form to create a new parent - return view('administrator/create_parent'); - } - - public function store() - { - // Handle the form submission to create a new parent - $data = [ - 'firstname' => $this->request->getPost('firstname'), - 'lastname' => $this->request->getPost('lastname'), - 'email' => strtolower($this->request->getPost('email')), - 'password' => password_hash($this->request->getPost('password'), PASSWORD_DEFAULT), - 'role' => 'parent', - ]; - $this->userModel->insert($data); - return redirect()->to('/administrator/parent'); - } - - public function edit($id) - { - // Retrieve the parent details to edit - $parent = $this->userModel->find($id); - return view('administrator/edit_parent', ['parent' => $parent]); - } - - public function update($id) - { - // Handle the form submission to update an existing parent - $data = [ - 'firstname' => $this->request->getPost('firstname'), - 'lastname' => $this->request->getPost('lastname'), - 'email' => strtolower($this->request->getPost('email')), - ]; - if ($this->request->getPost('password')) { - $data['password'] = password_hash($this->request->getPost('password'), PASSWORD_DEFAULT); - } - $this->userModel->update($id, $data); - return redirect()->to('/administrator/parent'); - } - - public function destroy($id) - { - // Delete the parent - $this->userModel->delete($id); - return redirect()->to('/administrator/parent'); - } - - public function attendance() - { - try { - // Get parent ID from session - $parentId = session()->get('user_id'); - - if (!$parentId) { - return redirect()->back()->with('error', 'Parent session not found.'); - } - - // Get current school year from config - $currentSchoolYear = $this->configModel->getConfig('school_year'); - - // Get selected school year (no semester filter on parent view) - $selectedYear = $this->request->getVar('school_year') ?? $currentSchoolYear; - - // Build query to retrieve attendance - $builder = $this->db->table('attendance_data'); - $builder->select('students.firstname, students.lastname, attendance_data.date, attendance_data.status, attendance_data.reason'); - $builder->join('students', 'students.id = attendance_data.student_id'); - $builder->where('attendance_data.school_year', $selectedYear); - // No semester filter so both semesters show for the selected year - $builder->where('students.parent_id', $parentId); - - $query = $builder->get(); - $attendanceResults = $query->getResultArray(); - - // Get list of available school years - $schoolYears = $this->db->table('attendance_data') - ->select('school_year') - ->distinct() - ->orderBy('school_year', 'DESC') - ->get() - ->getResultArray(); - - // If no records found, set a flag to show message in the view - if (empty($attendanceResults)) { - return view('/parent/attendance', [ - 'attendance' => null, - 'schoolYears' => $schoolYears, - 'selectedYear' => $selectedYear, - 'selectedSemester' => null, - //'error' => 'No attendance records found for the selected school year and semester.' - ]); - } - - // Return view with attendance results - return view('/parent/attendance', [ - 'attendance' => $attendanceResults, - 'schoolYears' => $schoolYears, - 'selectedYear' => $selectedYear, - 'selectedSemester' => null, - 'error' => null - ]); - } catch (Exception $e) { - log_message('error', 'Failed to retrieve attendance data: ' . $e->getMessage()); - - return view('/parent/attendance', [ - 'attendance' => null, - 'schoolYears' => [], - 'selectedYear' => $selectedYear ?? null, - 'selectedSemester' => null, - 'error' => 'Failed to retrieve attendance data. Please try again later.' - ]); - } - } - public function viewPayments() - { - $parentId = session()->get('user_id'); - - // Log the user ID for debugging - log_message('info', 'Logged in user ID: ' . $parentId); - - if (!$parentId) { - log_message('error', 'No user ID found in session.'); - return view('/parent/payment', ['invoices' => [], 'error' => 'No user ID found in session.']); - } - - // Fetch invoices for the logged-in user based on parent IDs - $builder = $this->db->table('invoices'); - $builder->select('*'); - $builder->groupStart() - ->where('parent_id', $parentId) - //->orWhere('secondparent_user_id', $userId) - ->groupEnd(); - $query = $builder->get(); - $invoices = $query->getResultArray(); - - // Log the query and results for debugging - log_message('info', 'Query executed: ' . $builder->getCompiledSelect()); - log_message('info', 'invoices: ' . print_r($invoices, true)); - - if (empty($invoices)) { - log_message('info', 'No invoices found for user ID: ' . $parentId); - } else { - log_message('info', count($invoices) . ' payment(s) found for user ID: ' . $parentId); - } - - // Pass the data to the view - return view('/parent/payment', [ - 'invoices' => $invoices - ]); - } - - public function enrollClasses() - { - try { - // Log session data for debugging - log_message('info', 'Session Data: ' . print_r(session()->get(), true)); - - // Get deadlines and school year from config - if (!$this->schoolYear) { - log_message('error', 'Current school year not found in configuration.'); - return redirect()->back()->with('error', 'Configuration error: School year missing.'); - } - - // Get selected school year from request or fallback - $selectedYear = $this->request->getGet('school_year') ?? $this->schoolYear; - $isEditable = ($selectedYear === $this->schoolYear); - - // Get parent ID from session - $parentId = session()->get('user_id'); - - if (!$parentId) { - log_message('error', 'User ID not found in session.'); - return redirect()->back()->with('error', 'User session error. Please log in again.'); - } - - // Verify user type is "parent" from the `users` table - $userData = $this->db->table('users') - ->select('user_type') - ->where('id', $parentId) - ->get() - ->getRowArray(); - - if (!$userData || $userData['user_type'] !== 'primary') { - return redirect()->back()->with('error', 'Invalid user type. Only primary parents can enroll.'); - } - - // Fetch students with parent_id = current logged in user - $students = $this->db->table('students') - ->where('parent_id', $parentId) - ->get() - ->getResultArray(); - - if (empty($students)) { - log_message('error', 'No students found for Parent ID: ' . $parentId); - return redirect()->to('/no-kids')->with('error', 'No students found. Please register your child first.'); - } - - // Map enrollment statuses - $statusMap = [ - 'admission under review' => 'admission under review', - 'payment pending' => 'payment pending', - 'enrolled' => 'enrolled', - 'withdraw under review' => 'withdraw under review', - 'refund pending' => 'refund pending', - 'withdrawn' => 'withdrawn', - 'denied' => 'denied', - 'waitlist' => 'waitlist' // ✅ Add this line - ]; - - - // Attach enrollment and class section data to each student - foreach ($students as &$student) { - $studentId = $student['id']; - - // Get class section info (can be multiple sections like Grade + Arabic) - $classSections = $this->studentClassModel->getClassSectionsByStudentId($studentId, $selectedYear, true); - $student['class_section'] = !empty($classSections) - ? implode(', ', $classSections) - : 'Class not Assigned'; - $isArabicClass = !empty($classSections) && array_reduce( - $classSections, - static fn($carry, $name) => $carry || (is_string($name) && stripos($name, 'arabic') === 0), - false - ); - - // ✅ Get enrollment status AND admission status - $enrollment = $this->db->table('enrollments') - ->select('enrollment_status, admission_status') - ->where('student_id', $studentId) - ->where('school_year', $selectedYear) - ->get() - ->getRowArray(); - - // ✅ Apply admission status override logic - if ($enrollment && isset($enrollment['admission_status'])) { - $student['admission_status'] = $enrollment['admission_status']; - - if ($enrollment['admission_status'] === 'denied') { - // Force enrollment_status to denied if admission is denied - $student['enrollment_status'] = 'denied'; - } else { - // Use enrollment_status from DB if admission is accepted/pending - $student['enrollment_status'] = $enrollment && isset($statusMap[$enrollment['enrollment_status']]) - ? $statusMap[$enrollment['enrollment_status']] - : 'not enrolled'; - } - } else { - // No enrollment record found - $student['admission_status'] = null; - $student['enrollment_status'] = 'not enrolled'; - } - - // If assigned to Arabic class without an enrollment record, display as enrolled. - if ($student['enrollment_status'] === 'not enrolled' && $isArabicClass) { - $student['enrollment_status'] = 'enrolled'; - } - - // ✅ Updated disable logic to include denied status - $student['disable_enroll'] = in_array( - $student['enrollment_status'], - ['admission under review', 'payment pending', 'enrolled', 'withdraw under review', 'denied'] - ); - } - - // Fetch available school years - $schoolYears = $this->db->table('enrollments') - ->select('school_year') - ->distinct() - ->orderBy('school_year', 'DESC') - ->get() - ->getResultArray(); - - if (empty($schoolYears)) { - $schoolYears[] = ['school_year' => $this->schoolYear]; - } - - // Render view - return view('/parent/enroll_classes', [ - 'students' => $students, - 'schoolYears' => $schoolYears, - 'selectedYear' => $selectedYear, - 'isEditable' => $isEditable, - 'withdrawalDeadline' => $this->withdrawalDeadline, - 'lastDayOfRegistration' => $this->lastDayOfRegistration, - 'schoolStartDate' => $this->schoolStartDate - ]); - } catch (Exception $e) { - log_message('error', 'An error occurred in enrollClasses: ' . $e->getMessage()); - return view('errors/html/error_500'); - } - } - - public function noKids() - { - return view('/parent/no_kids_registred'); - } - - public function enrollClassesHandler() - { - // Call enrollClasses() function at the start of this method - $this->enrollClasses(); - $refundService = new FeeCalculationService(); - - // Retrieve enrollment and withdrawal data from the POST request - $enroll = $this->request->getPost('enroll'); // Selected students for enrollment - $withdraw = $this->request->getPost('withdraw'); // Selected students for withdrawal - $parentId = session()->get('user_id'); // Parent ID from session - - if ($this->lastDayOfRegistration && local_date(utc_now(), 'Y-m-d') > date('Y-m-d', strtotime($this->lastDayOfRegistration))) { - if (!empty($enroll)) { - return redirect()->back()->with('error', 'Enrollment deadline has passed. New enrollments are not allowed.'); - } - } - - // Ensure there are students selected for either enrollment or withdrawal - if (empty($enroll) && empty($withdraw)) { - return redirect()->back()->with('error', 'No students selected for enrollment or withdrawal.'); - } - - // Handle enrollments - $studentData = []; - - if (!empty($enroll)) { - foreach ($enroll as $studentId) { - // Get student full name (supports both string return or array with firstname/lastname) - $studentInfo = $this->studentModel->getFullNameById($studentId); - - if (empty($studentName)) { - $studentName = "Student ID $studentId"; - log_message('warning', "Name for student ID $studentId not found in students table."); - } - - // Save student info into $studentData - $studentData[$studentId] = $studentInfo; // raw return from getFullName() - - // Check if the student already has an enrollment record for the current school year and semester - $existingEnrollment = $this->enrollmentModel - ->where('student_id', $studentId) - ->where('school_year', $this->schoolYear) - ->where('semester', $this->semester) - ->get() - ->getRowArray(); - - $studentSchoolId = $this->studentModel->getStudentSchoolIdByStudentId($studentId); - if (!$studentSchoolId) { - return redirect()->back()->with('error', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']}: Student school ID not found."); - } - if ($existingEnrollment) { - if ($existingEnrollment['is_withdrawn'] == 1) { - // Reactivate the enrollment if the student was previously withdrawn - $this->enrollmentModel->where('id', $existingEnrollment['id'])->update([ - 'is_withdrawn' => 0, - 'withdrawal_date' => null, - 'enrollment_status' => 'payment pending', - 'updated_at' => utc_now() - ]); - log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) has been re-enrolled in enrollment ID {$existingEnrollment['id']}."); - // Apply promotion-based class placement for the upcoming year - $this->applyPromotionAssignment((int)$studentId, (string)$this->schoolYear); - } else { - log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) is already actively enrolled."); - } - } else { - // If no enrollment record exists, insert a new enrollment record - $result = $this->enrollmentModel->insert([ - 'student_id' => $studentId, - 'parent_id' => $parentId, - 'school_year' => $this->schoolYear, - 'semester' => $this->semester, - 'enrollment_date' => local_date(utc_now(), 'Y-m-d'), - 'is_withdrawn' => 0, - 'enrollment_status' => 'admission under review', - 'admission_status' => 'pending', - 'created_at' => utc_now() - ]); - - if (!$result) { - dd($this->enrollmentModel->errors()); - } else { - log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) has been newly enrolled."); - // Apply promotion-based class placement for the upcoming year - $this->applyPromotionAssignment((int)$studentId, (string)$this->schoolYear); - } - } - } - } - - // $studentData now holds info for all students processed - - // Handle withdrawals - if (!empty($withdraw)) { - foreach ($withdraw as $studentId) { - $enrollment = $this->enrollmentModel - ->where('student_id', $studentId) - ->where('school_year', $this->schoolYear) - ->where('semester', $this->semester) - ->where('is_withdrawn', 0) // Only withdraw active enrollments - ->get() - ->getRowArray(); - - if ($enrollment !== null) { - // Update enrollment as withdrawn - $this->enrollmentModel->where('id', $enrollment['id'])->update([ - 'withdrawal_date' => local_date(utc_now(), 'Y-m-d'), - 'enrollment_status' => 'withdraw under review', // Withdrawal needs review - 'updated_at' => utc_now() - ]); - log_message('info', "Student ID $studentId has been withdrawn from enrollment ID {$enrollment['id']}."); - - // === Trigger refund process === - // Find the related invoice (you may need to adjust this based on your DB structure) - $invoice = $this->db->table('invoices') - ->where('parent_id', $parentId) - ->where('school_year', $this->schoolYear) - ->orderBy('created_at', 'DESC') - ->get() - ->getRowArray(); - - if ($invoice !== null) { - $invoiceId = $invoice['id']; - - $refundTable = $this->db->table('refunds'); - - $existingRefund = $refundTable - ->where('parent_id', $parentId) - ->where('invoice_id', $invoiceId) - ->where('school_year', $this->schoolYear) - ->get() - ->getRow(); - - if ($existingRefund) { - // Only update the fields that should change - $updateData = [ - 'reason' => 'Withdrawal under review for student ID ' . $studentId, - 'note' => null, - 'updated_by' => session()->get('user_id'), // optionally track updates - // Add other fields if *and only if* they must be changed - ]; - - $refundTable - ->where('id', $existingRefund->id) - ->update($updateData); - - log_message('info', "Refund record updated for invoice ID {$invoiceId}, student ID {$studentId}."); - } else { - // Only set these once, for new entries - $insertData = [ - 'parent_id' => $parentId, - 'invoice_id' => $invoiceId, - 'requested_at' => utc_now(), - 'school_year' => $this->schoolYear, - 'status' => 'Pending review', - 'reason' => 'Withdrawal under review for student ID ' . $studentId, - 'request' => 'new', - 'semester' => $this->semester, - 'refund_paid_amount' => 0.0, - ]; - - $refundTable->insert($insertData); - - log_message('info', "Refund record created for invoice ID {$invoiceId}, student ID {$studentId}."); - } - } else { - log_message('error', "No invoice found for parent ID {$parentId}, student ID {$studentId}."); - } - } else { - log_message('error', "No active enrollment found for student ID $studentId."); - } - } - } - - $parentData = $this->userModel->getUserInfoById($parentId); - $parentData['user_id'] = $parentId; - // Redirect to the success page after processing enrollment and withdrawal - if (!empty($withdraw)) { - // Redirect to withdrawal success page if there are withdrawals //parent/enroll_classes - return redirect()->to('/parent/enroll_classes'); - } else { - // Redirect to enrollment success page if there are enrollments - Events::trigger('admissionUnderReview', $parentData, $studentData); //send notification for enrolled student - return redirect()->to('/parent/enroll_classes'); - } - } - - /** - * If a promotion_queue record exists for this student and the given school year, - * create/update student_class row accordingly (base section until distribution), - * and mark the queue row as applied. - */ - private function applyPromotionAssignment(int $studentId, string $year): void - { - try { - $promo = new \App\Models\PromotionQueueModel(); - $classSectionModel = new \App\Models\ClassSectionModel(); - $studentClass = new \App\Models\StudentClassModel(); - - $row = $promo->where('student_id', $studentId) - ->where('school_year_to', $year) - ->first(); - - if (!$row) return; // nothing to do - - $targetSectionId = (int)($row['to_class_section_id'] ?? 0); - if ($targetSectionId <= 0) { - // Resolve base section for target class (e.g., '3') - $base = $classSectionModel->getBaseSectionByClassId((int)$row['to_class_id']); - if (!$base) { - log_message('warning', 'applyPromotionAssignment: base section not found for class_id=' . (int)$row['to_class_id']); - return; - } - $targetSectionId = (int)($base['class_section_id'] ?? 0); - } - - if ($targetSectionId <= 0) return; - - // Upsert into student_class for the target school year - $exists = $studentClass->where('student_id', $studentId) - ->where('school_year', $year) - ->first(); - - $payload = [ - 'student_id' => $studentId, - 'class_section_id' => $targetSectionId, - 'school_year' => $year, - 'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null, - 'updated_at' => utc_now(), - ]; - - if ($exists) { - $studentClass->update((int)$exists['id'], $payload); - } else { - $payload['created_at'] = utc_now(); - $studentClass->insert($payload); - } - - // Mark promotion as applied - $promo->update((int)$row['id'], [ - 'status' => 'applied', - 'updated_at' => utc_now(), - 'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null, - ]); - } catch (\Throwable $e) { - log_message('error', 'applyPromotionAssignment failed: ' . $e->getMessage()); - } - } - - public function enrollFailure() - { - echo view('/parent/enroll_failure'); - } - - public function payment() - { - // Get the logged-in user's ID - $userId = session()->get('user_id'); - - // Fetch invoices records where the user is either the first or second parent - $invoices = $this->db->table('invoices') - ->select('invoices.*, registeredKids') // Assuming 'registeredKids' is a field - ->where('parent_id', $userId) - //->orWhere('secondparent_user_id', $userId) - ->get() - ->getResultArray(); - - // Pass the invoices data to the view - return view('/parent/payment', [ - 'invoices' => $invoices - ]); - } - - public function paymentHandler() - { - $parentId = session()->get('user_id'); - - // Update all students for this parent to mark tuition as paid - $this->db->table('students') - ->where('parent_id', $parentId) - ->update(['tuition_paid' => 1]); - - // Clear the total tuition fee from the session - session()->remove('total_tuition_fee'); - - // Set success message in session - session()->setFlashdata('success', 'Tuition fees paid successfully'); - - return redirect()->to('/parent/payment_success'); - } - - public function messageParent() - { - return view('/parent/parent_message'); - } - - public function successMessage() - { - // Load the success message view - return view('/parent/success_message'); - } - - public function addSecondParent() - { - return view('/parent/add_second_parent'); - } - - public function createUserFromParentOrAuthorizedUser($userData, $relationToStudent) - { - $schoolIdService = new SchoolIdService(); - - // Step 1: Generate a secure token for email verification - $token = bin2hex(random_bytes(48)); - - // Step 2: Determine user type based on relationship - $userType = in_array(strtolower($relationToStudent), ['wife', 'husband']) ? 'Secondary' : 'Tertiary'; - - // Step 3: Validate incoming user data - $validation = \Config\Services::validation(); - $validation->setRules([ - 'firstname' => [ - 'label' => 'First Name', - 'rules' => 'required|min_length[2]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]', - 'errors' => [ - 'regex_match' => 'First name may only contain letters, spaces, and dashes.' - ] - ], - 'lastname' => [ - 'label' => 'Last Name', - 'rules' => 'required|min_length[2]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]', - 'errors' => [ - 'regex_match' => 'Last name may only contain letters, spaces, and dashes.' - ] - ], - 'email' => [ - 'label' => 'Email Address', - 'rules' => 'required|valid_email|max_length[150]|is_unique[users.email]', - 'errors' => [ - 'is_unique' => 'This email is already registered.' - ] - ], - 'cellphone' => [ - 'label' => 'Cell Phone', - 'rules' => 'required|regex_match[/^\d{10}$/]', - 'errors' => [ - 'regex_match' => 'Phone number must be exactly 10 digits.' - ] - ], - 'gender' => 'required|in_list[Male,Female]', - 'city' => 'required|max_length[100]', - 'state' => 'required|max_length[100]', - 'zip' => 'required|regex_match[/^\d{5}$/]', - ]); - - if (!$validation->run($userData)) { - log_message('error', 'User creation failed due to invalid data: ' . json_encode($validation->getErrors())); - return false; - } - - // Step 4: Prepare sanitized user data - $userEntry = [ - 'firstname' => ucfirst(strtolower($userData['firstname'])), - 'lastname' => ucfirst(strtolower($userData['lastname'])), - 'gender' => $userData['gender'], - 'cellphone' => $userData['cellphone'], - 'email' => strtolower($userData['email']), - 'address_street' => $userData['address_street'] ?? '', - 'apt' => $userData['apt'] ?? null, - 'city' => ucfirst(strtolower($userData['city'])), - 'state' => strtoupper($userData['state']), - 'zip' => $userData['zip'], - 'accept_school_policy' => $userData['accept_school_policy'] ?? 0, - 'token' => $token, - 'is_verified' => 0, - 'status' => 'Inactive', - 'user_type' => $userType, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - 'school_id' => $schoolIdService->generateUserSchoolId(), - ]; - - try { - // Step 5: Insert user into database - if (!$this->userModel->insert($userEntry)) { - log_message('error', 'Failed to insert user: ' . print_r($this->userModel->errors(), true)); - return false; - } - - $userId = $this->userModel->getInsertID(); - - // Step 6: Send activation email - $this->sendActivationEmail($userData['email'], $token); - - log_message('info', "User with ID $userId created successfully and activation email sent."); - return $userId; - } catch (Exception $e) { - log_message('error', 'Exception during user creation: ' . $e->getMessage()); - return false; - } - } - - private function sendActivationEmail($email, $token) - { - $emailController = new \App\Controllers\View\EmailController(); - - $subject = 'Activate Your Account'; - $activationLink = site_url('/user/confirm/' . $token); - $message = "Please click the following link to confirm your email and set your password: $activationLink"; - - if ($emailController->sendEmail($email, $subject, $message)) { - log_message('info', 'Activation email sent successfully to ' . $email); - } else { - log_message('error', 'Failed to send activation email to ' . $email); - } - } - - /** - * Updates or inserts an authorized user record based on user ID and email. - * - * @param int $userId The ID of the parent/user who is authorizing access. - * @param array $data Contains 'email', 'name', and any other required fields. - */ - protected function updateAuthorizedUsers($userId, $data) - { - $validation = \Config\Services::validation(); - - // Step 1: Define validation rules for authorized user fields - $validation->setRules([ - 'email' => [ - 'label' => 'Email', - 'rules' => 'required|valid_email|max_length[150]', - 'errors' => [ - 'required' => 'Email is required.', - 'valid_email' => 'Please provide a valid email address.', - 'max_length' => 'Email must be less than 150 characters.' - ] - ], - 'name' => [ - 'label' => 'Name', - 'rules' => 'required|min_length[3]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]', - 'errors' => [ - 'required' => 'Name is required.', - 'regex_match' => 'Name can only contain letters, spaces, and dashes.', - 'min_length' => 'Name must be at least 3 characters long.', - 'max_length' => 'Name must be less than 100 characters.' - ] - ] - ]); - - // Step 2: Run validation - if (!$validation->run($data)) { - log_message('error', 'Invalid authorized user data: ' . json_encode($validation->getErrors())); - return; // Skip update/insert if validation fails - } - - // Step 3: Check if an authorized user with same email/user ID exists - $existingAuthorizedUser = $this->authorizedUsersModel - ->where('user_id', $userId) - ->where('email', $data['email']) - ->first(); - - if ($existingAuthorizedUser) { - // Step 4: Update existing authorized user - $this->authorizedUsersModel->update($existingAuthorizedUser['id'], $data); - } else { - // Step 5: Insert new authorized user with pending status - $data['user_id'] = $userId; - $data['status'] = 'Pending'; // default status for new records - $this->authorizedUsersModel->insert($data); - } - } - - public function profile($id) - { - // Fetch the user's data based on the given ID - $user = $this->userModel->find($id); - - // Check if user exists - if (!$user) { - return redirect()->to('/')->with('error', 'User not found'); - } - - // Load the profile view with the user's data - return view('/profile', ['user' => $user]); - } - - public function updateProfile($id) - { - $user = $this->userModel->find($id); - - // Step 1: Check if user exists - if (!$user) { - return redirect()->to('/')->with('error', 'User not found'); - } - - // Step 2: Define validation rules - $validation = \Config\Services::validation(); - $validation->setRules([ - 'firstname' => [ - 'label' => 'First Name', - 'rules' => 'required|min_length[3]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]', - 'errors' => [ - 'regex_match' => 'First name must only contain letters, spaces, and dashes.' - ] - ], - 'lastname' => [ - 'label' => 'Last Name', - 'rules' => 'required|min_length[3]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]', - 'errors' => [ - 'regex_match' => 'Last name must only contain letters, spaces, and dashes.' - ] - ], - 'cellphone' => [ - 'label' => 'Cell Phone', - 'rules' => 'required|regex_match[/^\d{10}$/]', - 'errors' => [ - 'regex_match' => 'Cell phone must be exactly 10 digits.' - ] - ], - 'address_street' => [ - 'label' => 'Street Address', - 'rules' => 'required|min_length[5]|max_length[255]' - ], - 'city' => [ - 'label' => 'City', - 'rules' => 'required|min_length[2]|max_length[100]' - ], - 'state' => [ - 'label' => 'State', - 'rules' => 'required|min_length[2]|max_length[100]' - ], - 'zip' => [ - 'label' => 'ZIP Code', - 'rules' => 'required|regex_match[/^\d{5}$/]', - 'errors' => [ - 'regex_match' => 'ZIP code must be exactly 5 digits.' - ] - ] - ]); - - // Step 3: Validate input; reload view with error messages if validation fails - if (!$this->validate($validation->getRules())) { - return view('/profile', [ - 'user' => $user, - 'validation' => $this->validator, - ]); - } - - // Step 4: Prepare cleaned and validated data for update - $updatedData = [ - 'firstname' => ucfirst(strtolower($this->request->getPost('firstname'))), - 'lastname' => ucfirst(strtolower($this->request->getPost('lastname'))), - 'cellphone' => $this->request->getPost('cellphone'), - 'address_street' => $this->request->getPost('address_street'), - 'city' => ucfirst(strtolower($this->request->getPost('city'))), - 'state' => strtoupper($this->request->getPost('state')), - 'zip' => $this->request->getPost('zip'), - ]; - - // Step 5: Update user profile in the database - $this->userModel->update($id, $updatedData); - - // Step 6: Redirect with success message - session()->setFlashdata('success', 'Profile updated successfully'); - return redirect()->to('/profile/' . $id); - } - - public function isEmailUnique(string $email): bool - { - $tablesAndColumns = [ - 'users' => 'email', - 'emergency_contacts' => 'email', - ]; - - foreach ($tablesAndColumns as $table => $column) { - // Skip duplicate table lookups with different columns - $builder = $this->db->table($table); - $builder->where($column, $email); - $exists = $builder->countAllResults(); - - if ($exists > 0) { - return false; // Email already exists - } - } - - return true; // Email is unique - } - - - //form to save student and emergency contact data - public function saveStudentRegistration() - { - log_message('debug', print_r($this->request->getPost(), true)); - - $schoolIdService = new SchoolIdService(); - $parentId = session()->get('user_id'); - - if (!$this->lastDayOfRegistration || !strtotime($this->lastDayOfRegistration)) { - throw new \Exception('Invalid enrollment deadline date.'); - } - - if (strtolower($this->request->getMethod()) !== 'post') { - return redirect()->back()->with('error', 'Invalid request method.'); - } - - try { - // ✅ 1. Get existing data - $data = $this->getRegistrationData($parentId); - $existingKids = $data['existingKids']; - $existingECs = $data['emergencies']; - $maxChilds = $data['maxChilds']; - $maxEmergency = $data['maxEmergency']; - - $existingKidsCount = count($existingKids); - $existingECCount = count($existingECs); - - $incomingFirstNames = $this->request->getPost('studentFirstName') ?? []; - $incomingLastNames = $this->request->getPost('studentLastName') ?? []; - $incomingDOBs = $this->request->getPost('dob') ?? []; - - $newStudentCount = count(array_filter($incomingFirstNames)); - - // ✅ 2. Duplicate student check against existing records - foreach ($incomingFirstNames as $i => $firstName) { - $lastName = trim($incomingLastNames[$i] ?? ''); - $dob = trim($incomingDOBs[$i] ?? ''); - - if (empty($firstName) || empty($lastName) || empty($dob)) continue; - - foreach ($existingKids as $kid) { - if ( - strtolower($kid['firstname']) === strtolower($firstName) && - strtolower($kid['lastname']) === strtolower($lastName) && - $kid['dob'] === $dob - ) { - return redirect()->back()->withInput()->with('error', "Duplicate student detected: {$firstName} {$lastName} with DOB {$dob} already exists."); - } - } - } - - // ✅ 3. Duplicate student check within incoming data - $seenStudents = []; - foreach ($incomingFirstNames as $i => $firstName) { - $lastName = trim($incomingLastNames[$i] ?? ''); - $dob = trim($incomingDOBs[$i] ?? ''); - - if (empty($firstName) || empty($lastName) || empty($dob)) continue; - - $key = strtolower($firstName . '|' . $lastName . '|' . $dob); - if (isset($seenStudents[$key])) { - return redirect()->back()->withInput()->with('error', "Duplicate student entry in the form: {$firstName} {$lastName} with DOB {$dob}."); - } - $seenStudents[$key] = true; - } - - // ✅ 4. Emergency contact validation - $incomingECFirst = $this->request->getPost('emergency_firstname') ?? []; - $incomingECLast = $this->request->getPost('emergency_lastname') ?? []; - $incomingECPhones = $this->request->getPost('emergency_phone') ?? []; - $incomingECEmails = $this->request->getPost('emergency_email') ?? []; - - $newECCount = count(array_filter($incomingECFirst)); - - // Against existing - foreach ($incomingECFirst as $i => $first) { - $last = trim($incomingECLast[$i] ?? ''); - $phone = preg_replace('/\D/', '', $incomingECPhones[$i] ?? ''); - $email = strtolower(trim($incomingECEmails[$i] ?? '')); - - if (empty($first) || empty($last)) continue; - - foreach ($existingECs as $contact) { - $existingPhone = preg_replace('/\D/', '', $contact['cellphone']); - $existingEmail = strtolower($contact['email']); - - if ( - strtolower($contact['emergency_contact_name']) === strtolower(trim($first . ' ' . $last)) || - ($phone && $phone === $existingPhone) || - ($email && $email === $existingEmail) - ) { - return redirect()->back()->withInput()->with('error', "Duplicate emergency contact: {$first} {$last} already exists."); - } - } - } - - // Within incoming data - $seenContacts = []; - foreach ($incomingECFirst as $i => $first) { - $last = trim($incomingECLast[$i] ?? ''); - $phone = preg_replace('/\D/', '', $incomingECPhones[$i] ?? ''); - $email = strtolower(trim($incomingECEmails[$i] ?? '')); - - if (empty($first) || empty($last)) continue; - - $key = strtolower($first . '|' . $last . '|' . $phone . '|' . $email); - if (isset($seenContacts[$key])) { - return redirect()->back()->withInput()->with('error', "Duplicate emergency contact entry in the form: {$first} {$last}."); - } - $seenContacts[$key] = true; - } - - // ✅ 5. Check limits - if (($existingKidsCount + $newStudentCount) > $maxChilds) { - return redirect()->back()->withInput()->with('error', "Student limit exceeded. You have $existingKidsCount and tried to add $newStudentCount (limit: $maxChilds)."); - } - - if (($existingECCount + $newECCount) > $maxEmergency) { - return redirect()->back()->withInput()->with('error', "Emergency contact limit exceeded. You have $existingECCount and tried to add $newECCount (limit: $maxEmergency)."); - } - - // ✅ 6. Start transaction and save - $this->db->transStart(); - - $studentAdded = false; - - // Gather all POST data for last year flags - $rawPost = $this->request->getPost(); - - // Loop through students - foreach ($incomingFirstNames as $idx => $firstName) { - if (!empty($firstName)) { - // ✅ 7. Extract is_new flag (convert to boolean) - $lastYearKey = 'last_year_' . $idx; - $lastYearVal = strtolower(trim($rawPost[$lastYearKey] ?? '')); - - $isNew = match ($lastYearVal) { - 'yes' => false, // was with us last year => not new - 'no' => true, // was not with us => new student - default => null // missing or invalid - }; - - if (!isset($isNew)) { - return redirect()->back()->withInput()->with('error', "Please select if student #" . ($idx + 1) . " was with us last year."); - } - - // ✅ 8. Pass $isNew to the student save function - $result = $this->validateAndSaveOrUpdateStudent($idx, $parentId, $this->semester, $this->schoolYear, $schoolIdService, $isNew, null, null); - - if ($result) { - $studentAdded = true; - } - } - } - - $hasEC = !empty($incomingECFirst[0]) || !empty(($this->request->getPost('emergency_lastname')[0] ?? '')); - - if ($hasEC) { - $this->saveEmergencyContact($parentId, $this->semester, $this->schoolYear); - } - - if (!$studentAdded && !$hasEC) { - throw new \Exception('Please fill in at least a form before submitting.'); - } - - $this->db->transComplete(); - - if ($this->db->transStatus() === false) { - throw new \Exception('DB transaction failed.'); - } - - return redirect()->to(base_url('/parent/child_register'))->with('success', 'Registration successful!'); - } catch (\Throwable $e) { - log_message('error', $e->getMessage()); - $this->db->transRollback(); - return redirect()->back()->withInput()->with('error', $e->getMessage()); - } - } - - - // Function to check if the parent has registered kids and redirect accordingly - public function registerKidCheck() - { - $parentId = session()->get('user_id'); - - if (!$parentId) { - return redirect()->back()->with('error', 'Session expired. Please log in again.'); - } - - try { - $data = $this->getRegistrationData($parentId); - } catch (\RuntimeException $e) { - return redirect()->back()->with('error', $e->getMessage()); - } catch (\Throwable $e) { - log_message('error', "Failed to fetch registration data: " . $e->getMessage()); - return redirect()->back()->with('error', 'Failed to retrieve registration info.'); - } - $data['lastDayOfRegistration'] = $this->lastDayOfRegistration; - $data['registrationAgeDeadline'] = $this->dateAgeReference; - return view('parent/register_student', $data); - } - - private function getEnrollmentsByParent($parentId, $schoolYear) - { - return $this->enrollmentModel - ->where('parent_id', $parentId) - ->where('school_year', $schoolYear) - ->orderBy('enrollment_date', 'DESC') - ->findAll(); - } - - private function getRegistrationData(int $parentId): array - { - $enrollments = $this->getEnrollmentsByParent($parentId, $this->schoolYear); - - $enrollmentMap = []; - if (!empty($enrollments)) { - foreach ($enrollments as $enroll) { - $enrollmentMap[$enroll['student_id']] = $enroll; - } - } - - $user = $this->userModel->find($parentId); - if (!$user || $user['user_type'] !== 'primary') { - throw new \RuntimeException('Only primary parents are allowed to register children.'); - } - - $kids = $this->studentModel->where('parent_id', $parentId)->findAll(); - foreach ($kids as &$kid) { - $kid['allergies'] = $this->allergyModel - ->where('student_id', $kid['id']) - ->findColumn('allergy') ?? []; - - $kid['medical_conditions'] = $this->medicalConditionModel - ->where('student_id', $kid['id']) - ->findColumn('condition_name') ?? []; - - $kid['enrollment'] = isset($enrollmentMap[$kid['id']]['id']) && !empty($enrollmentMap[$kid['id']]['id']) ? 1 : 0; - } - - $emergencies = $this->emergencyContactModel->where('parent_id', $parentId)->findAll(); - - return [ - 'existingKids' => $kids, - 'emergencies' => $emergencies, - 'parent' => $user, - 'maxChilds' => $this->maxChilds, - 'maxEmergency' => $this->maxEmergency, - 'enrollments' => $enrollments, - ]; - } - - private function validateAndSaveOrUpdateStudent($idx, $parentId, $semester, $schoolYear, $schoolIdService, $isNew = null, $studentId = null) - { - $firstName = $this->request->getPost('studentFirstName')[$idx] ?? null; - $lastName = $this->request->getPost('studentLastName')[$idx] ?? null; - $dob = $this->request->getPost('dob')[$idx] ?? null; - $gender = $this->request->getPost('gender')[$idx] ?? null; - $grade = $this->request->getPost('registration_grade')[$idx] ?? null; - $conditions = $this->request->getPost('medical_conditions')[$idx] ?? []; - $allergies = $this->request->getPost('allergies')[$idx] ?? []; - $photoRaw = $this->request->getPost('photo_consent')[$idx] ?? ''; - - if (!$firstName || !$lastName || !$dob || !$gender || !$grade) { - return false; - } - - // Normalize names: trim, collapse internal spaces, ucfirst each word - $normalize = function (string $s): string { - $s = trim($s); - $s = preg_replace('/\s+/', ' ', $s); // collapse whitespace - // Keep your existing formatName if you prefer; this is a safe default: - return mb_convert_case($s, MB_CASE_TITLE, 'UTF-8'); // "ahmed ali" -> "Ahmed Ali" - }; - - $firstName = $normalize($firstName); - $lastName = $normalize($lastName); - - // Still do your stricter name validation if desired - $this->validateNames($firstName); - $this->validateNames($lastName); - - $dobObj = new \DateTime($dob); - $objregistrationAgeDeadline = new \DateTime($this->ageDateRefernce); - $age = $dobObj->diff($objregistrationAgeDeadline)->y; - - $validation = $this->validateDobAge($dob, $this->ageDateRefernce); - if (!$validation['isValid']) { - $displayDeadline = (new DateTime($this->ageDateRefernce))->format('m-d-Y'); - session()->setFlashdata( - 'error', - "Student '{$firstName} {$lastName}' {$validation['message']}. Age at deadline would be: $displayDeadline." - ); - return false; - } - - $photoConsent = strtolower($photoRaw) === 'yes' ? 1 : 0; - - $studentData = [ - 'firstname' => $firstName, - 'lastname' => $lastName, - 'age' => $age, - 'dob' => $dobObj->format('Y-m-d'), - 'gender' => $gender, - 'registration_grade' => $grade, - 'photo_consent' => $photoConsent, - 'parent_id' => (int) $parentId, - 'year_of_registration' => date('Y'), - 'school_year' => $schoolYear, - 'semester' => $semester, - ]; - - if (!is_null($isNew)) { - $studentData['is_new'] = $isNew ? 1 : 0; - } - - // ---------- DUPLICATE CHECK (case/space-insensitive) ---------- - // Using LOWER(TRIM(...)) bound safely; CI4 will bind the value. - $fnKey = mb_strtolower(trim(preg_replace('/\s+/', ' ', $firstName)), 'UTF-8'); - $lnKey = mb_strtolower(trim(preg_replace('/\s+/', ' ', $lastName)), 'UTF-8'); - $dobStr = $dobObj->format('Y-m-d'); - -$existing = $this->studentModel - ->where('school_year', $schoolYear) - ->where('dob', $dobStr) - ->where('firstname', $firstName) // already normalized above - ->where('lastname', $lastName) // already normalized above - ->first(); - - if (!$studentId && $existing) { - session()->setFlashdata( - 'error', - "Student '{$firstName} {$lastName}' with the same birthdate is already registered for $schoolYear." - ); - return false; - } - - - // ---------- UPDATE OR INSERT ---------- - if ($studentId) { - $this->studentModel->update($studentId, $studentData); - } else { - $studentData['registration_date'] = utc_now(); - $studentData['tuition_paid'] = 0; - $studentData['school_id'] = $schoolIdService->generateStudentSchoolId(); - - try { - $studentId = $this->studentModel->insert($studentData, true); - } catch (\CodeIgniter\Database\Exceptions\DatabaseException $e) { - // If DB unique index (below) triggers 1062 duplicate-key, show a friendly message - if (strpos($e->getMessage(), '1062') !== false) { - session()->setFlashdata('error', "Student '{$firstName} {$lastName}' with the same birthdate is already registered for $schoolYear."); - return false; - } - throw $e; // other DB errors bubble up - } - } - - // ---------- SAVE MEDICAL CONDITIONS ---------- - $this->medicalConditionModel->where('student_id', $studentId)->delete(); - foreach ((array) $conditions as $c) { - $c = trim($c); - if ($c !== '') { - $this->medicalConditionModel->insert([ - 'student_id' => $studentId, - 'condition_name' => $c, - ]); - } - } - - // ---------- SAVE ALLERGIES ---------- - $this->allergyModel->where('student_id', $studentId)->delete(); - foreach ((array) $allergies as $a) { - $a = trim($a); - if ($a !== '') { - $this->allergyModel->insert([ - 'student_id' => $studentId, - 'allergy' => $a, - ]); - } - } - - return true; - } - - - - /** - * Validates if a date of birth falls within required age range by a deadline - * - * @param string $dob Date of birth (YYYY-MM-DD format) - * @param string $registrationAgeDeadline Deadline date (YYYY-MM-DD format) - * @param int $minAge Minimum required age (default: 5) - * @param int $maxAge Maximum allowed age (default: 18) - * @return array ['isValid' => bool, 'message' => string] - */ - function validateDobAge(string $dob, string $registrationAgeDeadline, int $minAge = 5, int $maxAge = 18): array - { - $response = ['isValid' => false, 'message' => '', 'age' => null]; - - // -- Use a stable timezone for all comparisons (UTC is safest) - $tz = new DateTimeZone('UTC'); - - // 1) Empty? - $dob = trim($dob); - if ($dob === '') { - $response['message'] = 'Date of birth is required'; - return $response; - } - - // 2) Strict-parse DOB as a date-only at 00:00:00 (use leading "!" to zero unspecified fields) - $birthDate = DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $tz); - $errs = DateTimeImmutable::getLastErrors(); - if ($birthDate === false || ($errs['warning_count'] ?? 0) > 0 || ($errs['error_count'] ?? 0) > 0) { - $response['message'] = 'Invalid date format (Use YYYY-MM-DD)'; - return $response; - } - - // 3) Parse deadline; if it's date-only, we set it to end-of-day to be inclusive - try { - $deadline = new DateTimeImmutable($registrationAgeDeadline, $tz); - } catch (Throwable $e) { - $deadline = new DateTimeImmutable('now', $tz); - } - // Inclusive of the deadline date - $deadline = $deadline->setTime(23, 59, 59); - - // 4) Age at deadline (now that times are normalized) - $ageAtDeadline = $birthDate->diff($deadline)->y; - $response['age'] = $ageAtDeadline; - - // 5) Allowed birthdate window (inclusive) - // - Earliest birthdate = deadline - maxAge years (exactly maxAge on deadline is OK) - // - Latest birthdate = deadline - minAge years (exactly minAge on deadline is OK) - $minBirthDate = $deadline->modify("-{$maxAge} years")->setTime(0, 0, 0); // earliest allowed - $maxBirthDate = $deadline->modify("-{$minAge} years")->setTime(23, 59, 59); // latest allowed - - // 6) Validate - $isValid = ($birthDate >= $minBirthDate) && ($birthDate <= $maxBirthDate); - $response['isValid'] = $isValid; - - if (!$isValid) { - $response['message'] = sprintf( - 'Must be %d-%d years old by %s. Current age would be: %d', - $minAge, - $maxAge, - $deadline->format('m-d-Y'), - $ageAtDeadline - ); - } - - return $response; - } - - - private function validateNames($name) - { - if (!preg_match('/^[A-Za-z\s\-]{2,30}$/', $name)) { - throw new InvalidArgumentException("Invalid name format: Only letters, spaces, or dashes (2–30 chars) allowed."); - } - } - - private function saveEmergencyContact($parentId, $semester, $schoolYear, $single = null, $id = null) - { - $phoneFormatter = new PhoneFormatterService(); // Load the formatter service - - // ✅ SINGLE CONTACT MODE - if ($single !== null) { - $firstName = $this->formatName($single['first_name'] ?? ''); - $lastName = $this->formatName($single['last_name'] ?? ''); - $relation = trim($single['relation'] ?? ''); - $phone = $phoneFormatter->formatPhoneNumber($single['cellphone'] ?? ''); - $email = strtolower(trim($single['email'] ?? '')); - - if ($firstName === '' && $lastName === '' && $phone === '(000)-000-0000' && $email === '' && $relation === '') { - return; - } - - $this->validateNames($firstName); - $this->validateNames($lastName); - - if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) { - throw new \Exception('Invalid email format for emergency contact.'); - } - - $fullName = $firstName . ' ' . $lastName; - - $data = [ - 'parent_id' => $parentId, - 'emergency_contact_name' => $fullName, - 'cellphone' => $phone, - 'email' => $email, - 'relation' => $relation, - 'semester' => $semester, - 'school_year' => $schoolYear, - 'updated_at' => utc_now(), - ]; - - if ($id !== null) { - $duplicate = $this->emergencyContactModel - ->where('parent_id', $parentId) - ->where('emergency_contact_name', $fullName) - ->where('cellphone', $phone) - ->where('email', $email) - ->where('relation', $relation) - ->where('id !=', $id) - ->first(); - - if ($duplicate) { - session()->setFlashdata('error', 'Another emergency contact with the same information already exists.'); - return redirect()->back()->withInput(); - } - - $this->emergencyContactModel->update($id, $data); - } else { - $exists = $this->emergencyContactModel->where([ - 'parent_id' => $parentId, - 'emergency_contact_name' => $fullName, - 'cellphone' => $phone, - 'email' => $email, - 'relation' => $relation, - ])->first(); - - if ($exists) { - session()->setFlashdata('error', 'This emergency contact is already registered.'); - return redirect()->back()->withInput(); - } - - $this->emergencyContactModel->insert($data); - } - } - - // ✅ BULK MODE - $firstNames = $this->request->getPost('emergency_firstname') ?? []; - $lastNames = $this->request->getPost('emergency_lastname') ?? []; - $relations = $this->request->getPost('emergency_relation') ?? []; - $phones = $this->request->getPost('emergency_phone') ?? []; - $emails = $this->request->getPost('emergency_email') ?? []; - - foreach ($firstNames as $idx => $first) { - $firstName = $this->formatName($first ?? ''); - $lastName = $this->formatName($lastNames[$idx] ?? ''); - $relation = trim($relations[$idx] ?? ''); - $phone = $phoneFormatter->formatPhoneNumber($phones[$idx] ?? ''); - $email = strtolower(trim($emails[$idx] ?? '')); - - if ($firstName === '' && $lastName === '' && $phone === '(000)-000-0000' && $email === '' && $relation === '') { - continue; - } - - if ($phone === '(000)-000-0000') { - throw new \Exception('Invalid phone number.'); - } - if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) { - throw new \Exception('Invalid email format for emergency contact.'); - } - - $fullName = $firstName . ' ' . $lastName; - - $exists = $this->emergencyContactModel->where([ - 'parent_id' => $parentId, - 'emergency_contact_name' => $fullName, - 'cellphone' => $phone, - 'email' => $email, - 'relation' => $relation, - ])->first(); - - if (!$exists) { - $this->emergencyContactModel->insert([ - 'parent_id' => $parentId, - 'emergency_contact_name' => $fullName, - 'cellphone' => $phone, - 'email' => $email, - 'relation' => $relation, - 'semester' => $semester, - 'school_year' => $schoolYear, - ]); - } - } - } - - public function editEmergencyContact($id = null) - { - if ($id === null) { - $parentId = session()->get('user_id'); - $contacts = $this->emergencyContactModel->where('parent_id', $parentId)->findAll(); - return view('/parent/edit_emergency_contact', ['contacts' => $contacts]); - } - - if (strtolower($this->request->getMethod()) === 'post') { - $rules = [ - 'emergency_first_name' => [ - 'label' => 'First Name', - 'rules' => 'required|min_length[2]|max_length[50]|regex_match[/^[-A-Za-z ]+$/]', - 'errors' => [ - 'regex_match' => 'First name may only contain letters, spaces, and dashes.' - ] - ], - 'emergency_last_name' => [ - 'label' => 'Last Name', - 'rules' => 'required|min_length[2]|max_length[50]|regex_match[/^[-A-Za-z ]+$/]', - 'errors' => [ - 'regex_match' => 'Last name may only contain letters, spaces, and dashes.' - ] - ], - 'cellphone' => [ - 'label' => 'Cell Phone', - 'rules' => 'required|regex_match[/^\(?\d{3}\)?[-\s]?\d{3}[-\s]?\d{4}$/]', - 'errors' => [ - 'regex_match' => 'Please enter a valid phone number (e.g., 123-456-7890 or (123) 456 7890).' - ] - ], - - 'relation' => [ - 'label' => 'Relation', - 'rules' => 'required|min_length[3]|max_length[50]' - ], - 'email' => [ - 'label' => 'Email', - 'rules' => 'permit_empty|valid_email|max_length[150]' - ] - ]; - - if (!$this->validate($rules)) { - return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); - } - - $parentId = session()->get('user_id'); - $semester = session()->get('active_semester'); - $schoolYear = session()->get('active_school_year'); - - $this->saveEmergencyContact($parentId, $semester, $schoolYear, [ - 'first_name' => $this->request->getPost('emergency_first_name'), - 'last_name' => $this->request->getPost('emergency_last_name'), - 'cellphone' => $this->request->getPost('cellphone'), - 'email' => $this->request->getPost('email'), - 'relation' => $this->request->getPost('relation'), - ], $id); - - return redirect()->to('/parent/child_register') - ->with('message', 'Emergency contact updated successfully.'); - } - } - - public function editStudent($id) - { - $schoolIdService = new \App\Services\SchoolIdService(); - - if (strtolower($this->request->getMethod()) !== 'post') { - return redirect()->back()->with('error', 'Invalid request.'); - } - - $rules = [ - 'firstname' => [ - 'label' => 'First Name', - 'rules' => 'required|min_length[2]|max_length[255]|regex_match[/^[A-Za-z\s\-]+$/]', - 'errors' => ['regex_match' => 'Only letters, spaces, and dashes allowed.'] - ], - 'lastname' => [ - 'label' => 'Last Name', - 'rules' => 'required|min_length[2]|max_length[255]|regex_match[/^[A-Za-z\s\-]+$/]', - 'errors' => ['regex_match' => 'Only letters, spaces, and dashes allowed.'] - ], - 'dob' => 'required|valid_date', - 'gender' => 'required|in_list[Male,Female]', - 'photo_consent' => 'required|in_list[0,1]', - 'registration_grade' => 'permit_empty|max_length[50]', - ]; - - // ✅ Validate first, keep scalar post data for proper error messages - if (!$this->validate($rules)) { - return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); - } - - $parentId = session()->get('user_id'); - - // Prepare formData only AFTER validation - $formData = [ - 'studentFirstName' => [$this->request->getPost('firstname')], - 'studentLastName' => [$this->request->getPost('lastname')], - 'dob' => [$this->request->getPost('dob')], - 'gender' => [$this->request->getPost('gender')], - 'registration_grade' => [$this->request->getPost('registration_grade')], - 'photo_consent' => [$this->request->getPost('photo_consent')], - 'allergies' => [explode(',', $this->request->getPost('allergies') ?? '')], - 'medical_conditions' => [explode(',', $this->request->getPost('medical_conditions') ?? '')], - ]; - - // Inject temporarily only for update logic, not before validation - $this->request->setGlobal('post', $formData); - - // Save/update - $this->validateAndSaveOrUpdateStudent(0, $parentId, $this->semester, $this->schoolYear, $schoolIdService, $id, null, null); - - return redirect()->to('/parent/child_register')->with('success', 'Student updated!'); - } - - public function deleteStudent($id) - { - if (strtolower($this->request->getMethod()) !== 'post') { - return redirect()->back()->with('error', 'Invalid request method.'); - } - - $parentId = session()->get('user_id'); - - // Confirm student exists and belongs to the parent - $student = $this->studentModel - ->where('id', $id) - ->where('parent_id', $parentId) - ->first(); - - if (!$student) { - return redirect()->back()->with('error', 'Student not found or unauthorized.'); - } - - // Perform deletion - if (!$this->studentModel->delete($id)) { - return redirect()->back()->with('error', 'Failed to delete student.'); - } - - // ✅ Check if parent has any students left - $remainingStudents = $this->studentModel - ->where('parent_id', $parentId) - ->countAllResults(); - - if ($remainingStudents === 0) { - - // Delete all emergency contacts linked to this parent - $this->emergencyContactModel->where('parent_id', $parentId)->delete(); - } - - return redirect()->to('/parent/child_register')->with('success', 'Student deleted successfully.'); - } - - - - private function formatName(string $name): string - { - $name = trim($name); - $name = strtolower($name); - $name = ucwords($name, ' '); - $name = implode('-', array_map('ucfirst', explode('-', $name))); - - return $name; - } - - - public function addStudentForm() - { - $session = session(); - $count = $session->get('registeredStudentsCount') ?? 0; - - return view('partials/student_form', [ - 'registeredStudentsCount' => $count - ]); - } - - private function calculateAge($dob) - { - try { - $parts = explode('-', $this->schoolYear); - $endYear = isset($parts[1]) ? trim($parts[1]) : date('Y'); - - $registrationAgeDeadline = "$endYear-12-31"; - - // Convert both dates into DateTime objects - $deadlineObj = \DateTime::createFromFormat('Y-m-d', $registrationAgeDeadline); - $dobObj = \DateTime::createFromFormat('Y-m-d', $dob); - - if (!$deadlineObj || !$dobObj) { - throw new \Exception('Invalid date format for DOB or Enrollment_deadline.'); - } - - // For display or logging purposes (if needed) - $deadlineForDisplay = $deadlineObj->format('Y-m-d'); - $dobForDisplay = $dobObj->format('Y-m-d'); - - // Compare dates - if ($dobObj > $deadlineObj) { - log_message('debug', "DOB ($dobForDisplay) is after deadline ($deadlineForDisplay)"); - return -1; // Invalid age case - } - - // Calculate age - $ageInterval = $dobObj->diff($deadlineObj); - return $ageInterval->y; - } catch (\Exception $e) { - log_message('error', 'Invalid DOB or deadline in calculateAge(): ' . $e->getMessage()); - return -1; - } - } - - public function parentEventPage() - { - $schoolYear = session()->get('school_year'); - $semester = session()->get('semester'); - $parentId = session()->get('user_id'); - - // Get active events - $activeEvents = $this->eventModel->getActiveEvents($schoolYear, $semester); - $activeEventCount = is_array($activeEvents) ? count($activeEvents) : 0; - - // Get charges (participation info) - $chargesList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear); - - // Build a map: "studentId:eventId" => [ 'participation' => ..., 'date' => ... ] - $charges = []; - 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 - ]; - } - - // Get enrolled students - $students = $this->enrollmentModel->getEnrolledStudents($parentId, $schoolYear); - - return view('parent/event_participation', [ - 'activeEvents' => $activeEvents, - 'charges' => $charges, - 'yourStudents' => $students, - 'activeEventCount' => $activeEventCount, - ]); - } - - public function updateParticipation() - { - $participations = $this->request->getPost('participation'); // ['student_id:event_id' => 'yes'|'no'] - $parentId = session()->get('user_id'); - - foreach ($participations as $key => $value) { - [$studentId, $eventId] = explode(':', $key); - - $existing = $this->chargesModel->where([ - 'parent_id' => $parentId, - 'student_id' => $studentId, - 'event_id' => $eventId, - ])->first(); - - if ($value === 'no') { - if ($existing) { - $this->chargesModel->delete($existing['id']); - } - continue; // skip to next entry - } - - // value is 'yes' - if ($existing) { - $this->chargesModel->update($existing['id'], ['participation' => $value]); - } else { - $event = $this->eventModel->getEvent($eventId, $this->schoolYear); - - $this->chargesModel->insert([ - 'parent_id' => $parentId, - 'student_id' => $studentId, - 'event_id' => $eventId, - 'participation' => $value, - 'charged' => $event['amount'], - 'school_year' => $this->schoolYear, - 'semester' => $this->semester, - 'updated_by' => $parentId - ]); - } - } - //recalculate the invoice - $this->eventController->generateInvoice($parentId); - - return redirect()->back()->with('success', 'Participation updated'); - } -} diff --git a/app/old/PurchaseOrderController.php b/app/old/PurchaseOrderController.php deleted file mode 100644 index c0144188..00000000 --- a/app/old/PurchaseOrderController.php +++ /dev/null @@ -1,227 +0,0 @@ -poModel = new PurchaseOrderModel(); - $this->itemModel = new PurchaseOrderItemModel(); - $this->supplierModel = new SupplierModel(); - $this->supplyModel = new SupplyModel(); - $this->txnModel = new SupplyTransactionModel(); - $this->db = \Config\Database::connect(); - } - - public function index() - { - $q = trim($this->request->getGet('q') ?? ''); - $builder = $this->poModel->select('purchase_orders.*, suppliers.name AS supplier_name') - ->join('suppliers', 'suppliers.id = purchase_orders.supplier_id', 'left'); - - if ($q !== '') { - $builder->groupStart() - ->like('po_number', $q) - ->orLike('suppliers.name', $q) - ->groupEnd(); - } - - $orders = $builder->orderBy('purchase_orders.created_at', 'DESC')->paginate(20); - return view('inventory/po_index', [ - 'orders' => $orders, - 'pager' => $this->poModel->pager, - 'q' => $q, - ]); - } - - public function create() - { - return view('inventory/po_form', [ - 'title' => 'Create Purchase Order', - 'action' => site_url('inventory/po/store'), - 'suppliers' => $this->supplierModel->orderBy('name')->findAll(), - 'supplies' => $this->supplyModel->orderBy('name')->findAll(), - ]); - } - - public function store() - { - $po = $this->request->getPost([ - 'po_number','supplier_id','order_date','expected_date','notes' - ]); - $status = $this->request->getPost('status') ?? 'ordered'; - - $supply_ids = $this->request->getPost('item_supply_id') ?? []; - $descs = $this->request->getPost('item_description') ?? []; - $qtys = $this->request->getPost('item_quantity') ?? []; - $unit_costs = $this->request->getPost('item_unit_cost') ?? []; - - if (empty($supply_ids)) { - return redirect()->back()->withInput()->with('error', 'Add at least one line item.'); - } - - // compute totals - $subtotal = 0.0; - $items = []; - foreach ($supply_ids as $i => $sid) { - $q = max(0, (int)($qtys[$i] ?? 0)); - $uc = (float)($unit_costs[$i] ?? 0); - if ($sid && $q > 0) { - $line = $q * $uc; - $subtotal += $line; - $items[] = [ - 'supply_id' => (int)$sid, - 'description' => trim($descs[$i] ?? ''), - 'quantity' => $q, - 'unit_cost' => $uc, - 'received_qty'=> 0, - ]; - } - } - if (!$items) { - return redirect()->back()->withInput()->with('error', 'Valid line items required.'); - } - $tax = 0.00; - $total = $subtotal + $tax; - - $this->db->transStart(); - - $po['status'] = in_array($status, ['draft','ordered'], true) ? $status : 'ordered'; - $po['subtotal'] = $subtotal; - $po['tax'] = $tax; - $po['total'] = $total; - - if (!$this->poModel->save($po)) { - $this->db->transRollback(); - return redirect()->back()->withInput()->with('error', implode("\n", $this->poModel->errors())); - } - $poId = $this->poModel->getInsertID(); - - foreach ($items as $it) { - $it['purchase_order_id'] = $poId; - if (!$this->itemModel->insert($it)) { - $this->db->transRollback(); - return redirect()->back()->withInput()->with('error', 'Failed to save line items.'); - } - } - - $this->db->transComplete(); - if ($this->db->transStatus() === false) { - return redirect()->back()->withInput()->with('error', 'Failed to save purchase order.'); - } - - return redirect()->to(site_url('inventory/po/show/'.$poId))->with('success', 'PO created.'); - } - - public function show($id) - { - $po = $this->poModel->select('purchase_orders.*, suppliers.name AS supplier_name') - ->join('suppliers', 'suppliers.id = purchase_orders.supplier_id', 'left') - ->find($id); - if (!$po) return redirect()->to('inventory/po')->with('error', 'PO not found.'); - - $items = $this->itemModel->select('purchase_order_items.*, supplies.name AS supply_name, supplies.unit as supply_unit') - ->join('supplies', 'supplies.id = purchase_order_items.supply_id', 'left') - ->where('purchase_order_id', $id)->findAll(); - - return view('inventory/po_show', [ - 'po' => $po, - 'items' => $items, - ]); - } - - /** - * Receive items (partial or full) - * POST body: received[item_id] = qty_to_receive - */ - public function receive($id) - { - $po = $this->poModel->find($id); - if (!$po || in_array($po['status'], ['canceled','received'], true)) { - return redirect()->to('inventory/po/show/'.$id)->with('error', 'PO not receivable.'); - } - - $received = $this->request->getPost('received') ?? []; // [itemId => qty] - if (!$received) { - return redirect()->to('inventory/po/show/'.$id)->with('error', 'No items to receive.'); - } - - $issuedBy = (string) (session('user.email') ?? session('user.username') ?? 'system'); - - $this->db->transStart(); - - $completed = true; - foreach ($received as $itemId => $qty) { - $qty = (int)$qty; - if ($qty <= 0) continue; - - $item = $this->itemModel->where('purchase_order_id', $id)->find($itemId); - if (!$item) { $completed = false; continue; } - - $remaining = (int)$item['quantity'] - (int)$item['received_qty']; - $toReceive = min($remaining, $qty); - if ($toReceive <= 0) continue; - - // Update item received qty - $this->itemModel->update($itemId, [ - 'received_qty' => (int)$item['received_qty'] + $toReceive - ]); - - // Update supply on hand - $supply = $this->supplyModel->find($item['supply_id']); - if (!$supply) { $completed = false; continue; } - $newQty = (int)$supply['qty_on_hand'] + $toReceive; - $this->supplyModel->update($supply['id'], ['qty_on_hand' => $newQty]); - - // Log transaction IN - $this->txnModel->insert([ - 'supply_id' => $supply['id'], - 'type' => 'in', - 'quantity' => $toReceive, - 'ref' => 'PO ' . $po['po_number'], - 'issued_to' => 'Inventory', - 'issued_by' => $issuedBy, - 'notes' => 'Received against PO', - ]); - - if (($item['received_qty'] + $toReceive) < $item['quantity']) { - $completed = false; - } - } - - // Set PO status - $this->poModel->update($id, ['status' => $completed ? 'received' : 'ordered']); - - $this->db->transComplete(); - if ($this->db->transStatus() === false) { - return redirect()->to('inventory/po/show/'.$id)->with('error', 'Failed to receive items.'); - } - - return redirect()->to('inventory/po/show/'.$id)->with('success', $completed ? 'PO fully received.' : 'PO partially received.'); - } - - public function cancel($id) - { - $po = $this->poModel->find($id); - if (!$po || $po['status'] === 'received') { - return redirect()->to('inventory/po/show/'.$id)->with('error', 'Cannot cancel this PO.'); - } - $this->poModel->update($id, ['status' => 'canceled']); - return redirect()->to('inventory/po/show/'.$id)->with('success', 'PO canceled.'); - } -} diff --git a/app/old/SchoolIdService.php b/app/old/SchoolIdService.php deleted file mode 100644 index 84876f7d..00000000 --- a/app/old/SchoolIdService.php +++ /dev/null @@ -1,74 +0,0 @@ -db = \Config\Database::connect(); - $this->userModel = new UserModel(); - } - - public function assignSchoolIdToUser($userId) - { - $user = $this->userModel->find($userId); - if (!$user || $user['school_id']) { - return $user['school_id'] ?? null; - } - - $schoolId = $this->generateUserSchoolId(); - - $this->userModel->update($userId, ['school_id' => $schoolId]); - - return $schoolId; - } - -public function generateStudentSchoolId() -{ - $year = date('y'); - $maxId = 0; - - // Match the real prefix: STU + year - $studentMax = $this->db->table('students') - ->select('MAX(school_id) AS max_id') - ->like('school_id', "STU{$year}", 'after') - ->get() - ->getRowArray(); - - if (!empty($studentMax['max_id'])) { - // Skip "STUyy" (5 chars) - $numericPart = substr($studentMax['max_id'], 5); - $maxId = (int) $numericPart; - } - - $nextId = str_pad($maxId + 1, 5, '0', STR_PAD_LEFT); - return "STU{$year}{$nextId}"; -} - - - public function generateUserSchoolId() - { - $year = date('y'); - $maxId = 0; - - // Get maximum user ID for current year - $userMax = $this->db->table('users')->select('MAX(school_id) AS max_id') - ->notLike('school_id', 'S%') // Exclude student IDs - ->like('school_id', $year, 'after')->get()->getRowArray(); - - if (!empty($userMax['max_id'])) { - $numericPart = substr($userMax['max_id'], 2); // Skip 2-digit year - $maxId = max($maxId, (int)$numericPart); - } - - $nextId = str_pad($maxId + 1, 5, '0', STR_PAD_LEFT); - return "{$year}{$nextId}"; - } -} diff --git a/app/old/SlipPrinterController.php b/app/old/SlipPrinterController.php deleted file mode 100644 index 52e9b645..00000000 --- a/app/old/SlipPrinterController.php +++ /dev/null @@ -1,691 +0,0 @@ -configModel = new ConfigurationModel(); - $this->semester = $this->configModel->getConfig('semester'); - $this->schoolYear = $this->configModel->getConfig('school_year'); - } - /** - * POST /slips/print - * Expected fields (POST): school_year, student_name, date, time_in, grade, reason, admin_name - * You can wire this to your own form/data source; all fields are strings. - */ - public function print(): ResponseInterface - { - $req = $this->request; - - $data = [ - 'school_year' => trim((string) $req->getVar('school_year') ?: ''), - 'student_name' => trim((string) $req->getVar('student_name') ?: ''), - 'date' => trim((string) $req->getVar('date') ?: date('m/d/Y')), - 'time_in' => trim((string) $req->getVar('time_in') ?: date('h:i A')), - 'grade' => trim((string) $req->getVar('grade') ?: ''), - 'reason' => trim((string) $req->getVar('reason') ?: ''), - 'admin_name' => trim((string) $req->getVar('admin_name') ?: ''), - ]; - - // Always prefer current logged-in user's full name from DB - $adminFromDb = $this->currentAdminName(); - if ($adminFromDb !== '') { - $data['admin_name'] = $adminFromDb; - } - - // Validate minimal fields - if ($data['student_name'] === '') { - return $this->response->setStatusCode(422)->setJSON(['error' => 'Student name is required.']); - } - - try { - // Persist a log (best-effort) before generating PDF - try { - $printedBy = (int) (session()->get('user_id') ?? 0) ?: null; - $cfg = new ConfigurationModel(); - $semester = (string)($cfg->getConfig('semester') ?? ''); - $logData = [ - 'school_year' => $data['school_year'], - 'semester' => $semester, - 'student_name' => $data['student_name'], - 'slip_date' => $this->toDbDate($data['date']), - 'time_in' => $this->toDbTime($data['time_in']), - 'grade' => $data['grade'], - 'reason' => $data['reason'], - 'admin_name' => $data['admin_name'], - ]; - (new LateSlipLogModel())->logSlip($logData, $printedBy); - } catch (\Throwable $e) { - log_message('error', 'Late slip log insert failed: ' . $e->getMessage()); - } - - // Generate and stream a PDF instead of direct printer output - return $this->renderSlipPdfResponse($data); - } catch (\Throwable $e) { - log_message('error', 'Late slip PDF generation failed: ' . $e->getMessage()); - return $this->response->setStatusCode(500)->setJSON([ - 'error' => 'Printing failed.', - 'details' => $e->getMessage(), - ]); - } - } - - /** - * Show or print preview of late slips. - */ - public function preview(): ResponseInterface - { - $req = $this->request; - - try { - // ---- Step 1: Resolve current school year ---- - $currentYear = trim((string) ($this->schoolYear ?? '')); - if ($currentYear === '') { - $latest = (new LateSlipLogModel()) - ->select('school_year') - ->orderBy('id', 'DESC') - ->first(); - $currentYear = $latest['school_year'] ?? (date('Y') . '-' . (date('Y') + 1)); - } - - $selectedYear = trim((string) $req->getVar('school_year') ?: $currentYear); - $selectedSemester = strtolower(trim((string) $req->getVar('semester') ?: '')); - - $data = [ - 'school_year' => $selectedYear, - 'student_name' => trim((string) $req->getVar('student_name') ?: ''), - 'date' => trim((string) $req->getVar('date') ?: date('m/d/Y')), - 'time_in' => trim((string) $req->getVar('time_in') ?: date('h:i A')), - 'grade' => trim((string) $req->getVar('grade') ?: ''), - 'reason' => trim((string) $req->getVar('reason') ?: ''), - 'admin_name' => trim((string) $req->getVar('admin_name') ?: ''), - ]; - - // Prefer logged-in admin name - $adminFromDb = $this->currentAdminName(); - if ($adminFromDb !== '') { - $data['admin_name'] = $adminFromDb; - } - - // ---- Handle GET (HTML preview) ---- - if (strtolower($req->getMethod()) === 'get') { - $rows = []; - $logModel = new LateSlipLogModel(); - $builder = $logModel; - - // Filter by school year if not empty - if ($selectedYear !== '') { - $builder->where('school_year', $selectedYear); - } - - // Normalize and apply semester filter - $hasFilter = false; - if ($selectedSemester !== '') { - $hasFilter = true; - if ($selectedSemester === 'fall') { - $builder->groupStart() - ->where('semester', 'fall') - ->orWhere('semester', '1') - ->orWhere('semester', 1) - ->groupEnd(); - } elseif ($selectedSemester === 'spring') { - $builder->groupStart() - ->where('semester', 'spring') - ->orWhere('semester', '2') - ->orWhere('semester', 2) - ->groupEnd(); - } - } - - // ---- Execute query ---- - $logs = $builder->orderBy('id', 'DESC')->findAll(50); - - // 🟢 Only use fallback when *no filters are selected* - if (empty($logs) && !$hasFilter && $selectedYear === '') { - $logs = (new LateSlipLogModel())->orderBy('id', 'DESC')->findAll(50); - } - - // ---- Format rows ---- - foreach ($logs as $r) { - $dispDate = ''; - $slipDateRaw = trim((string)($r['slip_date'] ?? '')); - if ($slipDateRaw !== '' && $slipDateRaw !== '0000-00-00') { - $ts = strtotime($slipDateRaw); - $dispDate = $ts ? date('m/d/Y', $ts) : ''; - } - if ($dispDate === '') { - $printedAt = trim((string)($r['printed_at'] ?? '')); - if ($printedAt !== '') { - try { - $dispDate = local_date($printedAt, 'm/d/Y'); - } catch (\Throwable $e) { - $ts = strtotime($printedAt); - $dispDate = $ts ? date('m/d/Y', $ts) : ''; - } - } - } - - $dispTime = ''; - if (!empty($r['time_in'])) { - $t = strtotime($r['time_in']) ?: strtotime('today ' . $r['time_in']); - $dispTime = $t ? date('h:i A', $t) : ''; - } - - $rows[] = [ - 'school_year' => (string)($r['school_year'] ?? ''), - 'semester' => (string)($r['semester'] ?? ''), - 'student_name' => (string)($r['student_name'] ?? ''), - 'date' => $dispDate, - 'time_in' => $dispTime, - 'grade' => (string)($r['grade'] ?? ''), - 'reason' => (string)($r['reason'] ?? ''), - 'admin_name' => (string)($r['admin_name'] ?? ''), - ]; - } - - // ---- Render the view ---- - $html = view('slips/preview_list', [ - 'rows' => $rows, - 'school_year' => $selectedYear, - 'semester' => $selectedSemester, - ]); - - return $this->response - ->setStatusCode(200) - ->setHeader('Content-Type', 'text/html; charset=UTF-8') - ->setBody($html); - } - - // ---- POST mode (JSON preview for printer) ---- - $cfg = $this->printerConfig(); - $lineWidth = (int) ($cfg['chars_per_line'] ?? 48); - $feedLines = (int) ($cfg['feed_lines'] ?? 3); - - $header = "Al Rahma School at ISGL\nSchool Year: {$data['school_year']}\n\n"; - $body = implode("\n", $this->buildSlipLines($data, $lineWidth)); - $footer = str_repeat("\n", $feedLines); - $full = $header . $body . $footer; - - return $this->response->setJSON([ - 'ok' => true, - 'text' => $full, - 'width' => $lineWidth, - ]); - } catch (\Throwable $e) { - log_message('error', 'Late slip preview failed: ' . $e->getMessage()); - return $this->response->setStatusCode(500)->setJSON([ - 'ok' => false, - 'error' => 'Preview failed.', - 'details' => $e->getMessage(), - ]); - } - } - - /** - * POST /administrator/late_slips/reprint/{id} - * Reprint a late slip from a saved log row, then redirect back with status. - */ - public function reprint($id = null): ResponseInterface - { - $id = (int) ($id ?? 0); - if ($id <= 0) { - return redirect()->back()->with('error', 'Invalid slip id.'); - } - - $row = null; - try { - $row = (new LateSlipLogModel())->find($id); - } catch (\Throwable $e) { - $row = null; - } - if (!$row) { - return redirect()->back()->with('error', 'Slip not found.'); - } - - // Map DB row to print payload - $dateDisplay = ''; - if (!empty($row['slip_date'])) { - $ts = strtotime($row['slip_date']); - $dateDisplay = $ts ? date('m/d/Y', $ts) : ''; - } - if ($dateDisplay === '') $dateDisplay = date('m/d/Y'); - - $timeDisplay = ''; - if (!empty($row['time_in'])) { - $ts = strtotime($row['time_in']); - if ($ts === false) { - // Try combining with today - $ts = strtotime('today ' . $row['time_in']); - } - $timeDisplay = $ts ? date('h:i A', $ts) : ''; - } - if ($timeDisplay === '') $timeDisplay = date('h:i A'); - - $data = [ - 'school_year' => (string) ($row['school_year'] ?? ''), - 'student_name' => (string) ($row['student_name'] ?? ''), - 'date' => $dateDisplay, - 'time_in' => $timeDisplay, - 'grade' => (string) ($row['grade'] ?? ''), - 'reason' => (string) ($row['reason'] ?? ''), - 'admin_name' => (string) ($row['admin_name'] ?? ''), - ]; - - // Always prefer current logged-in user's full name from DB - $adminFromDb = $this->currentAdminName(); - if ($adminFromDb !== '') { - $data['admin_name'] = $adminFromDb; - } - - try { - // Format lines and print - $lines = $this->buildSlipLines($data); - $printer = $this->makePrinter(); - - $printer->setJustification(Printer::JUSTIFY_CENTER); - $printer->setEmphasis(true); - $printer->text("Al Rahma School at ISGL\n"); - $printer->setEmphasis(false); - $printer->text("School Year: {$data['school_year']}\n"); - $printer->feed(); - - $printer->setJustification(Printer::JUSTIFY_LEFT); - foreach ($lines as $ln) { - $printer->text($ln . "\n"); - } - - $printer->feed((int) ($this->printerConfig()['feed_lines'] ?? 3)); - $printer->cut(); - $printer->close(); - - // Log again (best-effort) for audit trail - try { - $printedBy = (int) (session()->get('user_id') ?? 0) ?: null; - $cfg = new ConfigurationModel(); - $semester = (string)($cfg->getConfig('semester') ?? ''); - $logData = [ - 'school_year' => $data['school_year'], - 'semester' => $semester, - 'student_name' => $data['student_name'], - 'slip_date' => $this->toDbDate($data['date']), - 'time_in' => $this->toDbTime($data['time_in']), - 'grade' => $data['grade'], - 'reason' => $data['reason'], - 'admin_name' => $data['admin_name'], - ]; - (new LateSlipLogModel())->logSlip($logData, $printedBy); - } catch (\Throwable $e) { - log_message('error', 'Late slip reprint log insert failed: ' . $e->getMessage()); - } - - return redirect()->back()->with('success', 'Late slip sent to printer.'); - } catch (\Throwable $e) { - return redirect()->back()->with('error', 'Reprint failed: ' . $e->getMessage()); - } - } - - /** - * Build monospaced slip lines for an 80mm (typically 48 chars) receipt. - * - * Layout required: - * Al Rahma School at ISGL "SchoolYear" - * Student Name ___________________________ - * Date ______________ Time In ______________ - * Grade _________________________________ - * Reason _________________________________ - * _______________________________________ - * Admin Name ____________________________ - */ - private function buildSlipLines(array $data, ?int $lineWidth = null): array - { - // Choose a sane default for 58mm rolls (~24–32 chars). Fall back to env config. - $cfgWidth = (int) ($this->printerConfig()['chars_per_line'] ?? 0); - $w = $lineWidth ?? ($cfgWidth > 0 ? $cfgWidth : 32); - - $lines = []; - $lines[] = $this->fitLine('Student Name: ' . (string)($data['student_name'] ?? ''), $w); - $lines[] = $this->fitLine('Date: ' . (string)($data['date'] ?? '') . ' ' . 'Time In: ' . (string)($data['time_in'] ?? ''), $w); - $lines[] = $this->fitLine('Grade: ' . (string)($data['grade'] ?? ''), $w); - $lines[] = $this->fitLine('Reason: ' . (string)($data['reason'] ?? ''), $w); - $lines[] = $this->fitLine('Admin: ' . (string)($data['admin_name'] ?? ''), $w); - - return $lines; - } - - /** - * Create and return a configured Mike42\Escpos\Printer instance. - */ - private function makePrinter(): Printer - { - $cfg = $this->printerConfig(); - $mode = strtolower((string) ($cfg['mode'] ?? 'network')); - - if ($mode === 'windows') { - if (stripos(PHP_OS_FAMILY, 'Windows') !== false) { - $name = (string) ($cfg['windows_name'] ?? 'POS-80'); - $connector = new \Mike42\Escpos\PrintConnectors\WindowsPrintConnector($name); - return new Printer($connector); - } - log_message('warning', 'PRINTER_MODE=windows on non-Windows OS; falling back to network.'); - $mode = 'network'; - } - - if ($mode === 'usb') { - if (stripos(PHP_OS_FAMILY, 'Windows') !== false) { - $name = (string) ($cfg['windows_name'] ?? 'POS-80'); - $connector = new \Mike42\Escpos\PrintConnectors\WindowsPrintConnector($name); - return new Printer($connector); - } - $vidStr = (string) ($cfg['usb_vid'] ?? ''); - $pidStr = (string) ($cfg['usb_pid'] ?? ''); - if ($vidStr === '' || $pidStr === '') { - throw new \RuntimeException('USB mode requires PRINTER_USB_VID and PRINTER_USB_PID'); - } - $vid = $this->hexToInt($vidStr); - $pid = $this->hexToInt($pidStr); - $connector = new \Mike42\Escpos\PrintConnectors\UsbPrintConnector($vid, $pid); - return new Printer($connector); - } - - // Default: network - $host = (string) ($cfg['host'] ?? '192.168.1.100'); - $port = (int) ($cfg['port'] ?? 9100); - $connector = new \Mike42\Escpos\PrintConnectors\NetworkPrintConnector($host, $port); - return new Printer($connector); - } - - - /** - * Build a "Label: ________VALUE" style line, using underscores to fill remaining space. - * If $value is empty, just render a long blank line after the label. - */ - private function labelWithLine(string $label, string $value, int $width, ?int $fillCount = null): string - { - $label = rtrim($label, ':') . ': '; - $maxFill = $fillCount ?? max(0, $width - strlen($label)); - $filled = $value !== '' - ? $this->fixedField($value, $maxFill, '_') - : str_repeat('_', $maxFill); - - $line = $label . $filled; - - // Ensure total width, pad or trim - return $this->fitLine($line, $width); - } - - /** - * Create a single field like "Date: ________" (or with value). - */ - private function fieldWithBlanks(string $label, string $value, int $fieldWidth): string - { - $label = rtrim($label, ':') . ': '; - $fill = max(0, $fieldWidth - strlen($label)); - $content = $value !== '' ? $this->fixedField($value, $fill, '_') : str_repeat('_', $fill); - return $this->fitLine($label . $content, $fieldWidth); - } - - /** - * Join two fixed-width fields into one line (e.g., Date field + Time In field). - */ - private function joinTwoFields(string $left, string $right, int $totalWidth): string - { - $gap = ' '; - $line = $left . $gap . $right; - return $this->fitLine($line, $totalWidth); - } - - /** - * Pad/truncate a value to an exact width using a fill character (for underlines). - */ - private function fixedField(string $val, int $width, string $fillChar = '_'): string - { - $val = (string) $val; - if (strlen($val) > $width) { - return substr($val, 0, $width); - } - return $val . str_repeat($fillChar, max(0, $width - strlen($val))); - } - - /** - * Ensure any line is exactly $width characters (trim or pad spaces). - */ - private function fitLine(string $line, int $width): string - { - $line = (string) $line; - if (strlen($line) > $width) { - return substr($line, 0, $width); - } - return $line . str_repeat(' ', $width - strlen($line)); - } - - /** - * "0x1a86" -> 0x1a86 (int). Accepts plain decimal too. - */ - private function hexToInt(string $hexOrDec): int - { - $hexOrDec = trim($hexOrDec); - if ($hexOrDec === '') return 0; - if (stripos($hexOrDec, '0x') === 0) { - return intval($hexOrDec, 16); - } - return (int) $hexOrDec; - } - - /** - * Consolidated printer configuration. Supports both dotted keys (printer.*) - * and uppercase PRINTER_* keys from .env. - */ - private function printerConfig(): array - { - $get = function ($keys, $default = null) { - $keys = is_array($keys) ? $keys : [$keys]; - foreach ($keys as $k) { - $v = env($k); - if ($v !== null && $v !== '') return $v; - } - return $default; - }; - - return [ - 'mode' => strtolower((string) $get(['printer.mode', 'PRINTER_MODE'], 'network')), - 'host' => (string) $get(['printer.host', 'PRINTER_HOST'], '192.168.1.100'), - 'port' => (int) $get(['printer.port', 'PRINTER_PORT'], 9100), - 'usb_vid' => (string) $get(['printer.usb.vid', 'PRINTER_USB_VID'], ''), - 'usb_pid' => (string) $get(['printer.usb.pid', 'PRINTER_USB_PID'], ''), - 'windows_name' => (string) $get(['printer.windows.name', 'PRINTER_WINDOWS_NAME'], 'POS-80'), - 'chars_per_line' => (int) $get(['printer.chars_per_line', 'PRINTER_CHARS_PER_LINE'], 48), - 'feed_lines' => (int) $get(['printer.feed_lines', 'PRINTER_FEED_LINES'], 3), - ]; - } - - /** - * Convert user-provided date/time strings into DB formats. - */ - private function toDbDate(?string $s): ?string - { - $s = trim((string)$s); - if ($s === '') return null; - // Explicitly parse common US formats to avoid day/month swaps. - if (preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $s)) { - return $s; - } - if (preg_match('/^\\d{1,2}\\/\\d{1,2}\\/\\d{4}$/', $s)) { - $dt = \DateTime::createFromFormat('!m/d/Y', $s); - return $dt ? $dt->format('Y-m-d') : null; - } - if (preg_match('/^\\d{1,2}-\\d{1,2}-\\d{4}$/', $s)) { - $dt = \DateTime::createFromFormat('!m-d-Y', $s); - return $dt ? $dt->format('Y-m-d') : null; - } - $ts = strtotime($s); - return $ts ? date('Y-m-d', $ts) : null; - } - - private function toDbTime(?string $s): ?string - { - $s = trim((string)$s); - if ($s === '') return null; - $ts = strtotime($s); - return $ts ? date('H:i:s', $ts) : null; - } - - /** - * Compute current admin full name using session user_id and users table. - * Falls back to session firstname/lastname, then user_name, else ''. - */ - private function currentAdminName(): string - { - try { - $uid = (int) (session()->get('user_id') ?? 0); - if ($uid > 0) { - $user = (new UserModel()) - ->select('firstname, lastname') - ->find($uid); - if ($user) { - $full = trim((string)($user['firstname'] ?? '') . ' ' . (string)($user['lastname'] ?? '')); - if ($full !== '') return $full; - } - } - } catch (\Throwable $e) { - // ignore and fall back - } - - $first = trim((string)(session()->get('firstname') ?? '')); - $last = trim((string)(session()->get('lastname') ?? '')); - $full = trim($first . ' ' . $last); - if ($full !== '') return $full; - return (string)(session()->get('user_name') ?? ''); - } - - /** - * Render the slip as a PDF and stream it inline to the browser. - */ - private function renderSlipPdfResponse(array $data): ResponseInterface - { - // Build HTML from a dedicated view - // Determine paper size (mm) - $paper = strtolower((string) ($this->request->getVar('paper') ?: env('SLIP_PAPER', 'card'))); - // Estimate dynamic height based on the PDF view font and line-height - // Title + seven content lines = 8 rows total - // Defaults match the current view: 13pt font, 2.0 line-height - $fontPt = (float) ($this->request->getVar('font_pt') ?? env('SLIP_FONT_PT', 13.0)); - $lineH = (float) ($this->request->getVar('line_h') ?? env('SLIP_LINE_H', 2.0)); - $linesCount = (int) ($this->request->getVar('rows') ?? 8); - if ($linesCount < 1) { - $linesCount = 8; - } - $ptPerLine = $fontPt * $lineH; - $mmPerPt = 25.4 / 72.0; // 1pt in mm - $contentMm = $ptPerLine * $linesCount * $mmPerPt; // content only - $pageMarginsMm = 3.0 * 2; // top+bottom from CSS - // Allow caller to increase safety buffer if their viewer/driver adds spacing - // Slightly bigger safety buffer to avoid second-page spillovers from drivers - $fudgeMm = (float) ($this->request->getVar('fudge_mm') ?? env('SLIP_FUDGE_MM', 12.0)); - $dynHeightMm = $contentMm + $pageMarginsMm + $fudgeMm; - - switch ($paper) { - case 'cardp': - case 'card-portrait': - $wMm = 53.98; - $hMm = max($dynHeightMm, 40.0); // portrait width, dynamic height - $wChars = 30; - break; - case 'receipt80': - $wMm = 72.0; - $hMm = 90.0; // 80mm roll (printable ~72mm) — taller to fit larger font - $wChars = 30; - break; - case 'receipt58': - $wMm = 58.0; - $hMm = 80.0; // 58mm roll — taller to fit larger font - $wChars = 24; - break; - case 'card': - default: - $wMm = 85.60; - $hMm = max($dynHeightMm, 40.0); // credit card width, dynamic height - $wChars = 40; - } - - // Optional manual override via query: height_mm (or h) - $hOverride = $this->request->getVar('height_mm') ?? $this->request->getVar('h'); - if (is_numeric($hOverride)) { - $hMm = max(30.0, min(200.0, (float) $hOverride)); - } - - // Build exactly six lines in monospace formatting - $w = $wChars; // characters per line for layout - $lines = []; - $lines[] = $this->fitLine('Al Rahma School at ISGL ' . ($data['school_year'] ?? ''), $w); - // Helper to compose "Label: value_____" without truncating value first - $compose = function (string $label, string $value, int $padUnderscore = 0) use ($w) { - $prefix = rtrim($label, ':') . ': '; - $line = $prefix . $value; - $len = strlen($line); - if ($padUnderscore > 0 && $len < $w) { - $line .= str_repeat('_', min($padUnderscore, $w - $len)); - } - return $this->fitLine($line, $w); - }; - // Student Name (no trailing underscores) - $lines[] = $compose('Student Name', (string)($data['student_name'] ?? ''), 0); - // Date and Time In on separate lines - $lines[] = $this->fitLine('Date: ' . (string)($data['date'] ?? ''), $w); - $lines[] = $this->fitLine('Time In: ' . (string)($data['time_in'] ?? ''), $w); - // Grade and Reason (no trailing underscores) - $lines[] = $compose('Grade', (string)($data['grade'] ?? ''), 0); - $lines[] = $compose('Reason', (string)($data['reason'] ?? ''), 0); - // Admin: do not underline, just value - $lines[] = $this->fitLine('Admin: ' . (string)($data['admin_name'] ?? ''), $w); - - $html = view('slips/slip_pdf', ['entry' => $data, 'lines' => $lines, 'page' => ['w_mm' => $wMm, 'h_mm' => $hMm]]); - - $options = new Options(); - $options->set('isRemoteEnabled', true); - $options->set('defaultFont', 'Courier'); - - $dompdf = new Dompdf($options); - $dompdf->loadHtml($html, 'UTF-8'); - // Ensure minimum height is enough for current content calculation - if ($hMm < $dynHeightMm) { - $hMm = $dynHeightMm; - } - - // Set paper size from selected mm values -> points - $mmToPt = 72 / 25.4; - $wPt = $wMm * $mmToPt; - $hPt = $hMm * $mmToPt; - $dompdf->setPaper([0, 0, $wPt, $hPt]); - $dompdf->render(); - - $filename = 'LateSlip_' . preg_replace('/[^A-Za-z0-9]+/', '_', (string)($data['student_name'] ?? 'slip')) . '_' . date('Ymd_His') . '.pdf'; - - return $this->response - ->setHeader('Content-Type', 'application/pdf') - ->setHeader('Content-Disposition', 'inline; filename="' . $filename . '"') - ->setBody($dompdf->output()); - } -} diff --git a/app/old/StudentController.php b/app/old/StudentController.php deleted file mode 100644 index c5a08db9..00000000 --- a/app/old/StudentController.php +++ /dev/null @@ -1,1724 +0,0 @@ -db = \Config\Database::connect(); - // Check if the database connection is established - if (!$this->db->connect()) { - log_message('error', 'Database connection failed.'); - throw new \Exception('Database connection failed.'); - } else { - log_message('info', 'Database connection successful.'); - } - $this->userModel = new UserModel(); - $this->configModel = new ConfigurationModel(); - $this->studentClassModel = new StudentClassModel(); - $this->studentModel = new StudentModel(); - $this->allergyModel = new StudentAllergyModel(); - $this->conditionModel = new StudentMedicalConditionModel(); - $this->classSectionModel = new ClassSectionModel(); - $this->emergencyContact = new EmergencyContactModel(); - $this->enrollmentModel = new EnrollmentModel(); - $this->semester = $this->configModel->getConfig('semester'); - $this->schoolYear = $this->configModel->getConfig('school_year'); - helper(['url', 'form']); - } - - public function assignClassStudent() - { - $isAjax = $this->request->isAJAX(); - - $studentId = (int) $this->request->getPost('student_id'); - $rawSections = $this->request->getPost('class_section_id'); - $isEventOnly = $this->request->getPost('is_event_only') ? 1 : 0; - $classSectionIds = []; - - // Normalize any input into an array of unique positive IDs - if (is_array($rawSections)) { - $classSectionIds = array_map('intval', $rawSections); - } elseif ($rawSections !== null && $rawSections !== '') { - $parts = is_string($rawSections) ? preg_split('/[,\s]+/', $rawSections) : [$rawSections]; - $classSectionIds = array_map('intval', $parts); - } - $classSectionIds = array_values(array_unique(array_filter($classSectionIds, static fn($v) => $v > 0))); - - $userId = (int) (session()->get('user_id') ?? 0); - $now = utc_now(); - - $jsonOut = function (array $payload, int $code = 200) { - // Always return a fresh CSRF for next request (works with csrfRegenerate = true) - $payload['csrfTokenName'] = csrf_token(); - $payload['csrfHash'] = csrf_hash(); - $payload[csrf_token()] = csrf_hash(); - return $this->response->setStatusCode($code)->setJSON($payload); - }; - - // Validate input - if (!$studentId || empty($classSectionIds)) { - $msg = 'Missing required data (student/class section).'; - return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 400) - : redirect()->back()->with('error', $msg); - } - - // Check student - $student = $this->studentModel->find($studentId); - if (!$student) { - $msg = 'Student not found.'; - return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 404) - : redirect()->back()->with('error', $msg); - } - - // Robust section lookup: allow id OR class_section_id - $sections = $this->classSectionModel - ->groupStart() - ->whereIn('class_section_id', $classSectionIds) - ->orWhereIn('id', $classSectionIds) - ->groupEnd() - ->findAll(); - - if (empty($sections)) { - $msg = 'Class/Section not found.'; - return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 404) - : redirect()->back()->with('error', $msg); - } - - // Map for quick lookup; preserve requested order - $sectionMap = []; - foreach ($sections as $sec) { - $sectionMap[(int)($sec['class_section_id'] ?? 0)] = $sec; - } - - $existing = $this->studentClassModel - ->where('student_id', $studentId) - ->where('school_year', (string)$this->schoolYear) - ->findAll(); - - $existingBySection = []; - foreach ($existing as $row) { - $existingBySection[(int)($row['class_section_id'] ?? 0)] = $row; - } - - $displayNames = []; - foreach ($classSectionIds as $cid) { - if (!isset($sectionMap[$cid])) continue; - $eventFlag = $isEventOnly ? 1 : 0; - if (isset($existingBySection[$cid])) { - $eventFlag = (int)($existingBySection[$cid]['is_event_only'] ?? 0); - } - $name = $this->formatClassSectionDisplayName($sectionMap[$cid], $cid); - if ($eventFlag) { - $name .= ' (Event)'; - } - $displayNames[] = $name; - } - - if (empty($displayNames)) { - $msg = 'Class/Section not found.'; - return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 404) - : redirect()->back()->with('error', $msg); - } - if (count($displayNames) !== count($classSectionIds)) { - $msg = 'One or more selected classes do not exist.'; - return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 404) - : redirect()->back()->with('error', $msg); - } - - $primarySectionId = $classSectionIds[0]; - $primarySection = $sectionMap[$primarySectionId] ?? reset($sectionMap); - - // 🔎 Derive parent class_id from the section row (fallback to raw query if needed) - $parentClassId = (int)($primarySection['class_id'] ?? 0); - if (!$parentClassId) { - // Adjust table name if your schema uses snake_case like 'class_section' - $row = $this->db->table('classSection') - ->select('class_id') - ->groupStart() - ->where('id', $primarySection['id'] ?? $primarySectionId) - ->orWhere('class_section_id', $primarySectionId) - ->groupEnd() - ->get()->getRowArray(); - if ($row && isset($row['class_id'])) { - $parentClassId = (int)$row['class_id']; - } - } - - $this->db->transBegin(); - - try { - // Upsert student_class entries per selection - $scPk = $this->studentClassModel->primaryKey ?? 'id'; - - foreach ($classSectionIds as $cid) { - $eventFlag = $isEventOnly ? 1 : 0; - if (isset($existingBySection[$cid])) { - $eventFlag = (int)($existingBySection[$cid]['is_event_only'] ?? 0); - } - $payload = [ - 'student_id' => $studentId, - 'class_section_id' => $cid, - 'school_year' => (string)$this->schoolYear, - 'is_event_only' => $eventFlag, - 'updated_by' => $userId ?: null, - 'updated_at' => $now, - ]; - - if (isset($existingBySection[$cid])) { - if (!$this->studentClassModel->update($existingBySection[$cid][$scPk], $payload)) { - throw new \RuntimeException('Failed to update assignment: ' . json_encode($this->studentClassModel->errors())); - } - } else { - $payload['created_at'] = $now; - if (!$this->studentClassModel->insert($payload)) { - throw new \RuntimeException('Failed to insert assignment: ' . json_encode($this->studentClassModel->errors())); - } - } - } - - $attnStats = []; - $scoreStats = []; - if (!$isEventOnly) { - // Update enrollment for current term (if exists) - $enroll = $this->enrollmentModel - ->where('student_id', $studentId) - ->where('school_year', (string)$this->schoolYear) - ->where('semester', (string)$this->semester) - ->first(); - - if ($enroll) { - $enPk = $this->enrollmentModel->primaryKey ?? 'id'; - if (!$this->enrollmentModel->update($enroll[$enPk], [ - 'class_section_id' => $primarySectionId, - 'enrollment_status' => 'payment pending', - // Ensure admission is marked accepted once moved out of review - 'admission_status' => 'accepted', - 'updated_at' => $now, - ])) { - throw new \RuntimeException('Failed to update enrollment: ' . json_encode($this->enrollmentModel->errors())); - } - } - - // 🔄 Re-tag attendance rows for this student in the current term (section + class) - $attnStats = $this->updateStudentAttendanceSection( - $studentId, - $primarySectionId, - $parentClassId ?: 0, // safe default if missing - (string)$this->semester, - (string)$this->schoolYear, - $userId ?: null - ); - - // 🔄 Re-tag score rows (quiz, project, homework, participation, exams, aggregates) for current term - $scoreStats = $this->updateStudentScoresSection( - $studentId, - $primarySectionId, - (string)$this->semester, - (string)$this->schoolYear, - $userId ?: null - ); - } - - if ($this->db->transStatus() === false) { - throw new \RuntimeException('Transaction failed.'); - } - - $this->db->transCommit(); - - $resp = [ - 'ok' => true, - 'student_id' => $studentId, - 'class_section_id' => $primarySectionId, - 'class_section_ids' => $classSectionIds, - 'class_section_name' => implode(', ', $displayNames), - 'class_section_names'=> $displayNames, - 'attendance_updates' => $attnStats, // {attendance_data_updated, attendance_record_updated} - 'score_updates' => $scoreStats, // per-table updated counts - 'message' => 'Assignment saved.', - ]; - - return $isAjax ? $jsonOut($resp, 200) - : redirect()->to(base_url('administrator/student_class_assignment'))->with('success', $resp['message']); - } catch (\Throwable $e) { - $this->db->transRollback(); - $msg = 'Unable to assign class: ' . $e->getMessage(); - return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 500) - : redirect()->back()->with('error', $msg); - } - } - - /** - * Remove a student-class assignment for the current term. - */ - public function removeClassStudent() - { - $isAjax = $this->request->isAJAX(); - - $studentId = (int) $this->request->getPost('student_id'); - $classSectionId = (int) $this->request->getPost('class_section_id'); - $userId = (int) (session()->get('user_id') ?? 0); - $now = utc_now(); - - $jsonOut = function (array $payload, int $code = 200) { - $payload['csrfTokenName'] = csrf_token(); - $payload['csrfHash'] = csrf_hash(); - $payload[csrf_token()] = csrf_hash(); - return $this->response->setStatusCode($code)->setJSON($payload); - }; - - if (!$studentId || !$classSectionId) { - $msg = 'Missing required data (student/class section).'; - return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 400) - : redirect()->back()->with('error', $msg); - } - - // Verify assignment exists for this term - $row = $this->studentClassModel - ->where('student_id', $studentId) - ->where('class_section_id', $classSectionId) - ->where('school_year', (string)$this->schoolYear) - ->first(); - - if (!$row) { - $msg = 'Assignment not found for this student/class.'; - return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 404) - : redirect()->back()->with('error', $msg); - } - - // Remaining assignments BEFORE delete (for enrollment swap) - $beforeIds = $this->studentClassModel->getClassSectionIdsByStudentId($studentId, (string)$this->schoolYear); - - $this->db->transBegin(); - try { - // Delete the assignment - if (!$this->studentClassModel - ->where('student_id', $studentId) - ->where('class_section_id', $classSectionId) - ->where('school_year', (string)$this->schoolYear) - ->delete()) { - throw new \RuntimeException('Failed to remove assignment.'); - } - - // Determine remaining assignments AFTER delete - $remainingIds = $this->studentClassModel->getClassSectionIdsByStudentId($studentId, (string)$this->schoolYear); - $remainingNames = $this->studentClassModel->getClassSectionsByStudentId($studentId, (string)$this->schoolYear, true); - $remainingDisplay = !empty($remainingNames) ? implode(', ', $remainingNames) : ''; - - // If enrollment pointed to removed class, move to another remaining class or null - $enroll = $this->enrollmentModel - ->where('student_id', $studentId) - ->where('school_year', (string)$this->schoolYear) - ->where('semester', (string)$this->semester) - ->first(); - if ($enroll) { - $enPk = $this->enrollmentModel->primaryKey ?? 'id'; - $newEnrollmentClass = null; - if (!empty($remainingIds)) { - $newEnrollmentClass = $remainingIds[0]; - } - if ((int)($enroll['class_section_id'] ?? 0) === $classSectionId || $newEnrollmentClass !== null) { - $this->enrollmentModel->update($enroll[$enPk], [ - 'class_section_id' => $newEnrollmentClass, - 'updated_at' => $now, - 'updated_by' => $userId ?: null, - ]); - } - } - - if ($this->db->transStatus() === false) { - throw new \RuntimeException('Transaction failed.'); - } - $this->db->transCommit(); - - $resp = [ - 'ok' => true, - 'student_id' => $studentId, - 'removed_class_id' => $classSectionId, - 'remaining_ids' => $remainingIds, - 'remaining_names' => $remainingNames, - 'remaining_display' => $remainingDisplay !== '' ? $remainingDisplay : 'No class assigned', - 'message' => 'Class removed.', - ]; - - return $isAjax ? $jsonOut($resp, 200) - : redirect()->to(base_url('administrator/student_class_assignment'))->with('success', $resp['message']); - } catch (\Throwable $e) { - $this->db->transRollback(); - $msg = 'Unable to remove class: ' . $e->getMessage(); - return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 500) - : redirect()->back()->with('error', $msg); - } - } - - public function removedStudents() - { - $schoolYear = (string)($this->schoolYear ?? ''); - - $activeStudents = $this->studentModel - ->select('id, school_id, firstname, lastname, gender, age') - ->where('is_active', 1) - ->orderBy('lastname', 'ASC') - ->orderBy('firstname', 'ASC') - ->findAll(); - - $removedStudents = $this->studentModel - ->select('id, school_id, firstname, lastname, gender, age') - ->where('is_active', 0) - ->orderBy('lastname', 'ASC') - ->orderBy('firstname', 'ASC') - ->findAll(); - - $classMap = []; - $classQuery = $this->db->table('student_class sc') - ->select('sc.student_id, cs.class_section_name') - ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left'); - if ($schoolYear !== '') { - $classQuery->where('sc.school_year', $schoolYear); - } - $classRows = $classQuery->get()->getResultArray(); - - foreach ($classRows as $row) { - $sid = (int)($row['student_id'] ?? 0); - $name = trim((string)($row['class_section_name'] ?? '')); - if ($sid <= 0 || $name === '') continue; - $classMap[$sid][] = $name; - } - - $attachClassNames = static function (array $students) use ($classMap): array { - foreach ($students as &$student) { - $sid = (int)($student['id'] ?? 0); - $names = $classMap[$sid] ?? []; - $names = array_values(array_unique(array_filter($names))); - $student['class_sections'] = $names; - $student['class_section_name'] = !empty($names) ? implode(', ', $names) : 'No class assigned'; - } - unset($student); - return $students; - }; - - return view('administrator/removed_students', [ - 'active_students' => $attachClassNames($activeStudents), - 'removed_students' => $attachClassNames($removedStudents), - 'school_year' => $schoolYear, - ]); - } - - public function setStudentActive() - { - $studentId = (int) $this->request->getPost('student_id'); - $isActiveRaw = (string) $this->request->getPost('is_active'); - $isActive = $isActiveRaw === '1' ? 1 : 0; - $now = utc_now(); - $userId = (int) (session()->get('user_id') ?? 0); - - if ($studentId <= 0) { - return redirect()->back()->with('error', 'Invalid student ID.'); - } - - $student = $this->studentModel->find($studentId); - if (!$student) { - return redirect()->back()->with('error', 'Student not found.'); - } - - if (!$this->studentModel->update($studentId, ['is_active' => $isActive])) { - return redirect()->back()->with('error', 'Unable to update student status.'); - } - - $message = $isActive ? 'Student restored successfully.' : 'Student removed successfully.'; - - if ($isActive === 1) { - $hasCurrentClass = $this->studentClassModel - ->where('student_id', $studentId) - ->where('school_year', (string)$this->schoolYear) - ->where('semester', (string)$this->semester) - ->first(); - - if (!$hasCurrentClass) { - $lastClass = $this->studentClassModel - ->where('student_id', $studentId) - ->orderBy('updated_at', 'DESC') - ->orderBy('created_at', 'DESC') - ->orderBy('id', 'DESC') - ->first(); - - $restoreClassId = (int)($lastClass['class_section_id'] ?? 0); - if ($restoreClassId > 0) { - $inserted = $this->studentClassModel->insert([ - 'student_id' => $studentId, - 'class_section_id' => $restoreClassId, - 'semester' => (string)$this->semester, - 'school_year' => (string)$this->schoolYear, - 'description' => $lastClass['description'] ?? null, - 'updated_by' => $userId ?: null, - 'updated_at' => $now, - 'created_at' => $now, - ]); - - if ($inserted) { - $classLabel = (string)($this->classSectionModel->getClassSectionNameBySectionId($restoreClassId) ?? ''); - if ($classLabel !== '') { - $message .= ' Class assignment restored to ' . $classLabel . '.'; - } - } else { - $message .= ' No class assignment found for the current term.'; - return redirect()->to(base_url('administrator/removed_students'))->with('warning', $message); - } - } else { - $message .= ' No class assignment found for the current term.'; - return redirect()->to(base_url('administrator/removed_students'))->with('warning', $message); - } - } - } - - if ($isActive === 0) { - if (!$this->notifyParentOfStudentRemoval($studentId)) { - $message .= ' Parent notification email could not be sent.'; - } - } - - return redirect()->to(base_url('administrator/removed_students'))->with('success', $message); - } - - private function notifyParentOfStudentRemoval(int $studentId): bool - { - try { - $studentRow = $this->studentModel - ->select([ - 'students.firstname AS student_firstname', - 'students.lastname AS student_lastname', - 'students.school_id', - 'users.id AS parent_id', - 'users.firstname AS parent_firstname', - 'users.lastname AS parent_lastname', - 'users.email AS parent_email', - ]) - ->join('users', 'users.id = students.parent_id', 'left') - ->where('students.id', $studentId) - ->first(); - - if (empty($studentRow)) { - log_message('warning', "Student removal email skipped: student {$studentId} not found."); - return false; - } - - $parentEmail = trim((string)($studentRow['parent_email'] ?? '')); - if ($parentEmail === '') { - log_message('warning', "Student removal email skipped: missing parent email for student {$studentId}."); - return false; - } - - $studentName = trim(trim((string)($studentRow['student_firstname'] ?? '')) . ' ' . trim((string)($studentRow['student_lastname'] ?? ''))); - $parentName = trim(trim((string)($studentRow['parent_firstname'] ?? '')) . ' ' . trim((string)($studentRow['parent_lastname'] ?? ''))); - if ($parentName === '') { - $parentName = 'Parent/Guardian'; - } - - $subject = 'Withdrawal Notice for ' . ($studentName !== '' ? $studentName : 'Your Student'); - - $html = view('emails/student_removed', [ - 'student_name' => $studentName, - 'school_id' => $studentRow['school_id'] ?? '', - 'parent_name' => $parentName, - 'signature' => 'AlRahma School Administration', - ], ['saveData' => true]); - - $sent = $this->sendHtmlEmail($parentEmail, $subject, $html); - $logLevel = $sent ? 'info' : 'warning'; - log_message($logLevel, "Student removal email " . ($sent ? '' : 'not ') . "sent (studentId: {$studentId}, parent: {$parentEmail})."); - - return $sent; - } catch (\Throwable $e) { - log_message('error', 'Student removal notification failed: ' . $e->getMessage()); - return false; - } - } - - private function sendHtmlEmail(string $to, string $subject, string $html): bool - { - try { - $service = function_exists('service') ? service('emailService') : null; - if (!$service && method_exists(Services::class, 'emailService')) { - $service = Services::emailService(); - } - - if ($service && method_exists($service, 'send')) { - $result = $service->send($to, $subject, $html, 'student-removal'); - if ($result) { - return true; - } - log_message('debug', 'Custom emailService failed to send student removal notice.'); - } - - $email = Services::email(); - $cfg = config('Email'); - $fromEmail = $cfg->fromEmail ?? $cfg->SMTPUser ?? 'no-reply@example.com'; - $fromName = $cfg->fromName ?? 'Al Rahma Sunday School'; - - $email->setTo($to); - $email->setFrom($fromEmail, $fromName); - $email->setSubject($subject); - $email->setMessage($html); - $email->setMailType('html'); - - $ok = $email->send(); - if (!$ok) { - $debug = method_exists($email, 'printDebugger') ? $email->printDebugger(['headers', 'subject']) : 'no debugger'; - log_message('debug', 'CI Email send failed for student removal: ' . print_r($debug, true)); - } - - return $ok; - } catch (\Throwable $e) { - log_message('debug', 'sendHtmlEmail exception for student removal: ' . $e->getMessage()); - return false; - } - } - - - private function updateStudentAttendanceSection( - int $studentId, - int $newClassSectionId, - int $newClassId, - string $semester, - string $schoolYear, - ?int $modifiedBy = null - ): array { - $now = utc_now(); - - // ---- attendance_data: set class_section_id + class_id ---- - $builderData = $this->db->table('attendance_data'); - $builderData - ->where('student_id', $studentId) - ->where('semester', $semester) - ->where('school_year', $schoolYear) - ->set([ - 'class_section_id' => $newClassSectionId, - 'class_id' => $newClassId, - 'updated_at' => $now, - ]); - - if ($modifiedBy) { - $builderData->set('modified_by', $modifiedBy); - } - - if ($builderData->update() === false) { - $err = $this->db->error(); - throw new \RuntimeException('Failed to update attendance_data: ' . ($err['message'] ?? 'unknown DB error')); - } - $dataUpdated = $this->db->affectedRows(); - - // ---- attendance_record: set class_section_id (table has no class_id) ---- - $builderRec = $this->db->table('attendance_record'); - $builderRec - ->where('student_id', $studentId) - ->where('semester', $semester) - ->where('school_year', $schoolYear) - ->set([ - 'class_section_id' => $newClassSectionId, - 'updated_at' => $now, - ]); - - if ($modifiedBy) { - $builderRec->set('modified_by', $modifiedBy); - } - - if ($builderRec->update() === false) { - $err = $this->db->error(); - throw new \RuntimeException('Failed to update attendance_record: ' . ($err['message'] ?? 'unknown DB error')); - } - $recUpdated = $this->db->affectedRows(); - - return [ - 'attendance_data_updated' => $dataUpdated, - 'attendance_record_updated' => $recUpdated, - ]; - } - - /** - * Update all score-related tables for a student to the new class_section_id - * within the provided semester and school year. - * Affected tables: homework, quiz, project, participation, midterm_exam, - * final_exam, final_score, semester_scores. - */ - private function updateStudentScoresSection( - int $studentId, - int $newClassSectionId, - string $semester, - string $schoolYear, - ?int $modifiedBy = null - ): array { - $now = utc_now(); - - $tables = [ - 'homework', - 'quiz', - 'project', - 'participation', - 'midterm_exam', - 'final_exam', - 'final_score', - 'semester_scores', - ]; - - $results = []; - foreach ($tables as $tbl) { - $builder = $this->db->table($tbl); - $builder - ->where('student_id', $studentId) - ->where('semester', $semester) - ->where('school_year', $schoolYear) - ->set([ - 'class_section_id' => $newClassSectionId, - 'updated_at' => $now, - ]); - - // Only set updated_by if the column exists in table schema. For most it does. - if ($modifiedBy !== null) { - $builder->set('updated_by', $modifiedBy); - } - - if ($builder->update() === false) { - $err = $this->db->error(); - throw new \RuntimeException('Failed to update ' . $tbl . ': ' . ($err['message'] ?? 'unknown DB error')); - } - - $results[$tbl . '_updated'] = $this->db->affectedRows(); - } - - return $results; - } - - - /** - * Build a display name from whatever fields the section row has. - * Supports: class_section_name, section_name, name, title, grade/letter combos. - */ - private function formatClassSectionDisplayName(array $row, int $fallbackId): string - { - if (!empty($row['class_section_name'])) return (string) $row['class_section_name']; - if (!empty($row['section_name'])) return (string) $row['section_name']; - if (!empty($row['name'])) return (string) $row['name']; - if (!empty($row['title'])) return (string) $row['title']; - - $grade = $row['grade_name'] ?? $row['grade'] ?? $row['class_name'] ?? null; - $letter = $row['section'] ?? $row['section_letter'] ?? $row['letter'] ?? null; - - if ($grade && $letter) return "{$grade} - {$letter}"; - if ($grade) return (string) $grade; - - return 'Section #' . $fallbackId; - } - - public function studentClassAssignment() - { - // Resolve selected year and available years - $selectedYear = trim((string)($this->request->getGet('schoolYear') ?? '')); - if ($selectedYear === '') $selectedYear = (string)($this->schoolYear ?? ''); - - $yearsRows = $this->db->table('enrollments') - ->select('DISTINCT school_year', false) - ->orderBy('school_year', 'DESC') - ->get()->getResultArray(); - $schoolYears = array_values(array_filter(array_map(static function ($r) { - return isset($r['school_year']) ? (string)$r['school_year'] : null; - }, $yearsRows))); - - // Retrieve students with an enrollment in the selected year (any status) - $students = $this->studentModel - ->select('students.id, students.firstname, students.lastname, students.registration_date, students.is_new, students.age, students.parent_id, students.registration_grade') - ->join('enrollments e', 'e.student_id = students.id', 'inner') - ->where('e.school_year', $selectedYear) - ->groupBy('students.id') - ->orderBy('students.lastname', 'ASC') - ->findAll(); - - // Fallback: if none found (data inconsistency), include students even without an enrollment row - if (empty($students)) { - $students = $this->studentModel - ->select('students.id, students.firstname, students.lastname, students.registration_date, students.is_new, students.age, students.parent_id, students.registration_grade') - ->orderBy('students.lastname', 'ASC') - ->findAll(); - } - - $studentData = []; - foreach ($students as $student) { - $sectionNames = $this->studentClassModel->getClassSectionsByStudentIdWithFlags((int)$student['id'], $selectedYear, true); - $sectionIds = $this->studentClassModel->getClassSectionIdsByStudentId((int)$student['id'], $selectedYear); - $sectionDisplay = !empty($sectionNames) ? implode(', ', $sectionNames) : ''; - - // Use primary parent or fallback to second - $pid = (int)($student['parent_id'] ?? 0); - if ($pid <= 0) $pid = (int)($student['secondparent_user_id'] ?? 0); - $emergencyInfo = $pid > 0 ? ($this->emergencyContact->getEmergencyContactByParentId($pid) ?? []) : []; - $emergencyContactName = $emergencyInfo['emergency_contact_name'] ?? ''; - $emergencyContactPhone = $emergencyInfo['cellphone'] ?? ''; - - $studentData[] = [ - 'student_id' => (int)$student['id'], - 'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')), - 'age' => $student['age'] ?? 'N/A', - 'email' => $emergencyContactName, - 'phone' => $emergencyContactPhone, - 'registration_grade' => $student['registration_grade'] ?? 'N/A', - 'class_section_name' => $sectionDisplay !== '' ? $sectionDisplay : 'No class assigned', - 'class_section_names' => $sectionNames, - 'class_section_ids' => $sectionIds, - 'new_student' => ((int)($student['is_new'] ?? 0) === 1) ? 'Yes' : 'No', - 'registration_date' => $student['registration_date'] ?? '', - 'school_year' => $selectedYear, - 'semester' => (string)($this->semester ?? ''), - ]; - } - - // Classes for selected year (fallback to all) - $classes = $this->classSectionModel - ->select('id, class_section_id, class_section_name, school_year') - ->where('school_year', $selectedYear) - ->orderBy('class_section_name','ASC') - ->findAll(); - if (empty($classes)) $classes = $this->classSectionModel->select('id, class_section_id, class_section_name')->orderBy('class_section_name','ASC')->findAll(); - - // Prepare data for view - return view('administrator/student_class_assignment', [ - 'students' => $studentData, - 'classes' => $classes, - 'schoolYears' => $schoolYears, - 'selectedYear' => $selectedYear, - 'currentYear' => (string)($this->schoolYear ?? ''), - 'isCurrentYear'=> ((string)$selectedYear === (string)($this->schoolYear ?? '')), - ]); - } - - /** - * GET admin view: page to trigger auto-distribution for a class/year. - */ - public function autoDistributePage() - { - // Resolve selected year and available years - $selectedYear = trim((string)($this->request->getGet('schoolYear') ?? '')); - if ($selectedYear === '') $selectedYear = (string)($this->schoolYear ?? ''); - - $yearsRows = $this->db->table('enrollments') - ->select('DISTINCT school_year', false) - ->orderBy('school_year', 'DESC') - ->get()->getResultArray(); - $schoolYears = array_values(array_filter(array_map(static function ($r) { - return isset($r['school_year']) ? (string)$r['school_year'] : null; - }, $yearsRows))); - - // Classes for selected year (fallback to all) - $classes = $this->classSectionModel - ->select('id, class_id, class_section_id, class_section_name, school_year') - ->where('school_year', $selectedYear) - ->orderBy('class_section_name','ASC') - ->findAll(); - if (empty($classes)) $classes = $this->classSectionModel->select('id, class_id, class_section_id, class_section_name')->orderBy('class_section_name','ASC')->findAll(); - - return view('administrator/sections_auto_distribute', [ - 'classes' => $classes, - 'schoolYears' => $schoolYears, - 'selectedYear' => $selectedYear, - 'currentYear' => (string)($this->schoolYear ?? ''), - ]); - } - - /** - * POST admin endpoint: auto distribute students into lettered sections for a class - * Input: class_id (int), students_per_section (int), school_year (optional) - * Uses promotion_queue for the selected year (students who passed last year to this class). - * Balances male/female per section and respects capacity. - */ - public function autoDistributeSections() - { - $isAjax = $this->request->isAJAX(); - $json = function (array $p, int $code = 200) { - $p['csrfTokenName'] = csrf_token(); - $p['csrfHash'] = csrf_hash(); - $p[csrf_token()] = csrf_hash(); - return $this->response->setStatusCode($code)->setJSON($p); - }; - - try { - $classId = (int) $this->request->getPost('class_id'); - $classSectionId = (int) $this->request->getPost('class_section_id'); - $perSec = (int) $this->request->getPost('students_per_section'); - $year = trim((string) ($this->request->getPost('school_year') ?? $this->schoolYear)); - - if ($classId <= 0 && $classSectionId > 0) { - $cid = $this->classSectionModel->getClassId($classSectionId); - $classId = (int) ($cid ?? 0); - } - - if ($classId <= 0 || $perSec <= 0) { - $msg = 'Invalid class_id or students_per_section.'; - return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg); - } - - $promo = new \App\Models\PromotionQueueModel(); - - // Candidates from promotion queue for this class/year and enrolled (or payment pending) - $cands = $promo->select('promotion_queue.*, students.gender') - ->join('students', 'students.id = promotion_queue.student_id', 'left') - ->where('promotion_queue.to_class_id', $classId) - ->where('promotion_queue.school_year_to', $year) - ->whereIn('promotion_queue.status', ['queued','assigned']) - ->findAll(); - - if (empty($cands)) { - $msg = 'No students found in promotion queue for selected class/year.'; - return $isAjax ? $json(['ok' => false, 'message' => $msg], 200) : redirect()->back()->with('info', $msg); - } - - // Filter to those with enrollment for this year in acceptable statuses - $studentIds = array_map(static fn($r) => (int)$r['student_id'], $cands); - $enrolledIds = []; - if (!empty($studentIds)) { - $rows = $this->db->table('enrollments') - ->select('student_id') - ->whereIn('student_id', $studentIds) - ->where('school_year', $year) - ->whereIn('enrollment_status', ['payment pending', 'enrolled']) - ->groupBy('student_id') - ->get()->getResultArray(); - $enrolledIds = array_map(static fn($r) => (int)$r['student_id'], $rows); - } - - $cands = array_values(array_filter($cands, static function ($r) use ($enrolledIds) { - return in_array((int)$r['student_id'], $enrolledIds, true); - })); - - if (empty($cands)) { - $msg = 'No eligible enrolled students found to distribute.'; - return $isAjax ? $json(['ok' => false, 'message' => $msg], 200) : redirect()->back()->with('info', $msg); - } - - $total = count($cands); - $sectionsNeeded = (int) ceil($total / $perSec); - - // Fetch lettered sections for this class - $letters = $this->classSectionModel->getLetterSectionsByClassId($classId); - if (empty($letters)) { - $msg = 'No lettered sections found for the selected class.'; - return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg); - } - - if (count($letters) < $sectionsNeeded) { - $msg = 'Not enough sections available. Needed: ' . $sectionsNeeded . ', available: ' . count($letters); - return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg); - } - - // Keep only required number of sections - $letters = array_slice($letters, 0, $sectionsNeeded); - - // Prepare buckets - $buckets = []; - foreach ($letters as $idx => $sec) { - $buckets[$idx] = [ - 'class_section_id' => (int)$sec['class_section_id'], - 'assigned' => [], - 'male' => 0, - 'female' => 0, - ]; - } - - // Split by gender - $males = []; - $females = []; - foreach ($cands as $r) { - $g = (string)($r['gender'] ?? ''); - if (strcasecmp($g, 'Female') === 0) $females[] = $r; else $males[] = $r; // default non-female -> male bucket - } - - // Helper to pick next bucket with available capacity and least of a gender - $pickBucket = function (string $gender) use (&$buckets, $perSec): ?int { - $bestIdx = null; - $bestCnt = PHP_INT_MAX; - foreach ($buckets as $i => $b) { - if (count($b['assigned']) >= $perSec) continue; - $cnt = ($gender === 'female') ? $b['female'] : $b['male']; - if ($cnt < $bestCnt) { - $bestCnt = $cnt; - $bestIdx = $i; - } - } - return $bestIdx; - }; - - // Assign males then females for balance - foreach ($males as $r) { - $bi = $pickBucket('male'); - if ($bi === null) break; - $buckets[$bi]['assigned'][] = (int)$r['student_id']; - $buckets[$bi]['male']++; - } - foreach ($females as $r) { - $bi = $pickBucket('female'); - if ($bi === null) break; - $buckets[$bi]['assigned'][] = (int)$r['student_id']; - $buckets[$bi]['female']++; - } - - // Persist: set to_class_section_id on queue and upsert student_class - $promoIdsBySid = []; - foreach ($cands as $r) { - $promoIdsBySid[(int)$r['student_id']] = (int)$r['id']; - } - - $studentClass = new StudentClassModel(); - $updatedBy = (int)(session()->get('user_id') ?? 0) ?: null; - $now = utc_now(); - - foreach ($buckets as $b) { - $secId = (int)$b['class_section_id']; - foreach ($b['assigned'] as $sid) { - // Update promotion queue - if (isset($promoIdsBySid[$sid])) { - $promo->update($promoIdsBySid[$sid], [ - 'to_class_section_id' => $secId, - 'status' => 'assigned', - 'updated_by' => $updatedBy, - 'updated_at' => $now, - ]); - } - - // Upsert student_class - $exists = $studentClass->where('student_id', $sid) - ->where('school_year', $year) - ->where('semester', (string)$this->semester) - ->first(); - - $payload = [ - 'student_id' => $sid, - 'class_section_id' => $secId, - 'school_year' => $year, - 'semester' => (string)$this->semester, - 'updated_by' => $updatedBy, - 'updated_at' => $now, - ]; - if ($exists) { - $studentClass->update((int)$exists['id'], $payload); - } else { - $payload['created_at'] = $now; - $studentClass->insert($payload); - } - } - } - - // Build a map for section id -> name (for friendly headers) - $nameById = []; - foreach ($letters as $secRow) { - $nameById[(int)$secRow['class_section_id']] = (string)($secRow['class_section_name'] ?? ''); - } - - $summary = []; - foreach ($buckets as $b) { - $secId = (int)$b['class_section_id']; - $summary[] = [ - 'class_section_id' => $secId, - 'class_section_name' => $nameById[$secId] ?? (string)$secId, - 'total' => count($b['assigned']), - 'male' => $b['male'], - 'female' => $b['female'], - ]; - } - - return $isAjax - ? $json(['ok' => true, 'message' => 'Auto distribution completed.', 'sections' => $summary]) - : redirect()->back()->with('success', 'Auto distribution completed.'); - } catch (\Throwable $e) { - $msg = 'Auto distribution failed: ' . $e->getMessage(); - return $isAjax ? $json(['ok' => false, 'message' => $msg], 500) : redirect()->back()->with('error', $msg); - } - } - - /** - * API: Return totals per base class (KG, 1..9, Youth) for promotion_queue in selected year - * Only counts students with enrollment status 'payment pending' or 'enrolled'. - */ - public function promotionTotalsApi() - { - try { - $year = trim((string)($this->request->getGet('school_year') ?? $this->schoolYear)); - - // Fetch base sections (no dash) and filter to KG, 1..9, youth - $bases = $this->classSectionModel - ->where("class_section_name NOT LIKE '%-%'", null, false) - ->orderBy('class_id', 'ASC') - ->findAll(); - - $wanted = []; - foreach ($bases as $r) { - $nameRaw = (string)($r['class_section_name'] ?? ''); - $name = strtolower($nameRaw); - - // Only KG, 1..9, Youth per request - if ($name === 'kg' || $name === 'youth') { - $wanted[] = $r; - continue; - } - - if (ctype_digit($name)) { - $num = (int)$name; - if ($num >= 1 && $num <= 9) { - $wanted[] = $r; - } - } - } - - $out = []; - foreach ($wanted as $r) { - $classId = (int)$r['class_id']; - // candidates from promotion_queue for this base class in the target year - $cands = $this->db->table('promotion_queue pq') - ->select('pq.student_id') - ->join('enrollments e', 'e.student_id = pq.student_id AND e.school_year = ' . $this->db->escape($year), 'left') - ->where('pq.to_class_id', $classId) - ->where('pq.school_year_to', $year) - ->whereIn('pq.status', ['queued','assigned','applied']) - ->whereIn('e.enrollment_status', ['payment pending', 'enrolled']) - ->groupBy('pq.student_id') - ->get()->getResultArray(); - - $out[] = [ - 'class_id' => $classId, - 'class_section_id' => (int)($r['class_section_id'] ?? 0), - 'class_section_name'=> (string)($r['class_section_name'] ?? ''), - 'total' => count($cands), - ]; - } - - return $this->response->setJSON(['ok' => true, 'year' => $year, 'rows' => $out]); - } catch (\Throwable $e) { - return $this->response->setStatusCode(500)->setJSON(['ok' => false, 'message' => $e->getMessage()]); - } - } - - /** - * POST /students/update/{id} - */ - public function editStudentData(?int $id = null) - { - $request = $this->request; - - if ($id === null) { - $id = (int) $request->getPost('id'); - } - if (!$id) { - return redirect()->back()->with('error', 'Invalid student ID.'); - } - - // Keep original post to detect presence of keys - $rawPost = $request->getPost(); - - // Validate all columns we edit (except id) - $rules = [ - 'school_id' => "permit_empty|alpha_numeric_punct|max_length[100]|is_unique[students.school_id,id,{$id}]", - 'firstname' => 'required|regex_match[/^[A-Za-z\s\-]{2,30}$/]', - 'lastname' => 'required|regex_match[/^[A-Za-z\s\-]{2,30}$/]', - 'dob' => 'required|valid_date[Y-m-d]', - 'age' => 'permit_empty|integer', // we compute it anyway - 'gender' => 'required|in_list[Male,Female,Other]', - 'registration_grade' => 'required|max_length[50]', - 'photo_consent' => 'permit_empty|in_list[0,1]', - - 'parent_id' => 'required|integer|greater_than[0]', - 'registration_date' => 'permit_empty', // accept Y-m-d or Y-m-d\TH:i; parse manually - 'tuition_paid' => 'permit_empty|in_list[0,1]', - 'year_of_registration' => 'permit_empty|regex_match[/^\d{4}(-\d{4})?$/]', - 'school_year' => 'permit_empty|regex_match[/^\d{4}-\d{4}$/]', - 'rfid_tag' => 'permit_empty|max_length[100]', - 'semester' => 'permit_empty|in_list[Fall,Spring,Summer]', - 'is_new' => 'required|in_list[0,1]', - 'is_active' => 'permit_empty|in_list[0,1]', - - // Free-text lists; parsed later - 'medical_conditions' => 'permit_empty', - 'allergies' => 'permit_empty', - // Touch flags from UI; used to decide whether to sync health lists - 'medical_touched' => 'permit_empty|in_list[0,1]', - 'allergies_touched' => 'permit_empty|in_list[0,1]', - ]; - - if (!$this->validate($rules)) { - return redirect()->back() - ->withInput() - ->with('error', 'Please correct the highlighted errors.') - ->with('errors', $this->validator->getErrors()); - } - - // Gather & sanitize inputs - $in = [ - 'school_id' => trim((string) $request->getPost('school_id')), - 'firstname' => $this->titleCase((string) $request->getPost('firstname')), - 'lastname' => $this->titleCase((string) $request->getPost('lastname')), - 'dob' => trim((string) $request->getPost('dob')), // Y-m-d - 'gender' => trim((string) $request->getPost('gender')), - 'registration_grade' => trim((string) $request->getPost('registration_grade')), - 'photo_consent' => (string) $request->getPost('photo_consent', FILTER_SANITIZE_NUMBER_INT), - - 'parent_id' => (string) $request->getPost('parent_id', FILTER_SANITIZE_NUMBER_INT), - 'registration_date' => trim((string) $request->getPost('registration_date')), // '' or 'Y-m-d' or 'Y-m-d\TH:i' - 'tuition_paid' => (string) $request->getPost('tuition_paid', FILTER_SANITIZE_NUMBER_INT), - 'year_of_registration' => trim((string) $request->getPost('year_of_registration')), - 'school_year' => trim((string) $request->getPost('school_year')), - 'rfid_tag' => trim((string) $request->getPost('rfid_tag')), - 'semester' => trim((string) $request->getPost('semester')), - 'is_new' => (string) $request->getPost('is_new', FILTER_SANITIZE_NUMBER_INT), - 'is_active' => (string) ($rawPost['is_active'] ?? ''), - - // raw lists (may be missing from POST entirely) - 'medical_conditions' => (string) ($rawPost['medical_conditions'] ?? ''), - 'allergies' => (string) ($rawPost['allergies'] ?? ''), - // touch flags ("1" when user interacted) - 'medical_touched' => (string) $request->getPost('medical_touched', FILTER_SANITIZE_NUMBER_INT), - 'allergies_touched' => (string) $request->getPost('allergies_touched', FILTER_SANITIZE_NUMBER_INT), - ]; - - $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); - $tzLocal = new \DateTimeZone($tzName); - $tzUtc = new \DateTimeZone('UTC'); - - try { - // DOB -> Y-m-d and compute age - $dob = \DateTimeImmutable::createFromFormat('Y-m-d', $in['dob'], $tzLocal); - if ($dob === false) { - throw new \RuntimeException('Invalid date of birth.'); - } - $dobStr = $dob->format('Y-m-d'); - - $today = new \DateTimeImmutable('today', $tzLocal); - $age = (int)$today->format('Y') - (int)$dob->format('Y'); - if ((int)$today->format('md') < (int)$dob->format('md')) $age--; - if ($age < 0) throw new \RuntimeException('DOB results in negative age.'); - - // registration_date: accept date or datetime-local; store as UTC DATETIME - $regDateStr = null; - if ($in['registration_date'] !== '') { - $rd = null; - foreach (['Y-m-d\TH:i', 'Y-m-d'] as $fmt) { - $tmp = \DateTimeImmutable::createFromFormat($fmt, $in['registration_date'], $tzLocal); - if ($tmp !== false) { - $rd = $tmp; - break; - } - } - if ($rd === null) { - throw new \RuntimeException('Invalid registration date.'); - } - // normalize to full minute precision in UTC - $regDateStr = $rd->setTimezone($tzUtc)->format('Y-m-d H:i:00'); - } - - // Build payload strictly to StudentModel::$allowedFields - $studentData = [ - 'school_id' => $in['school_id'] ?: null, - 'firstname' => $in['firstname'], - 'lastname' => $in['lastname'], - 'dob' => $dobStr, - 'age' => $age, - 'gender' => $in['gender'], - 'registration_grade' => $in['registration_grade'], - 'photo_consent' => (int) ($in['photo_consent'] === '1'), - - 'parent_id' => (int) $in['parent_id'], - 'registration_date' => $regDateStr, // nullable - 'tuition_paid' => (int) ($in['tuition_paid'] === '1'), - 'year_of_registration' => $in['year_of_registration'] ?: null, - 'school_year' => $in['school_year'] ?: null, - 'rfid_tag' => $in['rfid_tag'] ?: null, - 'semester' => $in['semester'] ?: null, - 'is_new' => (int) ($in['is_new'] === '1'), - ]; - if (array_key_exists('is_active', $rawPost)) { - $studentData['is_active'] = (int) ($in['is_active'] === '1'); - } - - // Normalize health lists (server-side safety) - $normConditions = $this->normalizeHealthList($in['medical_conditions'], 100); // -> condition_name - $normAllergies = $this->normalizeHealthList($in['allergies'], 100); // -> allergy - - $this->db->transStart(); - - $row = $this->studentModel->find($id); - if (!$row) { - $this->db->transRollback(); - return redirect()->back()->with('error', 'Student not found.'); - } - - if (!$this->studentModel->update($id, $studentData)) { - $this->db->transRollback(); - return redirect()->back() - ->withInput() - ->with('error', 'Could not update student.') - ->with('errors', $this->studentModel->errors() ?: []); - } - - // Only sync health lists when user actually changed them (touched=1) - // Fallback: if no touch flag but a non-empty value was explicitly posted, also sync. - $medTouched = ($in['medical_touched'] === '1'); - $allTouched = ($in['allergies_touched'] === '1'); - - if ($medTouched || (array_key_exists('medical_conditions', $rawPost) && trim($in['medical_conditions']) !== '')) { - $this->syncHealthList($id, $this->conditionModel, 'condition_name', $normConditions); - } - if ($allTouched || (array_key_exists('allergies', $rawPost) && trim($in['allergies']) !== '')) { - $this->syncHealthList($id, $this->allergyModel, 'allergy', $normAllergies); - } - - $this->db->transComplete(); - if ($this->db->transStatus() === false) { - return redirect()->back()->withInput()->with('error', 'Transaction failed while updating student.'); - } - - return redirect()->to('/administrator/student_profiles')->with('success', 'Student updated successfully.'); - } catch (\CodeIgniter\Database\Exceptions\DataException $e) { - log_message('error', '[Students:update] DataException: {msg}', ['msg' => $e->getMessage()]); - return redirect()->back()->withInput()->with('error', 'Database error while updating student.'); - } catch (\Throwable $e) { - log_message('error', '[Students:update] Exception: {msg}', ['msg' => $e->getMessage()]); - return redirect()->back()->withInput()->with('error', $e->getMessage()); - } - } - - - - /** - * Normalize a comma/semicolon/newline list: - * - Trim & collapse spaces - * - Drop placeholders like "none", "n/a", "na", "null", "nil" - * - Deduplicate case-insensitively - * - Truncate to $maxLen - */ - private function normalizeHealthList(?string $s, int $maxLen = 100): array - { - $s = (string)$s; - if ($s === '') return []; // empty means "no change" unless key missing (handled by caller) - - $parts = preg_split('/[,\n;]+/u', $s, -1, PREG_SPLIT_NO_EMPTY) ?: []; - $outAssoc = []; - foreach ($parts as $p) { - $v = trim(preg_replace('/\s+/u', ' ', $p)); - if ($v === '') continue; - if (preg_match('/^(none|n\/a|na|null|nil|no)$/i', $v)) continue; - $v = mb_substr($v, 0, $maxLen, 'UTF-8'); - $outAssoc[mb_strtolower($v, 'UTF-8')] = $v; // dedupe case-insensitively - } - return array_values($outAssoc); - } - - /** - * Diff-sync helper: - * - Reads existing rows for student - * - Deletes removed values - * - Inserts only new values - * Requires $model to have columns: id, student_id, and $field (e.g., allergy / condition_name) - */ - private function syncHealthList(int $studentId, \CodeIgniter\Model $model, string $field, array $newValues): void - { - // Fetch current values - $rows = $model->where('student_id', $studentId)->select("id, {$field}")->findAll(); - $current = []; - $byId = []; - foreach ($rows as $r) { - $val = (string)($r[$field] ?? ''); - $key = mb_strtolower(trim($val), 'UTF-8'); - if ($key === '') continue; - $current[$key] = $val; - $byId[$key] = (int)$r['id']; - } - - // Build new set - $incoming = []; - foreach ($newValues as $v) { - $key = mb_strtolower(trim($v), 'UTF-8'); - if ($key === '') continue; - $incoming[$key] = $v; - } - - // Compute diffs - $toDeleteKeys = array_diff(array_keys($current), array_keys($incoming)); - $toInsertKeys = array_diff(array_keys($incoming), array_keys($current)); - - // Delete removed - if (!empty($toDeleteKeys)) { - $ids = array_map(fn($k) => $byId[$k], $toDeleteKeys); - if (!empty($ids)) { - $model->whereIn('id', $ids)->delete(); - } - } - - // Insert new - if (!empty($toInsertKeys)) { - $batch = []; - foreach ($toInsertKeys as $k) { - $batch[] = [ - 'student_id' => $studentId, - $field => $incoming[$k], - ]; - } - if (!empty($batch)) { - // Use insertBatch; ignore duplicates if unique index exists - $model->insertBatch($batch); - } - } - } - - - /** - * Split a free-text list by commas/semicolons/newlines, trim items, - * de-duplicate (case-insensitive), and drop empties. Truncates to $maxLen. - */ - private function parseList(string $input, int $maxLen = 100): array - { - if ($input === '') return []; - $parts = preg_split('/[,\n;]+/u', $input); - if (!$parts) return []; - - $seen = []; - $out = []; - foreach ($parts as $raw) { - $item = trim(strip_tags($raw)); - if ($item === '') continue; - - // Case-insensitive de-dup - $key = mb_strtolower($item, 'UTF-8'); - if (isset($seen[$key])) continue; - $seen[$key] = true; - - // Enforce maxLen to align with model validation - $out[] = mb_substr($item, 0, $maxLen, 'UTF-8'); - } - return $out; - } - - private function titleCase(string $s): string - { - $s = strip_tags($s); - $s = mb_strtolower($s ?: '', 'UTF-8'); - return mb_convert_case($s, MB_CASE_TITLE_SIMPLE, 'UTF-8'); - } - - public function scoreCard() - { - $studentId = (int)($this->request->getPost('student_id') ?? 0); - if ($studentId <= 0) { - return redirect()->back()->with('error', 'Invalid student id.'); - } - - $student = $this->studentModel - ->select('id, firstname, lastname, school_id') - ->where('id', $studentId) - ->first(); - if (!$student) { - return $this->response->setStatusCode(404)->setBody('
Student not found.
'); - } - - $rows = $this->db->table('semester_scores ss') - ->select([ - 'ss.school_year', - 'ss.semester', - 'ss.homework_avg', - 'ss.project_avg', - 'ss.participation_score', - 'ss.quiz_avg', - 'ss.test_avg', - 'ss.attendance_score', - 'ss.ptap_score', - 'ss.midterm_exam_score', - 'ss.semester_score', - 'cs.class_section_name', - ]) - ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left') - ->where('ss.student_id', $studentId) - ->get() - ->getResultArray(); - - $semesterOrder = static function (string $semester): int { - $s = strtolower(trim($semester)); - if ($s === 'fall') return 1; - if ($s === 'spring') return 2; - return 3; - }; - - usort($rows, static function (array $a, array $b) use ($semesterOrder) { - $ay = (string)($a['school_year'] ?? ''); - $by = (string)($b['school_year'] ?? ''); - if ($ay !== $by) return strcmp($by, $ay); - $as = $semesterOrder((string)($a['semester'] ?? '')); - $bs = $semesterOrder((string)($b['semester'] ?? '')); - if ($as !== $bs) return $as <=> $bs; - return 0; - }); - - $yearScoreMap = []; - foreach ($rows as $r) { - $year = (string)($r['school_year'] ?? ''); - if ($year === '') continue; - $semKey = strtolower(trim((string)($r['semester'] ?? ''))); - $scoreVal = is_numeric($r['semester_score'] ?? null) ? (float)$r['semester_score'] : null; - if ($scoreVal === null) continue; - if (!isset($yearScoreMap[$year])) $yearScoreMap[$year] = []; - $yearScoreMap[$year][$semKey] = $scoreVal; - } - - foreach ($yearScoreMap as $year => $vals) { - $scores = []; - if (isset($vals['fall'])) $scores[] = $vals['fall']; - if (isset($vals['spring'])) $scores[] = $vals['spring']; - if (empty($scores)) { - $yearScoreMap[$year]['avg'] = null; - } else { - $yearScoreMap[$year]['avg'] = round(array_sum($scores) / count($scores), 1); - } - } - - $commentRows = $this->db->table('score_comments') - ->select('school_year, semester, score_type, comment, comment_review, created_at') - ->where('student_id', $studentId) - ->orderBy('created_at', 'DESC') - ->get() - ->getResultArray(); - - $commentMap = []; - foreach ($commentRows as $row) { - $year = (string)($row['school_year'] ?? ''); - $semester = strtolower(trim((string)($row['semester'] ?? ''))); - $typeRaw = strtolower(trim((string)($row['score_type'] ?? ''))); - if ($year === '' || $typeRaw === '') continue; - if ($typeRaw === 'attendance_comment') $typeRaw = 'attendance'; - $commentVal = trim((string)($row['comment'] ?? '')); - $reviewVal = trim((string)($row['comment_review'] ?? '')); - if (in_array($typeRaw, ['midterm', 'final', 'ptap'], true)) { - $commentVal = $reviewVal !== '' ? $reviewVal : $commentVal; - } elseif ($typeRaw === 'attendance' && $commentVal === '' && $reviewVal !== '') { - $commentVal = $reviewVal; - } - if ($commentVal === '') continue; - - $key = $year . '|' . $semester; - if (!isset($commentMap[$key])) $commentMap[$key] = []; - if (!isset($commentMap[$key][$typeRaw])) { - $commentMap[$key][$typeRaw] = $commentVal; - } - } - - foreach ($rows as &$r) { - $year = (string)($r['school_year'] ?? ''); - $semKey = strtolower(trim((string)($r['semester'] ?? ''))); - $key = $year . '|' . $semKey; - $r['comments'] = $commentMap[$key] ?? []; - $r['year_score'] = $yearScoreMap[$year]['avg'] ?? null; - } - unset($r); - - $rowsByYear = []; - foreach ($rows as $r) { - $year = (string)($r['school_year'] ?? ''); - if ($year === '') continue; - $semKey = strtolower(trim((string)($r['semester'] ?? ''))); - if ($semKey === '') continue; - if (!isset($rowsByYear[$year])) $rowsByYear[$year] = []; - $rowsByYear[$year][$semKey] = $r; - } - - $expandedRows = []; - foreach ($rowsByYear as $year => $bySem) { - $fallbackGrade = $bySem['fall']['class_section_name'] ?? ($bySem['spring']['class_section_name'] ?? null); - foreach (['fall', 'spring'] as $semKey) { - if (isset($bySem[$semKey])) { - $expandedRows[] = $bySem[$semKey]; - continue; - } - $expandedRows[] = [ - 'school_year' => $year, - 'semester' => ucfirst($semKey), - 'homework_avg' => null, - 'project_avg' => null, - 'participation_score' => null, - 'quiz_avg' => null, - 'test_avg' => null, - 'attendance_score' => null, - 'ptap_score' => null, - 'midterm_exam_score' => null, - 'semester_score' => null, - 'class_section_name' => $fallbackGrade, - 'comments' => [], - 'year_score' => $yearScoreMap[$year]['avg'] ?? null, - ]; - } - } - - if (!empty($expandedRows)) { - $rows = $expandedRows; - } - - return view('student/score_card', [ - 'student' => $student, - 'rows' => $rows, - ]); - } - - public function scoreCardIndex() - { - return redirect()->to('/student/score-card/list'); - } - - public function scoreCardAdmin() - { - $query = trim((string)($this->request->getGet('q') ?? '')); - $students = []; - $suggestions = []; - - if ($query !== '') { - $schoolIdPart = ''; - $namePart = $query; - if (preg_match('/^\s*([^-\s]+)\s*-\s*(.+)\s*$/', $query, $m)) { - $schoolIdPart = trim((string)$m[1]); - $namePart = trim((string)$m[2]); - } - $students = $this->studentModel - ->select('id, school_id, firstname, lastname, is_active') - ->groupStart() - ->like('firstname', $namePart) - ->orLike('lastname', $namePart) - ->orLike('school_id', $query) - ->groupEnd() - ->orderBy('lastname', 'ASC') - ->orderBy('firstname', 'ASC') - ->limit(50) - ->findAll(); - if ($schoolIdPart !== '' && empty($students)) { - $students = $this->studentModel - ->select('id, school_id, firstname, lastname, is_active') - ->groupStart() - ->like('school_id', $schoolIdPart) - ->orLike("CONCAT_WS(' ', firstname, lastname)", $namePart, 'both', null, false) - ->groupEnd() - ->orderBy('lastname', 'ASC') - ->orderBy('firstname', 'ASC') - ->limit(50) - ->findAll(); - } - } - - try { - $suggestions = $this->studentModel - ->select('id, school_id, firstname, lastname') - ->where('is_active', 1) - ->orderBy('lastname', 'ASC') - ->orderBy('firstname', 'ASC') - ->limit(1000) - ->findAll(); - } catch (\Throwable $e) { - $suggestions = []; - } - - return view('admin/student_score_card', [ - 'query' => $query, - 'students' => $students, - 'suggestions' => $suggestions, - ]); - } - - public function scoreCardList() - { - $roleRaw = session()->get('role'); - if (is_array($roleRaw)) { - $roleRaw = $roleRaw[0] ?? 'guest'; - } - $role = strtolower((string)($roleRaw ?? 'guest')); - - $students = []; - if (in_array($role, ['parent', 'parent_dashboard'], true)) { - $parentId = (int)(session()->get('user_id') ?? 0); - if ($parentId > 0) { - $students = $this->studentModel - ->select('id, school_id, firstname, lastname') - ->where('parent_id', $parentId) - ->orderBy('lastname', 'ASC') - ->orderBy('firstname', 'ASC') - ->findAll(); - } - } elseif (in_array($role, ['teacher', 'teacher_assistant', 'teacher_dashboard'], true)) { - $configModel = new \App\Models\ConfigurationModel(); - $schoolYear = session()->get('school_year') ?? $configModel->getConfig('school_year'); - $classSectionId = (int)(session()->get('class_section_id') ?? 0); - - if ($classSectionId > 0 && !empty($schoolYear)) { - $students = $this->studentModel->getByClassAndYear($classSectionId, $schoolYear); - } - - if (empty($students)) { - $userId = (int)(session()->get('user_id') ?? 0); - if ($userId > 0 && !empty($schoolYear)) { - $db = \Config\Database::connect(); - $sectionRows = $db->table('teacher_class') - ->select('class_section_id') - ->where('teacher_id', $userId) - ->where('school_year', (string)$schoolYear) - ->get() - ->getResultArray(); - $sectionIds = array_values(array_filter(array_unique(array_map( - static fn($r) => (int)($r['class_section_id'] ?? 0), - $sectionRows - )), static fn($v) => $v > 0)); - if (!empty($sectionIds)) { - $studentClassModel = new \App\Models\StudentClassModel(); - $rows = $studentClassModel->getStudentsByClassSectionIds($sectionIds); - $unique = []; - foreach ($rows as $r) { - $sid = (int)($r['student_id'] ?? 0); - if ($sid > 0) { - $unique[$sid] = [ - 'id' => $sid, - 'school_id' => $r['school_id'] ?? '', - 'firstname' => $r['firstname'] ?? '', - 'lastname' => $r['lastname'] ?? '', - ]; - } - } - $students = array_values($unique); - } - } - } - } else { - return redirect()->to('/login')->with('error', 'Access denied.'); - } - - return view('student/score_card_list', [ - 'students' => $students, - 'role' => $role, - ]); - } -} diff --git a/app/old/SubjectCurriculumController.php b/app/old/SubjectCurriculumController.php deleted file mode 100644 index adcb695f..00000000 --- a/app/old/SubjectCurriculumController.php +++ /dev/null @@ -1,143 +0,0 @@ - 'Islamic Studies', - 'quran' => 'Quran/Arabic', - ]; - - protected SubjectCurriculumModel $curriculumModel; - protected ClassModel $classModel; - - public function __construct() - { - helper(['form']); - $this->curriculumModel = new SubjectCurriculumModel(); - $this->classModel = new ClassModel(); - } - - public function index() - { - $data = $this->buildViewData(); - $data['editEntry'] = null; - return view('administrator/subject_curriculum', $data); - } - - public function store() - { - if (! $this->validate($this->getValidationRules())) { - return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); - } - - $data = $this->buildEntryDataFromInput(); - $insertedId = $this->curriculumModel->insert($data); - if ($insertedId === false) { - return redirect()->back()->withInput()->with('error', 'Unable to save the curriculum entry. Please try again.'); - } - - return redirect()->to('administrator/subject-curriculum')->with('success', 'Curriculum entry added.'); - } - - public function edit(int $id) - { - $entry = $this->curriculumModel->find($id); - if (! $entry) { - throw new PageNotFoundException('Curriculum entry not found.'); - } - - $data = $this->buildViewData(); - $data['editEntry'] = $entry; - return view('administrator/subject_curriculum', $data); - } - - public function update(int $id) - { - $entry = $this->curriculumModel->find($id); - if (! $entry) { - throw new PageNotFoundException('Curriculum entry not found.'); - } - - $redirectBack = redirect()->to('administrator/subject-curriculum/edit/' . $id)->withInput(); - if (! $this->validate($this->getValidationRules())) { - return $redirectBack->with('errors', $this->validator->getErrors()); - } - - $data = $this->buildEntryDataFromInput(); - if ($this->curriculumModel->update($id, $data) === false) { - return $redirectBack->with('error', 'Unable to update the curriculum entry.'); - } - - return redirect()->to('administrator/subject-curriculum')->with('success', 'Curriculum entry updated.'); - } - - public function delete(int $id) - { - $entry = $this->curriculumModel->find($id); - if (! $entry) { - throw new PageNotFoundException('Curriculum entry not found.'); - } - - $this->curriculumModel->delete($id); - return redirect()->to('administrator/subject-curriculum')->with('success', 'Curriculum entry deleted.'); - } - - private function buildViewData(): array - { - $classes = $this->classModel->orderBy('class_name', 'ASC')->findAll(); - $entries = $this->curriculumModel - ->builder() - ->select('subject_curriculum_items.*, classes.class_name') - ->join('classes', 'classes.id = subject_curriculum_items.class_id', 'left') - ->orderBy('classes.class_name', 'ASC') - ->orderBy('subject', 'ASC') - ->orderBy('unit_number', 'ASC') - ->orderBy('chapter_name', 'ASC') - ->get() - ->getResultArray(); - - return [ - 'classes' => $classes, - 'entries' => $entries, - 'subjectLabels' => self::SUBJECT_OPTIONS, - ]; - } - - private function buildEntryDataFromInput(): array - { - $unitNumber = $this->request->getPost('unit_number'); - $unitNumber = is_numeric($unitNumber) ? (int) $unitNumber : null; - if ($unitNumber === 0) { - $unitNumber = null; - } - - $unitTitle = trim((string) $this->request->getPost('unit_title')); - $chapterName = trim((string) $this->request->getPost('chapter_name')); - - return [ - 'class_id' => (int) $this->request->getPost('class_id'), - 'subject' => (string) $this->request->getPost('subject'), - 'unit_number' => $unitNumber, - 'unit_title' => $unitTitle !== '' ? $unitTitle : null, - 'chapter_name' => $chapterName, - ]; - } - - private function getValidationRules(): array - { - return [ - 'class_id' => 'required|is_natural_no_zero', - 'subject' => 'required|in_list[islamic,quran]', - 'chapter_name' => 'required|string|max_length[255]', - 'unit_number' => 'permit_empty|integer', - 'unit_title' => 'permit_empty|string|max_length[255]', - ]; - } -} diff --git a/app/old/TeacherController.php b/app/old/TeacherController.php deleted file mode 100644 index f4a87dcc..00000000 --- a/app/old/TeacherController.php +++ /dev/null @@ -1,1012 +0,0 @@ -db = \Config\Database::connect(); - // Check if the database connection is established - if (!$this->db->connect()) { - log_message('error', 'Database connection failed.'); - throw new \Exception('Database connection failed.'); - } else { - log_message('info', 'Database connection successful.'); - } - - // Initialize the config model - $this->configModel = new ConfigurationModel(); // Assuming ConfigModel is the model handling configurations - $this->teacherClassModel = new TeacherClassModel(); - $this->studentClassModel = new StudentClassModel(); - $this->teacherModel = new TeacherModel(); - $this->userModel = new UserModel(); - $this->medicalModel = new StudentMedicalConditionModel(); - $this->allergyModel = new StudentAllergyModel(); - $this->staffAttendanceModel = new StaffAttendanceModel(); - - // Retrieve the configuration values - $this->semester = $this->configModel->getConfig('semester'); - $this->schoolYear = $this->configModel->getConfig('school_year'); - } - - - public function classView() - { - try { - log_message('debug', 'TeacherController::classView() method called'); - - $user_id = session()->get('user_id'); - if (!$user_id) { - log_message('error', 'User not logged in'); - return redirect()->to('user/login')->with('error', 'Please log in first'); - } - - // Fetch all assignments (as main or TA) - $classes = $this->teacherClassModel->getClassAssignmentsByUserId($user_id, $this->schoolYear); - - if (empty($classes)) { - log_message('info', 'No classes found for teacher ID: ' . $user_id); - return redirect()->to('no-classes')->with('message', 'You do not have an assigned class yet. Please contact the administration.'); - } - - // Identify user and their role - $user = $this->userModel->find($user_id); - if (!$user) { - log_message('error', 'User not found for ID: ' . $user_id); - return redirect()->to('/error')->with('error', 'User not found'); - } - - $roles = $this->db->table('user_roles ur') - ->select('r.name') - ->join('roles r', 'r.id = ur.role_id') - ->where('ur.user_id', $user_id) - ->get()->getResultArray(); - - $roleNames = array_map(fn($r) => $r['name'], $roles); - - if (in_array('teacher', $roleNames)) { - $user['role_name'] = 'teacher'; - } elseif (in_array('teacher_assistant', $roleNames)) { - $user['role_name'] = 'teacher_assistant'; - } else { - log_message('error', 'Invalid role for user ID: ' . $user_id); - return redirect()->to('/error')->with('error', 'Access denied'); - } - - // Collect class section IDs - $classSectionIds = array_map('intval', array_column($classes, 'class_section_id')); - $requestedClassId = (int)($this->request->getGet('class_section_id') ?? 0); - $sessionClassId = (int)(session()->get('class_section_id') ?? 0); - $activeClassSectionId = null; - if ($requestedClassId && in_array($requestedClassId, $classSectionIds, true)) { - $activeClassSectionId = $requestedClassId; - } elseif ($sessionClassId && in_array($sessionClassId, $classSectionIds, true)) { - $activeClassSectionId = $sessionClassId; - } elseif (!empty($classSectionIds)) { - $activeClassSectionId = $classSectionIds[0]; - } - - if ($activeClassSectionId) { - session()->set('class_section_id', $activeClassSectionId); - } - $studentsData = []; - - if (!empty($classSectionIds)) { - // 1) Fetch all students for the selected class sections - $students = $this->studentClassModel->getStudentsByClassSectionIds($classSectionIds); - - if (!empty($students)) { - // 2) Collect unique student IDs - $studentIds = array_values(array_unique(array_map( - static fn(array $row) => (int) ($row['student_id'] ?? 0), - $students - ))); - $studentIds = array_values(array_filter($studentIds)); - - // 3) Bulk fetch medical conditions & allergies grouped by student_id - $conditionsByStudent = !empty($studentIds) - ? $this->medicalModel->getMedicalByStudentIds($studentIds) // [sid => [rows...]] - : []; - $allergiesByStudent = !empty($studentIds) - ? $this->allergyModel->getAllergiesByStudentIds($studentIds) // [sid => [rows...]] - : []; - - // 4) Attach only names/values (plus display strings) to each student - foreach ($students as $student) { - $sid = (int) ($student['student_id'] ?? 0); - $cid = (int) ($student['class_section_id'] ?? 0); - - // Extract lists (arrays of strings) - $medList = array_column($conditionsByStudent[$sid] ?? [], 'condition_name'); - $algList = array_column($allergiesByStudent[$sid] ?? [], 'allergy'); - - // Keep arrays for logic if needed - $student['medical_conditions'] = $medList; - $student['allergies'] = $algList; - - // Also provide display-ready strings so views don’t implode() themselves - $student['medical_conditions_text'] = implode(', ', $medList); - $student['allergies_text'] = implode(', ', $algList); - // Group under class section - $studentsData[$cid][] = $student; - } - } - } - // Build assigned staff lists (all Main teachers and all TAs) per class section - $assignedNames = []; - if (!empty($classSectionIds)) { - $rows = $this->db->table('teacher_class tc') - ->select('tc.class_section_id, tc.position, u.firstname, u.lastname') - ->join('users u', 'u.id = tc.teacher_id', 'inner') - ->whereIn('tc.class_section_id', $classSectionIds) - ->where('tc.school_year', $this->schoolYear) - ->orderBy('tc.class_section_id', 'ASC') - ->orderBy("FIELD(tc.position,'main','ta')", '', false) - ->orderBy('u.firstname', 'ASC') - ->get()->getResultArray(); - - foreach ($rows as $r) { - $sid = (int)($r['class_section_id'] ?? 0); - $pos = strtolower((string)($r['position'] ?? '')); - $name = trim(($r['firstname'] ?? '') . ' ' . ($r['lastname'] ?? '')); - if ($sid === 0 || $name === '') continue; - - if (in_array($pos, ['main', 'teacher'], true)) { - $assignedNames[$sid]['mains'][] = $name; - } elseif (in_array($pos, ['ta', 'assistant', 'teacher_assistant'], true)) { - $assignedNames[$sid]['tas'][] = $name; - } else { - $assignedNames[$sid]['others'][] = $name; - } - } - } - - // Log debug info - log_message('debug', 'Classes: ' . print_r($classes, true)); - log_message('debug', 'Students Data: ' . print_r($studentsData, true)); - log_message('debug', 'Assigned Names: ' . print_r($assignedNames, true)); - - // First class_section_id for default selection - $class_section_id = $activeClassSectionId; - - return view('/teacher/class_view', [ - 'teacher_name' => $user['firstname'] . ' ' . $user['lastname'], - 'classes' => $classes, - 'students' => $studentsData[$class_section_id] ?? [], - 'studentsData' => $studentsData, - 'class_section_id' => $class_section_id, - 'active_class_id' => $class_section_id, - // Backward compat: keep taNames for view fallback if needed - 'taNames' => [], - 'assignedNames' => $assignedNames, - ]); - } catch (\Exception $e) { - log_message('error', 'Error in TeacherController::classView(): ' . $e->getMessage()); - return redirect()->to('/error')->with('error', 'An error occurred. Please try again later.'); - } - } - - /** - * Switch the active class for the logged-in teacher. - * Validates the requested class belongs to the teacher for the current term. - */ - public function switchClass() - { - $userId = (int)(session()->get('user_id') ?? 0); - if (!$userId) { - return redirect()->back()->with('error', 'Not authenticated.'); - } - - $redirectTo = $this->request->getPost('redirect_to') ?? base_url('/teacher_dashboard'); - $requested = (int)$this->request->getPost('class_section_id'); - - $assignments = $this->teacherClassModel->getClassAssignmentsByUserId( - $userId, - (string)$this->schoolYear, - (string)$this->semester - ); - $allowedIds = array_map(static fn($a) => (int)($a['class_section_id'] ?? 0), $assignments); - $allowedIds = array_values(array_filter(array_unique($allowedIds))); - - if ($requested && in_array($requested, $allowedIds, true)) { - session()->set('class_section_id', $requested); - } else { - return redirect()->to($redirectTo)->with('error', 'You are not assigned to that class.'); - } - - return redirect()->to($redirectTo); - } - - public function noClasses() - { - return view('/teacher/no_classes'); - } - - - public function teacherClassAssignment() - { - $selectedYear = trim((string)($this->request->getGet('schoolYear') ?? $this->request->getGet('school_year') ?? '')); - if ($selectedYear === '') { - $selectedYear = (string)($this->schoolYear ?? 'Not Set'); - } - - // Build distinct school years from classSection (fallback to configured year) - try { - $yearsRows = $this->db->table('classSection') - ->distinct() - ->select('school_year') - ->orderBy('school_year', 'DESC') - ->get()->getResultArray(); - $schoolYears = array_values(array_filter(array_map(static function ($r) { - return isset($r['school_year']) ? (string)$r['school_year'] : null; - }, $yearsRows))); - if (empty($schoolYears) && !empty($this->schoolYear)) { - $schoolYears = [(string)$this->schoolYear]; - } - } catch (\Throwable $e) { - $schoolYears = !empty($this->schoolYear) ? [(string)$this->schoolYear] : []; - } - - return view('administrator/teacher_class_assignment', [ - 'schoolYear' => (string)$selectedYear, - 'schoolYears' => $schoolYears, - 'currentYear' => (string)($this->schoolYear ?? ''), - 'isCurrentYear' => ((string)$selectedYear === (string)($this->schoolYear ?? '')), - 'missingYear' => empty($this->schoolYear), - 'apiListUrl' => site_url('api/administrator/teacher-class/assignments'), - 'apiAssignUrl' => site_url('api/administrator/teacher-class/assign'), - 'apiDeleteUrl' => site_url('api/administrator/teacher-class/delete'), - 'csrfTokenName' => csrf_token(), - 'csrfTokenValue' => csrf_hash(), - ]); - } - - private function buildTeacherClassAssignmentPayload(?string $forYear = null): array - { - $classSectionModel = new ClassSectionModel(); - $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)) { - $classSections = $classSectionModel->orderBy('class_section_name', 'ASC')->findAll(); - } - - $classNames = []; - foreach ($classSections as $section) { - $sectionId = (int)($section['class_section_id'] ?? $section['id'] ?? 0); - if ($sectionId <= 0) { - continue; - } - $classNames[$sectionId] = $section['class_section_name'] ?? ($section['name'] ?? 'N/A'); - } - - $assignments = $this->teacherClassModel - ->where('school_year', $schoolYear) - ->findAll(); - - $assignmentMap = []; - foreach ($assignments as $assign) { - $teacherId = (int)($assign['teacher_id'] ?? 0); - $sectionId = (int)($assign['class_section_id'] ?? 0); - if ($teacherId <= 0 || $sectionId <= 0) { - continue; - } - - $role = strtolower((string)($assign['position'] ?? '')); - if (!in_array($role, ['main', 'ta'], true)) { - continue; - } - - if (!isset($assignmentMap[$teacherId])) { - $assignmentMap[$teacherId] = ['main' => [], 'ta' => []]; - } - - $assignmentMap[$teacherId][$role][] = [ - 'section_id' => $sectionId, - 'class_section_name' => $classNames[$sectionId] ?? 'N/A', - 'role' => $role, - 'semester' => (string)($assign['semester'] ?? $this->semester ?? ''), - 'school_year' => (string)($assign['school_year'] ?? $schoolYear), - ]; - } - - $teacherData = []; - foreach ($teachers as $teacher) { - $tid = (int)($teacher['id'] ?? 0); - if ($tid <= 0) { - continue; - } - - $assigned = $assignmentMap[$tid] ?? ['main' => [], 'ta' => []]; - $firstName = $teacher['firstname'] ?? ''; - $lastName = $teacher['lastname'] ?? ''; - $name = trim($firstName . ' ' . $lastName); - if ($name === '') { - $name = 'User#' . $tid; - } - - $teacherData[] = [ - 'teacher_id' => $tid, - 'firstname' => $firstName, - 'lastname' => $lastName, - 'name' => $name, - 'email' => $teacher['email'] ?? '', - 'cellphone' => $teacher['cellphone'] ?? '', - 'role' => $teacher['role'] ?? '', - 'school_year' => $this->schoolYear, - 'main_assignments' => $assigned['main'], - 'ta_assignments' => $assigned['ta'], - ]; - } - - $classesPayload = []; - foreach ($classSections as $section) { - $sectionId = (int)($section['class_section_id'] ?? $section['id'] ?? 0); - if ($sectionId <= 0) { - continue; - } - $classesPayload[] = [ - 'class_section_id' => $sectionId, - 'class_section_name' => $section['class_section_name'] ?? ($section['name'] ?? 'N/A'), - ]; - } - - return [ - 'school_year' => $schoolYear, - 'teachers' => $teacherData, - 'classes' => $classesPayload, - ]; - } - - private function respondAssignmentJson(array $payload, int $statusCode = 200) - { - $payload['csrf_token'] = csrf_token(); - $payload['csrf_hash'] = csrf_hash(); - return $this->response->setStatusCode($statusCode)->setJSON($payload); - } - - public function teacherClassAssignmentsApi() - { - $forYear = trim((string)($this->request->getGet('schoolYear') ?? $this->request->getGet('school_year') ?? '')); - $payload = $this->buildTeacherClassAssignmentPayload($forYear !== '' ? $forYear : null); - $payload['ok'] = true; - return $this->respondAssignmentJson($payload); - } - - - - - - public function refreshTeacherAssignments() - { - $teacherAssignments = $this->teacherClassModel->findAll(); - - // Return the data as JSON - return $this->response->setJSON([ - 'status' => 'success', - 'data' => $teacherAssignments - ]); - } - - public function messageTeacher() - { - return view('/teacher/teacher_message'); - } - - public function assignmentTeacher() - { - return view('/teacher/teacher_assignment'); - } - - - private function handleAssignClassTeacher(array $input): array - { - $teacherId = (int)($input['teacher_id'] ?? 0); - $classSectionId = (int)($input['class_section_id'] ?? 0); - $teacherRole = $input['teacher_role'] ?? null; - $loggedInUserId = (int)(session()->get('user_id') ?? 0); - $currentDateTime = utc_now(); - - if ($teacherId <= 0 || $classSectionId <= 0) { - return ['ok' => false, 'status' => 'error', 'message' => 'Missing required parameters for assignment.']; - } - - if (empty($this->schoolYear)) { - return ['ok' => false, 'status' => 'error', 'message' => 'School year not configured.']; - } - - if ($teacherRole === null || $teacherRole === '') { - $teacherRole = $this->userModel->getUserRole($teacherId); - } - - $isAssistant = strtolower((string)$teacherRole) === 'teacher_assistant'; - $position = $isAssistant ? 'ta' : 'main'; - - $alreadyAssigned = $this->teacherClassModel->where([ - 'teacher_id' => $teacherId, - 'class_section_id' => $classSectionId, - 'position' => $position, - 'school_year' => $this->schoolYear, - ])->first(); - - if ($alreadyAssigned) { - return ['ok' => false, 'status' => 'info', 'message' => 'This teacher is already assigned to this class with the same role.']; - } - - $insertData = [ - 'teacher_id' => $teacherId, - 'class_section_id' => $classSectionId, - 'position' => $position, - 'school_year' => $this->schoolYear, - 'created_at' => $currentDateTime, - 'updated_at' => $currentDateTime, - 'updated_by' => $loggedInUserId, - ]; - - try { - $this->teacherClassModel->insert($insertData); - } catch (\Throwable $e) { - log_message('error', 'assignClassTeacher failed: ' . $e->getMessage()); - return ['ok' => false, 'status' => 'error', 'message' => 'Unable to assign class. Please try again.']; - } - - return [ - 'ok' => true, - 'status' => 'success', - 'message' => 'Class assigned to ' . ($isAssistant ? 'Teacher Assistant' : 'Main Teacher') . ' successfully.', - 'position'=> $position, - ]; - } - - public function assignClassTeacher() - { - $result = $this->handleAssignClassTeacher([ - 'teacher_id' => $this->request->getPost('teacher_id'), - 'class_section_id' => $this->request->getPost('class_section_id'), - 'teacher_role' => $this->request->getPost('teacher_role'), - ]); - - if ($this->request->wantsJSON() || $this->request->isAJAX()) { - $statusCode = $result['ok'] ? 200 : 422; - return $this->respondAssignmentJson($result, $statusCode); - } - - session()->setFlashdata($result['status'], $result['message']); - return redirect()->to('administrator/teacher_class_assignment'); - } - - public function assignClassTeacherApi() - { - $payload = $this->request->getJSON(true) ?? []; - $result = $this->handleAssignClassTeacher($payload); - $statusCode = $result['ok'] ? 200 : 422; - return $this->respondAssignmentJson($result, $statusCode); - } - - // Functions for teacher assignments - private function createNewAssignment($teacherClassModel, $teacherId, $classSectionId, $loggedInUserId, $currentDateTime) - { - // Get the teacher's role to determine position - $teacherRole = $this->userModel->getUserRole($teacherId); - $position = (strtolower($teacherRole) === 'teacher_assistant') ? 'ta' : 'main'; - - // Prevent assigning TA without a main teacher already present - if ($position === 'ta') { - $mainExists = $teacherClassModel->where([ - 'class_section_id' => $classSectionId, - 'position' => 'main', - 'school_year' => $this->schoolYear, - ])->first(); - - if (!$mainExists) { - session()->setFlashdata('error', 'Cannot assign a Teacher Assistant without a Main Teacher assigned first.'); - return $this->teacherClassAssignment(); - } - } - - // Check for position conflict before inserting - $positionConflict = $teacherClassModel->where([ - 'class_section_id' => $classSectionId, - 'position' => $position, - 'school_year' => $this->schoolYear, - ])->first(); - - if ($positionConflict) { - session()->setFlashdata('error', ucfirst($position) . ' is already assigned to this class.'); - return $this->teacherClassAssignment(); - } - - // Insert new record - $teacherClassModel->insert([ - 'teacher_id' => $teacherId, - 'class_section_id' => $classSectionId, - 'position' => $position, - 'school_year' => $this->schoolYear, - 'updated_by' => $loggedInUserId, - 'created_at' => $currentDateTime, - 'updated_at' => $currentDateTime - ]); - - session()->setFlashdata('success', 'Teacher assigned successfully.'); - return $this->teacherClassAssignment(); - } - - - private function updateTeacherAssignment($existingClass, $teacherId, $teacherClassModel, $loggedInUserId, $currentDateTime) - { - $teacherRole = $this->userModel->getUserRole($teacherId); - $position = (strtolower($teacherRole) === 'teacher_assistant') ? 'ta' : 'main'; - - // Check if same class_section_id already has a main/ta - $positionConflict = $teacherClassModel->where([ - 'class_section_id' => $existingClass['class_section_id'], - 'position' => $position, - 'school_year' => $this->schoolYear, - ])->first(); - - if ($positionConflict && $positionConflict['teacher_id'] !== $teacherId) { - session()->setFlashdata('error', ucfirst($position) . ' already assigned to this class.'); - return $this->teacherClassAssignment(); - } - - $teacherClassModel->update($existingClass['id'], [ - 'teacher_id' => $teacherId, - 'position' => $position, - 'updated_by' => $loggedInUserId, - 'updated_at' => $currentDateTime - ]); - - session()->setFlashdata('success', ucfirst($position) . ' reassigned successfully.'); - return $this->teacherClassAssignment(); - } - - private function clearupdateTeacherAssignment($existingAssignment, $teacherId, $teacherClassModel, $loggedInUserId, $currentDateTime) - { - // Step 1: Delete all TAs for this class_section + school_year - $teacherClassModel->where([ - 'class_section_id' => $existingAssignment['class_section_id'], - 'position' => 'ta', - 'school_year' => $this->schoolYear, - ])->delete(); - - // Step 2: Update the main teacher assignment - $teacherClassModel->update($existingAssignment['id'], [ - 'teacher_id' => $teacherId, - 'position' => 'main', - 'updated_by' => $loggedInUserId, - 'updated_at' => $currentDateTime - ]); - - session()->setFlashdata('success', 'Main teacher reassigned and assistants cleared.'); - return $this->teacherClassAssignment(); - } - - - private function updateTeacherAssignmentForSameClass($existingClass, $teacherClassModel, $loggedInUserId, $currentDateTime) - { - // No changes to assistants needed — schema uses separate rows per person - $teacherClassModel->update($existingClass['id'], [ - 'updated_by' => $loggedInUserId, - 'updated_at' => $currentDateTime - ]); - - session()->setFlashdata('info', 'This class is already assigned to the teacher.'); - return $this->teacherClassAssignment(); - } - - private function assignNewAssistant($existingClass, $teacherClassModel, $teacherId, $classSectionId, $loggedInUserId, $currentDateTime) - { - // Check if TA already assigned to this class for the current school year - $alreadyAssigned = $teacherClassModel->where([ - 'teacher_id' => $teacherId, - 'class_section_id' => $classSectionId, - 'school_year' => $this->schoolYear, - 'position' => 'ta' - ])->first(); - - if ($alreadyAssigned) { - session()->setFlashdata('info', 'This assistant is already assigned to the class.'); - return $this->teacherClassAssignment(); - } - - // Insert a new row for the assistant - $teacherClassModel->insert([ - 'teacher_id' => $teacherId, - 'class_section_id' => $classSectionId, - 'school_year' => $this->schoolYear, - 'position' => 'ta', - 'created_at' => $currentDateTime, - 'updated_at' => $currentDateTime, - 'updated_by' => $loggedInUserId - ]); - - session()->setFlashdata('success', 'Assistant assigned successfully.'); - return $this->teacherClassAssignment(); - } - - private function updateAssistantAssignment($existingAssignment, $classSectionId, $teacherClassModel, $loggedInUserId, $currentDateTime) - { - $teacherClassModel->update($existingAssignment['id'], [ - 'class_section_id' => $classSectionId, - 'updated_by' => $loggedInUserId, - 'updated_at' => $currentDateTime - ]); - - session()->setFlashdata('success', 'Assistant reassigned successfully.'); - return $this->teacherClassAssignment(); - } - - private function updateAssistantForSameClass($existingAssignment, $existingClass, $teacherId, $teacherClassModel, $loggedInUserId, $currentDateTime) - { - // If the assignment already exists and class is the same, flash info - if ($existingAssignment['class_section_id'] == $existingClass['class_section_id']) { - session()->setFlashdata('info', 'This assistant is already assigned to the selected class.'); - return $this->teacherClassAssignment(); - } - - // Otherwise, update to correct class_section - $teacherClassModel->update($existingAssignment['id'], [ - 'class_section_id' => $existingClass['class_section_id'], - 'updated_by' => $loggedInUserId, - 'updated_at' => $currentDateTime - ]); - - session()->setFlashdata('success', 'Assistant moved to the correct class.'); - return $this->teacherClassAssignment(); - } - - private function handleDeleteAssignment(array $input): array - { - $teacherId = (int)($input['teacher_id'] ?? 0); - $classSectionId = (int)($input['class_section_id'] ?? 0); - $position = strtolower((string)($input['position'] ?? '')); - - if ($teacherId <= 0 || $classSectionId <= 0 || !in_array($position, ['main', 'ta'], true)) { - return ['ok' => false, 'status' => 'error', 'message' => 'Missing or invalid data for deletion.']; - } - - if (empty($this->schoolYear)) { - return ['ok' => false, 'status' => 'error', 'message' => 'School year not configured.']; - } - - $requestedSchoolYear = trim((string)($input['school_year'] ?? '')); - $schoolYear = $requestedSchoolYear !== '' ? $requestedSchoolYear : $this->schoolYear; - - if ($schoolYear === '') { - return ['ok' => false, 'status' => 'error', 'message' => 'School year not configured.']; - } - - $assignment = $this->teacherClassModel - ->where('teacher_id', $teacherId) - ->where('class_section_id', $classSectionId) - ->where('school_year', $schoolYear) - ->where('position', $position) - ->first(); - - if (!$assignment) { - return ['ok' => false, 'status' => 'error', 'message' => 'Assignment not found.']; - } - - try { - $this->teacherClassModel->delete($assignment['id']); - } catch (\Throwable $e) { - log_message('error', 'deleteAssignment failed: ' . $e->getMessage()); - return ['ok' => false, 'status' => 'error', 'message' => 'Unable to remove assignment. Please try again.']; - } - - return [ - 'ok' => true, - 'status' => 'success', - 'message' => ucfirst($position) . ' assignment removed successfully.', - ]; - } - - public function deleteAssignment() - { - $result = $this->handleDeleteAssignment([ - 'teacher_id' => $this->request->getPost('teacher_id'), - 'class_section_id' => $this->request->getPost('class_section_id'), - 'position' => $this->request->getPost('position'), - ]); - - if ($this->request->wantsJSON() || $this->request->isAJAX()) { - $statusCode = $result['ok'] ? 200 : 422; - return $this->respondAssignmentJson($result, $statusCode); - } - - session()->setFlashdata($result['status'], $result['message']); - return redirect()->to('administrator/teacher_class_assignment'); - } - - public function deleteAssignmentApi() - { - $payload = $this->request->getJSON(true) ?? []; - $result = $this->handleDeleteAssignment($payload); - $statusCode = $result['ok'] ? 200 : 422; - return $this->respondAssignmentJson($result, $statusCode); - } - - /** - * Compute all allowed absence dates (Sundays) for the configured school year - * limited to future dates from today, within Sep (start year) to May (end year). - */ - private function allowedAbsenceDates(): array - { - $todayStr = local_date(utc_now(), 'Y-m-d'); - try { $today = new \DateTimeImmutable($todayStr); } catch (\Throwable $e) { $today = new \DateTimeImmutable(); } - - $sy = (string)($this->schoolYear ?? ''); - $startYear = null; $endYear = null; - if (preg_match('/^(\d{4})\D+(\d{4})$/', $sy, $m)) { - $startYear = (int)$m[1]; - $endYear = (int)$m[2]; - } else { - // Fallback based on current date - $cy = (int)date('Y'); - $cm = (int)date('n'); - if ($cm >= 9) { // Sep-Dec - $startYear = $cy; - $endYear = $cy + 1; - } else { // Jan-Aug - $startYear = $cy - 1; - $endYear = $cy; - } - } - - try { - $start = new \DateTimeImmutable(sprintf('%04d-09-01', $startYear)); - $end = new \DateTimeImmutable(sprintf('%04d-05-31', $endYear)); - } catch (\Throwable $e) { - return []; - } - - // Clamp start to today if in range - if ($start < $today) { - $start = $today; - } - - // Iterate and collect Sundays in range [start..end] - $dates = []; - for ($cursor = $start; $cursor <= $end; $cursor = $cursor->modify('+1 day')) { - if ((int)$cursor->format('w') === 0) { // Sunday - $dates[] = $cursor->format('Y-m-d'); - } - } - - return $dates; - } - - /** - * Show self-service absence/vacation page for the logged-in teacher/TA. - * Lists only the current user and allows selecting days to mark as absent with a reason. - */ - public function absenceForm() - { - $userId = (int)(session()->get('user_id') ?? 0); - if ($userId <= 0) { - return redirect()->to('login')->with('error', 'Please log in first.'); - } - - $teacher = $this->userModel->find($userId); - $displayName = $teacher ? trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? '')) : 'Unknown'; - - $semester = (string)($this->semester ?? ''); - $schoolYear = (string)($this->schoolYear ?? ''); - - // Fetch existing self-reported staff attendance rows for this user (current term) - $existing = $this->staffAttendanceModel - ->where('user_id', $userId) - ->where('semester', $semester) - ->where('school_year', $schoolYear) - ->orderBy('date', 'DESC') - ->findAll(); - - return view('/teacher/absence_vacation', [ - 'teacher_name' => $displayName, - 'semester' => $semester, - 'schoolYear' => $schoolYear, - 'existing' => $existing, - 'availableDates' => $this->allowedAbsenceDates(), - ]); - } - - /** - * Save absence/vacation selections for the logged-in teacher. - * Writes to staff_attendance as status=absent with a provided reason. - */ - public function submitAbsence() - { - $userId = (int)(session()->get('user_id') ?? 0); - if ($userId <= 0) { - return redirect()->to('login')->with('error', 'Please log in first.'); - } - - $semester = (string)($this->semester ?? ''); - $schoolYear = (string)($this->schoolYear ?? ''); - if ($schoolYear === '') { - return redirect()->to('/teacher/absence')->with('status', 'error')->with('message', 'Semester or school year not configured.'); - } - $semesterResolver = new SemesterRangeService($this->configModel); - - // Gather inputs - $dates = (array)($this->request->getPost('dates') ?? []); - $reasonType = trim((string)($this->request->getPost('reason_type') ?? '')); - $reasonText = trim((string)($this->request->getPost('reason') ?? '')); - - if ($reasonText === '') { - return redirect()->to('/teacher/absence') - ->with('status', 'error') - ->with('message', 'Reason is required.'); - } - - // Build reason string (combine category + text when type is present) - $reasonBase = ($reasonType !== '') ? strtolower($reasonType) : ''; - $reason = $reasonBase !== '' ? ($reasonBase . ': ' . $reasonText) : $reasonText; - - // Enforce allowed Sundays list (future Sundays in Sep..May for the term) - $allowedDates = $this->allowedAbsenceDates(); - $allowedSet = array_fill_keys($allowedDates, true); - - // Find one role for logging (optional field) - $roleName = $this->userModel->getUserRole($userId) ?: null; - - // Validate dates and save - $saved = 0; $invalid = []; - $savedDates = []; - // de-duplicate selected dates - $dates = array_values(array_unique(array_map('strval', $dates))); - foreach ($dates as $d) { - $d = trim((string)$d); - if ($d === '') continue; - $dt = date_create_from_format('Y-m-d', $d); - if (!$dt || $dt->format('Y-m-d') !== $d || empty($allowedSet[$d])) { - $invalid[] = $d; - continue; - } - - $semesterForDate = $semesterResolver->getSemesterForDate($d); - if ($semesterForDate === '') { - $semesterForDate = $semester; - } - - $ok = $this->staffAttendanceModel->upsertOne( - userId: $userId, - roleName: $roleName, - date: $d, - semester: $semesterForDate, - schoolYear: $schoolYear, - status: StaffAttendanceModel::STATUS_ABSENT, - reason: $reason, - editorId: $userId - ); - if ($ok) { $saved++; $savedDates[] = $d; } - } - - if (!empty($invalid)) { - return redirect()->to('/teacher/absence') - ->with('status', 'error') - ->with('message', 'Invalid dates: ' . implode(', ', $invalid)); - } - - // Send notification email to principal with request details - try { - $user = $this->userModel->find($userId) ?: []; - $fullName = trim(($user['firstname'] ?? '') . ' ' . ($user['lastname'] ?? '')) ?: 'Unknown'; - $userEmail = $user['email'] ?? ''; - $role = $roleName ?: 'staff'; - $dateList = !empty($savedDates) ? implode(', ', $savedDates) : implode(', ', $dates); - - // If teacher/TA, include assigned class(es) for current term - $assignedText = '-'; - if (in_array(strtolower((string)$roleName), ['teacher', 'teacher_assistant'], true)) { - try { - $assignments = $this->teacherClassModel->getClassAssignmentsByUserId($userId, $schoolYear, $semester); - if (!empty($assignments)) { - $parts = []; - foreach ($assignments as $as) { - $name = (string)($as['class_section_name'] ?? ''); - $r = (string)($as['teacher_role'] ?? ''); - if ($name !== '') { - $parts[] = $r ? ($name . ' (' . $r . ')') : $name; - } - } - if (!empty($parts)) { - $assignedText = implode(', ', $parts); - } else { - $assignedText = 'No assigned class'; - } - } else { - $assignedText = 'No assigned class'; - } - } catch (\Throwable $e) { - $assignedText = 'N/A'; - } - } - - $subject = sprintf('TimeOff Request: %s (%s) — %s', $fullName, ucfirst((string)$role), $dateList ?: 'No dates'); - $submittedAt = utc_now(); - - $body = '
' - . '

New TimeOff Request

' - . '

A staff time-off request was submitted from the teacher portal.

' - . '' - . '' - . '' - . '' - . '' - . '' - . '' - . '' - . '' - . (in_array(strtolower((string)$roleName), ['teacher','teacher_assistant'], true) - ? '' - : '') - . '' - . '
Name' . esc($fullName) . '
Email' . esc($userEmail) . '
Role' . esc((string)$role) . '
Semester' . esc($semester) . '
School Year' . esc($schoolYear) . '
Reason Type' . esc($reasonType ?: '-') . '
Reason' . esc($reasonText) . '
Dates' . esc($dateList ?: '-') . '
Assigned Class(es)' . esc($assignedText) . '
Submitted At' . esc($submittedAt) . '
' - . '
'; - - $linkService = new StaffTimeOffLinkService(); - $token = $linkService->createToken([ - 'uid' => $userId, - 'email' => $userEmail, - 'name' => $fullName, - 'role' => $role, - 'dates' => $dateList ?: '-', - 'reason' => $reasonText, - 'reason_type' => $reasonType, - 'submitted_at' => $submittedAt, - 'origin' => 'teacher portal', - ]); - $notifyUrl = base_url('timeoff/notify/' . rawurlencode($token)); - $body .= '

Click Send confirmation email to ' - . esc($fullName) . ' so the staff member is notified automatically. ' - . 'This link expires in 14 days.

'; - - // Use configured EmailService with notifications sender profile - $mailer = \Config\Services::emailService(); - $principalEmail = env('PRINCIPAL_EMAIL') ?: 'principal@alrahmaisgl.org'; - $mailer->send($principalEmail, $subject, $body, 'notifications'); - } catch (\Throwable $e) { - log_message('error', 'Failed to send TimeOff email: ' . $e->getMessage()); - } - - return redirect()->to('/teacher/absence') - ->with('status', 'success') - ->with('message', $saved . ' day(s) saved as absent.'); - } -} diff --git a/routes/api.php b/routes/api.php index 69ab7fc2..34da0a93 100755 --- a/routes/api.php +++ b/routes/api.php @@ -10,12 +10,17 @@ use App\Http\Controllers\Api\Administrator\AdministratorAbsenceController; use App\Http\Controllers\Api\Administrator\AdministratorDashboardController; use App\Http\Controllers\Api\Administrator\AdministratorNotificationController; use App\Http\Controllers\Api\Administrator\AdministratorTeacherSubmissionController; +use App\Http\Controllers\Api\Administrator\EmergencyContactController as AdministratorEmergencyContactController; +use App\Http\Controllers\Api\Administrator\TeacherClassAssignmentController; use App\Http\Controllers\Api\Badges\BadgeController; use App\Http\Controllers\Api\Students\StudentController; use App\Http\Controllers\Api\Attendance\AttendanceController; +use App\Http\Controllers\Api\Students\StudentController as StudentApiController; use App\Http\Controllers\Api\Finance\InvoiceController; use App\Http\Controllers\Api\Assignment\AssignmentApiController; use App\Http\Controllers\Api\Parents\AuthorizedUsersController; +use App\Http\Controllers\Api\Parents\ParentController as ParentApiController; +use App\Http\Controllers\Api\Parents\ParentAttendanceReportController as ParentAttendanceReportApiController; use App\Http\Controllers\Api\AttendanceTracking\AttendanceTrackingController; use App\Http\Controllers\Api\Email\BroadcastEmailController; use App\Http\Controllers\Api\Settings\ConfigurationController; @@ -39,6 +44,7 @@ use App\Http\Controllers\Api\Finance\FinancialController; use App\Http\Controllers\Api\System\FlagController; use App\Http\Controllers\Api\Scores\GradingController; use App\Http\Controllers\Api\System\HealthController; +use App\Http\Controllers\Api\System\SchoolIdController; use App\Http\Controllers\Api\System\SemesterRangeController; use App\Http\Controllers\Api\Scores\HomeworkController; use App\Http\Controllers\Api\Frontend\InfoIconController; @@ -92,6 +98,9 @@ use App\Http\Controllers\Api\Grading\GradingController as GradingApiController; use App\Http\Controllers\Api\Grading\HomeworkTrackingController; use App\Http\Controllers\Api\Scores\ScoreCommentController as ScoreCommentApiController; use App\Http\Controllers\Api\Messaging\WhatsappController; +use App\Http\Controllers\Api\Subjects\SubjectCurriculumController as SubjectCurriculumApiController; +use App\Http\Controllers\Api\Exams\ExamDraftController as ExamDraftApiController; +use App\Http\Controllers\Api\Settings\ConfigurationAdminController; use App\Http\Controllers\Api\Attendance\AdminAttendanceApiController; use App\Http\Controllers\Api\Attendance\AttendanceCommentTemplateController; @@ -114,6 +123,10 @@ Route::prefix('v1')->group(function () { Route::get('teacher-submissions', [AdministratorTeacherSubmissionController::class, 'index']); Route::post('teacher-submissions/notify', [AdministratorTeacherSubmissionController::class, 'notify']); + Route::get('teacher-class/assignments', [TeacherClassAssignmentController::class, 'index']); + Route::post('teacher-class/assign', [TeacherClassAssignmentController::class, 'store']); + Route::post('teacher-class/delete', [TeacherClassAssignmentController::class, 'destroy']); + Route::get('notifications/alerts', [AdministratorNotificationController::class, 'alerts']); Route::post('notifications/alerts', [AdministratorNotificationController::class, 'saveAlerts']); Route::get('notifications/print-recipients', [AdministratorNotificationController::class, 'printRecipients']); @@ -122,6 +135,11 @@ Route::prefix('v1')->group(function () { Route::get('enrollment-withdrawal', [AdministratorEnrollmentController::class, 'index']); Route::get('enrollment-withdrawal/new-students', [AdministratorEnrollmentController::class, 'newStudents']); Route::post('enrollment-withdrawal/update-statuses', [AdministratorEnrollmentController::class, 'updateStatuses']); + + Route::get('emergency-contacts', [AdministratorEmergencyContactController::class, 'index']); + Route::get('emergency-contacts/data', [AdministratorEmergencyContactController::class, 'index']); + Route::patch('emergency-contacts/{contactId}', [AdministratorEmergencyContactController::class, 'update']); + Route::delete('emergency-contacts/{contactId}', [AdministratorEmergencyContactController::class, 'destroy']); }); Route::middleware('auth:sanctum')->prefix('attendance')->group(function () { @@ -164,6 +182,48 @@ Route::prefix('v1')->group(function () { Route::middleware('auth:sanctum')->group(function () { Route::get('stats', [StatsController::class, 'index']); + Route::prefix('teachers')->group(function () { + Route::get('classes', [TeacherController::class, 'classes']); + Route::post('classes/switch', [TeacherController::class, 'switchClass']); + Route::get('absence/form', [TeacherController::class, 'absenceForm']); + Route::post('absence', [TeacherController::class, 'submitAbsence']); + }); + + Route::prefix('students')->group(function () { + Route::get('assignments', [StudentApiController::class, 'assignments']); + Route::post('assign-class', [StudentApiController::class, 'assignClass']); + Route::post('remove-class', [StudentApiController::class, 'removeClass']); + Route::get('removed', [StudentApiController::class, 'removed']); + Route::post('set-active', [StudentApiController::class, 'setActive']); + Route::post('auto-distribute', [StudentApiController::class, 'autoDistribute']); + Route::get('promotion-totals', [StudentApiController::class, 'promotionTotals']); + Route::patch('{studentId}', [StudentApiController::class, 'update']); + Route::get('{studentId}/score-card', [StudentApiController::class, 'scoreCard']); + }); + + Route::prefix('parents')->group(function () { + Route::get('attendance', [ParentApiController::class, 'attendance']); + Route::get('invoices', [ParentApiController::class, 'invoices']); + Route::get('enrollments', [ParentApiController::class, 'enrollments']); + Route::post('enrollments', [ParentApiController::class, 'updateEnrollments']); + Route::get('registration', [ParentApiController::class, 'registration']); + Route::post('registration', [ParentApiController::class, 'storeRegistration']); + Route::patch('students/{studentId}', [ParentApiController::class, 'updateStudent']); + Route::delete('students/{studentId}', [ParentApiController::class, 'deleteStudent']); + Route::get('emergency-contacts', [ParentApiController::class, 'emergencyContacts']); + Route::post('emergency-contacts', [ParentApiController::class, 'storeEmergencyContact']); + Route::patch('emergency-contacts/{contactId}', [ParentApiController::class, 'updateEmergencyContact']); + Route::get('profile', [ParentApiController::class, 'profile']); + Route::patch('profile', [ParentApiController::class, 'updateProfile']); + Route::get('events', [ParentApiController::class, 'events']); + Route::post('events/participation', [ParentApiController::class, 'updateParticipation']); + Route::get('attendance-reports/form', [ParentAttendanceReportApiController::class, 'form']); + Route::post('attendance-reports', [ParentAttendanceReportApiController::class, 'submit']); + Route::get('attendance-reports', [ParentAttendanceReportApiController::class, 'list']); + Route::post('attendance-reports/check-existing', [ParentAttendanceReportApiController::class, 'checkExisting']); + Route::patch('attendance-reports/{reportId}', [ParentAttendanceReportApiController::class, 'update']); + }); + Route::prefix('users')->group(function () { Route::get('/', [UserController::class, 'index']); Route::post('/', [UserController::class, 'store']); @@ -319,11 +379,44 @@ Route::prefix('v1')->group(function () { Route::get('exams/final/{name}', [FilesController::class, 'examDraftFinal']); }); + Route::prefix('reports/slips')->group(function () { + Route::post('print', [SlipPrinterController::class, 'print']); + Route::post('preview', [SlipPrinterController::class, 'preview']); + Route::get('logs', [SlipPrinterController::class, 'logs']); + Route::post('reprint', [SlipPrinterController::class, 'reprint']); + }); + + Route::prefix('subjects/curriculum')->group(function () { + Route::get('/', [SubjectCurriculumApiController::class, 'index']); + Route::get('{id}', [SubjectCurriculumApiController::class, 'show']); + Route::post('/', [SubjectCurriculumApiController::class, 'store']); + Route::patch('{id}', [SubjectCurriculumApiController::class, 'update']); + Route::delete('{id}', [SubjectCurriculumApiController::class, 'destroy']); + }); + + Route::prefix('exams/drafts')->group(function () { + Route::get('teacher', [ExamDraftApiController::class, 'teacherIndex']); + Route::post('teacher', [ExamDraftApiController::class, 'teacherStore']); + Route::get('admin', [ExamDraftApiController::class, 'adminIndex']); + Route::post('admin/legacy', [ExamDraftApiController::class, 'adminUploadLegacy']); + Route::post('admin/review', [ExamDraftApiController::class, 'adminReview']); + }); + + Route::prefix('configuration')->group(function () { + Route::get('/', [ConfigurationAdminController::class, 'index']); + Route::post('/', [ConfigurationAdminController::class, 'store']); + Route::patch('{id}', [ConfigurationAdminController::class, 'update']); + Route::delete('{id}', [ConfigurationAdminController::class, 'destroy']); + }); + Route::prefix('system')->group(function () { Route::get('semester-range/school-year', [SemesterRangeController::class, 'schoolYearRange']); Route::get('semester-range/semester', [SemesterRangeController::class, 'semesterRange']); Route::get('semester-range/normalize', [SemesterRangeController::class, 'normalize']); Route::get('semester-range/resolve', [SemesterRangeController::class, 'resolve']); + Route::get('school-ids/student', [SchoolIdController::class, 'generateStudent']); + Route::get('school-ids/user', [SchoolIdController::class, 'generateUser']); + Route::post('school-ids/assign', [SchoolIdController::class, 'assign']); }); Route::prefix('incidents')->group(function () { diff --git a/school_api b/school_api index 93afb4d7e974f81b62bcb1c5e46634aa2d5c4baa..a61060365bdd522a2a6715c644d3145ebc460526 100644 GIT binary patch delta 351 zcmZo@P-|#Vn;^}|H&Mo!k*_hKHG#1;fvGitxix{MHG#D?fvq)xy)}VjYXavIdp=gS zMg~4U9tSQzj$*dP&4LQ~Y$nb5temk7@{QrGsw`1O-fnrNPAAHS{+b1WmV?qi+q8_&5NXv_-+{y+TR`9JZ$ z<$nP*<{CdM3o`>F>-4wzoZ>*~D-8TU_}}wC|Me+x9^F8}nm`JCcF;kyj{KltDCKjXg(6h6y8 J{cb&{G5{I;Fb4nt diff --git a/tests/Feature/Api/V1/Administrator/EmergencyContactControllerTest.php b/tests/Feature/Api/V1/Administrator/EmergencyContactControllerTest.php new file mode 100644 index 00000000..578a74de --- /dev/null +++ b/tests/Feature/Api/V1/Administrator/EmergencyContactControllerTest.php @@ -0,0 +1,124 @@ +create(); + $parent = User::factory()->create([ + 'firstname' => 'Omar', + 'lastname' => 'Parent', + 'cellphone' => '1111111111', + ]); + + DB::table('parents')->insert([ + 'secondparent_firstname' => 'Nora', + 'secondparent_lastname' => 'Parent', + 'secondparent_gender' => 'Female', + 'secondparent_email' => 'nora.parent@example.com', + 'secondparent_phone' => '2222222222', + 'firstparent_id' => $parent->id, + 'secondparent_id' => 1234, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + Student::factory()->create([ + 'parent_id' => $parent->id, + 'firstname' => 'Rami', + 'lastname' => 'Student', + 'school_id' => 'S-202', + ]); + + EmergencyContact::query()->create([ + 'parent_id' => $parent->id, + 'emergency_contact_name' => 'Uncle Amin', + 'cellphone' => '3333333333', + 'email' => 'amin@example.com', + 'relation' => 'Uncle', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + Sanctum::actingAs($admin); + $response = $this->getJson('/api/v1/administrator/emergency-contacts'); + + $response->assertOk(); + $response->assertJson(['ok' => true]); + $response->assertJsonPath('groups.0.parent_id', $parent->id); + $response->assertJsonPath('groups.0.parent_phones.0', '1111111111'); + $response->assertJsonPath('groups.0.parent_phones.1', '2222222222'); + } + + public function test_update_changes_emergency_contact(): void + { + $admin = User::factory()->create(); + $parent = User::factory()->create(); + + $contact = EmergencyContact::query()->create([ + 'parent_id' => $parent->id, + 'emergency_contact_name' => 'Old Name', + 'cellphone' => '4444444444', + 'email' => 'old@example.com', + 'relation' => 'Friend', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + Sanctum::actingAs($admin); + $response = $this->patchJson('/api/v1/administrator/emergency-contacts/' . $contact->id, [ + 'name' => 'New Name', + 'cellphone' => '5555555555', + 'email' => 'new@example.com', + 'relation' => 'Neighbor', + ]); + + $response->assertOk(); + $response->assertJson(['ok' => true]); + $this->assertDatabaseHas('emergency_contacts', [ + 'id' => $contact->id, + 'emergency_contact_name' => 'New Name', + 'email' => 'new@example.com', + 'relation' => 'Neighbor', + ]); + } + + public function test_destroy_deletes_emergency_contact(): void + { + $admin = User::factory()->create(); + $parent = User::factory()->create(); + + $contact = EmergencyContact::query()->create([ + 'parent_id' => $parent->id, + 'emergency_contact_name' => 'Delete Name', + 'cellphone' => '6666666666', + 'email' => 'delete@example.com', + 'relation' => 'Friend', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + Sanctum::actingAs($admin); + $response = $this->deleteJson('/api/v1/administrator/emergency-contacts/' . $contact->id); + + $response->assertOk(); + $response->assertJson(['ok' => true]); + $this->assertDatabaseMissing('emergency_contacts', [ + 'id' => $contact->id, + ]); + } +} diff --git a/tests/Feature/Api/V1/Administrator/TeacherClassAssignmentControllerTest.php b/tests/Feature/Api/V1/Administrator/TeacherClassAssignmentControllerTest.php new file mode 100644 index 00000000..14d2497d --- /dev/null +++ b/tests/Feature/Api/V1/Administrator/TeacherClassAssignmentControllerTest.php @@ -0,0 +1,141 @@ +seedConfig(); + $admin = $this->seedAdminUser(); + $this->seedTeacherUser(); + $this->seedClassSection(); + + Sanctum::actingAs($admin); + $response = $this->getJson('/api/v1/administrator/teacher-class/assignments?school_year=2025-2026'); + + $response->assertOk(); + $response->assertJson(['ok' => true]); + $this->assertNotEmpty($response->json('teachers')); + $this->assertNotEmpty($response->json('classes')); + } + + public function test_store_and_delete_assignment(): void + { + $this->seedConfig(); + $admin = $this->seedAdminUser(); + $teacher = $this->seedTeacherUser(); + $classSectionId = $this->seedClassSection(); + + Sanctum::actingAs($admin); + $storeResponse = $this->postJson('/api/v1/administrator/teacher-class/assign', [ + 'teacher_id' => $teacher->id, + 'class_section_id' => $classSectionId, + 'teacher_role' => 'teacher', + 'school_year' => '2025-2026', + ]); + + $storeResponse->assertOk(); + $this->assertDatabaseHas('teacher_class', [ + 'teacher_id' => $teacher->id, + 'class_section_id' => $classSectionId, + 'position' => 'main', + ]); + + $deleteResponse = $this->postJson('/api/v1/administrator/teacher-class/delete', [ + 'teacher_id' => $teacher->id, + 'class_section_id' => $classSectionId, + 'position' => 'main', + 'school_year' => '2025-2026', + ]); + + $deleteResponse->assertOk(); + $this->assertDatabaseMissing('teacher_class', [ + 'teacher_id' => $teacher->id, + 'class_section_id' => $classSectionId, + 'position' => 'main', + ]); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + } + + private function seedAdminUser(): User + { + $userId = DB::table('users')->insertGetId([ + 'firstname' => 'Admin', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'admin.teacher.assignment@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + return User::query()->findOrFail($userId); + } + + private function seedTeacherUser(): User + { + $roleId = DB::table('roles')->insertGetId([ + 'name' => 'teacher', + ]); + + $userId = DB::table('users')->insertGetId([ + 'firstname' => 'Olivia', + 'lastname' => 'Teacher', + 'cellphone' => '6666666666', + 'email' => 'teacher.assignment.admin@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + DB::table('user_roles')->insert([ + 'user_id' => $userId, + 'role_id' => $roleId, + ]); + + return User::query()->findOrFail($userId); + } + + private function seedClassSection(): int + { + DB::table('classSection')->insert([ + 'class_id' => 1, + 'class_section_id' => 401, + 'class_section_name' => '4A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + return 401; + } +} diff --git a/tests/Feature/Api/V1/Exams/ExamDraftControllerTest.php b/tests/Feature/Api/V1/Exams/ExamDraftControllerTest.php new file mode 100644 index 00000000..e9071c4b --- /dev/null +++ b/tests/Feature/Api/V1/Exams/ExamDraftControllerTest.php @@ -0,0 +1,153 @@ +seedConfig(); + $teacher = $this->seedTeacher(); + $classSectionId = $this->seedClassSection(); + $this->seedAssignment($teacher->id, $classSectionId); + + Sanctum::actingAs($teacher); + $file = UploadedFile::fake()->create('draft.docx', 10, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + + $response = $this->post('/api/v1/exams/drafts/teacher', [ + 'class_section_id' => $classSectionId, + 'exam_type' => 'Final Exam', + 'description' => 'Draft', + 'draft_file' => $file, + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('exam_drafts', [ + 'teacher_id' => $teacher->id, + 'class_section_id' => $classSectionId, + 'status' => 'submitted', + ]); + } + + public function test_admin_review_updates_status(): void + { + $this->seedConfig(); + $admin = $this->seedAdmin(); + $draftId = $this->seedDraft(); + + Sanctum::actingAs($admin); + $response = $this->postJson('/api/v1/exams/drafts/admin/review', [ + 'draft_id' => $draftId, + 'review_status' => 'reviewed', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('exam_drafts', [ + 'id' => $draftId, + 'status' => 'reviewed', + ]); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + } + + private function seedTeacher(): User + { + $userId = DB::table('users')->insertGetId([ + 'firstname' => 'Teacher', + 'lastname' => 'User', + 'cellphone' => '1111111111', + 'email' => 'teacher.exam.feature@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + return User::query()->findOrFail($userId); + } + + private function seedAdmin(): User + { + $userId = DB::table('users')->insertGetId([ + 'firstname' => 'Admin', + 'lastname' => 'User', + 'cellphone' => '2222222222', + 'email' => 'admin.exam.feature@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + return User::query()->findOrFail($userId); + } + + private function seedClassSection(): int + { + DB::table('classSection')->insert([ + 'class_id' => 1, + 'class_section_id' => 802, + 'class_section_name' => '8B', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + return 802; + } + + private function seedAssignment(int $teacherId, int $classSectionId): void + { + DB::table('teacher_class')->insert([ + 'teacher_id' => $teacherId, + 'class_section_id' => $classSectionId, + 'position' => 'main', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedDraft(): int + { + return DB::table('exam_drafts')->insertGetId([ + 'teacher_id' => 1, + 'class_section_id' => 802, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'exam_type' => 'Quiz', + 'draft_title' => 'Quiz Draft', + 'description' => 'Draft', + 'status' => 'submitted', + 'version' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/tests/Feature/Api/V1/Parents/ParentAttendanceReportControllerTest.php b/tests/Feature/Api/V1/Parents/ParentAttendanceReportControllerTest.php new file mode 100644 index 00000000..f5a77139 --- /dev/null +++ b/tests/Feature/Api/V1/Parents/ParentAttendanceReportControllerTest.php @@ -0,0 +1,198 @@ +seedConfig(); + $parent = $this->seedParent(); + $this->seedStudent($parent->id); + + Sanctum::actingAs($parent); + $response = $this->getJson('/api/v1/parents/attendance-reports/form'); + + $response->assertOk(); + $response->assertJson(['ok' => true]); + $this->assertNotEmpty($response->json('students')); + $this->assertNotEmpty($response->json('sundays')); + } + + public function test_submit_creates_report_and_attendance(): void + { + $this->seedConfig(); + $parent = $this->seedParent(); + $studentId = $this->seedStudent($parent->id); + $this->seedClassSection($studentId); + + $date = $this->nextSunday(); + Sanctum::actingAs($parent); + + $response = $this->postJson('/api/v1/parents/attendance-reports', [ + 'student_ids' => [$studentId], + 'date' => $date, + 'type' => 'absent', + 'reason' => 'Sick', + ]); + + $response->assertStatus(201); + $response->assertJson(['ok' => true]); + + $this->assertDatabaseHas('parent_attendance_reports', [ + 'student_id' => $studentId, + 'report_date' => $date, + 'type' => 'absent', + ]); + $this->assertDatabaseHas('attendance_data', [ + 'student_id' => $studentId, + 'date' => $date, + 'status' => 'absent', + ]); + } + + public function test_check_existing_returns_rows(): void + { + $this->seedConfig(); + $parent = $this->seedParent(); + $studentId = $this->seedStudent($parent->id); + $date = $this->nextSunday(); + + DB::table('parent_attendance_reports')->insert([ + 'parent_id' => $parent->id, + 'student_id' => $studentId, + 'report_date' => $date, + 'type' => 'late', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'new', + ]); + + Sanctum::actingAs($parent); + $response = $this->postJson('/api/v1/parents/attendance-reports/check-existing', [ + 'student_ids' => [$studentId], + 'date' => $date, + 'type' => 'late', + ]); + + $response->assertOk(); + $response->assertJson(['ok' => true]); + $this->assertSame($studentId, $response->json('students.0.student_id')); + } + + public function test_update_report_updates_reason(): void + { + $this->seedConfig(); + $parent = $this->seedParent(); + $studentId = $this->seedStudent($parent->id); + $date = $this->nextSunday(); + + $reportId = DB::table('parent_attendance_reports')->insertGetId([ + 'parent_id' => $parent->id, + 'student_id' => $studentId, + 'report_date' => $date, + 'type' => 'absent', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'new', + ]); + + Sanctum::actingAs($parent); + $response = $this->patchJson('/api/v1/parents/attendance-reports/' . $reportId, [ + 'reason' => 'Family emergency', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('parent_attendance_reports', [ + 'id' => $reportId, + 'reason' => 'Family emergency', + ]); + } + + private function seedParent(): User + { + $id = DB::table('users')->insertGetId([ + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '1234567890', + 'email' => 'parent.attendance@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + return User::query()->findOrFail($id); + } + + private function seedStudent(int $parentId): int + { + return DB::table('students')->insertGetId([ + 'school_id' => 'S-300', + 'firstname' => 'Evan', + 'lastname' => 'Parent', + 'dob' => '2014-09-01', + 'age' => 11, + 'gender' => 'Male', + 'photo_consent' => 1, + 'parent_id' => $parentId, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + } + + private function seedClassSection(int $studentId): void + { + DB::table('classSection')->insert([ + 'class_id' => 1, + 'class_section_id' => 10, + 'class_section_name' => '1A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('student_class')->insert([ + 'student_id' => $studentId, + 'class_section_id' => 10, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ['config_key' => 'date_age_reference', 'config_value' => '2025-12-31'], + ['config_key' => 'parent_report_cutoff', 'config_value' => '23:59'], + ['config_key' => 'fall_semester_start', 'config_value' => '2025-09-01'], + ['config_key' => 'spring_semester_start', 'config_value' => '2026-01-15'], + ['config_key' => 'last_school_day', 'config_value' => '2026-06-15'], + ]); + } + + private function nextSunday(): string + { + $dt = new \DateTime('today'); + if ((int) $dt->format('w') !== 0) { + $dt->modify('next sunday'); + } + return $dt->format('Y-m-d'); + } +} diff --git a/tests/Feature/Api/V1/Parents/ParentControllerTest.php b/tests/Feature/Api/V1/Parents/ParentControllerTest.php new file mode 100644 index 00000000..aa2f91fd --- /dev/null +++ b/tests/Feature/Api/V1/Parents/ParentControllerTest.php @@ -0,0 +1,125 @@ +seedConfig(); + $parent = $this->seedParent(); + + $studentId = DB::table('students')->insertGetId([ + 'school_id' => 'S-100', + 'firstname' => 'Adam', + 'lastname' => 'Parent', + 'dob' => '2015-09-01', + 'age' => 10, + 'gender' => 'Male', + 'photo_consent' => 1, + 'parent_id' => $parent->id, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + DB::table('attendance_data')->insert([ + 'class_id' => 1, + 'class_section_id' => 1, + 'student_id' => $studentId, + 'school_id' => '1', + 'date' => '2025-10-01', + 'status' => 'absent', + 'reason' => 'Sick', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + Sanctum::actingAs($parent); + $response = $this->getJson('/api/v1/parents/attendance?school_year=2025-2026'); + + $response->assertOk(); + $response->assertJson(['ok' => true]); + $response->assertJsonPath('attendance.0.status', 'absent'); + } + + public function test_registration_creates_student_and_contact(): void + { + $this->seedConfig(); + $parent = $this->seedParent(); + + Sanctum::actingAs($parent); + $payload = [ + 'students' => [ + [ + 'firstname' => 'Sara', + 'lastname' => 'Parent', + 'dob' => '2016-02-01', + 'gender' => 'Female', + 'registration_grade' => '2', + 'photo_consent' => true, + 'is_new' => true, + 'allergies' => ['Peanuts'], + 'medical_conditions' => ['Asthma'], + ], + ], + 'emergency_contacts' => [ + [ + 'name' => 'John Parent', + 'cellphone' => '1234567890', + 'email' => 'john.parent@example.com', + 'relation' => 'Father', + ], + ], + ]; + + $response = $this->postJson('/api/v1/parents/registration', $payload); + + $response->assertStatus(201); + $response->assertJson(['ok' => true]); + $this->assertDatabaseHas('students', ['firstname' => 'Sara', 'parent_id' => $parent->id]); + $this->assertDatabaseHas('emergency_contacts', ['parent_id' => $parent->id, 'emergency_contact_name' => 'John Parent']); + } + + private function seedParent(): User + { + $id = DB::table('users')->insertGetId([ + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '1234567890', + 'email' => 'parent@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + return User::query()->findOrFail($id); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ['config_key' => 'date_age_reference', 'config_value' => '2025-12-31'], + ['config_key' => 'max_kids', 'config_value' => '5'], + ['config_key' => 'max_emergency', 'config_value' => '5'], + ['config_key' => 'enrollment_deadline', 'config_value' => '2025-09-01'], + ]); + } +} diff --git a/tests/Feature/Api/V1/Reports/SlipPrinterControllerTest.php b/tests/Feature/Api/V1/Reports/SlipPrinterControllerTest.php new file mode 100644 index 00000000..20cea54d --- /dev/null +++ b/tests/Feature/Api/V1/Reports/SlipPrinterControllerTest.php @@ -0,0 +1,108 @@ +seedConfig(); + $user = $this->seedUser(); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/v1/reports/slips/preview', [ + 'student_name' => 'Ali Student', + 'school_year' => '2025-2026', + ]); + + $response->assertOk(); + $response->assertJson(['ok' => true]); + $this->assertNotEmpty($response->json('text')); + } + + public function test_print_creates_log_and_returns_pdf(): void + { + $this->seedConfig(); + $user = $this->seedUser(); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/v1/reports/slips/print', [ + 'student_name' => 'Nora Student', + 'school_year' => '2025-2026', + 'date' => '09/01/2025', + 'time_in' => '8:10 AM', + 'grade' => '3', + ]); + + $response->assertOk(); + $this->assertSame('application/pdf', $response->headers->get('content-type')); + $this->assertDatabaseHas('late_slip_logs', [ + 'student_name' => 'Nora Student', + 'school_year' => '2025-2026', + ]); + } + + public function test_logs_returns_rows(): void + { + $this->seedConfig(); + $user = $this->seedUser(); + + DB::table('late_slip_logs')->insert([ + 'school_year' => '2025-2026', + 'semester' => 'fall', + 'student_name' => 'Log Student', + 'slip_date' => '2025-09-01', + 'time_in' => '08:00:00', + 'grade' => '2', + 'reason' => 'Late', + 'admin_name' => 'Admin', + 'printed_by' => $user->id, + 'printed_at' => now(), + ]); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/v1/reports/slips/logs?school_year=2025-2026'); + + $response->assertOk(); + $response->assertJson(['ok' => true]); + $this->assertNotEmpty($response->json('logs')); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + } + + private function seedUser(): User + { + $userId = DB::table('users')->insertGetId([ + 'firstname' => 'Admin', + 'lastname' => 'User', + 'cellphone' => '9999999999', + 'email' => 'slip.printer@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + return User::query()->findOrFail($userId); + } +} diff --git a/tests/Feature/Api/V1/Settings/ConfigurationControllerTest.php b/tests/Feature/Api/V1/Settings/ConfigurationControllerTest.php new file mode 100644 index 00000000..220f22c0 --- /dev/null +++ b/tests/Feature/Api/V1/Settings/ConfigurationControllerTest.php @@ -0,0 +1,66 @@ +insert([ + 'config_key' => 'school_year', + 'config_value' => '2025-2026', + ]); + + Sanctum::actingAs($this->seedUser()); + $response = $this->getJson('/api/v1/configuration'); + + $response->assertOk(); + $response->assertJson(['ok' => true]); + $this->assertNotEmpty($response->json('configs')); + } + + public function test_store_creates_config(): void + { + Sanctum::actingAs($this->seedUser()); + $response = $this->postJson('/api/v1/configuration', [ + 'config_key' => 'semester', + 'config_value' => 'Fall', + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('configuration', [ + 'config_key' => 'semester', + 'config_value' => 'Fall', + ]); + } + + private function seedUser(): User + { + $userId = DB::table('users')->insertGetId([ + 'firstname' => 'Admin', + 'lastname' => 'User', + 'cellphone' => '3333333333', + 'email' => 'config@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + return User::query()->findOrFail($userId); + } +} diff --git a/tests/Feature/Api/V1/Students/StudentControllerTest.php b/tests/Feature/Api/V1/Students/StudentControllerTest.php new file mode 100644 index 00000000..ccb9d698 --- /dev/null +++ b/tests/Feature/Api/V1/Students/StudentControllerTest.php @@ -0,0 +1,128 @@ +seedConfig(); + $user = $this->seedUser(); + $studentId = $this->seedStudent(); + $classSectionId = $this->seedClassSection(); + + Sanctum::actingAs($user); + $assignResponse = $this->postJson('/api/v1/students/assign-class', [ + 'student_id' => $studentId, + 'class_section_id' => [$classSectionId], + ]); + + $assignResponse->assertOk(); + $this->assertDatabaseHas('student_class', [ + 'student_id' => $studentId, + 'class_section_id' => $classSectionId, + ]); + + $removeResponse = $this->postJson('/api/v1/students/remove-class', [ + 'student_id' => $studentId, + 'class_section_id' => $classSectionId, + ]); + + $removeResponse->assertOk(); + $this->assertDatabaseMissing('student_class', [ + 'student_id' => $studentId, + 'class_section_id' => $classSectionId, + ]); + } + + public function test_score_card_returns_rows(): void + { + $this->seedConfig(); + $user = $this->seedUser(); + $studentId = $this->seedStudent(); + $this->seedClassSection(); + + DB::table('semester_scores')->insert([ + 'student_id' => $studentId, + 'class_section_id' => 701, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'semester_score' => 90, + ]); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/v1/students/' . $studentId . '/score-card'); + + $response->assertOk(); + $response->assertJson(['ok' => true]); + $this->assertNotEmpty($response->json('rows')); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + } + + private function seedUser(): User + { + $userId = DB::table('users')->insertGetId([ + 'firstname' => 'Admin', + 'lastname' => 'User', + 'cellphone' => '7777777777', + 'email' => 'student.controller@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + return User::query()->findOrFail($userId); + } + + private function seedStudent(): int + { + return DB::table('students')->insertGetId([ + 'school_id' => 'S-900', + 'firstname' => 'Omar', + 'lastname' => 'Student', + 'dob' => '2014-09-01', + 'age' => 11, + 'gender' => 'Male', + 'photo_consent' => 1, + 'parent_id' => 1, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + } + + private function seedClassSection(): int + { + DB::table('classSection')->insert([ + 'class_id' => 1, + 'class_section_id' => 701, + 'class_section_name' => '7A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + return 701; + } +} diff --git a/tests/Feature/Api/V1/Subjects/SubjectCurriculumControllerTest.php b/tests/Feature/Api/V1/Subjects/SubjectCurriculumControllerTest.php new file mode 100644 index 00000000..a4b073c8 --- /dev/null +++ b/tests/Feature/Api/V1/Subjects/SubjectCurriculumControllerTest.php @@ -0,0 +1,95 @@ +seedUser(); + $classId = $this->seedClass(); + $this->seedEntry($classId); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/v1/subjects/curriculum'); + + $response->assertOk(); + $response->assertJson(['ok' => true]); + $this->assertNotEmpty($response->json('entries')); + } + + public function test_store_creates_entry(): void + { + $user = $this->seedUser(); + $classId = $this->seedClass(); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/v1/subjects/curriculum', [ + 'class_id' => $classId, + 'subject' => 'islamic', + 'unit_number' => 1, + 'unit_title' => 'Faith', + 'chapter_name' => 'Intro', + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('subject_curriculum_items', [ + 'class_id' => $classId, + 'chapter_name' => 'Intro', + ]); + } + + private function seedUser(): User + { + $userId = DB::table('users')->insertGetId([ + 'firstname' => 'Admin', + 'lastname' => 'User', + 'cellphone' => '1111111111', + 'email' => 'curriculum@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + return User::query()->findOrFail($userId); + } + + private function seedClass(): int + { + return DB::table('classes')->insertGetId([ + 'class_name' => 'Grade 2', + 'schedule' => 'Sunday', + 'capacity' => 25, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + } + + private function seedEntry(int $classId): void + { + DB::table('subject_curriculum_items')->insert([ + 'class_id' => $classId, + 'subject' => 'islamic', + 'unit_number' => 1, + 'unit_title' => 'Faith', + 'chapter_name' => 'Intro', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/tests/Feature/Api/V1/System/SchoolIdControllerTest.php b/tests/Feature/Api/V1/System/SchoolIdControllerTest.php new file mode 100644 index 00000000..27f1a9f3 --- /dev/null +++ b/tests/Feature/Api/V1/System/SchoolIdControllerTest.php @@ -0,0 +1,61 @@ +create(); + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/system/school-ids/student'); + + $response->assertOk(); + $response->assertJson(['ok' => true]); + $response->assertJsonPath('result.type', 'student'); + $this->assertNotEmpty($response->json('result.school_id')); + } + + public function test_generate_user_returns_id(): void + { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/system/school-ids/user'); + + $response->assertOk(); + $response->assertJson(['ok' => true]); + $response->assertJsonPath('result.type', 'user'); + $this->assertNotEmpty($response->json('result.school_id')); + } + + public function test_assign_sets_user_school_id(): void + { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $target = User::factory()->create([ + 'school_id' => null, + ]); + + $response = $this->postJson('/api/v1/system/school-ids/assign', [ + 'user_id' => $target->id, + ]); + + $response->assertOk(); + $response->assertJson(['ok' => true]); + $this->assertNotEmpty($response->json('result.school_id')); + $this->assertSame( + $response->json('result.school_id'), + User::query()->findOrFail($target->id)->school_id + ); + } +} diff --git a/tests/Feature/Api/V1/Teachers/TeacherControllerTest.php b/tests/Feature/Api/V1/Teachers/TeacherControllerTest.php new file mode 100644 index 00000000..1c48c6fd --- /dev/null +++ b/tests/Feature/Api/V1/Teachers/TeacherControllerTest.php @@ -0,0 +1,166 @@ +seedConfig(); + $teacher = $this->seedTeacherUser(); + $classSectionId = $this->seedClassSection(); + $this->seedTeacherAssignment($teacher->id, $classSectionId); + $this->seedStudent($teacher->id, $classSectionId); + + Sanctum::actingAs($teacher); + $response = $this->getJson('/api/v1/teachers/classes?class_section_id=' . $classSectionId); + + $response->assertOk(); + $response->assertJson(['ok' => true]); + $this->assertNotEmpty($response->json('students')); + $this->assertSame($classSectionId, $response->json('active_class_section_id')); + } + + public function test_switch_class_returns_active_id(): void + { + $this->seedConfig(); + $teacher = $this->seedTeacherUser(); + $classSectionId = $this->seedClassSection(); + $this->seedTeacherAssignment($teacher->id, $classSectionId); + + Sanctum::actingAs($teacher); + $response = $this->postJson('/api/v1/teachers/classes/switch', [ + 'class_section_id' => $classSectionId, + ]); + + $response->assertOk(); + $response->assertJsonPath('active_class_section_id', $classSectionId); + } + + public function test_absence_submit_creates_staff_attendance(): void + { + Carbon::setTestNow(Carbon::parse('2025-10-01')); + $this->seedConfig(); + $teacher = $this->seedTeacherUser(); + + Sanctum::actingAs($teacher); + $formResponse = $this->getJson('/api/v1/teachers/absence/form'); + + $formResponse->assertOk(); + $date = $formResponse->json('available_dates.0'); + + $response = $this->postJson('/api/v1/teachers/absence', [ + 'dates' => [$date], + 'reason' => 'Family event', + 'reason_type' => 'personal', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('staff_attendance', [ + 'user_id' => $teacher->id, + 'date' => $date, + 'status' => 'absent', + ]); + Carbon::setTestNow(); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ['config_key' => 'fall_semester_start', 'config_value' => '2025-09-01'], + ['config_key' => 'spring_semester_start', 'config_value' => '2026-01-15'], + ['config_key' => 'last_school_day', 'config_value' => '2026-06-15'], + ]); + } + + private function seedTeacherUser(): User + { + $roleId = DB::table('roles')->insertGetId([ + 'name' => 'teacher', + ]); + + $userId = DB::table('users')->insertGetId([ + 'firstname' => 'Tara', + 'lastname' => 'Teacher', + 'cellphone' => '4444444444', + 'email' => 'teacher.feature@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + DB::table('user_roles')->insert([ + 'user_id' => $userId, + 'role_id' => $roleId, + ]); + + return User::query()->findOrFail($userId); + } + + private function seedClassSection(): int + { + DB::table('classSection')->insert([ + 'class_id' => 1, + 'class_section_id' => 301, + 'class_section_name' => '3A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + return 301; + } + + private function seedTeacherAssignment(int $teacherId, int $classSectionId): void + { + DB::table('teacher_class')->insert([ + 'teacher_id' => $teacherId, + 'class_section_id' => $classSectionId, + 'position' => 'main', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedStudent(int $parentId, int $classSectionId): void + { + $studentId = DB::table('students')->insertGetId([ + 'school_id' => 'S-600', + 'firstname' => 'Kai', + 'lastname' => 'Student', + 'dob' => '2014-09-01', + 'age' => 11, + 'gender' => 'Male', + 'photo_consent' => 1, + 'parent_id' => $parentId, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + DB::table('student_class')->insert([ + 'student_id' => $studentId, + 'class_section_id' => $classSectionId, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + } +} diff --git a/tests/Unit/Services/EmergencyContacts/EmergencyContactDirectoryServiceTest.php b/tests/Unit/Services/EmergencyContacts/EmergencyContactDirectoryServiceTest.php new file mode 100644 index 00000000..cc483e73 --- /dev/null +++ b/tests/Unit/Services/EmergencyContacts/EmergencyContactDirectoryServiceTest.php @@ -0,0 +1,68 @@ +create([ + 'firstname' => 'Salma', + 'lastname' => 'Parent', + 'cellphone' => '1111111111', + ]); + + DB::table('parents')->insert([ + 'secondparent_firstname' => 'Hadi', + 'secondparent_lastname' => 'Parent', + 'secondparent_gender' => 'Male', + 'secondparent_email' => 'second.parent@example.com', + 'secondparent_phone' => '2222222222', + 'firstparent_id' => $parent->id, + 'secondparent_id' => 999, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + Student::factory()->create([ + 'parent_id' => $parent->id, + 'firstname' => 'Layla', + 'lastname' => 'Student', + 'school_id' => 'S-101', + ]); + + EmergencyContact::query()->create([ + 'parent_id' => $parent->id, + 'emergency_contact_name' => 'Aunt Sara', + 'cellphone' => '3333333333', + 'email' => 'sara@example.com', + 'relation' => 'Aunt', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $service = app(EmergencyContactDirectoryService::class); + $groups = $service->groups(); + + $this->assertCount(1, $groups); + $group = $groups[0]; + + $this->assertSame($parent->id, $group['parent_id']); + $this->assertSame('Salma Parent', $group['parent_name']); + $this->assertSame(['1111111111', '2222222222'], $group['parent_phones']); + $this->assertNotEmpty($group['students']); + $this->assertNotEmpty($group['contacts']); + } +} diff --git a/tests/Unit/Services/Exams/ExamDraftTeacherServiceTest.php b/tests/Unit/Services/Exams/ExamDraftTeacherServiceTest.php new file mode 100644 index 00000000..ff07eded --- /dev/null +++ b/tests/Unit/Services/Exams/ExamDraftTeacherServiceTest.php @@ -0,0 +1,93 @@ +seedConfig(); + $teacherId = $this->seedTeacher(); + $classSectionId = $this->seedClassSection(); + $this->seedAssignment($teacherId, $classSectionId); + + $service = new ExamDraftTeacherService(new ExamDraftConfigService(), new ExamDraftFileService()); + + $file = UploadedFile::fake()->create('draft.docx', 10, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + $result = $service->store($teacherId, [ + 'class_section_id' => $classSectionId, + 'exam_type' => 'Final Exam', + 'description' => 'Draft', + ], $file); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('exam_drafts', [ + 'teacher_id' => $teacherId, + 'class_section_id' => $classSectionId, + 'status' => 'submitted', + ]); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + } + + private function seedTeacher(): int + { + return DB::table('users')->insertGetId([ + 'firstname' => 'Teacher', + 'lastname' => 'User', + 'cellphone' => '1111111111', + 'email' => 'teacher.exam@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + } + + private function seedClassSection(): int + { + DB::table('classSection')->insert([ + 'class_id' => 1, + 'class_section_id' => 801, + 'class_section_name' => '8A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + return 801; + } + + private function seedAssignment(int $teacherId, int $classSectionId): void + { + DB::table('teacher_class')->insert([ + 'teacher_id' => $teacherId, + 'class_section_id' => $classSectionId, + 'position' => 'main', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/tests/Unit/Services/Parents/ParentAttendanceReportServiceTest.php b/tests/Unit/Services/Parents/ParentAttendanceReportServiceTest.php new file mode 100644 index 00000000..e230f997 --- /dev/null +++ b/tests/Unit/Services/Parents/ParentAttendanceReportServiceTest.php @@ -0,0 +1,85 @@ +seedConfig(); + $parentId = $this->seedParent(); + $this->seedStudent($parentId); + + $service = new ParentAttendanceReportService( + new ParentConfigService(), + new ParentAttendanceReportCalendarService(new ParentConfigService()), + new SemesterRangeService(new SemesterConfigService()) + ); + + $data = $service->formData($parentId); + + $this->assertSame($parentId, $data['students'][0]['parent_id']); + $this->assertNotEmpty($data['sundays']); + } + + private function seedParent(): int + { + return DB::table('users')->insertGetId([ + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '1234567890', + 'email' => 'parent.report.service@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + } + + private function seedStudent(int $parentId): void + { + DB::table('students')->insert([ + 'school_id' => 'S-400', + 'firstname' => 'Casey', + 'lastname' => 'Parent', + 'dob' => '2014-09-01', + 'age' => 11, + 'gender' => 'Female', + 'photo_consent' => 1, + 'parent_id' => $parentId, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ['config_key' => 'date_age_reference', 'config_value' => '2025-12-31'], + ['config_key' => 'parent_report_cutoff', 'config_value' => '23:59'], + ['config_key' => 'fall_semester_start', 'config_value' => '2025-09-01'], + ['config_key' => 'spring_semester_start', 'config_value' => '2026-01-15'], + ['config_key' => 'last_school_day', 'config_value' => '2026-06-15'], + ]); + } +} diff --git a/tests/Unit/Services/Parents/ParentAttendanceServiceTest.php b/tests/Unit/Services/Parents/ParentAttendanceServiceTest.php new file mode 100644 index 00000000..7ed2a824 --- /dev/null +++ b/tests/Unit/Services/Parents/ParentAttendanceServiceTest.php @@ -0,0 +1,74 @@ +insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ['config_key' => 'date_age_reference', 'config_value' => '2025-12-31'], + ]); + + $parentId = DB::table('users')->insertGetId([ + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '1234567890', + 'email' => 'parent2@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + $studentId = DB::table('students')->insertGetId([ + 'school_id' => 'S-101', + 'firstname' => 'Maya', + 'lastname' => 'Parent', + 'dob' => '2014-09-01', + 'age' => 11, + 'gender' => 'Female', + 'photo_consent' => 1, + 'parent_id' => $parentId, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + DB::table('attendance_data')->insert([ + 'class_id' => 1, + 'class_section_id' => 1, + 'student_id' => $studentId, + 'school_id' => '1', + 'date' => '2025-10-02', + 'status' => 'late', + 'reason' => 'Traffic', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $service = new ParentAttendanceService(new ParentConfigService()); + $result = $service->listAttendance($parentId, '2025-2026'); + + $this->assertSame('2025-2026', $result['selectedYear']); + $this->assertCount(1, $result['attendance']); + $this->assertSame('late', $result['attendance'][0]['status']); + } +} diff --git a/tests/Unit/Services/Parents/ParentConfigServiceTest.php b/tests/Unit/Services/Parents/ParentConfigServiceTest.php new file mode 100644 index 00000000..3bf0c70d --- /dev/null +++ b/tests/Unit/Services/Parents/ParentConfigServiceTest.php @@ -0,0 +1,35 @@ +insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ['config_key' => 'date_age_reference', 'config_value' => '2025-12-31'], + ['config_key' => 'max_kids', 'config_value' => '4'], + ['config_key' => 'max_emergency', 'config_value' => '2'], + ['config_key' => 'enrollment_deadline', 'config_value' => '2025-09-01'], + ['config_key' => 'refund_deadline', 'config_value' => '2025-09-15'], + ['config_key' => 'fall_semester_start', 'config_value' => '2025-09-05'], + ]); + + $service = new ParentConfigService(); + $context = $service->context(); + + $this->assertSame('2025-2026', $context['school_year']); + $this->assertSame('Fall', $context['semester']); + $this->assertSame(4, $context['max_kids']); + $this->assertSame(2, $context['max_emergency']); + } +} diff --git a/tests/Unit/Services/Parents/ParentEmergencyContactServiceTest.php b/tests/Unit/Services/Parents/ParentEmergencyContactServiceTest.php new file mode 100644 index 00000000..c6bf969c --- /dev/null +++ b/tests/Unit/Services/Parents/ParentEmergencyContactServiceTest.php @@ -0,0 +1,60 @@ +seedConfig(); + $parentId = $this->seedParent(); + + $service = new ParentEmergencyContactService(new ParentConfigService(), new PhoneFormatterService()); + $contact = $service->store($parentId, [ + 'name' => 'Sam Contact', + 'cellphone' => '1234567890', + 'email' => 'sam@example.com', + 'relation' => 'Uncle', + ]); + + $this->assertSame('Sam Contact', $contact['emergency_contact_name']); + $this->assertDatabaseHas('emergency_contacts', ['parent_id' => $parentId, 'emergency_contact_name' => 'Sam Contact']); + } + + private function seedParent(): int + { + return DB::table('users')->insertGetId([ + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '1234567890', + 'email' => 'parent5@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + } +} diff --git a/tests/Unit/Services/Parents/ParentEnrollmentServiceTest.php b/tests/Unit/Services/Parents/ParentEnrollmentServiceTest.php new file mode 100644 index 00000000..83b0aa1c --- /dev/null +++ b/tests/Unit/Services/Parents/ParentEnrollmentServiceTest.php @@ -0,0 +1,84 @@ +seedConfig(); + $parentId = $this->seedParent(); + + $studentId = DB::table('students')->insertGetId([ + 'school_id' => 'S-200', + 'firstname' => 'Lana', + 'lastname' => 'Parent', + 'dob' => '2013-09-01', + 'age' => 12, + 'gender' => 'Female', + 'photo_consent' => 1, + 'parent_id' => $parentId, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + DB::table('enrollments')->insert([ + 'student_id' => $studentId, + 'parent_id' => $parentId, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'enrollment_date' => '2025-09-01', + 'enrollment_status' => 'enrolled', + 'admission_status' => 'accepted', + 'is_withdrawn' => 0, + ]); + + $service = new ParentEnrollmentService(new ParentConfigService()); + $result = $service->overview($parentId, '2025-2026'); + + $this->assertSame('2025-2026', $result['selectedYear']); + $this->assertCount(1, $result['students']); + $this->assertSame('enrolled', $result['students'][0]['enrollment_status']); + } + + private function seedParent(): int + { + return DB::table('users')->insertGetId([ + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '1234567890', + 'email' => 'parent3@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ['config_key' => 'date_age_reference', 'config_value' => '2025-12-31'], + ['config_key' => 'enrollment_deadline', 'config_value' => '2025-09-01'], + ['config_key' => 'refund_deadline', 'config_value' => '2025-09-15'], + ['config_key' => 'fall_semester_start', 'config_value' => '2025-09-05'], + ]); + } +} diff --git a/tests/Unit/Services/Parents/ParentInvoiceServiceTest.php b/tests/Unit/Services/Parents/ParentInvoiceServiceTest.php new file mode 100644 index 00000000..510a736c --- /dev/null +++ b/tests/Unit/Services/Parents/ParentInvoiceServiceTest.php @@ -0,0 +1,63 @@ +seedParent(); + $otherParentId = $this->seedParent('other@example.com'); + + DB::table('invoices')->insert([ + 'parent_id' => $parentId, + 'invoice_number' => 'INV-1', + 'total_amount' => 100, + 'paid_amount' => 0, + 'balance' => 100, + 'issue_date' => '2025-09-01', + ]); + + DB::table('invoices')->insert([ + 'parent_id' => $otherParentId, + 'invoice_number' => 'INV-2', + 'total_amount' => 50, + 'paid_amount' => 0, + 'balance' => 50, + 'issue_date' => '2025-09-02', + ]); + + $service = new ParentInvoiceService(); + $rows = $service->listInvoices($parentId); + + $this->assertCount(1, $rows); + $this->assertSame('INV-1', $rows[0]['invoice_number']); + } + + private function seedParent(string $email = 'parent@example.com'): int + { + return DB::table('users')->insertGetId([ + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '1234567890', + 'email' => $email, + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + } +} diff --git a/tests/Unit/Services/Parents/ParentRegistrationServiceTest.php b/tests/Unit/Services/Parents/ParentRegistrationServiceTest.php new file mode 100644 index 00000000..fbb9eecd --- /dev/null +++ b/tests/Unit/Services/Parents/ParentRegistrationServiceTest.php @@ -0,0 +1,78 @@ +seedConfig(); + $parentId = $this->seedParent(); + + $service = new ParentRegistrationService(new ParentConfigService(), new SchoolIdService()); + $result = $service->register($parentId, [ + [ + 'firstname' => 'Omar', + 'lastname' => 'Parent', + 'dob' => '2015-06-01', + 'gender' => 'Male', + 'registration_grade' => '3', + 'photo_consent' => true, + 'is_new' => true, + 'allergies' => ['Eggs'], + 'medical_conditions' => ['Asthma'], + ], + ], [ + [ + 'name' => 'Nora Parent', + 'cellphone' => '1234567890', + 'email' => 'nora@example.com', + 'relation' => 'Mother', + ], + ]); + + $this->assertNotEmpty($result['created_student_ids']); + $this->assertDatabaseHas('students', ['firstname' => 'Omar', 'parent_id' => $parentId]); + $this->assertDatabaseHas('emergency_contacts', ['parent_id' => $parentId, 'emergency_contact_name' => 'Nora Parent']); + } + + private function seedParent(): int + { + return DB::table('users')->insertGetId([ + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '1234567890', + 'email' => 'parent4@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ['config_key' => 'date_age_reference', 'config_value' => '2025-12-31'], + ['config_key' => 'max_kids', 'config_value' => '5'], + ['config_key' => 'max_emergency', 'config_value' => '5'], + ]); + } +} diff --git a/tests/Unit/Services/Reports/SlipPrinterFormatterServiceTest.php b/tests/Unit/Services/Reports/SlipPrinterFormatterServiceTest.php new file mode 100644 index 00000000..f15deeb5 --- /dev/null +++ b/tests/Unit/Services/Reports/SlipPrinterFormatterServiceTest.php @@ -0,0 +1,21 @@ +assertSame('2025-09-01', $service->toDbDate('09/01/2025')); + } + + public function test_to_db_time_parses_string(): void + { + $service = new SlipPrinterFormatterService(); + $this->assertSame('08:15:00', $service->toDbTime('8:15 AM')); + } +} diff --git a/tests/Unit/Services/SchoolIds/SchoolIdAssignmentServiceTest.php b/tests/Unit/Services/SchoolIds/SchoolIdAssignmentServiceTest.php new file mode 100644 index 00000000..4667d627 --- /dev/null +++ b/tests/Unit/Services/SchoolIds/SchoolIdAssignmentServiceTest.php @@ -0,0 +1,38 @@ +create([ + 'school_id' => null, + ]); + + $service = app(SchoolIdAssignmentService::class); + $schoolId = $service->assignToUser($user->id); + + $this->assertNotNull($schoolId); + $this->assertSame($schoolId, User::query()->findOrFail($user->id)->school_id); + } + + public function test_assign_returns_existing_school_id(): void + { + $user = User::factory()->create([ + 'school_id' => '2500001', + ]); + + $service = app(SchoolIdAssignmentService::class); + $schoolId = $service->assignToUser($user->id); + + $this->assertSame('2500001', $schoolId); + } +} diff --git a/tests/Unit/Services/SchoolIds/SchoolIdGenerationServiceTest.php b/tests/Unit/Services/SchoolIds/SchoolIdGenerationServiceTest.php new file mode 100644 index 00000000..4823f673 --- /dev/null +++ b/tests/Unit/Services/SchoolIds/SchoolIdGenerationServiceTest.php @@ -0,0 +1,32 @@ +generateStudentId(); + + $year = date('y'); + $this->assertStringStartsWith('STU' . $year, $schoolId); + $this->assertSame(10, strlen($schoolId)); + } + + public function test_generates_user_school_id_with_prefix(): void + { + $service = app(SchoolIdGenerationService::class); + $schoolId = $service->generateUserId(); + + $year = date('y'); + $this->assertStringStartsWith($year, $schoolId); + $this->assertSame(7, strlen($schoolId)); + } +} diff --git a/tests/Unit/Services/Settings/ConfigurationServiceTest.php b/tests/Unit/Services/Settings/ConfigurationServiceTest.php new file mode 100644 index 00000000..a69db2eb --- /dev/null +++ b/tests/Unit/Services/Settings/ConfigurationServiceTest.php @@ -0,0 +1,27 @@ +store([ + 'config_key' => 'school_year', + 'config_value' => '2025-2026', + ]); + + $this->assertNotNull($config->id); + $this->assertDatabaseHas('configuration', [ + 'id' => $config->id, + 'config_key' => 'school_year', + ]); + } +} diff --git a/tests/Unit/Services/Students/StudentAssignmentServiceTest.php b/tests/Unit/Services/Students/StudentAssignmentServiceTest.php new file mode 100644 index 00000000..a0cb4dcc --- /dev/null +++ b/tests/Unit/Services/Students/StudentAssignmentServiceTest.php @@ -0,0 +1,95 @@ +seedConfig(); + $studentId = $this->seedStudent(); + $classSectionId = $this->seedClassSection(); + + $service = new StudentAssignmentService(new StudentConfigService()); + $result = $service->assignClasses($studentId, [$classSectionId], false); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('student_class', [ + 'student_id' => $studentId, + 'class_section_id' => $classSectionId, + 'school_year' => '2025-2026', + ]); + } + + public function test_remove_deletes_assignment(): void + { + $this->seedConfig(); + $studentId = $this->seedStudent(); + $classSectionId = $this->seedClassSection(); + + DB::table('student_class')->insert([ + 'student_id' => $studentId, + 'class_section_id' => $classSectionId, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new StudentAssignmentService(new StudentConfigService()); + $result = $service->removeClass($studentId, $classSectionId); + + $this->assertTrue($result['ok']); + $this->assertDatabaseMissing('student_class', [ + 'student_id' => $studentId, + 'class_section_id' => $classSectionId, + 'school_year' => '2025-2026', + ]); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + } + + private function seedStudent(): int + { + return DB::table('students')->insertGetId([ + 'school_id' => 'S-700', + 'firstname' => 'Mina', + 'lastname' => 'Student', + 'dob' => '2014-09-01', + 'age' => 11, + 'gender' => 'Female', + 'photo_consent' => 1, + 'parent_id' => 1, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + } + + private function seedClassSection(): int + { + DB::table('classSection')->insert([ + 'class_id' => 1, + 'class_section_id' => 501, + 'class_section_name' => '5A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + return 501; + } +} diff --git a/tests/Unit/Services/Students/StudentProfileServiceTest.php b/tests/Unit/Services/Students/StudentProfileServiceTest.php new file mode 100644 index 00000000..aabe1091 --- /dev/null +++ b/tests/Unit/Services/Students/StudentProfileServiceTest.php @@ -0,0 +1,61 @@ +seedStudent(); + + $service = new StudentProfileService(); + $result = $service->updateStudent($studentId, [ + 'firstname' => 'Lee', + 'lastname' => 'Student', + 'dob' => '2014-09-01', + 'age' => 11, + 'gender' => 'Male', + 'registration_grade' => '1', + 'photo_consent' => true, + 'parent_id' => 1, + 'registration_date' => '2025-09-01', + 'tuition_paid' => false, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'rfid_tag' => 'RF-1', + 'semester' => 'Fall', + 'is_new' => true, + 'allergies' => 'Peanuts', + ]); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('student_allergies', [ + 'student_id' => $studentId, + 'allergy' => 'Peanuts', + ]); + } + + private function seedStudent(): int + { + return DB::table('students')->insertGetId([ + 'school_id' => 'S-850', + 'firstname' => 'Lee', + 'lastname' => 'Student', + 'dob' => '2014-09-01', + 'age' => 11, + 'gender' => 'Male', + 'photo_consent' => 1, + 'parent_id' => 1, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + } +} diff --git a/tests/Unit/Services/Students/StudentScoreCardServiceTest.php b/tests/Unit/Services/Students/StudentScoreCardServiceTest.php new file mode 100644 index 00000000..5dd26889 --- /dev/null +++ b/tests/Unit/Services/Students/StudentScoreCardServiceTest.php @@ -0,0 +1,71 @@ +seedStudent(); + $this->seedClassSection(); + + DB::table('semester_scores')->insert([ + 'student_id' => $studentId, + 'class_section_id' => 601, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'semester_score' => 88, + ]); + + DB::table('score_comments')->insert([ + 'student_id' => $studentId, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'score_type' => 'midterm', + 'comment_review' => 'Great effort', + 'created_at' => now(), + ]); + + $service = new StudentScoreCardService(); + $result = $service->scoreCard($studentId); + + $this->assertTrue($result['ok']); + $this->assertSame('S-800', $result['student']['school_id']); + $this->assertSame('Great effort', $result['rows'][0]['comments']['midterm']); + } + + private function seedStudent(): int + { + return DB::table('students')->insertGetId([ + 'school_id' => 'S-800', + 'firstname' => 'Zane', + 'lastname' => 'Student', + 'dob' => '2014-09-01', + 'age' => 11, + 'gender' => 'Male', + 'photo_consent' => 1, + 'parent_id' => 1, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + } + + private function seedClassSection(): void + { + DB::table('classSection')->insert([ + 'class_id' => 1, + 'class_section_id' => 601, + 'class_section_name' => '6A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + } +} diff --git a/tests/Unit/Services/Subjects/SubjectCurriculumServiceTest.php b/tests/Unit/Services/Subjects/SubjectCurriculumServiceTest.php new file mode 100644 index 00000000..239a6dcc --- /dev/null +++ b/tests/Unit/Services/Subjects/SubjectCurriculumServiceTest.php @@ -0,0 +1,73 @@ +seedClass(); + $service = new SubjectCurriculumService(); + + $entry = $service->store([ + 'class_id' => $classId, + 'subject' => 'islamic', + 'unit_number' => 1, + 'unit_title' => 'Faith', + 'chapter_name' => 'Intro', + ]); + + $this->assertNotNull($entry->id); + $this->assertDatabaseHas('subject_curriculum_items', [ + 'id' => $entry->id, + 'class_id' => $classId, + ]); + } + + public function test_update_changes_title(): void + { + $classId = $this->seedClass(); + $entryId = DB::table('subject_curriculum_items')->insertGetId([ + 'class_id' => $classId, + 'subject' => 'quran', + 'unit_number' => 2, + 'unit_title' => 'Old', + 'chapter_name' => 'Chapter 1', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new SubjectCurriculumService(); + $entry = $service->update($entryId, [ + 'class_id' => $classId, + 'subject' => 'quran', + 'unit_number' => 2, + 'unit_title' => 'New', + 'chapter_name' => 'Chapter 1', + ]); + + $this->assertNotNull($entry); + $this->assertDatabaseHas('subject_curriculum_items', [ + 'id' => $entryId, + 'unit_title' => 'New', + ]); + } + + private function seedClass(): int + { + return DB::table('classes')->insertGetId([ + 'class_name' => 'Grade 1', + 'schedule' => 'Sunday', + 'capacity' => 20, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + } +} diff --git a/tests/Unit/Services/Teachers/TeacherAbsenceServiceTest.php b/tests/Unit/Services/Teachers/TeacherAbsenceServiceTest.php new file mode 100644 index 00000000..76002de0 --- /dev/null +++ b/tests/Unit/Services/Teachers/TeacherAbsenceServiceTest.php @@ -0,0 +1,108 @@ +seedConfig(); + $teacherId = $this->seedTeacherUser(); + + $service = new TeacherAbsenceService( + new TeacherConfigService(), + new SemesterRangeService(new SemesterConfigService()), + new StaffTimeOffLinkService() + ); + + $data = $service->formData($teacherId); + + $this->assertSame('Fall', $data['semester']); + $this->assertNotEmpty($data['available_dates']); + Carbon::setTestNow(); + } + + public function test_submit_creates_staff_attendance(): void + { + Carbon::setTestNow(Carbon::parse('2025-10-01')); + $this->seedConfig(); + $teacherId = $this->seedTeacherUser(); + + $service = new TeacherAbsenceService( + new TeacherConfigService(), + new SemesterRangeService(new SemesterConfigService()), + new StaffTimeOffLinkService() + ); + + $date = $service->formData($teacherId)['available_dates'][0]; + + $result = $service->submit($teacherId, [ + 'dates' => [$date], + 'reason' => 'Family event', + 'reason_type' => 'personal', + ]); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('staff_attendance', [ + 'user_id' => $teacherId, + 'date' => $date, + 'status' => 'absent', + ]); + Carbon::setTestNow(); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ['config_key' => 'fall_semester_start', 'config_value' => '2025-09-01'], + ['config_key' => 'spring_semester_start', 'config_value' => '2026-01-15'], + ['config_key' => 'last_school_day', 'config_value' => '2026-06-15'], + ]); + } + + private function seedTeacherUser(): int + { + $roleId = DB::table('roles')->insertGetId([ + 'name' => 'teacher', + ]); + + $userId = DB::table('users')->insertGetId([ + 'firstname' => 'Nina', + 'lastname' => 'Teacher', + 'cellphone' => '3333333333', + 'email' => 'teacher.absence@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + DB::table('user_roles')->insert([ + 'user_id' => $userId, + 'role_id' => $roleId, + ]); + + return $userId; + } +} diff --git a/tests/Unit/Services/Teachers/TeacherAssignmentServiceTest.php b/tests/Unit/Services/Teachers/TeacherAssignmentServiceTest.php new file mode 100644 index 00000000..05bf836e --- /dev/null +++ b/tests/Unit/Services/Teachers/TeacherAssignmentServiceTest.php @@ -0,0 +1,117 @@ +seedConfig(); + $teacherId = $this->seedTeacherUser(); + $classSectionId = $this->seedClassSection(); + + $service = new TeacherAssignmentService(new TeacherConfigService()); + $result = $service->assign([ + 'teacher_id' => $teacherId, + 'class_section_id' => $classSectionId, + 'teacher_role' => 'teacher', + ]); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('teacher_class', [ + 'teacher_id' => $teacherId, + 'class_section_id' => $classSectionId, + 'position' => 'main', + ]); + } + + public function test_delete_removes_assignment(): void + { + $this->seedConfig(); + $teacherId = $this->seedTeacherUser(); + $classSectionId = $this->seedClassSection(); + + DB::table('teacher_class')->insert([ + 'teacher_id' => $teacherId, + 'class_section_id' => $classSectionId, + 'position' => 'ta', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new TeacherAssignmentService(new TeacherConfigService()); + $result = $service->delete([ + 'teacher_id' => $teacherId, + 'class_section_id' => $classSectionId, + 'position' => 'ta', + ]); + + $this->assertTrue($result['ok']); + $this->assertDatabaseMissing('teacher_class', [ + 'teacher_id' => $teacherId, + 'class_section_id' => $classSectionId, + 'position' => 'ta', + ]); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + } + + private function seedTeacherUser(): int + { + $roleId = DB::table('roles')->insertGetId([ + 'name' => 'teacher', + ]); + + $userId = DB::table('users')->insertGetId([ + 'firstname' => 'Alex', + 'lastname' => 'Teacher', + 'cellphone' => '2222222222', + 'email' => 'teacher.assignment@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + DB::table('user_roles')->insert([ + 'user_id' => $userId, + 'role_id' => $roleId, + ]); + + return $userId; + } + + private function seedClassSection(): int + { + DB::table('classSection')->insert([ + 'class_id' => 1, + 'class_section_id' => 201, + 'class_section_name' => '2A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + return 201; + } +} diff --git a/tests/Unit/Services/Teachers/TeacherDashboardServiceTest.php b/tests/Unit/Services/Teachers/TeacherDashboardServiceTest.php new file mode 100644 index 00000000..635510e0 --- /dev/null +++ b/tests/Unit/Services/Teachers/TeacherDashboardServiceTest.php @@ -0,0 +1,118 @@ +seedConfig(); + $teacherId = $this->seedTeacherUser(); + $classSectionId = $this->seedClassSection(); + $this->seedTeacherAssignment($teacherId, $classSectionId); + $this->seedStudent($teacherId, $classSectionId); + + $service = new TeacherDashboardService(new TeacherConfigService()); + $data = $service->classView($teacherId, $classSectionId); + + $this->assertSame($classSectionId, $data['active_class_section_id']); + $this->assertNotEmpty($data['classes']); + $this->assertNotEmpty($data['students']); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + } + + private function seedTeacherUser(): int + { + $roleId = DB::table('roles')->insertGetId([ + 'name' => 'teacher', + ]); + + $userId = DB::table('users')->insertGetId([ + 'firstname' => 'Tina', + 'lastname' => 'Teacher', + 'cellphone' => '1111111111', + 'email' => 'teacher.dashboard@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + DB::table('user_roles')->insert([ + 'user_id' => $userId, + 'role_id' => $roleId, + ]); + + return $userId; + } + + private function seedClassSection(): int + { + DB::table('classSection')->insert([ + 'class_id' => 1, + 'class_section_id' => 101, + 'class_section_name' => '1A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + return 101; + } + + private function seedTeacherAssignment(int $teacherId, int $classSectionId): void + { + DB::table('teacher_class')->insert([ + 'teacher_id' => $teacherId, + 'class_section_id' => $classSectionId, + 'position' => 'main', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedStudent(int $parentId, int $classSectionId): void + { + $studentId = DB::table('students')->insertGetId([ + 'school_id' => 'S-500', + 'firstname' => 'Sam', + 'lastname' => 'Student', + 'dob' => '2014-09-01', + 'age' => 11, + 'gender' => 'Male', + 'photo_consent' => 1, + 'parent_id' => $parentId, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + DB::table('student_class')->insert([ + 'student_id' => $studentId, + 'class_section_id' => $classSectionId, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + } +}