db = $db; $this->configModel = $configModel; $this->studentModel = $studentModel; $this->studentClassModel = $studentClassModel; $this->parentMeetingModel = $parentMeetingModel; $this->userModel = $userModel; $this->schoolYear = $schoolYear; $this->semester = $semester; } public function setTerm(string $schoolYear, string $semester): void { $this->schoolYear = $schoolYear; $this->semester = $semester; } public function belowSixtyPage(array $params = []) { $get = $params['get'] ?? []; $post = $params['post'] ?? []; $schoolYear = (string) ($params['school_year'] ?? $this->schoolYear); // This page is Fall only. $semester = 'fall'; $isYearMode = false; $schoolYears = $this->getSchoolYearsForScores($schoolYear); /* * Use your existing below-60 fetcher. * Do NOT query below_sixty_status. That table does not exist. */ $rows = $this->fetchBelowSixtyRows($schoolYear, $semester); /* * Hard guard: * Keep only Fall semester rows with semester_score < 60. * This prevents whole-year rows or accidental other semester rows * from sneaking into this Fall-only page. */ $rows = array_values(array_filter($rows, static function ($row) { $semesterValue = strtolower(trim((string)($row['semester'] ?? 'fall'))); $scoreRaw = $row['semester_score'] ?? null; if ($semesterValue !== '' && $semesterValue !== 'fall') { return false; } if (!is_numeric($scoreRaw)) { return false; } return (float)$scoreRaw < 60; })); foreach ($rows as &$row) { $row['status'] = $row['status'] ?? 'Open'; $row['note'] = $row['note'] ?? ''; } unset($row); $canViewGrading = $this->userHasMenuUrl('grading'); return ['kind' => 'view', 'view' => 'grading/below_sixty', 'data' => [ 'rows' => $rows, 'semester' => $semester, 'schoolYear' => $schoolYear, 'schoolYears' => $schoolYears, 'isYearMode' => $isYearMode, 'canViewGrading' => $canViewGrading, ]]; } public function editBelowSixtyEmail(array $params = []) { $get = $params['get'] ?? []; $post = $params['post'] ?? []; $studentId = (int)($get['student_id'] ?? null); $semester = trim((string)($get['semester'] ?? null)); $schoolYear = trim((string)($get['school_year'] ?? null)); if ($studentId <= 0 || $semester === '' || $schoolYear === '') { return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing student or term.']; } $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester); if (empty($row)) { return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Student record not found for the selected term.']; } $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? '')); $subject = $this->buildBelowSixtySubject($studentName, $semester, $schoolYear); $parentName = $this->fetchBelowSixtyParentName($studentId); $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, ]; $emailData = [ 'title' => $subject, 'parent_name' => $parentName, 'student_name' => $studentName !== '' ? $studentName : 'your student', 'class_section_name' => $row['class_section_name'] ?? '', 'semester' => $semester, 'school_year' => $schoolYear, 'scores' => $scores, 'comment' => $row['comment'] ?? '', 'sent_at' => utc_now(), ]; $html = view('emails/below_sixty_performance', $emailData, ['saveData' => true]); return ['kind' => 'view', 'view' => 'grading/below_sixty_email_editor', 'data' => [ 'studentId' => $studentId, 'studentName' => $studentName, 'semester' => $semester, 'schoolYear' => $schoolYear, 'subject' => $subject, 'html' => $html, ]]; } public function sendBelowSixtyEmail(array $params = []) { $get = $params['get'] ?? []; $post = $params['post'] ?? []; $studentId = (int)($post['student_id'] ?? null); $semester = trim((string)($post['semester'] ?? null)); $schoolYear = trim((string)($post['school_year'] ?? null)); $subjectInput = trim((string)($post['subject'] ?? null)); $htmlInput = (string)(($post['html'] ?? null) ?? ''); if ($studentId <= 0 || $semester === '' || $schoolYear === '') { return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing student or term.']; } $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester); if (empty($row)) { return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Student record not found for the selected term.']; } $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? '')); $subject = $subjectInput !== '' ? $subjectInput : $this->buildBelowSixtySubject($studentName, $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, ]; $payload = [ 'student_id' => $studentId, 'student_name' => $studentName, 'class_section_name' => $row['class_section_name'] ?? '', 'semester' => $semester, 'school_year' => $schoolYear, 'scores' => $scores, 'comment' => $row['comment'] ?? '', 'subject' => $subject, ]; if (trim($htmlInput) !== '') { $payload['html'] = $htmlInput; } Events::trigger('below60.email', $payload); return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'status', 'message' => 'Email sent to parent(s).']; } public function updateBelowSixtyStatus(array $params = []) { $get = $params['get'] ?? []; $post = $params['post'] ?? []; $studentId = (int)($post['student_id'] ?? null); $semester = trim((string)($post['semester'] ?? null)); $schoolYear = trim((string)($post['school_year'] ?? null)); $status = trim((string)($post['status'] ?? null)); $note = trim((string)($post['note'] ?? null)); if ($studentId <= 0 || $semester === '' || $schoolYear === '' || $status === '') { return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing required status data.']; } $status = ucfirst(strtolower($status)); if (!in_array($status, ['Open', 'Closed'], true)) { return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Invalid status.']; } $flagModel = new CurrentFlagModel(); $semKey = strtolower(trim($semester)); $redirectUrl = base_url('grading/below-60'); $query = http_build_query([ 'semester' => $semester, 'school_year' => $schoolYear, ]); if ($query !== '') { $redirectUrl .= '?' . $query; } $existing = $flagModel ->where('student_id', $studentId) ->where('flag', 'grade') ->where('school_year', $schoolYear) ->where('LOWER(TRIM(semester))', $semKey) ->first(); $userId = (int)(session()->get('user_id') ?? 0) ?: null; $now = utc_now(); $ok = true; if ($existing) { $data = [ 'flag_state' => $status, 'flag_datetime' => $now, 'semester' => $semester, 'school_year' => $schoolYear, 'updated_at' => $now, ]; if ($status === 'Open') { $data['updated_by_open'] = $userId; if ($note !== '') { $prev = (string)($existing['open_description'] ?? ''); $data['open_description'] = trim($prev . PHP_EOL . $note); } } else { $data['updated_by_closed'] = $userId; if ($note !== '') { $prev = (string)($existing['close_description'] ?? ''); $data['close_description'] = trim($prev . PHP_EOL . $note); } } $ok = (bool) $flagModel->update((int)$existing['id'], $data); } else { $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester); $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? '')); $grade = (string)($row['class_section_name'] ?? ''); $data = [ 'student_id' => $studentId, 'student_name' => $studentName !== '' ? $studentName : 'Student', 'grade' => $grade, 'flag' => 'grade', 'flag_datetime' => $now, 'flag_state' => $status, 'semester' => $semester, 'school_year' => $schoolYear, 'updated_at' => $now, ]; if ($status === 'Open') { $data['updated_by_open'] = $userId; if ($note !== '') $data['open_description'] = $note; } else { $data['updated_by_closed'] = $userId; if ($note !== '') $data['close_description'] = $note; } $ok = (bool) $flagModel->insert($data); } if (!$ok) { log_message('error', 'updateBelowSixtyStatus failed', [ 'student_id' => $studentId, 'semester' => $semester, 'school_year' => $schoolYear, 'status' => $status, 'errors' => $flagModel->errors(), ]); return ['kind' => 'flash', 'redirect' => $redirectUrl, 'type' => 'error', 'message' => 'Failed to update status.']; } return ['kind' => 'flash', 'redirect' => $redirectUrl, 'type' => 'status', 'message' => 'Status updated.']; } public function scheduleBelowSixtyPage(array $params = []) { $get = $params['get'] ?? []; $post = $params['post'] ?? []; $studentId = (int)(($get['student_id'] ?? null) ?? 0); $semester = trim((string)(($get['semester'] ?? null) ?? '')); $schoolYear = trim((string)(($get['school_year'] ?? null) ?? '')); if ($studentId <= 0 || $semester === '' || $schoolYear === '') { return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing student or term.']; } $context = $this->fetchBelowSixtyMeetingContext($studentId, $schoolYear, $semester); if (empty($context)) { return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Student record not found for the selected term.']; } return ['kind' => 'view', 'view' => 'grading/schedule_meeting', 'data' => [ 'studentId' => $studentId, 'studentName' => $context['student_name'], 'parentName' => $context['parent_name'], 'classSection' => $context['class_section_name'], 'semester' => $semester, 'schoolYear' => $schoolYear, ]]; } public function saveBelowSixtyMeeting(array $params = []) { $get = $params['get'] ?? []; $post = $params['post'] ?? []; $studentId = (int)($post['student_id'] ?? null); $semester = trim((string)($post['semester'] ?? null)); $schoolYear = trim((string)($post['school_year'] ?? null)); $date = trim((string)($post['date'] ?? null)); $time = trim((string)($post['time'] ?? null)); $notes = trim((string)($post['notes'] ?? null)); if ($studentId <= 0 || $semester === '' || $schoolYear === '' || $date === '') { return ['kind' => 'flash', 'redirect' => 'back', 'withInput' => true, 'type' => 'error', 'message' => 'Missing required fields.']; } $context = $this->fetchBelowSixtyMeetingContext($studentId, $schoolYear, $semester); if (empty($context)) { return ['kind' => 'flash', 'redirect' => 'back', 'withInput' => true, 'type' => 'error', 'message' => 'Student record not found for the selected term.']; } $studentName = $context['student_name']; $parentName = $context['parent_name']; $classSection = $context['class_section_name']; $timeLabel = $time !== '' ? (' ' . $time) : ''; $title = 'Parent Meeting: ' . $parentName . ' — ' . $studentName; if ($time !== '') { $title .= ' (' . $time . ')'; } $descriptionParts = []; $descriptionParts[] = 'Student: ' . $studentName; $descriptionParts[] = 'Parent: ' . $parentName; if ($classSection !== '') { $descriptionParts[] = 'Section: ' . $classSection; } $descriptionParts[] = 'Date/Time: ' . $date . $timeLabel; if ($notes !== '') { $descriptionParts[] = 'Notes: ' . $notes; } $description = implode("\n", $descriptionParts); $data = [ 'student_id' => $studentId, 'parent_user_id' => $context['parent_user_id'] ?? null, 'parent_name' => $parentName, 'student_name' => $studentName, 'class_section_name' => $classSection, 'date' => $date, 'time' => $time !== '' ? $time : null, 'notes' => $notes !== '' ? $notes : null, 'semester' => $semester, 'school_year' => $schoolYear, 'status' => 'scheduled', 'created_by' => (int)(session()->get('user_id') ?? 0) ?: null, ]; $ok = $this->parentMeetingModel->insert($data); if ($ok) { $query = http_build_query([ 'semester' => $semester, 'school_year' => $schoolYear, ]); return ['kind' => 'flash', 'redirect' => base_url('grading/below-60') . ($query ? ('?' . $query) : ''), 'type' => 'status', 'message' => 'Meeting scheduled and added to calendars.']; } return ['kind' => 'flash', 'redirect' => 'back', 'withInput' => true, 'type' => 'error', 'message' => 'Failed to schedule the meeting.']; } public function belowSixtyDecisionsPage(array $params = []) { $get = $params['get'] ?? []; $post = $params['post'] ?? []; $configuredYear = (string) ($params['school_year'] ?? $this->schoolYear); $isReadonly = (bool) ($params['is_readonly'] ?? false); $schoolYear = trim((string)(($get['school_year'] ?? null) ?? '')); if ($schoolYear === '') { $schoolYear = $configuredYear; } // This page is whole-year only. $semester = 'year'; $schoolYears = $this->getSchoolYearsForScores($schoolYear); $db = $this->db; /* * Whole-year score source: * Fall semester_score + Spring semester_score / 2 * * Only students with BOTH Fall and Spring scores are included. * Only students with year_score < 60 are listed. */ $scoreRows = $db->table('semester_scores ss') ->select([ 's.id AS student_id', 's.firstname', 's.lastname', 's.age', 's.school_id', 's.is_active', 'cs.class_section_name', 'e.enrollment_status', 'e.is_withdrawn', 'LOWER(TRIM(ss.semester)) AS sem_key', 'ss.semester_score', ]) ->join('students s', 's.id = ss.student_id', 'inner') ->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $this->db->escape($schoolYear), 'left') ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left') ->groupStart() ->where('s.is_active', 1) ->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false) ->orWhere('e.is_withdrawn', 1) ->groupEnd() ->where('ss.school_year', $schoolYear) ->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring']) ->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(); $studentMap = []; foreach ($scoreRows as $row) { $sid = (int)($row['student_id'] ?? 0); if ($sid <= 0) { continue; } if (!isset($studentMap[$sid])) { $studentMap[$sid] = [ 'student_id' => $sid, 'school_id' => $row['school_id'] ?? '', 'firstname' => $row['firstname'] ?? '', 'lastname' => $row['lastname'] ?? '', 'age' => $row['age'] ?? null, 'class_section_name' => $row['class_section_name'] ?? '', 'fall_score' => null, 'spring_score' => null, 'year_score' => null, ]; } $semKey = strtolower(trim((string)($row['sem_key'] ?? ''))); $score = is_numeric($row['semester_score']) ? (float)$row['semester_score'] : null; if ($score === null) { continue; } if ($semKey === 'fall') { $studentMap[$sid]['fall_score'] = $score; } elseif ($semKey === 'spring') { $studentMap[$sid]['spring_score'] = $score; } } $rows = []; foreach ($studentMap as $sid => $student) { $fall = $student['fall_score']; $spring = $student['spring_score']; // Whole-year result requires both semesters. if ($fall === null || $spring === null) { continue; } $yearScore = round(($fall + $spring) / 2, 2); if ($yearScore >= 60) { continue; } $student['year_score'] = $yearScore; $rows[$sid] = $student; } $studentIds = array_keys($rows); /* * Load saved below-60 manual decisions. * These are year-level decisions now. */ $decisionMap = []; if (!empty($studentIds)) { $belowDecModel = new BelowSixtyDecisionModel(); $decisionRows = $belowDecModel ->whereIn('student_id', $studentIds) ->where('semester', 'year') ->where('school_year', $schoolYear) ->findAll(); foreach ($decisionRows as $d) { $sid = (int)($d['student_id'] ?? 0); if ($sid > 0) { $decisionMap[$sid] = $d; } } } foreach ($rows as $sid => &$row) { $row['decision'] = $decisionMap[$sid]['decision'] ?? ''; $row['decision_notes'] = $decisionMap[$sid]['notes'] ?? ''; } unset($row); /* * Load final generated whole-year decisions from student_decisions. * This table is now year-based, so do NOT filter by semester. */ $finalDecisionMap = []; if (!empty($studentIds)) { $finalRows = $db->table('student_decisions') ->whereIn('student_id', $studentIds) ->where('school_year', $schoolYear) ->get() ->getResultArray(); foreach ($finalRows as $fr) { $sid = (int)($fr['student_id'] ?? 0); if ($sid > 0) { $finalDecisionMap[$sid] = $fr; } } } foreach ($rows as $sid => &$row) { $row['consolidated_decision'] = $finalDecisionMap[$sid]['decision'] ?? null; if ( isset($finalDecisionMap[$sid]['year_score']) && $finalDecisionMap[$sid]['year_score'] !== '' && is_numeric($finalDecisionMap[$sid]['year_score']) ) { $row['year_score'] = round((float)$finalDecisionMap[$sid]['year_score'], 2); } } unset($row); /* * Load certificate numbers. */ $certMap = []; if (!empty($studentIds)) { $certRows = $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 $sid => &$row) { $row['certificate_number'] = $certMap[$sid] ?? ''; } unset($row); // Re-index for the view. $rows = array_values($rows); $canViewGrading = $this->userHasMenuUrl('grading'); return ['kind' => 'view', 'view' => 'grading/below_sixty_decisions', 'data' => [ 'rows' => $rows, 'semester' => $semester, 'schoolYear' => $schoolYear, 'schoolYears' => $schoolYears, 'canViewGrading' => $canViewGrading, 'isEditable' => ! $isReadonly, ]]; } public function saveBelowSixtyDecision(array $params = []) { $get = $params['get'] ?? []; $post = $params['post'] ?? []; $configuredYear = (string) ($params['school_year'] ?? $this->schoolYear); $studentId = (int)(($post['student_id'] ?? null) ?? 0); $semester = strtolower(trim((string)(($post['semester'] ?? null) ?? 'year'))); $schoolYear = trim((string)(($post['school_year'] ?? null) ?? '')); $decision = trim((string)(($post['decision'] ?? null) ?? '')); $notes = trim((string)(($post['notes'] ?? null) ?? '')); if ($studentId <= 0 || $schoolYear === '') { return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing student or school year.']; } if ($schoolYear !== $configuredYear) { return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Selected school year does not match the submitted decision.']; } $schoolYearContext = service('schoolYearContext')->forYearName($schoolYear); service('schoolYearWriteGuard')->assertWritable($schoolYearContext); // This decision page should feed certificate decisions as whole-year decisions. // Force year mode here so certificate logic receives final year decision. $semester = 'year'; $db = \Config\Database::connect(); /* * 1. Save/update the manual decision in below_sixty_decisions. * This keeps your below-60 page history working. */ $belowModel = new \App\Models\BelowSixtyDecisionModel(); $existingBelow = $belowModel ->where('student_id', $studentId) ->where('semester', $semester) ->where('school_year', $schoolYear) ->first(); $belowPayload = [ 'student_id' => $studentId, 'semester' => $semester, 'school_year' => $schoolYear, 'decision' => $decision, 'notes' => $notes, ]; if ($existingBelow) { $belowModel->update((int)$existingBelow['id'], $belowPayload); } else { $belowModel->insert($belowPayload); } /* * 2. Calculate the student's whole-year score. * Certificate page uses student_decisions.year_score. */ $scoreRows = $db->table('semester_scores ss') ->select([ 'LOWER(TRIM(ss.semester)) AS sem_key', 'ss.semester_score', 'cs.class_section_name', ]) ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left') ->where('ss.student_id', $studentId) ->where('ss.school_year', $schoolYear) ->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring']) ->where('ss.semester_score IS NOT NULL', null, false) ->orderBy('ss.updated_at', 'DESC') ->orderBy('ss.id', 'DESC') ->get() ->getResultArray(); $fallScore = null; $springScore = null; $classSectionName = null; foreach ($scoreRows as $sr) { $semKey = strtolower(trim((string)($sr['sem_key'] ?? ''))); $score = is_numeric($sr['semester_score'] ?? null) ? (float)$sr['semester_score'] : null; if ($classSectionName === null && !empty($sr['class_section_name'])) { $classSectionName = (string)$sr['class_section_name']; } if ($semKey === 'fall' && $fallScore === null) { $fallScore = $score; } if ($semKey === 'spring' && $springScore === null) { $springScore = $score; } } if ($fallScore !== null && $springScore !== null) { $yearScore = round(($fallScore + $springScore) / 2, 2); } elseif ($fallScore !== null) { $yearScore = round($fallScore, 2); } elseif ($springScore !== null) { $yearScore = round($springScore, 2); } else { $yearScore = null; } /* * 3. Sync into student_decisions. * This is the part your certificate page needs. * * student_decisions is now year-based: * - no semester * - no semester_score * - uses year_score */ $source = $decision === '' ? 'pending' : 'manual'; $studentDecisionPayload = [ 'student_id' => $studentId, 'school_year' => $schoolYear, 'class_section_name' => $classSectionName, 'year_score' => $yearScore, 'decision' => $decision !== '' ? $decision : null, 'source' => $source, 'notes' => $notes !== '' ? $notes : null, 'generated_by' => (int)(session()->get('user_id') ?? 0) ?: null, ]; $existingStudentDecision = $db->table('student_decisions') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->get() ->getRowArray(); if ($existingStudentDecision) { $db->table('student_decisions') ->where('id', (int)$existingStudentDecision['id']) ->update($studentDecisionPayload); } else { $db->table('student_decisions') ->insert($studentDecisionPayload); } $query = http_build_query([ 'semester' => 'year', 'school_year' => $schoolYear, ]); return ['kind' => 'flash', 'redirect' => base_url('grading/below-60/decisions') . '?' . $query, 'type' => 'status', 'message' => 'Decision saved and certificate decision updated.']; } public function studentDecisionDetails(array $params = []) { $get = $params['get'] ?? []; $post = $params['post'] ?? []; $studentId = (int)($get['student_id'] ?? null); $schoolYear = trim((string)($get['school_year'] ?? null)); if ($studentId <= 0 || $schoolYear === '') { return ['kind' => 'json', 'status' => 400, 'data' => ['error' => 'Missing student or school year.']]; } return ['kind' => 'json', 'status' => 200, 'data' => [ 'semesters' => $this->fetchAllSemestersForStudent($studentId, $schoolYear), ]]; } public function previewBelowSixtyDecisionEmail(array $params = []) { $get = $params['get'] ?? []; $post = $params['post'] ?? []; $studentId = (int)(($get['student_id'] ?? null) ?? 0); $schoolYear = trim((string)(($get['school_year'] ?? null) ?? '')); if ($studentId <= 0 || $schoolYear === '') { return ['kind' => 'json', 'status' => 200, 'data' => [ 'error' => 'Missing student or school year.', ]]; } /* * Whole-year decision email. * Do NOT query semester_scores.semester = "year". * The Details button uses fetchAllSemestersForStudent(), so this email does too. */ $context = $this->getBelowSixtyDecisionEmailContext($studentId, $schoolYear); if (empty($context['student'])) { return ['kind' => 'json', 'status' => 200, 'data' => [ 'error' => 'Student not found.', ]]; } if (empty($context['decision_row']) || trim((string)($context['decision_row']['decision'] ?? '')) === '') { return ['kind' => 'json', 'status' => 200, 'data' => [ 'error' => 'No saved decision found for this student. Save a decision first.', ]]; } $studentName = (string)($context['student_name'] ?? 'Student'); $classSectionName = (string)($context['class_section_name'] ?? ''); $decisionRow = $context['decision_row']; $decision = trim((string)($decisionRow['decision'] ?? '')); $notes = trim((string)($decisionRow['notes'] ?? '')); $fallScore = $context['fall_score'] ?? null; $springScore = $context['spring_score'] ?? null; $yearScore = $context['year_score'] ?? null; /* * Same data used by the Details modal. */ $allSemesters = $context['all_semesters'] ?? []; if (empty($allSemesters)) { $allSemesters = $this->fetchAllSemestersForStudent($studentId, $schoolYear); } $fallText = $fallScore !== null ? number_format((float)$fallScore, 2) : 'N/A'; $springText = $springScore !== null ? number_format((float)$springScore, 2) : 'N/A'; $yearText = $yearScore !== null ? number_format((float)$yearScore, 2) : 'N/A'; $subject = 'Whole Year Academic Decision — ' . $studentName . ' (' . $schoolYear . ')'; $html = '

Dear Parent/Guardian,

This message is regarding ' . esc($studentName) . ' for the ' . esc($schoolYear) . ' school year.

Class: ' . esc($classSectionName !== '' ? $classSectionName : 'N/A') . '
Fall Score: ' . esc($fallText) . '
Spring Score: ' . esc($springText) . '
Whole-Year Score: ' . esc($yearText) . '
Decision: ' . esc($decision) . '

'; if ($notes !== '') { $html .= '

Decision Notes:
' . nl2br(esc($notes)) . '

'; } /* * Add the exact Details-button score/comment content into the email. */ $html .= $this->buildDecisionEmailDetailsHtml($allSemesters); $html .= '

Please contact the school administration if you have any questions.

Regards,
Al Rahma Sunday School

'; return ['kind' => 'json', 'status' => 200, 'data' => [ 'ok' => true, 'subject' => $subject, 'html' => $html, 'decision' => $decision, 'score' => $yearScore, ]]; } public function sendBelowSixtyDecisionEmail(array $params = []) { $get = $params['get'] ?? []; $post = $params['post'] ?? []; $studentId = (int)(($post['student_id'] ?? null) ?? 0); $schoolYear = trim((string)(($post['school_year'] ?? null) ?? '')); $subjectInput = trim((string)(($post['subject'] ?? null) ?? '')); $htmlInput = (string)(($post['html'] ?? null) ?? ''); if ($studentId <= 0 || $schoolYear === '') { return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing student or school year.']; } /* * Whole-year decision email. * Do NOT use sendBelowSixtyEmail(), because that one is semester-score based. */ $semester = 'year'; $context = $this->getBelowSixtyDecisionEmailContext($studentId, $schoolYear); if (empty($context['student'])) { return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Student not found.']; } if (empty($context['decision_row']) || trim((string)($context['decision_row']['decision'] ?? '')) === '') { return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'No saved decision found for this student.']; } $studentName = $context['student_name']; $classSectionName = $context['class_section_name']; $decisionRow = $context['decision_row']; $subject = $subjectInput !== '' ? $subjectInput : 'Whole Year Academic Decision — ' . $studentName . ' (' . $schoolYear . ')'; $payload = [ 'student_id' => $studentId, 'student_name' => $studentName, 'class_section_name' => $classSectionName, 'semester' => 'year', 'school_year' => $schoolYear, 'decision' => (string)($decisionRow['decision'] ?? ''), 'notes' => (string)($decisionRow['notes'] ?? ''), 'subject' => $subject, 'scores' => [ 'fall_score' => $context['fall_score'], 'spring_score' => $context['spring_score'], 'year_score' => $context['year_score'], ], 'all_semesters' => $context['all_semesters'], ]; if (trim($htmlInput) !== '') { $payload['html'] = $htmlInput; } Events::trigger('below60.decision_email', $payload); $query = http_build_query([ 'semester' => 'year', 'school_year' => $schoolYear, ]); return ['kind' => 'flash', 'redirect' => base_url('grading/below-60/decisions') . '?' . $query, 'type' => 'status', 'message' => 'Decision email sent to parent(s).']; } public function fetchBelowSixtyRows(string $schoolYear, string $semester): array { $isYearMode = strtolower(trim($semester)) === 'year'; if ($isYearMode) { $rows = $this->db->table('semester_scores ss') ->select('s.id AS student_id') ->select('s.school_id') ->select('s.firstname') ->select('s.lastname') ->select('MAX(s.is_active) AS is_active', false) ->select('cs.class_section_name') ->select("'year' AS semester", false) ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.homework_avg END) AS fall_homework_avg", false) ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.homework_avg END) AS spring_homework_avg", false) ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.project_avg END) AS fall_project_avg", false) ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.project_avg END) AS spring_project_avg", false) ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.participation_score END) AS fall_participation_score", false) ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.participation_score END) AS spring_participation_score", false) ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN COALESCE(ss.test_avg, ss.quiz_avg) END) AS fall_test_avg", false) ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN COALESCE(ss.test_avg, ss.quiz_avg) END) AS spring_test_avg", false) ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.ptap_score END) AS fall_ptap_score", false) ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.ptap_score END) AS spring_ptap_score", false) ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.attendance_score END) AS fall_attendance_score", false) ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.attendance_score END) AS spring_attendance_score", false) ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.midterm_exam_score END) AS fall_midterm_exam_score", false) ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.midterm_exam_score END) AS spring_midterm_exam_score", false) ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.final_exam_score END) AS fall_final_exam_score", false) ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.final_exam_score END) AS spring_final_exam_score", false) ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.semester_score END) AS fall_score", false) ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.semester_score END) AS spring_score", false) ->select("( MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.semester_score END) + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.semester_score END) ) / 2 AS semester_score", false) ->select("( MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.homework_avg END) + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.homework_avg END) ) / 2 AS homework_avg", false) ->select("( MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.project_avg END) + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.project_avg END) ) / 2 AS project_avg", false) ->select("( MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.participation_score END) + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.participation_score END) ) / 2 AS participation_score", false) ->select("( MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN COALESCE(ss.test_avg, ss.quiz_avg) END) + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN COALESCE(ss.test_avg, ss.quiz_avg) END) ) / 2 AS test_avg", false) ->select("( MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.ptap_score END) + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.ptap_score END) ) / 2 AS ptap_score", false) ->select("( MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.attendance_score END) + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.attendance_score END) ) / 2 AS attendance_score", false) ->select("( MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.midterm_exam_score END) + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.midterm_exam_score END) ) / 2 AS midterm_exam_score", false) ->select("( MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.final_exam_score END) + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.final_exam_score END) ) / 2 AS final_exam_score", false) ->select('MAX(e.enrollment_status) AS enrollment_status, MAX(e.is_withdrawn) AS is_withdrawn', false) ->join('students s', 's.id = ss.student_id', 'inner') ->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $this->db->escape($schoolYear), 'left') ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left') ->groupStart() ->where('s.is_active', 1) ->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false) ->orWhere('e.is_withdrawn', 1) ->groupEnd() ->where('ss.school_year', $schoolYear) ->where('ss.semester_score IS NOT NULL', null, false) ->where("LOWER(TRIM(ss.semester)) IN ('fall', 'spring')", null, false) ->groupBy('s.id, s.school_id, s.firstname, s.lastname, ss.class_section_id, cs.class_section_name') ->having('fall_score IS NOT NULL', null, false) ->having('spring_score IS NOT NULL', null, false) ->having('semester_score <', 60) ->orderBy('s.lastname', 'ASC') ->orderBy('s.firstname', 'ASC') ->get() ->getResultArray(); foreach ($rows as &$row) { $row['comment'] = ''; $row['status'] = 'Open'; $row['note'] = ''; } unset($row); return $rows; } $semesterKey = 'fall'; $builder = $this->db->table('semester_scores ss') ->select([ 's.id AS student_id', 's.school_id', 's.firstname', 's.lastname', 's.is_active', 'cs.class_section_name', 'ss.semester', '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', 'e.enrollment_status', 'e.is_withdrawn', ]) ->join('students s', 's.id = ss.student_id', 'inner') ->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $this->db->escape($schoolYear), 'left') ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left') ->groupStart() ->where('s.is_active', 1) ->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false) ->orWhere('e.is_withdrawn', 1) ->groupEnd() ->where('ss.school_year', $schoolYear) ->where('ss.semester_score IS NOT NULL', null, false) ->where('ss.semester_score <', 60) ->where("LOWER(TRIM(ss.semester))", $semesterKey); $rows = $builder ->orderBy('s.lastname', 'ASC') ->orderBy('s.firstname', 'ASC') ->orderBy('ss.semester', 'ASC') ->get() ->getResultArray(); if (empty($rows)) { return []; } $studentIds = array_values(array_unique(array_map( static fn($r) => (int)($r['student_id'] ?? 0), $rows ))); $studentIds = array_values(array_filter($studentIds, static fn($id) => $id > 0)); $commentMap = []; if (!empty($studentIds)) { $commentRows = $this->db->table('score_comments') ->select('student_id, semester, comment, created_at') ->where('score_type', 'general') ->where('school_year', $schoolYear) ->whereIn('student_id', $studentIds) ->where("LOWER(TRIM(semester))", $semesterKey) ->orderBy('created_at', 'DESC') ->get() ->getResultArray(); foreach ($commentRows as $row) { $sid = (int)($row['student_id'] ?? 0); $sem = strtolower(trim((string)($row['semester'] ?? ''))); $key = $sid . '_' . $sem; if ($sid > 0 && !isset($commentMap[$key])) { $commentMap[$key] = (string)($row['comment'] ?? ''); } } } $statusMap = []; $noteMap = []; if (!empty($studentIds)) { $flagRows = $this->db->table('current_flag') ->select('student_id, semester, flag_state, open_description, close_description') ->where('flag', 'grade') ->where('school_year', $schoolYear) ->whereIn('student_id', $studentIds) ->where("LOWER(TRIM(semester))", $semesterKey) ->get() ->getResultArray(); foreach ($flagRows as $row) { $sid = (int)($row['student_id'] ?? 0); if ($sid <= 0) continue; $sem = strtolower(trim((string)($row['semester'] ?? ''))); $key = $sid . '_' . $sem; $statusMap[$key] = (string)($row['flag_state'] ?? ''); $openNote = trim((string)($row['open_description'] ?? '')); $closeNote = trim((string)($row['close_description'] ?? '')); $noteMap[$key] = [ 'open' => $openNote, 'closed' => $closeNote, ]; } } foreach ($rows as &$row) { $sid = (int)($row['student_id'] ?? 0); $sem = strtolower(trim((string)($row['semester'] ?? ''))); $key = $sid . '_' . $sem; $row['comment'] = $commentMap[$key] ?? ''; $flagState = strtolower(trim((string)($statusMap[$key] ?? ''))); $row['status'] = ($flagState === 'closed' || $flagState === 'canceled') ? 'Closed' : 'Open'; $noteBag = $noteMap[$key] ?? ['open' => '', 'closed' => '']; $rawNote = $row['status'] === 'Closed' ? (string)$noteBag['closed'] : (string)$noteBag['open']; if ($rawNote !== '') { $lines = preg_split('/\R/', $rawNote); $lines = array_values(array_filter(array_map('trim', $lines), static fn($val) => $val !== '')); $row['note'] = $lines ? end($lines) : ''; } else { $row['note'] = ''; } } unset($row); return $rows; } public function fetchBelowSixtyEmailRow(int $studentId, string $schoolYear, string $semester): array { $semesterKey = strtolower(trim($semester)); $row = $this->db->table('semester_scores ss') ->select([ 's.id AS student_id', 's.firstname', 's.lastname', '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.semester_score', ]) ->join('students s', 's.id = ss.student_id', 'inner') ->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $this->db->escape($schoolYear), 'left') ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left') ->where('ss.school_year', $schoolYear) ->where('ss.student_id', $studentId) ->groupStart() ->where('s.is_active', 1) ->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false) ->orWhere('e.is_withdrawn', 1) ->groupEnd() ->where("LOWER(TRIM(ss.semester))", $semesterKey) ->get() ->getRowArray(); if (!$row) return []; $commentRow = $this->db->table('score_comments') ->select('comment') ->where('score_type', 'general') ->where('school_year', $schoolYear) ->where("LOWER(TRIM(semester))", $semesterKey) ->where('student_id', $studentId) ->orderBy('created_at', 'DESC') ->get() ->getRowArray(); $row['comment'] = (string)($commentRow['comment'] ?? ''); return $row; } public 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); } public function fetchBelowSixtyParentName(int $studentId): string { $parentName = 'Parent/Guardian'; try { $rows = $this->db->query( "SELECT u.firstname, u.lastname 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 = ? ORDER BY fg.is_primary DESC, u.lastname, u.firstname LIMIT 1", [$studentId] )->getResultArray(); if (!empty($rows[0])) { $candidate = trim((string)($rows[0]['firstname'] ?? '') . ' ' . (string)($rows[0]['lastname'] ?? '')); if ($candidate !== '') { $parentName = $candidate; } } } catch (\Throwable $e) { } return $parentName; } public function buildBelowSixtySubject(string $studentName, string $semester, string $schoolYear): string { $subject = 'Student Performance Alert'; if ($studentName !== '') { $subject .= ' — ' . $studentName; } if ($semester !== '' || $schoolYear !== '') { $subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')'; } return $subject; } public function fetchBelowSixtyMeetingContext(int $studentId, string $schoolYear, string $semester): array { $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester); if (empty($row)) { return []; } $db = $this->db; $parentName = 'Parent/Guardian'; $parentUserId = null; try { $pRows = $db->query( "SELECT u.id AS user_id, u.firstname, u.lastname 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 = ? ORDER BY fg.is_primary DESC, u.lastname, u.firstname LIMIT 1", [$studentId] )->getResultArray(); if (!empty($pRows[0])) { $parentUserId = (int)($pRows[0]['user_id'] ?? 0) ?: null; $parentName = trim((string)($pRows[0]['firstname'] ?? '') . ' ' . (string)($pRows[0]['lastname'] ?? '')); if ($parentName === '') { $parentName = 'Parent/Guardian'; } } } catch (\Throwable $e) { } // Legacy fallback: students.parent_id if ($parentUserId === null) { try { $srow = $db->query( "SELECT s.parent_id, u.firstname, u.lastname FROM students s LEFT JOIN users u ON u.id = s.parent_id WHERE s.id = ? LIMIT 1", [$studentId] )->getRowArray(); if (!empty($srow)) { $parentUserId = (int)($srow['parent_id'] ?? 0) ?: null; $fallbackName = trim((string)($srow['firstname'] ?? '') . ' ' . (string)($srow['lastname'] ?? '')); if ($fallbackName !== '') { $parentName = $fallbackName; } } } catch (\Throwable $e) { } } $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? '')); return [ 'student_name' => $studentName !== '' ? $studentName : 'Student', 'parent_name' => $parentName, 'parent_user_id' => $parentUserId, 'class_section_name' => (string)($row['class_section_name'] ?? ''), ]; } public function buildDecisionEmailDetailsHtml(array $semesters): string { if (empty($semesters)) { return '

