diff --git a/app/Config/Events.php b/app/Config/Events.php index 7ec0e4c..a2bd704 100644 --- a/app/Config/Events.php +++ b/app/Config/Events.php @@ -9,6 +9,7 @@ use App\Listeners\SchoolEventListener; use App\Listeners\AttendanceConsequenceListener; use App\Listeners\WhatsappInviteListener; use App\Listeners\BelowSixtyEmailListener; +use App\Listeners\DecisionEmailListener; // Create an instance so we can use $this->emailService like your other handlers $waListener = new WhatsappInviteListener(service('emailService')); @@ -118,6 +119,7 @@ Events::on('attendance.follow_up', [AttendanceConsequenceListener::class, 'fol Events::on('attendance.final_warning', [AttendanceConsequenceListener::class, 'finalWarning']); Events::on('attendance.dismissal', [AttendanceConsequenceListener::class, 'dismissal']); Events::on('below60.email', [BelowSixtyEmailListener::class, 'handle']); +Events::on('below60.decision_email', [DecisionEmailListener::class, 'handle']); //Whatsapp Event listener Events::on('whatsapp_invites.send', [$waListener, 'handle']); diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 7ff831c..8f86bb7 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -182,7 +182,8 @@ $routes->get('administrator/trophy/final', 'View\TrophyController::final' $routes->get('administrator/certificates', 'View\CertificateController::index', ['filter' => 'auth:admin']); $routes->get('administrator/certificates/csrf-token', 'View\CertificateController::csrfToken', ['filter' => 'auth:admin']); $routes->post('administrator/certificates/generate', 'View\CertificateController::generate', ['filter' => 'auth:admin']); -$routes->get('administrator/certificates/log', 'View\CertificateController::auditLog', ['filter' => 'auth:admin']); +$routes->get('administrator/certificates/log', 'View\CertificateController::auditLog', ['filter' => 'auth:admin']); +$routes->get('administrator/certificates/reprint/(:any)', 'View\CertificateController::reprint/$1', ['filter' => 'auth:read']); $routes->get('verify/(:segment)', 'View\CertificateController::verify/$1'); @@ -522,6 +523,14 @@ $routes->post('grading/below-60/email', 'View\GradingController::sendBelowSixtyE $routes->post('grading/below-60/status', 'View\GradingController::updateBelowSixtyStatus', ['filter' => 'auth:read']); $routes->get('grading/below-60/schedule', 'View\GradingController::scheduleBelowSixty', ['filter' => 'auth:read']); $routes->post('grading/below-60/schedule', 'View\GradingController::saveBelowSixtyMeeting', ['filter' => 'auth:read']); +$routes->get('grading/decisions', 'View\GradingController::allDecisions', ['filter' => 'auth:read']); +$routes->post('grading/decisions/generate', 'View\GradingController::generateAllDecisions', ['filter' => 'auth:read']); +$routes->get('grading/below-60/decisions', 'View\GradingController::belowSixtyDecisions', ['filter' => 'auth:read']); +$routes->post('grading/below-60/decisions/save', 'View\GradingController::saveBelowSixtyDecision', ['filter' => 'auth:read']); +$routes->get('grading/below-60/decisions/student-details', 'View\GradingController::studentDecisionDetails', ['filter' => 'auth:read']); +$routes->get('grading/below-60/decisions/email/preview', 'View\GradingController::previewDecisionEmail', ['filter' => 'auth:read']); +$routes->get('grading/below-60/decisions/email/edit', 'View\GradingController::editDecisionEmail', ['filter' => 'auth:read']); +$routes->post('grading/below-60/decisions/email', 'View\GradingController::sendDecisionEmail', ['filter' => 'auth:read']); // Final part diff --git a/app/Controllers/View/CertificateController.php b/app/Controllers/View/CertificateController.php index 08737b3..53a1e47 100644 --- a/app/Controllers/View/CertificateController.php +++ b/app/Controllers/View/CertificateController.php @@ -6,6 +6,7 @@ use App\Controllers\BaseController; use App\Models\ClassSectionModel; use App\Models\ConfigurationModel; use App\Models\CertificateRecordModel; +use App\Models\StudentDecisionModel; class CertificateController extends BaseController { @@ -27,40 +28,120 @@ class CertificateController extends BaseController public function index() { $db = \Config\Database::connect(); - $classSectionId = $this->request->getGet('class_section_id'); + $selectedCsid = $this->request->getGet('class_section_id'); $schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear; - $classSections = $db->table('classSection cs') - ->select('cs.class_section_id, cs.class_section_name') - ->join('student_class sc', 'sc.class_section_id = cs.class_section_id') + // ── All enrolled students across every class section ─────────────────── + $allEnrolled = $db->table('student_class sc') + ->select('s.id AS student_id, s.firstname, s.lastname, sc.class_section_id, cs.class_section_name') ->join('students s', 's.id = sc.student_id') + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id') ->where('s.is_active', 1) ->where('sc.school_year', $schoolYear) - ->groupBy('cs.class_section_id, cs.class_section_name') - ->having('COUNT(s.id) >', 1) - ->orderBy('cs.class_section_name', 'ASC') + ->orderBy('s.firstname', 'ASC') + ->orderBy('s.lastname', 'ASC') ->get()->getResultArray(); - $students = []; - if ($classSectionId) { - $students = $db->table('student_class sc') - ->select('s.id, s.firstname, s.lastname, cs.class_section_name AS grade') - ->join('students s', 's.id = sc.student_id') - ->join('classSection cs', 'cs.class_section_id = sc.class_section_id') - ->where('sc.class_section_id', (int) $classSectionId) - ->where('s.is_active', 1) - ->where('sc.school_year', $schoolYear) - ->orderBy('s.lastname', 'ASC') - ->orderBy('s.firstname', 'ASC') - ->get()->getResultArray(); + $allIds = array_unique(array_column($allEnrolled, 'student_id')); + + // ── Semester scores ──────────────────────────────────────────────────── + $allScoreMap = []; + if (!empty($allIds)) { + foreach ($db->table('semester_scores') + ->select('student_id, semester, semester_score') + ->whereIn('student_id', $allIds) + ->where('school_year', $schoolYear) + ->where('semester_score IS NOT NULL', null, false) + ->get()->getResultArray() as $sr) { + $allScoreMap[(int)$sr['student_id']][ucfirst(strtolower($sr['semester']))] = + is_numeric($sr['semester_score']) ? (float)$sr['semester_score'] : null; + } + } + + // ── Below-60 manual decisions ────────────────────────────────────────── + $allBelowMap = []; + if (!empty($allIds)) { + foreach ($db->table('below_sixty_decisions') + ->whereIn('student_id', $allIds) + ->where('school_year', $schoolYear) + ->get()->getResultArray() as $b) { + $allBelowMap[(int)$b['student_id']][ucfirst(strtolower($b['semester']))] = (string)$b['decision']; + } + } + + // ── Certificate records (most recent per student) ────────────────────── + $certsByStudent = []; + if (!empty($allIds)) { + foreach ($db->table('certificate_records') + ->select('student_id, certificate_number, issued_at') + ->whereIn('student_id', $allIds) + ->where('school_year', $schoolYear) + ->orderBy('issued_at', 'DESC') + ->get()->getResultArray() as $c) { + $sid = (int)$c['student_id']; + if (!isset($certsByStudent[$sid])) { + $certsByStudent[$sid] = $c['certificate_number']; + } + } + } + + // ── Build per-student decisions + per-class buckets ──────────────────── + $decisionsByStudent = []; + $studentsByClass = []; // [csid => [student rows...]] + $statsPerClass = []; // [csid => {name, total, pass, cert}] + + foreach ($allEnrolled as $row) { + $sid = (int)$row['student_id']; + $csid = (int)$row['class_section_id']; + + // Decisions per semester + foreach ($allScoreMap[$sid] ?? [] as $sem => $score) { + if ($score === null) continue; + if ($score >= 60) { + $dec = 'Pass'; $src = 'auto'; + } elseif (!empty($allBelowMap[$sid][$sem])) { + $dec = $allBelowMap[$sid][$sem]; $src = 'manual'; + } else { + $dec = ''; $src = 'pending'; + } + $decisionsByStudent[$sid][$sem] = ['decision' => $dec, 'source' => $src]; + } + + // Per-class stats + if (!isset($statsPerClass[$csid])) { + $statsPerClass[$csid] = ['name' => $row['class_section_name'], 'total' => 0, 'pass' => 0, 'cert' => 0]; + } + $statsPerClass[$csid]['total']++; + + $sems = $allScoreMap[$sid] ?? []; + $isPass = !empty($sems); + foreach ($sems as $sem => $score) { + if ($score === null) { $isPass = false; break; } + if ($score >= 60) continue; + $md = $allBelowMap[$sid][$sem] ?? ''; + if ($md === '' || $md !== 'Pass') { $isPass = false; break; } + } + if ($isPass) $statsPerClass[$csid]['pass']++; + if (isset($certsByStudent[$sid])) $statsPerClass[$csid]['cert']++; + + // Group students by class + $studentsByClass[$csid][] = $row; + } + + // ── Determine default active tab ─────────────────────────────────────── + $firstCsid = !empty($allEnrolled) ? (int)$allEnrolled[0]['class_section_id'] : null; + if ($selectedCsid === null && $firstCsid !== null) { + $selectedCsid = (string)$firstCsid; } return view('admin/certificates/index', [ - 'classSections' => $classSections, - 'students' => $students, - 'selectedClassId' => $classSectionId, - 'schoolYear' => $schoolYear, - 'certDate' => date('m/d/Y'), + 'studentsByClass' => $studentsByClass, + 'selectedClassId' => $selectedCsid, + 'schoolYear' => $schoolYear, + 'certDate' => date('m/d/Y'), + 'decisionsByStudent' => $decisionsByStudent, + 'certsByStudent' => $certsByStudent, + 'statsPerClass' => $statsPerClass, ]); } @@ -102,6 +183,51 @@ class CertificateController extends BaseController return view('certificates/verify', ['record' => $record ?: null]); } + // ─── Reprint an existing certificate ────────────────────────────────────── + + public function reprint(string $certNumber) + { + $record = \Config\Database::connect() + ->table('certificate_records') + ->where('certificate_number', strtoupper($certNumber)) + ->get()->getRowArray(); + + if (!$record) { + return redirect()->to('administrator/certificates/log') + ->with('error', 'Certificate not found: ' . $certNumber); + } + + $certDateFormatted = ''; + if (!empty($record['cert_date'])) { + $ts = strtotime((string)$record['cert_date']); + if ($ts) { + $certDateFormatted = date('m/d/Y', $ts); + } + } + + $student = [ + 'firstname' => (string)($record['student_name'] ?? ''), + 'lastname' => '', + 'grade' => (string)($record['grade'] ?? ''), + 'cert_number' => (string)($record['certificate_number'] ?? ''), + ]; + + // student_name is stored as "Firstname Lastname" — split for the PDF builder + $parts = explode(' ', trim($student['firstname']), 2); + if (count($parts) === 2) { + $student['firstname'] = $parts[0]; + $student['lastname'] = $parts[1]; + } + + $pdfData = $this->buildPdf([$student], $certDateFormatted ?: date('m/d/Y')); + $filename = 'Certificate_' . strtoupper($certNumber) . '.pdf'; + + return $this->response + ->setHeader('Content-Type', 'application/pdf') + ->setHeader('Content-Disposition', 'inline; filename="' . $filename . '"') + ->setBody($pdfData); + } + // ─── PDF generation ──────────────────────────────────────────────────────── public function generate() @@ -188,20 +314,38 @@ class CertificateController extends BaseController $issuedAt = date('Y-m-d H:i:s'); $certDateDb = $this->parseCertDate($certDate); + // Load any existing certificates for these students this school year + $db = \Config\Database::connect(); + $existingCerts = $db->table('certificate_records') + ->select('student_id, certificate_number') + ->whereIn('student_id', array_column($students, 'id')) + ->where('school_year', $schoolYear) + ->get()->getResultArray(); + $existingCertMap = []; + foreach ($existingCerts as $ec) { + $existingCertMap[(int)$ec['student_id']] = $ec['certificate_number']; + } + foreach ($students as &$student) { - $certNumber = $this->certRecordModel->nextNumber($schoolYear); - $this->certRecordModel->insert([ - 'certificate_number' => $certNumber, - 'student_id' => $student['id'], - 'student_name' => $student['firstname'] . ' ' . $student['lastname'], - 'grade' => $this->formatGrade($student['grade'] ?? ''), - 'cert_date' => $certDateDb, - 'school_year' => $schoolYear, - 'class_section_id' => $classSectionId ?: null, - 'issued_by' => $issuedBy, - 'issued_at' => $issuedAt, - ]); - $student['cert_number'] = $certNumber; + $sid = (int)$student['id']; + if (isset($existingCertMap[$sid])) { + // Reuse existing certificate number — do not create a new record + $student['cert_number'] = $existingCertMap[$sid]; + } else { + $certNumber = $this->certRecordModel->nextNumber($schoolYear); + $this->certRecordModel->insert([ + 'certificate_number' => $certNumber, + 'student_id' => $sid, + 'student_name' => $student['firstname'] . ' ' . $student['lastname'], + 'grade' => $this->formatGrade($student['grade'] ?? ''), + 'cert_date' => $certDateDb, + 'school_year' => $schoolYear, + 'class_section_id' => $classSectionId ?: null, + 'issued_by' => $issuedBy, + 'issued_at' => $issuedAt, + ]); + $student['cert_number'] = $certNumber; + } } unset($student); @@ -234,15 +378,20 @@ class CertificateController extends BaseController $clean = trim($raw); $lower = strtolower($clean); - if (preg_match('/^\d+$/', $clean) && (int)$clean >= 1 && (int)$clean <= 9) { - return 'Grade ' . $clean; + // Strip section suffix: "Grade 1-A" → "Grade 1", "Grade 2-B" → "Grade 2" + if (preg_match('/^grade\s*(\d+)/i', $clean, $m)) { + return 'Grade ' . (int)$m[1]; } if ($lower === 'youth') { return 'Youth'; } - if ($lower === 'kg') { + if ($lower === 'kg' || $lower === 'kindergarten') { return 'Kindergarten'; } + // Raw number or number-section (e.g. "1", "1-A", "2-B") → keep number only + if (preg_match('/^(\d+)([- ][A-Za-z0-9]+)?$/', $clean, $m)) { + return 'Grade ' . (int)$m[1]; + } return $clean; } diff --git a/app/Controllers/View/GradingController.php b/app/Controllers/View/GradingController.php index 9d385c0..320496f 100644 --- a/app/Controllers/View/GradingController.php +++ b/app/Controllers/View/GradingController.php @@ -28,6 +28,8 @@ use App\Models\PlacementLevelModel; use App\Models\PlacementBatchModel; use App\Models\PlacementScoreModel; use App\Models\GradingLockModel; +use App\Models\BelowSixtyDecisionModel; +use App\Models\StudentDecisionModel; use App\Services\NavbarService; //use App\Models\ScoreModel; @@ -1954,6 +1956,73 @@ class GradingController extends Controller return false; } + private function fetchAllSemestersForStudent(int $studentId, string $schoolYear): array + { + $rows = $this->db->table('semester_scores ss') + ->select([ + 'ss.semester', + 'cs.class_section_name', + 'ss.homework_avg', + 'ss.project_avg', + 'ss.participation_score', + 'COALESCE(ss.test_avg, ss.quiz_avg) AS test_avg', + 'ss.ptap_score', + 'ss.attendance_score', + 'ss.midterm_exam_score', + 'ss.final_exam_score', + 'ss.semester_score', + ]) + ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left') + ->where('ss.student_id', $studentId) + ->where('ss.school_year', $schoolYear) + ->orderBy('ss.semester', 'ASC') + ->get() + ->getResultArray(); + + $semesters = []; + foreach ($rows as $sr) { + $sem = ucfirst(strtolower(trim((string)($sr['semester'] ?? '')))); + $semesters[$sem] = [ + 'semester' => $sem, + 'class_section_name' => $sr['class_section_name'] ?? '', + 'homework_avg' => $sr['homework_avg'] ?? null, + 'project_avg' => $sr['project_avg'] ?? null, + 'participation_score' => $sr['participation_score'] ?? null, + 'test_avg' => $sr['test_avg'] ?? null, + 'ptap_score' => $sr['ptap_score'] ?? null, + 'attendance_score' => $sr['attendance_score'] ?? null, + 'midterm_exam_score' => $sr['midterm_exam_score'] ?? null, + 'final_exam_score' => $sr['final_exam_score'] ?? null, + 'semester_score' => $sr['semester_score'] ?? null, + 'comments' => [], + ]; + } + + if (!empty($semesters)) { + $commentRows = $this->db->table('score_comments') + ->select('semester, score_type, comment, created_at') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->where('comment IS NOT NULL', null, false) + ->where('comment !=', '') + ->orderBy('semester', 'ASC') + ->orderBy('score_type', 'ASC') + ->orderBy('created_at', 'DESC') + ->get() + ->getResultArray(); + + foreach ($commentRows as $c) { + $sem = ucfirst(strtolower(trim((string)($c['semester'] ?? '')))); + $type = strtolower(trim((string)($c['score_type'] ?? 'general'))); + if (isset($semesters[$sem]) && !isset($semesters[$sem]['comments'][$type])) { + $semesters[$sem]['comments'][$type] = (string)($c['comment'] ?? ''); + } + } + } + + return array_values($semesters); + } + private function fetchBelowSixtyParentName(int $studentId): string { $parentName = 'Parent/Guardian'; @@ -2124,6 +2193,556 @@ class GradingController extends Controller } + public function belowSixtyDecisions() + { + $configuredSemester = (string) $this->semester; + $configuredYear = (string) $this->schoolYear; + + $semester = trim((string)($this->request->getGet('semester') ?? '')); + $schoolYear = trim((string)($this->request->getGet('school_year') ?? '')); + if ($semester === '') $semester = $configuredSemester !== '' ? $configuredSemester : 'Fall'; + if ($schoolYear === '') $schoolYear = $configuredYear; + + $schoolYears = $this->getSchoolYearsForScores($schoolYear); + $rows = $this->fetchBelowSixtyRows($schoolYear, $semester); + + $decisionModel = new BelowSixtyDecisionModel(); + $studentIds = array_values(array_unique(array_filter( + array_map(static fn($r) => (int)($r['student_id'] ?? 0), $rows), + static fn($id) => $id > 0 + ))); + + $decisionMap = []; + if (!empty($studentIds)) { + $dRows = $decisionModel + ->whereIn('student_id', $studentIds) + ->where('semester', $semester) + ->where('school_year', $schoolYear) + ->findAll(); + foreach ($dRows as $d) { + $decisionMap[(int)$d['student_id']] = $d; + } + } + + foreach ($rows as &$row) { + $sid = (int)($row['student_id'] ?? 0); + $row['decision'] = $decisionMap[$sid]['decision'] ?? ''; + $row['decision_notes'] = $decisionMap[$sid]['notes'] ?? ''; + } + unset($row); + + // Load consolidated decisions from student_decisions for this term + $sdModel = new StudentDecisionModel(); + $sdRows = $sdModel + ->where('semester', $semester) + ->where('school_year', $schoolYear) + ->findAll(); + $sdMap = []; + foreach ($sdRows as $sd) { + $sdMap[(int)$sd['student_id']] = $sd; + } + + // Load the most recent certificate per student for this school_year + $studentIds = array_values(array_unique(array_filter( + array_map(static fn($r) => (int)($r['student_id'] ?? 0), $rows), + static fn($id) => $id > 0 + ))); + $certMap = []; + if (!empty($studentIds)) { + $certRows = $this->db->table('certificate_records') + ->select('student_id, certificate_number, issued_at') + ->where('school_year', $schoolYear) + ->whereIn('student_id', $studentIds) + ->orderBy('issued_at', 'DESC') + ->get()->getResultArray(); + foreach ($certRows as $cr) { + $sid = (int)($cr['student_id'] ?? 0); + if ($sid > 0 && !isset($certMap[$sid])) { + $certMap[$sid] = (string)($cr['certificate_number'] ?? ''); + } + } + } + + foreach ($rows as &$row) { + $sid = (int)($row['student_id'] ?? 0); + $row['consolidated_decision'] = $sdMap[$sid]['decision'] ?? null; + $row['certificate_number'] = $certMap[$sid] ?? ''; + } + unset($row); + + $canViewGrading = $this->userHasMenuUrl('grading'); + + return view('grading/below_sixty_decisions', [ + 'rows' => $rows, + 'semester' => $semester, + 'schoolYear' => $schoolYear, + 'schoolYears' => $schoolYears, + 'canViewGrading' => $canViewGrading, + ]); + } + + public function saveBelowSixtyDecision() + { + $studentId = (int)$this->request->getPost('student_id'); + $semester = trim((string)$this->request->getPost('semester')); + $schoolYear = trim((string)$this->request->getPost('school_year')); + $decision = trim((string)$this->request->getPost('decision')); + $notes = trim((string)$this->request->getPost('notes')); + + if ($studentId <= 0 || $semester === '' || $schoolYear === '') { + return redirect()->back()->with('error', 'Missing required data.'); + } + + $allowed = ['', 'Pass', 'Repeat Class', 'Make-up exam in fall', 'Deferred decision', 'Expel', 'Withdrawn']; + if (!in_array($decision, $allowed, true)) { + return redirect()->back()->with('error', 'Invalid decision value.'); + } + + $decisionModel = new BelowSixtyDecisionModel(); + $existing = $decisionModel + ->where('student_id', $studentId) + ->where('semester', $semester) + ->where('school_year', $schoolYear) + ->first(); + + $userId = (int)(session()->get('user_id') ?? 0) ?: null; + $payload = [ + 'decision' => $decision !== '' ? $decision : null, + 'notes' => $notes !== '' ? $notes : null, + 'decided_by' => $userId, + ]; + + if ($existing) { + $decisionModel->update((int)$existing['id'], $payload); + } else { + $payload['student_id'] = $studentId; + $payload['semester'] = $semester; + $payload['school_year'] = $schoolYear; + $decisionModel->insert($payload); + } + + $query = http_build_query(['semester' => $semester, 'school_year' => $schoolYear]); + return redirect()->to(base_url('grading/below-60/decisions') . ($query ? '?' . $query : '')) + ->with('status', 'Decision saved.'); + } + + public function studentDecisionDetails() + { + $studentId = (int)$this->request->getGet('student_id'); + $schoolYear = trim((string)$this->request->getGet('school_year')); + + if ($studentId <= 0 || $schoolYear === '') { + return $this->response->setJSON(['error' => 'Missing student or school year.'])->setStatusCode(400); + } + + return $this->response->setJSON([ + 'semesters' => $this->fetchAllSemestersForStudent($studentId, $schoolYear), + ]); + } + + public function previewDecisionEmail() + { + $studentId = (int)$this->request->getGet('student_id'); + $semester = trim((string)$this->request->getGet('semester')); + $schoolYear = trim((string)$this->request->getGet('school_year')); + + if ($studentId <= 0 || $semester === '' || $schoolYear === '') { + return $this->response->setJSON(['error' => 'Missing student or term.'])->setStatusCode(400); + } + + $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester); + if (empty($row)) { + return $this->response->setJSON(['error' => 'Student not found.'])->setStatusCode(404); + } + + $decisionModel = new BelowSixtyDecisionModel(); + $decisionRow = $decisionModel + ->where('student_id', $studentId) + ->where('semester', $semester) + ->where('school_year', $schoolYear) + ->first(); + + $decision = (string)($decisionRow['decision'] ?? ''); + $notes = (string)($decisionRow['notes'] ?? ''); + $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? '')); + $parentName = $this->fetchBelowSixtyParentName($studentId); + + $subject = 'Academic Decision'; + if ($studentName !== '') $subject .= ' — ' . $studentName; + if ($semester !== '' || $schoolYear !== '') { + $subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')'; + } + + $scores = [ + 'homework_avg' => $row['homework_avg'] ?? null, + 'project_avg' => $row['project_avg'] ?? null, + 'participation_score' => $row['participation_score'] ?? null, + 'test_avg' => $row['test_avg'] ?? null, + 'ptap_score' => $row['ptap_score'] ?? null, + 'attendance_score' => $row['attendance_score'] ?? null, + 'midterm_exam_score' => $row['midterm_exam_score'] ?? null, + 'semester_score' => $row['semester_score'] ?? null, + ]; + + // Fetch all semesters' scores + comments for the email + $allSemesters = $this->fetchAllSemestersForStudent($studentId, $schoolYear); + + $html = view('emails/below_sixty_decision', [ + 'title' => $subject, + 'parent_name' => $parentName, + 'student_name' => $studentName !== '' ? $studentName : 'your student', + 'class_section_name' => $row['class_section_name'] ?? '', + 'semester' => $semester, + 'school_year' => $schoolYear, + 'decision' => $decision, + 'notes' => $notes, + 'scores' => $scores, + 'all_semesters' => array_values($allSemesters), + ], ['saveData' => true]); + + return $this->response->setJSON([ + 'subject' => $subject, + 'html' => $html, + 'student_id' => $studentId, + ]); + } + + public function editDecisionEmail() + { + $studentId = (int)$this->request->getGet('student_id'); + $semester = trim((string)$this->request->getGet('semester')); + $schoolYear = trim((string)$this->request->getGet('school_year')); + + if ($studentId <= 0 || $semester === '' || $schoolYear === '') { + return redirect()->back()->with('error', 'Missing student or term.'); + } + + $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester); + if (empty($row)) { + return redirect()->back()->with('error', 'Student record not found for the selected term.'); + } + + $decisionModel = new BelowSixtyDecisionModel(); + $decisionRow = $decisionModel + ->where('student_id', $studentId) + ->where('semester', $semester) + ->where('school_year', $schoolYear) + ->first(); + + $decision = (string)($decisionRow['decision'] ?? ''); + $notes = (string)($decisionRow['notes'] ?? ''); + + $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? '')); + $parentName = $this->fetchBelowSixtyParentName($studentId); + + $subject = 'Academic Decision'; + if ($studentName !== '') $subject .= ' — ' . $studentName; + if ($semester !== '' || $schoolYear !== '') { + $subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')'; + } + + $scores = [ + 'homework_avg' => $row['homework_avg'] ?? null, + 'project_avg' => $row['project_avg'] ?? null, + 'participation_score' => $row['participation_score'] ?? null, + 'test_avg' => $row['test_avg'] ?? null, + 'ptap_score' => $row['ptap_score'] ?? null, + 'attendance_score' => $row['attendance_score'] ?? null, + 'midterm_exam_score' => $row['midterm_exam_score'] ?? null, + 'semester_score' => $row['semester_score'] ?? null, + ]; + + $html = view('emails/below_sixty_decision', [ + 'title' => $subject, + 'parent_name' => $parentName, + 'student_name' => $studentName !== '' ? $studentName : 'your student', + 'class_section_name' => $row['class_section_name'] ?? '', + 'semester' => $semester, + 'school_year' => $schoolYear, + 'decision' => $decision, + 'notes' => $notes, + 'scores' => $scores, + ], ['saveData' => true]); + + return view('grading/below_sixty_decision_email_editor', [ + 'studentId' => $studentId, + 'studentName' => $studentName, + 'semester' => $semester, + 'schoolYear' => $schoolYear, + 'subject' => $subject, + 'html' => $html, + 'decision' => $decision, + ]); + } + + public function sendDecisionEmail() + { + $studentId = (int)$this->request->getPost('student_id'); + $semester = trim((string)$this->request->getPost('semester')); + $schoolYear = trim((string)$this->request->getPost('school_year')); + $subjectInput= trim((string)$this->request->getPost('subject')); + $htmlInput = (string)($this->request->getPost('html') ?? ''); + + if ($studentId <= 0 || $semester === '' || $schoolYear === '') { + return redirect()->back()->with('error', 'Missing student or term.'); + } + + $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester); + if (empty($row)) { + return redirect()->back()->with('error', 'Student record not found for the selected term.'); + } + + $decisionModel = new BelowSixtyDecisionModel(); + $decisionRow = $decisionModel + ->where('student_id', $studentId) + ->where('semester', $semester) + ->where('school_year', $schoolYear) + ->first(); + + $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? '')); + $subject = $subjectInput !== '' ? $subjectInput : ('Academic Decision — ' . $studentName . ' (' . trim($semester . ' ' . $schoolYear) . ')'); + + $allSemesters = $this->fetchAllSemestersForStudent($studentId, $schoolYear); + + $payload = [ + 'student_id' => $studentId, + 'student_name' => $studentName, + 'class_section_name' => $row['class_section_name'] ?? '', + 'semester' => $semester, + 'school_year' => $schoolYear, + 'decision' => (string)($decisionRow['decision'] ?? ''), + 'notes' => (string)($decisionRow['notes'] ?? ''), + 'subject' => $subject, + 'all_semesters' => $allSemesters, + 'scores' => [ + 'homework_avg' => $row['homework_avg'] ?? null, + 'project_avg' => $row['project_avg'] ?? null, + 'participation_score' => $row['participation_score'] ?? null, + 'test_avg' => $row['test_avg'] ?? null, + 'ptap_score' => $row['ptap_score'] ?? null, + 'attendance_score' => $row['attendance_score'] ?? null, + 'midterm_exam_score' => $row['midterm_exam_score'] ?? null, + 'semester_score' => $row['semester_score'] ?? null, + ], + ]; + + if (trim($htmlInput) !== '') { + $payload['html'] = $htmlInput; + } + + \CodeIgniter\Events\Events::trigger('below60.decision_email', $payload); + + $query = http_build_query(['semester' => $semester, 'school_year' => $schoolYear]); + return redirect()->to(base_url('grading/below-60/decisions') . ($query ? '?' . $query : '')) + ->with('status', 'Decision email sent to parent(s).'); + } + + public function allDecisions() + { + $configuredSemester = (string)$this->semester; + $configuredYear = (string)$this->schoolYear; + + $semester = trim((string)($this->request->getGet('semester') ?? '')); + $schoolYear = trim((string)($this->request->getGet('school_year') ?? '')); + if ($semester === '') $semester = $configuredSemester !== '' ? $configuredSemester : 'Fall'; + if ($schoolYear === '') $schoolYear = $configuredYear; + + $schoolYears = $this->getSchoolYearsForScores($schoolYear); + + // Load saved decisions for this term + $decModel = new StudentDecisionModel(); + $saved = $decModel + ->where('semester', $semester) + ->where('school_year', $schoolYear) + ->findAll(); + $savedMap = []; + foreach ($saved as $s) { + $savedMap[(int)$s['student_id']] = $s; + } + + // All semester_scores rows for this term (one per student) + $semKey = strtolower(trim($semester)); + $scoreRows = $this->db->table('semester_scores ss') + ->select([ + 's.id AS student_id', + 's.school_id', + 's.firstname', + 's.lastname', + 'cs.class_section_name', + 'ss.semester_score', + ]) + ->join('students s', 's.id = ss.student_id', 'inner') + ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left') + ->where('s.is_active', 1) + ->where('ss.school_year', $schoolYear) + ->where("LOWER(TRIM(ss.semester))", $semKey) + ->where('ss.semester_score IS NOT NULL', null, false) + ->orderBy('cs.class_section_name', 'ASC') + ->orderBy('s.lastname', 'ASC') + ->orderBy('s.firstname', 'ASC') + ->get()->getResultArray(); + + // Pull below-60 decisions + $belowDecModel = new BelowSixtyDecisionModel(); + $belowRows = $belowDecModel + ->where('semester', $semester) + ->where('school_year', $schoolYear) + ->findAll(); + $belowMap = []; + foreach ($belowRows as $b) { + $belowMap[(int)$b['student_id']] = $b; + } + + // Build rows combining live scores with saved/computed decisions + $rows = []; + foreach ($scoreRows as $sr) { + $sid = (int)$sr['student_id']; + $score = is_numeric($sr['semester_score']) ? (float)$sr['semester_score'] : null; + + if (isset($savedMap[$sid])) { + $decision = (string)($savedMap[$sid]['decision'] ?? ''); + $source = (string)($savedMap[$sid]['source'] ?? 'auto'); + $notes = (string)($savedMap[$sid]['notes'] ?? ''); + } elseif ($score !== null && $score >= 60) { + $decision = 'Pass'; + $source = 'auto'; + $notes = ''; + } elseif ($score !== null && isset($belowMap[$sid])) { + $decision = (string)($belowMap[$sid]['decision'] ?? ''); + $source = $decision !== '' ? 'manual' : 'pending'; + $notes = (string)($belowMap[$sid]['notes'] ?? ''); + } else { + $decision = $score !== null ? '' : ''; + $source = 'pending'; + $notes = ''; + } + + $rows[] = [ + 'student_id' => $sid, + 'school_id' => $sr['school_id'] ?? '', + 'firstname' => $sr['firstname'] ?? '', + 'lastname' => $sr['lastname'] ?? '', + 'class_section_name' => $sr['class_section_name'] ?? '', + 'semester_score' => $score, + 'decision' => $decision, + 'source' => $source, + 'notes' => $notes, + 'saved' => isset($savedMap[$sid]), + ]; + } + + $generated = !empty($saved); + + return view('grading/all_decisions', [ + 'rows' => $rows, + 'semester' => $semester, + 'schoolYear' => $schoolYear, + 'schoolYears' => $schoolYears, + 'generated' => $generated, + ]); + } + + public function generateAllDecisions() + { + $semester = trim((string)$this->request->getPost('semester')); + $schoolYear = trim((string)$this->request->getPost('school_year')); + + if ($semester === '' || $schoolYear === '') { + return redirect()->back()->with('error', 'Missing semester or school year.'); + } + + $semKey = strtolower(trim($semester)); + $scoreRows = $this->db->table('semester_scores ss') + ->select([ + 's.id AS student_id', + 's.firstname', + 's.lastname', + 'cs.class_section_name', + 'ss.semester_score', + ]) + ->join('students s', 's.id = ss.student_id', 'inner') + ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left') + ->where('s.is_active', 1) + ->where('ss.school_year', $schoolYear) + ->where("LOWER(TRIM(ss.semester))", $semKey) + ->where('ss.semester_score IS NOT NULL', null, false) + ->get()->getResultArray(); + + if (empty($scoreRows)) { + return redirect()->back()->with('error', 'No semester scores found for this selection.'); + } + + // Pull all below-60 decisions for this term + $belowDecModel = new BelowSixtyDecisionModel(); + $belowRows = $belowDecModel + ->where('semester', $semester) + ->where('school_year', $schoolYear) + ->findAll(); + $belowMap = []; + foreach ($belowRows as $b) { + $belowMap[(int)$b['student_id']] = $b; + } + + // Load existing saved decisions to upsert + $decModel = new StudentDecisionModel(); + $existing = $decModel + ->where('semester', $semester) + ->where('school_year', $schoolYear) + ->findAll(); + $existingMap = []; + foreach ($existing as $e) { + $existingMap[(int)$e['student_id']] = $e; + } + + $userId = (int)(session()->get('user_id') ?? 0) ?: null; + $now = utc_now(); + $saved = 0; + + foreach ($scoreRows as $sr) { + $sid = (int)$sr['student_id']; + $score = is_numeric($sr['semester_score']) ? (float)$sr['semester_score'] : null; + + if ($score === null) continue; + + if ($score >= 60) { + $decision = 'Pass'; + $source = 'auto'; + $notes = null; + } elseif (isset($belowMap[$sid]) && (string)($belowMap[$sid]['decision'] ?? '') !== '') { + $decision = (string)$belowMap[$sid]['decision']; + $source = 'manual'; + $notes = ($belowMap[$sid]['notes'] ?? '') !== '' ? (string)$belowMap[$sid]['notes'] : null; + } else { + $decision = null; + $source = 'pending'; + $notes = null; + } + + $payload = [ + 'student_id' => $sid, + 'semester' => $semester, + 'school_year' => $schoolYear, + 'class_section_name' => $sr['class_section_name'] ?? null, + 'semester_score' => $score, + 'decision' => $decision, + 'source' => $source, + 'notes' => $notes, + 'generated_by' => $userId, + ]; + + if (isset($existingMap[$sid])) { + $decModel->update($existingMap[$sid]['id'], $payload); + } else { + $decModel->insert($payload); + } + $saved++; + } + + $query = http_build_query(['semester' => $semester, 'school_year' => $schoolYear]); + return redirect()->to(base_url('grading/decisions') . '?' . $query) + ->with('status', "Decisions generated for {$saved} students."); + } + public function getScoreComment() { // Get all students for the current semester and school year diff --git a/app/Database/Migrations/2026-05-25-000001_CreateBelowSixtyDecisions.php b/app/Database/Migrations/2026-05-25-000001_CreateBelowSixtyDecisions.php new file mode 100644 index 0000000..7017d42 --- /dev/null +++ b/app/Database/Migrations/2026-05-25-000001_CreateBelowSixtyDecisions.php @@ -0,0 +1,70 @@ +db->tableExists('below_sixty_decisions')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'student_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'semester' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + ], + 'school_year' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + ], + 'decision' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'null' => true, + ], + 'notes' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'decided_by' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addUniqueKey(['student_id', 'semester', 'school_year']); + $this->forge->addKey(['school_year', 'semester']); + $this->forge->createTable('below_sixty_decisions'); + } + + public function down() + { + $this->forge->dropTable('below_sixty_decisions', true); + } +} diff --git a/app/Database/Migrations/2026-05-25-000002_CreateStudentDecisions.php b/app/Database/Migrations/2026-05-25-000002_CreateStudentDecisions.php new file mode 100644 index 0000000..e3ce06d --- /dev/null +++ b/app/Database/Migrations/2026-05-25-000002_CreateStudentDecisions.php @@ -0,0 +1,85 @@ +db->tableExists('student_decisions')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'student_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'semester' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + ], + 'school_year' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + ], + 'class_section_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'null' => true, + ], + 'semester_score' => [ + 'type' => 'DECIMAL', + 'constraint' => '8,2', + 'null' => true, + ], + 'decision' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'null' => true, + ], + 'source' => [ + 'type' => 'ENUM', + 'constraint' => ['auto', 'manual', 'pending'], + 'default' => 'auto', + ], + 'notes' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'generated_by' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addUniqueKey(['student_id', 'semester', 'school_year']); + $this->forge->addKey(['school_year', 'semester']); + $this->forge->createTable('student_decisions'); + } + + public function down() + { + $this->forge->dropTable('student_decisions', true); + } +} diff --git a/app/Listeners/DecisionEmailListener.php b/app/Listeners/DecisionEmailListener.php new file mode 100644 index 0000000..4528941 --- /dev/null +++ b/app/Listeners/DecisionEmailListener.php @@ -0,0 +1,136 @@ +query( + "SELECT u.firstname, u.lastname, u.email, fg.is_primary + FROM family_students fs + JOIN family_guardians fg ON fg.family_id = fs.family_id + JOIN users u ON u.id = fg.user_id + WHERE fs.student_id = ? + AND fg.receive_emails = 1 + AND u.email IS NOT NULL AND u.email != '' + ORDER BY fg.is_primary DESC, u.lastname, u.firstname", + [$studentId] + )->getResultArray(); + + if (empty($rows)) { + log_message('warning', 'DecisionEmail: no guardian emails for student_id=' . $studentId); + return; + } + + $emails = []; + foreach ($rows as $row) { + $em = trim((string)($row['email'] ?? '')); + if ($em !== '') $emails[$em] = true; + } + $emails = array_keys($emails); + + $primary = $rows[0] ?? []; + $parentName = trim((string)($primary['firstname'] ?? '') . ' ' . (string)($primary['lastname'] ?? '')); + + $subject = (string)($payload['subject'] ?? ''); + if ($subject === '') { + $subject = 'Academic Decision'; + if ($studentName !== '') $subject .= ' — ' . $studentName; + if ($semester !== '' || $schoolYear !== '') { + $subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')'; + } + } + + $emailData = [ + 'title' => $subject, + 'parent_name' => $parentName !== '' ? $parentName : 'Parent/Guardian', + 'student_name' => $studentName !== '' ? $studentName : 'your student', + 'class_section_name' => $classSection, + 'semester' => $semester, + 'school_year' => $schoolYear, + 'decision' => $decision, + 'notes' => $notes, + 'scores' => $scores, + 'all_semesters' => $allSemesters, + ]; + + $html = trim((string)($payload['html'] ?? '')); + if ($html === '') { + $html = view('emails/below_sixty_decision', $emailData, ['saveData' => true]); + } + + $okAny = false; + foreach ($emails as $to) { + $ok = self::sendViaEmailService($to, $subject, $html); + if (!$ok) { + $ok = self::sendViaCiEmail($to, $subject, $html); + } + $okAny = $okAny || $ok; + } + + if ($okAny) { + log_message('info', 'DecisionEmail: sent for student_id=' . $studentId); + } else { + log_message('error', 'DecisionEmail: failed for student_id=' . $studentId); + } + } + + protected static function sendViaEmailService(string $to, string $subject, string $html): bool + { + try { + $svc = function_exists('service') ? service('emailService') : null; + if (!$svc && method_exists(Services::class, 'emailService')) { + $svc = Services::emailService(); + } + if (!$svc) return false; + return (bool)$svc->send($to, $subject, $html, 'general'); + } catch (\Throwable $e) { + log_message('debug', 'DecisionEmail sendViaEmailService failed: ' . $e->getMessage()); + return false; + } + } + + protected static function sendViaCiEmail(string $to, string $subject, string $html): bool + { + try { + $email = Services::email(); + $cfg = config('Email'); + $fromEmail = $cfg->fromEmail ?? $cfg->SMTPUser ?? 'no-reply@example.com'; + $fromName = $cfg->fromName ?? 'Al Rahma Sunday School'; + + $email->setTo($to); + $email->setFrom($fromEmail, $fromName); + $email->setSubject($subject); + $email->setMessage($html); + $email->setMailType('html'); + $ok = $email->send(); + if (!$ok) { + $dbg = method_exists($email, 'printDebugger') ? $email->printDebugger(['headers', 'subject']) : 'no debugger'; + log_message('debug', 'DecisionEmail CI send failed: ' . print_r($dbg, true)); + } + return $ok; + } catch (\Throwable $e) { + log_message('debug', 'DecisionEmail sendViaCiEmail exception: ' . $e->getMessage()); + return false; + } + } +} diff --git a/app/Models/BelowSixtyDecisionModel.php b/app/Models/BelowSixtyDecisionModel.php new file mode 100644 index 0000000..0348dfd --- /dev/null +++ b/app/Models/BelowSixtyDecisionModel.php @@ -0,0 +1,24 @@ +extend('layout/management_layout') ?> section('content') ?> -
+
+
-
-
-

