From 2543df3d33a8a1415681feab7849ab7912139312 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 9 Apr 2026 01:50:24 -0400 Subject: [PATCH] exam draft notification and submission --- app/Controllers/View/ExamDraftController.php | 158 ++++++++++++++++--- app/Models/ExamDraftModel.php | 1 + app/Views/administrator/exam_drafts.php | 43 ++++- app/Views/teacher/drafts.php | 100 ++++++++++-- 4 files changed, 262 insertions(+), 40 deletions(-) diff --git a/app/Controllers/View/ExamDraftController.php b/app/Controllers/View/ExamDraftController.php index a32524b..78879e6 100644 --- a/app/Controllers/View/ExamDraftController.php +++ b/app/Controllers/View/ExamDraftController.php @@ -26,6 +26,9 @@ class ExamDraftController extends BaseController protected bool $hasAcceptanceTypeColumn = false; protected bool $hasAdminIdColumn = false; protected bool $hasReviewRevisionColumn = false; + protected bool $hasReviewerCommentColumn = false; + protected bool $hasReviewerCommentsColumn = false; + protected bool $hasAdminCommentsColumn = false; protected string $authorIdColumn = 'teacher_id'; protected string $authorFileColumn = 'teacher_file'; protected string $authorFilenameColumn = 'teacher_filename'; @@ -65,6 +68,9 @@ class ExamDraftController extends BaseController $this->hasAcceptanceTypeColumn = $this->schemaHasColumn('exam_drafts', 'acceptance_type'); $this->hasAdminIdColumn = $this->schemaHasColumn('exam_drafts', 'admin_id'); $this->hasReviewRevisionColumn = $this->schemaHasColumn('exam_drafts', 'review_revision'); + $this->hasReviewerCommentColumn = $this->schemaHasColumn('exam_drafts', 'reviewer_comment'); + $this->hasReviewerCommentsColumn = $this->schemaHasColumn('exam_drafts', 'reviewer_comments'); + $this->hasAdminCommentsColumn = $this->schemaHasColumn('exam_drafts', 'admin_comments'); $this->authorIdColumn = $this->schemaHasColumn('exam_drafts', 'teacher_id') ? 'teacher_id' : 'author_id'; $this->authorFileColumn = $this->schemaHasColumn('exam_drafts', 'teacher_file') ? 'teacher_file' : 'author_file'; $this->authorFilenameColumn = $this->schemaHasColumn('exam_drafts', 'teacher_filename') ? 'teacher_filename' : 'author_filename'; @@ -73,7 +79,9 @@ class ExamDraftController extends BaseController : ($this->schemaHasColumn('exam_drafts', 'reviewer_id') ? 'reviewer_id' : ''); $this->reviewerCommentColumn = $this->schemaHasColumn('exam_drafts', 'reviewer_comment') ? 'reviewer_comment' - : ($this->schemaHasColumn('exam_drafts', 'admin_comments') ? 'admin_comments' : ''); + : ($this->schemaHasColumn('exam_drafts', 'reviewer_comments') + ? 'reviewer_comments' + : ($this->schemaHasColumn('exam_drafts', 'admin_comments') ? 'admin_comments' : '')); helper(['form', 'url', 'date']); } @@ -116,19 +124,22 @@ class ExamDraftController extends BaseController // Keep all submissions visible; legacy ones are also surfaced in a separate tab $drafts = $allDrafts; + $legacyQuery = $this->examDraftModel + ->select($this->draftSelectColumns()) + ->select('cs.class_section_name') + ->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left') + ->where('exam_drafts.is_legacy', 1) + ->where('exam_drafts.status', 'legacy') + ->where('exam_drafts.final_file IS NOT NULL', null, false); + if (!empty($classSectionIds)) { - $legacyExams = $this->examDraftModel - ->select($this->draftSelectColumns()) - ->select('cs.class_section_name') - ->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left') - ->whereIn('exam_drafts.class_section_id', $classSectionIds) - ->where('exam_drafts.is_legacy', 1) - ->where('exam_drafts.status', 'legacy') - ->where('exam_drafts.final_file IS NOT NULL', null, false) - ->orderBy('cs.class_section_name', 'ASC') - ->orderBy('exam_drafts.created_at', 'DESC') - ->findAll(); + $legacyQuery = $legacyQuery->whereIn('exam_drafts.class_section_id', $classSectionIds); } + + $legacyExams = $legacyQuery + ->orderBy('cs.class_section_name', 'ASC') + ->orderBy('exam_drafts.created_at', 'DESC') + ->findAll(); } else { // Legacy column absent, show all drafts and skip legacy tab query $drafts = $allDrafts; @@ -283,11 +294,12 @@ class ExamDraftController extends BaseController 'status' => 'submitted', ]; $this->applyAuthorFilePayload($updateSubmit, $teacherFile, $teacherFilename); - if ($this->examDraftModel->update($draftRowId, $updateSubmit)) { - $saved = $this->ensureDraftStatus($draftRowId, 'submitted'); - $this->notifyExamDraftEvent($saved, 'submitted'); - return redirect()->to('/teacher/exam-drafts')->with('success', 'Exam draft submitted for review.'); - } + if ($this->examDraftModel->update($draftRowId, $updateSubmit)) { + $saved = $this->ensureDraftStatus($draftRowId, 'submitted'); + $this->notifyPrincipalExamDraftSubmitted($saved, $teacherId, $classSectionId); + $this->notifyExamDraftEvent($saved, 'submitted'); + return redirect()->to('/teacher/exam-drafts')->with('success', 'Exam draft submitted for review.'); + } return redirect()->back()->withInput()->with('error', 'Unable to submit the exam draft.'); } @@ -319,6 +331,7 @@ class ExamDraftController extends BaseController if ($this->examDraftModel->insert($payload)) { $newId = (int) $this->examDraftModel->getInsertID(); $saved = $newId > 0 ? $this->ensureDraftStatus($newId, 'submitted') : null; + $this->notifyPrincipalExamDraftSubmitted($saved, $teacherId, $classSectionId); $this->notifyExamDraftEvent($saved, 'submitted'); return redirect()->to('/teacher/exam-drafts')->with('success', 'Exam draft submitted for review.'); } @@ -556,8 +569,16 @@ class ExamDraftController extends BaseController 'status' => $status, 'reviewed_at' => utc_now(), ]; - if ($this->reviewerCommentColumn !== '') { - $update[$this->reviewerCommentColumn] = $reviewerComment === '' ? null : $reviewerComment; + $existingComment = (string) ($draft['reviewer_comment'] ?? $draft['reviewer_comments'] ?? $draft['admin_comments'] ?? ''); + $commentValue = $reviewerComment === '' ? null : $this->appendReviewerComment($existingComment, $reviewerComment); + if ($this->hasReviewerCommentColumn) { + $update['reviewer_comment'] = $commentValue; + } + if ($this->hasReviewerCommentsColumn) { + $update['reviewer_comments'] = $commentValue; + } + if ($this->hasAdminCommentsColumn) { + $update['admin_comments'] = $commentValue; } if ($this->reviewerIdColumn !== '') { $update[$this->reviewerIdColumn] = (int) (session()->get('user_id') ?? 0); @@ -589,8 +610,14 @@ class ExamDraftController extends BaseController if ($this->hasReviewRevisionColumn) { $newRow['review_revision'] = $reviewRevision; } - if ($this->reviewerCommentColumn !== '') { - $newRow[$this->reviewerCommentColumn] = $update[$this->reviewerCommentColumn] ?? null; + if ($this->hasReviewerCommentColumn) { + $newRow['reviewer_comment'] = $commentValue; + } + if ($this->hasReviewerCommentsColumn) { + $newRow['reviewer_comments'] = $commentValue; + } + if ($this->hasAdminCommentsColumn) { + $newRow['admin_comments'] = $commentValue; } if ($this->reviewerIdColumn !== '') { $newRow[$this->reviewerIdColumn] = $update[$this->reviewerIdColumn] ?? null; @@ -831,6 +858,46 @@ class ExamDraftController extends BaseController return $saved; } + private function notifyPrincipalExamDraftSubmitted(?array $draft, int $teacherId, int $classSectionId): void + { + $principalEmail = trim((string) (env('PRINCIPAL_EMAIL') ?: '')); + if ($principalEmail === '' || !filter_var($principalEmail, FILTER_VALIDATE_EMAIL)) { + return; + } + if (empty($draft)) { + return; + } + + try { + $teacher = $this->userModel->find($teacherId) ?? []; + $teacherName = trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? '')); + if ($teacherName === '') { + $teacherName = 'Teacher #' . $teacherId; + } + + $class = $this->classSectionModel->find($classSectionId) ?? []; + $className = $class['class_section_name'] ?? ('Class ' . $classSectionId); + + $subject = 'Exam draft submitted: ' . $className; + $submittedAt = $draft['created_at'] ?? $draft['updated_at'] ?? ''; + $body = '