Detailed Scores and Comments

No detailed score data found.

'; } $scoreLabels = [ 'homework_avg' => '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', ]; $commentTypeLabels = [ 'general' => 'General', 'attendance' => 'Attendance', 'attendance_comment' => 'Attendance', 'midterm' => 'Midterm', 'final' => 'Final Exam', 'ptap' => 'PTAP', ]; $html = '

Detailed Scores and Comments

'; foreach ($semesters as $sem) { $semesterName = trim((string)($sem['semester'] ?? '')); if ($semesterName === '') { $semesterName = 'Semester'; } $classSectionName = trim((string)($sem['class_section_name'] ?? '')); $html .= '

' . esc($semesterName) . ' Semester'; if ($classSectionName !== '') { $html .= ' — ' . esc($classSectionName); } $html .= '

'; $hasScoreRow = false; foreach ($scoreLabels as $key => $label) { if (!array_key_exists($key, $sem)) { continue; } $value = $sem[$key]; if ($value === null || $value === '') { continue; } $scoreText = is_numeric($value) ? number_format((float)$value, 2) : (string)$value; $fontWeight = $key === 'semester_score' ? 'font-weight:bold;' : ''; $html .= ' '; $hasScoreRow = true; } if (!$hasScoreRow) { $html .= ' '; } $html .= '
Item Score
' . esc($label) . ' ' . esc($scoreText) . '
No scores recorded.
'; $comments = $sem['comments'] ?? []; if (is_array($comments) && !empty($comments)) { $deduped = []; $seen = []; foreach ($comments as $type => $text) { $text = trim((string)$text); if ($text === '') { continue; } $label = $commentTypeLabels[$type] ?? (string)$type; $key = $label . '|' . $text; if (isset($seen[$key])) { continue; } $seen[$key] = true; $deduped[] = [ 'label' => $label, 'text' => $text, ]; } if (!empty($deduped)) { $html .= '