Generate Certificates

-
Select a class, choose students, then generate and print their certificates.
-
+ ['label'=>, 'slug'=>, 'csids'=>[]] + +foreach ($statsPerClass as $csid => $cs) { + $name = $cs['name']; + $lower = strtolower(trim($name)); + + if (preg_match('/grade\s*(\d+)/i', $name, $m)) { + $n = (int)$m[1]; + $label = 'Grade ' . $n; + $sortKey = '1_' . str_pad($n, 3, '0', STR_PAD_LEFT); + $slug = 'grade' . $n; + } elseif (strpos($lower, 'kg') !== false || strpos($lower, 'kindergarten') !== false) { + $label = 'KG'; + $sortKey = '0_kg'; + $slug = 'kg'; + } elseif (strpos($lower, 'arabic') !== false) { + $label = 'Arabic'; + $sortKey = '2_arabic'; + $slug = 'arabic'; + } elseif (strpos($lower, 'youth') !== false) { + $label = 'Youth'; + $sortKey = '5_youth'; + $slug = 'youth'; + } else { + $label = $name; + $sortKey = '3_' . $lower; + $slug = preg_replace('/[^a-z0-9]+/', '-', $lower); + } + + if (!isset($gradeGroups[$sortKey])) { + $gradeGroups[$sortKey] = ['label' => $label, 'slug' => $slug, 'csids' => []]; + } + $gradeGroups[$sortKey]['csids'][] = $csid; +} +ksort($gradeGroups); +$gradeKeys = array_keys($gradeGroups); +$defaultKey = $gradeKeys[0] ?? null; + +$decisionBadge = [ + 'Pass' => 'success', + 'Repeat Class' => 'danger', + 'Make-up exam in fall' => 'info', + 'Deferred decision' => 'info', + 'Expel' => 'danger', + 'Withdrawn' => 'secondary', +]; +?> + +