A teacher has submitted a new exam draft.

' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '
Teacher' . esc($teacherName) . '
Class' . esc($className) . '
Exam type' . esc($draft['exam_type'] ?? 'N/A') . '
Version' . esc((string) ($draft['version'] ?? '')) . '
Status' . esc((string) ($draft['status'] ?? 'submitted')) . '
Submitted at' . esc((string) $submittedAt) . '
' + . '

Review drafts: Admin exam drafts

'; + + $mailer = \Config\Services::emailService(); + $mailer->send($principalEmail, $subject, $body, 'notifications'); + } catch (\Throwable $e) { + log_message('error', 'Failed to send exam draft notification: ' . $e->getMessage()); + } + } + private function nextDraftVersion(array $draft): int { $query = $this->examDraftModel @@ -887,6 +954,36 @@ class ExamDraftController extends BaseController return $current + 1; } + private function appendReviewerComment(string $existing, string $incoming): string + { + $existing = trim($existing); + $incoming = trim($incoming); + if ($incoming === '') { + return $existing; + } + + if ($existing !== '' && str_starts_with($incoming, $existing)) { + $remainder = trim(substr($incoming, strlen($existing))); + if ($remainder === '') { + return $existing; + } + $incoming = $remainder; + } + + $lines = $existing === '' ? [] : preg_split('/\R/', $existing); + $lastLine = $lines ? trim((string) end($lines)) : ''; + if ($lastLine !== '') { + $lastBody = preg_replace('/^\d+\-\s*/', '', $lastLine); + if ($lastBody !== null && trim($lastBody) === $incoming) { + return $existing; + } + } + $nextIndex = count($lines) + 1; + $incoming = trim(preg_replace('/\R+/', ' ', $incoming) ?? $incoming); + $lines[] = $nextIndex . '- ' . $incoming; + return implode("\n", $lines); + } + private function statusOptions(): array { return ['submitted', 'accepted', 'review needed', 'rejected', 'canceled', 'under review', 'legacy']; @@ -1001,6 +1098,7 @@ class ExamDraftController extends BaseController $grouped[$key]['_is_review_row'] = true; $grouped[$key]['review_files'] = []; $grouped[$key]['final_version'] = null; + $grouped[$key]['_latest_review_comment'] = null; } $grouped[$key]['review_files'][] = [ 'review_revision' => $reviewRev, @@ -1008,6 +1106,16 @@ class ExamDraftController extends BaseController 'final_filename' => $row['final_filename'] ?? null, 'status' => $row['status'] ?? null, ]; + $reviewComment = $row['reviewer_comment'] ?? $row['admin_comments'] ?? null; + if ($reviewComment !== null && $reviewComment !== '') { + $latestComment = $grouped[$key]['_latest_review_comment'] ?? null; + if ($latestComment === null || $reviewRev > (int) ($latestComment['review_revision'] ?? 0)) { + $grouped[$key]['_latest_review_comment'] = [ + 'review_revision' => $reviewRev, + 'comment' => $reviewComment, + ]; + } + } if (strtolower((string) ($row['status'] ?? '')) === 'accepted') { $currentFinal = $grouped[$key]['final_version'] ?? null; if ($currentFinal === null || $reviewRev > (int) ($currentFinal['review_revision'] ?? 0)) { @@ -1029,8 +1137,12 @@ class ExamDraftController extends BaseController if (!empty($grouped[$key]['_is_review_row'])) { $reviewFiles = $grouped[$key]['review_files'] ?? []; + $latestReviewComment = $grouped[$key]['_latest_review_comment'] ?? null; $grouped[$key] = $row; $grouped[$key]['review_files'] = $reviewFiles; + if (!empty($latestReviewComment['comment'])) { + $grouped[$key]['reviewer_comment'] = $latestReviewComment['comment']; + } unset($grouped[$key]['_is_review_row']); } } @@ -1043,7 +1155,11 @@ class ExamDraftController extends BaseController $row['final_file'] = $row['final_version']['final_file'] ?? $row['final_file'] ?? null; $row['final_filename'] = $row['final_version']['final_filename'] ?? $row['final_filename'] ?? null; } + if (empty($row['reviewer_comment']) && !empty($row['_latest_review_comment']['comment'])) { + $row['reviewer_comment'] = $row['_latest_review_comment']['comment']; + } unset($row['_is_review_row']); + unset($row['_latest_review_comment']); } unset($row); diff --git a/app/Models/ExamDraftModel.php b/app/Models/ExamDraftModel.php index 80244f8..bf96c84 100644 --- a/app/Models/ExamDraftModel.php +++ b/app/Models/ExamDraftModel.php @@ -52,6 +52,7 @@ class ExamDraftModel extends Model 'admin_id', 'is_legacy', 'reviewer_comment', + 'reviewer_comments', 'admin_comments', 'reviewed_at', 'final_file', diff --git a/app/Views/administrator/exam_drafts.php b/app/Views/administrator/exam_drafts.php index c7f6b37..70dab41 100644 --- a/app/Views/administrator/exam_drafts.php +++ b/app/Views/administrator/exam_drafts.php @@ -198,7 +198,7 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi - +
@@ -392,19 +392,27 @@ document.addEventListener('DOMContentLoaded', () => { } }; - const saveRow = (row) => { + const saveRow = (row, options = {}) => { const form = row.querySelector('form'); if (!form) { return; } const statusSelect = form.querySelector('select[name="review_status"]'); - if (!statusSelect || statusSelect.value === '') { + if (!statusSelect) { return; } const statusLabel = form.querySelector('.js-review-status'); const data = new FormData(form); data.delete('final_file'); data.delete('old_exam_file'); + if (options.commitComment) { + const commentInput = form.querySelector('textarea[name="reviewer_comment"]'); + if (commentInput) { + const raw = commentInput.value || ''; + const committed = raw.endsWith('\n') ? raw : raw + '\n'; + data.set('reviewer_comment', committed); + } + } const csrfToken = syncCsrfToken(form); if (csrfToken) { data.set(csrfTokenName, csrfToken); @@ -480,10 +488,29 @@ document.addEventListener('DOMContentLoaded', () => { return; } const row = target.closest('tr'); - if (row) { - debouncedSave(row); + if (row && target.value.endsWith('\n')) { + const lastCommitted = target.dataset.lastCommitted || ''; + if (target.value !== lastCommitted) { + target.dataset.lastCommitted = target.value; + debouncedSave(row, { commitComment: true }); + } } }); + + document.addEventListener('blur', (event) => { + const target = event.target; + if (!(target instanceof HTMLTextAreaElement) || target.name !== 'reviewer_comment') { + return; + } + const row = target.closest('tr'); + if (row) { + const lastCommitted = target.dataset.lastCommitted || ''; + if (target.value !== lastCommitted) { + target.dataset.lastCommitted = target.value; + debouncedSave(row, { commitComment: true }); + } + } + }, true); }); endSection() ?> @@ -496,13 +523,15 @@ document.addEventListener('DOMContentLoaded', () => { word-break: break-word; } .exam-drafts-table th { - min-width: 120px; + min-width: 0; + white-space: nowrap; } .exam-drafts-table td { - max-width: 220px; + max-width: none; } .exam-drafts-table { table-layout: auto; + width: max-content; } .exam-drafts-table thead th { background-color: #f8f9fa; diff --git a/app/Views/teacher/drafts.php b/app/Views/teacher/drafts.php index 417c365..aec0361 100644 --- a/app/Views/teacher/drafts.php +++ b/app/Views/teacher/drafts.php @@ -11,9 +11,36 @@ $schoolYear = $schoolYear ?? ''; $semester = $semester ?? ''; $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024); ?> -
+ +
-

Exam drafts

+
+

Exam drafts

+ + + +
getFlashdata('success')): ?>
getFlashdata('success')) ?>
@@ -24,7 +51,7 @@ $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
- 'row g-3']) ?> + 'row g-3', 'id' => 'examDraftForm']) ?>
@@ -65,7 +92,7 @@ $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);

Your submissions

- +
@@ -85,7 +112,7 @@ $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024); ?> - + - + @@ -137,15 +164,18 @@ $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024); -

Previous exams (legacy)

-
-
Class
@@ -124,7 +151,7 @@ $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024); —
+
+
+ + + + @@ -154,12 +184,16 @@ $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024); - + + + + + @@ -172,8 +206,41 @@ $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024); endSection() ?> +section('scripts') ?> +endSection() ?>
Class Title Ver. StatusSchool yearSemesterExam typeUpdated Actions
- - Print + + View