diff --git a/app/Config/Services.php b/app/Config/Services.php index 1468871..929dacb 100644 --- a/app/Config/Services.php +++ b/app/Config/Services.php @@ -329,4 +329,167 @@ class Services extends BaseService new \App\Libraries\InvoiceLedgerService() ); } + + public static function enrollmentWithdrawal(bool $getShared = true): \App\Services\EnrollmentWithdrawalService + { + if ($getShared) { + return static::getSharedInstance('enrollmentWithdrawal'); + } + + $db = \Config\Database::connect(); + + return new \App\Services\EnrollmentWithdrawalService( + $db, + model(\App\Models\StudentModel::class), + model(\App\Models\EnrollmentModel::class), + model(\App\Models\StudentClassModel::class), + model(\App\Models\ClassSectionModel::class), + model(\App\Models\UserModel::class), + model(\App\Models\InvoiceModel::class), + model(\App\Models\RefundModel::class) + ); + } + + public static function teacherSubmissionReport(bool $getShared = true): \App\Services\TeacherSubmissionReportService + { + if ($getShared) { + return static::getSharedInstance('teacherSubmissionReport'); + } + + return new \App\Services\TeacherSubmissionReportService( + \Config\Database::connect(), + model(\App\Models\ConfigurationModel::class), + model(\App\Models\StudentClassModel::class), + model(\App\Models\ClassSectionModel::class), + model(\App\Models\UserModel::class) + ); + } + + public static function administratorDashboard(bool $getShared = true): \App\Services\AdministratorDashboardService + { + if ($getShared) { + return static::getSharedInstance('administratorDashboard'); + } + + return new \App\Services\AdministratorDashboardService( + \Config\Database::connect(), + model(\App\Models\UserModel::class), + model(\App\Models\LoginActivityModel::class) + ); + } + + public static function adminNotificationSettings(bool $getShared = true): \App\Services\AdminNotificationSettingsService + { + if ($getShared) { + return static::getSharedInstance('adminNotificationSettings'); + } + + return new \App\Services\AdminNotificationSettingsService( + \Config\Database::connect(), + model(\App\Models\AdminNotificationSubjectModel::class) + ); + } + + public static function administratorDirectory(bool $getShared = true): \App\Services\AdministratorDirectoryService + { + if ($getShared) { + return static::getSharedInstance('administratorDirectory'); + } + + return new \App\Services\AdministratorDirectoryService( + \Config\Database::connect(), + model(\App\Models\StudentClassModel::class), + model(\App\Models\UserModel::class), + model(\App\Models\UserRoleModel::class), + model(\App\Models\InvoiceModel::class), + model(\App\Models\StudentModel::class) + ); + } + + public static function gradingScore(bool $getShared = true): \App\Services\GradingScoreService + { + if ($getShared) { + return static::getSharedInstance('gradingScore'); + } + + $configModel = model(\App\Models\ConfigurationModel::class); + $attendanceCalculator = new \App\Services\Calculators\AttendanceCalculator( + model(\App\Models\AttendanceRecordModel::class), + $configModel, + model(\App\Models\CalendarModel::class) + ); + + return new \App\Services\GradingScoreService( + \Config\Database::connect(), + $configModel, + model(\App\Models\HomeworkModel::class), + model(\App\Models\UserModel::class), + model(\App\Models\StudentClassModel::class), + model(\App\Models\StudentModel::class), + model(\App\Models\TeacherClassModel::class), + model(\App\Models\ClassSectionModel::class), + $attendanceCalculator, + model(\App\Models\GradingLockModel::class), + static::semesterScoreService(), + (string) $configModel->getConfig('school_year'), + (string) getSemester() + ); + } + + public static function placementGrading(bool $getShared = true): \App\Services\PlacementGradingService + { + if ($getShared) { + return static::getSharedInstance('placementGrading'); + } + + $configModel = model(\App\Models\ConfigurationModel::class); + + return new \App\Services\PlacementGradingService( + \Config\Database::connect(), + model(\App\Models\StudentModel::class), + model(\App\Models\PlacementLevelModel::class), + model(\App\Models\PlacementBatchModel::class), + model(\App\Models\PlacementScoreModel::class), + (string) $configModel->getConfig('school_year') + ); + } + + public static function belowSixty(bool $getShared = true): \App\Services\BelowSixtyService + { + if ($getShared) { + return static::getSharedInstance('belowSixty'); + } + + $configModel = model(\App\Models\ConfigurationModel::class); + + return new \App\Services\BelowSixtyService( + \Config\Database::connect(), + $configModel, + model(\App\Models\StudentModel::class), + model(\App\Models\StudentClassModel::class), + model(\App\Models\ParentMeetingScheduleModel::class), + model(\App\Models\UserModel::class), + (string) $configModel->getConfig('school_year'), + (string) getSemester() + ); + } + + public static function studentDecision(bool $getShared = true): \App\Services\StudentDecisionService + { + if ($getShared) { + return static::getSharedInstance('studentDecision'); + } + + $configModel = model(\App\Models\ConfigurationModel::class); + + return new \App\Services\StudentDecisionService( + \Config\Database::connect(), + $configModel, + model(\App\Models\StudentModel::class), + model(\App\Models\StudentClassModel::class), + model(\App\Models\UserModel::class), + (string) $configModel->getConfig('school_year'), + (string) getSemester() + ); + } } diff --git a/app/Controllers/View/AdministratorController.php b/app/Controllers/View/AdministratorController.php index 347ce04..812b3bd 100644 --- a/app/Controllers/View/AdministratorController.php +++ b/app/Controllers/View/AdministratorController.php @@ -4,91 +4,32 @@ namespace App\Controllers\View; use App\Controllers\BaseController; use App\Models\UserModel; use App\Models\RoleModel; -use App\Models\PermissionModel; -use App\Models\RolePermissionModel; -use App\Models\StudentModel; -use App\Models\LoginActivityModel; -use CodeIgniter\Controller; -use App\Models\InvoiceModel; -use App\Models\ClassSectionModel; use App\Models\UserRoleModel; use App\Models\ConfigurationModel; -use App\Models\RefundModel; -use App\Models\EnrollmentModel; -use App\Models\AdminNotificationSubjectModel; -use Doctrine\DBAL\Configuration; -use App\Services\FeeCalculationService; -use App\Models\StudentClassModel; -use App\Models\StudentSectionDistributionDraftModel; -use App\Controllers\View\EmailController; -use App\Controllers\View\InvoiceController; -use App\Libraries\RefundEligibilityService; use App\Models\StaffAttendanceModel; use App\Libraries\StaffTimeOffLinkService; -use App\Models\AttendanceDayModel; -use App\Models\ScoreCommentModel; -use App\Models\SemesterScoreModel; -use App\Models\TeacherClassModel; -use App\Models\TeacherSubmissionNotificationHistoryModel; -use App\Models\ExamDraftModel; -use App\Models\HomeworkModel; use App\Services\SemesterRangeService; -use App\Support\Enrollment\DeliberationDecision; - -use CodeIgniter\Events\Events; class AdministratorController extends BaseController { - protected $permissionModel; - protected $rolePermissionModel; protected $roleModel; protected $userModel; protected $userRoleModel; - protected $db; protected $configModel; protected $semester; protected $schoolYear; - protected $studentModel; - protected $loginActivityModel; - protected $invoiceModel; - protected $refundModel; - protected $enrollmentModel; - protected $classSectionModel; - protected $studentClassModel; protected $staffAttendanceModel; - protected $adminNotificationSubjectModel; public function __construct() { helper('auth'); - // Load models - $this->rolePermissionModel = new RolePermissionModel(); - $this->permissionModel = new PermissionModel(); $this->roleModel = new RoleModel(); $this->userModel = new UserModel(); $this->configModel = new ConfigurationModel(); - $this->studentModel = new StudentModel(); - $this->loginActivityModel = new LoginActivityModel(); $this->userRoleModel = new UserRoleModel(); - $this->invoiceModel = new InvoiceModel(); - $this->refundModel = new RefundModel(); - $this->enrollmentModel = new EnrollmentModel(); - $this->classSectionModel = new ClassSectionModel(); - $this->adminNotificationSubjectModel = new AdminNotificationSubjectModel(); - $this->semester = getSemester(); $this->schoolYear = $this->configModel->getConfig('school_year'); - $this->studentClassModel = new StudentClassModel(); $this->staffAttendanceModel = new StaffAttendanceModel(); - // Load the database service - $this->db = \Config\Database::connect(); - // Check if the database connection is established - if (!$this->db->connect()) { - log_message('error', 'Database connection failed.'); - throw new \Exception('Database connection failed.'); - } else { - log_message('info', 'Database connection successful.'); - } } /** @@ -136,55 +77,6 @@ class AdministratorController extends BaseController return $dates; } - private function getPreviousSchoolYear(string $schoolYear): string - { - $schoolYear = trim($schoolYear); - if ($schoolYear === '') { - return ''; - } - - if (preg_match('/^(\d{4})\s*-\s*(\d{4})$/', $schoolYear, $m)) { - return ((int)$m[1] - 1) . '-' . ((int)$m[2] - 1); - } - - if (preg_match('/^(\d{4})\s*-\s*(\d{2})$/', $schoolYear, $m)) { - $start = (int)$m[1] - 1; - $end = (int)$m[2] - 1; - if ($end < 0) { - $end += 100; - } - return sprintf('%04d-%02d', $start, $end); - } - - if (preg_match('/^\d{4}$/', $schoolYear)) { - return (string)((int)$schoolYear - 1); - } - - return ''; - } - - private function getSchoolYearStartYear(string $schoolYear): ?int - { - $schoolYear = trim($schoolYear); - if ($schoolYear === '') { - return null; - } - - if (preg_match('/^(\d{4})\s*-\s*(\d{4})$/', $schoolYear, $m)) { - return (int)$m[1]; - } - - if (preg_match('/^(\d{4})\s*-\s*(\d{2})$/', $schoolYear, $m)) { - return (int)$m[1]; - } - - if (preg_match('/^\d{4}$/', $schoolYear)) { - return (int)$schoolYear; - } - - return null; - } - /** * Admin self-service absence/vacation page (same features as teacher page) */ @@ -383,252 +275,6 @@ class AdministratorController extends BaseController return false; } - public function administratorDashboard() - { - helper('url'); - - $searchData = $this->buildUserSearchData((string) $this->request->getGet('query')); - - return view('administrator/administratordashboard', array_merge($searchData, [ - 'dashboardEndpoint' => site_url('api/administrator/dashboard'), - ])); - } - - public function dashboardMetrics() - { - return $this->response->setJSON($this->buildDashboardMetrics()); - } - - private function buildDashboardMetrics(): array - { - $recentActivities = $this->loginActivityModel->getLastActivities(4); - if (!is_array($recentActivities)) { - $recentActivities = []; - } - - $totalAdmins = (int) ($this->userModel->countAdminsBySchoolYear($this->schoolYear) ?? 0); - - $teachers = $this->userModel->getUsersByRoleAndSchoolYear('teacher', $this->schoolYear); - $totalTeachers = $this->countUniqueEntities($teachers); - - $teacherAssistants = $this->userModel->getUsersByRoleAndSchoolYear('teacher_assistant', $this->schoolYear); - $totalTeacherAssistants = $this->countUniqueEntities($teacherAssistants); - - $parents = $this->userModel->getUsersByRoleAndSchoolYear('parent', $this->schoolYear); - $totalParents = $this->countUniqueEntities($parents); - - // Count only students that have a class assigned and exist in student_class for the current school year - $totalStudents = (int) ( - $this->db->table('student_class') - ->select('COUNT(DISTINCT student_class.student_id) AS cnt') - ->join('students', 'students.id = student_class.student_id', 'inner') - ->where('student_class.school_year', $this->schoolYear) - ->where('student_class.class_section_id IS NOT NULL', null, false) - ->where('students.is_active', 1) - ->get() - ->getRow('cnt') - ?? 0 - ); - - return [ - 'counts' => [ - 'students' => $totalStudents, - 'teachers' => $totalTeachers, - 'teacherAssistants' => $totalTeacherAssistants, - 'admins' => $totalAdmins, - 'parents' => $totalParents, - ], - 'recentActivities' => array_map(static function ($activity) { - if (!is_array($activity)) { - return []; - } - return [ - 'login_time' => $activity['login_time'] ?? null, - 'email' => $activity['email'] ?? null, - ]; - }, $recentActivities), - 'meta' => [ - 'schoolYear' => $this->schoolYear, - 'semester' => $this->semester, - ], - ]; - } - - private function countUniqueEntities($rows): int - { - if (!is_array($rows) || $rows === []) { - return 0; - } - - $ids = []; - foreach ($rows as $row) { - if (!is_array($row)) { - continue; - } - if (isset($row['id'])) { - $ids[] = (int) $row['id']; - continue; - } - if (isset($row['user_id'])) { - $ids[] = (int) $row['user_id']; - } - } - - return count(array_unique($ids)); - } - - public function userSearch() - { - $data = $this->buildUserSearchData((string) $this->request->getGet('query')); - - return view('administrator/search_results', $data); - } - - private function buildUserSearchData(string $query): array - { - $q = trim($query); - - if ($q === '') { - return [ - 'query' => '', - 'results' => [], - 'scope_used' => 'unscoped-raw', - 'scope_label' => 'all years/semesters (raw)', - 'total_found' => 0, - ]; - } - - $db = $this->db; - - // 1) Tokenize input: split by whitespace and punctuation, keep meaningful pieces - $rawTokens = preg_split('/[,\s]+/u', $q, -1, PREG_SPLIT_NO_EMPTY) ?: []; - $tokens = array_values(array_filter(array_map('trim', $rawTokens))); - - // 2) Build phone variants for any token that looks numeric-ish - $phoneMap = []; // token => variants[] - foreach ($tokens as $t) { - $digits = preg_replace('/\D+/', '', $t); - if ($digits === '') { - continue; - } - - $v = []; - if (strlen($digits) >= 7) { - // base forms - $v[] = $digits; - if (strlen($digits) === 10) { - $v[] = sprintf('(%s)-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6)); - $v[] = sprintf('%s-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6)); - $v[] = sprintf('%s %s %s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6)); - // country-code forms - $v[] = '1' . $digits; - $v[] = '+1' . $digits; - $v[] = '+1 ' . sprintf('(%s) %s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6)); - $v[] = '+1-' . sprintf('%s-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6)); - } elseif (strlen($digits) === 11 && str_starts_with($digits, '1')) { - $ten = substr($digits, 1); - $v[] = $ten; - $v[] = sprintf('(%s)-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6)); - $v[] = sprintf('%s-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6)); - $v[] = sprintf('%s %s %s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6)); - $v[] = '+1' . $ten; - $v[] = '+1 ' . sprintf('(%s) %s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6)); - $v[] = '+1-' . sprintf('%s-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6)); - } else { - // 7-9 digits: keep as-is (partial phone fragment) - $v[] = $digits; - } - } - if (!empty($v)) { - $phoneMap[$t] = array_values(array_unique($v)); - } - } - - // Helper: ( token1 AND token2 AND ... ), each token may match ANY of $columns, - // and phone variants are only applied to the provided $phoneCols for THIS table. - $applyMultiTokenLike = function ($qb, array $columns, array $tokens, array $phoneCols = []) use ($phoneMap) { - foreach ($tokens as $t) { - $qb->groupStart(); // OR across columns for this token - foreach ($columns as $i => $col) { - if ($i === 0) { - $qb->like($col, $t); - } else { - $qb->orLike($col, $t); - } - } - if (!empty($phoneMap[$t]) && !empty($phoneCols)) { - foreach ($phoneMap[$t] as $pv) { - foreach ($phoneCols as $pcol) { - $qb->orLike($pcol, $pv); - } - } - } - $qb->groupEnd(); - } - return $qb; - }; - - // ===== RAW UNscoped searches (flat arrays) ===== - - // USERS (phone col: cellphone) - $uCols = ['firstname', 'lastname', 'email', 'cellphone', 'school_id', 'city', 'state']; - $uQB = $db->table('users') - ->select('id, firstname, lastname, email, cellphone, school_id, city, state'); - $applyMultiTokenLike($uQB, $uCols, $tokens, ['cellphone']); - $users = $uQB->limit(150)->get()->getResultArray(); - - // STUDENTS (no phone column to search) - $sCols = ['firstname', 'lastname', 'school_id', 'rfid_tag', 'dob', 'gender']; - $sQB = $db->table('students') - ->select('id, parent_id, school_id, firstname, lastname, dob, gender, rfid_tag'); - $applyMultiTokenLike($sQB, $sCols, $tokens, []); - $students = $sQB->limit(150)->get()->getResultArray(); - - // PARENTS (phone col: secondparent_phone) - $pCols = ['secondparent_firstname', 'secondparent_lastname', 'secondparent_email', 'secondparent_phone']; - $pQB = $db->table('parents') - ->select('id, firstparent_id, secondparent_firstname, secondparent_lastname, secondparent_email, secondparent_phone'); - $applyMultiTokenLike($pQB, $pCols, $tokens, ['secondparent_phone']); - foreach ($tokens as $t) { - if (ctype_digit($t)) { - $pQB->orWhere('firstparent_id', (int) $t)->orWhere('id', (int) $t); - } - } - $parents = $pQB->limit(150)->get()->getResultArray(); - - // STAFF (phone col: phone) - $stCols = ['firstname', 'lastname', 'email', 'role_name', 'phone']; - $stQB = $db->table('staff') - ->select('id, user_id, firstname, lastname, email, phone, role_name, active_role'); - $applyMultiTokenLike($stQB, $stCols, $tokens, ['phone']); - $staff = $stQB->limit(150)->get()->getResultArray(); - - // EMERGENCY CONTACTS (phone col: cellphone) - $ecCols = ['emergency_contact_name', 'relation', 'email', 'cellphone']; - $ecQB = $db->table('emergency_contacts') - ->select('id, parent_id, emergency_contact_name, relation, cellphone, email'); - $applyMultiTokenLike($ecQB, $ecCols, $tokens, ['cellphone']); - $emergency = $ecQB->limit(150)->get()->getResultArray(); - - $raw = [ - 'users' => $users, - 'students' => $students, - 'parents' => $parents, - 'staff' => $staff, - 'emergency_contacts' => $emergency, - ]; - - $total = count($users) + count($students) + count($parents) + count($staff) + count($emergency); - - return [ - 'query' => $q, - 'results' => $raw, - 'scope_used' => 'unscoped-raw', - 'scope_label' => 'all years/semesters (raw, tokenized)', - 'total_found' => $total, - ]; - } - public function teachers() { return view('administrator/teachers'); // This is the correct view path @@ -699,1320 +345,6 @@ class AdministratorController extends BaseController return view('administrator/feedback_complaints'); } - public function teacherSubmissionsReport() - { - $semester = (string)(getSemester() ?? $this->semester ?? ''); - $schoolYear = trim((string) ($this->request->getGet('school_year') ?? '')); - if ($schoolYear === '') { - $schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); - } - $semesterResolver = new SemesterRangeService($this->configModel); - $semesterNorm = $semesterResolver->normalizeSemester($semester); - $semesterFilter = $semesterNorm !== '' ? $semesterNorm : $semester; - $semesterCandidates = $this->buildSemesterCandidates($semesterFilter); - $lowProgressRaw = (string) $this->request->getGet('low_progress_sections'); - $lowProgressSectionIds = array_values(array_unique(array_filter(array_map( - 'intval', - preg_split('/\s*,\s*/', $lowProgressRaw, -1, PREG_SPLIT_NO_EMPTY) - )))); - - $scoreComments = new ScoreCommentModel(); - $semesterScores = new SemesterScoreModel(); - $attendanceDays = new AttendanceDayModel(); - $examDrafts = new ExamDraftModel(); - $homeworkModel = new HomeworkModel(); - $historyModel = new TeacherSubmissionNotificationHistoryModel(); - - $assignmentQuery = $this->db->table('teacher_class tc') - ->select([ - 'tc.class_section_id', - 'cs.class_section_name', - 'tc.teacher_id', - 'u.firstname', - 'u.lastname', - 'tc.position', - ]) - ->join('classSection cs', 'cs.class_section_id = tc.class_section_id', 'left') - ->join('users u', 'u.id = tc.teacher_id', 'left') - ->orderBy('cs.class_section_name', 'ASC'); - - // teacher_class assignments are scoped by school year only. - // The table has no semester column; semester filtering belongs on - // semester-specific records such as scores, comments, attendance, - // homework, and exam drafts. - if ($schoolYear !== '') { - $assignmentQuery->where('tc.school_year', $schoolYear); - } - - $assignmentRows = $assignmentQuery->get()->getResultArray(); - - $studentCounts = $this->studentClassModel->getStudentCountsBySection($schoolYear !== '' ? $schoolYear : null); - $sectionRows = $this->classSectionModel - ->select('class_section_id, class_section_name') - ->orderBy('class_section_name', 'ASC') - ->findAll(); - $sectionMap = []; - foreach ($sectionRows as $sectionRow) { - $sectionId = (int) ($sectionRow['class_section_id'] ?? 0); - if ($sectionId <= 0) { - continue; - } - if (empty($studentCounts[$sectionId])) { - continue; - } - $sectionMap[$sectionId] = $sectionRow['class_section_name'] ?? "Section {$sectionId}"; - } - $sectionIds = array_keys($sectionMap); - - [$progressExpectedWeeks, $progressSubmittedBySection] = $this->buildClassProgressStats($sectionIds); - $examDraftCounts = []; - $examDraftDeadline = $this->resolveTeacherDashboardExamDraftDeadline($semester, $schoolYear); - $examDraftDeadlineConfig = trim((string) ($this->configModel->getConfig('exam_draft_deadline') ?? '')); - $examDraftDeadlineFormatted = ''; - if ($examDraftDeadlineConfig !== '') { - $parsedUi = $this->parseExamDraftDeadlineConfigValue(); - $examDraftDeadlineFormatted = $parsedUi !== null ? $parsedUi->format('M j, Y') : ''; - } - $homeworkCounts = []; - if (! empty($sectionIds)) { - $draftBuilder = $examDrafts - ->select('class_section_id') - ->whereIn('class_section_id', $sectionIds); - if ($schoolYear !== '') { - $draftBuilder->where('school_year', $schoolYear); - } - if (!empty($semesterCandidates)) { - $draftBuilder->whereIn('semester', $semesterCandidates); - } - if ($this->db->fieldExists('is_legacy', 'exam_drafts')) { - $draftBuilder->where('is_legacy', 0); - } - $draftRows = $draftBuilder->findAll(); - foreach ($draftRows as $draft) { - $sectionId = (int) ($draft['class_section_id'] ?? 0); - if ($sectionId <= 0) { - continue; - } - $examDraftCounts[$sectionId] = ($examDraftCounts[$sectionId] ?? 0) + 1; - } - - $homeworkBuilder = $homeworkModel - ->select('class_section_id, homework_index') - ->whereIn('class_section_id', $sectionIds); - if ($schoolYear !== '') { - $homeworkBuilder->where('school_year', $schoolYear); - } - if (!empty($semesterCandidates)) { - $homeworkBuilder->whereIn('semester', $semesterCandidates); - } - $homeworkRows = $homeworkBuilder - ->where('score IS NOT NULL', null, false) - ->where('score !=', '') - ->groupBy('class_section_id, homework_index') - ->findAll(); - foreach ($homeworkRows as $row) { - $sectionId = (int) ($row['class_section_id'] ?? 0); - if ($sectionId <= 0) { - continue; - } - $homeworkCounts[$sectionId] = ($homeworkCounts[$sectionId] ?? 0) + 1; - } - } - - if (empty($lowProgressSectionIds)) { - $lowProgressSectionIds = $this->resolveLowProgressSectionIds($sectionIds); - } - - $teachersBySection = []; - foreach ($assignmentRows as $assignment) { - $sectionId = (int)($assignment['class_section_id'] ?? 0); - if ($sectionId <= 0) { - continue; - } - - $positionKey = strtolower(trim((string)($assignment['position'] ?? ''))); - $roleKey = $positionKey !== '' ? $positionKey : 'teacher'; - $positionLabel = match ($roleKey) { - 'ta' => 'TA', - 'main' => 'Main', - default => $roleKey !== '' ? ucfirst($roleKey) : 'Teacher', - }; - - $teacherFullName = trim(($assignment['firstname'] ?? '') . ' ' . ($assignment['lastname'] ?? '')); - $teacherId = (int)($assignment['teacher_id'] ?? 0); - if ($teacherFullName === '' || $teacherId <= 0) { - continue; - } - - $entry = &$teachersBySection[$sectionId]; - if (!isset($entry)) { - $entry = [ - 'class_section' => $assignment['class_section_name'] ?? ($sectionMap[$sectionId] ?? "Section {$sectionId}"), - 'teachers' => [], - ]; - } - - $entry['teachers'][] = [ - 'id' => $teacherId, - 'label' => "{$positionLabel}: {$teacherFullName}", - 'role_key' => $roleKey, - ]; - unset($entry); - } - - $today = (new \DateTimeImmutable('now', new \DateTimeZone(date_default_timezone_get() ?: 'UTC')))->format('Y-m-d'); - - $rows = []; - $totalStatuses = 0; - $missingItemCount = 0; - $allTeacherIds = []; - $allClassSectionIds = []; - $examTerm = $this->resolveExamTermLabel($semester); - $examScoreField = $examTerm === 'final' ? 'final_exam_score' : 'midterm_exam_score'; - - foreach ($sectionMap as $classSectionId => $sectionName) { - $classSectionId = (int)$classSectionId; - if ($classSectionId <= 0) { - continue; - } - - $studentEntries = $this->db->table('student_class') - ->select('student_id') - ->where('class_section_id', $classSectionId) - ->where('school_year', $schoolYear) - ->get() - ->getResultArray(); - if (empty($studentEntries)) { - $studentEntries = $this->studentClassModel - ->select('student_id') - ->where('class_section_id', $classSectionId) - ->where('school_year', $schoolYear) - ->findAll(); - } - $studentIds = array_filter(array_map(static fn($entry) => (int)($entry['student_id'] ?? 0), $studentEntries)); - $expected = count($studentIds); - - $midtermStudents = []; - $participationStudents = []; - if ($classSectionId > 0) { - $scoreQuery = $semesterScores - ->where('class_section_id', $classSectionId) - ->where('school_year', $schoolYear); - if (!empty($semesterCandidates)) { - $scoreQuery->whereIn('semester', $semesterCandidates); - } - $scoreRecords = $scoreQuery->findAll(); - foreach ($scoreRecords as $score) { - $sid = (int)($score['student_id'] ?? 0); - if ($sid <= 0 || ($expected > 0 && !in_array($sid, $studentIds, true))) { - continue; - } - $midtermValue = trim((string)($score[$examScoreField] ?? '')); - if ($midtermValue !== '') { - $midtermStudents[$sid] = true; - } - $participationValue = trim((string)($score['participation_score'] ?? '')); - if ($participationValue !== '') { - $participationStudents[$sid] = true; - } - } - } - - $midtermCommentStudents = []; - $ptapCommentStudents = []; - if (!empty($studentIds)) { - $commentQuery = $scoreComments - ->select('student_id, score_type, comment') - ->whereIn('student_id', $studentIds) - ->where('school_year', $schoolYear) - ->whereIn('score_type', [$examTerm, 'ptap']); - if (!empty($semesterCandidates)) { - $commentQuery->whereIn('semester', $semesterCandidates); - } - $comments = $commentQuery->findAll(); - foreach ($comments as $comment) { - $sid = (int)($comment['student_id'] ?? 0); - if ($sid <= 0) { - continue; - } - $text = trim((string)($comment['comment'] ?? '')); - if ($text === '') { - continue; - } - $type = strtolower(trim((string)($comment['score_type'] ?? ''))); - if ($type === $examTerm) { - $midtermCommentStudents[$sid] = true; - } - if ($type === 'ptap') { - $ptapCommentStudents[$sid] = true; - } - } - } - - $attendanceQuery = $attendanceDays - ->where('class_section_id', $classSectionId) - ->where('school_year', $schoolYear) - ->where('date', $today); - if (!empty($semesterCandidates)) { - $attendanceQuery->whereIn('semester', $semesterCandidates); - } - $attendanceRow = $attendanceQuery->first(); - $attendanceSubmitted = $attendanceRow && in_array(strtolower((string)($attendanceRow['status'] ?? '')), ['submitted', 'published', 'finalized'], true); - - $section = $teachersBySection[$classSectionId] ?? ['teachers' => []]; - $teacherList = $section['teachers'] ?? []; - if (!empty($teacherList)) { - usort($teacherList, function ($a, $b) { - return $this->teacherRolePriority($a['role_key'] ?? 'teacher') <=> $this->teacherRolePriority($b['role_key'] ?? 'teacher'); - }); - $teacherList = array_values($teacherList); - } - - foreach ($teacherList as $teacherEntry) { - if (!empty($teacherEntry['id'])) { - $allTeacherIds[] = $teacherEntry['id']; - } - } - $allClassSectionIds[] = $classSectionId; - - $midtermScoreStatus = $this->submissionStatus(count($midtermStudents), $expected); - $midtermCommentStatus = $this->submissionStatus(count($midtermCommentStudents), $expected); - $participationStatus = $this->submissionStatus(count($participationStudents), $expected); - $ptapCommentStatus = $this->submissionStatus(count($ptapCommentStudents), $expected); - $attendanceStatus = $this->attendanceStatus($attendanceSubmitted); - $progressSubmitted = (int) ($progressSubmittedBySection[$classSectionId] ?? 0); - $classProgressStatus = $this->progressStatus($progressSubmitted, $progressExpectedWeeks); - $draftSubmitted = (int) ($examDraftCounts[$classSectionId] ?? 0); - $examDraftStatus = $this->draftStatus($draftSubmitted, $examDraftDeadline); - $homeworkSubmitted = (int) ($homeworkCounts[$classSectionId] ?? 0); - $homeworkStatus = $this->homeworkStatus($homeworkSubmitted); - $statusDetails = [ - 'midterm_score_status' => $midtermScoreStatus, - 'midterm_comment_status' => $midtermCommentStatus, - 'participation_status' => $participationStatus, - 'ptap_comment_status' => $ptapCommentStatus, - 'class_progress_status' => $classProgressStatus, - 'exam_draft_status' => $examDraftStatus, - 'homework_status' => $homeworkStatus, - ]; - $missingItemsForSection = $this->buildMissingItems($statusDetails, $semester); - $missingItemCount += count($missingItemsForSection); - $totalStatuses += count($statusDetails); - - $rows[] = [ - 'class_section' => $sectionMap[$classSectionId] ?? ($section['class_section'] ?? "Section {$classSectionId}"), - 'class_section_id' => $classSectionId, - 'teachers' => $teacherList, - 'midterm_score_status' => $midtermScoreStatus, - 'midterm_comment_status' => $midtermCommentStatus, - 'participation_status' => $participationStatus, - 'ptap_comment_status' => $ptapCommentStatus, - 'attendance_status' => $attendanceStatus, - 'class_progress_status' => $classProgressStatus, - 'exam_draft_status' => $examDraftStatus, - 'homework_status' => $homeworkStatus, - 'missing_items' => $missingItemsForSection, - 'student_count' => $expected, - ]; - } - - $historyMap = []; - $teacherIds = array_values(array_unique($allTeacherIds)); - $classSectionIds = array_values(array_unique($allClassSectionIds)); - if (!empty($teacherIds) && !empty($classSectionIds)) { - $historyRecords = $historyModel - ->select('teacher_submission_notification_history.*, u.firstname, u.lastname') - ->join('users u', 'u.id = teacher_submission_notification_history.admin_id', 'left') - ->where('notification_category', 'teacher_submissions') - ->whereIn('teacher_submission_notification_history.teacher_id', $teacherIds) - ->whereIn('teacher_submission_notification_history.class_section_id', $classSectionIds) - ->orderBy('sent_at', 'DESC') - ->findAll(); - - foreach ($historyRecords as $record) { - $sectionId = (int)($record['class_section_id'] ?? 0); - $teacherId = (int)($record['teacher_id'] ?? 0); - if ($sectionId <= 0 || $teacherId <= 0) { - continue; - } - $sentAt = $record['sent_at'] ?? null; - $sentAtText = $sentAt ? local_datetime($sentAt, 'M j, Y g:i A') : ''; - $adminName = trim(($record['firstname'] ?? '') . ' ' . ($record['lastname'] ?? '')); - if ($adminName === '') { - $adminName = 'Administrator'; - } - $historyMap[$sectionId][$teacherId][] = [ - 'sent_at_text' => $sentAtText, - 'admin_name' => $adminName, - 'status' => strtolower((string)($record['status'] ?? 'sent')), - ]; - } - - foreach ($historyMap as &$teachersHistory) { - foreach ($teachersHistory as &$entries) { - $entries = array_slice($entries, 0, 3); - } - unset($entries); - } - unset($teachersHistory); - } - - $summary = [ - 'total_items' => $totalStatuses, - 'missing_items' => $missingItemCount, - 'submitted_items' => max(0, $totalStatuses - $missingItemCount), - 'submission_percentage' => $totalStatuses > 0 - ? (int)round((($totalStatuses - $missingItemCount) / $totalStatuses) * 100) - : 100, - ]; - - return view('administrator/teacher_submissions', [ - 'rows' => $rows, - 'semester' => $semester, - 'schoolYear' => $schoolYear, - 'notificationHistory' => $historyMap, - 'summary' => $summary, - 'lowProgressSectionIds' => $lowProgressSectionIds, - 'examDraftDeadlineConfig' => $examDraftDeadlineConfig, - 'examDraftDeadlineFormatted' => $examDraftDeadlineFormatted, - ]); - } - - private function resolveLowProgressSectionIds(array $sectionIds): array - { - [$expectedWeeks, $submittedBySection] = $this->buildClassProgressStats($sectionIds); - if ($expectedWeeks <= 0) { - return []; - } - - $lowProgressSectionIds = []; - foreach ($sectionIds as $sectionId) { - $submitted = (int) ($submittedBySection[$sectionId] ?? 0); - $percent = ($submitted / $expectedWeeks) * 100; - if ($percent < 50) { - $lowProgressSectionIds[] = $sectionId; - } - } - - return $lowProgressSectionIds; - } - - private function buildClassProgressStats(array $sectionIds): array - { - $sectionIds = array_values(array_unique(array_filter(array_map('intval', $sectionIds)))); - if (empty($sectionIds)) { - return [0, []]; - } - - $semesterResolver = new SemesterRangeService($this->configModel); - $schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); - $semester = (string)(getSemester() ?? ''); - $schoolYearForRange = $schoolYear !== '' ? $schoolYear : $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); - [$rangeStart, $rangeEnd] = $semesterResolver->getSchoolYearRange($schoolYearForRange); - $semesterNorm = $semesterResolver->normalizeSemester($semester); - if ($semesterNorm !== '' && $schoolYearForRange !== '') { - $semRange = $semesterResolver->getSemesterRange($schoolYearForRange, $semesterNorm); - if ($semRange) { - [$rangeStart, $rangeEnd] = $semRange; - } - } - - $dateList = []; - try { - $start = new \DateTimeImmutable($rangeStart); - $end = new \DateTimeImmutable($rangeEnd); - $cursor = $start; - $w = (int) $cursor->format('w'); - if ($w !== 0) { - $cursor = $cursor->modify('next sunday'); - } - while ($cursor <= $end) { - $dateList[] = $cursor->format('Y-m-d'); - $cursor = $cursor->modify('+7 days'); - } - } catch (\Throwable $e) { - $dateList = []; - } - - $noSchoolDays = []; - $events = []; - try { - $calendarModel = new \App\Models\CalendarModel(); - $events = $calendarModel->getEvents(); - } catch (\Throwable $e) { - $events = []; - } - foreach ($events as $event) { - $d = substr((string) ($event['date'] ?? ''), 0, 10); - if ($d === '' || empty($event['no_school'])) { - continue; - } - if ($d < $rangeStart || $d > $rangeEnd) { - continue; - } - $eventYear = trim((string) ($event['school_year'] ?? '')); - if ($schoolYearForRange !== '' && $eventYear !== '' && $eventYear !== $schoolYearForRange) { - continue; - } - $noSchoolDays[$d] = true; - } - - $anchorSundayYmd = ''; - try { - $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); - $tzObj = new \DateTimeZone($tzName ?: 'UTC'); - } catch (\Throwable $e) { - try { - $tzObj = new \DateTimeZone(user_timezone() ?: 'UTC'); - } catch (\Throwable $e2) { - $tzObj = new \DateTimeZone('UTC'); - } - } - try { - $nowDate = new \DateTime('now', $tzObj); - } catch (\Throwable $e) { - $nowDate = new \DateTime('now'); - } - $weekday = (int) $nowDate->format('w'); - $anchorSundayYmd = $weekday === 0 - ? $nowDate->format('Y-m-d') - : $nowDate->modify('next sunday')->format('Y-m-d'); - - $activeDatesSet = []; - if (! empty($dateList) && $anchorSundayYmd !== '') { - foreach ($dateList as $d) { - if ($d <= $anchorSundayYmd && empty($noSchoolDays[$d])) { - $activeDatesSet[$d] = true; - } - } - } - $expectedWeeks = count($activeDatesSet); - if ($expectedWeeks === 0) { - return [0, []]; - } - - $builder = $this->db->table('class_progress_reports') - ->select('class_section_id, week_start') - ->whereIn('class_section_id', $sectionIds); - if (! empty($activeDatesSet)) { - $builder->whereIn('week_start', array_keys($activeDatesSet)); - } - $rows = $builder->get()->getResultArray(); - - $submittedBySection = []; - foreach ($rows as $row) { - $sectionId = (int) ($row['class_section_id'] ?? 0); - $weekStart = (string) ($row['week_start'] ?? ''); - if ($sectionId === 0 || $weekStart === '' || empty($activeDatesSet[$weekStart])) { - continue; - } - $submittedBySection[$sectionId][$weekStart] = true; - } - - $counts = []; - foreach ($sectionIds as $sectionId) { - $counts[$sectionId] = isset($submittedBySection[$sectionId]) - ? count($submittedBySection[$sectionId]) - : 0; - } - - return [$expectedWeeks, $counts]; - } - - public function sendTeacherSubmissionNotifications() - {$notify = $this->request->getPost('notify'); - if (!is_array($notify)) { - return redirect()->back()->with('info', 'Select at least one teacher to notify.'); - } - $semester = (string)(getSemester() ?? $this->semester ?? ''); - $missingItemsPayload = $this->request->getPost('missing_items') ?? []; - $homeworkNotifyAll = (bool) $this->request->getPost('homework_notify_all'); - $examTerm = $this->resolveExamTermLabel($semester); - $examScoreLabel = $examTerm === 'final' ? 'final scores' : 'midterm scores'; - $examCommentLabel = $examTerm === 'final' ? 'final comments' : 'midterm comments'; - $forcedItems = []; - if ($this->request->getPost('notify_midterm_score')) { - $forcedItems[] = $examScoreLabel; - } - if ($this->request->getPost('notify_midterm_comment')) { - $forcedItems[] = $examCommentLabel; - } - if ($this->request->getPost('notify_participation')) { - $forcedItems[] = 'participation'; - } - if ($this->request->getPost('notify_ptap_comment')) { - $forcedItems[] = 'PTAP comments'; - } - if ($this->request->getPost('notify_class_progress')) { - $forcedItems[] = 'class progress'; - } - if ($this->request->getPost('notify_exam_draft')) { - $forcedItems[] = 'exam draft'; - } - - $targets = []; - foreach ($notify as $sectionIdRaw => $teachers) { - $sectionId = (int)$sectionIdRaw; - if ($sectionId <= 0 || !is_array($teachers)) { - continue; - } - foreach ($teachers as $teacherIdRaw => $value) { - $teacherId = (int)$teacherIdRaw; - if ($teacherId <= 0 || $value === null || $value === '') { - continue; - } - $key = "{$sectionId}_{$teacherId}"; - $targets[$key] = [ - 'class_section_id' => $sectionId, - 'teacher_id' => $teacherId, - ]; - } - } - - if (empty($targets)) { - return redirect()->back()->with('info', 'Select at least one teacher to notify.'); - } - - $targets = array_values($targets); - $teacherIds = array_values(array_unique(array_column($targets, 'teacher_id'))); - $classSectionIds = array_values(array_unique(array_column($targets, 'class_section_id'))); - - $classSections = $this->classSectionModel - ->select('class_section_id, class_section_name') - ->whereIn('class_section_id', $classSectionIds) - ->findAll(); - $classSectionMap = []; - foreach ($classSections as $section) { - $classSectionMap[(int)($section['class_section_id'] ?? 0)] = $section['class_section_name'] ?? ''; - } - - $teachers = $this->userModel - ->select('id, firstname, lastname, email') - ->whereIn('id', $teacherIds) - ->findAll(); - $teacherLookup = []; - foreach ($teachers as $teacher) { - $teacherLookup[(int)$teacher['id']] = $teacher; - } - - $mailer = new EmailController(); - $adminId = (int)(session()->get('user_id') ?? 0); - if ($adminId <= 0) { - return redirect()->to('/login'); - } - $adminUser = $this->userModel->find($adminId); - $adminName = trim(($adminUser['firstname'] ?? '') . ' ' . ($adminUser['lastname'] ?? '')) ?: 'Administrator'; - - $historyModel = new TeacherSubmissionNotificationHistoryModel(); - $scoreUrl = site_url('/'); - $progressUrl = site_url('teacher/progress/history'); - $examDraftUrl = site_url('teacher/exam-drafts'); - $homeworkUrl = site_url('teacher/addHomework'); - $examDraftDeadlineEmailHtml = $this->buildExamDraftDeadlineEmailHtml(); - $sentCount = 0; - $failCount = 0; - - foreach ($targets as $target) { - $classSectionId = (int)$target['class_section_id']; - $teacherId = (int)$target['teacher_id']; - $teacher = $teacherLookup[$teacherId] ?? null; - $sectionName = $classSectionMap[$classSectionId] ?? "Section {$classSectionId}"; - $teacherName = $teacher ? trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? '')) : ''; - if ($teacherName === '') { - $teacherName = 'Teacher'; - } - $subject = "Reminder: Complete submissions for {$sectionName}"; - $missingPayload = $missingItemsPayload[$classSectionId][$teacherId] ?? ''; - $missingItems = $this->parseMissingItemsPayload((string)$missingPayload); - $selectedItems = $forcedItems; - if ($homeworkNotifyAll && !in_array('homework', $selectedItems, true)) { - $selectedItems[] = 'homework'; - } - if (!empty($selectedItems)) { - $missingItems = array_values(array_unique($selectedItems)); - } - if (!empty($missingItems)) { - $missingText = htmlspecialchars( - $this->formatMissingItemsText($missingItems), - ENT_QUOTES, - 'UTF-8' - ); - $missingNote = "

Outstanding items: {$missingText}.

"; - } else { - $missingNote = "

Our records show no outstanding submissions for this section, but please verify if anything still needs attention.

"; - } - - $subject = "Reminder: Complete submissions for {$sectionName}"; - $progressNote = ''; - if (in_array('class progress', $missingItems, true)) { - $progressNote = "

Class progress submissions can be updated at Teacher Progress History.

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

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

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

Homework scores can be submitted at Teacher Homework.

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

Dear {$teacherName},

" - . "

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

" - . $missingNote - . $progressNote - . $examDraftNote - . $homeworkNote - . ($nonScoreOnly ? '' : "

Visit Teacher Score Submission to address any remaining items.

") - . "

Thank you,
Al Rahma Administration

"; - - $email = $teacher['email'] ?? ''; - $status = 'failed'; - if (!empty($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) { - $ok = $mailer->sendEmail($email, $subject, $body, 'notifications'); - $status = $ok ? 'sent' : 'failed'; - } - - if ($status === 'sent') { - $sentCount++; - } else { - $failCount++; - } - - $historyModel->insert([ - 'teacher_id' => $teacherId, - 'class_section_id' => $classSectionId, - 'admin_id' => $adminId, - 'notification_category' => 'teacher_submissions', - 'message' => $this->truncateNotificationMessage($body), - 'status' => $status, - 'school_year' => $this->schoolYear, - 'semester' => $this->semester, - 'sent_at' => utc_now(), - ]); - } - - $statusParts = []; - if ($sentCount > 0) { - $statusParts[] = $sentCount . ' reminder' . ($sentCount === 1 ? '' : 's') . ' sent'; - } - if ($failCount > 0) { - $statusParts[] = $failCount . ' reminder' . ($failCount === 1 ? '' : 's') . ' failed'; - } - - $message = !empty($statusParts) ? implode(' and ', $statusParts) : 'No notifications were sent.'; - $flashType = $failCount === 0 ? 'success' : 'warning'; - - return redirect()->back()->with($flashType, $message); - } - - private function submissionStatus(int $filled, int $expected): array - { - if ($expected <= 0) { - return [ - 'label' => 'No students', - 'badge' => 'bg-secondary', - 'detail' => '', - 'completed' => true, - ]; - } - $completed = $filled >= $expected; - return [ - 'label' => $completed ? 'Submitted' : 'Missing', - 'badge' => $completed ? 'bg-success' : 'bg-danger', - 'detail' => "{$filled}/{$expected}", - 'completed' => $completed, - ]; - } - - private function progressStatus(int $submitted, int $expected): array - { - if ($expected <= 0) { - return [ - 'label' => 'N/A', - 'badge' => 'bg-secondary', - 'detail' => '', - 'completed' => true, - ]; - } - $completed = $submitted >= $expected; - return [ - 'label' => $completed ? 'Submitted' : 'Missing', - 'badge' => $completed ? 'bg-success' : 'bg-danger', - 'detail' => "{$submitted}/{$expected}", - 'completed' => $completed, - ]; - } - - private function homeworkStatus(int $submitted): array - { - $completed = $submitted > 0; - return [ - 'label' => $completed ? 'Submitted' : 'Missing', - 'badge' => $completed ? 'bg-success' : 'bg-danger', - 'detail' => $completed ? (string) $submitted : '0', - 'completed' => $completed, - ]; - } - - private function draftStatus(int $submitted, ?\DateTimeImmutable $deadline): array - { - if ($deadline !== null) { - $today = new \DateTimeImmutable('today'); - if ($today < $deadline) { - return [ - 'label' => 'Pending', - 'badge' => 'bg-secondary', - 'detail' => 'Not due', - 'completed' => true, - ]; - } - } - $completed = $submitted > 0; - return [ - 'label' => $completed ? 'Submitted' : 'Missing', - 'badge' => $completed ? 'bg-success' : 'bg-danger', - 'detail' => $completed ? (string) $submitted : '0', - 'completed' => $completed, - ]; - } - - /** - * Exam draft due date for the teacher submissions dashboard: prefers the configuration key - * `exam_draft_deadline` (same as automated reminders); otherwise fall/spring exam deadlines. - */ - private function resolveTeacherDashboardExamDraftDeadline(string $semester, string $schoolYear): ?\DateTimeImmutable - { - $fromExamDraftKey = $this->parseExamDraftDeadlineConfigValue(); - if ($fromExamDraftKey !== null) { - return $fromExamDraftKey; - } - - return $this->resolveExamDraftDeadline($semester, $schoolYear); - } - - /** - * Parses the `exam_draft_deadline` configuration value using the application timezone (midnight that calendar day). - */ - private function parseExamDraftDeadlineConfigValue(): ?\DateTimeImmutable - { - $raw = trim((string) ($this->configModel->getConfig('exam_draft_deadline') ?? '')); - if ($raw === '') { - return null; - } - $tz = new \DateTimeZone(config('App')->appTimezone ?? 'UTC'); - try { - $deadline = new \DateTimeImmutable($raw, $tz); - } catch (\Throwable $e) { - return null; - } - - return $deadline->setTime(0, 0, 0); - } - - /** - * HTML snippet for reminder emails when exam draft is included (deadline from exam_draft_deadline config). - */ - private function buildExamDraftDeadlineEmailHtml(): string - { - $raw = trim((string) ($this->configModel->getConfig('exam_draft_deadline') ?? '')); - if ($raw === '') { - return ''; - } - $parsed = $this->parseExamDraftDeadlineConfigValue(); - $display = $parsed !== null - ? htmlspecialchars($parsed->format('l, F j, Y'), ENT_QUOTES, 'UTF-8') - : htmlspecialchars($raw, ENT_QUOTES, 'UTF-8'); - $rawEsc = htmlspecialchars($raw, ENT_QUOTES, 'UTF-8'); - - return '

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

'; - } - - private function resolveExamDraftDeadline(string $semester, string $schoolYear): ?\DateTimeImmutable - { - $semesterKey = strtolower(trim($semester)); - if ($semesterKey === 'fall') { - $deadlineValue = (string)($this->configModel->getConfig('fall_exam_deadline') ?? ''); - } elseif ($semesterKey === 'spring') { - $deadlineValue = (string)($this->configModel->getConfig('spring_exam_deadline') ?? ''); - } else { - return null; - } - $deadlineValue = trim($deadlineValue); - if ($deadlineValue === '') { - return null; - } - try { - $deadline = new \DateTimeImmutable($deadlineValue); - } catch (\Throwable $e) { - return null; - } - if ($schoolYear !== '' && preg_match('/^\d{4}-\d{4}$/', $schoolYear)) { - $deadlineYear = $deadline->format('Y'); - if ($deadlineYear === '1970') { - return null; - } - } - return $deadline->setTime(0, 0, 0); - } - - private function resolveExamTermLabel(string $semester): string - { - $semesterKey = strtolower(trim($semester)); - if ($semesterKey === '') { - return 'midterm'; - } - if (str_contains($semesterKey, 'spring')) { - return 'final'; - } - if (str_contains($semesterKey, 'fall')) { - return 'midterm'; - } - return 'midterm'; - } - - private function buildSemesterCandidates(string $semester): array - { - $semester = trim((string) $semester); - if ($semester === '') { - return []; - } - $candidates = [ - $semester, - strtolower($semester), - strtoupper($semester), - ucfirst(strtolower($semester)), - ]; - $candidates = array_values(array_unique(array_filter($candidates, static fn ($v) => $v !== ''))); - return $candidates; - } - private function attendanceStatus(bool $submitted): array - { - return [ - 'label' => $submitted ? 'Submitted' : 'Missing', - 'badge' => $submitted ? 'bg-success' : 'bg-danger', - 'completed' => $submitted, - ]; - } - - private function buildMissingItems(array $statusMap, string $semester): array - { - $examTerm = $this->resolveExamTermLabel($semester); - $examScoreLabel = $examTerm === 'final' ? 'final scores' : 'midterm scores'; - $examCommentLabel = $examTerm === 'final' ? 'final comments' : 'midterm comments'; - $labels = [ - 'midterm_score_status' => $examScoreLabel, - 'midterm_comment_status' => $examCommentLabel, - 'participation_status' => 'participation', - 'ptap_comment_status' => 'PTAP comments', - 'attendance_status' => 'attendance', - 'class_progress_status' => 'class progress', - 'exam_draft_status' => 'exam draft', - 'homework_status' => 'homework', - ]; - - $items = []; - foreach ($statusMap as $key => $status) { - $completed = $status['completed'] ?? true; - if (!$completed && isset($labels[$key])) { - $items[] = $labels[$key]; - } - } - - return array_values($items); - } - - private function formatMissingItemsText(array $items): string - { - $items = array_values(array_filter(array_map('trim', $items), static fn($v) => $v !== '')); - $count = count($items); - if ($count === 0) { - return ''; - } - if ($count === 1) { - return $items[0]; - } - if ($count === 2) { - return $items[0] . ' and ' . $items[1]; - } - $last = array_pop($items); - return implode(', ', $items) . ' and ' . $last; - } - - private function teacherRolePriority(string $roleKey): int - { - switch (strtolower($roleKey)) { - case 'main': - return 1; - case 'ta': - return 2; - default: - return 3; - } - } - - private function truncateNotificationMessage(string $html, int $limit = 1000): string - { - $text = trim(strip_tags($html)); - if ($text === '') { - return ''; - } - if (mb_strlen($text) <= $limit) { - return $text; - } - return mb_substr($text, 0, $limit) . '…'; - } - - private function parseMissingItemsPayload(string $payload): array - { - if ($payload === '') { - return []; - } - $decoded = @json_decode(base64_decode($payload, true) ?: '', true); - if (!is_array($decoded)) { - return []; - } - - $items = []; - foreach ($decoded as $item) { - $item = trim((string)$item); - if ($item === '') { - continue; - } - $items[] = $item; - } - - return array_values(array_unique($items)); - } - - public function notificationsAlerts() - { - if (!$this->canManageAdminNotifications()) { - return redirect()->to('/login'); - } - - $admins = $this->fetchAdminNotificationUsers(); - $subjects = $this->notificationSubjectOptions(); - $assignedSubjects = []; - $tableReady = $this->db->tableExists('admin_notification_subjects'); - - if ($tableReady && !empty($admins)) { - $adminIds = array_map('intval', array_column($admins, 'id')); - $rows = $this->adminNotificationSubjectModel - ->select('id, admin_id, subject') - ->whereIn('admin_id', $adminIds) - ->findAll(); - - foreach ($rows as $row) { - $adminId = (int) ($row['admin_id'] ?? 0); - $subject = (string) ($row['subject'] ?? ''); - if ($adminId <= 0 || $subject === '') { - continue; - } - if (!isset($assignedSubjects[$adminId])) { - $assignedSubjects[$adminId] = []; - } - $assignedSubjects[$adminId][$subject] = true; - } - } - - return view('administrator/notifications_alerts', [ - 'admins' => $admins, - 'subjects' => $subjects, - 'assignedSubjects' => $assignedSubjects, - 'tableReady' => $tableReady, - ]); - } - - public function saveNotificationSubjects() - { - if (!$this->canManageAdminNotifications()) { - return redirect()->to('/login'); - } - - if (!$this->db->tableExists('admin_notification_subjects')) { - return redirect()->to('/administrator/notifications_alerts') - ->with('error', 'Notification subject storage is missing. Run migrations first.'); - } - - $posted = $this->request->getPost('subjects'); - if (!is_array($posted)) { - return redirect()->to('/administrator/notifications_alerts') - ->with('error', 'No selections were submitted.'); - } - - $subjects = $this->notificationSubjectOptions(); - $allowed = array_keys($subjects); - - $admins = $this->fetchAdminNotificationUsers(); - $adminIds = array_map('intval', array_column($admins, 'id')); - if (empty($adminIds)) { - return redirect()->to('/administrator/notifications_alerts') - ->with('info', 'No admins found to update.'); - } - - $existing = $this->adminNotificationSubjectModel - ->select('id, admin_id, subject') - ->whereIn('admin_id', $adminIds) - ->findAll(); - - $existingMap = []; - foreach ($existing as $row) { - $adminId = (int) ($row['admin_id'] ?? 0); - $subject = (string) ($row['subject'] ?? ''); - if ($adminId <= 0 || $subject === '') { - continue; - } - if (!isset($existingMap[$adminId])) { - $existingMap[$adminId] = []; - } - $existingMap[$adminId][$subject] = (int) ($row['id'] ?? 0); - } - - $updates = 0; - - foreach ($adminIds as $adminId) { - $subjectRaw = $posted[$adminId] ?? []; - - $selected = []; - if (is_array($subjectRaw)) { - foreach ($subjectRaw as $key => $value) { - $candidate = is_string($key) ? $key : $value; - if (!is_string($candidate)) { - continue; - } - $candidate = trim($candidate); - if ($candidate !== '') { - $selected[] = $candidate; - } - } - } - - $selected = array_values(array_unique(array_filter($selected, function ($value) use ($allowed) { - return in_array($value, $allowed, true); - }))); - - $current = array_keys($existingMap[$adminId] ?? []); - $toDelete = array_values(array_diff($current, $selected)); - $toInsert = array_values(array_diff($selected, $current)); - - if (!empty($toDelete)) { - $this->adminNotificationSubjectModel - ->where('admin_id', $adminId) - ->whereIn('subject', $toDelete) - ->delete(); - $updates += count($toDelete); - } - - if (!empty($toInsert)) { - $batch = []; - foreach ($toInsert as $subject) { - $batch[] = [ - 'admin_id' => $adminId, - 'subject' => $subject, - ]; - } - $this->adminNotificationSubjectModel->insertBatch($batch); - $updates += count($toInsert); - } - } - - return redirect()->to('/administrator/notifications_alerts') - ->with('success', $updates > 0 ? 'Notification subjects updated.' : 'No changes were made.'); - } - - public function printNotificationRecipients() - { - if (!$this->canManageAdminNotifications()) { - return redirect()->to('/login'); - } - - $admins = $this->fetchAdminNotificationUsers(); - $tableReady = $this->db->tableExists('admin_notification_subjects'); - $assigned = []; - - if ($tableReady && !empty($admins)) { - $adminIds = array_map('intval', array_column($admins, 'id')); - $rows = $this->adminNotificationSubjectModel - ->select('admin_id') - ->where('subject', 'print_requests') - ->whereIn('admin_id', $adminIds) - ->findAll(); - - foreach ($rows as $row) { - $adminId = (int) ($row['admin_id'] ?? 0); - if ($adminId <= 0) { - continue; - } - $assigned[$adminId] = true; - } - } - - return view('administrator/print_notification_admins', [ - 'admins' => $admins, - 'assigned' => $assigned, - 'tableReady' => $tableReady, - ]); - } - - public function savePrintNotificationRecipients() - { - if (!$this->canManageAdminNotifications()) { - return redirect()->to('/login'); - } - - if (!$this->db->tableExists('admin_notification_subjects')) { - return redirect()->to('/administrator/print-notifications') - ->with('error', 'Notification subject storage is missing. Run migrations first.'); - } - - $admins = $this->fetchAdminNotificationUsers(); - $adminIds = array_map('intval', array_column($admins, 'id')); - if (empty($adminIds)) { - return redirect()->to('/administrator/print-notifications') - ->with('info', 'No admins found to update.'); - } - - $posted = (array) $this->request->getPost('notify'); - $selected = []; - foreach ($posted as $key => $value) { - $adminId = (int) $key; - if ($adminId <= 0) { - continue; - } - if (!in_array($adminId, $adminIds, true)) { - continue; - } - $selected[] = $adminId; - } - $selected = array_values(array_unique($selected)); - - $existingRows = $this->adminNotificationSubjectModel - ->select('admin_id') - ->where('subject', 'print_requests') - ->whereIn('admin_id', $adminIds) - ->findAll(); - - $current = array_values(array_unique(array_map( - fn($row) => (int) ($row['admin_id'] ?? 0), - $existingRows - ))); - - $toDelete = array_values(array_diff($current, $selected)); - $toInsert = array_values(array_diff($selected, $current)); - - if (!empty($toDelete)) { - $this->adminNotificationSubjectModel - ->where('subject', 'print_requests') - ->whereIn('admin_id', $toDelete) - ->delete(); - } - - if (!empty($toInsert)) { - $batch = []; - foreach ($toInsert as $adminId) { - $batch[] = [ - 'admin_id' => $adminId, - 'subject' => 'print_requests', - ]; - } - $this->adminNotificationSubjectModel->insertBatch($batch); - } - - $changes = count($toDelete) + count($toInsert); - return redirect()->to('/administrator/print-notifications') - ->with('success', $changes > 0 ? 'Print notification recipients updated.' : 'No changes were made.'); - } - - private function notificationSubjectOptions(): array - { - return [ - 'academics' => 'Academics', - 'attendance' => 'Attendance', - 'events' => 'Events', - 'finance' => 'Finance', - 'general' => 'General', - 'print_requests' => 'Print Requests', - ]; - } - - private function getAdminNotificationExcludedRoles(): array - { - return [ - 'parent', - 'student', - 'guest', - 'teacher', - 'assistant teacher', - 'teacher assistant', - 'teacher_assistant', - 'assistant_teacher', - 'ta', - 'authorized_user', - ]; - } - - private function canManageAdminNotifications(): bool - { - $session = session(); - if (! $session->get('is_logged_in')) { - return false; - } - - $role = trim((string) ($session->get('role') ?? '')); - if ($role === '') { - return false; - } - - $excluded = array_map( - fn ($value) => strtolower(trim((string) $value)), - $this->getAdminNotificationExcludedRoles() - ); - - return !in_array(strtolower($role), $excluded, true); - } - - private function fetchAdminNotificationUsers(): array - { - $excluded = $this->getAdminNotificationExcludedRoles(); - $excludedList = "'" . implode("','", $excluded) . "'"; - - return $this->db->table('users u') - ->select('u.id, u.firstname, u.lastname, u.email') - ->join('user_roles ur', 'ur.user_id = u.id', 'inner') - ->join('roles r', 'r.id = ur.role_id', 'inner') - ->where('r.name IS NOT NULL', null, false) - ->where('ur.deleted_at', null) - ->where("LOWER(r.name) NOT IN ({$excludedList})", null, false) - ->groupBy('u.id, u.firstname, u.lastname, u.email') - ->orderBy('u.lastname', 'ASC') - ->orderBy('u.firstname', 'ASC') - ->get() - ->getResultArray(); - } - public function feeCollection() { return view('administrator/fee_collection'); @@ -2058,237 +390,6 @@ class AdministratorController extends BaseController return view('administrator/contact_information'); } - public function studentProfiles() - { - $db = db_connect(); - $isPg = ($db->getPlatform() === 'Postgre'); // 'MySQLi', 'Postgre', 'SQLSRV', ... - $selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); - - // In MySQL, avoid truncation of long lists - if (!$isPg) { - $db->query('SET SESSION group_concat_max_len = 8192'); - } - - $b = $db->table('students'); - - if ($isPg) { - // Postgres: use subqueries for aggregates to avoid GROUP BY on students.* - $students = $b->select([ - 'students.*', - 'users.firstname AS parent_firstname', - 'users.lastname AS parent_lastname', - 'users.email AS parent_email', - 'users.cellphone AS parent_phone', - - // Pick one emergency contact (first by id) - "(SELECT ec.emergency_contact_name FROM emergency_contacts ec - WHERE ec.parent_id = students.parent_id - ORDER BY ec.id ASC LIMIT 1) AS emergency_name", - "(SELECT ec.relation FROM emergency_contacts ec - WHERE ec.parent_id = students.parent_id - ORDER BY ec.id ASC LIMIT 1) AS emergency_relationship", - "(SELECT ec.cellphone FROM emergency_contacts ec - WHERE ec.parent_id = students.parent_id - ORDER BY ec.id ASC LIMIT 1) AS emergency_phone", - "(SELECT ec.email FROM emergency_contacts ec - WHERE ec.parent_id = students.parent_id - ORDER BY ec.id ASC LIMIT 1) AS emergency_email", - - // Aggregated lists - "(SELECT STRING_AGG(DISTINCT sa.allergy, ', ' ORDER BY sa.allergy) - FROM student_allergies sa WHERE sa.student_id = students.id) AS allergies", - "(SELECT STRING_AGG(DISTINCT smc.condition_name, ', ' ORDER BY smc.condition_name) - FROM student_medical_conditions smc WHERE smc.student_id = students.id) AS medical_conditions", - ]) - ->join('users', 'users.id = students.parent_id', 'left') - ->orderBy('students.lastname', 'ASC') - ->orderBy('students.firstname', 'ASC') - ->get() - ->getResultArray(); - } else { - // MySQL: GROUP_CONCAT + MIN() for emergency_* to satisfy ONLY_FULL_GROUP_BY - $students = $b->select([ - 'students.*', - 'users.firstname AS parent_firstname', - 'users.lastname AS parent_lastname', - 'users.email AS parent_email', - 'users.cellphone AS parent_phone', - - 'MIN(emergency_contacts.emergency_contact_name) AS emergency_name', - 'MIN(emergency_contacts.relation) AS emergency_relationship', - 'MIN(emergency_contacts.cellphone) AS emergency_phone', - 'MIN(emergency_contacts.email) AS emergency_email', - - "GROUP_CONCAT(DISTINCT student_allergies.allergy - ORDER BY student_allergies.allergy SEPARATOR ', ') AS allergies", - "GROUP_CONCAT(DISTINCT student_medical_conditions.condition_name - ORDER BY student_medical_conditions.condition_name SEPARATOR ', ') AS medical_conditions", - ]) - ->join('users', 'users.id = students.parent_id', 'left') - ->join('emergency_contacts', 'emergency_contacts.parent_id = students.parent_id', 'left') - ->join('student_allergies', 'student_allergies.student_id = students.id', 'left') - ->join('student_medical_conditions', 'student_medical_conditions.student_id = students.id', 'left') - ->groupBy('students.id') - ->orderBy('students.lastname', 'ASC') - ->orderBy('students.firstname', 'ASC') - ->get() - ->getResultArray(); - } - - $enrollmentStatusByStudentId = []; - $studentIds = array_values(array_unique(array_filter(array_map( - static fn (array $row): int => (int) ($row['id'] ?? 0), - $students - )))); - if ($selectedYear !== '' && !empty($studentIds)) { - $enrollmentRows = $db->table('enrollments') - ->select('student_id, enrollment_status') - ->whereIn('student_id', $studentIds) - ->where('school_year', $selectedYear) - ->orderBy('student_id', 'ASC') - ->orderBy('updated_at', 'DESC') - ->orderBy('enrollment_date', 'DESC') - ->orderBy('id', 'DESC') - ->get() - ->getResultArray(); - - foreach ($enrollmentRows as $enrollmentRow) { - $studentId = (int) ($enrollmentRow['student_id'] ?? 0); - if ($studentId > 0 && !isset($enrollmentStatusByStudentId[$studentId])) { - $enrollmentStatusByStudentId[$studentId] = (string) ($enrollmentRow['enrollment_status'] ?? ''); - } - } - } - - // === Inject current-year class_section_name from student_class and replace grade === - foreach ($students as $i => $row) { - $sid = (int) ($row['id'] ?? 0); - if ($sid > 0) { - $classSectionName = (string) ($this->studentClassModel->getClassSectionNameByStudentId($sid, $selectedYear) ?? ''); - - $students[$i]['class_section_name'] = $classSectionName; - $students[$i]['enrollment_status'] = $enrollmentStatusByStudentId[$sid] ?? ''; - } else { - // Keep keys consistent even if id missing - $students[$i]['class_section_name'] = ''; - $students[$i]['enrollment_status'] = ''; - } - - $studentYear = trim((string) ($row['school_year'] ?? '')); - $students[$i]['age'] = $this->calculateAgeAsOfSchoolYearStartYear( - $row['dob'] ?? null, - $studentYear !== '' ? $studentYear : $selectedYear - ); - } - // === end injection === - - return view('administrator/student_profiles', [ - 'students' => $students, - 'gradeOptions' => ['K', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'Youth'], - 'genderOptions' => ['Male', 'Female', 'Other'], - 'selectedYear' => $selectedYear, - ]); - } - - private function calculateAgeAsOfSchoolYearStartYear(?string $dob, string $schoolYear): ?int - { - $dob = trim((string) $dob); - $schoolYear = trim($schoolYear); - - if ($dob === '' || ! preg_match('/^(\d{4})/', $schoolYear, $matches)) { - return null; - } - - try { - $timezone = new \DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone())); - $birthDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $timezone); - $errors = \DateTimeImmutable::getLastErrors(); - $hasParseErrors = is_array($errors) - && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0); - - if ($birthDate === false || $hasParseErrors) { - return null; - } - - $schoolYearStartYearCutoff = new \DateTimeImmutable($matches[1] . '-09-01', $timezone); - if ($birthDate > $schoolYearStartYearCutoff) { - return null; - } - - return $birthDate->diff($schoolYearStartYearCutoff)->y; - } catch (\Throwable $e) { - log_message('warning', 'Unable to calculate school-year age from DOB: {message}', [ - 'message' => $e->getMessage(), - ]); - - return null; - } - } - - - - public function parentProfiles() - { - if ($redirect = $this->redirectWithoutLegacyTermFilters('administrator/parent_profiles')) { - return $redirect; - } - - // Fetch all users with their roles in one go - $allUsers = $this->userModel->findAll(); - $parents = []; - - // Fetch roles for users in one query to avoid looping over all users - foreach ($allUsers as $user) { - // Get the roles of the user directly - $roles = $this->userRoleModel->getRolesByUserId($user['id']); - $isParent = false; - - // Check if $roles is iterable and contains the 'parent' role - if (is_array($roles)) { - foreach ($roles as $role) { - if (isset($role['role_name']) && $role['role_name'] === 'parent') { - $isParent = true; - break; - } - } - } - - if ($isParent) { - // Retrieve the latest paid amount and balance for this parent - $paidAmount = $this->invoiceModel->getLatestInvoicePaidAmount($user['id']) ?? 0; - $balance = $this->invoiceModel->getLatestInvoiceBalance($user['id']) ?? 0; - - // Get students for this parent - $studentsData = $this->studentModel->where('parent_id', $user['id'])->findAll(); - $students = []; - foreach ($studentsData as $student) { - $classSectionName = $this->studentClassModel->getClassSectionNameByStudentId($student['id']) ?? 'N/A'; - $students[] = [ - 'name' => $student['firstname'] . ' ' . $student['lastname'], - 'class_section' => $classSectionName - ]; - } - - // Add parent data to the array - $parents[] = [ - 'id' => $user['id'], - 'school_id' => $user['school_id'], - 'firstname' => $user['firstname'], - 'lastname' => $user['lastname'], - 'email' => $user['email'], - 'cellphone' => $user['cellphone'], - 'gender' => $user['gender'], - 'created_at' => $user['created_at'], - 'paid_amount' => $paidAmount, - 'balance' => $balance, - 'students' => $students, - ]; - } - } - - return view('administrator/parent_profile', ['parents' => $parents]); - } - private function redirectWithoutLegacyTermFilters(string $route): ?\CodeIgniter\HTTP\RedirectResponse { $legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester']; @@ -2392,109 +493,160 @@ class AdministratorController extends BaseController return redirect()->to('/administrator/manage-users'); } + private function canManageAdminNotifications(): bool + { + $session = session(); + if (! $session->get('is_logged_in')) { + return false; + } + + $role = trim((string) ($session->get('role') ?? '')); + if ($role === '') { + return false; + } + + $excluded = array_map( + fn ($value) => strtolower(trim((string) $value)), + service('adminNotificationSettings')->excludedRoles() + ); + + return !in_array(strtolower($role), $excluded, true); + } + + public function administratorDashboard() + { + helper('url'); + + $searchData = service('administratorDashboard')->search((string) $this->request->getGet('query')); + + return view('administrator/administratordashboard', array_merge($searchData, [ + 'dashboardEndpoint' => site_url('api/administrator/dashboard'), + ])); + } + + public function dashboardMetrics() + { + return $this->response->setJSON( + service('administratorDashboard')->metrics((string) $this->schoolYear, (string) $this->semester) + ); + } + + public function userSearch() + { + $data = service('administratorDashboard')->search((string) $this->request->getGet('query')); + + return view('administrator/search_results', $data); + } + + public function teacherSubmissionsReport() + { + $semester = (string) (getSemester() ?? $this->semester ?? ''); + $schoolYear = trim((string) ($this->request->getGet('school_year') ?? '')); + if ($schoolYear === '') { + $schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); + } + $lowProgressRaw = (string) $this->request->getGet('low_progress_sections'); + $lowProgressSectionIds = array_values(array_unique(array_filter(array_map( + 'intval', + preg_split('/\s*,\s*/', $lowProgressRaw, -1, PREG_SPLIT_NO_EMPTY) + )))); + + return view( + 'administrator/teacher_submissions', + service('teacherSubmissionReport')->buildReport($semester, $schoolYear, $lowProgressSectionIds) + ); + } + + public function sendTeacherSubmissionNotifications() + { + $result = service('teacherSubmissionReport')->sendNotifications( + (array) $this->request->getPost(), + (string) (getSemester() ?? $this->semester ?? ''), + (int) (session()->get('user_id') ?? 0) + ); + + if (($result['redirect'] ?? '') === 'login') { + return redirect()->to('/login'); + } + + return redirect()->back()->with((string) ($result['type'] ?? 'info'), (string) ($result['message'] ?? '')); + } + + public function notificationsAlerts() + { + if (!$this->canManageAdminNotifications()) { + return redirect()->to('/login'); + } + + return view('administrator/notifications_alerts', service('adminNotificationSettings')->alertsPage()); + } + + public function saveNotificationSubjects() + { + if (!$this->canManageAdminNotifications()) { + return redirect()->to('/login'); + } + + $result = service('adminNotificationSettings')->saveSubjects($this->request->getPost('subjects')); + + return redirect()->to('/administrator/notifications_alerts') + ->with((string) ($result['type'] ?? 'info'), (string) ($result['message'] ?? '')); + } + + public function printNotificationRecipients() + { + if (!$this->canManageAdminNotifications()) { + return redirect()->to('/login'); + } + + return view('administrator/print_notification_admins', service('adminNotificationSettings')->printRecipientsPage()); + } + + public function savePrintNotificationRecipients() + { + if (!$this->canManageAdminNotifications()) { + return redirect()->to('/login'); + } + + $result = service('adminNotificationSettings')->savePrintRecipients((array) $this->request->getPost('notify')); + + return redirect()->to('/administrator/print-notifications') + ->with((string) ($result['type'] ?? 'info'), (string) ($result['message'] ?? '')); + } + + public function studentProfiles() + { + $selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); + + return view( + 'administrator/student_profiles', + service('administratorDirectory')->studentProfiles($selectedYear) + ); + } + + public function parentProfiles() + { + if ($redirect = $this->redirectWithoutLegacyTermFilters('administrator/parent_profiles')) { + return $redirect; + } + + return view('administrator/parent_profile', service('administratorDirectory')->parentProfiles()); + } + public function showEnrollmentWithdrawalPage() { try { $schoolYearContext = $this->resolveSchoolYearContext(); $selectedYear = $schoolYearContext->yearName(); - - $this->syncReviewDecisionEnrollments($selectedYear); - - $students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear); - - $removedPriorStatuses = $this->removedPriorYearStudentStatuses($selectedYear); - $returningStudentIds = $this->priorYearStudentIds($selectedYear); - - foreach ($students as &$s) { - // ===== Ensure IDs needed by the modal ===== - $s['student_id'] = (int)($s['id'] ?? 0); - $priorRemovedStatus = $removedPriorStatuses[$s['student_id']] ?? null; - $s['removed_previous_year'] = $priorRemovedStatus !== null ? 'Yes' : 'No'; - $s['prior_removed_status'] = $priorRemovedStatus; - - // Prefer parent_id; fallback to secondparent_user_id if present - if (empty($s['parent_id']) && !empty($s['secondparent_user_id'])) { - $s['parent_id'] = (int)$s['secondparent_user_id']; - } else { - $s['parent_id'] = (int)($s['parent_id'] ?? 0); - } - - // ===== Parent display + sort keys (keep existing behavior) ===== - $pf = trim((string)($s['parent_firstname'] ?? '')); - $pl = trim((string)($s['parent_lastname'] ?? '')); - - // Fallback: if only a single full name exists - if ($pf === '' && $pl === '' && !empty($s['parent_fullname'])) { - $parts = preg_split('/\s+/', trim((string)$s['parent_fullname']), 2); - $pf = $parts[0] ?? ''; - $pl = $parts[1] ?? ''; - } - - $s['parent_label'] = trim($pf . ' ' . $pl); - $s['parent_sort'] = trim(($pl !== '' ? $pl : $pf) . ' ' . $pf); - - if ($s['parent_label'] === '') { - $s['parent_label'] = 'Unknown Parent'; - $s['parent_sort'] = 'ZZZ Unknown Parent'; - } - - // ===== New-student flags ===== - $s['is_new'] = (int) ($s['is_new'] ?? 0); - if (isset($returningStudentIds[$s['student_id']])) { - $s['is_new'] = 0; - } - $s['new_student'] = $s['is_new'] === 1 ? 'Yes' : 'No'; - - // ===== Admission override ===== - // Enrollment status for selected year - $statusForYear = $this->enrollmentModel->getEnrollmentStatus((int)$s['student_id'], $selectedYear); - if (!empty($priorRemovedStatus)) { - $s['enrollment_status'] = $priorRemovedStatus; - } elseif (!empty($statusForYear)) { - $s['enrollment_status'] = $statusForYear; - } elseif (($s['admission_status'] ?? null) === 'denied') { - $s['enrollment_status'] = 'denied'; - } else { - $s['enrollment_status'] = 'admission under review'; - $s['admission_status'] = 'pending'; - } - - // ===== Class section name for the selected year ===== - $name = $this->studentClassModel->getClassSectionsByStudentId((int)$s['student_id'], $selectedYear); - $s['class_section'] = $name ?: 'Class not Assigned'; - $calculatedAge = $this->calculateAgeAsOfSchoolYearStartYear($s['dob'] ?? null, $selectedYear); - $s['age'] = $calculatedAge ?? ($s['age'] ?? null); - - // ===== Sortable registration date (for data-order in view) ===== - $s['registration_date_order'] = !empty($s['registration_date']) - ? date('Y-m-d', strtotime($s['registration_date'])) - : ''; - } - unset($s); // break reference - - // ===== Sort by parent, then student (lastname, firstname) ===== - usort($students, function (array $a, array $b) { - $pa = $a['parent_sort'] ?? ''; - $pb = $b['parent_sort'] ?? ''; - if (strcasecmp($pa, $pb) === 0) { - $la = $a['lastname'] ?? ''; - $lb = $b['lastname'] ?? ''; - $cmp = strcasecmp($la, $lb); - if ($cmp !== 0) return $cmp; - return strcasecmp($a['firstname'] ?? '', $b['firstname'] ?? ''); - } - return strcasecmp($pa, $pb); - }); - - $classes = $this->enrollmentClassOptions((string)$selectedYear); + $payload = service('enrollmentWithdrawal')->buildRoster($selectedYear, (string) $this->semester); return view('enroll_withdraw/enrollment_withdrawal', [ - 'students' => $students, - 'classes' => $classes, // <-- used by the modal