Generate Certificates

+ + +
+
+ + + +
+
+ +getFlashdata('error')): ?> + + - getFlashdata('error')): ?> - - + +
No classes found for .
+ - -
-
Filter Students
-
-
-
-
- - -
-
- - -
-
- -
+ + + + +
+ $group): ?> + +
+ + + + +

+ + +

No active students.

+ + + + + + + + +
+
+ + Students + + + Pass + + + Generated + + + Remaining +
- -
-
- - - -
- - - - -
-
- - Students - - -
-
- - - -
-
- - -
+
+ + +
-
-
- - - - - - - - - - - - - - - - - - - -
Last NameFirst NameGrade / Class
- -
-
+ +
+ + + + + + + + + + + + + $d['decision'], $stuDec); + $hasPending = in_array('', $allDecs, true); + $unique = array_values(array_unique(array_filter($allDecs, fn($d) => $d !== ''))); + $isPass = !empty($stuDec) && !$hasPending && $unique === ['Pass']; + $displayDecs = $isPass ? ['Pass'] : array_values(array_filter($unique, fn($d) => $d !== 'Pass')); + ?> + + + + + + + + + +
+ + First NameLast NameDecisionCertificate No.
+ > + + + + + Pending + + + + + + + + + + + + + +
- -
- + - -
No active students found for the selected class and school year.
- -
Select a class above to load its student roster.
- + + + +
+
+ + +
+
+ +section('scripts') ?> + + + +endSection() ?> + endSection() ?> diff --git a/app/Views/emails/below_sixty_decision.php b/app/Views/emails/below_sixty_decision.php new file mode 100644 index 0000000..57e3b19 --- /dev/null +++ b/app/Views/emails/below_sixty_decision.php @@ -0,0 +1,152 @@ +extend('layout/email_layout') ?> + +section('content') ?> +
+

