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 = '
A staff time-off request was submitted from the teacher portal.
' + . '| Name | ' . e($fullName) . ' |
| ' . 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) . ' |
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' : '') . '.A staff time-off request was submitted from the teacher portal.
' - . '| Name | ' . esc($fullName) . ' |
| ' . 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) . ' |
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 93afb4d7..a6106036 100644 Binary files a/school_api and b/school_api differ 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', + ]); + } +}