Comments

'; foreach ($deduped as $comment) { $html .= '

' . esc($comment['label']) . ':
' . nl2br(esc($comment['text'])) . '

'; } } } } return $html; } public function getBelowSixtyDecisionEmailContext(int $studentId, string $schoolYear): array { $student = $this->db->table('students s') ->select([ 's.id', 's.firstname', 's.lastname', 's.is_active', ]) ->where('s.id', $studentId) ->get() ->getRowArray(); /* * Do not require is_active = 1 here. * You were getting "Student not found" even though the student ID exists. * If the record exists, let the email preview work. */ if (!$student) { return [ 'student' => null, 'decision_row' => null, 'student_name' => '', 'class_section_name' => '', 'fall_score' => null, 'spring_score' => null, 'year_score' => null, 'all_semesters' => [], ]; } $studentName = trim((string)($student['firstname'] ?? '') . ' ' . (string)($student['lastname'] ?? '')); if ($studentName === '') { $studentName = 'Student'; } $decisionRow = $this->db->table('below_sixty_decisions') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->where('semester', 'year') ->get() ->getRowArray(); $scoreRows = $this->db->table('semester_scores ss') ->select([ 'LOWER(TRIM(ss.semester)) AS sem_key', 'ss.semester', 'ss.semester_score', 'ss.class_section_id', 'cs.class_section_name', ]) ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left') ->where('ss.student_id', $studentId) ->where('ss.school_year', $schoolYear) ->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring']) ->where('ss.semester_score IS NOT NULL', null, false) ->orderBy('ss.updated_at', 'DESC') ->orderBy('ss.id', 'DESC') ->get() ->getResultArray(); $fallScore = null; $springScore = null; $classSectionName = ''; foreach ($scoreRows as $sr) { $semKey = strtolower(trim((string)($sr['sem_key'] ?? ''))); $score = is_numeric($sr['semester_score'] ?? null) ? (float)$sr['semester_score'] : null; if ($classSectionName === '' && !empty($sr['class_section_name'])) { $classSectionName = (string)$sr['class_section_name']; } if ($score === null) { continue; } if ($semKey === 'fall' && $fallScore === null) { $fallScore = $score; } if ($semKey === 'spring' && $springScore === null) { $springScore = $score; } } if ($classSectionName === '') { $enrollment = $this->db->table('student_class sc') ->select('cs.class_section_name') ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') ->where('sc.student_id', $studentId) ->where('sc.school_year', $schoolYear) ->orderBy('sc.id', 'DESC') ->get() ->getRowArray(); $classSectionName = (string)($enrollment['class_section_name'] ?? ''); } if ($fallScore !== null && $springScore !== null) { $yearScore = round(($fallScore + $springScore) / 2, 2); } elseif ($fallScore !== null) { $yearScore = round($fallScore, 2); } elseif ($springScore !== null) { $yearScore = round($springScore, 2); } else { $yearScore = null; } return [ 'student' => $student, 'decision_row' => $decisionRow, 'student_name' => $studentName, 'class_section_name' => $classSectionName, 'fall_score' => $fallScore, 'spring_score' => $springScore, 'year_score' => $yearScore, 'all_semesters' => $this->fetchAllSemestersForStudent($studentId, $schoolYear), ]; } public function normalizeSemesterInput(?string $semester): ?string { if (!is_string($semester)) { return null; } $trimmed = trim($semester); return $trimmed === '' ? null : $trimmed; } public function resolveSemesterSelection(?string $requestedSemester, array $semesterOptions, ?string $fallbackSemester): string { if ($requestedSemester !== null) { foreach ($semesterOptions as $option) { if (strcasecmp($option, $requestedSemester) === 0) { return $option; } } return $requestedSemester; } if ($fallbackSemester !== null && $fallbackSemester !== '') { foreach ($semesterOptions as $option) { if (strcasecmp($option, $fallbackSemester) === 0) { return $option; } } return $fallbackSemester; } return $semesterOptions[0] ?? ''; } public function getSemestersForSchoolYear(string $schoolYear, ?string $fallbackSemester = null): array { $rows = $this->db->table('semester_scores') ->select('DISTINCT semester', false) ->where('semester IS NOT NULL', null, false) ->where('semester != ""', null, false) ->where('school_year', $schoolYear) ->orderBy('semester', 'ASC') ->get() ->getResultArray(); $semesters = []; foreach ($rows as $row) { $value = trim((string) ($row['semester'] ?? '')); if ($value === '') continue; $semesters[] = $value; } if (empty($semesters)) { $semesters = ['Fall', 'Spring']; } if ($fallbackSemester !== null && $fallbackSemester !== '' && !in_array($fallbackSemester, $semesters, true)) { array_unshift($semesters, $fallbackSemester); } return array_values(array_unique($semesters)); } public function getSchoolYearsForScores(?string $fallback = null): array { $schoolYears = []; try { $rows = $this->db->table('semester_scores') ->select('DISTINCT school_year', false) ->where('school_year IS NOT NULL', null, false) ->where('school_year != ""', null, false) ->orderBy('school_year', 'DESC') ->get() ->getResultArray(); foreach ($rows as $row) { $val = (string)($row['school_year'] ?? ''); if ($val !== '') $schoolYears[] = $val; } } catch (\Throwable $e) { } try { $rows2 = $this->db->table('student_class') ->select('DISTINCT school_year', false) ->where('school_year IS NOT NULL', null, false) ->where('school_year != ""', null, false) ->orderBy('school_year', 'DESC') ->get() ->getResultArray(); foreach ($rows2 as $row) { $val = (string)($row['school_year'] ?? ''); if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val; } } catch (\Throwable $e) { } if ($fallback && !in_array($fallback, $schoolYears, true)) { array_unshift($schoolYears, $fallback); } return array_values(array_unique($schoolYears)); } public function userHasMenuUrl(string $needle): bool { $needle = strtolower(trim($needle)); if ($needle === '') { return false; } $rawRole = session()->get('role'); $roles = is_array($rawRole) ? $rawRole : [$rawRole ?? 'guest']; $roles = array_values(array_filter(array_map('strval', $roles))); if (empty($roles)) { return false; } $service = new NavbarService(); $menu = $service->getMenuForRoles($roles); if (empty($menu)) { return false; } $normalize = static function (string $url) use ($needle): string { $url = strtolower(trim($url)); if ($url === '') return ''; $url = preg_replace('#^https?://[^/]+/#i', '', $url); $url = ltrim($url, '/'); return $url; }; $target = $normalize($needle); $stack = $menu; while (!empty($stack)) { $node = array_shift($stack); if (!empty($node['url'])) { $url = $normalize((string)$node['url']); if ($url !== '' && $url === $target) { return true; } } if (!empty($node['children']) && is_array($node['children'])) { foreach ($node['children'] as $child) { $stack[] = $child; } } } return false; } /** * Parses a classSection name into a (classId, label) pair compatible with your Attendance view. * - KG -> (0, 'KG') * - Youth-> (13, 'Youth') * - Grade N -> (N, 'Grade N') * Fallback: tries to extract first number; else returns (99, original). */ public function parseClassIdFromSectionName(string $name): array { $n = trim($name); // Common patterns used in your data if (preg_match('/\bKG\b/i', $n)) { return [0, 'KG']; } if (preg_match('/\bYouth\b/i', $n)) { return [13, 'Youth']; } if (preg_match('/Grade\s*(\d+)/i', $n, $m)) { $g = (int)$m[1]; return [$g, 'Grade ' . $g]; } // Fallback: any number present becomes the grade id if (preg_match('/(\d+)/', $n, $m2)) { $g = (int)$m2[1]; return [$g, 'Grade ' . $g]; } // Last fallback return [99, $n]; } /** * Resolve class_section_id for a student in the current term. */ public function resolveClassSectionIdForStudent(int $studentId): int { $row = $this->db->table('student_class') ->select('class_section_id') ->where('student_id', $studentId) ->where('school_year', $this->schoolYear) ->where('semester', $this->semester) ->get()->getRow(); return $row ? (int)$row->class_section_id : 0; } /** Simple slugifier used as array keys for tabs */ public function slugify(string $s): string { $s = strtolower($s); $s = preg_replace('/[^a-z0-9]+/i', '-', $s); return trim($s, '-'); } /** Rank: KG first, numbers ascending, Youth last */ public function rankOf(string $name): int { $k = strtolower(trim($name)); if ($k === 'kg' || $k === 'kindergarten') return -100; if ($k === 'youth') return 100000; if (preg_match('/\d+/', $k, $m)) return (int)$m[0]; return 50000; } }