+ Dear , +

+ +

+ We are writing to share the school's decision regarding + + () + for the school year. +

+ + + + + + + +
Decision
+ + + +

Comments:

+

+ +

+ + + 'Homework Avg', + 'project_avg' => 'Project Avg', + 'participation_score' => 'Participation', + 'test_avg' => 'Test Avg', + 'ptap_score' => 'PTAP Score', + 'attendance_score' => 'Attendance', + 'midterm_exam_score' => 'Midterm Score', + 'final_exam_score' => 'Final Exam', + 'semester_score' => 'Semester Score', + ]; + + $allSemesters = is_array($all_semesters ?? null) ? $all_semesters : []; + $hasSemesters = !empty($allSemesters); + ?> + + +

Score Summary —

+ + + $_) { + $v = $sem[$key] ?? null; + if ($v !== null && $v !== '') { $hasAnyScore = true; break; } + } + ?> +

Semester

+ + + + + + + + + $label): ?> + + + + + + + + + + +
ItemScore
+ +
No scores recorded.
+ 'General', + 'attendance' => 'Attendance', + 'attendance_comment' => 'Attendance', + 'midterm' => 'Midterm', + 'final' => 'Final Exam', + 'ptap' => 'PTAP', + ]; + // Deduplicate by label+text + $seenCmt = []; + $dedupedCmt = []; + foreach ($semComments as $ctype => $ctext) { + $ctext = trim((string)$ctext); + if ($ctext === '') continue; + $clabel = $commentTypeLabels[$ctype] ?? ucfirst($ctype); + $key = $clabel . '|' . $ctext; + if (!isset($seenCmt[$key])) { $seenCmt[$key] = true; $dedupedCmt[] = [$clabel, $ctext]; } + } + ?> + +

