Files
alrahma_sunday_school/app/Controllers/View/GradingController.php
T
2026-02-10 22:11:06 -05:00

2085 lines
81 KiB
PHP

<?php
namespace App\Controllers\View;
use App\Models\StudentModel;
use App\Models\StudentClassModel;
use App\Models\ConfigurationModel;
use CodeIgniter\Controller;
use CodeIgniter\Events\Events;
use App\Models\HomeworkModel;
use App\Models\QuizModel;
use App\Models\ProjectModel;
use App\Models\MidtermExamModel;
use App\Models\FinalExamModel;
use App\Models\SemesterScoreModel; // Assuming you use test_avg here
use App\Models\ScoreCommentModel;
use App\Models\TeacherClassModel;
use App\Models\UserModel;
use App\Models\ClassSectionModel;
use App\Models\CalendarModel;
use App\Models\AttendanceRecordModel;
use App\Services\Calculators\AttendanceCalculator;
use RuntimeException;
use Config\Services;
use App\Models\ParentMeetingScheduleModel;
use App\Models\CurrentFlagModel;
use App\Models\PlacementLevelModel;
use App\Models\PlacementBatchModel;
use App\Models\PlacementScoreModel;
use App\Models\GradingLockModel;
//use App\Models\ScoreModel;
class GradingController extends Controller
{
protected $semesterScoreService;
protected $db;
protected $configModel;
protected $homeworkModel;
protected $userModel;
protected $schoolYear;
protected $semester;
protected $studentClassModel;
protected $studentModel;
protected $teacherClassModel;
protected $classSection;
protected $attendanceCalculator;
protected $parentMeetingModel;
protected $placementLevelModel;
protected $placementBatchModel;
protected $placementScoreModel;
protected $gradingLockModel;
public function __construct()
{
// Initialize models
$this->teacherClassModel = new TeacherClassModel();
$this->homeworkModel = new HomeworkModel();
$this->userModel = new UserModel();
$this->studentClassModel = new StudentClassModel();
$this->studentModel = new StudentModel();
$this->configModel = new ConfigurationModel();
$this->db = \Config\Database::connect();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->classSection = new ClassSectionModel();
$this->attendanceCalculator = new AttendanceCalculator(
new AttendanceRecordModel(),
$this->configModel,
new CalendarModel()
);
$this->parentMeetingModel = new ParentMeetingScheduleModel();
$this->placementLevelModel = new PlacementLevelModel();
$this->placementBatchModel = new PlacementBatchModel();
$this->placementScoreModel = new PlacementScoreModel();
$this->gradingLockModel = new GradingLockModel();
// Log the service initialization
log_message('debug', 'Initializing SemesterScoreService');
$this->semesterScoreService = service('semesterScoreService');
}
public function show($type, $classSectionId, $studentId)
{
$scoreModel = $this->getModelByType($type);
$studentModel = new StudentModel();
$configModel = new ConfigurationModel();
$schoolYear = $configModel->getConfig('school_year');
$semester = $configModel->getConfig('semester');
$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 view("grading/{$type}", [
'student' => $student,
'scores' => $scores,
'type' => $type,
'classSectionId' => $classSectionId, // ✅ pass it manually
'semester' => $semester, // ✅ Pass semester to the view
'scoresLocked' => $scoresLocked,
]);
}
private 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 update()
{
$type = $this->request->getPost('type');
$studentId = $this->request->getPost('student_id');
$classSectionId = $this->request->getPost('class_section_id');
$configModel = new ConfigurationModel();
$studentModel = new StudentModel();
$schoolYear = $configModel->getConfig('school_year');
$semester = $configModel->getConfig('semester');
$model = $this->getModelByType($type);
$classSectionIdInt = (int) ($classSectionId ?? 0);
if ($classSectionIdInt > 0 && $this->isScoresLocked($classSectionIdInt, $semester, $schoolYear)) {
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
}
if (in_array($type, ['homework', 'quiz', 'project'])) {
$scoreIds = $this->request->getPost('score_ids');
$scores = $this->request->getPost('scores');
$comments = $this->request->getPost('comments');
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 = $this->request->getPost('score');
$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 = $this->request->getPost('comment');
$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 redirect()->back()->with('status', 'Scores updated successfully.');
}
public function grading()
{
$schoolYear = (string) $this->schoolYear;
$configuredSemester = (string) $this->semester;
$requestedClassId = (int) ($this->request->getGet('class_id') ?? 0);
$semesterOptions = $this->getSemestersForSchoolYear($schoolYear, $configuredSemester);
$session = session();
$requestedSemester = $this->normalizeSemesterInput($this->request->getGet('semester'));
$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);
// 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);
}
// Build structures keyed by BUSINESS section id
$grades = []; // class_id => [ ['class_section_id','class_section_name'], ... ]
$studentsBySection = []; // section_id => [ students... ]
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;
$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,
'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 view('grading/grading_main', [
'grades' => $grades,
'studentsBySection' => $studentsBySection,
'semester' => $semester,
'schoolYear' => $schoolYear,
'requestedClassId' => $requestedClassId,
'semesterOptions' => $semesterOptions,
'scoresReleased' => $scoresReleased,
'scoresReleasedFall' => $scoresReleasedFall,
'scoresReleasedSpring' => $scoresReleasedSpring,
'scoreLocks' => $scoreLocks,
]);
}
public function toggleScoreLock()
{
$classSectionId = (int) ($this->request->getPost('class_section_id') ?? 0);
$semester = trim((string) ($this->request->getPost('semester') ?? $this->semester));
$schoolYear = trim((string) ($this->request->getPost('school_year') ?? $this->schoolYear));
if ($classSectionId <= 0 || $semester === '' || $schoolYear === '') {
return redirect()->back()->with('error', '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 redirect()->back()->with('status', '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 redirect()->back()->with('status', 'Scores locked for this class.');
}
public function lockAllScores()
{
$semester = trim((string) ($this->request->getPost('semester') ?? $this->semester));
$schoolYear = trim((string) ($this->request->getPost('school_year') ?? $this->schoolYear));
if ($semester === '' || $schoolYear === '') {
return redirect()->back()->with('error', '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 redirect()->back()->with('error', '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 redirect()->back()->with('status', 'Scores locked for all classes.');
}
private function isScoresLocked(int $classSectionId, string $semester, string $schoolYear): bool
{
return $this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear);
}
public function updatePlacementLevel()
{
$studentId = (int) ($this->request->getPost('student_id') ?? 0);
$levelRaw = trim((string) ($this->request->getPost('placement_level') ?? ''));
$schoolYear = (string) ($this->request->getPost('school_year') ?? $this->schoolYear);
if ($studentId <= 0 || $schoolYear === '') {
return redirect()->back()->with('error', 'Missing student or school year.');
}
$level = $levelRaw === '' ? null : (int) $levelRaw;
if ($level !== null && !in_array($level, [1, 2, 3], true)) {
return redirect()->back()->with('error', 'Invalid placement level.');
}
$existing = $this->placementLevelModel
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->first();
if ($level === null) {
if ($existing) {
$this->placementLevelModel->delete($existing['id']);
}
return redirect()->back()->with('status', 'Placement level cleared.');
}
$payload = [
'student_id' => $studentId,
'school_year' => $schoolYear,
'level' => $level,
'updated_by' => session()->get('user_id'),
];
if ($existing) {
$this->placementLevelModel->update($existing['id'], $payload);
} else {
$payload['created_by'] = session()->get('user_id');
$this->placementLevelModel->insert($payload);
}
return redirect()->back()->with('status', 'Placement level updated.');
}
public function placement()
{
$classSectionId = (int) ($this->request->getGet('class_section_id') ?? 0);
$schoolYear = (string) ($this->request->getGet('school_year') ?? $this->schoolYear);
$placementTest = (string) ($this->request->getGet('placement_test') ?? '');
$openFlag = (string) ($this->request->getGet('open') ?? '');
if ($classSectionId <= 0 || $schoolYear === '') {
$showStudents = ($placementTest !== '' && $openFlag === '1');
$students = $showStudents ? $this->fetchActiveStudentsWithSection($schoolYear) : [];
$batches = $this->fetchPlacementBatches($schoolYear);
$batchDetails = $this->fetchPlacementBatchDetails($batches, $schoolYear);
return view('grading/placement_index', [
'schoolYear' => $schoolYear,
'students' => $students,
'batches' => $batches,
'batchDetails' => $batchDetails,
'placementTest' => $placementTest,
'showStudents' => $showStudents,
]);
}
$sectionName = $this->classSection->getClassSectionNameBySectionId($classSectionId) ?? '';
$classId = $this->classSection->getClassId($classSectionId);
$students = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, null, $schoolYear);
$studentIds = array_values(array_filter(array_map(
static fn($row) => (int) ($row['student_id'] ?? 0),
$students
), static fn($id) => $id > 0));
$levels = [];
if (!empty($studentIds)) {
$rows = $this->placementLevelModel
->whereIn('student_id', $studentIds)
->where('school_year', $schoolYear)
->findAll();
foreach ($rows as $row) {
$levels[(int) $row['student_id']] = $row['level'] ?? null;
}
}
return view('grading/placement', [
'classSectionId' => $classSectionId,
'classSectionName' => $sectionName,
'classId' => $classId,
'schoolYear' => $schoolYear,
'students' => $students,
'levels' => $levels,
]);
}
public function updatePlacementLevels()
{
$classSectionId = (int) ($this->request->getPost('class_section_id') ?? 0);
$schoolYear = (string) ($this->request->getPost('school_year') ?? $this->schoolYear);
$levels = $this->request->getPost('placement_level') ?? [];
if ($classSectionId <= 0 || $schoolYear === '') {
return redirect()->back()->with('error', 'Missing class section or school year.');
}
$students = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, null, $schoolYear);
$validIds = array_values(array_filter(array_map(
static fn($row) => (int) ($row['student_id'] ?? 0),
$students
), static fn($id) => $id > 0));
$validSet = array_flip($validIds);
$existingRows = [];
if (!empty($validIds)) {
$rows = $this->placementLevelModel
->whereIn('student_id', $validIds)
->where('school_year', $schoolYear)
->findAll();
foreach ($rows as $row) {
$existingRows[(int) $row['student_id']] = $row;
}
}
$userId = session()->get('user_id');
foreach ($levels as $studentIdRaw => $levelRaw) {
$studentId = (int) $studentIdRaw;
if (!isset($validSet[$studentId])) {
continue;
}
$levelRaw = trim((string) $levelRaw);
$level = $levelRaw === '' ? null : (int) $levelRaw;
if ($level !== null && !in_array($level, [1, 2, 3], true)) {
continue;
}
if ($level === null) {
if (isset($existingRows[$studentId])) {
$this->placementLevelModel->delete($existingRows[$studentId]['id']);
}
continue;
}
$payload = [
'student_id' => $studentId,
'school_year' => $schoolYear,
'level' => $level,
'updated_by' => $userId,
];
if (isset($existingRows[$studentId])) {
$this->placementLevelModel->update($existingRows[$studentId]['id'], $payload);
} else {
$payload['created_by'] = $userId;
$this->placementLevelModel->insert($payload);
}
}
return redirect()->back()->with('status', 'Placement levels updated.');
}
public function updatePlacementLevelsAll()
{
$schoolYear = (string) ($this->request->getPost('school_year') ?? $this->schoolYear);
$placementTest = (string) ($this->request->getPost('placement_test') ?? '');
$levels = $this->request->getPost('placement_level') ?? [];
if ($schoolYear === '' || $placementTest === '') {
return redirect()->back()->with('error', 'Missing placement test or school year.');
}
$students = $this->fetchActiveStudentsWithSection($schoolYear);
$validIds = array_values(array_filter(array_map(
static fn($row) => (int) ($row['student_id'] ?? 0),
$students
), static fn($id) => $id > 0));
$validSet = array_flip($validIds);
$userId = session()->get('user_id');
$batchId = $this->placementBatchModel->insert([
'placement_test' => $placementTest,
'school_year' => $schoolYear,
'created_by' => $userId,
'updated_by' => $userId,
]);
if (!$batchId) {
return redirect()->back()->with('error', 'Unable to create placement batch.');
}
$savedCount = 0;
foreach ($levels as $studentIdRaw => $levelRaw) {
$studentId = (int) $studentIdRaw;
if (!isset($validSet[$studentId])) {
continue;
}
$levelRaw = trim((string) $levelRaw);
if ($levelRaw === '') {
continue;
}
$level = (int) $levelRaw;
if ($level < 0 || $level > 100) {
continue;
}
$this->placementScoreModel->insert([
'batch_id' => (int) $batchId,
'student_id' => $studentId,
'score' => $level,
'created_by' => $userId,
'updated_by' => $userId,
]);
$savedCount++;
}
if ($savedCount === 0) {
$this->placementBatchModel->delete((int) $batchId);
return redirect()->back()->with('error', 'No scores entered. Batch not saved.');
}
return redirect()->to(base_url('grading/placement'))
->with('status', 'Placement batch saved.');
}
public function editPlacementBatch(int $batchId)
{
$batch = $this->placementBatchModel->find($batchId);
if (!$batch) {
return redirect()->to(base_url('grading/placement'))
->with('error', 'Placement batch not found.');
}
$schoolYear = (string) ($batch['school_year'] ?? $this->schoolYear);
$students = $this->fetchActiveStudentsWithSection($schoolYear);
$scores = $this->fetchPlacementScoresForBatch($batchId);
return view('grading/placement_batch', [
'batch' => $batch,
'students' => $students,
'scores' => $scores,
]);
}
public function updatePlacementBatch(int $batchId)
{
$batch = $this->placementBatchModel->find($batchId);
if (!$batch) {
return redirect()->to(base_url('grading/placement'))
->with('error', 'Placement batch not found.');
}
$schoolYear = (string) ($batch['school_year'] ?? $this->schoolYear);
$levels = $this->request->getPost('placement_level') ?? [];
$students = $this->fetchActiveStudentsWithSection($schoolYear);
$validIds = array_values(array_filter(array_map(
static fn($row) => (int) ($row['student_id'] ?? 0),
$students
), static fn($id) => $id > 0));
$validSet = array_flip($validIds);
$existing = $this->fetchPlacementScoresForBatch($batchId);
$userId = session()->get('user_id');
foreach ($levels as $studentIdRaw => $scoreRaw) {
$studentId = (int) $studentIdRaw;
if (!isset($validSet[$studentId])) {
continue;
}
$scoreRaw = trim((string) $scoreRaw);
if ($scoreRaw === '') {
if (isset($existing[$studentId])) {
$this->placementScoreModel->delete($existing[$studentId]['id']);
}
continue;
}
$score = (int) $scoreRaw;
if ($score < 0 || $score > 100) {
continue;
}
if (isset($existing[$studentId])) {
$this->placementScoreModel->update($existing[$studentId]['id'], [
'score' => $score,
'updated_by' => $userId,
]);
} else {
$this->placementScoreModel->insert([
'batch_id' => $batchId,
'student_id' => $studentId,
'score' => $score,
'created_by' => $userId,
'updated_by' => $userId,
]);
}
}
$this->placementBatchModel->update($batchId, [
'updated_by' => $userId,
]);
return redirect()->to(base_url('grading/placement'))
->with('status', 'Placement batch updated.');
}
private function fetchActiveStudentsWithSection(string $schoolYear): array
{
return $this->db->table('students s')
->select('s.id AS student_id, s.school_id, s.firstname, s.lastname, sc.class_section_id, cs.class_section_name, c.class_name')
->join('student_class sc', 'sc.student_id = s.id AND sc.school_year = ' . $this->db->escape($schoolYear), 'left')
->join('`classSection` cs', 'cs.class_section_id = sc.class_section_id', 'left')
->join('classes c', 'c.id = cs.class_id', 'left')
->where('s.is_active', 1)
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->getResultArray();
}
private function fetchPlacementBatches(string $schoolYear): array
{
return $this->placementBatchModel
->where('school_year', $schoolYear)
->orderBy('created_at', 'DESC')
->findAll();
}
private function fetchPlacementBatchDetails(array $batches, string $schoolYear): array
{
if (empty($batches)) {
return [];
}
$batchIds = array_values(array_filter(array_map(
static fn($row) => (int) ($row['id'] ?? 0),
$batches
), static fn($id) => $id > 0));
if (empty($batchIds)) {
return [];
}
$rows = $this->db->table('placement_scores ps')
->select('ps.batch_id, ps.score, s.school_id, s.firstname, s.lastname, cs.class_section_name, c.class_name')
->join('students s', 's.id = ps.student_id', 'inner')
->join('student_class sc', 'sc.student_id = s.id AND sc.school_year = ' . $this->db->escape($schoolYear), 'left')
->join('`classSection` cs', 'cs.class_section_id = sc.class_section_id', 'left')
->join('classes c', 'c.id = cs.class_id', 'left')
->whereIn('ps.batch_id', $batchIds)
->where('s.is_active', 1)
->orderBy('ps.batch_id', 'ASC')
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->getResultArray();
$details = [];
foreach ($rows as $row) {
$bid = (int) ($row['batch_id'] ?? 0);
if ($bid <= 0) continue;
$details[$bid][] = $row;
}
return $details;
}
private function fetchPlacementScoresForBatch(int $batchId): array
{
$rows = $this->placementScoreModel
->where('batch_id', $batchId)
->findAll();
$scores = [];
foreach ($rows as $row) {
$scores[(int) $row['student_id']] = $row;
}
return $scores;
}
public function belowSixty()
{
$configuredSemester = (string) $this->semester;
$configuredYear = (string) $this->schoolYear;
$requestedSemester = trim((string)($this->request->getGet('semester') ?? ''));
$requestedYear = trim((string)($this->request->getGet('school_year') ?? ''));
$semester = $requestedSemester !== '' ? $requestedSemester : ($configuredSemester !== '' ? $configuredSemester : 'Fall');
$schoolYear = $requestedYear !== '' ? $requestedYear : $configuredYear;
$schoolYears = $this->getSchoolYearsForScores($schoolYear);
$rows = $this->fetchBelowSixtyRows($schoolYear, $semester);
return view('grading/below_sixty', [
'rows' => $rows,
'semester' => $semester,
'schoolYear' => $schoolYear,
'schoolYears' => $schoolYears,
]);
}
public function sendBelowSixtyEmail()
{
$studentId = (int)$this->request->getPost('student_id');
$semester = trim((string)$this->request->getPost('semester'));
$schoolYear = trim((string)$this->request->getPost('school_year'));
if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
return redirect()->back()->with('error', 'Missing student or term.');
}
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
if (empty($row)) {
return redirect()->back()->with('error', 'Student record not found for the selected term.');
}
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
$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'] ?? '',
];
Events::trigger('below60.email', $payload);
return redirect()->back()->with('status', 'Email sent to parent(s).');
}
public function updateBelowSixtyStatus()
{
$studentId = (int)$this->request->getPost('student_id');
$semester = trim((string)$this->request->getPost('semester'));
$schoolYear = trim((string)$this->request->getPost('school_year'));
$status = trim((string)$this->request->getPost('status'));
$note = trim((string)$this->request->getPost('note'));
if ($studentId <= 0 || $semester === '' || $schoolYear === '' || $status === '') {
return redirect()->back()->with('error', 'Missing required status data.');
}
$status = ucfirst(strtolower($status));
if (!in_array($status, ['Open', 'Closed'], true)) {
return redirect()->back()->with('error', 'Invalid status.');
}
$flagModel = new CurrentFlagModel();
$semKey = strtolower(trim($semester));
$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();
if ($existing) {
$data = [
'flag_state' => $status,
'flag_datetime' => $now,
'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);
}
}
$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;
}
$flagModel->insert($data);
}
return redirect()->back()->with('status', 'Status updated.');
}
public function scheduleBelowSixty()
{
$studentId = (int)($this->request->getGet('student_id') ?? 0);
$semester = trim((string)($this->request->getGet('semester') ?? ''));
$schoolYear = trim((string)($this->request->getGet('school_year') ?? ''));
if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
return redirect()->back()->with('error', 'Missing student or term.');
}
$context = $this->fetchBelowSixtyMeetingContext($studentId, $schoolYear, $semester);
if (empty($context)) {
return redirect()->back()->with('error', 'Student record not found for the selected term.');
}
return view('grading/schedule_meeting', [
'studentId' => $studentId,
'studentName' => $context['student_name'],
'parentName' => $context['parent_name'],
'classSection' => $context['class_section_name'],
'semester' => $semester,
'schoolYear' => $schoolYear,
]);
}
public function saveBelowSixtyMeeting()
{
$studentId = (int)$this->request->getPost('student_id');
$semester = trim((string)$this->request->getPost('semester'));
$schoolYear = trim((string)$this->request->getPost('school_year'));
$date = trim((string)$this->request->getPost('date'));
$time = trim((string)$this->request->getPost('time'));
$notes = trim((string)$this->request->getPost('notes'));
if ($studentId <= 0 || $semester === '' || $schoolYear === '' || $date === '') {
return redirect()->back()->withInput()->with('error', 'Missing required fields.');
}
$context = $this->fetchBelowSixtyMeetingContext($studentId, $schoolYear, $semester);
if (empty($context)) {
return redirect()->back()->withInput()->with('error', '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 redirect()->to(base_url('grading/below-60') . ($query ? ('?' . $query) : ''))
->with('status', 'Meeting scheduled and added to calendars.');
}
return redirect()->back()->withInput()->with('error', 'Failed to schedule the meeting.');
}
public function toggleParentScoresRelease()
{
$semester = (string) ($this->request->getPost('semester') ?? '');
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 redirect()->back()->with('status', $msg);
}
return redirect()->back()->with('error', 'Unable to update the scores release flag.');
}
private 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;
}
private 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);
}
private 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 refreshSemesterScores()
{
$classSectionId = (int) ($this->request->getPost('class_section_id') ?? 0);
if ($classSectionId <= 0) {
return redirect()->back()->with('error', 'Missing class section.');
}
$requestedSemester = (string) ($this->request->getPost('semester') ?? '');
$requestedYear = (string) ($this->request->getPost('school_year') ?? '');
$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 redirect()->back()->with('error', '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 redirect()->back()->with('error', 'Refresh failed. Check logs.');
}
return redirect()->back()->with('status', 'Semester scores refreshed for this class/term.');
}
private 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
*/
private function buildGradingRows(string $semEsc, string $yrEsc, string $schoolYear): 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',
'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'
])
->join('`classSection` cs', 'cs.class_section_id = sc.class_section_id', 'left')
->join('students s', 's.id = sc.student_id', 'inner')
->join(
'placement_levels pl',
"pl.student_id = s.id AND pl.school_year = {$yrEsc}",
'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)
->where('s.is_active', 1); // Exclude removed/inactive students
return $builder
->orderBy('cs.class_id', 'ASC')
->orderBy('cs.class_section_name', 'ASC')
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()->getResultArray();
}
private 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;
}
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 fetchBelowSixtyRows(string $schoolYear, string $semester): array
{
$semesterKey = strtolower(trim($semester));
$rows = $this->db->table('semester_scores ss')
->select([
's.id AS student_id',
's.school_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.final_exam_score',
'ss.semester_score',
])
->join('students s', 's.id = ss.student_id', 'inner')
->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
->where('s.is_active', 1)
->where('ss.school_year', $schoolYear)
->where("LOWER(TRIM(ss.semester))", $semesterKey)
->where('ss.semester_score IS NOT NULL', null, false)
->where('ss.semester_score <', 60)
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', '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, comment, created_at')
->where('score_type', 'general')
->where('school_year', $schoolYear)
->where("LOWER(TRIM(semester))", $semesterKey)
->whereIn('student_id', $studentIds)
->orderBy('created_at', 'DESC')
->get()
->getResultArray();
foreach ($commentRows as $row) {
$sid = (int)($row['student_id'] ?? 0);
if ($sid > 0 && !isset($commentMap[$sid])) {
$commentMap[$sid] = (string)($row['comment'] ?? '');
}
}
}
$statusMap = [];
if (!empty($studentIds)) {
$flagRows = $this->db->table('current_flag')
->select('student_id, flag_state')
->where('flag', 'grade')
->where('school_year', $schoolYear)
->where("LOWER(TRIM(semester))", $semesterKey)
->whereIn('student_id', $studentIds)
->get()
->getResultArray();
foreach ($flagRows as $row) {
$sid = (int)($row['student_id'] ?? 0);
if ($sid <= 0) continue;
$statusMap[$sid] = (string)($row['flag_state'] ?? '');
}
}
foreach ($rows as &$row) {
$sid = (int)($row['student_id'] ?? 0);
$row['comment'] = $commentMap[$sid] ?? '';
$flagState = strtolower(trim((string)($statusMap[$sid] ?? '')));
$row['status'] = ($flagState === 'closed' || $flagState === 'canceled') ? 'Closed' : 'Open';
}
unset($row);
return $rows;
}
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('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
->where('ss.school_year', $schoolYear)
->where('ss.student_id', $studentId)
->where('s.is_active', 1)
->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 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'] ?? ''),
];
}
/**
* 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;
}
public function getScoreComment()
{
// 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;
}
/**
* 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
*/
private 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;
}
}