db = $db; $this->configModel = $configModel; $this->studentModel = $studentModel; $this->studentClassModel = $studentClassModel; $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 previewDecisionEmail(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' => 'json', 'status' => 400, 'data' => ['error' => 'Missing student or term.']]; } $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester); if (empty($row)) { return ['kind' => 'json', 'status' => 404, 'data' => ['error' => 'Student not found.']]; } $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 ['kind' => 'json', 'status' => 200, 'data' => [ 'subject' => $subject, 'html' => $html, 'student_id' => $studentId, ]]; } public function editDecisionEmail(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.']; } $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 ['kind' => 'view', 'view' => 'grading/below_sixty_decision_email_editor', 'data' => [ 'studentId' => $studentId, 'studentName' => $studentName, 'semester' => $semester, 'schoolYear' => $schoolYear, 'subject' => $subject, 'html' => $html, 'decision' => $decision, ]]; } public function sendDecisionEmail(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.']; } $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 ['kind' => 'flash', 'redirect' => base_url('grading/below-60/decisions') . ($query ? '?' . $query : ''), 'type' => 'status', 'message' => 'Decision email sent to parent(s).']; } public function allDecisionsPage(array $params = []) { $get = $params['get'] ?? []; $post = $params['post'] ?? []; $configuredYear = (string)$this->schoolYear; $schoolYear = trim((string)(($get['school_year'] ?? null) ?? '')); if ($schoolYear === '') { $schoolYear = $configuredYear; } $schoolYears = $this->getSchoolYearsForScores($schoolYear); // Load saved YEAR decisions for this school year. // New structure: // one row per student per school_year // uses year_score, not semester_score // does not use semester = 'year' $decModel = new StudentDecisionModel(); $saved = $decModel ->where('school_year', $schoolYear) ->findAll(); $savedMap = []; foreach ($saved as $s) { $sid = (int)($s['student_id'] ?? 0); if ($sid > 0) { $savedMap[$sid] = $s; } } // Fetch Fall and Spring semester scores per student. // These raw semester scores are only used to calculate the final year_score. $allScoreRows = $this->db->table('semester_scores ss') ->select([ 's.id AS student_id', 's.school_id', 's.firstname', 's.lastname', 's.gender', 's.dob', 's.is_active', 'ss.class_section_id', 'cs.class_section_name', 'c.class_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') ->join('classes c', 'c.id = cs.class_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(); // Group Fall/Spring scores by student. $studentMap = []; foreach ($allScoreRows as $sr) { $sid = (int)($sr['student_id'] ?? 0); if ($sid <= 0) { continue; } if (!isset($studentMap[$sid])) { $studentMap[$sid] = [ 'school_id' => $sr['school_id'] ?? '', 'firstname' => $sr['firstname'] ?? '', 'lastname' => $sr['lastname'] ?? '', 'gender' => $sr['gender'] ?? '', 'dob' => $sr['dob'] ?? '', 'is_active' => (int)($sr['is_active'] ?? 1), 'enrollment_status' => $sr['enrollment_status'] ?? '', 'is_withdrawn' => (int)($sr['is_withdrawn'] ?? 0), 'class_section_id' => (int)($sr['class_section_id'] ?? 0), 'class_name' => $sr['class_name'] ?? '', 'class_section_name' => $sr['class_section_name'] ?? '', 'fall_score' => null, 'spring_score' => null, ]; } $semKey = strtolower(trim((string)($sr['sem_key'] ?? ''))); $val = is_numeric($sr['semester_score']) ? (float)$sr['semester_score'] : null; if ($semKey === 'fall') { $studentMap[$sid]['fall_score'] = $val; } elseif ($semKey === 'spring') { $studentMap[$sid]['spring_score'] = $val; } } // Pull below-60 manual decisions for this school year. // Used only when the calculated year_score is below 60. $belowDecModel = new BelowSixtyDecisionModel(); $belowRows = $belowDecModel ->where('school_year', $schoolYear) ->findAll(); $belowMap = []; foreach ($belowRows as $b) { $sid = (int)($b['student_id'] ?? 0); if ($sid <= 0) { continue; } // Keep the first non-empty decision found for this student. if (!isset($belowMap[$sid]) || trim((string)($belowMap[$sid]['decision'] ?? '')) === '') { $belowMap[$sid] = $b; } } // Build final display rows. $rows = []; foreach ($studentMap as $sid => $info) { $fall = $info['fall_score']; $spring = $info['spring_score']; if ($fall !== null && $spring !== null) { $yearScore = round(($fall + $spring) / 2, 2); } elseif ($fall !== null) { $yearScore = round((float)$fall, 2); } elseif ($spring !== null) { $yearScore = round((float)$spring, 2); } else { $yearScore = null; } if (isset($savedMap[$sid])) { $savedRow = $savedMap[$sid]; $decision = trim((string)($savedRow['decision'] ?? '')); $source = trim((string)($savedRow['source'] ?? 'pending')); $notes = (string)($savedRow['notes'] ?? ''); if (isset($savedRow['year_score']) && $savedRow['year_score'] !== '' && is_numeric($savedRow['year_score'])) { $yearScore = round((float)$savedRow['year_score'], 2); } } elseif ($yearScore !== null && $yearScore >= 60) { $decision = 'Pass'; $source = 'auto'; $notes = ''; } elseif ($yearScore !== null && isset($belowMap[$sid])) { $decision = trim((string)($belowMap[$sid]['decision'] ?? '')); $source = $decision !== '' ? 'manual' : 'pending'; $notes = (string)($belowMap[$sid]['notes'] ?? ''); } else { $decision = ''; $source = 'pending'; $notes = ''; } $currentClassSectionName = trim((string)($info['class_section_name'] ?? '')); if ($currentClassSectionName === '' && isset($savedMap[$sid])) { $currentClassSectionName = trim((string)($savedMap[$sid]['class_section_name'] ?? '')); } $currentClassName = trim((string)($info['class_name'] ?? '')); if ($currentClassName === '') { $currentClassName = $this->classOnlyLabel($currentClassSectionName); } $rows[] = [ 'student_id' => $sid, 'school_id' => $info['school_id'], 'firstname' => $info['firstname'], 'lastname' => $info['lastname'], 'gender' => $info['gender'] ?? '', 'dob' => $info['dob'] ?? '', 'is_active' => (int)($info['is_active'] ?? 1), 'enrollment_status' => $info['enrollment_status'] ?? '', 'is_withdrawn' => (int)($info['is_withdrawn'] ?? 0), 'class_section_id' => (int)($info['class_section_id'] ?? 0), 'class_name' => $currentClassName, 'class_section_name' => $currentClassSectionName, 'fall_score' => $fall, 'spring_score' => $spring, 'year_score' => $yearScore, 'decision' => $decision, 'next_year_placement' => $this->nextYearPlacementLabel($decision, $currentClassName, $currentClassSectionName, (string)($info['dob'] ?? ''), $schoolYear), 'source' => $source, 'notes' => $notes, 'saved' => isset($savedMap[$sid]), 'is_trophy' => false, ]; } $rowsByClass = []; foreach ($rows as $index => $row) { $classSectionId = (int)($row['class_section_id'] ?? 0); if ($classSectionId <= 0) { continue; } $rowsByClass[$classSectionId][] = $index; } foreach ($rowsByClass as $classIndexes) { $scores = []; foreach ($classIndexes as $rowIndex) { $yearScore = $rows[$rowIndex]['year_score'] ?? null; if (is_numeric($yearScore)) { $scores[] = (float)$yearScore; } } $thresholdInfo = $this->calculateTrophyThreshold($scores, 75.0); $threshold = $thresholdInfo['threshold']; if ($threshold === null) { continue; } foreach ($classIndexes as $rowIndex) { $yearScore = $rows[$rowIndex]['year_score'] ?? null; $rows[$rowIndex]['is_trophy'] = is_numeric($yearScore) && (float)$yearScore >= $threshold; } } $generated = !empty($saved); return ['kind' => 'view', 'view' => 'grading/all_decisions', 'data' => [ 'rows' => $rows, 'schoolYear' => $schoolYear, 'schoolYears' => $schoolYears, 'generated' => $generated, ]]; } public function generateAllDecisions(array $params = []) { $get = $params['get'] ?? []; $post = $params['post'] ?? []; $schoolYear = trim((string)($post['school_year'] ?? null)); if ($schoolYear === '') { return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing school year.']; } // Fetch Fall and Spring scores per student. $allScoreRows = $this->db->table('semester_scores ss') ->select([ 's.id AS student_id', 's.firstname', 's.lastname', 's.is_active', 'cs.class_section_name', '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) ->get() ->getResultArray(); if (empty($allScoreRows)) { return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'No semester scores found for this school year.']; } // Group Fall/Spring scores by student. $studentMap = []; foreach ($allScoreRows as $sr) { $sid = (int)($sr['student_id'] ?? 0); if ($sid <= 0) { continue; } if (!isset($studentMap[$sid])) { $studentMap[$sid] = [ 'firstname' => $sr['firstname'] ?? '', 'lastname' => $sr['lastname'] ?? '', 'class_section_name' => $sr['class_section_name'] ?? '', 'fall_score' => null, 'spring_score' => null, ]; } $semKey = strtolower(trim((string)($sr['sem_key'] ?? ''))); $val = is_numeric($sr['semester_score']) ? (float)$sr['semester_score'] : null; if ($semKey === 'fall') { $studentMap[$sid]['fall_score'] = $val; } elseif ($semKey === 'spring') { $studentMap[$sid]['spring_score'] = $val; } } // Pull below-60 manual decisions for this school year. $belowDecModel = new BelowSixtyDecisionModel(); $belowRows = $belowDecModel ->where('school_year', $schoolYear) ->findAll(); $belowMap = []; foreach ($belowRows as $b) { $sid = (int)($b['student_id'] ?? 0); if ($sid <= 0) { continue; } if (!isset($belowMap[$sid]) || trim((string)($belowMap[$sid]['decision'] ?? '')) === '') { $belowMap[$sid] = $b; } } // Load existing year decisions so we update instead of duplicating. // New structure: one row per student per school_year. $decModel = new StudentDecisionModel(); $existing = $decModel ->where('school_year', $schoolYear) ->findAll(); $existingMap = []; foreach ($existing as $e) { $sid = (int)($e['student_id'] ?? 0); if ($sid > 0) { $existingMap[$sid] = $e; } } $userId = (int)(session()->get('user_id') ?? 0) ?: null; $savedCount = 0; foreach ($studentMap as $sid => $info) { $fall = $info['fall_score']; $spring = $info['spring_score']; if ($fall !== null && $spring !== null) { $yearScore = round(($fall + $spring) / 2, 2); } elseif ($fall !== null) { $yearScore = round((float)$fall, 2); } elseif ($spring !== null) { $yearScore = round((float)$spring, 2); } else { continue; } if ($yearScore >= 60) { $decision = 'Pass'; $source = 'auto'; $notes = null; } elseif (isset($belowMap[$sid]) && trim((string)($belowMap[$sid]['decision'] ?? '')) !== '') { $decision = trim((string)$belowMap[$sid]['decision']); $source = 'manual'; $notes = trim((string)($belowMap[$sid]['notes'] ?? '')); $notes = $notes !== '' ? $notes : null; } else { $decision = null; $source = 'pending'; $notes = null; } // Important fix: // use year_score, not semester_score. // do not save semester = 'year'. $payload = [ 'student_id' => $sid, 'school_year' => $schoolYear, 'class_section_name' => $info['class_section_name'] ?? null, 'year_score' => $yearScore, 'decision' => $decision, 'source' => $source, 'notes' => $notes, 'generated_by' => $userId, ]; if (isset($existingMap[$sid])) { $decModel->update((int)$existingMap[$sid]['id'], $payload); } else { $decModel->insert($payload); } $savedCount++; } $query = http_build_query(['school_year' => $schoolYear]); return ['kind' => 'flash', 'redirect' => base_url('grading/decisions') . '?' . $query, 'type' => 'status', 'message' => "Decisions generated for {$savedCount} students."]; } private function nextYearPlacementLabel( ?string $decision, string $currentClassName, string $currentClassSectionName, string $dob, string $schoolYear ): string { $normalizedDecision = DeliberationDecision::normalize($decision); $classLabel = $this->classOnlyLabel($currentClassName !== '' ? $currentClassName : $currentClassSectionName); if ($this->isKgClass($classLabel)) { $kgPlacement = $this->kgPlacementByComingSeptember($dob, $schoolYear); if ($kgPlacement !== '') { return $kgPlacement; } } if ($normalizedDecision === DeliberationDecision::REPEAT_CLASS) { return $classLabel; } return ''; } private function classOnlyLabel(string $className): string { return trim((string) preg_replace('/-.+$/', '', $className)); } private function isKgClass(string $className): bool { $value = strtoupper(trim($className)); return preg_match('/(^|[^A-Z0-9])KG([^A-Z0-9]|$)/', $value) === 1 || str_contains($value, 'KINDERGARTEN'); } private function kgPlacementByComingSeptember(string $dob, string $schoolYear): string { $dob = trim($dob); if ($dob === '') { return ''; } try { $birthDate = new \DateTimeImmutable($dob); $cutoff = $this->comingSeptemberFirstCutoff($schoolYear); } catch (\Throwable) { return ''; } if ($birthDate > $cutoff) { return ''; } return $birthDate->diff($cutoff)->y >= 6 ? '1' : 'KG'; } private function comingSeptemberFirstCutoff(string $schoolYear): \DateTimeImmutable { if (preg_match('/^(\d{4})-\d{4}$/', $schoolYear, $matches) === 1) { return new \DateTimeImmutable(((int) $matches[1] + 1) . '-09-01'); } $today = new \DateTimeImmutable('today'); $cutoff = new \DateTimeImmutable($today->format('Y') . '-09-01'); return $today <= $cutoff ? $cutoff : $cutoff->modify('+1 year'); } private function normalizeSemesterInput(?string $semester): ?string { if (!is_string($semester)) { return null; } $trimmed = trim($semester); return $trimmed === '' ? null : $trimmed; } private 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] ?? ''; } private 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)); } private 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)); } private 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). */ private 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. */ private 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 */ private 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 */ private 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; } private 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; } private 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; } private 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; } 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 calculateTrophyThreshold(array $scores, float $percentile = 75.0): array { $scores = array_values(array_filter( $scores, static fn ($value): bool => is_numeric($value) && $value !== null )); $scores = array_map('floatval', $scores); sort($scores); $count = count($scores); if ($count === 0) { return ['threshold' => null, 'winners' => 0, 'method' => 'empty']; } $minWinners = 3; $maxWinners = max($minWinners, (int) floor($count * (1 - $percentile / 100))); $threshold = $this->empiricalTrophyPercentile($scores, $percentile); $winners = $this->countScoresAtOrAbove($scores, $threshold); if ($winners < $minWinners) { $target = min($minWinners, $count); $descending = array_reverse($scores); $threshold = $descending[$target - 1]; $winners = $this->countScoresAtOrAbove($scores, $threshold); return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_reduced']; } if ($winners <= $maxWinners) { return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'empirical_percentile']; } $result = $this->capTrophyThresholdByRank($scores, $maxWinners); if ($result['winners'] < $minWinners) { $target = min($minWinners, $count); $descending = array_reverse($scores); $threshold = $descending[$target - 1]; $winners = $this->countScoresAtOrAbove($scores, $threshold); return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_after_cap']; } return $result; } private function capTrophyThresholdByRank(array $sortedScores, int $max): array { $descending = array_reverse($sortedScores); $threshold = $descending[$max - 1]; $winners = $this->countScoresAtOrAbove($sortedScores, $threshold); if ($winners <= $max) { return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'capped_25pct']; } $uniqueHigherScores = array_values(array_unique(array_filter( $sortedScores, static fn ($score): bool => $score > $threshold ))); sort($uniqueHigherScores); foreach ($uniqueHigherScores as $candidate) { $winnerCount = $this->countScoresAtOrAbove($sortedScores, $candidate); if ($winnerCount <= $max) { return ['threshold' => $candidate, 'winners' => $winnerCount, 'method' => 'capped_25pct']; } } return ['threshold' => $sortedScores[0], 'winners' => count($sortedScores), 'method' => 'all_equal']; } private function empiricalTrophyPercentile(array $sortedScores, float $percentile): float { $count = count($sortedScores); if ($count === 0) { return 0.0; } $index = ($percentile / 100.0) * ($count - 1); $lower = (int) floor($index); $upper = (int) ceil($index); if ($lower === $upper) { return $sortedScores[$lower]; } return $sortedScores[$lower] + ($index - $lower) * ($sortedScores[$upper] - $sortedScores[$lower]); } private function countScoresAtOrAbove(array $scores, float $threshold): int { return count(array_filter( $scores, static fn ($score): bool => $score >= $threshold )); } }