db = $db; $this->configModel = $configModel; $this->studentClassModel = $studentClassModel; $this->classSectionModel = $classSectionModel; $this->userModel = $userModel; } public function buildReport(string $semester, string $schoolYear, array $lowProgressSectionIds = []): array { $this->schoolYear = $schoolYear; $this->semester = $semester; $semesterResolver = new SemesterRangeService($this->configModel); $semesterNorm = $semesterResolver->normalizeSemester($semester); $semesterFilter = $semesterNorm !== '' ? $semesterNorm : $semester; $semesterCandidates = $this->buildSemesterCandidates($semesterFilter); $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 [ 'rows' => $rows, 'semester' => $semester, 'schoolYear' => $schoolYear, 'notificationHistory' => $historyMap, 'summary' => $summary, 'lowProgressSectionIds' => $lowProgressSectionIds, 'examDraftDeadlineConfig' => $examDraftDeadlineConfig, 'examDraftDeadlineFormatted' => $examDraftDeadlineFormatted, ]; } public function sendNotifications(array $post, string $semester, int $adminId): array { $notify = $post['notify'] ?? null; if (!is_array($notify)) { return ['redirect' => 'back', 'type' => 'info', 'message' => 'Select at least one teacher to notify.']; } $semester = (string)(getSemester() ?? $this->semester ?? ''); $missingItemsPayload = $post['missing_items'] ?? []; $homeworkNotifyAll = (bool) ($post['homework_notify_all'] ?? false); $examTerm = $this->resolveExamTermLabel($semester); $examScoreLabel = $examTerm === 'final' ? 'final scores' : 'midterm scores'; $examCommentLabel = $examTerm === 'final' ? 'final comments' : 'midterm comments'; $forcedItems = []; if (!empty($post['notify_midterm_score'])) { $forcedItems[] = $examScoreLabel; } if (!empty($post['notify_midterm_comment'])) { $forcedItems[] = $examCommentLabel; } if (!empty($post['notify_participation'])) { $forcedItems[] = 'participation'; } if (!empty($post['notify_ptap_comment'])) { $forcedItems[] = 'PTAP comments'; } if (!empty($post['notify_class_progress'])) { $forcedItems[] = 'class progress'; } if (!empty($post['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', 'type' => 'info', 'message' => '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(); if ($adminId <= 0) { return ['redirect' => 'login', 'type' => 'error', 'message' => '']; } $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', 'type' => $flashType, 'message' => $message]; } private function resolveLowProgressSectionIds(array $sectionIds): array { [$expectedWeeks, $submittedBySection] = $this->buildClassProgressStats($sectionIds); if ($expectedWeeks <= 0) { return []; } $lowProgressSectionIds = []; foreach ($sectionIds as $sectionId) { $submitted = (int) ($submittedBySection[$sectionId] ?? 0); $percent = ($submitted / $expectedWeeks) * 100; if ($percent < 50) { $lowProgressSectionIds[] = $sectionId; } } return $lowProgressSectionIds; } private function buildClassProgressStats(array $sectionIds): array { $sectionIds = array_values(array_unique(array_filter(array_map('intval', $sectionIds)))); if (empty($sectionIds)) { return [0, []]; } $semesterResolver = new SemesterRangeService($this->configModel); $schoolYear = (string) ($this->schoolYear ?? ''); $semester = (string)(getSemester() ?? ''); $schoolYearForRange = $schoolYear !== '' ? $schoolYear : (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]; } 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)); } }