db = $db; $this->configModel = $configModel; $this->homeworkModel = $homeworkModel; $this->userModel = $userModel; $this->studentClassModel = $studentClassModel; $this->studentModel = $studentModel; $this->teacherClassModel = $teacherClassModel; $this->classSection = $classSection; $this->attendanceCalculator = $attendanceCalculator; $this->gradingLockModel = $gradingLockModel; $this->semesterScoreService = $semesterScoreService; $this->schoolYear = $schoolYear; $this->semester = $semester; } public function setTerm(string $schoolYear, string $semester): void { $this->schoolYear = $schoolYear; $this->semester = $semester; } public function showType($type, $classSectionId, $studentId, array $params = []) { $get = $params['get'] ?? []; $post = $params['post'] ?? []; $scoreModel = $this->getModelByType($type); $studentModel = new StudentModel(); $configModel = new ConfigurationModel(); $schoolYear = $configModel->getConfig('school_year'); $semester = getSemester(); $student = $studentModel->find($studentId); $scores = $scoreModel->where([ 'student_id' => $studentId, 'semester' => $semester, 'school_year' => $schoolYear ])->findAll(); $scoresLocked = false; $classSectionIdInt = (int) ($classSectionId ?? 0); if ($classSectionIdInt > 0) { $scoresLocked = $this->gradingLockModel->isLocked($classSectionIdInt, $semester, $schoolYear); } return ['kind' => 'view', 'view' => "grading/{$type}", 'data' => [ 'student' => $student, 'scores' => $scores, 'type' => $type, 'classSectionId' => $classSectionId, // ✅ pass it manually 'semester' => $semester, // ✅ Pass semester to the view 'scoresLocked' => $scoresLocked, ]]; } public function updateScores(array $params = []) { $get = $params['get'] ?? []; $post = $params['post'] ?? []; $type = ($post['type'] ?? null); $studentId = ($post['student_id'] ?? null); $classSectionId = ($post['class_section_id'] ?? null); $configModel = new ConfigurationModel(); $studentModel = new StudentModel(); $schoolYear = $configModel->getConfig('school_year'); $semester = getSemester(); $model = $this->getModelByType($type); $classSectionIdInt = (int) ($classSectionId ?? 0); if ($classSectionIdInt > 0 && $this->isScoresLocked($classSectionIdInt, $semester, $schoolYear)) { return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Scores are locked for this class. Unlock to edit.']; } if (in_array($type, ['homework', 'quiz', 'project'])) { $scoreIds = ($post['score_ids'] ?? null); $scores = ($post['scores'] ?? null); $comments = ($post['comments'] ?? null); foreach ($scoreIds as $i => $id) { $model->update($id, [ 'score' => $scores[$i], 'comment' => $comments[$i] ?? null, 'updated_at' => utc_now() ]); } } elseif (in_array($type, ['midterm', 'final', 'test'])) { $score = ($post['score'] ?? null); $data = [ 'score' => $score, 'updated_at' => utc_now() ]; $existing = $model->where([ 'student_id' => $studentId, 'class_section_id' => $classSectionId, 'semester' => $semester, 'school_year' => $schoolYear ])->first(); if ($existing) { $model->update($existing['id'], $data); } else { $data += [ 'student_id' => $studentId, 'class_section_id' => $classSectionId, 'semester' => $semester, 'school_year' => $schoolYear, 'created_at' => utc_now() ]; $model->insert($data); } } elseif ($type === 'comments') { $comment = ($post['comment'] ?? null); $model->where([ 'student_id' => $studentId, 'semester' => $semester, 'school_year' => $schoolYear ])->delete(); // Remove existing comments of this type (optional) $model->insert([ 'student_id' => $studentId, 'score_type' => 'general', 'semester' => $semester, 'school_year' => $schoolYear, 'comment' => $comment, 'commented_by' => session()->get('user_id'), 'created_at' => utc_now() ]); } $studentTeacherInfo = $studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear); // Call the updateScoresForStudents method try { $this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear); } catch (RuntimeException $e) { // Handle error } return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'status', 'message' => 'Scores updated successfully.']; } public function gradingPage(array $params = []) { $get = $params['get'] ?? []; $post = $params['post'] ?? []; $schoolYear = (string) $this->schoolYear; $configuredSemester = (string) $this->semester; $requestedClassId = (int) (($get['class_id'] ?? null) ?? 0); $semesterOptions = $this->getSemestersForSchoolYear($schoolYear, $configuredSemester); $session = session(); $requestedSemester = $this->normalizeSemesterInput(($get['semester'] ?? null)); $sessionSemester = $this->normalizeSemesterInput($session->get('grading_selected_semester')); $effectiveRequested = $requestedSemester ?? $sessionSemester; $semester = trim($this->resolveSemesterSelection($effectiveRequested, $semesterOptions, $configuredSemester)); if ($semester === '') { $semester = $configuredSemester !== '' ? $configuredSemester : ($semesterOptions[0] ?? 'Fall'); } if (!in_array($semester, $semesterOptions, true)) { $semesterOptions[] = $semester; } $semesterOptions = array_values(array_unique($semesterOptions)); $session->set('grading_selected_semester', $semester); $this->ensureParentReleaseKeyExists('Fall'); $this->ensureParentReleaseKeyExists('Spring'); $scoresReleased = $this->getParentScoresReleasedForSemester($semester); $scoresReleasedFall = $this->getParentScoresReleasedForSemester('Fall'); $scoresReleasedSpring = $this->getParentScoresReleasedForSemester('Spring'); // Refresh PTAP/semester scores for the requested class (if provided) so values are present. if ($requestedClassId > 0 && $this->semesterScoreService !== null) { $sectionIds = $this->classSection ->select('class_section_id') ->where('class_id', $requestedClassId) ->findAll(); $sectionIds = array_values(array_filter(array_map( static fn($row) => (int)($row['class_section_id'] ?? 0), $sectionIds ), static fn($id) => $id > 0)); foreach ($sectionIds as $sectionId) { $studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId( $sectionId, $semester, $schoolYear ); if (empty($studentTeacherInfo)) { continue; } try { $this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear); } catch (\Throwable $e) { log_message( 'error', 'GradingController::grading score refresh failed for section ' . $sectionId . ': ' . $e->getMessage() ); } } } // Normalize the semester text for safe comparison $semEsc = $this->db->escape($semester); $yrEsc = $this->db->escape($schoolYear); $rows = $this->buildGradingRows($semEsc, $yrEsc, $schoolYear, $semester); // Preload quiz/homework/project/participation/midterm score counts to distinguish true zeros from empty scores $quizCounts = []; $homeworkCounts = []; $projectCounts = []; $participationCounts = []; $midtermCounts = []; if (!empty($rows)) { $sectionIds = []; $studentIds = []; foreach ($rows as $r) { $sid = (int) ($r['student_id'] ?? 0); $sec = (int) ($r['section_id'] ?? 0); if ($sid > 0) $studentIds[$sid] = true; if ($sec > 0) $sectionIds[$sec] = true; } $sectionIds = array_keys($sectionIds); $studentIds = array_keys($studentIds); if (!empty($sectionIds) && !empty($studentIds)) { $quizRows = $this->db->table('quiz') ->select('student_id, class_section_id, COUNT(*) AS cnt') ->where('semester', $semester) ->where('school_year', $schoolYear) ->whereIn('class_section_id', $sectionIds) ->whereIn('student_id', $studentIds) ->where('score IS NOT NULL', null, false) ->groupBy('student_id, class_section_id') ->get()->getResultArray(); foreach ($quizRows as $qr) { $sec = (int) ($qr['class_section_id'] ?? 0); $sid = (int) ($qr['student_id'] ?? 0); if ($sec > 0 && $sid > 0) { $quizCounts[$sec][$sid] = (int) ($qr['cnt'] ?? 0); } } $hwRows = $this->db->table('homework') ->select('student_id, class_section_id, COUNT(*) AS cnt') ->where('semester', $semester) ->where('school_year', $schoolYear) ->whereIn('class_section_id', $sectionIds) ->whereIn('student_id', $studentIds) ->where('score IS NOT NULL', null, false) ->groupBy('student_id, class_section_id') ->get()->getResultArray(); foreach ($hwRows as $hr) { $sec = (int) ($hr['class_section_id'] ?? 0); $sid = (int) ($hr['student_id'] ?? 0); if ($sec > 0 && $sid > 0) { $homeworkCounts[$sec][$sid] = (int) ($hr['cnt'] ?? 0); } } $projectRows = $this->db->table('project') ->select('student_id, class_section_id, COUNT(*) AS cnt') ->where('semester', $semester) ->where('school_year', $schoolYear) ->whereIn('class_section_id', $sectionIds) ->whereIn('student_id', $studentIds) ->where('score IS NOT NULL', null, false) ->groupBy('student_id, class_section_id') ->get()->getResultArray(); foreach ($projectRows as $pr) { $sec = (int) ($pr['class_section_id'] ?? 0); $sid = (int) ($pr['student_id'] ?? 0); if ($sec > 0 && $sid > 0) { $projectCounts[$sec][$sid] = (int) ($pr['cnt'] ?? 0); } } $participationRows = $this->db->table('participation') ->select('student_id, class_section_id, COUNT(*) AS cnt') ->where('semester', $semester) ->where('school_year', $schoolYear) ->whereIn('class_section_id', $sectionIds) ->whereIn('student_id', $studentIds) ->where('score IS NOT NULL', null, false) ->groupBy('student_id, class_section_id') ->get()->getResultArray(); foreach ($participationRows as $par) { $sec = (int) ($par['class_section_id'] ?? 0); $sid = (int) ($par['student_id'] ?? 0); if ($sec > 0 && $sid > 0) { $participationCounts[$sec][$sid] = (int) ($par['cnt'] ?? 0); } } $midtermRows = $this->db->table('midterm_exam') ->select('student_id, class_section_id, COUNT(*) AS cnt') ->where('semester', $semester) ->where('school_year', $schoolYear) ->whereIn('class_section_id', $sectionIds) ->whereIn('student_id', $studentIds) ->where('score IS NOT NULL', null, false) ->groupBy('student_id, class_section_id') ->get()->getResultArray(); foreach ($midtermRows as $mr) { $sec = (int) ($mr['class_section_id'] ?? 0); $sid = (int) ($mr['student_id'] ?? 0); if ($sec > 0 && $sid > 0) { $midtermCounts[$sec][$sid] = (int) ($mr['cnt'] ?? 0); } } } } // If any section is missing PTAP or semester score, refresh and reload once $sectionsNeedingRefresh = []; foreach ($rows as $r) { $sectionId = (int) ($r['section_id'] ?? 0); if ($sectionId <= 0) continue; $ptapMissing = $r['ss_ptap_score'] === null; $semMissing = $r['ss_semester_score'] === null; if ($ptapMissing || $semMissing) { $sectionsNeedingRefresh[$sectionId] = true; } } if (!empty($sectionsNeedingRefresh) && $this->semesterScoreService !== null) { foreach (array_keys($sectionsNeedingRefresh) as $sectionId) { $studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId( $sectionId, $semester, $schoolYear ); if (empty($studentTeacherInfo)) { continue; } try { $this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear); } catch (\Throwable $e) { log_message( 'error', 'GradingController::grading refresh missing scores for section ' . $sectionId . ': ' . $e->getMessage() ); } } // Reload rows after refresh $rows = $this->buildGradingRows($semEsc, $yrEsc, $schoolYear, $semester); } // Build structures keyed by BUSINESS section id $grades = []; // class_id => [ ['class_section_id','class_section_name'], ... ] $studentsBySection = []; // section_id => [ students... ] $seenStudentsBySection = []; foreach ($rows as $r) { $sectionId = (int) ($r['section_id'] ?? 0); // BUSINESS id $classId = (int) ($r['class_id'] ?? 0); $sectionName = (string) ($r['class_section_name'] ?? ''); if ($sectionId <= 0 || $classId <= 0) continue; if (!isset($grades[$classId])) $grades[$classId] = []; $exists = false; foreach ($grades[$classId] as $s) { if ((int)$s['class_section_id'] === $sectionId) { $exists = true; break; } } if (!$exists) { $grades[$classId][] = [ 'class_section_id' => $sectionId, 'class_section_name' => $sectionName, ]; } $sid = (int) ($r['student_id'] ?? 0); if ($sid <= 0) continue; if (isset($seenStudentsBySection[$sectionId][$sid])) continue; $seenStudentsBySection[$sectionId][$sid] = true; $ptapScore = $r['ss_ptap_score'] ?? null; $semesterScore = $r['ss_semester_score'] ?? null; $attendanceScore = $this->calculateAttendanceScoreForStudent( $sid, $semester, $schoolYear, $sectionId ); if ($attendanceScore === null) { $rawAttendance = $r['ss_attendance_score'] ?? null; if ($rawAttendance !== null && $rawAttendance !== '') { $attendanceScore = round((float) $rawAttendance, 2); } } $homeworkAvg = isset($r['ss_homework_avg']) && $r['ss_homework_avg'] !== '' ? round((float) $r['ss_homework_avg'], 2) : null; if ($homeworkAvg !== null && (float) $homeworkAvg === 0.0) { $hwCount = (int) ($homeworkCounts[$sectionId][$sid] ?? 0); if ($hwCount === 0) { $homeworkAvg = null; } } $projectAvg = isset($r['ss_project_avg']) && $r['ss_project_avg'] !== '' ? round((float) $r['ss_project_avg'], 2) : null; if ($projectAvg !== null && (float) $projectAvg === 0.0) { $prCount = (int) ($projectCounts[$sectionId][$sid] ?? 0); if ($prCount === 0) { $projectAvg = null; } } $participationScore = isset($r['ss_participation_score']) && $r['ss_participation_score'] !== '' ? round((float) $r['ss_participation_score'], 2) : null; if ($participationScore !== null && (float) $participationScore === 0.0) { $pCount = (int) ($participationCounts[$sectionId][$sid] ?? 0); if ($pCount === 0) { $participationScore = null; } } $midtermExam = isset($r['ss_midterm_exam_score']) && $r['ss_midterm_exam_score'] !== '' ? round((float) $r['ss_midterm_exam_score'], 2) : null; if ($midtermExam !== null && (float) $midtermExam === 0.0) { $mCount = (int) ($midtermCounts[$sectionId][$sid] ?? 0); if ($mCount === 0) { $midtermExam = null; } } $quizAvg = isset($r['ss_quiz_avg']) && $r['ss_quiz_avg'] !== '' ? round((float) $r['ss_quiz_avg'], 2) : null; if ($quizAvg !== null && (float) $quizAvg === 0.0) { $quizCount = (int) ($quizCounts[$sectionId][$sid] ?? 0); if ($quizCount === 0) { $quizAvg = null; } } $studentsBySection[$sectionId][] = [ 'id' => $sid, 'school_id' => $r['school_id'] ?? null, 'firstname' => $r['firstname'] ?? null, 'lastname' => $r['lastname'] ?? null, 'is_active' => (int)($r['is_active'] ?? 1), 'enrollment_status' => $r['enrollment_status'] ?? '', 'is_withdrawn' => (int)($r['is_withdrawn'] ?? 0), 'class_id' => $classId, 'ptap' => is_null($ptapScore) ? null : round((float) $ptapScore, 2), 'semester_score' => is_null($semesterScore) ? null : round((float) $semesterScore, 2), 'attendance' => $attendanceScore, 'homework_avg' => $homeworkAvg, 'project_avg' => $projectAvg, 'quiz_avg' => $quizAvg, 'participation' => $participationScore, 'midterm_exam' => $midtermExam, 'final_exam' => isset($r['ss_final_exam_score']) && $r['ss_final_exam_score'] !== '' ? round((float) $r['ss_final_exam_score'], 2) : null, 'matched_biz_csid' => $r['matched_biz_csid'] ?? null, 'matched_pk_csid' => $r['matched_pk_csid'] ?? null, 'placement_level' => $r['placement_level'] ?? null, ]; } $scoreLocks = []; $lockSectionIds = []; foreach ($grades as $sections) { foreach ($sections as $section) { $sid = (int) ($section['class_section_id'] ?? 0); if ($sid > 0) { $lockSectionIds[$sid] = true; } } } $lockSectionIds = array_keys($lockSectionIds); if (!empty($lockSectionIds)) { $lockRows = $this->gradingLockModel ->whereIn('class_section_id', $lockSectionIds) ->where('semester', $semester) ->where('school_year', $schoolYear) ->findAll(); foreach ($lockRows as $row) { $scoreLocks[(int) ($row['class_section_id'] ?? 0)] = !empty($row['is_locked']); } } return ['kind' => 'view', 'view' => 'grading/grading_main', 'data' => [ 'grades' => $grades, 'studentsBySection' => $studentsBySection, 'semester' => $semester, 'schoolYear' => $schoolYear, 'requestedClassId' => $requestedClassId, 'semesterOptions' => $semesterOptions, 'scoresReleased' => $scoresReleased, 'scoresReleasedFall' => $scoresReleasedFall, 'scoresReleasedSpring' => $scoresReleasedSpring, 'scoreLocks' => $scoreLocks, ]]; } public function toggleScoreLock(array $params = []) { $get = $params['get'] ?? []; $post = $params['post'] ?? []; $classSectionId = (int) (($post['class_section_id'] ?? null) ?? 0); $semester = trim((string) (($post['semester'] ?? null) ?? $this->semester)); $schoolYear = trim((string) (($post['school_year'] ?? null) ?? $this->schoolYear)); if ($classSectionId <= 0 || $semester === '' || $schoolYear === '') { return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing class section or term.']; } $existing = $this->gradingLockModel->getLock($classSectionId, $semester, $schoolYear); $userId = (int) (session()->get('user_id') ?? 0); if (!empty($existing) && !empty($existing['is_locked'])) { $this->gradingLockModel->update($existing['id'], [ 'is_locked' => 0, 'locked_by' => null, 'locked_at' => null, ]); return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'status', 'message' => 'Scores unlocked for this class.']; } if (!empty($existing)) { $this->gradingLockModel->update($existing['id'], [ 'is_locked' => 1, 'locked_by' => $userId > 0 ? $userId : null, 'locked_at' => utc_now(), ]); } else { $this->gradingLockModel->insert([ 'class_section_id' => $classSectionId, 'semester' => $semester, 'school_year' => $schoolYear, 'is_locked' => 1, 'locked_by' => $userId > 0 ? $userId : null, 'locked_at' => utc_now(), ]); } return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'status', 'message' => 'Scores locked for this class.']; } public function lockAllScores(array $params = []) { $get = $params['get'] ?? []; $post = $params['post'] ?? []; $semester = trim((string) (($post['semester'] ?? null) ?? $this->semester)); $schoolYear = trim((string) (($post['school_year'] ?? null) ?? $this->schoolYear)); if ($semester === '' || $schoolYear === '') { return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing semester or school year.']; } $sectionRows = $this->classSection ->select('class_section_id') ->groupBy('class_section_id') ->findAll(); $sectionIds = array_values(array_filter(array_map( static fn($row) => (int) ($row['class_section_id'] ?? 0), $sectionRows ), static fn($id) => $id > 0)); if (empty($sectionIds)) { return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'No class sections found to lock.']; } $existingLocks = $this->gradingLockModel ->whereIn('class_section_id', $sectionIds) ->where('semester', $semester) ->where('school_year', $schoolYear) ->findAll(); $existingBySection = []; foreach ($existingLocks as $row) { $sid = (int) ($row['class_section_id'] ?? 0); if ($sid > 0) { $existingBySection[$sid] = $row; } } $userId = (int) (session()->get('user_id') ?? 0); $now = utc_now(); $insertRows = []; foreach ($sectionIds as $sid) { if (!empty($existingBySection[$sid])) { if (!empty($existingBySection[$sid]['is_locked'])) { continue; } $this->gradingLockModel->update($existingBySection[$sid]['id'], [ 'is_locked' => 1, 'locked_by' => $userId > 0 ? $userId : null, 'locked_at' => $now, ]); continue; } $insertRows[] = [ 'class_section_id' => $sid, 'semester' => $semester, 'school_year' => $schoolYear, 'is_locked' => 1, 'locked_by' => $userId > 0 ? $userId : null, 'locked_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]; } if (!empty($insertRows)) { $this->gradingLockModel->insertBatch($insertRows); } return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'status', 'message' => 'Scores locked for all classes.']; } public function toggleParentScoresRelease(array $params = []) { $get = $params['get'] ?? []; $post = $params['post'] ?? []; $semester = (string) (($post['semester'] ?? null) ?? ''); if ($semester === '') { $semester = (string) (session()->get('grading_selected_semester') ?? $this->semester); } $configKey = $this->getParentReleaseKey($semester) ?? 'parent_scores_released'; $releaseScoresRaw = (string) ($this->configModel->getConfig($configKey) ?? ''); $scoresReleased = in_array(strtolower(trim($releaseScoresRaw)), ['1', 'true', 'yes', 'y', 'on'], true); $nextValue = $scoresReleased ? '0' : '1'; $ok = $this->configModel->setConfigValueByKey($configKey, $nextValue); log_message('info', 'toggleParentScoresRelease', [ 'semester' => $semester, 'config_key' => $configKey, 'prev' => $releaseScoresRaw, 'next' => $nextValue, 'ok' => $ok, ]); if ($ok) { $msg = $scoresReleased ? 'Parent exam/semester scores are now hidden.' : 'Parent exam/semester scores are now released.'; $msg .= ' (' . $configKey . '=' . $nextValue . ')'; return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'status', 'message' => $msg]; } return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Unable to update the scores release flag.']; } public function refreshSemesterScores(array $params = []) { $get = $params['get'] ?? []; $post = $params['post'] ?? []; $classSectionId = (int) (($post['class_section_id'] ?? null) ?? 0); if ($classSectionId <= 0) { return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing class section.']; } $requestedSemester = (string) (($post['semester'] ?? null) ?? ''); $requestedYear = (string) (($post['school_year'] ?? null) ?? ''); $semester = $this->normalizeSemesterInput($requestedSemester) ?? $requestedSemester; if ($semester === '') { $semester = (string) $this->semester; } $schoolYear = trim($requestedYear) !== '' ? trim($requestedYear) : (string) $this->schoolYear; $studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId( $classSectionId, $semester, $schoolYear ); if (empty($studentTeacherInfo)) { return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'No students found for this class/term.']; } try { $this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear); $this->refreshAttendanceComments($classSectionId, $semester, $schoolYear); } catch (\Throwable $e) { log_message('error', 'refreshSemesterScores failed: ' . $e->getMessage()); return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Refresh failed. Check logs.']; } return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'status', 'message' => 'Semester scores refreshed for this class/term.']; } public function getScoreComment(array $params = []) { $get = $params['get'] ?? []; $post = $params['post'] ?? []; // Get all students for the current semester and school year $studentClassEntries = $this->studentClassModel ->where('semester', $this->semester) ->where('school_year', $this->schoolYear) ->findAll(); // Group student IDs $studentIds = array_map(fn($entry) => $entry['student_id'], $studentClassEntries); // Fetch all scores and comments for the students $scoresAndComments = $this->getAllScoresAndComments($studentIds, $this->semester, $this->schoolYear); return $scoresAndComments; } public function getModelByType($type) { return match ($type) { 'homework' => new HomeworkModel(), 'quiz' => new QuizModel(), 'project' => new ProjectModel(), 'midterm' => new MidtermExamModel(), 'final' => new FinalExamModel(), 'test' => new SemesterScoreModel(), // Assuming you use test_avg here 'comments' => new ScoreCommentModel(), default => throw new \InvalidArgumentException("Invalid type: $type"), }; } public function isScoresLocked(int $classSectionId, string $semester, string $schoolYear): bool { return $this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear); } public function getParentReleaseKey(string $semester): ?string { $norm = strtolower(trim((string) $semester)); if ($norm === 'fall') { return 'parent_scores_released_fall'; } if ($norm === 'spring') { return 'parent_scores_released_spring'; } return null; } public function getParentScoresReleasedForSemester(string $semester): bool { $key = $this->getParentReleaseKey($semester); $raw = $key ? $this->configModel->getConfig($key) : null; $raw = (string) ($raw ?? ''); return in_array(strtolower(trim($raw)), ['1', 'true', 'yes', 'y', 'on'], true); } public function ensureParentReleaseKeyExists(string $semester): void { $key = $this->getParentReleaseKey($semester); if (!$key) { return; } if ($this->configModel->getConfigValueByKey($key) === null) { $this->configModel->setConfigValueByKey($key, '0'); } } public function refreshAttendanceComments(int $classSectionId, string $semester, string $schoolYear): void { helper('attendance_comment'); $scoreModel = new SemesterScoreModel(); $commentModel = new ScoreCommentModel(); $scoreRows = $scoreModel ->select(['student_id', 'attendance_score']) ->where('class_section_id', $classSectionId) ->where('semester', $semester) ->where('school_year', $schoolYear) ->findAll(); if (empty($scoreRows)) { return; } $studentIds = array_values(array_unique(array_map( static fn($row) => (int) ($row['student_id'] ?? 0), $scoreRows ))); $studentIds = array_values(array_filter($studentIds, static fn($id) => $id > 0)); if (empty($studentIds)) { return; } $students = $this->studentModel ->select(['id', 'firstname']) ->whereIn('id', $studentIds) ->findAll(); $nameMap = []; foreach ($students as $st) { $nameMap[(int)$st['id']] = (string) ($st['firstname'] ?? ''); } $existing = $commentModel ->where('score_type', 'attendance') ->where('class_section_id', $classSectionId) ->where('semester', $semester) ->where('school_year', $schoolYear) ->whereIn('student_id', $studentIds) ->findAll(); $existingByStudent = []; foreach ($existing as $row) { $existingByStudent[(int)$row['student_id']] = $row; } foreach ($scoreRows as $row) { $sid = (int) ($row['student_id'] ?? 0); if ($sid <= 0) { continue; } $score = isset($row['attendance_score']) ? (float) $row['attendance_score'] : null; if ($score === null) { continue; } $auto = attendance_comment_from_score($score, $nameMap[$sid] ?? ''); if ($auto === null) { continue; } if (isset($existingByStudent[$sid])) { $commentModel->update($existingByStudent[$sid]['id'], [ 'comment' => $auto, ]); } else { $commentModel->insert([ 'student_id' => $sid, 'class_section_id' => $classSectionId, 'score_type' => 'attendance', 'semester' => $semester, 'school_year' => $schoolYear, 'comment' => $auto, 'commented_by' => null, 'created_at' => utc_now(), ]); } } } /** * Build grading rows with PTAP and semester scores (business + pk join). * * @param string $semEsc Escaped semester string for SQL * @param string $yrEsc Escaped school year string for SQL * @param string $schoolYear Raw school year value for filtering student_class * @return array */ public function buildGradingRows(string $semEsc, string $yrEsc, string $schoolYear, string $semesterRaw): array { $builder = $this->db->table('student_class sc') ->select([ 'cs.id AS section_pk', 'cs.class_section_id AS section_id', // BUSINESS id used in URLs 'cs.class_id AS class_id', // 13=KG, 1..11=Grade N, 12=Youth 'cs.class_section_name', 's.id AS student_id', 's.school_id', 's.firstname', 's.lastname', 's.is_active', 'e.enrollment_status', 'e.is_withdrawn', 'pl.level AS placement_level', // Prefer business-id match; fall back to pk match 'COALESCE(ss_b.ptap_score, ss_p.ptap_score) AS ss_ptap_score', 'COALESCE(ss_b.semester_score, ss_p.semester_score) AS ss_semester_score', 'COALESCE(ss_b.attendance_score, ss_p.attendance_score) AS ss_attendance_score', 'COALESCE(ss_b.homework_avg, ss_p.homework_avg) AS ss_homework_avg', 'COALESCE(ss_b.project_avg, ss_p.project_avg) AS ss_project_avg', 'COALESCE(ss_b.quiz_avg, ss_p.quiz_avg) AS ss_quiz_avg', 'COALESCE(ss_b.participation_score, ss_p.participation_score) AS ss_participation_score', 'COALESCE(ss_b.midterm_exam_score, ss_p.midterm_exam_score) AS ss_midterm_exam_score', 'COALESCE(ss_b.final_exam_score, ss_p.final_exam_score) AS ss_final_exam_score', // helpful to debug what matched: 'ss_b.class_section_id AS matched_biz_csid', 'ss_p.class_section_id AS matched_pk_csid' ]) ->distinct() ->join('`classSection` cs', 'cs.class_section_id = sc.class_section_id', 'left') ->join('students s', 's.id = sc.student_id', 'inner') ->join( 'enrollments e', "e.student_id = s.id AND e.school_year = {$yrEsc}", 'left' ) ->join( 'placement_levels pl', 'pl.student_id = s.id', 'left' ) // business-id join ->join( 'semester_scores ss_b', "ss_b.student_id = s.id AND ss_b.class_section_id = sc.class_section_id AND LOWER(ss_b.semester) = LOWER(TRIM({$semEsc})) AND ss_b.school_year = {$yrEsc}", 'left' ) // pk join ->join( 'semester_scores ss_p', "ss_p.student_id = s.id AND ss_p.class_section_id = cs.id AND LOWER(ss_p.semester) = LOWER(TRIM({$semEsc})) AND ss_p.school_year = {$yrEsc}", 'left' ) ->where('sc.school_year', $schoolYear) ->groupStart() ->where('s.is_active', 1) ->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false) ->orWhere('e.is_withdrawn', 1) ->groupEnd(); return $builder ->orderBy('cs.class_id', 'ASC') ->orderBy('cs.class_section_name', 'ASC') ->orderBy('s.lastname', 'ASC') ->orderBy('s.firstname', 'ASC') ->get()->getResultArray(); } public function calculateAttendanceScoreForStudent(int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): ?float { try { $result = $this->attendanceCalculator->calculate($studentId, $semester, $schoolYear, $classSectionId); $score = $result['attendance_score'] ?? null; if ($score === null || $score === '') { return null; } return round((float) $score, 2); } catch (\Throwable $e) { log_message( 'error', 'GradingController::calculateAttendanceScoreForStudent failed for ' . "student {$studentId}: " . $e->getMessage() ); } return null; } public 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; } public 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']; } public 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]); } public function countScoresAtOrAbove(array $scores, float $threshold): int { return count(array_filter( $scores, static fn ($score): bool => $score >= $threshold )); } /** * Fetch all scores and comments for a list of students based on semester and school year. * * @param array $studentIds List of student IDs. * @param string $semester Current semester (e.g., 'fall', 'spring'). * @param string $schoolYear Current school year (e.g., '2025-2026'). * @return array */ public function getAllScoresAndComments($studentIds, $semester, $schoolYear) { // Validate input parameters if (empty($studentIds) || !is_array($studentIds)) { return []; } if (empty($this->semester) || empty($this->schoolYear)) { throw new \InvalidArgumentException('Semester and school year must be provided'); } // Initialize models $models = [ 'final_exam' => new FinalExamModel(), 'homework' => new HomeworkModel(), 'midterm' => new MidtermExamModel(), 'project' => new ProjectModel(), 'quiz' => new QuizModel(), 'comments' => new ScoreCommentModel(), 'semester_scores' => new SemesterScoreModel(), 'student' => new StudentModel(), 'student_class' => new StudentClassModel(), 'teacher_class' => new TeacherClassModel(), 'config' => new ConfigurationModel(), 'attendance' => new AttendanceRecordModel() ]; // Get semester days configuration $semesterKey = strtolower($semester) === 'fall' ? 'total_semester1_days' : 'total_semester2_days'; $totalSemesterDays = $models['config']->getConfig($semesterKey) ?? 0; // Common query conditions $conditions = [ 'semester' => $this->semester, 'school_year' => $this->schoolYear ]; // Fetch all student data first $students = $models['student']->whereIn('id', $studentIds) ->where('school_year', $this->schoolYear) ->findAll(); if (empty($students)) { return []; } // Initialize result array with student data $allScores = []; foreach ($students as $student) { $className = $models['student_class']->getClassSectionsByStudentId($student['id'], $this->schoolYear); $updatedBy = $models['teacher_class']->getTeacherIdByClassSection($className, $this->semester, $this->schoolYear); $allScores[$student['id']] = [ 'school_id' => $student['school_id'], 'firstname' => $student['firstname'], 'lastname' => $student['lastname'], 'class_name' => $className, //'teacherId' => $updatedBy, 'comments' => [] // Initialize empty comments array ]; } // Fetch and process attendance data foreach ($studentIds as $studentId) { if (!isset($allScores[$studentId])) continue; $absences = $models['attendance']->getTotalAbsences($studentId, $this->semester, $this->schoolYear); $attendance = min((($totalSemesterDays - $absences + 1) / $totalSemesterDays) * 100, 100); $allScores[$studentId]['attendance'] = [ 'score' => round($attendance, 2), 'absences' => $absences, 'total_days' => $totalSemesterDays ]; } // Fetch and process all score types $scoreTypes = [ 'final_exam' => $models['final_exam'], 'homework' => $models['homework'], 'midterm' => $models['midterm'], 'project' => $models['project'], 'quiz' => $models['quiz'], 'semester_score' => $models['semester_scores'] ]; foreach ($scoreTypes as $type => $model) { $scores = $model->whereIn('student_id', $studentIds) ->where($conditions) ->findAll(); foreach ($scores as $score) { if ($type === 'semester_score') { $allScores[$score['student_id']][$type] = [ 'homework_avg' => $score['homework_avg'], 'quiz_avg' => $score['quiz_avg'], 'project_avg' => $score['project_avg'], 'midterm_exam_score' => $score['midterm_exam_score'], 'final_exam_score' => $score['final_exam_score'], 'attendance_score' => $score['attendance_score'], 'participation_score' => $score['participation_score'], 'ptap_score' => $score['ptap_score'], 'test_avg' => $score['test_avg'], 'semester_score' => $score['semester_score'], 'semester' => $score['semester'], 'school_year' => $score['school_year'] ]; } else { $allScores[$score['student_id']][$type] = $score; } } } // Fetch and process comments $comments = $models['comments']->whereIn('student_id', $studentIds) ->where($conditions) ->findAll(); foreach ($comments as $comment) { $allScores[$comment['student_id']]['comments'][] = $comment; } return $allScores; } 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; } }