1042 lines
38 KiB
PHP
1042 lines
38 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Controllers\View\EmailController;
|
|
use App\Models\AttendanceDayModel;
|
|
use App\Models\ClassSectionModel;
|
|
use App\Models\ConfigurationModel;
|
|
use App\Models\ExamDraftModel;
|
|
use App\Models\HomeworkModel;
|
|
use App\Models\ScoreCommentModel;
|
|
use App\Models\SemesterScoreModel;
|
|
use App\Models\StudentClassModel;
|
|
use App\Models\TeacherSubmissionNotificationHistoryModel;
|
|
use App\Models\UserModel;
|
|
|
|
class TeacherSubmissionReportService
|
|
{
|
|
protected $db;
|
|
protected $configModel;
|
|
protected $studentClassModel;
|
|
protected $classSectionModel;
|
|
protected $userModel;
|
|
protected string $schoolYear = '';
|
|
protected string $semester = '';
|
|
|
|
public function __construct(
|
|
\CodeIgniter\Database\BaseConnection $db,
|
|
ConfigurationModel $configModel,
|
|
StudentClassModel $studentClassModel,
|
|
ClassSectionModel $classSectionModel,
|
|
UserModel $userModel
|
|
) {
|
|
$this->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 = "<p>Outstanding items: {$missingText}.</p>";
|
|
} else {
|
|
$missingNote = "<p>Our records show no outstanding submissions for this section, but please verify if anything still needs attention.</p>";
|
|
}
|
|
|
|
$subject = "Reminder: Complete submissions for {$sectionName}";
|
|
$progressNote = '';
|
|
if (in_array('class progress', $missingItems, true)) {
|
|
$progressNote = "<p>Class progress submissions can be updated at <a href=\"{$progressUrl}\">Teacher Progress History</a>.</p>";
|
|
}
|
|
$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 = "<p>" . ucfirst($draftLabel) . " submissions can be updated at <a href=\"{$examDraftUrl}\">Teacher Exam Drafts</a>.</p>"
|
|
. $examDraftDeadlineEmailHtml;
|
|
}
|
|
$homeworkNote = '';
|
|
if (in_array('homework', $missingItems, true)) {
|
|
$homeworkNote = "<p>Homework scores can be submitted at <a href=\"{$homeworkUrl}\">Teacher Homework</a>.</p>";
|
|
}
|
|
$hasScoreItems = (bool) array_intersect($missingItems, [
|
|
'midterm scores',
|
|
'midterm comments',
|
|
'final scores',
|
|
'final comments',
|
|
'participation',
|
|
'PTAP comments',
|
|
'homework',
|
|
]);
|
|
$nonScoreOnly = ! empty($missingItems) && ! $hasScoreItems;
|
|
$body = "<p>Dear {$teacherName},</p>"
|
|
. "<p>Administration is gently reminding you to wrap up any remaining "
|
|
. ($nonScoreOnly ? "submissions for {$sectionName}." : "score submissions, comments, and related items for {$sectionName}.")
|
|
. "</p>"
|
|
. $missingNote
|
|
. $progressNote
|
|
. $examDraftNote
|
|
. $homeworkNote
|
|
. ($nonScoreOnly ? '' : "<p>Visit <a href=\"{$scoreUrl}\">Teacher Score Submission</a> to address any remaining items.</p>")
|
|
. "<p>Thank you,<br>Al Rahma Administration</p>";
|
|
|
|
$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 '<p><strong>Exam draft submission deadline</strong> (<code>exam_draft_deadline</code>): '
|
|
. "<strong>{$display}</strong>"
|
|
. ($parsed !== null && $rawEsc !== $display ? " <span style=\"color:#555;\">(configured value: {$rawEsc})</span>" : '')
|
|
. '.</p>';
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|