Comments

+ +

+ : +

+ + + + + + $v !== null && $v !== ''); + ?> + +

Score Summary:

+ + + + + + + + + $label): ?> + + + + + + + +
ItemScore
+ + + +

+ Please do not hesitate to contact the school if you have any questions or concerns. +

+ +

+ Thank you,
+ Al Rahma Sunday School +

+
+endSection() ?> diff --git a/app/Views/grading/all_decisions.php b/app/Views/grading/all_decisions.php new file mode 100644 index 0000000..ead84af --- /dev/null +++ b/app/Views/grading/all_decisions.php @@ -0,0 +1,197 @@ +extend('layout/management_layout') ?> +section('content') ?> + +
+
+

Student Decisions — All Students

+ + include('partials/academic_filter') ?> + +
+
+ + + Saved + + Not yet generated + +
+ +
+ + getFlashdata('status'))): ?> +
+ getFlashdata('status')) ?> + +
+ + getFlashdata('error'))): ?> +
+ getFlashdata('error')) ?> + +
+ + + +
+ + + + + + Scores ≥ 60 → Pass (auto)  |  + Scores < 60 → pulled from + + Below-60 Decisions + + +
+ + +
No semester scores found for this selection.
+ + + 'success', + 'Repeat Class' => 'danger', + 'Make-up exam in fall' => 'info', + 'Deferred decision' => 'info', + 'Expel' => 'danger', + 'Withdrawn' => 'secondary', + ]; + $sourceBadge = [ + 'auto' => ['bg-success-subtle text-success-emphasis', 'Auto'], + 'manual' => ['bg-primary-subtle text-primary-emphasis', 'Manual'], + 'pending' => ['bg-warning-subtle text-warning-emphasis', 'Pending'], + ]; + + // Stats + $stats = ['Pass' => 0, 'Other' => 0, 'Pending' => 0]; + foreach ($rows as $r) { + if ($r['decision'] === 'Pass') $stats['Pass']++; + elseif ($r['decision'] === '' || $r['source'] === 'pending') $stats['Pending']++; + else $stats['Other']++; + } + ?> + + +
+
+
+
Pass
+
+
+
+
Other decision
+
+
+
+
Pending
+
+
+
+
Total
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
Student NameSectionScoreDecisionSourceNotes
+ + + + + + + + + + + +
+
+ + +
+ $color): ?> + + + — decision colour key +
+ + +
+
+ +endSection() ?> + +section('scripts') ?> + + +endSection() ?> diff --git a/app/Views/grading/below_sixty.php b/app/Views/grading/below_sixty.php index 1e057ee..3691805 100644 --- a/app/Views/grading/below_sixty.php +++ b/app/Views/grading/below_sixty.php @@ -11,13 +11,17 @@
- - - Back to Grading +
+ + Decisions - - You do not have access to the Grading page. - + + + Back to Grading + + +
extend('layout/management_layout') ?> +section('content') ?> + +
+
+
+
+

Send Decision Email

+
+ + +  — + +
+
+ + ← Back to Decisions + +
+ +
+ + + + + + +
+
+ + +
+
+ + +
Review and edit the message before sending.
+ +
+
+ + Cancel + + +
+
+
+
+
+ +endSection() ?> + +section('scripts') ?> + + +endSection() ?> diff --git a/app/Views/grading/below_sixty_decisions.php b/app/Views/grading/below_sixty_decisions.php new file mode 100644 index 0000000..490ba32 --- /dev/null +++ b/app/Views/grading/below_sixty_decisions.php @@ -0,0 +1,534 @@ +extend('layout/management_layout') ?> +section('content') ?> + +
+
+

Below 60 — Decisions

+ + include('partials/academic_filter') ?> + + + + getFlashdata('status'))): ?> + + + getFlashdata('error'))): ?> + + + + '— No decision yet —', + 'Pass' => 'Pass', + 'Repeat Class' => 'Repeat Class', + 'Make-up exam in fall' => 'Make-up exam in fall', + 'Deferred decision' => 'Deferred decision', + 'Expel' => 'Expel', + 'Withdrawn' => 'Withdrawn', + ]; + + $decisionBadge = [ + 'Pass' => 'success', + 'Repeat Class' => 'danger', + 'Make-up exam in fall' => 'info', + 'Deferred decision' => 'info', + 'Expel' => 'danger', + 'Withdrawn' => 'secondary', + ]; + + $displayScore = function ($value) { + if ($value === null || $value === '') return '—'; + if (is_numeric($value)) return esc(number_format((float)$value, 2, '.', '')); + return esc($value); + }; + ?> + + +
+ No students below 60 for this selection. +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Student NameSectionScoreComments / Rationale & DecisionBelow-60 DecisionFinal DecisionCertificate
+
+ +
+
+ + + + + +
+ + +
+
+
+ + + + + Pending + + + + + + Generate + + + + + + + + + +
+
+ +
+ $color): ?> + + + — decision colour key +
+ +
+
+ + + + + + + +endSection() ?> + +section('scripts') ?> + + + +endSection() ?>