diff --git a/app/Config/Services.php b/app/Config/Services.php
index 1468871..929dacb 100644
--- a/app/Config/Services.php
+++ b/app/Config/Services.php
@@ -329,4 +329,167 @@ class Services extends BaseService
new \App\Libraries\InvoiceLedgerService()
);
}
+
+ public static function enrollmentWithdrawal(bool $getShared = true): \App\Services\EnrollmentWithdrawalService
+ {
+ if ($getShared) {
+ return static::getSharedInstance('enrollmentWithdrawal');
+ }
+
+ $db = \Config\Database::connect();
+
+ return new \App\Services\EnrollmentWithdrawalService(
+ $db,
+ model(\App\Models\StudentModel::class),
+ model(\App\Models\EnrollmentModel::class),
+ model(\App\Models\StudentClassModel::class),
+ model(\App\Models\ClassSectionModel::class),
+ model(\App\Models\UserModel::class),
+ model(\App\Models\InvoiceModel::class),
+ model(\App\Models\RefundModel::class)
+ );
+ }
+
+ public static function teacherSubmissionReport(bool $getShared = true): \App\Services\TeacherSubmissionReportService
+ {
+ if ($getShared) {
+ return static::getSharedInstance('teacherSubmissionReport');
+ }
+
+ return new \App\Services\TeacherSubmissionReportService(
+ \Config\Database::connect(),
+ model(\App\Models\ConfigurationModel::class),
+ model(\App\Models\StudentClassModel::class),
+ model(\App\Models\ClassSectionModel::class),
+ model(\App\Models\UserModel::class)
+ );
+ }
+
+ public static function administratorDashboard(bool $getShared = true): \App\Services\AdministratorDashboardService
+ {
+ if ($getShared) {
+ return static::getSharedInstance('administratorDashboard');
+ }
+
+ return new \App\Services\AdministratorDashboardService(
+ \Config\Database::connect(),
+ model(\App\Models\UserModel::class),
+ model(\App\Models\LoginActivityModel::class)
+ );
+ }
+
+ public static function adminNotificationSettings(bool $getShared = true): \App\Services\AdminNotificationSettingsService
+ {
+ if ($getShared) {
+ return static::getSharedInstance('adminNotificationSettings');
+ }
+
+ return new \App\Services\AdminNotificationSettingsService(
+ \Config\Database::connect(),
+ model(\App\Models\AdminNotificationSubjectModel::class)
+ );
+ }
+
+ public static function administratorDirectory(bool $getShared = true): \App\Services\AdministratorDirectoryService
+ {
+ if ($getShared) {
+ return static::getSharedInstance('administratorDirectory');
+ }
+
+ return new \App\Services\AdministratorDirectoryService(
+ \Config\Database::connect(),
+ model(\App\Models\StudentClassModel::class),
+ model(\App\Models\UserModel::class),
+ model(\App\Models\UserRoleModel::class),
+ model(\App\Models\InvoiceModel::class),
+ model(\App\Models\StudentModel::class)
+ );
+ }
+
+ public static function gradingScore(bool $getShared = true): \App\Services\GradingScoreService
+ {
+ if ($getShared) {
+ return static::getSharedInstance('gradingScore');
+ }
+
+ $configModel = model(\App\Models\ConfigurationModel::class);
+ $attendanceCalculator = new \App\Services\Calculators\AttendanceCalculator(
+ model(\App\Models\AttendanceRecordModel::class),
+ $configModel,
+ model(\App\Models\CalendarModel::class)
+ );
+
+ return new \App\Services\GradingScoreService(
+ \Config\Database::connect(),
+ $configModel,
+ model(\App\Models\HomeworkModel::class),
+ model(\App\Models\UserModel::class),
+ model(\App\Models\StudentClassModel::class),
+ model(\App\Models\StudentModel::class),
+ model(\App\Models\TeacherClassModel::class),
+ model(\App\Models\ClassSectionModel::class),
+ $attendanceCalculator,
+ model(\App\Models\GradingLockModel::class),
+ static::semesterScoreService(),
+ (string) $configModel->getConfig('school_year'),
+ (string) getSemester()
+ );
+ }
+
+ public static function placementGrading(bool $getShared = true): \App\Services\PlacementGradingService
+ {
+ if ($getShared) {
+ return static::getSharedInstance('placementGrading');
+ }
+
+ $configModel = model(\App\Models\ConfigurationModel::class);
+
+ return new \App\Services\PlacementGradingService(
+ \Config\Database::connect(),
+ model(\App\Models\StudentModel::class),
+ model(\App\Models\PlacementLevelModel::class),
+ model(\App\Models\PlacementBatchModel::class),
+ model(\App\Models\PlacementScoreModel::class),
+ (string) $configModel->getConfig('school_year')
+ );
+ }
+
+ public static function belowSixty(bool $getShared = true): \App\Services\BelowSixtyService
+ {
+ if ($getShared) {
+ return static::getSharedInstance('belowSixty');
+ }
+
+ $configModel = model(\App\Models\ConfigurationModel::class);
+
+ return new \App\Services\BelowSixtyService(
+ \Config\Database::connect(),
+ $configModel,
+ model(\App\Models\StudentModel::class),
+ model(\App\Models\StudentClassModel::class),
+ model(\App\Models\ParentMeetingScheduleModel::class),
+ model(\App\Models\UserModel::class),
+ (string) $configModel->getConfig('school_year'),
+ (string) getSemester()
+ );
+ }
+
+ public static function studentDecision(bool $getShared = true): \App\Services\StudentDecisionService
+ {
+ if ($getShared) {
+ return static::getSharedInstance('studentDecision');
+ }
+
+ $configModel = model(\App\Models\ConfigurationModel::class);
+
+ return new \App\Services\StudentDecisionService(
+ \Config\Database::connect(),
+ $configModel,
+ model(\App\Models\StudentModel::class),
+ model(\App\Models\StudentClassModel::class),
+ model(\App\Models\UserModel::class),
+ (string) $configModel->getConfig('school_year'),
+ (string) getSemester()
+ );
+ }
}
diff --git a/app/Controllers/View/AdministratorController.php b/app/Controllers/View/AdministratorController.php
index 347ce04..812b3bd 100644
--- a/app/Controllers/View/AdministratorController.php
+++ b/app/Controllers/View/AdministratorController.php
@@ -4,91 +4,32 @@ namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\UserModel;
use App\Models\RoleModel;
-use App\Models\PermissionModel;
-use App\Models\RolePermissionModel;
-use App\Models\StudentModel;
-use App\Models\LoginActivityModel;
-use CodeIgniter\Controller;
-use App\Models\InvoiceModel;
-use App\Models\ClassSectionModel;
use App\Models\UserRoleModel;
use App\Models\ConfigurationModel;
-use App\Models\RefundModel;
-use App\Models\EnrollmentModel;
-use App\Models\AdminNotificationSubjectModel;
-use Doctrine\DBAL\Configuration;
-use App\Services\FeeCalculationService;
-use App\Models\StudentClassModel;
-use App\Models\StudentSectionDistributionDraftModel;
-use App\Controllers\View\EmailController;
-use App\Controllers\View\InvoiceController;
-use App\Libraries\RefundEligibilityService;
use App\Models\StaffAttendanceModel;
use App\Libraries\StaffTimeOffLinkService;
-use App\Models\AttendanceDayModel;
-use App\Models\ScoreCommentModel;
-use App\Models\SemesterScoreModel;
-use App\Models\TeacherClassModel;
-use App\Models\TeacherSubmissionNotificationHistoryModel;
-use App\Models\ExamDraftModel;
-use App\Models\HomeworkModel;
use App\Services\SemesterRangeService;
-use App\Support\Enrollment\DeliberationDecision;
-
-use CodeIgniter\Events\Events;
class AdministratorController extends BaseController
{
- protected $permissionModel;
- protected $rolePermissionModel;
protected $roleModel;
protected $userModel;
protected $userRoleModel;
- protected $db;
protected $configModel;
protected $semester;
protected $schoolYear;
- protected $studentModel;
- protected $loginActivityModel;
- protected $invoiceModel;
- protected $refundModel;
- protected $enrollmentModel;
- protected $classSectionModel;
- protected $studentClassModel;
protected $staffAttendanceModel;
- protected $adminNotificationSubjectModel;
public function __construct()
{
helper('auth');
- // Load models
- $this->rolePermissionModel = new RolePermissionModel();
- $this->permissionModel = new PermissionModel();
$this->roleModel = new RoleModel();
$this->userModel = new UserModel();
$this->configModel = new ConfigurationModel();
- $this->studentModel = new StudentModel();
- $this->loginActivityModel = new LoginActivityModel();
$this->userRoleModel = new UserRoleModel();
- $this->invoiceModel = new InvoiceModel();
- $this->refundModel = new RefundModel();
- $this->enrollmentModel = new EnrollmentModel();
- $this->classSectionModel = new ClassSectionModel();
- $this->adminNotificationSubjectModel = new AdminNotificationSubjectModel();
-
$this->semester = getSemester();
$this->schoolYear = $this->configModel->getConfig('school_year');
- $this->studentClassModel = new StudentClassModel();
$this->staffAttendanceModel = new StaffAttendanceModel();
- // Load the database service
- $this->db = \Config\Database::connect();
- // Check if the database connection is established
- if (!$this->db->connect()) {
- log_message('error', 'Database connection failed.');
- throw new \Exception('Database connection failed.');
- } else {
- log_message('info', 'Database connection successful.');
- }
}
/**
@@ -136,55 +77,6 @@ class AdministratorController extends BaseController
return $dates;
}
- private function getPreviousSchoolYear(string $schoolYear): string
- {
- $schoolYear = trim($schoolYear);
- if ($schoolYear === '') {
- return '';
- }
-
- if (preg_match('/^(\d{4})\s*-\s*(\d{4})$/', $schoolYear, $m)) {
- return ((int)$m[1] - 1) . '-' . ((int)$m[2] - 1);
- }
-
- if (preg_match('/^(\d{4})\s*-\s*(\d{2})$/', $schoolYear, $m)) {
- $start = (int)$m[1] - 1;
- $end = (int)$m[2] - 1;
- if ($end < 0) {
- $end += 100;
- }
- return sprintf('%04d-%02d', $start, $end);
- }
-
- if (preg_match('/^\d{4}$/', $schoolYear)) {
- return (string)((int)$schoolYear - 1);
- }
-
- return '';
- }
-
- private function getSchoolYearStartYear(string $schoolYear): ?int
- {
- $schoolYear = trim($schoolYear);
- if ($schoolYear === '') {
- return null;
- }
-
- if (preg_match('/^(\d{4})\s*-\s*(\d{4})$/', $schoolYear, $m)) {
- return (int)$m[1];
- }
-
- if (preg_match('/^(\d{4})\s*-\s*(\d{2})$/', $schoolYear, $m)) {
- return (int)$m[1];
- }
-
- if (preg_match('/^\d{4}$/', $schoolYear)) {
- return (int)$schoolYear;
- }
-
- return null;
- }
-
/**
* Admin self-service absence/vacation page (same features as teacher page)
*/
@@ -383,252 +275,6 @@ class AdministratorController extends BaseController
return false;
}
- public function administratorDashboard()
- {
- helper('url');
-
- $searchData = $this->buildUserSearchData((string) $this->request->getGet('query'));
-
- return view('administrator/administratordashboard', array_merge($searchData, [
- 'dashboardEndpoint' => site_url('api/administrator/dashboard'),
- ]));
- }
-
- public function dashboardMetrics()
- {
- return $this->response->setJSON($this->buildDashboardMetrics());
- }
-
- private function buildDashboardMetrics(): array
- {
- $recentActivities = $this->loginActivityModel->getLastActivities(4);
- if (!is_array($recentActivities)) {
- $recentActivities = [];
- }
-
- $totalAdmins = (int) ($this->userModel->countAdminsBySchoolYear($this->schoolYear) ?? 0);
-
- $teachers = $this->userModel->getUsersByRoleAndSchoolYear('teacher', $this->schoolYear);
- $totalTeachers = $this->countUniqueEntities($teachers);
-
- $teacherAssistants = $this->userModel->getUsersByRoleAndSchoolYear('teacher_assistant', $this->schoolYear);
- $totalTeacherAssistants = $this->countUniqueEntities($teacherAssistants);
-
- $parents = $this->userModel->getUsersByRoleAndSchoolYear('parent', $this->schoolYear);
- $totalParents = $this->countUniqueEntities($parents);
-
- // Count only students that have a class assigned and exist in student_class for the current school year
- $totalStudents = (int) (
- $this->db->table('student_class')
- ->select('COUNT(DISTINCT student_class.student_id) AS cnt')
- ->join('students', 'students.id = student_class.student_id', 'inner')
- ->where('student_class.school_year', $this->schoolYear)
- ->where('student_class.class_section_id IS NOT NULL', null, false)
- ->where('students.is_active', 1)
- ->get()
- ->getRow('cnt')
- ?? 0
- );
-
- return [
- 'counts' => [
- 'students' => $totalStudents,
- 'teachers' => $totalTeachers,
- 'teacherAssistants' => $totalTeacherAssistants,
- 'admins' => $totalAdmins,
- 'parents' => $totalParents,
- ],
- 'recentActivities' => array_map(static function ($activity) {
- if (!is_array($activity)) {
- return [];
- }
- return [
- 'login_time' => $activity['login_time'] ?? null,
- 'email' => $activity['email'] ?? null,
- ];
- }, $recentActivities),
- 'meta' => [
- 'schoolYear' => $this->schoolYear,
- 'semester' => $this->semester,
- ],
- ];
- }
-
- private function countUniqueEntities($rows): int
- {
- if (!is_array($rows) || $rows === []) {
- return 0;
- }
-
- $ids = [];
- foreach ($rows as $row) {
- if (!is_array($row)) {
- continue;
- }
- if (isset($row['id'])) {
- $ids[] = (int) $row['id'];
- continue;
- }
- if (isset($row['user_id'])) {
- $ids[] = (int) $row['user_id'];
- }
- }
-
- return count(array_unique($ids));
- }
-
- public function userSearch()
- {
- $data = $this->buildUserSearchData((string) $this->request->getGet('query'));
-
- return view('administrator/search_results', $data);
- }
-
- private function buildUserSearchData(string $query): array
- {
- $q = trim($query);
-
- if ($q === '') {
- return [
- 'query' => '',
- 'results' => [],
- 'scope_used' => 'unscoped-raw',
- 'scope_label' => 'all years/semesters (raw)',
- 'total_found' => 0,
- ];
- }
-
- $db = $this->db;
-
- // 1) Tokenize input: split by whitespace and punctuation, keep meaningful pieces
- $rawTokens = preg_split('/[,\s]+/u', $q, -1, PREG_SPLIT_NO_EMPTY) ?: [];
- $tokens = array_values(array_filter(array_map('trim', $rawTokens)));
-
- // 2) Build phone variants for any token that looks numeric-ish
- $phoneMap = []; // token => variants[]
- foreach ($tokens as $t) {
- $digits = preg_replace('/\D+/', '', $t);
- if ($digits === '') {
- continue;
- }
-
- $v = [];
- if (strlen($digits) >= 7) {
- // base forms
- $v[] = $digits;
- if (strlen($digits) === 10) {
- $v[] = sprintf('(%s)-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
- $v[] = sprintf('%s-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
- $v[] = sprintf('%s %s %s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
- // country-code forms
- $v[] = '1' . $digits;
- $v[] = '+1' . $digits;
- $v[] = '+1 ' . sprintf('(%s) %s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
- $v[] = '+1-' . sprintf('%s-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
- } elseif (strlen($digits) === 11 && str_starts_with($digits, '1')) {
- $ten = substr($digits, 1);
- $v[] = $ten;
- $v[] = sprintf('(%s)-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
- $v[] = sprintf('%s-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
- $v[] = sprintf('%s %s %s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
- $v[] = '+1' . $ten;
- $v[] = '+1 ' . sprintf('(%s) %s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
- $v[] = '+1-' . sprintf('%s-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
- } else {
- // 7-9 digits: keep as-is (partial phone fragment)
- $v[] = $digits;
- }
- }
- if (!empty($v)) {
- $phoneMap[$t] = array_values(array_unique($v));
- }
- }
-
- // Helper: ( token1 AND token2 AND ... ), each token may match ANY of $columns,
- // and phone variants are only applied to the provided $phoneCols for THIS table.
- $applyMultiTokenLike = function ($qb, array $columns, array $tokens, array $phoneCols = []) use ($phoneMap) {
- foreach ($tokens as $t) {
- $qb->groupStart(); // OR across columns for this token
- foreach ($columns as $i => $col) {
- if ($i === 0) {
- $qb->like($col, $t);
- } else {
- $qb->orLike($col, $t);
- }
- }
- if (!empty($phoneMap[$t]) && !empty($phoneCols)) {
- foreach ($phoneMap[$t] as $pv) {
- foreach ($phoneCols as $pcol) {
- $qb->orLike($pcol, $pv);
- }
- }
- }
- $qb->groupEnd();
- }
- return $qb;
- };
-
- // ===== RAW UNscoped searches (flat arrays) =====
-
- // USERS (phone col: cellphone)
- $uCols = ['firstname', 'lastname', 'email', 'cellphone', 'school_id', 'city', 'state'];
- $uQB = $db->table('users')
- ->select('id, firstname, lastname, email, cellphone, school_id, city, state');
- $applyMultiTokenLike($uQB, $uCols, $tokens, ['cellphone']);
- $users = $uQB->limit(150)->get()->getResultArray();
-
- // STUDENTS (no phone column to search)
- $sCols = ['firstname', 'lastname', 'school_id', 'rfid_tag', 'dob', 'gender'];
- $sQB = $db->table('students')
- ->select('id, parent_id, school_id, firstname, lastname, dob, gender, rfid_tag');
- $applyMultiTokenLike($sQB, $sCols, $tokens, []);
- $students = $sQB->limit(150)->get()->getResultArray();
-
- // PARENTS (phone col: secondparent_phone)
- $pCols = ['secondparent_firstname', 'secondparent_lastname', 'secondparent_email', 'secondparent_phone'];
- $pQB = $db->table('parents')
- ->select('id, firstparent_id, secondparent_firstname, secondparent_lastname, secondparent_email, secondparent_phone');
- $applyMultiTokenLike($pQB, $pCols, $tokens, ['secondparent_phone']);
- foreach ($tokens as $t) {
- if (ctype_digit($t)) {
- $pQB->orWhere('firstparent_id', (int) $t)->orWhere('id', (int) $t);
- }
- }
- $parents = $pQB->limit(150)->get()->getResultArray();
-
- // STAFF (phone col: phone)
- $stCols = ['firstname', 'lastname', 'email', 'role_name', 'phone'];
- $stQB = $db->table('staff')
- ->select('id, user_id, firstname, lastname, email, phone, role_name, active_role');
- $applyMultiTokenLike($stQB, $stCols, $tokens, ['phone']);
- $staff = $stQB->limit(150)->get()->getResultArray();
-
- // EMERGENCY CONTACTS (phone col: cellphone)
- $ecCols = ['emergency_contact_name', 'relation', 'email', 'cellphone'];
- $ecQB = $db->table('emergency_contacts')
- ->select('id, parent_id, emergency_contact_name, relation, cellphone, email');
- $applyMultiTokenLike($ecQB, $ecCols, $tokens, ['cellphone']);
- $emergency = $ecQB->limit(150)->get()->getResultArray();
-
- $raw = [
- 'users' => $users,
- 'students' => $students,
- 'parents' => $parents,
- 'staff' => $staff,
- 'emergency_contacts' => $emergency,
- ];
-
- $total = count($users) + count($students) + count($parents) + count($staff) + count($emergency);
-
- return [
- 'query' => $q,
- 'results' => $raw,
- 'scope_used' => 'unscoped-raw',
- 'scope_label' => 'all years/semesters (raw, tokenized)',
- 'total_found' => $total,
- ];
- }
-
public function teachers()
{
return view('administrator/teachers'); // This is the correct view path
@@ -699,1320 +345,6 @@ class AdministratorController extends BaseController
return view('administrator/feedback_complaints');
}
- public function teacherSubmissionsReport()
- {
- $semester = (string)(getSemester() ?? $this->semester ?? '');
- $schoolYear = trim((string) ($this->request->getGet('school_year') ?? ''));
- if ($schoolYear === '') {
- $schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
- }
- $semesterResolver = new SemesterRangeService($this->configModel);
- $semesterNorm = $semesterResolver->normalizeSemester($semester);
- $semesterFilter = $semesterNorm !== '' ? $semesterNorm : $semester;
- $semesterCandidates = $this->buildSemesterCandidates($semesterFilter);
- $lowProgressRaw = (string) $this->request->getGet('low_progress_sections');
- $lowProgressSectionIds = array_values(array_unique(array_filter(array_map(
- 'intval',
- preg_split('/\s*,\s*/', $lowProgressRaw, -1, PREG_SPLIT_NO_EMPTY)
- ))));
-
- $scoreComments = new ScoreCommentModel();
- $semesterScores = new SemesterScoreModel();
- $attendanceDays = new AttendanceDayModel();
- $examDrafts = new ExamDraftModel();
- $homeworkModel = new HomeworkModel();
- $historyModel = new TeacherSubmissionNotificationHistoryModel();
-
- $assignmentQuery = $this->db->table('teacher_class tc')
- ->select([
- 'tc.class_section_id',
- 'cs.class_section_name',
- 'tc.teacher_id',
- 'u.firstname',
- 'u.lastname',
- 'tc.position',
- ])
- ->join('classSection cs', 'cs.class_section_id = tc.class_section_id', 'left')
- ->join('users u', 'u.id = tc.teacher_id', 'left')
- ->orderBy('cs.class_section_name', 'ASC');
-
- // teacher_class assignments are scoped by school year only.
- // The table has no semester column; semester filtering belongs on
- // semester-specific records such as scores, comments, attendance,
- // homework, and exam drafts.
- if ($schoolYear !== '') {
- $assignmentQuery->where('tc.school_year', $schoolYear);
- }
-
- $assignmentRows = $assignmentQuery->get()->getResultArray();
-
- $studentCounts = $this->studentClassModel->getStudentCountsBySection($schoolYear !== '' ? $schoolYear : null);
- $sectionRows = $this->classSectionModel
- ->select('class_section_id, class_section_name')
- ->orderBy('class_section_name', 'ASC')
- ->findAll();
- $sectionMap = [];
- foreach ($sectionRows as $sectionRow) {
- $sectionId = (int) ($sectionRow['class_section_id'] ?? 0);
- if ($sectionId <= 0) {
- continue;
- }
- if (empty($studentCounts[$sectionId])) {
- continue;
- }
- $sectionMap[$sectionId] = $sectionRow['class_section_name'] ?? "Section {$sectionId}";
- }
- $sectionIds = array_keys($sectionMap);
-
- [$progressExpectedWeeks, $progressSubmittedBySection] = $this->buildClassProgressStats($sectionIds);
- $examDraftCounts = [];
- $examDraftDeadline = $this->resolveTeacherDashboardExamDraftDeadline($semester, $schoolYear);
- $examDraftDeadlineConfig = trim((string) ($this->configModel->getConfig('exam_draft_deadline') ?? ''));
- $examDraftDeadlineFormatted = '';
- if ($examDraftDeadlineConfig !== '') {
- $parsedUi = $this->parseExamDraftDeadlineConfigValue();
- $examDraftDeadlineFormatted = $parsedUi !== null ? $parsedUi->format('M j, Y') : '';
- }
- $homeworkCounts = [];
- if (! empty($sectionIds)) {
- $draftBuilder = $examDrafts
- ->select('class_section_id')
- ->whereIn('class_section_id', $sectionIds);
- if ($schoolYear !== '') {
- $draftBuilder->where('school_year', $schoolYear);
- }
- if (!empty($semesterCandidates)) {
- $draftBuilder->whereIn('semester', $semesterCandidates);
- }
- if ($this->db->fieldExists('is_legacy', 'exam_drafts')) {
- $draftBuilder->where('is_legacy', 0);
- }
- $draftRows = $draftBuilder->findAll();
- foreach ($draftRows as $draft) {
- $sectionId = (int) ($draft['class_section_id'] ?? 0);
- if ($sectionId <= 0) {
- continue;
- }
- $examDraftCounts[$sectionId] = ($examDraftCounts[$sectionId] ?? 0) + 1;
- }
-
- $homeworkBuilder = $homeworkModel
- ->select('class_section_id, homework_index')
- ->whereIn('class_section_id', $sectionIds);
- if ($schoolYear !== '') {
- $homeworkBuilder->where('school_year', $schoolYear);
- }
- if (!empty($semesterCandidates)) {
- $homeworkBuilder->whereIn('semester', $semesterCandidates);
- }
- $homeworkRows = $homeworkBuilder
- ->where('score IS NOT NULL', null, false)
- ->where('score !=', '')
- ->groupBy('class_section_id, homework_index')
- ->findAll();
- foreach ($homeworkRows as $row) {
- $sectionId = (int) ($row['class_section_id'] ?? 0);
- if ($sectionId <= 0) {
- continue;
- }
- $homeworkCounts[$sectionId] = ($homeworkCounts[$sectionId] ?? 0) + 1;
- }
- }
-
- if (empty($lowProgressSectionIds)) {
- $lowProgressSectionIds = $this->resolveLowProgressSectionIds($sectionIds);
- }
-
- $teachersBySection = [];
- foreach ($assignmentRows as $assignment) {
- $sectionId = (int)($assignment['class_section_id'] ?? 0);
- if ($sectionId <= 0) {
- continue;
- }
-
- $positionKey = strtolower(trim((string)($assignment['position'] ?? '')));
- $roleKey = $positionKey !== '' ? $positionKey : 'teacher';
- $positionLabel = match ($roleKey) {
- 'ta' => 'TA',
- 'main' => 'Main',
- default => $roleKey !== '' ? ucfirst($roleKey) : 'Teacher',
- };
-
- $teacherFullName = trim(($assignment['firstname'] ?? '') . ' ' . ($assignment['lastname'] ?? ''));
- $teacherId = (int)($assignment['teacher_id'] ?? 0);
- if ($teacherFullName === '' || $teacherId <= 0) {
- continue;
- }
-
- $entry = &$teachersBySection[$sectionId];
- if (!isset($entry)) {
- $entry = [
- 'class_section' => $assignment['class_section_name'] ?? ($sectionMap[$sectionId] ?? "Section {$sectionId}"),
- 'teachers' => [],
- ];
- }
-
- $entry['teachers'][] = [
- 'id' => $teacherId,
- 'label' => "{$positionLabel}: {$teacherFullName}",
- 'role_key' => $roleKey,
- ];
- unset($entry);
- }
-
- $today = (new \DateTimeImmutable('now', new \DateTimeZone(date_default_timezone_get() ?: 'UTC')))->format('Y-m-d');
-
- $rows = [];
- $totalStatuses = 0;
- $missingItemCount = 0;
- $allTeacherIds = [];
- $allClassSectionIds = [];
- $examTerm = $this->resolveExamTermLabel($semester);
- $examScoreField = $examTerm === 'final' ? 'final_exam_score' : 'midterm_exam_score';
-
- foreach ($sectionMap as $classSectionId => $sectionName) {
- $classSectionId = (int)$classSectionId;
- if ($classSectionId <= 0) {
- continue;
- }
-
- $studentEntries = $this->db->table('student_class')
- ->select('student_id')
- ->where('class_section_id', $classSectionId)
- ->where('school_year', $schoolYear)
- ->get()
- ->getResultArray();
- if (empty($studentEntries)) {
- $studentEntries = $this->studentClassModel
- ->select('student_id')
- ->where('class_section_id', $classSectionId)
- ->where('school_year', $schoolYear)
- ->findAll();
- }
- $studentIds = array_filter(array_map(static fn($entry) => (int)($entry['student_id'] ?? 0), $studentEntries));
- $expected = count($studentIds);
-
- $midtermStudents = [];
- $participationStudents = [];
- if ($classSectionId > 0) {
- $scoreQuery = $semesterScores
- ->where('class_section_id', $classSectionId)
- ->where('school_year', $schoolYear);
- if (!empty($semesterCandidates)) {
- $scoreQuery->whereIn('semester', $semesterCandidates);
- }
- $scoreRecords = $scoreQuery->findAll();
- foreach ($scoreRecords as $score) {
- $sid = (int)($score['student_id'] ?? 0);
- if ($sid <= 0 || ($expected > 0 && !in_array($sid, $studentIds, true))) {
- continue;
- }
- $midtermValue = trim((string)($score[$examScoreField] ?? ''));
- if ($midtermValue !== '') {
- $midtermStudents[$sid] = true;
- }
- $participationValue = trim((string)($score['participation_score'] ?? ''));
- if ($participationValue !== '') {
- $participationStudents[$sid] = true;
- }
- }
- }
-
- $midtermCommentStudents = [];
- $ptapCommentStudents = [];
- if (!empty($studentIds)) {
- $commentQuery = $scoreComments
- ->select('student_id, score_type, comment')
- ->whereIn('student_id', $studentIds)
- ->where('school_year', $schoolYear)
- ->whereIn('score_type', [$examTerm, 'ptap']);
- if (!empty($semesterCandidates)) {
- $commentQuery->whereIn('semester', $semesterCandidates);
- }
- $comments = $commentQuery->findAll();
- foreach ($comments as $comment) {
- $sid = (int)($comment['student_id'] ?? 0);
- if ($sid <= 0) {
- continue;
- }
- $text = trim((string)($comment['comment'] ?? ''));
- if ($text === '') {
- continue;
- }
- $type = strtolower(trim((string)($comment['score_type'] ?? '')));
- if ($type === $examTerm) {
- $midtermCommentStudents[$sid] = true;
- }
- if ($type === 'ptap') {
- $ptapCommentStudents[$sid] = true;
- }
- }
- }
-
- $attendanceQuery = $attendanceDays
- ->where('class_section_id', $classSectionId)
- ->where('school_year', $schoolYear)
- ->where('date', $today);
- if (!empty($semesterCandidates)) {
- $attendanceQuery->whereIn('semester', $semesterCandidates);
- }
- $attendanceRow = $attendanceQuery->first();
- $attendanceSubmitted = $attendanceRow && in_array(strtolower((string)($attendanceRow['status'] ?? '')), ['submitted', 'published', 'finalized'], true);
-
- $section = $teachersBySection[$classSectionId] ?? ['teachers' => []];
- $teacherList = $section['teachers'] ?? [];
- if (!empty($teacherList)) {
- usort($teacherList, function ($a, $b) {
- return $this->teacherRolePriority($a['role_key'] ?? 'teacher') <=> $this->teacherRolePriority($b['role_key'] ?? 'teacher');
- });
- $teacherList = array_values($teacherList);
- }
-
- foreach ($teacherList as $teacherEntry) {
- if (!empty($teacherEntry['id'])) {
- $allTeacherIds[] = $teacherEntry['id'];
- }
- }
- $allClassSectionIds[] = $classSectionId;
-
- $midtermScoreStatus = $this->submissionStatus(count($midtermStudents), $expected);
- $midtermCommentStatus = $this->submissionStatus(count($midtermCommentStudents), $expected);
- $participationStatus = $this->submissionStatus(count($participationStudents), $expected);
- $ptapCommentStatus = $this->submissionStatus(count($ptapCommentStudents), $expected);
- $attendanceStatus = $this->attendanceStatus($attendanceSubmitted);
- $progressSubmitted = (int) ($progressSubmittedBySection[$classSectionId] ?? 0);
- $classProgressStatus = $this->progressStatus($progressSubmitted, $progressExpectedWeeks);
- $draftSubmitted = (int) ($examDraftCounts[$classSectionId] ?? 0);
- $examDraftStatus = $this->draftStatus($draftSubmitted, $examDraftDeadline);
- $homeworkSubmitted = (int) ($homeworkCounts[$classSectionId] ?? 0);
- $homeworkStatus = $this->homeworkStatus($homeworkSubmitted);
- $statusDetails = [
- 'midterm_score_status' => $midtermScoreStatus,
- 'midterm_comment_status' => $midtermCommentStatus,
- 'participation_status' => $participationStatus,
- 'ptap_comment_status' => $ptapCommentStatus,
- 'class_progress_status' => $classProgressStatus,
- 'exam_draft_status' => $examDraftStatus,
- 'homework_status' => $homeworkStatus,
- ];
- $missingItemsForSection = $this->buildMissingItems($statusDetails, $semester);
- $missingItemCount += count($missingItemsForSection);
- $totalStatuses += count($statusDetails);
-
- $rows[] = [
- 'class_section' => $sectionMap[$classSectionId] ?? ($section['class_section'] ?? "Section {$classSectionId}"),
- 'class_section_id' => $classSectionId,
- 'teachers' => $teacherList,
- 'midterm_score_status' => $midtermScoreStatus,
- 'midterm_comment_status' => $midtermCommentStatus,
- 'participation_status' => $participationStatus,
- 'ptap_comment_status' => $ptapCommentStatus,
- 'attendance_status' => $attendanceStatus,
- 'class_progress_status' => $classProgressStatus,
- 'exam_draft_status' => $examDraftStatus,
- 'homework_status' => $homeworkStatus,
- 'missing_items' => $missingItemsForSection,
- 'student_count' => $expected,
- ];
- }
-
- $historyMap = [];
- $teacherIds = array_values(array_unique($allTeacherIds));
- $classSectionIds = array_values(array_unique($allClassSectionIds));
- if (!empty($teacherIds) && !empty($classSectionIds)) {
- $historyRecords = $historyModel
- ->select('teacher_submission_notification_history.*, u.firstname, u.lastname')
- ->join('users u', 'u.id = teacher_submission_notification_history.admin_id', 'left')
- ->where('notification_category', 'teacher_submissions')
- ->whereIn('teacher_submission_notification_history.teacher_id', $teacherIds)
- ->whereIn('teacher_submission_notification_history.class_section_id', $classSectionIds)
- ->orderBy('sent_at', 'DESC')
- ->findAll();
-
- foreach ($historyRecords as $record) {
- $sectionId = (int)($record['class_section_id'] ?? 0);
- $teacherId = (int)($record['teacher_id'] ?? 0);
- if ($sectionId <= 0 || $teacherId <= 0) {
- continue;
- }
- $sentAt = $record['sent_at'] ?? null;
- $sentAtText = $sentAt ? local_datetime($sentAt, 'M j, Y g:i A') : '';
- $adminName = trim(($record['firstname'] ?? '') . ' ' . ($record['lastname'] ?? ''));
- if ($adminName === '') {
- $adminName = 'Administrator';
- }
- $historyMap[$sectionId][$teacherId][] = [
- 'sent_at_text' => $sentAtText,
- 'admin_name' => $adminName,
- 'status' => strtolower((string)($record['status'] ?? 'sent')),
- ];
- }
-
- foreach ($historyMap as &$teachersHistory) {
- foreach ($teachersHistory as &$entries) {
- $entries = array_slice($entries, 0, 3);
- }
- unset($entries);
- }
- unset($teachersHistory);
- }
-
- $summary = [
- 'total_items' => $totalStatuses,
- 'missing_items' => $missingItemCount,
- 'submitted_items' => max(0, $totalStatuses - $missingItemCount),
- 'submission_percentage' => $totalStatuses > 0
- ? (int)round((($totalStatuses - $missingItemCount) / $totalStatuses) * 100)
- : 100,
- ];
-
- return view('administrator/teacher_submissions', [
- 'rows' => $rows,
- 'semester' => $semester,
- 'schoolYear' => $schoolYear,
- 'notificationHistory' => $historyMap,
- 'summary' => $summary,
- 'lowProgressSectionIds' => $lowProgressSectionIds,
- 'examDraftDeadlineConfig' => $examDraftDeadlineConfig,
- 'examDraftDeadlineFormatted' => $examDraftDeadlineFormatted,
- ]);
- }
-
- private function resolveLowProgressSectionIds(array $sectionIds): array
- {
- [$expectedWeeks, $submittedBySection] = $this->buildClassProgressStats($sectionIds);
- if ($expectedWeeks <= 0) {
- return [];
- }
-
- $lowProgressSectionIds = [];
- foreach ($sectionIds as $sectionId) {
- $submitted = (int) ($submittedBySection[$sectionId] ?? 0);
- $percent = ($submitted / $expectedWeeks) * 100;
- if ($percent < 50) {
- $lowProgressSectionIds[] = $sectionId;
- }
- }
-
- return $lowProgressSectionIds;
- }
-
- private function buildClassProgressStats(array $sectionIds): array
- {
- $sectionIds = array_values(array_unique(array_filter(array_map('intval', $sectionIds))));
- if (empty($sectionIds)) {
- return [0, []];
- }
-
- $semesterResolver = new SemesterRangeService($this->configModel);
- $schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
- $semester = (string)(getSemester() ?? '');
- $schoolYearForRange = $schoolYear !== '' ? $schoolYear : $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
- [$rangeStart, $rangeEnd] = $semesterResolver->getSchoolYearRange($schoolYearForRange);
- $semesterNorm = $semesterResolver->normalizeSemester($semester);
- if ($semesterNorm !== '' && $schoolYearForRange !== '') {
- $semRange = $semesterResolver->getSemesterRange($schoolYearForRange, $semesterNorm);
- if ($semRange) {
- [$rangeStart, $rangeEnd] = $semRange;
- }
- }
-
- $dateList = [];
- try {
- $start = new \DateTimeImmutable($rangeStart);
- $end = new \DateTimeImmutable($rangeEnd);
- $cursor = $start;
- $w = (int) $cursor->format('w');
- if ($w !== 0) {
- $cursor = $cursor->modify('next sunday');
- }
- while ($cursor <= $end) {
- $dateList[] = $cursor->format('Y-m-d');
- $cursor = $cursor->modify('+7 days');
- }
- } catch (\Throwable $e) {
- $dateList = [];
- }
-
- $noSchoolDays = [];
- $events = [];
- try {
- $calendarModel = new \App\Models\CalendarModel();
- $events = $calendarModel->getEvents();
- } catch (\Throwable $e) {
- $events = [];
- }
- foreach ($events as $event) {
- $d = substr((string) ($event['date'] ?? ''), 0, 10);
- if ($d === '' || empty($event['no_school'])) {
- continue;
- }
- if ($d < $rangeStart || $d > $rangeEnd) {
- continue;
- }
- $eventYear = trim((string) ($event['school_year'] ?? ''));
- if ($schoolYearForRange !== '' && $eventYear !== '' && $eventYear !== $schoolYearForRange) {
- continue;
- }
- $noSchoolDays[$d] = true;
- }
-
- $anchorSundayYmd = '';
- try {
- $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
- $tzObj = new \DateTimeZone($tzName ?: 'UTC');
- } catch (\Throwable $e) {
- try {
- $tzObj = new \DateTimeZone(user_timezone() ?: 'UTC');
- } catch (\Throwable $e2) {
- $tzObj = new \DateTimeZone('UTC');
- }
- }
- try {
- $nowDate = new \DateTime('now', $tzObj);
- } catch (\Throwable $e) {
- $nowDate = new \DateTime('now');
- }
- $weekday = (int) $nowDate->format('w');
- $anchorSundayYmd = $weekday === 0
- ? $nowDate->format('Y-m-d')
- : $nowDate->modify('next sunday')->format('Y-m-d');
-
- $activeDatesSet = [];
- if (! empty($dateList) && $anchorSundayYmd !== '') {
- foreach ($dateList as $d) {
- if ($d <= $anchorSundayYmd && empty($noSchoolDays[$d])) {
- $activeDatesSet[$d] = true;
- }
- }
- }
- $expectedWeeks = count($activeDatesSet);
- if ($expectedWeeks === 0) {
- return [0, []];
- }
-
- $builder = $this->db->table('class_progress_reports')
- ->select('class_section_id, week_start')
- ->whereIn('class_section_id', $sectionIds);
- if (! empty($activeDatesSet)) {
- $builder->whereIn('week_start', array_keys($activeDatesSet));
- }
- $rows = $builder->get()->getResultArray();
-
- $submittedBySection = [];
- foreach ($rows as $row) {
- $sectionId = (int) ($row['class_section_id'] ?? 0);
- $weekStart = (string) ($row['week_start'] ?? '');
- if ($sectionId === 0 || $weekStart === '' || empty($activeDatesSet[$weekStart])) {
- continue;
- }
- $submittedBySection[$sectionId][$weekStart] = true;
- }
-
- $counts = [];
- foreach ($sectionIds as $sectionId) {
- $counts[$sectionId] = isset($submittedBySection[$sectionId])
- ? count($submittedBySection[$sectionId])
- : 0;
- }
-
- return [$expectedWeeks, $counts];
- }
-
- public function sendTeacherSubmissionNotifications()
- {$notify = $this->request->getPost('notify');
- if (!is_array($notify)) {
- return redirect()->back()->with('info', 'Select at least one teacher to notify.');
- }
- $semester = (string)(getSemester() ?? $this->semester ?? '');
- $missingItemsPayload = $this->request->getPost('missing_items') ?? [];
- $homeworkNotifyAll = (bool) $this->request->getPost('homework_notify_all');
- $examTerm = $this->resolveExamTermLabel($semester);
- $examScoreLabel = $examTerm === 'final' ? 'final scores' : 'midterm scores';
- $examCommentLabel = $examTerm === 'final' ? 'final comments' : 'midterm comments';
- $forcedItems = [];
- if ($this->request->getPost('notify_midterm_score')) {
- $forcedItems[] = $examScoreLabel;
- }
- if ($this->request->getPost('notify_midterm_comment')) {
- $forcedItems[] = $examCommentLabel;
- }
- if ($this->request->getPost('notify_participation')) {
- $forcedItems[] = 'participation';
- }
- if ($this->request->getPost('notify_ptap_comment')) {
- $forcedItems[] = 'PTAP comments';
- }
- if ($this->request->getPost('notify_class_progress')) {
- $forcedItems[] = 'class progress';
- }
- if ($this->request->getPost('notify_exam_draft')) {
- $forcedItems[] = 'exam draft';
- }
-
- $targets = [];
- foreach ($notify as $sectionIdRaw => $teachers) {
- $sectionId = (int)$sectionIdRaw;
- if ($sectionId <= 0 || !is_array($teachers)) {
- continue;
- }
- foreach ($teachers as $teacherIdRaw => $value) {
- $teacherId = (int)$teacherIdRaw;
- if ($teacherId <= 0 || $value === null || $value === '') {
- continue;
- }
- $key = "{$sectionId}_{$teacherId}";
- $targets[$key] = [
- 'class_section_id' => $sectionId,
- 'teacher_id' => $teacherId,
- ];
- }
- }
-
- if (empty($targets)) {
- return redirect()->back()->with('info', 'Select at least one teacher to notify.');
- }
-
- $targets = array_values($targets);
- $teacherIds = array_values(array_unique(array_column($targets, 'teacher_id')));
- $classSectionIds = array_values(array_unique(array_column($targets, 'class_section_id')));
-
- $classSections = $this->classSectionModel
- ->select('class_section_id, class_section_name')
- ->whereIn('class_section_id', $classSectionIds)
- ->findAll();
- $classSectionMap = [];
- foreach ($classSections as $section) {
- $classSectionMap[(int)($section['class_section_id'] ?? 0)] = $section['class_section_name'] ?? '';
- }
-
- $teachers = $this->userModel
- ->select('id, firstname, lastname, email')
- ->whereIn('id', $teacherIds)
- ->findAll();
- $teacherLookup = [];
- foreach ($teachers as $teacher) {
- $teacherLookup[(int)$teacher['id']] = $teacher;
- }
-
- $mailer = new EmailController();
- $adminId = (int)(session()->get('user_id') ?? 0);
- if ($adminId <= 0) {
- return redirect()->to('/login');
- }
- $adminUser = $this->userModel->find($adminId);
- $adminName = trim(($adminUser['firstname'] ?? '') . ' ' . ($adminUser['lastname'] ?? '')) ?: 'Administrator';
-
- $historyModel = new TeacherSubmissionNotificationHistoryModel();
- $scoreUrl = site_url('/');
- $progressUrl = site_url('teacher/progress/history');
- $examDraftUrl = site_url('teacher/exam-drafts');
- $homeworkUrl = site_url('teacher/addHomework');
- $examDraftDeadlineEmailHtml = $this->buildExamDraftDeadlineEmailHtml();
- $sentCount = 0;
- $failCount = 0;
-
- foreach ($targets as $target) {
- $classSectionId = (int)$target['class_section_id'];
- $teacherId = (int)$target['teacher_id'];
- $teacher = $teacherLookup[$teacherId] ?? null;
- $sectionName = $classSectionMap[$classSectionId] ?? "Section {$classSectionId}";
- $teacherName = $teacher ? trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? '')) : '';
- if ($teacherName === '') {
- $teacherName = 'Teacher';
- }
- $subject = "Reminder: Complete submissions for {$sectionName}";
- $missingPayload = $missingItemsPayload[$classSectionId][$teacherId] ?? '';
- $missingItems = $this->parseMissingItemsPayload((string)$missingPayload);
- $selectedItems = $forcedItems;
- if ($homeworkNotifyAll && !in_array('homework', $selectedItems, true)) {
- $selectedItems[] = 'homework';
- }
- if (!empty($selectedItems)) {
- $missingItems = array_values(array_unique($selectedItems));
- }
- if (!empty($missingItems)) {
- $missingText = htmlspecialchars(
- $this->formatMissingItemsText($missingItems),
- ENT_QUOTES,
- 'UTF-8'
- );
- $missingNote = "
Outstanding items: {$missingText}.
";
- } else {
- $missingNote = "Our records show no outstanding submissions for this section, but please verify if anything still needs attention.
";
- }
-
- $subject = "Reminder: Complete submissions for {$sectionName}";
- $progressNote = '';
- if (in_array('class progress', $missingItems, true)) {
- $progressNote = "Class progress submissions can be updated at Teacher Progress History .
";
- }
- $examDraftNote = '';
- if (in_array('exam draft', $missingItems, true)) {
- $semesterLabel = strtolower(trim((string) $semester));
- if ($semesterLabel === 'fall') {
- $draftLabel = 'midterm exam draft';
- } elseif ($semesterLabel === 'spring') {
- $draftLabel = 'final exam draft';
- } else {
- $draftLabel = 'exam draft';
- }
- $examDraftNote = "" . ucfirst($draftLabel) . " submissions can be updated at Teacher Exam Drafts .
"
- . $examDraftDeadlineEmailHtml;
- }
- $homeworkNote = '';
- if (in_array('homework', $missingItems, true)) {
- $homeworkNote = "Homework scores can be submitted at Teacher Homework .
";
- }
- $hasScoreItems = (bool) array_intersect($missingItems, [
- 'midterm scores',
- 'midterm comments',
- 'final scores',
- 'final comments',
- 'participation',
- 'PTAP comments',
- 'homework',
- ]);
- $nonScoreOnly = ! empty($missingItems) && ! $hasScoreItems;
- $body = "Dear {$teacherName},
"
- . "Administration is gently reminding you to wrap up any remaining "
- . ($nonScoreOnly ? "submissions for {$sectionName}." : "score submissions, comments, and related items for {$sectionName}.")
- . "
"
- . $missingNote
- . $progressNote
- . $examDraftNote
- . $homeworkNote
- . ($nonScoreOnly ? '' : "Visit Teacher Score Submission to address any remaining items.
")
- . "Thank you, Al Rahma Administration
";
-
- $email = $teacher['email'] ?? '';
- $status = 'failed';
- if (!empty($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) {
- $ok = $mailer->sendEmail($email, $subject, $body, 'notifications');
- $status = $ok ? 'sent' : 'failed';
- }
-
- if ($status === 'sent') {
- $sentCount++;
- } else {
- $failCount++;
- }
-
- $historyModel->insert([
- 'teacher_id' => $teacherId,
- 'class_section_id' => $classSectionId,
- 'admin_id' => $adminId,
- 'notification_category' => 'teacher_submissions',
- 'message' => $this->truncateNotificationMessage($body),
- 'status' => $status,
- 'school_year' => $this->schoolYear,
- 'semester' => $this->semester,
- 'sent_at' => utc_now(),
- ]);
- }
-
- $statusParts = [];
- if ($sentCount > 0) {
- $statusParts[] = $sentCount . ' reminder' . ($sentCount === 1 ? '' : 's') . ' sent';
- }
- if ($failCount > 0) {
- $statusParts[] = $failCount . ' reminder' . ($failCount === 1 ? '' : 's') . ' failed';
- }
-
- $message = !empty($statusParts) ? implode(' and ', $statusParts) : 'No notifications were sent.';
- $flashType = $failCount === 0 ? 'success' : 'warning';
-
- return redirect()->back()->with($flashType, $message);
- }
-
- private function submissionStatus(int $filled, int $expected): array
- {
- if ($expected <= 0) {
- return [
- 'label' => 'No students',
- 'badge' => 'bg-secondary',
- 'detail' => '',
- 'completed' => true,
- ];
- }
- $completed = $filled >= $expected;
- return [
- 'label' => $completed ? 'Submitted' : 'Missing',
- 'badge' => $completed ? 'bg-success' : 'bg-danger',
- 'detail' => "{$filled}/{$expected}",
- 'completed' => $completed,
- ];
- }
-
- private function progressStatus(int $submitted, int $expected): array
- {
- if ($expected <= 0) {
- return [
- 'label' => 'N/A',
- 'badge' => 'bg-secondary',
- 'detail' => '',
- 'completed' => true,
- ];
- }
- $completed = $submitted >= $expected;
- return [
- 'label' => $completed ? 'Submitted' : 'Missing',
- 'badge' => $completed ? 'bg-success' : 'bg-danger',
- 'detail' => "{$submitted}/{$expected}",
- 'completed' => $completed,
- ];
- }
-
- private function homeworkStatus(int $submitted): array
- {
- $completed = $submitted > 0;
- return [
- 'label' => $completed ? 'Submitted' : 'Missing',
- 'badge' => $completed ? 'bg-success' : 'bg-danger',
- 'detail' => $completed ? (string) $submitted : '0',
- 'completed' => $completed,
- ];
- }
-
- private function draftStatus(int $submitted, ?\DateTimeImmutable $deadline): array
- {
- if ($deadline !== null) {
- $today = new \DateTimeImmutable('today');
- if ($today < $deadline) {
- return [
- 'label' => 'Pending',
- 'badge' => 'bg-secondary',
- 'detail' => 'Not due',
- 'completed' => true,
- ];
- }
- }
- $completed = $submitted > 0;
- return [
- 'label' => $completed ? 'Submitted' : 'Missing',
- 'badge' => $completed ? 'bg-success' : 'bg-danger',
- 'detail' => $completed ? (string) $submitted : '0',
- 'completed' => $completed,
- ];
- }
-
- /**
- * Exam draft due date for the teacher submissions dashboard: prefers the configuration key
- * `exam_draft_deadline` (same as automated reminders); otherwise fall/spring exam deadlines.
- */
- private function resolveTeacherDashboardExamDraftDeadline(string $semester, string $schoolYear): ?\DateTimeImmutable
- {
- $fromExamDraftKey = $this->parseExamDraftDeadlineConfigValue();
- if ($fromExamDraftKey !== null) {
- return $fromExamDraftKey;
- }
-
- return $this->resolveExamDraftDeadline($semester, $schoolYear);
- }
-
- /**
- * Parses the `exam_draft_deadline` configuration value using the application timezone (midnight that calendar day).
- */
- private function parseExamDraftDeadlineConfigValue(): ?\DateTimeImmutable
- {
- $raw = trim((string) ($this->configModel->getConfig('exam_draft_deadline') ?? ''));
- if ($raw === '') {
- return null;
- }
- $tz = new \DateTimeZone(config('App')->appTimezone ?? 'UTC');
- try {
- $deadline = new \DateTimeImmutable($raw, $tz);
- } catch (\Throwable $e) {
- return null;
- }
-
- return $deadline->setTime(0, 0, 0);
- }
-
- /**
- * HTML snippet for reminder emails when exam draft is included (deadline from exam_draft_deadline config).
- */
- private function buildExamDraftDeadlineEmailHtml(): string
- {
- $raw = trim((string) ($this->configModel->getConfig('exam_draft_deadline') ?? ''));
- if ($raw === '') {
- return '';
- }
- $parsed = $this->parseExamDraftDeadlineConfigValue();
- $display = $parsed !== null
- ? htmlspecialchars($parsed->format('l, F j, Y'), ENT_QUOTES, 'UTF-8')
- : htmlspecialchars($raw, ENT_QUOTES, 'UTF-8');
- $rawEsc = htmlspecialchars($raw, ENT_QUOTES, 'UTF-8');
-
- return 'Exam draft submission deadline (exam_draft_deadline): '
- . "{$display} "
- . ($parsed !== null && $rawEsc !== $display ? " (configured value: {$rawEsc}) " : '')
- . '.
';
- }
-
- private function resolveExamDraftDeadline(string $semester, string $schoolYear): ?\DateTimeImmutable
- {
- $semesterKey = strtolower(trim($semester));
- if ($semesterKey === 'fall') {
- $deadlineValue = (string)($this->configModel->getConfig('fall_exam_deadline') ?? '');
- } elseif ($semesterKey === 'spring') {
- $deadlineValue = (string)($this->configModel->getConfig('spring_exam_deadline') ?? '');
- } else {
- return null;
- }
- $deadlineValue = trim($deadlineValue);
- if ($deadlineValue === '') {
- return null;
- }
- try {
- $deadline = new \DateTimeImmutable($deadlineValue);
- } catch (\Throwable $e) {
- return null;
- }
- if ($schoolYear !== '' && preg_match('/^\d{4}-\d{4}$/', $schoolYear)) {
- $deadlineYear = $deadline->format('Y');
- if ($deadlineYear === '1970') {
- return null;
- }
- }
- return $deadline->setTime(0, 0, 0);
- }
-
- private function resolveExamTermLabel(string $semester): string
- {
- $semesterKey = strtolower(trim($semester));
- if ($semesterKey === '') {
- return 'midterm';
- }
- if (str_contains($semesterKey, 'spring')) {
- return 'final';
- }
- if (str_contains($semesterKey, 'fall')) {
- return 'midterm';
- }
- return 'midterm';
- }
-
- private function buildSemesterCandidates(string $semester): array
- {
- $semester = trim((string) $semester);
- if ($semester === '') {
- return [];
- }
- $candidates = [
- $semester,
- strtolower($semester),
- strtoupper($semester),
- ucfirst(strtolower($semester)),
- ];
- $candidates = array_values(array_unique(array_filter($candidates, static fn ($v) => $v !== '')));
- return $candidates;
- }
- private function attendanceStatus(bool $submitted): array
- {
- return [
- 'label' => $submitted ? 'Submitted' : 'Missing',
- 'badge' => $submitted ? 'bg-success' : 'bg-danger',
- 'completed' => $submitted,
- ];
- }
-
- private function buildMissingItems(array $statusMap, string $semester): array
- {
- $examTerm = $this->resolveExamTermLabel($semester);
- $examScoreLabel = $examTerm === 'final' ? 'final scores' : 'midterm scores';
- $examCommentLabel = $examTerm === 'final' ? 'final comments' : 'midterm comments';
- $labels = [
- 'midterm_score_status' => $examScoreLabel,
- 'midterm_comment_status' => $examCommentLabel,
- 'participation_status' => 'participation',
- 'ptap_comment_status' => 'PTAP comments',
- 'attendance_status' => 'attendance',
- 'class_progress_status' => 'class progress',
- 'exam_draft_status' => 'exam draft',
- 'homework_status' => 'homework',
- ];
-
- $items = [];
- foreach ($statusMap as $key => $status) {
- $completed = $status['completed'] ?? true;
- if (!$completed && isset($labels[$key])) {
- $items[] = $labels[$key];
- }
- }
-
- return array_values($items);
- }
-
- private function formatMissingItemsText(array $items): string
- {
- $items = array_values(array_filter(array_map('trim', $items), static fn($v) => $v !== ''));
- $count = count($items);
- if ($count === 0) {
- return '';
- }
- if ($count === 1) {
- return $items[0];
- }
- if ($count === 2) {
- return $items[0] . ' and ' . $items[1];
- }
- $last = array_pop($items);
- return implode(', ', $items) . ' and ' . $last;
- }
-
- private function teacherRolePriority(string $roleKey): int
- {
- switch (strtolower($roleKey)) {
- case 'main':
- return 1;
- case 'ta':
- return 2;
- default:
- return 3;
- }
- }
-
- private function truncateNotificationMessage(string $html, int $limit = 1000): string
- {
- $text = trim(strip_tags($html));
- if ($text === '') {
- return '';
- }
- if (mb_strlen($text) <= $limit) {
- return $text;
- }
- return mb_substr($text, 0, $limit) . '…';
- }
-
- private function parseMissingItemsPayload(string $payload): array
- {
- if ($payload === '') {
- return [];
- }
- $decoded = @json_decode(base64_decode($payload, true) ?: '', true);
- if (!is_array($decoded)) {
- return [];
- }
-
- $items = [];
- foreach ($decoded as $item) {
- $item = trim((string)$item);
- if ($item === '') {
- continue;
- }
- $items[] = $item;
- }
-
- return array_values(array_unique($items));
- }
-
- public function notificationsAlerts()
- {
- if (!$this->canManageAdminNotifications()) {
- return redirect()->to('/login');
- }
-
- $admins = $this->fetchAdminNotificationUsers();
- $subjects = $this->notificationSubjectOptions();
- $assignedSubjects = [];
- $tableReady = $this->db->tableExists('admin_notification_subjects');
-
- if ($tableReady && !empty($admins)) {
- $adminIds = array_map('intval', array_column($admins, 'id'));
- $rows = $this->adminNotificationSubjectModel
- ->select('id, admin_id, subject')
- ->whereIn('admin_id', $adminIds)
- ->findAll();
-
- foreach ($rows as $row) {
- $adminId = (int) ($row['admin_id'] ?? 0);
- $subject = (string) ($row['subject'] ?? '');
- if ($adminId <= 0 || $subject === '') {
- continue;
- }
- if (!isset($assignedSubjects[$adminId])) {
- $assignedSubjects[$adminId] = [];
- }
- $assignedSubjects[$adminId][$subject] = true;
- }
- }
-
- return view('administrator/notifications_alerts', [
- 'admins' => $admins,
- 'subjects' => $subjects,
- 'assignedSubjects' => $assignedSubjects,
- 'tableReady' => $tableReady,
- ]);
- }
-
- public function saveNotificationSubjects()
- {
- if (!$this->canManageAdminNotifications()) {
- return redirect()->to('/login');
- }
-
- if (!$this->db->tableExists('admin_notification_subjects')) {
- return redirect()->to('/administrator/notifications_alerts')
- ->with('error', 'Notification subject storage is missing. Run migrations first.');
- }
-
- $posted = $this->request->getPost('subjects');
- if (!is_array($posted)) {
- return redirect()->to('/administrator/notifications_alerts')
- ->with('error', 'No selections were submitted.');
- }
-
- $subjects = $this->notificationSubjectOptions();
- $allowed = array_keys($subjects);
-
- $admins = $this->fetchAdminNotificationUsers();
- $adminIds = array_map('intval', array_column($admins, 'id'));
- if (empty($adminIds)) {
- return redirect()->to('/administrator/notifications_alerts')
- ->with('info', 'No admins found to update.');
- }
-
- $existing = $this->adminNotificationSubjectModel
- ->select('id, admin_id, subject')
- ->whereIn('admin_id', $adminIds)
- ->findAll();
-
- $existingMap = [];
- foreach ($existing as $row) {
- $adminId = (int) ($row['admin_id'] ?? 0);
- $subject = (string) ($row['subject'] ?? '');
- if ($adminId <= 0 || $subject === '') {
- continue;
- }
- if (!isset($existingMap[$adminId])) {
- $existingMap[$adminId] = [];
- }
- $existingMap[$adminId][$subject] = (int) ($row['id'] ?? 0);
- }
-
- $updates = 0;
-
- foreach ($adminIds as $adminId) {
- $subjectRaw = $posted[$adminId] ?? [];
-
- $selected = [];
- if (is_array($subjectRaw)) {
- foreach ($subjectRaw as $key => $value) {
- $candidate = is_string($key) ? $key : $value;
- if (!is_string($candidate)) {
- continue;
- }
- $candidate = trim($candidate);
- if ($candidate !== '') {
- $selected[] = $candidate;
- }
- }
- }
-
- $selected = array_values(array_unique(array_filter($selected, function ($value) use ($allowed) {
- return in_array($value, $allowed, true);
- })));
-
- $current = array_keys($existingMap[$adminId] ?? []);
- $toDelete = array_values(array_diff($current, $selected));
- $toInsert = array_values(array_diff($selected, $current));
-
- if (!empty($toDelete)) {
- $this->adminNotificationSubjectModel
- ->where('admin_id', $adminId)
- ->whereIn('subject', $toDelete)
- ->delete();
- $updates += count($toDelete);
- }
-
- if (!empty($toInsert)) {
- $batch = [];
- foreach ($toInsert as $subject) {
- $batch[] = [
- 'admin_id' => $adminId,
- 'subject' => $subject,
- ];
- }
- $this->adminNotificationSubjectModel->insertBatch($batch);
- $updates += count($toInsert);
- }
- }
-
- return redirect()->to('/administrator/notifications_alerts')
- ->with('success', $updates > 0 ? 'Notification subjects updated.' : 'No changes were made.');
- }
-
- public function printNotificationRecipients()
- {
- if (!$this->canManageAdminNotifications()) {
- return redirect()->to('/login');
- }
-
- $admins = $this->fetchAdminNotificationUsers();
- $tableReady = $this->db->tableExists('admin_notification_subjects');
- $assigned = [];
-
- if ($tableReady && !empty($admins)) {
- $adminIds = array_map('intval', array_column($admins, 'id'));
- $rows = $this->adminNotificationSubjectModel
- ->select('admin_id')
- ->where('subject', 'print_requests')
- ->whereIn('admin_id', $adminIds)
- ->findAll();
-
- foreach ($rows as $row) {
- $adminId = (int) ($row['admin_id'] ?? 0);
- if ($adminId <= 0) {
- continue;
- }
- $assigned[$adminId] = true;
- }
- }
-
- return view('administrator/print_notification_admins', [
- 'admins' => $admins,
- 'assigned' => $assigned,
- 'tableReady' => $tableReady,
- ]);
- }
-
- public function savePrintNotificationRecipients()
- {
- if (!$this->canManageAdminNotifications()) {
- return redirect()->to('/login');
- }
-
- if (!$this->db->tableExists('admin_notification_subjects')) {
- return redirect()->to('/administrator/print-notifications')
- ->with('error', 'Notification subject storage is missing. Run migrations first.');
- }
-
- $admins = $this->fetchAdminNotificationUsers();
- $adminIds = array_map('intval', array_column($admins, 'id'));
- if (empty($adminIds)) {
- return redirect()->to('/administrator/print-notifications')
- ->with('info', 'No admins found to update.');
- }
-
- $posted = (array) $this->request->getPost('notify');
- $selected = [];
- foreach ($posted as $key => $value) {
- $adminId = (int) $key;
- if ($adminId <= 0) {
- continue;
- }
- if (!in_array($adminId, $adminIds, true)) {
- continue;
- }
- $selected[] = $adminId;
- }
- $selected = array_values(array_unique($selected));
-
- $existingRows = $this->adminNotificationSubjectModel
- ->select('admin_id')
- ->where('subject', 'print_requests')
- ->whereIn('admin_id', $adminIds)
- ->findAll();
-
- $current = array_values(array_unique(array_map(
- fn($row) => (int) ($row['admin_id'] ?? 0),
- $existingRows
- )));
-
- $toDelete = array_values(array_diff($current, $selected));
- $toInsert = array_values(array_diff($selected, $current));
-
- if (!empty($toDelete)) {
- $this->adminNotificationSubjectModel
- ->where('subject', 'print_requests')
- ->whereIn('admin_id', $toDelete)
- ->delete();
- }
-
- if (!empty($toInsert)) {
- $batch = [];
- foreach ($toInsert as $adminId) {
- $batch[] = [
- 'admin_id' => $adminId,
- 'subject' => 'print_requests',
- ];
- }
- $this->adminNotificationSubjectModel->insertBatch($batch);
- }
-
- $changes = count($toDelete) + count($toInsert);
- return redirect()->to('/administrator/print-notifications')
- ->with('success', $changes > 0 ? 'Print notification recipients updated.' : 'No changes were made.');
- }
-
- private function notificationSubjectOptions(): array
- {
- return [
- 'academics' => 'Academics',
- 'attendance' => 'Attendance',
- 'events' => 'Events',
- 'finance' => 'Finance',
- 'general' => 'General',
- 'print_requests' => 'Print Requests',
- ];
- }
-
- private function getAdminNotificationExcludedRoles(): array
- {
- return [
- 'parent',
- 'student',
- 'guest',
- 'teacher',
- 'assistant teacher',
- 'teacher assistant',
- 'teacher_assistant',
- 'assistant_teacher',
- 'ta',
- 'authorized_user',
- ];
- }
-
- private function canManageAdminNotifications(): bool
- {
- $session = session();
- if (! $session->get('is_logged_in')) {
- return false;
- }
-
- $role = trim((string) ($session->get('role') ?? ''));
- if ($role === '') {
- return false;
- }
-
- $excluded = array_map(
- fn ($value) => strtolower(trim((string) $value)),
- $this->getAdminNotificationExcludedRoles()
- );
-
- return !in_array(strtolower($role), $excluded, true);
- }
-
- private function fetchAdminNotificationUsers(): array
- {
- $excluded = $this->getAdminNotificationExcludedRoles();
- $excludedList = "'" . implode("','", $excluded) . "'";
-
- return $this->db->table('users u')
- ->select('u.id, u.firstname, u.lastname, u.email')
- ->join('user_roles ur', 'ur.user_id = u.id', 'inner')
- ->join('roles r', 'r.id = ur.role_id', 'inner')
- ->where('r.name IS NOT NULL', null, false)
- ->where('ur.deleted_at', null)
- ->where("LOWER(r.name) NOT IN ({$excludedList})", null, false)
- ->groupBy('u.id, u.firstname, u.lastname, u.email')
- ->orderBy('u.lastname', 'ASC')
- ->orderBy('u.firstname', 'ASC')
- ->get()
- ->getResultArray();
- }
-
public function feeCollection()
{
return view('administrator/fee_collection');
@@ -2058,237 +390,6 @@ class AdministratorController extends BaseController
return view('administrator/contact_information');
}
- public function studentProfiles()
- {
- $db = db_connect();
- $isPg = ($db->getPlatform() === 'Postgre'); // 'MySQLi', 'Postgre', 'SQLSRV', ...
- $selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
-
- // In MySQL, avoid truncation of long lists
- if (!$isPg) {
- $db->query('SET SESSION group_concat_max_len = 8192');
- }
-
- $b = $db->table('students');
-
- if ($isPg) {
- // Postgres: use subqueries for aggregates to avoid GROUP BY on students.*
- $students = $b->select([
- 'students.*',
- 'users.firstname AS parent_firstname',
- 'users.lastname AS parent_lastname',
- 'users.email AS parent_email',
- 'users.cellphone AS parent_phone',
-
- // Pick one emergency contact (first by id)
- "(SELECT ec.emergency_contact_name FROM emergency_contacts ec
- WHERE ec.parent_id = students.parent_id
- ORDER BY ec.id ASC LIMIT 1) AS emergency_name",
- "(SELECT ec.relation FROM emergency_contacts ec
- WHERE ec.parent_id = students.parent_id
- ORDER BY ec.id ASC LIMIT 1) AS emergency_relationship",
- "(SELECT ec.cellphone FROM emergency_contacts ec
- WHERE ec.parent_id = students.parent_id
- ORDER BY ec.id ASC LIMIT 1) AS emergency_phone",
- "(SELECT ec.email FROM emergency_contacts ec
- WHERE ec.parent_id = students.parent_id
- ORDER BY ec.id ASC LIMIT 1) AS emergency_email",
-
- // Aggregated lists
- "(SELECT STRING_AGG(DISTINCT sa.allergy, ', ' ORDER BY sa.allergy)
- FROM student_allergies sa WHERE sa.student_id = students.id) AS allergies",
- "(SELECT STRING_AGG(DISTINCT smc.condition_name, ', ' ORDER BY smc.condition_name)
- FROM student_medical_conditions smc WHERE smc.student_id = students.id) AS medical_conditions",
- ])
- ->join('users', 'users.id = students.parent_id', 'left')
- ->orderBy('students.lastname', 'ASC')
- ->orderBy('students.firstname', 'ASC')
- ->get()
- ->getResultArray();
- } else {
- // MySQL: GROUP_CONCAT + MIN() for emergency_* to satisfy ONLY_FULL_GROUP_BY
- $students = $b->select([
- 'students.*',
- 'users.firstname AS parent_firstname',
- 'users.lastname AS parent_lastname',
- 'users.email AS parent_email',
- 'users.cellphone AS parent_phone',
-
- 'MIN(emergency_contacts.emergency_contact_name) AS emergency_name',
- 'MIN(emergency_contacts.relation) AS emergency_relationship',
- 'MIN(emergency_contacts.cellphone) AS emergency_phone',
- 'MIN(emergency_contacts.email) AS emergency_email',
-
- "GROUP_CONCAT(DISTINCT student_allergies.allergy
- ORDER BY student_allergies.allergy SEPARATOR ', ') AS allergies",
- "GROUP_CONCAT(DISTINCT student_medical_conditions.condition_name
- ORDER BY student_medical_conditions.condition_name SEPARATOR ', ') AS medical_conditions",
- ])
- ->join('users', 'users.id = students.parent_id', 'left')
- ->join('emergency_contacts', 'emergency_contacts.parent_id = students.parent_id', 'left')
- ->join('student_allergies', 'student_allergies.student_id = students.id', 'left')
- ->join('student_medical_conditions', 'student_medical_conditions.student_id = students.id', 'left')
- ->groupBy('students.id')
- ->orderBy('students.lastname', 'ASC')
- ->orderBy('students.firstname', 'ASC')
- ->get()
- ->getResultArray();
- }
-
- $enrollmentStatusByStudentId = [];
- $studentIds = array_values(array_unique(array_filter(array_map(
- static fn (array $row): int => (int) ($row['id'] ?? 0),
- $students
- ))));
- if ($selectedYear !== '' && !empty($studentIds)) {
- $enrollmentRows = $db->table('enrollments')
- ->select('student_id, enrollment_status')
- ->whereIn('student_id', $studentIds)
- ->where('school_year', $selectedYear)
- ->orderBy('student_id', 'ASC')
- ->orderBy('updated_at', 'DESC')
- ->orderBy('enrollment_date', 'DESC')
- ->orderBy('id', 'DESC')
- ->get()
- ->getResultArray();
-
- foreach ($enrollmentRows as $enrollmentRow) {
- $studentId = (int) ($enrollmentRow['student_id'] ?? 0);
- if ($studentId > 0 && !isset($enrollmentStatusByStudentId[$studentId])) {
- $enrollmentStatusByStudentId[$studentId] = (string) ($enrollmentRow['enrollment_status'] ?? '');
- }
- }
- }
-
- // === Inject current-year class_section_name from student_class and replace grade ===
- foreach ($students as $i => $row) {
- $sid = (int) ($row['id'] ?? 0);
- if ($sid > 0) {
- $classSectionName = (string) ($this->studentClassModel->getClassSectionNameByStudentId($sid, $selectedYear) ?? '');
-
- $students[$i]['class_section_name'] = $classSectionName;
- $students[$i]['enrollment_status'] = $enrollmentStatusByStudentId[$sid] ?? '';
- } else {
- // Keep keys consistent even if id missing
- $students[$i]['class_section_name'] = '';
- $students[$i]['enrollment_status'] = '';
- }
-
- $studentYear = trim((string) ($row['school_year'] ?? ''));
- $students[$i]['age'] = $this->calculateAgeAsOfSchoolYearStartYear(
- $row['dob'] ?? null,
- $studentYear !== '' ? $studentYear : $selectedYear
- );
- }
- // === end injection ===
-
- return view('administrator/student_profiles', [
- 'students' => $students,
- 'gradeOptions' => ['K', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'Youth'],
- 'genderOptions' => ['Male', 'Female', 'Other'],
- 'selectedYear' => $selectedYear,
- ]);
- }
-
- private function calculateAgeAsOfSchoolYearStartYear(?string $dob, string $schoolYear): ?int
- {
- $dob = trim((string) $dob);
- $schoolYear = trim($schoolYear);
-
- if ($dob === '' || ! preg_match('/^(\d{4})/', $schoolYear, $matches)) {
- return null;
- }
-
- try {
- $timezone = new \DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone()));
- $birthDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $timezone);
- $errors = \DateTimeImmutable::getLastErrors();
- $hasParseErrors = is_array($errors)
- && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0);
-
- if ($birthDate === false || $hasParseErrors) {
- return null;
- }
-
- $schoolYearStartYearCutoff = new \DateTimeImmutable($matches[1] . '-09-01', $timezone);
- if ($birthDate > $schoolYearStartYearCutoff) {
- return null;
- }
-
- return $birthDate->diff($schoolYearStartYearCutoff)->y;
- } catch (\Throwable $e) {
- log_message('warning', 'Unable to calculate school-year age from DOB: {message}', [
- 'message' => $e->getMessage(),
- ]);
-
- return null;
- }
- }
-
-
-
- public function parentProfiles()
- {
- if ($redirect = $this->redirectWithoutLegacyTermFilters('administrator/parent_profiles')) {
- return $redirect;
- }
-
- // Fetch all users with their roles in one go
- $allUsers = $this->userModel->findAll();
- $parents = [];
-
- // Fetch roles for users in one query to avoid looping over all users
- foreach ($allUsers as $user) {
- // Get the roles of the user directly
- $roles = $this->userRoleModel->getRolesByUserId($user['id']);
- $isParent = false;
-
- // Check if $roles is iterable and contains the 'parent' role
- if (is_array($roles)) {
- foreach ($roles as $role) {
- if (isset($role['role_name']) && $role['role_name'] === 'parent') {
- $isParent = true;
- break;
- }
- }
- }
-
- if ($isParent) {
- // Retrieve the latest paid amount and balance for this parent
- $paidAmount = $this->invoiceModel->getLatestInvoicePaidAmount($user['id']) ?? 0;
- $balance = $this->invoiceModel->getLatestInvoiceBalance($user['id']) ?? 0;
-
- // Get students for this parent
- $studentsData = $this->studentModel->where('parent_id', $user['id'])->findAll();
- $students = [];
- foreach ($studentsData as $student) {
- $classSectionName = $this->studentClassModel->getClassSectionNameByStudentId($student['id']) ?? 'N/A';
- $students[] = [
- 'name' => $student['firstname'] . ' ' . $student['lastname'],
- 'class_section' => $classSectionName
- ];
- }
-
- // Add parent data to the array
- $parents[] = [
- 'id' => $user['id'],
- 'school_id' => $user['school_id'],
- 'firstname' => $user['firstname'],
- 'lastname' => $user['lastname'],
- 'email' => $user['email'],
- 'cellphone' => $user['cellphone'],
- 'gender' => $user['gender'],
- 'created_at' => $user['created_at'],
- 'paid_amount' => $paidAmount,
- 'balance' => $balance,
- 'students' => $students,
- ];
- }
- }
-
- return view('administrator/parent_profile', ['parents' => $parents]);
- }
-
private function redirectWithoutLegacyTermFilters(string $route): ?\CodeIgniter\HTTP\RedirectResponse
{
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
@@ -2392,109 +493,160 @@ class AdministratorController extends BaseController
return redirect()->to('/administrator/manage-users');
}
+ private function canManageAdminNotifications(): bool
+ {
+ $session = session();
+ if (! $session->get('is_logged_in')) {
+ return false;
+ }
+
+ $role = trim((string) ($session->get('role') ?? ''));
+ if ($role === '') {
+ return false;
+ }
+
+ $excluded = array_map(
+ fn ($value) => strtolower(trim((string) $value)),
+ service('adminNotificationSettings')->excludedRoles()
+ );
+
+ return !in_array(strtolower($role), $excluded, true);
+ }
+
+ public function administratorDashboard()
+ {
+ helper('url');
+
+ $searchData = service('administratorDashboard')->search((string) $this->request->getGet('query'));
+
+ return view('administrator/administratordashboard', array_merge($searchData, [
+ 'dashboardEndpoint' => site_url('api/administrator/dashboard'),
+ ]));
+ }
+
+ public function dashboardMetrics()
+ {
+ return $this->response->setJSON(
+ service('administratorDashboard')->metrics((string) $this->schoolYear, (string) $this->semester)
+ );
+ }
+
+ public function userSearch()
+ {
+ $data = service('administratorDashboard')->search((string) $this->request->getGet('query'));
+
+ return view('administrator/search_results', $data);
+ }
+
+ public function teacherSubmissionsReport()
+ {
+ $semester = (string) (getSemester() ?? $this->semester ?? '');
+ $schoolYear = trim((string) ($this->request->getGet('school_year') ?? ''));
+ if ($schoolYear === '') {
+ $schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
+ }
+ $lowProgressRaw = (string) $this->request->getGet('low_progress_sections');
+ $lowProgressSectionIds = array_values(array_unique(array_filter(array_map(
+ 'intval',
+ preg_split('/\s*,\s*/', $lowProgressRaw, -1, PREG_SPLIT_NO_EMPTY)
+ ))));
+
+ return view(
+ 'administrator/teacher_submissions',
+ service('teacherSubmissionReport')->buildReport($semester, $schoolYear, $lowProgressSectionIds)
+ );
+ }
+
+ public function sendTeacherSubmissionNotifications()
+ {
+ $result = service('teacherSubmissionReport')->sendNotifications(
+ (array) $this->request->getPost(),
+ (string) (getSemester() ?? $this->semester ?? ''),
+ (int) (session()->get('user_id') ?? 0)
+ );
+
+ if (($result['redirect'] ?? '') === 'login') {
+ return redirect()->to('/login');
+ }
+
+ return redirect()->back()->with((string) ($result['type'] ?? 'info'), (string) ($result['message'] ?? ''));
+ }
+
+ public function notificationsAlerts()
+ {
+ if (!$this->canManageAdminNotifications()) {
+ return redirect()->to('/login');
+ }
+
+ return view('administrator/notifications_alerts', service('adminNotificationSettings')->alertsPage());
+ }
+
+ public function saveNotificationSubjects()
+ {
+ if (!$this->canManageAdminNotifications()) {
+ return redirect()->to('/login');
+ }
+
+ $result = service('adminNotificationSettings')->saveSubjects($this->request->getPost('subjects'));
+
+ return redirect()->to('/administrator/notifications_alerts')
+ ->with((string) ($result['type'] ?? 'info'), (string) ($result['message'] ?? ''));
+ }
+
+ public function printNotificationRecipients()
+ {
+ if (!$this->canManageAdminNotifications()) {
+ return redirect()->to('/login');
+ }
+
+ return view('administrator/print_notification_admins', service('adminNotificationSettings')->printRecipientsPage());
+ }
+
+ public function savePrintNotificationRecipients()
+ {
+ if (!$this->canManageAdminNotifications()) {
+ return redirect()->to('/login');
+ }
+
+ $result = service('adminNotificationSettings')->savePrintRecipients((array) $this->request->getPost('notify'));
+
+ return redirect()->to('/administrator/print-notifications')
+ ->with((string) ($result['type'] ?? 'info'), (string) ($result['message'] ?? ''));
+ }
+
+ public function studentProfiles()
+ {
+ $selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
+
+ return view(
+ 'administrator/student_profiles',
+ service('administratorDirectory')->studentProfiles($selectedYear)
+ );
+ }
+
+ public function parentProfiles()
+ {
+ if ($redirect = $this->redirectWithoutLegacyTermFilters('administrator/parent_profiles')) {
+ return $redirect;
+ }
+
+ return view('administrator/parent_profile', service('administratorDirectory')->parentProfiles());
+ }
+
public function showEnrollmentWithdrawalPage()
{
try {
$schoolYearContext = $this->resolveSchoolYearContext();
$selectedYear = $schoolYearContext->yearName();
-
- $this->syncReviewDecisionEnrollments($selectedYear);
-
- $students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
-
- $removedPriorStatuses = $this->removedPriorYearStudentStatuses($selectedYear);
- $returningStudentIds = $this->priorYearStudentIds($selectedYear);
-
- foreach ($students as &$s) {
- // ===== Ensure IDs needed by the modal =====
- $s['student_id'] = (int)($s['id'] ?? 0);
- $priorRemovedStatus = $removedPriorStatuses[$s['student_id']] ?? null;
- $s['removed_previous_year'] = $priorRemovedStatus !== null ? 'Yes' : 'No';
- $s['prior_removed_status'] = $priorRemovedStatus;
-
- // Prefer parent_id; fallback to secondparent_user_id if present
- if (empty($s['parent_id']) && !empty($s['secondparent_user_id'])) {
- $s['parent_id'] = (int)$s['secondparent_user_id'];
- } else {
- $s['parent_id'] = (int)($s['parent_id'] ?? 0);
- }
-
- // ===== Parent display + sort keys (keep existing behavior) =====
- $pf = trim((string)($s['parent_firstname'] ?? ''));
- $pl = trim((string)($s['parent_lastname'] ?? ''));
-
- // Fallback: if only a single full name exists
- if ($pf === '' && $pl === '' && !empty($s['parent_fullname'])) {
- $parts = preg_split('/\s+/', trim((string)$s['parent_fullname']), 2);
- $pf = $parts[0] ?? '';
- $pl = $parts[1] ?? '';
- }
-
- $s['parent_label'] = trim($pf . ' ' . $pl);
- $s['parent_sort'] = trim(($pl !== '' ? $pl : $pf) . ' ' . $pf);
-
- if ($s['parent_label'] === '') {
- $s['parent_label'] = 'Unknown Parent';
- $s['parent_sort'] = 'ZZZ Unknown Parent';
- }
-
- // ===== New-student flags =====
- $s['is_new'] = (int) ($s['is_new'] ?? 0);
- if (isset($returningStudentIds[$s['student_id']])) {
- $s['is_new'] = 0;
- }
- $s['new_student'] = $s['is_new'] === 1 ? 'Yes' : 'No';
-
- // ===== Admission override =====
- // Enrollment status for selected year
- $statusForYear = $this->enrollmentModel->getEnrollmentStatus((int)$s['student_id'], $selectedYear);
- if (!empty($priorRemovedStatus)) {
- $s['enrollment_status'] = $priorRemovedStatus;
- } elseif (!empty($statusForYear)) {
- $s['enrollment_status'] = $statusForYear;
- } elseif (($s['admission_status'] ?? null) === 'denied') {
- $s['enrollment_status'] = 'denied';
- } else {
- $s['enrollment_status'] = 'admission under review';
- $s['admission_status'] = 'pending';
- }
-
- // ===== Class section name for the selected year =====
- $name = $this->studentClassModel->getClassSectionsByStudentId((int)$s['student_id'], $selectedYear);
- $s['class_section'] = $name ?: 'Class not Assigned';
- $calculatedAge = $this->calculateAgeAsOfSchoolYearStartYear($s['dob'] ?? null, $selectedYear);
- $s['age'] = $calculatedAge ?? ($s['age'] ?? null);
-
- // ===== Sortable registration date (for data-order in view) =====
- $s['registration_date_order'] = !empty($s['registration_date'])
- ? date('Y-m-d', strtotime($s['registration_date']))
- : '';
- }
- unset($s); // break reference
-
- // ===== Sort by parent, then student (lastname, firstname) =====
- usort($students, function (array $a, array $b) {
- $pa = $a['parent_sort'] ?? '';
- $pb = $b['parent_sort'] ?? '';
- if (strcasecmp($pa, $pb) === 0) {
- $la = $a['lastname'] ?? '';
- $lb = $b['lastname'] ?? '';
- $cmp = strcasecmp($la, $lb);
- if ($cmp !== 0) return $cmp;
- return strcasecmp($a['firstname'] ?? '', $b['firstname'] ?? '');
- }
- return strcasecmp($pa, $pb);
- });
-
- $classes = $this->enrollmentClassOptions((string)$selectedYear);
+ $payload = service('enrollmentWithdrawal')->buildRoster($selectedYear, (string) $this->semester);
return view('enroll_withdraw/enrollment_withdrawal', [
- 'students' => $students,
- 'classes' => $classes, // <-- used by the modal
- 'selectedYear' => $selectedYear,
- 'currentYear' => $selectedYear,
+ 'students' => $payload['students'],
+ 'classes' => $payload['classes'],
+ 'selectedYear' => $selectedYear,
+ 'currentYear' => $selectedYear,
'isCurrentYear' => ! $schoolYearContext->isReadonly(),
- 'missingYear' => $selectedYear === '',
+ 'missingYear' => $selectedYear === '',
]);
} catch (\Throwable $e) {
log_message('error', 'Enrollment/Withdrawal page error: {msg}', ['msg' => $e->getMessage()]);
@@ -2502,91 +654,18 @@ class AdministratorController extends BaseController
}
}
- // API: Enrollment/Withdrawal data for admin page
public function enrollmentWithdrawalData()
{
try {
- $selectedYear = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
-
- $this->syncReviewDecisionEnrollments($selectedYear);
-
- $students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
-
- $removedPriorStatuses = $this->removedPriorYearStudentStatuses($selectedYear);
- $returningStudentIds = $this->priorYearStudentIds($selectedYear);
-
- foreach ($students as &$s) {
- $s['student_id'] = (int)($s['id'] ?? 0);
- $priorRemovedStatus = $removedPriorStatuses[$s['student_id']] ?? null;
- $s['removed_previous_year'] = $priorRemovedStatus !== null ? 'Yes' : 'No';
- $s['prior_removed_status'] = $priorRemovedStatus;
-
- if (empty($s['parent_id']) && !empty($s['secondparent_user_id'])) {
- $s['parent_id'] = (int)$s['secondparent_user_id'];
- } else {
- $s['parent_id'] = (int)($s['parent_id'] ?? 0);
- }
-
- $pf = trim((string)($s['parent_firstname'] ?? ''));
- $pl = trim((string)($s['parent_lastname'] ?? ''));
- if ($pf === '' && $pl === '' && !empty($s['parent_fullname'])) {
- $parts = preg_split('/\s+/', trim((string)$s['parent_fullname']), 2);
- $pf = $parts[0] ?? '';
- $pl = $parts[1] ?? '';
- }
- $s['parent_label'] = trim($pf . ' ' . $pl) ?: 'Unknown Parent';
- $s['parent_sort'] = trim(($pl !== '' ? $pl : $pf) . ' ' . $pf) ?: 'ZZZ Unknown Parent';
-
- $s['is_new'] = (int) ($s['is_new'] ?? 0);
- if (isset($returningStudentIds[$s['student_id']])) {
- $s['is_new'] = 0;
- }
- $s['new_student'] = $s['is_new'] === 1 ? 'Yes' : 'No';
-
- $statusForYear = $this->enrollmentModel->getEnrollmentStatus((int)$s['student_id'], $selectedYear);
- if (!empty($priorRemovedStatus)) {
- $s['enrollment_status'] = $priorRemovedStatus;
- } elseif (!empty($statusForYear)) {
- $s['enrollment_status'] = $statusForYear;
- } elseif (($s['admission_status'] ?? null) === 'denied') {
- $s['enrollment_status'] = 'denied';
- } else {
- $s['enrollment_status'] = 'admission under review';
- $s['admission_status'] = 'pending';
- }
-
- $className = $this->studentClassModel->getClassSectionsByStudentId((int)$s['student_id'], $selectedYear);
- $s['class_section'] = $className ?: 'Class not Assigned';
- $calculatedAge = $this->calculateAgeAsOfSchoolYearStartYear($s['dob'] ?? null, $selectedYear);
- $s['age'] = $calculatedAge ?? ($s['age'] ?? null);
-
- $s['registration_date_order'] = !empty($s['registration_date'])
- ? date('Y-m-d', strtotime($s['registration_date']))
- : '';
- }
- unset($s);
-
- usort($students, function (array $a, array $b) {
- $pa = $a['parent_sort'] ?? '';
- $pb = $b['parent_sort'] ?? '';
- if (strcasecmp($pa, $pb) === 0) {
- $la = $a['lastname'] ?? '';
- $lb = $b['lastname'] ?? '';
- $cmp = strcasecmp($la, $lb);
- if ($cmp !== 0) return $cmp;
- return strcasecmp($a['firstname'] ?? '', $b['firstname'] ?? '');
- }
- return strcasecmp($pa, $pb);
- });
-
- $classes = $this->enrollmentClassOptions((string)$selectedYear);
+ $selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
+ $payload = service('enrollmentWithdrawal')->buildRoster($selectedYear, (string) $this->semester);
return $this->response->setJSON([
- 'students' => $students,
- 'classes' => $classes,
+ 'students' => $payload['students'],
+ 'classes' => $payload['classes'],
'csrfHash' => csrf_hash(),
- 'semester' => (string)$this->semester,
- 'school_year' => (string)$selectedYear,
+ 'semester' => (string) $this->semester,
+ 'school_year' => (string) $selectedYear,
]);
} catch (\Throwable $e) {
log_message('error', 'enrollmentWithdrawalData error: {msg}', ['msg' => $e->getMessage()]);
@@ -2594,831 +673,24 @@ class AdministratorController extends BaseController
}
}
- private function syncReviewDecisionEnrollments(string $selectedYear): void
- {
- $selectedYear = trim($selectedYear);
- $sourceYear = $this->getPreviousSchoolYear($selectedYear);
- if ($selectedYear === '' || $sourceYear === '' || ! $this->db->tableExists('enrollments')) {
- return;
- }
-
- $studentIds = $this->sourceYearStudentIds($sourceYear);
- if ($studentIds === []) {
- return;
- }
-
- $transitionService = service('enrollmentTransition');
- $now = utc_now();
-
- foreach ($studentIds as $studentId) {
- try {
- $evaluation = $transitionService->evaluate((int) $studentId, $sourceYear, $selectedYear, 'parent');
- } catch (\Throwable $e) {
- log_message('error', 'Review & Decision enrollment sync evaluation failed for student {studentId}: {message}', [
- 'studentId' => $studentId,
- 'message' => $e->getMessage(),
- ]);
- continue;
- }
-
- if (! $this->needsReviewDecisionEnrollment($evaluation)) {
- continue;
- }
-
- $student = $this->studentModel->find((int) $studentId);
- if (! is_array($student)) {
- continue;
- }
-
- $parentId = (int) ($student['parent_id'] ?? ($student['secondparent_user_id'] ?? 0));
- if ($parentId <= 0) {
- log_message('warning', 'Review & Decision enrollment sync skipped student {studentId}: no parent ID.', [
- 'studentId' => $studentId,
- ]);
- continue;
- }
-
- $existing = $this->db->table('enrollments')
- ->select('id, enrollment_status')
- ->where('student_id', (int) $studentId)
- ->where('school_year', $selectedYear)
- ->orderBy('updated_at', 'DESC')
- ->orderBy('id', 'DESC')
- ->limit(1)
- ->get()
- ->getRowArray();
-
- if ($existing !== null) {
- $existingStatus = (string) ($existing['enrollment_status'] ?? '');
- if ($existingStatus === 'review & decision' || ! in_array($existingStatus, ['', 'admission under review'], true)) {
- continue;
- }
- }
-
- $payload = [
- 'student_id' => (int) $studentId,
- 'parent_id' => $parentId,
- 'school_year' => $selectedYear,
- 'semester' => (string) $this->semester,
- 'source_school_year' => $sourceYear,
- 'deliberation_decision' => $evaluation['deliberation_decision'] ?? null,
- 'source_grade_id' => $evaluation['source_grade_id'] ?? null,
- 'assigned_grade_id' => $evaluation['assigned_grade_id'] ?? null,
- 'source_class_section_id' => $evaluation['source_class_section_id'] ?? null,
- 'assigned_class_section_id' => $evaluation['assigned_class_section_id'] ?? null,
- 'placement_status' => $evaluation['placement_status'] ?? 'not_created',
- 'age_reference_date' => $evaluation['age_reference_date'] ?? null,
- 'age_on_reference_date' => $evaluation['age_on_reference_date'] ?? null,
- 'adult_student' => ! empty($evaluation['adult_student']) ? 1 : 0,
- 'parent_enrollment_allowed' => ! empty($evaluation['parent_enrollment_allowed']) ? 1 : 0,
- 'student_self_enrollment_allowed' => ! empty($evaluation['student_self_enrollment_allowed']) ? 1 : 0,
- 'exception_required' => 1,
- 'exception_reason' => implode(', ', array_filter(array_column($evaluation['flags'] ?? [], 'flag_type'))) ?: implode(' ', array_map('strval', $evaluation['blockers'] ?? [])),
- 'enrollment_date' => local_date(utc_now(), 'Y-m-d'),
- 'enrollment_status' => 'review & decision',
- 'admission_status' => 'pending',
- 'is_withdrawn' => 0,
- 'updated_at' => $now,
- ];
- $payload = $this->filterEnrollmentPayloadByColumns($payload);
-
- if ($existing !== null) {
- $payload['id'] = (int) $existing['id'];
- } else {
- $payload['created_at'] = $now;
- }
-
- \Config\Services::enrollmentStatus(false)->upsertStatus(
- $this->filterEnrollmentPayloadByColumns($payload),
- (int) (session()->get('user_id') ?? 0) ?: null,
- 'admin_review_decision_enrollment'
- );
- }
- }
-
- private function needsReviewDecisionEnrollment(array $evaluation): bool
- {
- $decision = (string) ($evaluation['deliberation_decision'] ?? '');
-
- if (in_array($decision, [
- DeliberationDecision::EXPELLED,
- DeliberationDecision::WITHDRAWN,
- DeliberationDecision::DEFERRED_DECISION,
- ], true)) {
- return true;
- }
-
- $hasSourceAssignment = (int) ($evaluation['source_class_section_id'] ?? 0) > 0
- || (int) ($evaluation['source_grade_id'] ?? 0) > 0;
-
- return $decision === ''
- && $hasSourceAssignment
- && array_filter($evaluation['blockers'] ?? []) !== [];
- }
-
- private function sourceYearStudentIds(string $sourceYear): array
- {
- $studentIds = [];
-
- foreach (['student_class', 'enrollments', 'student_decisions'] as $table) {
- if (! $this->db->tableExists($table) || ! $this->db->fieldExists('student_id', $table)) {
- continue;
- }
-
- $yearColumn = match ($table) {
- 'student_class', 'student_decisions' => 'school_year',
- default => 'school_year',
- };
-
- if (! $this->db->fieldExists($yearColumn, $table)) {
- continue;
- }
-
- $rows = $this->db->table($table)
- ->select('student_id')
- ->where($yearColumn, $sourceYear)
- ->where('student_id IS NOT NULL', null, false)
- ->get()
- ->getResultArray();
-
- foreach ($rows as $row) {
- $studentId = (int) ($row['student_id'] ?? 0);
- if ($studentId > 0) {
- $studentIds[$studentId] = true;
- }
- }
- }
-
- return array_keys($studentIds);
- }
-
- private function filterEnrollmentPayloadByColumns(array $payload): array
- {
- foreach (array_keys($payload) as $column) {
- if (! $this->db->fieldExists($column, 'enrollments')) {
- unset($payload[$column]);
- }
- }
-
- return $payload;
- }
-
- private function enrollmentClassOptions(string $selectedYear): array
- {
- $select = ['id', 'class_section_id', 'class_section_name'];
- $hasSchoolYear = $this->db->fieldExists('school_year', 'classSection');
- $hasSemester = $this->db->fieldExists('semester', 'classSection');
-
- if ($hasSchoolYear) {
- $select[] = 'school_year';
- }
- if ($hasSemester) {
- $select[] = 'semester';
- }
-
- $query = $this->classSectionModel
- ->select(implode(', ', $select))
- ->orderBy('class_section_name', 'ASC');
-
- if ($hasSchoolYear && $selectedYear !== '') {
- $query->where('school_year', $selectedYear);
- }
- if ($hasSemester) {
- $query->where('semester', (string)$this->semester);
- }
-
- $classes = $query->findAll();
-
- if (! empty($classes) || (! $hasSchoolYear && ! $hasSemester)) {
- return $classes;
- }
-
- return $this->classSectionModel
- ->select('id, class_section_id, class_section_name')
- ->orderBy('class_section_name', 'ASC')
- ->findAll();
- }
-
- private function removedPriorYearStudentStatuses(string $selectedYear): array
- {
- $selectedStartYear = $this->getSchoolYearStartYear($selectedYear);
- if ($selectedStartYear === null || ! $this->db->tableExists('enrollments')) {
- return [];
- }
-
- $select = ['student_id', 'school_year'];
- $hasIsWithdrawn = $this->db->fieldExists('is_withdrawn', 'enrollments');
- $hasEnrollmentStatus = $this->db->fieldExists('enrollment_status', 'enrollments');
- $hasAdmissionStatus = $this->db->fieldExists('admission_status', 'enrollments');
-
- if ($hasIsWithdrawn) {
- $select[] = 'is_withdrawn';
- }
- if ($hasEnrollmentStatus) {
- $select[] = 'enrollment_status';
- }
- if ($hasAdmissionStatus) {
- $select[] = 'admission_status';
- }
-
- if (! $hasIsWithdrawn && ! $hasEnrollmentStatus && ! $hasAdmissionStatus) {
- return [];
- }
-
- $builder = $this->db->table('enrollments')
- ->select(implode(', ', $select))
- ->where('student_id IS NOT NULL', null, false)
- ->where('school_year IS NOT NULL', null, false)
- ->groupStart();
-
- $hasRemovalCondition = false;
- if ($hasIsWithdrawn) {
- $builder->orWhere('is_withdrawn', 1);
- $hasRemovalCondition = true;
- }
-
- if ($hasEnrollmentStatus) {
- $builder->orWhereIn('enrollment_status', ['withdrawn', 'denied']);
- $hasRemovalCondition = true;
- }
-
- if ($hasAdmissionStatus) {
- $builder->orWhere('admission_status', 'denied');
- $hasRemovalCondition = true;
- }
-
- $builder->groupEnd();
- if (! $hasRemovalCondition) {
- return [];
- }
-
- $removedPriorStatuses = [];
- foreach ($builder->get()->getResultArray() as $row) {
- $rowYear = $this->getSchoolYearStartYear((string)($row['school_year'] ?? ''));
- $studentId = (int)($row['student_id'] ?? 0);
- if ($studentId <= 0 || $rowYear === null || $rowYear >= $selectedStartYear) {
- continue;
- }
-
- $status = $this->priorRemovedEnrollmentStatus($row);
- if ($status === null) {
- continue;
- }
-
- if (
- !isset($removedPriorStatuses[$studentId])
- || $rowYear > (int)$removedPriorStatuses[$studentId]['year']
- ) {
- $removedPriorStatuses[$studentId] = [
- 'year' => $rowYear,
- 'status' => $status,
- ];
- }
- }
-
- $statusByStudentId = [];
- foreach ($removedPriorStatuses as $studentId => $row) {
- $statusByStudentId[(int)$studentId] = (string)$row['status'];
- }
-
- return $statusByStudentId;
- }
-
- private function priorRemovedEnrollmentStatus(array $row): ?string
- {
- $enrollmentStatus = strtolower(trim((string)($row['enrollment_status'] ?? '')));
- $admissionStatus = strtolower(trim((string)($row['admission_status'] ?? '')));
-
- if ($enrollmentStatus === 'denied' || $admissionStatus === 'denied') {
- return 'denied';
- }
-
- if ($enrollmentStatus === 'withdrawn' || (int)($row['is_withdrawn'] ?? 0) === 1) {
- return 'withdrawn';
- }
-
- return null;
- }
-
- private function priorYearStudentIds(string $selectedYear): array
- {
- $selectedStartYear = $this->getSchoolYearStartYear($selectedYear);
- if ($selectedStartYear === null) {
- return [];
- }
-
- $studentIds = [];
- foreach (['enrollments', 'student_class'] as $table) {
- if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) {
- continue;
- }
-
- $rows = $this->db->table($table)
- ->select('student_id, school_year')
- ->where('student_id IS NOT NULL', null, false)
- ->where('school_year IS NOT NULL', null, false)
- ->get()
- ->getResultArray();
-
- foreach ($rows as $row) {
- $rowStartYear = $this->getSchoolYearStartYear((string) ($row['school_year'] ?? ''));
- $studentId = (int) ($row['student_id'] ?? 0);
- if ($studentId > 0 && $rowStartYear !== null && $rowStartYear < $selectedStartYear) {
- $studentIds[$studentId] = true;
- }
- }
- }
-
- return $studentIds;
- }
-
- /**
- * Show only newly registered students, with contact modal support.
- *
- * Route example:
- * $routes->get('admin/enrollment/new-students', 'EnrollmentController::showNewStudents', ['filter' => 'auth:view_new_students']);
- */
public function showNewStudents(): string
{
- $rows = $this->studentModel->getStudentsWithParentsAndEmergency($this->schoolYear);
-
- $newStudents = [];
- foreach ($rows as $r) {
- $r['new_student'] = 'Yes';
- $classSection = $this->studentClassModel->getClassSectionsByStudentId($r['id'], $this->schoolYear);
- $enrollmentstatus = $this->enrollmentModel->getEnrollmentStatus($r['id'], $this->schoolYear);
- // robust default
- $r['class_section'] = (isset($classSection) && trim((string)$classSection) !== '')
- ? $classSection
- : 'Class not Assigned';
-
- $r['is_new'] = (int) $r['is_new'];
- $r['new_student'] = $r['is_new'] === 1 ? "Yes" : "No";
- $r['modalIdContact'] = 'contact_' . (int)($r['id'] ?? 0);
- $r['enrollment_status'] = $enrollmentstatus;
-
- // ✅ Format registration_date for display
- if (!empty($r['registration_date'])) {
- try {
- $r['registration_date'] = (new \DateTime($r['registration_date']))->format('Y-m-d');
- } catch (\Throwable $e) {
- $r['registration_date'] = '';
- }
- }
-
- // Age comes directly from DB (already stored in students.age)
- $newStudents[] = $r;
- }
-
- return view('enroll_withdraw/new-students', [
- 'new_students' => $newStudents,
- 'total_new' => count($newStudents),
- ]);
+ return view(
+ 'enroll_withdraw/new-students',
+ service('enrollmentWithdrawal')->newStudents((string) $this->schoolYear)
+ );
}
-
-
-
- //update enrollment status
public function adminEnrollmentWithdrawalHandler()
{
- $refundService = new FeeCalculationService();
- $enrollmentStatusService = \Config\Services::enrollmentStatus(false);
- $performedBy = (int) (session()->get('user_id') ?? 0) ?: null;
- $this->db->transStart();
+ $result = service('enrollmentWithdrawal')->updateStatuses(
+ $this->request->getPost('enrollment_status'),
+ (string) $this->schoolYear,
+ (string) $this->semester,
+ (int) (session()->get('user_id') ?? 0) ?: null
+ );
- try {
- $enrollmentStatuses = $this->request->getPost('enrollment_status');
- if (empty($enrollmentStatuses)) {
- return redirect()->to(base_url('enroll_withdraw/enrollment_withdrawal'))
- ->with('error', 'No enrollment statuses were submitted.');
- }
-
- $errors = [];
-
- // For batching emails: parent -> status -> [students...]
- $groupsByParentStatus = []; // [parent_id][status][] = ['student_id'=>, 'student_name'=>]
- $parentInfo = []; // [parent_id] = ['user_id','email','firstname','lastname']
- $refundParents = []; // parent_id => true (for refund calc)
- $refundAmountByParent = []; // parent_id => amount
-
- $validStatuses = [
- 'admission under review',
- 'review & decision',
- 'payment pending',
- 'enrolled',
- 'withdraw under review',
- 'refund pending',
- 'withdrawn',
- 'denied',
- 'waitlist',
- ];
-
- foreach ($enrollmentStatuses as $studentId => $newEnrollmentStatus) {
- if (!in_array($newEnrollmentStatus, $validStatuses, true)) {
- $errors[] = "Invalid enrollment status '$newEnrollmentStatus' for student ID $studentId.";
- continue;
- }
-
- // Map admission_status based on the desired new status (needed for create-or-update)
- if ($newEnrollmentStatus === 'denied') {
- $admissionStatus = 'denied';
- } elseif (in_array($newEnrollmentStatus, ['enrolled', 'payment pending'], true)) {
- $admissionStatus = 'accepted';
- } else {
- $admissionStatus = 'pending';
- }
-
- // Current enrollment row
- $enrollmentRow = $this->db->table('enrollments')
- ->where('student_id', $studentId)
- ->where('school_year', $this->schoolYear)
- ->get()
- ->getRowArray();
-
- // If no enrollment found for this student/year, create one so invoice generation has data
- if (!$enrollmentRow) {
- $stu = $this->studentModel->find((int)$studentId) ?? [];
- $parentId = (int)($stu['parent_id'] ?? ($stu['secondparent_user_id'] ?? 0));
- if (!$parentId) {
- $errors[] = "No parent ID found for student ID $studentId.";
- continue;
- }
-
- $isWithdrawn = in_array($newEnrollmentStatus, ['withdrawn', 'refund pending', 'withdraw under review'], true) ? 1 : 0;
-
- $result = $enrollmentStatusService->upsertStatus([
- 'student_id' => (int)$studentId,
- 'parent_id' => $parentId,
- 'school_year' => (string)$this->schoolYear,
- 'semester' => (string)$this->semester,
- 'enrollment_date' => local_date(utc_now(), 'Y-m-d'),
- 'is_withdrawn' => $isWithdrawn,
- 'enrollment_status' => $newEnrollmentStatus,
- 'admission_status' => $admissionStatus,
- 'created_at' => utc_now(),
- 'updated_at' => utc_now(),
- ], $performedBy, 'admin_enrollment_withdrawal_handler');
-
- if ((int) ($result['id'] ?? 0) <= 0) {
- $errors[] = "Failed to create enrollment for student ID $studentId.";
- continue;
- }
-
- // Group this newly-created change for notifications
- $studentRow = $this->studentModel->find($studentId) ?? [];
- $studentName = trim(($studentRow['firstname'] ?? '') . ' ' . ($studentRow['lastname'] ?? '')) ?: "Student #{$studentId}";
-
- if (!isset($parentInfo[$parentId])) {
- $p = $this->userModel->find($parentId) ?? [];
- $parentInfo[$parentId] = [
- 'user_id' => $p['id'] ?? $parentId,
- 'email' => $p['email'] ?? null,
- 'firstname' => $p['firstname'] ?? '',
- 'lastname' => $p['lastname'] ?? '',
- ];
- }
- $groupsByParentStatus[$parentId][$newEnrollmentStatus][] = [
- 'student_id' => (int) $studentId,
- 'student_name' => $studentName,
- ];
-
- if ($newEnrollmentStatus === 'refund pending') {
- $refundParents[$parentId] = true;
- }
-
- log_message('info', "Created enrollment for student ID {$studentId} with status {$newEnrollmentStatus} and admission {$admissionStatus}.");
- if (in_array($newEnrollmentStatus, ['payment pending', 'enrolled'], true)) {
- $this->applyDistributionDraftToStudentClass((int)$studentId, (string)$this->schoolYear);
- }
- continue; // go to next student
- }
-
- $oldStatus = $enrollmentRow['enrollment_status'] ?? null;
- $parentId = $enrollmentRow['parent_id'] ?? null;
-
- if (!$parentId) {
- $errors[] = "No parent ID found for student ID $studentId.";
- continue;
- }
-
- // admissionStatus computed above
-
- if ($oldStatus === $newEnrollmentStatus) {
- $enrollmentStatusService->upsertStatus([
- 'id' => (int) $enrollmentRow['id'],
- 'student_id' => (int) $studentId,
- 'parent_id' => (int) $parentId,
- 'school_year' => (string) $this->schoolYear,
- 'semester' => (string) ($enrollmentRow['semester'] ?? $this->semester),
- 'enrollment_status' => $newEnrollmentStatus,
- 'admission_status' => $admissionStatus,
- 'updated_at' => utc_now(),
- ], $performedBy, 'admin_enrollment_status_repair');
- log_message('debug', "No status change for student {$studentId} ({$oldStatus}); repaired activity flag.");
- if (in_array($newEnrollmentStatus, ['payment pending', 'enrolled'], true)) {
- $this->applyDistributionDraftToStudentClass((int)$studentId, (string)$this->schoolYear);
- }
- continue;
- }
-
- $result = $enrollmentStatusService->upsertStatus([
- 'id' => (int) $enrollmentRow['id'],
- 'student_id' => (int) $studentId,
- 'parent_id' => (int) $parentId,
- 'school_year' => (string) $this->schoolYear,
- 'semester' => (string) ($enrollmentRow['semester'] ?? $this->semester),
- 'enrollment_status' => $newEnrollmentStatus,
- 'admission_status' => $admissionStatus,
- 'updated_at' => utc_now(),
- ], $performedBy, 'admin_enrollment_withdrawal_handler');
-
- if ((int) ($result['id'] ?? 0) <= 0) {
- $errors[] = "Failed to update enrollment for student ID $studentId.";
- continue;
- }
-
- log_message('info', "Updated enrollment for student ID $studentId: {$oldStatus} → {$newEnrollmentStatus} (admission: {$admissionStatus})");
- if (in_array($newEnrollmentStatus, ['payment pending', 'enrolled'], true)) {
- $this->applyDistributionDraftToStudentClass((int)$studentId, (string)$this->schoolYear);
- }
-
- // Student name
- $studentRow = $this->studentModel->find($studentId);
- $studentName = trim(($studentRow['firstname'] ?? '') . ' ' . ($studentRow['lastname'] ?? '')) ?: "Student #{$studentId}";
-
- // Cache parent info once
- if (!isset($parentInfo[$parentId])) {
- $p = $this->userModel->find($parentId) ?? [];
- $parentInfo[$parentId] = [
- 'user_id' => $p['id'] ?? $parentId, // assuming parent_id == user_id
- 'email' => $p['email'] ?? null,
- 'firstname' => $p['firstname'] ?? '',
- 'lastname' => $p['lastname'] ?? '',
- ];
- }
-
- // Group by parent & status for minimal emails
- $groupsByParentStatus[$parentId][$newEnrollmentStatus][] = [
- 'student_id' => (int) $studentId,
- 'student_name' => $studentName,
- ];
-
- // Mark for refund calc
- if ($newEnrollmentStatus === 'refund pending') {
- $refundParents[$parentId] = true;
- }
- }
-
- // Compute refunds ONCE per parent needing it
- foreach (array_keys($refundParents) as $pid) {
- $students = $this->enrollmentModel
- ->where('parent_id', $pid)
- ->where('school_year', $this->schoolYear)
- ->findAll();
-
- if (empty($students)) {
- // If a parent is marked for refund but has no enrollments, just log and continue.
- log_message('info', "No enrollments found for parent ID {$pid} (for refund calc); skipping refund.");
- continue;
- }
-
- $invoice = $this->invoiceModel->where('parent_id', $pid)
- ->where('school_year', $this->schoolYear)
- ->orderBy('created_at', 'DESC')
- ->first();
-
- if (!$invoice) {
- $errors[] = "No invoice found for parent ID $pid (for refund calc).";
- continue;
- }
-
- $refundAmount = $refundService->calculateRefund($students, $pid);
- $refundAmountByParent[$pid] = $refundAmount;
-
- $existingRefund = $this->refundModel->where('invoice_id', $invoice['id'])->first();
-
- if ($existingRefund) {
- $refundId = (int)$existingRefund['id'];
- $status = strtolower((string)($existingRefund['status'] ?? ''));
- $isApprovedState = in_array($status, ['approved', 'partial', 'paid', 'partially_paid'], true);
- $calculatedCents = max(0, (int)round($refundAmount * 100));
- $paidCents = (new RefundEligibilityService())->getCompletedPayoutTotalCentsForRefund($refundId);
- $targetCents = $isApprovedState ? max($calculatedCents, $paidCents) : $calculatedCents;
- $update = [
- 'refund_amount' => $targetCents / 100,
- 'updated_by' => session()->get('user_id') ?? null,
- ];
- if ($isApprovedState) {
- $update['approved_amount_cents'] = $targetCents;
- } else {
- $update['status'] = 'Pending';
- $update['requested_amount_cents'] = $targetCents;
- }
- if ($isApprovedState && $paidCents > $calculatedCents) {
- $message = sprintf(
- 'Completed payouts (%0.2f) exceed recalculated refundable credit (%0.2f).',
- $paidCents / 100,
- $calculatedCents / 100
- );
- $update['reconciliation_status'] = 'requires_review';
- $update['reconciliation_reason'] = $message;
- $update['reconciliation_required_at'] = utc_now();
- log_message('critical', 'Refund reconciliation required for refund #' . $refundId . ': ' . $message);
- } else {
- $update['reconciliation_status'] = null;
- $update['reconciliation_reason'] = null;
- $update['reconciliation_required_at'] = null;
- }
- $this->refundModel->update($refundId, $update);
- } else {
- $this->refundModel->insert([
- 'parent_id' => $pid,
- 'school_year' => $invoice['school_year'],
- 'invoice_id' => $invoice['id'],
- 'refund_amount' => $refundAmount,
- 'requested_amount_cents' => (int)round($refundAmount * 100),
- 'approved_amount_cents' => null,
- 'currency' => 'USD',
- 'refund_paid_amount' => 0.0,
- 'status' => 'Pending',
- 'source_type' => 'tuition_withdrawal',
- 'source_id' => (int)$invoice['id'],
- 'requested_at' => utc_now(),
- 'updated_by' => session()->get('user_id') ?? null,
- ]);
- }
-
- log_message('info', "Refund of $refundAmount created/updated for invoice ID {$invoice['id']} (parent {$pid}).");
- }
-
- $this->db->transComplete();
-
- if (!$this->db->transStatus()) {
- return redirect()->to(base_url('enroll_withdraw/enrollment_withdrawal'))
- ->with('error', 'A database error occurred. Changes were rolled back.');
- }
-
- // === AFTER COMMIT: fire specific events, batched per parent/status ===
- $eventMap = [
- 'admission under review' => 'admissionUnderReview',
- 'review & decision' => 'admissionUnderReview',
- 'payment pending' => 'paymentPending',
- 'enrolled' => 'studentEnrolled',
- 'withdraw under review' => 'withdrawUnderReview',
- 'refund pending' => 'refundPending',
- 'withdrawn' => 'withdrawn',
- 'denied' => 'denied',
- 'waitlist' => 'waitlist',
- ];
-
- foreach ($groupsByParentStatus as $pid => $byStatus) {
- // Common parent data
- $p = $parentInfo[$pid] ?? ['user_id' => $pid, 'email' => null, 'firstname' => '', 'lastname' => ''];
-
- // Fetch invoice once for this parent (for payment pending email data if needed)
- $invoice = $this->invoiceModel->where('parent_id', $pid)
- ->where('school_year', $this->schoolYear)
- ->orderBy('created_at', 'DESC')
- ->first();
-
- foreach ($byStatus as $status => $studentsArr) {
- if (empty($eventMap[$status])) {
- continue; // unknown mapping
- }
-
- // Build second arg: student list
- $studentData = [];
- foreach ($studentsArr as $s) {
- $studentData[] = ['name' => $s['student_name'], 'student_id' => $s['student_id']];
- }
-
- // Parent payload (first arg)
- $parentData = [
- 'user_id' => $p['user_id'],
- 'email' => $p['email'],
- 'firstname' => $p['firstname'],
- 'lastname' => $p['lastname'],
- 'school_year' => $this->schoolYear,
- 'portalLink' => base_url('/login'),
- ];
-
- // Enrich with status-specific fields
- if ($status === 'payment pending') {
- if ($invoice) {
- $parentData['amount'] = (float) ($invoice['balance'] ?? $invoice['amount_due'] ?? $invoice['total_amount'] ?? 0);
- $parentData['due_date'] = $invoice['due_date'] ?? null;
- }
- } elseif ($status === 'refund pending') {
- $parentData['amount'] = $refundAmountByParent[$pid] ?? null;
- }
-
- $eventName = $eventMap[$status];
-
- log_message('info', "Triggering event '{$eventName}' for parent {$pid} with " . count($studentData) . " student(s).");
- Events::trigger($eventName, $parentData, $studentData);
- }
- }
-
- // === Server-side safety net: generate/update invoices for parents whose statuses require it ===
- try {
- $needsInvoiceFor = ['payment pending', 'enrolled', 'withdrawn', 'refund pending'];
- $invCtl = new InvoiceController();
- foreach ($groupsByParentStatus as $pid => $byStatus) {
- $statuses = array_keys($byStatus);
- $requires = array_intersect($statuses, $needsInvoiceFor);
- if (!empty($requires)) {
- // Best-effort; ignore response object
- try {
- $invCtl->generateInvoice((string)$pid);
- } catch (\Throwable $e) {
- log_message('error', 'Invoice fallback generation failed for parent {pid}: {err}', ['pid' => $pid, 'err' => $e->getMessage()]);
- }
- }
- }
- } catch (\Throwable $e) {
- log_message('error', 'Invoice fallback block error: ' . $e->getMessage());
- }
-
- if (!empty($errors)) {
- return redirect()->to(base_url('enroll_withdraw/enrollment_withdrawal'))
- ->with('error', implode(' ', $errors));
- }
-
- return redirect()->to(base_url('enroll_withdraw/enrollment_withdrawal'))
- ->with('success', 'Enrollment statuses updated and notifications sent.');
- } catch (\Throwable $e) {
- $this->db->transRollback();
- log_message('error', 'Enrollment withdrawal error: ' . $e->getMessage());
- return redirect()->to(base_url('enroll_withdraw/enrollment_withdrawal'))
- ->with('error', 'An unexpected error occurred while processing enrollments.');
- }
- }
-
- private function applyDistributionDraftToStudentClass(int $studentId, string $year): void
- {
- try {
- $draftModel = new StudentSectionDistributionDraftModel();
- $draft = $draftModel->where('student_id', $studentId)
- ->where('school_year', $year)
- ->where('status', 'pending')
- ->first();
-
- if (!$draft) {
- return;
- }
-
- $targetSectionId = (int)($draft['class_section_id'] ?? 0);
- if ($targetSectionId <= 0) {
- return;
- }
-
- $studentClass = new StudentClassModel();
- $exists = $studentClass->where('student_id', $studentId)
- ->where('school_year', $year)
- ->first();
-
- $payload = [
- 'student_id' => $studentId,
- 'class_section_id' => $targetSectionId,
- 'school_year' => $year,
- 'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
- 'updated_at' => utc_now(),
- ];
-
- if ($exists) {
- $studentClass->update((int)$exists['id'], $payload);
- } else {
- $payload['created_at'] = utc_now();
- $studentClass->insert($payload);
- }
-
- $this->db->table('enrollments')
- ->where('student_id', $studentId)
- ->where('school_year', $year)
- ->whereIn('enrollment_status', ['payment pending', 'enrolled'])
- ->update([
- 'class_section_id' => $targetSectionId,
- 'updated_at' => utc_now(),
- ]);
-
- $this->db->table('promotion_queue')
- ->where('student_id', $studentId)
- ->where('school_year_to', $year)
- ->update([
- 'to_class_section_id' => $targetSectionId,
- 'status' => 'applied',
- 'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
- 'updated_at' => utc_now(),
- ]);
-
- $draftModel->update((int)$draft['id'], [
- 'status' => 'applied',
- 'applied_at' => utc_now(),
- 'updated_at' => utc_now(),
- ]);
- } catch (\Throwable $e) {
- log_message('error', 'applyDistributionDraftToStudentClass failed: ' . $e->getMessage());
- }
+ return redirect()->to(base_url('enroll_withdraw/enrollment_withdrawal'))
+ ->with(!empty($result['ok']) ? 'success' : 'error', (string) ($result['message'] ?? ''));
}
}
diff --git a/app/Controllers/View/GradingController.php b/app/Controllers/View/GradingController.php
index ee9bb7f..a35bafb 100644
--- a/app/Controllers/View/GradingController.php
+++ b/app/Controllers/View/GradingController.php
@@ -3,4157 +3,293 @@
namespace App\Controllers\View;
use App\Controllers\BaseController;
-use App\Models\StudentModel;
-use App\Models\StudentClassModel;
-use App\Models\ConfigurationModel;
-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\BelowSixtyDecisionModel;
-use App\Models\StudentDecisionModel;
-use App\Services\NavbarService;
-use App\Support\Enrollment\DeliberationDecision;
-
-//use App\Models\ScoreModel;
-
class GradingController extends BaseController
{
- 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;
+ protected string $schoolYear = '';
+ protected string $semester = '';
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 = getSemester();
- $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();
+ $configModel = model(\App\Models\ConfigurationModel::class);
+ $this->schoolYear = (string) $configModel->getConfig('school_year');
+ $this->semester = (string) getSemester();
+ }
- // Log the service initialization
- log_message('debug', 'Initializing SemesterScoreService');
+ private function requestParams(): array
+ {
+ return [
+ 'get' => $this->request->getGet() ?? [],
+ 'post' => $this->request->getPost() ?? [],
+ ];
+ }
- $this->semesterScoreService = service('semesterScoreService');
+ private function respondGrading(array $result)
+ {
+ $kind = $result['kind'] ?? '';
+
+ if ($kind === 'view') {
+ return view((string) $result['view'], $result['data'] ?? []);
+ }
+
+ if ($kind === 'json') {
+ $status = (int) ($result['status'] ?? 200);
+ return $this->response->setStatusCode($status)->setJSON($result['data'] ?? []);
+ }
+
+ if ($kind === 'flash') {
+ $redirect = $result['redirect'] ?? 'back';
+ $type = (string) ($result['type'] ?? 'status');
+ $message = $result['message'] ?? '';
+ $response = ($redirect === 'back') ? redirect()->back() : redirect()->to($redirect);
+ if (!empty($result['withInput'])) {
+ $response = $response->withInput();
+ }
+ return $response->with($type, $message);
+ }
+
+ // Raw payload (e.g. getScoreComment)
+ return $this->response->setJSON($result);
+ }
+
+ private function scoreService()
+ {
+ $service = service('gradingScore');
+ $service->setTerm($this->schoolYear, $this->semester);
+ return $service;
+ }
+
+ private function placementService()
+ {
+ $service = service('placementGrading');
+ $service->setSchoolYear($this->schoolYear);
+ return $service;
+ }
+
+ private function belowSixtyService()
+ {
+ $service = service('belowSixty');
+ $service->setTerm($this->schoolYear, $this->semester);
+ return $service;
+ }
+
+ private function decisionService()
+ {
+ $service = service('studentDecision');
+ $service->setTerm($this->schoolYear, $this->semester);
+ return $service;
}
public function show($type, $classSectionId, $studentId)
{
- $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 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"),
- };
+ return $this->respondGrading(
+ $this->scoreService()->showType($type, $classSectionId, $studentId, $this->requestParams())
+ );
}
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 = getSemester();
-
- $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.');
+ return $this->respondGrading(
+ $this->scoreService()->updateScores($this->requestParams())
+ );
}
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, $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 view('grading/grading_main', [
- 'grades' => $grades,
- 'studentsBySection' => $studentsBySection,
- 'semester' => $semester,
- 'schoolYear' => $schoolYear,
- 'requestedClassId' => $requestedClassId,
- 'semesterOptions' => $semesterOptions,
- 'scoresReleased' => $scoresReleased,
- 'scoresReleasedFall' => $scoresReleasedFall,
- 'scoresReleasedSpring' => $scoresReleasedSpring,
- 'scoreLocks' => $scoreLocks,
- ]);
+ return $this->respondGrading(
+ $this->scoreService()->gradingPage($this->requestParams())
+ );
}
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.');
+ return $this->respondGrading(
+ $this->scoreService()->toggleScoreLock($this->requestParams())
+ );
}
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)
- ->first();
-
- if ($level === null) {
- if ($existing) {
- $this->placementLevelModel->delete($existing['id']);
- }
- return redirect()->back()->with('status', 'Placement level cleared.');
- }
-
- $payload = [
- 'student_id' => $studentId,
- '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)
- ->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)
- ->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,
- '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, s.is_active, sc.class_section_id, cs.class_section_name, c.class_name, e.enrollment_status, e.is_withdrawn')
- ->join('student_class sc', 'sc.student_id = s.id AND sc.school_year = ' . $this->db->escape($schoolYear), 'left')
- ->join('enrollments e', 'e.student_id = s.id AND e.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')
- ->groupStart()
- ->where('s.is_active', 1)
- ->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
- ->orWhere('e.is_withdrawn', 1)
- ->groupEnd()
- ->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.student_id, ps.score, s.school_id, s.firstname, s.lastname, s.is_active, e.enrollment_status, e.is_withdrawn, 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('enrollments e', 'e.student_id = s.id AND e.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)
- ->groupStart()
- ->where('s.is_active', 1)
- ->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
- ->orWhere('e.is_withdrawn', 1)
- ->groupEnd()
- ->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()
-{
- $schoolYear = $this->currentSchoolYearName((string) $this->schoolYear);
-
- // This page is Fall only.
- $semester = 'fall';
- $isYearMode = false;
-
- $schoolYears = $this->getSchoolYearsForScores($schoolYear);
-
- /*
- * Use your existing below-60 fetcher.
- * Do NOT query below_sixty_status. That table does not exist.
- */
- $rows = $this->fetchBelowSixtyRows($schoolYear, $semester);
-
- /*
- * Hard guard:
- * Keep only Fall semester rows with semester_score < 60.
- * This prevents whole-year rows or accidental other semester rows
- * from sneaking into this Fall-only page.
- */
- $rows = array_values(array_filter($rows, static function ($row) {
- $semesterValue = strtolower(trim((string)($row['semester'] ?? 'fall')));
- $scoreRaw = $row['semester_score'] ?? null;
-
- if ($semesterValue !== '' && $semesterValue !== 'fall') {
- return false;
- }
-
- if (!is_numeric($scoreRaw)) {
- return false;
- }
-
- return (float)$scoreRaw < 60;
- }));
-
- foreach ($rows as &$row) {
- $row['status'] = $row['status'] ?? 'Open';
- $row['note'] = $row['note'] ?? '';
- }
-
- unset($row);
-
- $canViewGrading = $this->userHasMenuUrl('grading');
-
- return view('grading/below_sixty', [
- 'rows' => $rows,
- 'semester' => $semester,
- 'schoolYear' => $schoolYear,
- 'schoolYears' => $schoolYears,
- 'isYearMode' => $isYearMode,
- 'canViewGrading' => $canViewGrading,
- ]);
-}
-
- public function editBelowSixtyEmail()
- {
- $studentId = (int)$this->request->getGet('student_id');
- $semester = trim((string)$this->request->getGet('semester'));
- $schoolYear = trim((string)$this->request->getGet('school_year'));
-
- if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
- return redirect()->back()->with('error', 'Missing student or term.');
- }
-
- $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
- if (empty($row)) {
- return redirect()->back()->with('error', 'Student record not found for the selected term.');
- }
-
- $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
- $subject = $this->buildBelowSixtySubject($studentName, $semester, $schoolYear);
- $parentName = $this->fetchBelowSixtyParentName($studentId);
-
- $scores = [
- 'homework_avg' => $row['homework_avg'] ?? null,
- 'project_avg' => $row['project_avg'] ?? null,
- 'participation_score' => $row['participation_score'] ?? null,
- 'test_avg' => $row['test_avg'] ?? null,
- 'ptap_score' => $row['ptap_score'] ?? null,
- 'attendance_score' => $row['attendance_score'] ?? null,
- 'midterm_exam_score' => $row['midterm_exam_score'] ?? null,
- 'semester_score' => $row['semester_score'] ?? null,
- ];
-
- $emailData = [
- 'title' => $subject,
- 'parent_name' => $parentName,
- 'student_name' => $studentName !== '' ? $studentName : 'your student',
- 'class_section_name' => $row['class_section_name'] ?? '',
- 'semester' => $semester,
- 'school_year' => $schoolYear,
- 'scores' => $scores,
- 'comment' => $row['comment'] ?? '',
- 'sent_at' => utc_now(),
- ];
-
- $html = view('emails/below_sixty_performance', $emailData, ['saveData' => true]);
-
- return view('grading/below_sixty_email_editor', [
- 'studentId' => $studentId,
- 'studentName' => $studentName,
- 'semester' => $semester,
- 'schoolYear' => $schoolYear,
- 'subject' => $subject,
- 'html' => $html,
- ]);
- }
-
- 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'));
- $subjectInput = trim((string)$this->request->getPost('subject'));
- $htmlInput = (string)($this->request->getPost('html') ?? '');
-
- if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
- return redirect()->back()->with('error', 'Missing student or term.');
- }
-
- $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
- if (empty($row)) {
- return redirect()->back()->with('error', 'Student record not found for the selected term.');
- }
-
- $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
- $subject = $subjectInput !== ''
- ? $subjectInput
- : $this->buildBelowSixtySubject($studentName, $semester, $schoolYear);
- $scores = [
- 'homework_avg' => $row['homework_avg'] ?? null,
- 'project_avg' => $row['project_avg'] ?? null,
- 'participation_score' => $row['participation_score'] ?? null,
- 'test_avg' => $row['test_avg'] ?? null,
- 'ptap_score' => $row['ptap_score'] ?? null,
- 'attendance_score' => $row['attendance_score'] ?? null,
- 'midterm_exam_score' => $row['midterm_exam_score'] ?? null,
- 'semester_score' => $row['semester_score'] ?? null,
- ];
-
- $payload = [
- 'student_id' => $studentId,
- 'student_name' => $studentName,
- 'class_section_name' => $row['class_section_name'] ?? '',
- 'semester' => $semester,
- 'school_year' => $schoolYear,
- 'scores' => $scores,
- 'comment' => $row['comment'] ?? '',
- 'subject' => $subject,
- ];
-
- if (trim($htmlInput) !== '') {
- $payload['html'] = $htmlInput;
- }
-
- Events::trigger('below60.email', $payload);
-
- return 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));
- $redirectUrl = base_url('grading/below-60');
- $query = http_build_query([
- 'semester' => $semester,
- 'school_year' => $schoolYear,
- ]);
- if ($query !== '') {
- $redirectUrl .= '?' . $query;
- }
-
- $existing = $flagModel
- ->where('student_id', $studentId)
- ->where('flag', 'grade')
- ->where('school_year', $schoolYear)
- ->where('LOWER(TRIM(semester))', $semKey)
- ->first();
-
- $userId = (int)(session()->get('user_id') ?? 0) ?: null;
- $now = utc_now();
- $ok = true;
-
- if ($existing) {
- $data = [
- 'flag_state' => $status,
- 'flag_datetime' => $now,
- 'semester' => $semester,
- 'school_year' => $schoolYear,
- 'updated_at' => $now,
- ];
- if ($status === 'Open') {
- $data['updated_by_open'] = $userId;
- if ($note !== '') {
- $prev = (string)($existing['open_description'] ?? '');
- $data['open_description'] = trim($prev . PHP_EOL . $note);
- }
- } else {
- $data['updated_by_closed'] = $userId;
- if ($note !== '') {
- $prev = (string)($existing['close_description'] ?? '');
- $data['close_description'] = trim($prev . PHP_EOL . $note);
- }
- }
- $ok = (bool) $flagModel->update((int)$existing['id'], $data);
- } else {
- $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
- $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
- $grade = (string)($row['class_section_name'] ?? '');
- $data = [
- 'student_id' => $studentId,
- 'student_name' => $studentName !== '' ? $studentName : 'Student',
- 'grade' => $grade,
- 'flag' => 'grade',
- 'flag_datetime' => $now,
- 'flag_state' => $status,
- 'semester' => $semester,
- 'school_year' => $schoolYear,
- 'updated_at' => $now,
- ];
- if ($status === 'Open') {
- $data['updated_by_open'] = $userId;
- if ($note !== '') $data['open_description'] = $note;
- } else {
- $data['updated_by_closed'] = $userId;
- if ($note !== '') $data['close_description'] = $note;
- }
- $ok = (bool) $flagModel->insert($data);
- }
-
- if (!$ok) {
- log_message('error', 'updateBelowSixtyStatus failed', [
- 'student_id' => $studentId,
- 'semester' => $semester,
- 'school_year' => $schoolYear,
- 'status' => $status,
- 'errors' => $flagModel->errors(),
- ]);
- return redirect()->to($redirectUrl)->with('error', 'Failed to update status.');
- }
-
- return redirect()->to($redirectUrl)->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.');
+ return $this->respondGrading(
+ $this->scoreService()->lockAllScores($this->requestParams())
+ );
}
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');
- }
+ return $this->respondGrading(
+ $this->scoreService()->toggleParentScoresRelease($this->requestParams())
+ );
}
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
+ return $this->respondGrading(
+ $this->scoreService()->refreshSemesterScores($this->requestParams())
);
- 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, 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();
- }
-
- 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
- {
- $isYearMode = strtolower(trim($semester)) === 'year';
-
- if ($isYearMode) {
- $rows = $this->db->table('semester_scores ss')
- ->select('s.id AS student_id')
- ->select('s.school_id')
- ->select('s.firstname')
- ->select('s.lastname')
- ->select('MAX(s.is_active) AS is_active', false)
- ->select('cs.class_section_name')
- ->select("'year' AS semester", false)
- ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.homework_avg END) AS fall_homework_avg", false)
- ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.homework_avg END) AS spring_homework_avg", false)
- ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.project_avg END) AS fall_project_avg", false)
- ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.project_avg END) AS spring_project_avg", false)
- ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.participation_score END) AS fall_participation_score", false)
- ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.participation_score END) AS spring_participation_score", false)
- ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN COALESCE(ss.test_avg, ss.quiz_avg) END) AS fall_test_avg", false)
- ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN COALESCE(ss.test_avg, ss.quiz_avg) END) AS spring_test_avg", false)
- ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.ptap_score END) AS fall_ptap_score", false)
- ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.ptap_score END) AS spring_ptap_score", false)
- ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.attendance_score END) AS fall_attendance_score", false)
- ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.attendance_score END) AS spring_attendance_score", false)
- ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.midterm_exam_score END) AS fall_midterm_exam_score", false)
- ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.midterm_exam_score END) AS spring_midterm_exam_score", false)
- ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.final_exam_score END) AS fall_final_exam_score", false)
- ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.final_exam_score END) AS spring_final_exam_score", false)
- ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.semester_score END) AS fall_score", false)
- ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.semester_score END) AS spring_score", false)
- ->select("(
- MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.semester_score END)
- + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.semester_score END)
- ) / 2 AS semester_score", false)
- ->select("(
- MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.homework_avg END)
- + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.homework_avg END)
- ) / 2 AS homework_avg", false)
- ->select("(
- MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.project_avg END)
- + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.project_avg END)
- ) / 2 AS project_avg", false)
- ->select("(
- MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.participation_score END)
- + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.participation_score END)
- ) / 2 AS participation_score", false)
- ->select("(
- MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN COALESCE(ss.test_avg, ss.quiz_avg) END)
- + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN COALESCE(ss.test_avg, ss.quiz_avg) END)
- ) / 2 AS test_avg", false)
- ->select("(
- MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.ptap_score END)
- + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.ptap_score END)
- ) / 2 AS ptap_score", false)
- ->select("(
- MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.attendance_score END)
- + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.attendance_score END)
- ) / 2 AS attendance_score", false)
- ->select("(
- MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.midterm_exam_score END)
- + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.midterm_exam_score END)
- ) / 2 AS midterm_exam_score", false)
- ->select("(
- MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.final_exam_score END)
- + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.final_exam_score END)
- ) / 2 AS final_exam_score", false)
- ->select('MAX(e.enrollment_status) AS enrollment_status, MAX(e.is_withdrawn) AS is_withdrawn', false)
- ->join('students s', 's.id = ss.student_id', 'inner')
- ->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $this->db->escape($schoolYear), 'left')
- ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
- ->groupStart()
- ->where('s.is_active', 1)
- ->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
- ->orWhere('e.is_withdrawn', 1)
- ->groupEnd()
- ->where('ss.school_year', $schoolYear)
- ->where('ss.semester_score IS NOT NULL', null, false)
- ->where("LOWER(TRIM(ss.semester)) IN ('fall', 'spring')", null, false)
- ->groupBy('s.id, s.school_id, s.firstname, s.lastname, ss.class_section_id, cs.class_section_name')
- ->having('fall_score IS NOT NULL', null, false)
- ->having('spring_score IS NOT NULL', null, false)
- ->having('semester_score <', 60)
- ->orderBy('s.lastname', 'ASC')
- ->orderBy('s.firstname', 'ASC')
- ->get()
- ->getResultArray();
-
- foreach ($rows as &$row) {
- $row['comment'] = '';
- $row['status'] = 'Open';
- $row['note'] = '';
- }
- unset($row);
-
- return $rows;
- }
-
- $semesterKey = 'fall';
- $builder = $this->db->table('semester_scores ss')
- ->select([
- 's.id AS student_id',
- 's.school_id',
- 's.firstname',
- 's.lastname',
- 's.is_active',
- 'cs.class_section_name',
- 'ss.semester',
- 'ss.homework_avg',
- 'ss.project_avg',
- 'ss.participation_score',
- 'COALESCE(ss.test_avg, ss.quiz_avg) AS test_avg',
- 'ss.ptap_score',
- 'ss.attendance_score',
- 'ss.midterm_exam_score',
- 'ss.final_exam_score',
- 'ss.semester_score',
- 'e.enrollment_status',
- 'e.is_withdrawn',
- ])
- ->join('students s', 's.id = ss.student_id', 'inner')
- ->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $this->db->escape($schoolYear), 'left')
- ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
- ->groupStart()
- ->where('s.is_active', 1)
- ->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
- ->orWhere('e.is_withdrawn', 1)
- ->groupEnd()
- ->where('ss.school_year', $schoolYear)
- ->where('ss.semester_score IS NOT NULL', null, false)
- ->where('ss.semester_score <', 60)
- ->where("LOWER(TRIM(ss.semester))", $semesterKey);
-
- $rows = $builder
- ->orderBy('s.lastname', 'ASC')
- ->orderBy('s.firstname', 'ASC')
- ->orderBy('ss.semester', 'ASC')
- ->get()
- ->getResultArray();
-
- if (empty($rows)) {
- return [];
- }
-
- $studentIds = array_values(array_unique(array_map(
- static fn($r) => (int)($r['student_id'] ?? 0),
- $rows
- )));
- $studentIds = array_values(array_filter($studentIds, static fn($id) => $id > 0));
-
- $commentMap = [];
- if (!empty($studentIds)) {
- $commentRows = $this->db->table('score_comments')
- ->select('student_id, semester, comment, created_at')
- ->where('score_type', 'general')
- ->where('school_year', $schoolYear)
- ->whereIn('student_id', $studentIds)
- ->where("LOWER(TRIM(semester))", $semesterKey)
- ->orderBy('created_at', 'DESC')
- ->get()
- ->getResultArray();
- foreach ($commentRows as $row) {
- $sid = (int)($row['student_id'] ?? 0);
- $sem = strtolower(trim((string)($row['semester'] ?? '')));
- $key = $sid . '_' . $sem;
- if ($sid > 0 && !isset($commentMap[$key])) {
- $commentMap[$key] = (string)($row['comment'] ?? '');
- }
- }
- }
-
- $statusMap = [];
- $noteMap = [];
- if (!empty($studentIds)) {
- $flagRows = $this->db->table('current_flag')
- ->select('student_id, semester, flag_state, open_description, close_description')
- ->where('flag', 'grade')
- ->where('school_year', $schoolYear)
- ->whereIn('student_id', $studentIds)
- ->where("LOWER(TRIM(semester))", $semesterKey)
- ->get()
- ->getResultArray();
- foreach ($flagRows as $row) {
- $sid = (int)($row['student_id'] ?? 0);
- if ($sid <= 0) continue;
- $sem = strtolower(trim((string)($row['semester'] ?? '')));
- $key = $sid . '_' . $sem;
- $statusMap[$key] = (string)($row['flag_state'] ?? '');
- $openNote = trim((string)($row['open_description'] ?? ''));
- $closeNote = trim((string)($row['close_description'] ?? ''));
- $noteMap[$key] = [
- 'open' => $openNote,
- 'closed' => $closeNote,
- ];
- }
- }
-
- foreach ($rows as &$row) {
- $sid = (int)($row['student_id'] ?? 0);
- $sem = strtolower(trim((string)($row['semester'] ?? '')));
- $key = $sid . '_' . $sem;
- $row['comment'] = $commentMap[$key] ?? '';
- $flagState = strtolower(trim((string)($statusMap[$key] ?? '')));
- $row['status'] = ($flagState === 'closed' || $flagState === 'canceled') ? 'Closed' : 'Open';
- $noteBag = $noteMap[$key] ?? ['open' => '', 'closed' => ''];
- $rawNote = $row['status'] === 'Closed' ? (string)$noteBag['closed'] : (string)$noteBag['open'];
- if ($rawNote !== '') {
- $lines = preg_split('/\R/', $rawNote);
- $lines = array_values(array_filter(array_map('trim', $lines), static fn($val) => $val !== ''));
- $row['note'] = $lines ? end($lines) : '';
- } else {
- $row['note'] = '';
- }
- }
- unset($row);
-
- return $rows;
- }
-
-
- private function fetchBelowSixtyEmailRow(int $studentId, string $schoolYear, string $semester): array
- {
- $semesterKey = strtolower(trim($semester));
- $row = $this->db->table('semester_scores ss')
- ->select([
- 's.id AS student_id',
- 's.firstname',
- 's.lastname',
- 'cs.class_section_name',
- 'ss.homework_avg',
- 'ss.project_avg',
- 'ss.participation_score',
- 'COALESCE(ss.test_avg, ss.quiz_avg) AS test_avg',
- 'ss.ptap_score',
- 'ss.attendance_score',
- 'ss.midterm_exam_score',
- 'ss.semester_score',
- ])
- ->join('students s', 's.id = ss.student_id', 'inner')
- ->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $this->db->escape($schoolYear), 'left')
- ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
- ->where('ss.school_year', $schoolYear)
- ->where('ss.student_id', $studentId)
- ->groupStart()
- ->where('s.is_active', 1)
- ->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
- ->orWhere('e.is_withdrawn', 1)
- ->groupEnd()
- ->where("LOWER(TRIM(ss.semester))", $semesterKey)
- ->get()
- ->getRowArray();
-
- if (!$row) return [];
-
- $commentRow = $this->db->table('score_comments')
- ->select('comment')
- ->where('score_type', 'general')
- ->where('school_year', $schoolYear)
- ->where("LOWER(TRIM(semester))", $semesterKey)
- ->where('student_id', $studentId)
- ->orderBy('created_at', 'DESC')
- ->get()
- ->getRowArray();
- $row['comment'] = (string)($commentRow['comment'] ?? '');
-
- return $row;
- }
-
- private function 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;
- }
-
- private function fetchAllSemestersForStudent(int $studentId, string $schoolYear): array
- {
- $rows = $this->db->table('semester_scores ss')
- ->select([
- 'ss.semester',
- 'cs.class_section_name',
- 'ss.homework_avg',
- 'ss.project_avg',
- 'ss.participation_score',
- 'COALESCE(ss.test_avg, ss.quiz_avg) AS test_avg',
- 'ss.ptap_score',
- 'ss.attendance_score',
- 'ss.midterm_exam_score',
- 'ss.final_exam_score',
- 'ss.semester_score',
- ])
- ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
- ->where('ss.student_id', $studentId)
- ->where('ss.school_year', $schoolYear)
- ->orderBy('ss.semester', 'ASC')
- ->get()
- ->getResultArray();
-
- $semesters = [];
- foreach ($rows as $sr) {
- $sem = ucfirst(strtolower(trim((string)($sr['semester'] ?? ''))));
- $semesters[$sem] = [
- 'semester' => $sem,
- 'class_section_name' => $sr['class_section_name'] ?? '',
- 'homework_avg' => $sr['homework_avg'] ?? null,
- 'project_avg' => $sr['project_avg'] ?? null,
- 'participation_score' => $sr['participation_score'] ?? null,
- 'test_avg' => $sr['test_avg'] ?? null,
- 'ptap_score' => $sr['ptap_score'] ?? null,
- 'attendance_score' => $sr['attendance_score'] ?? null,
- 'midterm_exam_score' => $sr['midterm_exam_score'] ?? null,
- 'final_exam_score' => $sr['final_exam_score'] ?? null,
- 'semester_score' => $sr['semester_score'] ?? null,
- 'comments' => [],
- ];
- }
-
- if (!empty($semesters)) {
- $commentRows = $this->db->table('score_comments')
- ->select('semester, score_type, comment, created_at')
- ->where('student_id', $studentId)
- ->where('school_year', $schoolYear)
- ->where('comment IS NOT NULL', null, false)
- ->where('comment !=', '')
- ->orderBy('semester', 'ASC')
- ->orderBy('score_type', 'ASC')
- ->orderBy('created_at', 'DESC')
- ->get()
- ->getResultArray();
-
- foreach ($commentRows as $c) {
- $sem = ucfirst(strtolower(trim((string)($c['semester'] ?? ''))));
- $type = strtolower(trim((string)($c['score_type'] ?? 'general')));
- if (isset($semesters[$sem]) && !isset($semesters[$sem]['comments'][$type])) {
- $semesters[$sem]['comments'][$type] = (string)($c['comment'] ?? '');
- }
- }
- }
-
- return array_values($semesters);
- }
-
- private function fetchBelowSixtyParentName(int $studentId): string
- {
- $parentName = 'Parent/Guardian';
- try {
- $rows = $this->db->query(
- "SELECT u.firstname, u.lastname
- FROM family_students fs
- JOIN family_guardians fg ON fg.family_id = fs.family_id
- JOIN users u ON u.id = fg.user_id
- WHERE fs.student_id = ?
- ORDER BY fg.is_primary DESC, u.lastname, u.firstname
- LIMIT 1",
- [$studentId]
- )->getResultArray();
- if (!empty($rows[0])) {
- $candidate = trim((string)($rows[0]['firstname'] ?? '') . ' ' . (string)($rows[0]['lastname'] ?? ''));
- if ($candidate !== '') {
- $parentName = $candidate;
- }
- }
- } catch (\Throwable $e) {
- }
- return $parentName;
- }
-
- private function buildBelowSixtySubject(string $studentName, string $semester, string $schoolYear): string
- {
- $subject = 'Student Performance Alert';
- if ($studentName !== '') {
- $subject .= ' — ' . $studentName;
- }
- if ($semester !== '' || $schoolYear !== '') {
- $subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')';
- }
- return $subject;
- }
-
- private function 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 belowSixtyDecisions()
-{
- $schoolYearContext = $this->resolveSchoolYearContext();
- $configuredYear = $schoolYearContext->yearName();
-
- $schoolYear = trim((string)($this->request->getGet('school_year') ?? ''));
-
- if ($schoolYear === '') {
- $schoolYear = $configuredYear;
- }
-
- // This page is whole-year only.
- $semester = 'year';
-
- $schoolYears = $this->getSchoolYearsForScores($schoolYear);
-
- $db = $this->db;
-
- /*
- * Whole-year score source:
- * Fall semester_score + Spring semester_score / 2
- *
- * Only students with BOTH Fall and Spring scores are included.
- * Only students with year_score < 60 are listed.
- */
- $scoreRows = $db->table('semester_scores ss')
- ->select([
- 's.id AS student_id',
- 's.firstname',
- 's.lastname',
- 's.age',
- 's.school_id',
- 's.is_active',
- 'cs.class_section_name',
- 'e.enrollment_status',
- 'e.is_withdrawn',
- 'LOWER(TRIM(ss.semester)) AS sem_key',
- 'ss.semester_score',
- ])
- ->join('students s', 's.id = ss.student_id', 'inner')
- ->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $this->db->escape($schoolYear), 'left')
- ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
- ->groupStart()
- ->where('s.is_active', 1)
- ->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
- ->orWhere('e.is_withdrawn', 1)
- ->groupEnd()
- ->where('ss.school_year', $schoolYear)
- ->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring'])
- ->where('ss.semester_score IS NOT NULL', null, false)
- ->orderBy('cs.class_section_name', 'ASC')
- ->orderBy('s.lastname', 'ASC')
- ->orderBy('s.firstname', 'ASC')
- ->get()
- ->getResultArray();
-
- $studentMap = [];
-
- foreach ($scoreRows as $row) {
- $sid = (int)($row['student_id'] ?? 0);
-
- if ($sid <= 0) {
- continue;
- }
-
- if (!isset($studentMap[$sid])) {
- $studentMap[$sid] = [
- 'student_id' => $sid,
- 'school_id' => $row['school_id'] ?? '',
- 'firstname' => $row['firstname'] ?? '',
- 'lastname' => $row['lastname'] ?? '',
- 'age' => $row['age'] ?? null,
- 'class_section_name' => $row['class_section_name'] ?? '',
- 'fall_score' => null,
- 'spring_score' => null,
- 'year_score' => null,
- ];
- }
-
- $semKey = strtolower(trim((string)($row['sem_key'] ?? '')));
- $score = is_numeric($row['semester_score']) ? (float)$row['semester_score'] : null;
-
- if ($score === null) {
- continue;
- }
-
- if ($semKey === 'fall') {
- $studentMap[$sid]['fall_score'] = $score;
- } elseif ($semKey === 'spring') {
- $studentMap[$sid]['spring_score'] = $score;
- }
- }
-
- $rows = [];
-
- foreach ($studentMap as $sid => $student) {
- $fall = $student['fall_score'];
- $spring = $student['spring_score'];
-
- // Whole-year result requires both semesters.
- if ($fall === null || $spring === null) {
- continue;
- }
-
- $yearScore = round(($fall + $spring) / 2, 2);
-
- if ($yearScore >= 60) {
- continue;
- }
-
- $student['year_score'] = $yearScore;
- $rows[$sid] = $student;
- }
-
- $studentIds = array_keys($rows);
-
- /*
- * Load saved below-60 manual decisions.
- * These are year-level decisions now.
- */
- $decisionMap = [];
-
- if (!empty($studentIds)) {
- $belowDecModel = new BelowSixtyDecisionModel();
-
- $decisionRows = $belowDecModel
- ->whereIn('student_id', $studentIds)
- ->where('semester', 'year')
- ->where('school_year', $schoolYear)
- ->findAll();
-
- foreach ($decisionRows as $d) {
- $sid = (int)($d['student_id'] ?? 0);
-
- if ($sid > 0) {
- $decisionMap[$sid] = $d;
- }
- }
- }
-
- foreach ($rows as $sid => &$row) {
- $row['decision'] = $decisionMap[$sid]['decision'] ?? '';
- $row['decision_notes'] = $decisionMap[$sid]['notes'] ?? '';
- }
-
- unset($row);
-
- /*
- * Load final generated whole-year decisions from student_decisions.
- * This table is now year-based, so do NOT filter by semester.
- */
- $finalDecisionMap = [];
-
- if (!empty($studentIds)) {
- $finalRows = $db->table('student_decisions')
- ->whereIn('student_id', $studentIds)
- ->where('school_year', $schoolYear)
- ->get()
- ->getResultArray();
-
- foreach ($finalRows as $fr) {
- $sid = (int)($fr['student_id'] ?? 0);
-
- if ($sid > 0) {
- $finalDecisionMap[$sid] = $fr;
- }
- }
- }
-
- foreach ($rows as $sid => &$row) {
- $row['consolidated_decision'] = $finalDecisionMap[$sid]['decision'] ?? null;
-
- if (
- isset($finalDecisionMap[$sid]['year_score'])
- && $finalDecisionMap[$sid]['year_score'] !== ''
- && is_numeric($finalDecisionMap[$sid]['year_score'])
- ) {
- $row['year_score'] = round((float)$finalDecisionMap[$sid]['year_score'], 2);
- }
- }
-
- unset($row);
-
- /*
- * Load certificate numbers.
- */
- $certMap = [];
-
- if (!empty($studentIds)) {
- $certRows = $db->table('certificate_records')
- ->select('student_id, certificate_number, issued_at')
- ->where('school_year', $schoolYear)
- ->whereIn('student_id', $studentIds)
- ->orderBy('issued_at', 'DESC')
- ->get()
- ->getResultArray();
-
- foreach ($certRows as $cr) {
- $sid = (int)($cr['student_id'] ?? 0);
-
- if ($sid > 0 && !isset($certMap[$sid])) {
- $certMap[$sid] = (string)($cr['certificate_number'] ?? '');
- }
- }
- }
-
- foreach ($rows as $sid => &$row) {
- $row['certificate_number'] = $certMap[$sid] ?? '';
- }
-
- unset($row);
-
- // Re-index for the view.
- $rows = array_values($rows);
-
- $canViewGrading = $this->userHasMenuUrl('grading');
-
- return view('grading/below_sixty_decisions', [
- 'rows' => $rows,
- 'semester' => $semester,
- 'schoolYear' => $schoolYear,
- 'schoolYears' => $schoolYears,
- 'canViewGrading' => $canViewGrading,
- 'isEditable' => ! $schoolYearContext->isReadonly(),
- ]);
-}
-
-public function saveBelowSixtyDecision()
-{
- $schoolYearContext = $this->resolveSchoolYearContext();
- $this->assertSchoolYearWritable($schoolYearContext);
-
- $studentId = (int)($this->request->getPost('student_id') ?? 0);
- $semester = strtolower(trim((string)($this->request->getPost('semester') ?? 'year')));
- $schoolYear = trim((string)($this->request->getPost('school_year') ?? ''));
- $decision = trim((string)($this->request->getPost('decision') ?? ''));
- $notes = trim((string)($this->request->getPost('notes') ?? ''));
-
- if ($studentId <= 0 || $schoolYear === '') {
- return redirect()->back()->with('error', 'Missing student or school year.');
- }
-
- if ($schoolYear !== $schoolYearContext->yearName()) {
- return redirect()->back()->with('error', 'Selected school year does not match the submitted decision.');
- }
-
- // This decision page should feed certificate decisions as whole-year decisions.
- // Force year mode here so certificate logic receives final year decision.
- $semester = 'year';
-
- $db = \Config\Database::connect();
-
- /*
- * 1. Save/update the manual decision in below_sixty_decisions.
- * This keeps your below-60 page history working.
- */
- $belowModel = new \App\Models\BelowSixtyDecisionModel();
-
- $existingBelow = $belowModel
- ->where('student_id', $studentId)
- ->where('semester', $semester)
- ->where('school_year', $schoolYear)
- ->first();
-
- $belowPayload = [
- 'student_id' => $studentId,
- 'semester' => $semester,
- 'school_year' => $schoolYear,
- 'decision' => $decision,
- 'notes' => $notes,
- ];
-
- if ($existingBelow) {
- $belowModel->update((int)$existingBelow['id'], $belowPayload);
- } else {
- $belowModel->insert($belowPayload);
- }
-
- /*
- * 2. Calculate the student's whole-year score.
- * Certificate page uses student_decisions.year_score.
- */
- $scoreRows = $db->table('semester_scores ss')
- ->select([
- 'LOWER(TRIM(ss.semester)) AS sem_key',
- 'ss.semester_score',
- 'cs.class_section_name',
- ])
- ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
- ->where('ss.student_id', $studentId)
- ->where('ss.school_year', $schoolYear)
- ->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring'])
- ->where('ss.semester_score IS NOT NULL', null, false)
- ->orderBy('ss.updated_at', 'DESC')
- ->orderBy('ss.id', 'DESC')
- ->get()
- ->getResultArray();
-
- $fallScore = null;
- $springScore = null;
- $classSectionName = null;
-
- foreach ($scoreRows as $sr) {
- $semKey = strtolower(trim((string)($sr['sem_key'] ?? '')));
- $score = is_numeric($sr['semester_score'] ?? null) ? (float)$sr['semester_score'] : null;
-
- if ($classSectionName === null && !empty($sr['class_section_name'])) {
- $classSectionName = (string)$sr['class_section_name'];
- }
-
- if ($semKey === 'fall' && $fallScore === null) {
- $fallScore = $score;
- }
-
- if ($semKey === 'spring' && $springScore === null) {
- $springScore = $score;
- }
- }
-
- if ($fallScore !== null && $springScore !== null) {
- $yearScore = round(($fallScore + $springScore) / 2, 2);
- } elseif ($fallScore !== null) {
- $yearScore = round($fallScore, 2);
- } elseif ($springScore !== null) {
- $yearScore = round($springScore, 2);
- } else {
- $yearScore = null;
- }
-
- /*
- * 3. Sync into student_decisions.
- * This is the part your certificate page needs.
- *
- * student_decisions is now year-based:
- * - no semester
- * - no semester_score
- * - uses year_score
- */
- $source = $decision === '' ? 'pending' : 'manual';
-
- $studentDecisionPayload = [
- 'student_id' => $studentId,
- 'school_year' => $schoolYear,
- 'class_section_name' => $classSectionName,
- 'year_score' => $yearScore,
- 'decision' => $decision !== '' ? $decision : null,
- 'source' => $source,
- 'notes' => $notes !== '' ? $notes : null,
- 'generated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
- ];
-
- $existingStudentDecision = $db->table('student_decisions')
- ->where('student_id', $studentId)
- ->where('school_year', $schoolYear)
- ->get()
- ->getRowArray();
-
- if ($existingStudentDecision) {
- $db->table('student_decisions')
- ->where('id', (int)$existingStudentDecision['id'])
- ->update($studentDecisionPayload);
- } else {
- $db->table('student_decisions')
- ->insert($studentDecisionPayload);
- }
-
- $query = http_build_query([
- 'semester' => 'year',
- 'school_year' => $schoolYear,
- ]);
-
- return redirect()
- ->to(base_url('grading/below-60/decisions') . '?' . $query)
- ->with('status', 'Decision saved and certificate decision updated.');
-}
-
- public function studentDecisionDetails()
- {
- $studentId = (int)$this->request->getGet('student_id');
- $schoolYear = trim((string)$this->request->getGet('school_year'));
-
- if ($studentId <= 0 || $schoolYear === '') {
- return $this->response->setJSON(['error' => 'Missing student or school year.'])->setStatusCode(400);
- }
-
- return $this->response->setJSON([
- 'semesters' => $this->fetchAllSemestersForStudent($studentId, $schoolYear),
- ]);
- }
-
-public function previewBelowSixtyDecisionEmail()
-{
- $studentId = (int)($this->request->getGet('student_id') ?? 0);
- $schoolYear = trim((string)($this->request->getGet('school_year') ?? ''));
-
- if ($studentId <= 0 || $schoolYear === '') {
- return $this->response->setJSON([
- 'error' => 'Missing student or school year.',
- ]);
- }
-
- /*
- * Whole-year decision email.
- * Do NOT query semester_scores.semester = "year".
- * The Details button uses fetchAllSemestersForStudent(), so this email does too.
- */
- $context = $this->getBelowSixtyDecisionEmailContext($studentId, $schoolYear);
-
- if (empty($context['student'])) {
- return $this->response->setJSON([
- 'error' => 'Student not found.',
- ]);
- }
-
- if (empty($context['decision_row']) || trim((string)($context['decision_row']['decision'] ?? '')) === '') {
- return $this->response->setJSON([
- 'error' => 'No saved decision found for this student. Save a decision first.',
- ]);
- }
-
- $studentName = (string)($context['student_name'] ?? 'Student');
- $classSectionName = (string)($context['class_section_name'] ?? '');
- $decisionRow = $context['decision_row'];
-
- $decision = trim((string)($decisionRow['decision'] ?? ''));
- $notes = trim((string)($decisionRow['notes'] ?? ''));
-
- $fallScore = $context['fall_score'] ?? null;
- $springScore = $context['spring_score'] ?? null;
- $yearScore = $context['year_score'] ?? null;
-
- /*
- * Same data used by the Details modal.
- */
- $allSemesters = $context['all_semesters'] ?? [];
-
- if (empty($allSemesters)) {
- $allSemesters = $this->fetchAllSemestersForStudent($studentId, $schoolYear);
- }
-
- $fallText = $fallScore !== null
- ? number_format((float)$fallScore, 2)
- : 'N/A';
-
- $springText = $springScore !== null
- ? number_format((float)$springScore, 2)
- : 'N/A';
-
- $yearText = $yearScore !== null
- ? number_format((float)$yearScore, 2)
- : 'N/A';
-
- $subject = 'Whole Year Academic Decision — ' . $studentName . ' (' . $schoolYear . ')';
-
- $html = '
- Dear Parent/Guardian,
-
-
- This message is regarding ' . esc($studentName) . '
- for the ' . esc($schoolYear) . ' school year.
-
-
-
- Class: ' . esc($classSectionName !== '' ? $classSectionName : 'N/A') . '
- Fall Score: ' . esc($fallText) . '
- Spring Score: ' . esc($springText) . '
- Whole-Year Score: ' . esc($yearText) . '
- Decision: ' . esc($decision) . '
-
- ';
-
- if ($notes !== '') {
- $html .= '
-
- Decision Notes:
- ' . nl2br(esc($notes)) . '
-
- ';
- }
-
- /*
- * Add the exact Details-button score/comment content into the email.
- */
- $html .= $this->buildDecisionEmailDetailsHtml($allSemesters);
-
- $html .= '
-
- Please contact the school administration if you have any questions.
-
-
-
- Regards,
- Al Rahma Sunday School
-
- ';
-
- return $this->response->setJSON([
- 'ok' => true,
- 'subject' => $subject,
- 'html' => $html,
- 'decision' => $decision,
- 'score' => $yearScore,
- ]);
-}
-
-private function buildDecisionEmailDetailsHtml(array $semesters): string
-{
- if (empty($semesters)) {
- return '
- Detailed Scores and Comments
- No detailed score data found.
- ';
- }
-
- $scoreLabels = [
- 'homework_avg' => 'Homework Avg',
- 'project_avg' => 'Project Avg',
- 'participation_score' => 'Participation',
- 'test_avg' => 'Test Avg',
- 'ptap_score' => 'PTAP Score',
- 'attendance_score' => 'Attendance',
- 'midterm_exam_score' => 'Midterm Score',
- 'final_exam_score' => 'Final Exam',
- 'semester_score' => 'Semester Score',
- ];
-
- $commentTypeLabels = [
- 'general' => 'General',
- 'attendance' => 'Attendance',
- 'attendance_comment' => 'Attendance',
- 'midterm' => 'Midterm',
- 'final' => 'Final Exam',
- 'ptap' => 'PTAP',
- ];
-
- $html = '
-
- Detailed Scores and Comments
- ';
-
- foreach ($semesters as $sem) {
- $semesterName = trim((string)($sem['semester'] ?? ''));
-
- if ($semesterName === '') {
- $semesterName = 'Semester';
- }
-
- $classSectionName = trim((string)($sem['class_section_name'] ?? ''));
-
- $html .= '
-
- ' . esc($semesterName) . ' Semester ';
-
- if ($classSectionName !== '') {
- $html .= ' — ' . esc($classSectionName);
- }
-
- $html .= '
-
-
-
-
-
- Item
- Score
-
-
-
- ';
-
- $hasScoreRow = false;
-
- foreach ($scoreLabels as $key => $label) {
- if (!array_key_exists($key, $sem)) {
- continue;
- }
-
- $value = $sem[$key];
-
- if ($value === null || $value === '') {
- continue;
- }
-
- $scoreText = is_numeric($value)
- ? number_format((float)$value, 2)
- : (string)$value;
-
- $fontWeight = $key === 'semester_score' ? 'font-weight:bold;' : '';
-
- $html .= '
-
- ' . esc($label) . '
- ' . esc($scoreText) . '
-
- ';
-
- $hasScoreRow = true;
- }
-
- if (!$hasScoreRow) {
- $html .= '
-
- No scores recorded.
-
- ';
- }
-
- $html .= '
-
-
- ';
-
- $comments = $sem['comments'] ?? [];
-
- if (is_array($comments) && !empty($comments)) {
- $deduped = [];
- $seen = [];
-
- foreach ($comments as $type => $text) {
- $text = trim((string)$text);
-
- if ($text === '') {
- continue;
- }
-
- $label = $commentTypeLabels[$type] ?? (string)$type;
- $key = $label . '|' . $text;
-
- if (isset($seen[$key])) {
- continue;
- }
-
- $seen[$key] = true;
- $deduped[] = [
- 'label' => $label,
- 'text' => $text,
- ];
- }
-
- if (!empty($deduped)) {
- $html .= '
- Comments
- ';
-
- foreach ($deduped as $comment) {
- $html .= '
-
- ' . esc($comment['label']) . ':
- ' . nl2br(esc($comment['text'])) . '
-
- ';
- }
- }
- }
- }
-
- return $html;
-}
-
-public function sendBelowSixtyDecisionEmail()
-{
- $studentId = (int)($this->request->getPost('student_id') ?? 0);
- $schoolYear = trim((string)($this->request->getPost('school_year') ?? ''));
- $subjectInput = trim((string)($this->request->getPost('subject') ?? ''));
- $htmlInput = (string)($this->request->getPost('html') ?? '');
-
- if ($studentId <= 0 || $schoolYear === '') {
- return redirect()->back()->with('error', 'Missing student or school year.');
- }
-
- /*
- * Whole-year decision email.
- * Do NOT use sendBelowSixtyEmail(), because that one is semester-score based.
- */
- $semester = 'year';
-
- $context = $this->getBelowSixtyDecisionEmailContext($studentId, $schoolYear);
-
- if (empty($context['student'])) {
- return redirect()->back()->with('error', 'Student not found.');
- }
-
- if (empty($context['decision_row']) || trim((string)($context['decision_row']['decision'] ?? '')) === '') {
- return redirect()->back()->with('error', 'No saved decision found for this student.');
- }
-
- $studentName = $context['student_name'];
- $classSectionName = $context['class_section_name'];
- $decisionRow = $context['decision_row'];
-
- $subject = $subjectInput !== ''
- ? $subjectInput
- : 'Whole Year Academic Decision — ' . $studentName . ' (' . $schoolYear . ')';
-
- $payload = [
- 'student_id' => $studentId,
- 'student_name' => $studentName,
- 'class_section_name' => $classSectionName,
- 'semester' => 'year',
- 'school_year' => $schoolYear,
- 'decision' => (string)($decisionRow['decision'] ?? ''),
- 'notes' => (string)($decisionRow['notes'] ?? ''),
- 'subject' => $subject,
- 'scores' => [
- 'fall_score' => $context['fall_score'],
- 'spring_score' => $context['spring_score'],
- 'year_score' => $context['year_score'],
- ],
- 'all_semesters' => $context['all_semesters'],
- ];
-
- if (trim($htmlInput) !== '') {
- $payload['html'] = $htmlInput;
- }
-
- Events::trigger('below60.decision_email', $payload);
-
- $query = http_build_query([
- 'semester' => 'year',
- 'school_year' => $schoolYear,
- ]);
-
- return redirect()
- ->to(base_url('grading/below-60/decisions') . '?' . $query)
- ->with('status', 'Decision email sent to parent(s).');
-}
-
-
-private function getBelowSixtyDecisionEmailContext(int $studentId, string $schoolYear): array
-{
- $student = $this->db->table('students s')
- ->select([
- 's.id',
- 's.firstname',
- 's.lastname',
- 's.is_active',
- ])
- ->where('s.id', $studentId)
- ->get()
- ->getRowArray();
-
- /*
- * Do not require is_active = 1 here.
- * You were getting "Student not found" even though the student ID exists.
- * If the record exists, let the email preview work.
- */
- if (!$student) {
- return [
- 'student' => null,
- 'decision_row' => null,
- 'student_name' => '',
- 'class_section_name' => '',
- 'fall_score' => null,
- 'spring_score' => null,
- 'year_score' => null,
- 'all_semesters' => [],
- ];
- }
-
- $studentName = trim((string)($student['firstname'] ?? '') . ' ' . (string)($student['lastname'] ?? ''));
-
- if ($studentName === '') {
- $studentName = 'Student';
- }
-
- $decisionRow = $this->db->table('below_sixty_decisions')
- ->where('student_id', $studentId)
- ->where('school_year', $schoolYear)
- ->where('semester', 'year')
- ->get()
- ->getRowArray();
-
- $scoreRows = $this->db->table('semester_scores ss')
- ->select([
- 'LOWER(TRIM(ss.semester)) AS sem_key',
- 'ss.semester',
- 'ss.semester_score',
- 'ss.class_section_id',
- 'cs.class_section_name',
- ])
- ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
- ->where('ss.student_id', $studentId)
- ->where('ss.school_year', $schoolYear)
- ->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring'])
- ->where('ss.semester_score IS NOT NULL', null, false)
- ->orderBy('ss.updated_at', 'DESC')
- ->orderBy('ss.id', 'DESC')
- ->get()
- ->getResultArray();
-
- $fallScore = null;
- $springScore = null;
- $classSectionName = '';
-
- foreach ($scoreRows as $sr) {
- $semKey = strtolower(trim((string)($sr['sem_key'] ?? '')));
- $score = is_numeric($sr['semester_score'] ?? null) ? (float)$sr['semester_score'] : null;
-
- if ($classSectionName === '' && !empty($sr['class_section_name'])) {
- $classSectionName = (string)$sr['class_section_name'];
- }
-
- if ($score === null) {
- continue;
- }
-
- if ($semKey === 'fall' && $fallScore === null) {
- $fallScore = $score;
- }
-
- if ($semKey === 'spring' && $springScore === null) {
- $springScore = $score;
- }
- }
-
- if ($classSectionName === '') {
- $enrollment = $this->db->table('student_class sc')
- ->select('cs.class_section_name')
- ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
- ->where('sc.student_id', $studentId)
- ->where('sc.school_year', $schoolYear)
- ->orderBy('sc.id', 'DESC')
- ->get()
- ->getRowArray();
-
- $classSectionName = (string)($enrollment['class_section_name'] ?? '');
- }
-
- if ($fallScore !== null && $springScore !== null) {
- $yearScore = round(($fallScore + $springScore) / 2, 2);
- } elseif ($fallScore !== null) {
- $yearScore = round($fallScore, 2);
- } elseif ($springScore !== null) {
- $yearScore = round($springScore, 2);
- } else {
- $yearScore = null;
- }
-
- return [
- 'student' => $student,
- 'decision_row' => $decisionRow,
- 'student_name' => $studentName,
- 'class_section_name' => $classSectionName,
- 'fall_score' => $fallScore,
- 'spring_score' => $springScore,
- 'year_score' => $yearScore,
- 'all_semesters' => $this->fetchAllSemestersForStudent($studentId, $schoolYear),
- ];
-}
-
- public function previewDecisionEmail()
- {
- $studentId = (int)$this->request->getGet('student_id');
- $semester = trim((string)$this->request->getGet('semester'));
- $schoolYear = trim((string)$this->request->getGet('school_year'));
-
- if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
- return $this->response->setJSON(['error' => 'Missing student or term.'])->setStatusCode(400);
- }
-
- $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
- if (empty($row)) {
- return $this->response->setJSON(['error' => 'Student not found.'])->setStatusCode(404);
- }
-
- $decisionModel = new BelowSixtyDecisionModel();
- $decisionRow = $decisionModel
- ->where('student_id', $studentId)
- ->where('semester', $semester)
- ->where('school_year', $schoolYear)
- ->first();
-
- $decision = (string)($decisionRow['decision'] ?? '');
- $notes = (string)($decisionRow['notes'] ?? '');
- $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
- $parentName = $this->fetchBelowSixtyParentName($studentId);
-
- $subject = 'Academic Decision';
- if ($studentName !== '') $subject .= ' — ' . $studentName;
- if ($semester !== '' || $schoolYear !== '') {
- $subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')';
- }
-
- $scores = [
- 'homework_avg' => $row['homework_avg'] ?? null,
- 'project_avg' => $row['project_avg'] ?? null,
- 'participation_score' => $row['participation_score'] ?? null,
- 'test_avg' => $row['test_avg'] ?? null,
- 'ptap_score' => $row['ptap_score'] ?? null,
- 'attendance_score' => $row['attendance_score'] ?? null,
- 'midterm_exam_score' => $row['midterm_exam_score'] ?? null,
- 'semester_score' => $row['semester_score'] ?? null,
- ];
-
- // Fetch all semesters' scores + comments for the email
- $allSemesters = $this->fetchAllSemestersForStudent($studentId, $schoolYear);
-
- $html = view('emails/below_sixty_decision', [
- 'title' => $subject,
- 'parent_name' => $parentName,
- 'student_name' => $studentName !== '' ? $studentName : 'your student',
- 'class_section_name' => $row['class_section_name'] ?? '',
- 'semester' => $semester,
- 'school_year' => $schoolYear,
- 'decision' => $decision,
- 'notes' => $notes,
- 'scores' => $scores,
- 'all_semesters' => array_values($allSemesters),
- ], ['saveData' => true]);
-
- return $this->response->setJSON([
- 'subject' => $subject,
- 'html' => $html,
- 'student_id' => $studentId,
- ]);
- }
-
- public function editDecisionEmail()
- {
- $studentId = (int)$this->request->getGet('student_id');
- $semester = trim((string)$this->request->getGet('semester'));
- $schoolYear = trim((string)$this->request->getGet('school_year'));
-
- if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
- return redirect()->back()->with('error', 'Missing student or term.');
- }
-
- $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
- if (empty($row)) {
- return redirect()->back()->with('error', 'Student record not found for the selected term.');
- }
-
- $decisionModel = new BelowSixtyDecisionModel();
- $decisionRow = $decisionModel
- ->where('student_id', $studentId)
- ->where('semester', $semester)
- ->where('school_year', $schoolYear)
- ->first();
-
- $decision = (string)($decisionRow['decision'] ?? '');
- $notes = (string)($decisionRow['notes'] ?? '');
-
- $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
- $parentName = $this->fetchBelowSixtyParentName($studentId);
-
- $subject = 'Academic Decision';
- if ($studentName !== '') $subject .= ' — ' . $studentName;
- if ($semester !== '' || $schoolYear !== '') {
- $subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')';
- }
-
- $scores = [
- 'homework_avg' => $row['homework_avg'] ?? null,
- 'project_avg' => $row['project_avg'] ?? null,
- 'participation_score' => $row['participation_score'] ?? null,
- 'test_avg' => $row['test_avg'] ?? null,
- 'ptap_score' => $row['ptap_score'] ?? null,
- 'attendance_score' => $row['attendance_score'] ?? null,
- 'midterm_exam_score' => $row['midterm_exam_score'] ?? null,
- 'semester_score' => $row['semester_score'] ?? null,
- ];
-
- $html = view('emails/below_sixty_decision', [
- 'title' => $subject,
- 'parent_name' => $parentName,
- 'student_name' => $studentName !== '' ? $studentName : 'your student',
- 'class_section_name' => $row['class_section_name'] ?? '',
- 'semester' => $semester,
- 'school_year' => $schoolYear,
- 'decision' => $decision,
- 'notes' => $notes,
- 'scores' => $scores,
- ], ['saveData' => true]);
-
- return view('grading/below_sixty_decision_email_editor', [
- 'studentId' => $studentId,
- 'studentName' => $studentName,
- 'semester' => $semester,
- 'schoolYear' => $schoolYear,
- 'subject' => $subject,
- 'html' => $html,
- 'decision' => $decision,
- ]);
- }
-
- public function sendDecisionEmail()
- {
- $studentId = (int)$this->request->getPost('student_id');
- $semester = trim((string)$this->request->getPost('semester'));
- $schoolYear = trim((string)$this->request->getPost('school_year'));
- $subjectInput= trim((string)$this->request->getPost('subject'));
- $htmlInput = (string)($this->request->getPost('html') ?? '');
-
- if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
- return redirect()->back()->with('error', 'Missing student or term.');
- }
-
- $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
- if (empty($row)) {
- return redirect()->back()->with('error', 'Student record not found for the selected term.');
- }
-
- $decisionModel = new BelowSixtyDecisionModel();
- $decisionRow = $decisionModel
- ->where('student_id', $studentId)
- ->where('semester', $semester)
- ->where('school_year', $schoolYear)
- ->first();
-
- $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
- $subject = $subjectInput !== '' ? $subjectInput : ('Academic Decision — ' . $studentName . ' (' . trim($semester . ' ' . $schoolYear) . ')');
-
- $allSemesters = $this->fetchAllSemestersForStudent($studentId, $schoolYear);
-
- $payload = [
- 'student_id' => $studentId,
- 'student_name' => $studentName,
- 'class_section_name' => $row['class_section_name'] ?? '',
- 'semester' => $semester,
- 'school_year' => $schoolYear,
- 'decision' => (string)($decisionRow['decision'] ?? ''),
- 'notes' => (string)($decisionRow['notes'] ?? ''),
- 'subject' => $subject,
- 'all_semesters' => $allSemesters,
- 'scores' => [
- 'homework_avg' => $row['homework_avg'] ?? null,
- 'project_avg' => $row['project_avg'] ?? null,
- 'participation_score' => $row['participation_score'] ?? null,
- 'test_avg' => $row['test_avg'] ?? null,
- 'ptap_score' => $row['ptap_score'] ?? null,
- 'attendance_score' => $row['attendance_score'] ?? null,
- 'midterm_exam_score' => $row['midterm_exam_score'] ?? null,
- 'semester_score' => $row['semester_score'] ?? null,
- ],
- ];
-
- if (trim($htmlInput) !== '') {
- $payload['html'] = $htmlInput;
- }
-
- \CodeIgniter\Events\Events::trigger('below60.decision_email', $payload);
-
- $query = http_build_query(['semester' => $semester, 'school_year' => $schoolYear]);
- return redirect()->to(base_url('grading/below-60/decisions') . ($query ? '?' . $query : ''))
- ->with('status', 'Decision email sent to parent(s).');
- }
-
-public function allDecisions()
-{
- $configuredYear = (string)$this->schoolYear;
-
- $schoolYear = trim((string)($this->request->getGet('school_year') ?? ''));
- if ($schoolYear === '') {
- $schoolYear = $configuredYear;
- }
-
- $schoolYears = $this->getSchoolYearsForScores($schoolYear);
-
- // Load saved YEAR decisions for this school year.
- // New structure:
- // one row per student per school_year
- // uses year_score, not semester_score
- // does not use semester = 'year'
- $decModel = new StudentDecisionModel();
-
- $saved = $decModel
- ->where('school_year', $schoolYear)
- ->findAll();
-
- $savedMap = [];
- foreach ($saved as $s) {
- $sid = (int)($s['student_id'] ?? 0);
- if ($sid > 0) {
- $savedMap[$sid] = $s;
- }
- }
-
- // Fetch Fall and Spring semester scores per student.
- // These raw semester scores are only used to calculate the final year_score.
- $allScoreRows = $this->db->table('semester_scores ss')
- ->select([
- 's.id AS student_id',
- 's.school_id',
- 's.firstname',
- 's.lastname',
- 's.gender',
- 's.dob',
- 's.is_active',
- 'ss.class_section_id',
- 'cs.class_section_name',
- 'c.class_name',
- 'e.enrollment_status',
- 'e.is_withdrawn',
- 'LOWER(TRIM(ss.semester)) AS sem_key',
- 'ss.semester_score',
- ])
- ->join('students s', 's.id = ss.student_id', 'inner')
- ->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $this->db->escape($schoolYear), 'left')
- ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
- ->join('classes c', 'c.id = cs.class_id', 'left')
- ->groupStart()
- ->where('s.is_active', 1)
- ->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
- ->orWhere('e.is_withdrawn', 1)
- ->groupEnd()
- ->where('ss.school_year', $schoolYear)
- ->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring'])
- ->where('ss.semester_score IS NOT NULL', null, false)
- ->orderBy('cs.class_section_name', 'ASC')
- ->orderBy('s.lastname', 'ASC')
- ->orderBy('s.firstname', 'ASC')
- ->get()
- ->getResultArray();
-
- // Group Fall/Spring scores by student.
- $studentMap = [];
-
- foreach ($allScoreRows as $sr) {
- $sid = (int)($sr['student_id'] ?? 0);
-
- if ($sid <= 0) {
- continue;
- }
-
- if (!isset($studentMap[$sid])) {
- $studentMap[$sid] = [
- 'school_id' => $sr['school_id'] ?? '',
- 'firstname' => $sr['firstname'] ?? '',
- 'lastname' => $sr['lastname'] ?? '',
- 'gender' => $sr['gender'] ?? '',
- 'dob' => $sr['dob'] ?? '',
- 'is_active' => (int)($sr['is_active'] ?? 1),
- 'enrollment_status' => $sr['enrollment_status'] ?? '',
- 'is_withdrawn' => (int)($sr['is_withdrawn'] ?? 0),
- 'class_section_id' => (int)($sr['class_section_id'] ?? 0),
- 'class_name' => $sr['class_name'] ?? '',
- 'class_section_name' => $sr['class_section_name'] ?? '',
- 'fall_score' => null,
- 'spring_score' => null,
- ];
- }
-
- $semKey = strtolower(trim((string)($sr['sem_key'] ?? '')));
- $val = is_numeric($sr['semester_score']) ? (float)$sr['semester_score'] : null;
-
- if ($semKey === 'fall') {
- $studentMap[$sid]['fall_score'] = $val;
- } elseif ($semKey === 'spring') {
- $studentMap[$sid]['spring_score'] = $val;
- }
- }
-
- // Pull below-60 manual decisions for this school year.
- // Used only when the calculated year_score is below 60.
- $belowDecModel = new BelowSixtyDecisionModel();
-
- $belowRows = $belowDecModel
- ->where('school_year', $schoolYear)
- ->findAll();
-
- $belowMap = [];
-
- foreach ($belowRows as $b) {
- $sid = (int)($b['student_id'] ?? 0);
-
- if ($sid <= 0) {
- continue;
- }
-
- // Keep the first non-empty decision found for this student.
- if (!isset($belowMap[$sid]) || trim((string)($belowMap[$sid]['decision'] ?? '')) === '') {
- $belowMap[$sid] = $b;
- }
- }
-
- // Build final display rows.
- $rows = [];
-
- foreach ($studentMap as $sid => $info) {
- $fall = $info['fall_score'];
- $spring = $info['spring_score'];
-
- if ($fall !== null && $spring !== null) {
- $yearScore = round(($fall + $spring) / 2, 2);
- } elseif ($fall !== null) {
- $yearScore = round((float)$fall, 2);
- } elseif ($spring !== null) {
- $yearScore = round((float)$spring, 2);
- } else {
- $yearScore = null;
- }
-
- if (isset($savedMap[$sid])) {
- $savedRow = $savedMap[$sid];
-
- $decision = trim((string)($savedRow['decision'] ?? ''));
- $source = trim((string)($savedRow['source'] ?? 'pending'));
- $notes = (string)($savedRow['notes'] ?? '');
-
- if (isset($savedRow['year_score']) && $savedRow['year_score'] !== '' && is_numeric($savedRow['year_score'])) {
- $yearScore = round((float)$savedRow['year_score'], 2);
- }
- } elseif ($yearScore !== null && $yearScore >= 60) {
- $decision = 'Pass';
- $source = 'auto';
- $notes = '';
- } elseif ($yearScore !== null && isset($belowMap[$sid])) {
- $decision = trim((string)($belowMap[$sid]['decision'] ?? ''));
- $source = $decision !== '' ? 'manual' : 'pending';
- $notes = (string)($belowMap[$sid]['notes'] ?? '');
- } else {
- $decision = '';
- $source = 'pending';
- $notes = '';
- }
-
- $currentClassSectionName = trim((string)($info['class_section_name'] ?? ''));
- if ($currentClassSectionName === '' && isset($savedMap[$sid])) {
- $currentClassSectionName = trim((string)($savedMap[$sid]['class_section_name'] ?? ''));
- }
- $currentClassName = trim((string)($info['class_name'] ?? ''));
- if ($currentClassName === '') {
- $currentClassName = $this->classOnlyLabel($currentClassSectionName);
- }
-
- $rows[] = [
- 'student_id' => $sid,
- 'school_id' => $info['school_id'],
- 'firstname' => $info['firstname'],
- 'lastname' => $info['lastname'],
- 'gender' => $info['gender'] ?? '',
- 'dob' => $info['dob'] ?? '',
- 'is_active' => (int)($info['is_active'] ?? 1),
- 'enrollment_status' => $info['enrollment_status'] ?? '',
- 'is_withdrawn' => (int)($info['is_withdrawn'] ?? 0),
- 'class_section_id' => (int)($info['class_section_id'] ?? 0),
- 'class_name' => $currentClassName,
- 'class_section_name' => $currentClassSectionName,
- 'fall_score' => $fall,
- 'spring_score' => $spring,
- 'year_score' => $yearScore,
- 'decision' => $decision,
- 'next_year_placement' => $this->nextYearPlacementLabel($decision, $currentClassName, $currentClassSectionName, (string)($info['dob'] ?? ''), $schoolYear),
- 'source' => $source,
- 'notes' => $notes,
- 'saved' => isset($savedMap[$sid]),
- 'is_trophy' => false,
- ];
- }
-
- $rowsByClass = [];
-
- foreach ($rows as $index => $row) {
- $classSectionId = (int)($row['class_section_id'] ?? 0);
-
- if ($classSectionId <= 0) {
- continue;
- }
-
- $rowsByClass[$classSectionId][] = $index;
- }
-
- foreach ($rowsByClass as $classIndexes) {
- $scores = [];
-
- foreach ($classIndexes as $rowIndex) {
- $yearScore = $rows[$rowIndex]['year_score'] ?? null;
-
- if (is_numeric($yearScore)) {
- $scores[] = (float)$yearScore;
- }
- }
-
- $thresholdInfo = $this->calculateTrophyThreshold($scores, 75.0);
- $threshold = $thresholdInfo['threshold'];
-
- if ($threshold === null) {
- continue;
- }
-
- foreach ($classIndexes as $rowIndex) {
- $yearScore = $rows[$rowIndex]['year_score'] ?? null;
-
- $rows[$rowIndex]['is_trophy'] = is_numeric($yearScore) && (float)$yearScore >= $threshold;
- }
- }
-
- $generated = !empty($saved);
-
- return view('grading/all_decisions', [
- 'rows' => $rows,
- 'schoolYear' => $schoolYear,
- 'schoolYears' => $schoolYears,
- 'generated' => $generated,
- ]);
-}
-
-
-public function generateAllDecisions()
-{
- $schoolYear = trim((string)$this->request->getPost('school_year'));
-
- if ($schoolYear === '') {
- return redirect()->back()->with('error', 'Missing school year.');
- }
-
- // Fetch Fall and Spring scores per student.
- $allScoreRows = $this->db->table('semester_scores ss')
- ->select([
- 's.id AS student_id',
- 's.firstname',
- 's.lastname',
- 's.is_active',
- 'cs.class_section_name',
- 'LOWER(TRIM(ss.semester)) AS sem_key',
- 'ss.semester_score',
- ])
- ->join('students s', 's.id = ss.student_id', 'inner')
- ->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $this->db->escape($schoolYear), 'left')
- ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
- ->groupStart()
- ->where('s.is_active', 1)
- ->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
- ->orWhere('e.is_withdrawn', 1)
- ->groupEnd()
- ->where('ss.school_year', $schoolYear)
- ->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring'])
- ->where('ss.semester_score IS NOT NULL', null, false)
- ->get()
- ->getResultArray();
-
- if (empty($allScoreRows)) {
- return redirect()->back()->with('error', 'No semester scores found for this school year.');
- }
-
- // Group Fall/Spring scores by student.
- $studentMap = [];
-
- foreach ($allScoreRows as $sr) {
- $sid = (int)($sr['student_id'] ?? 0);
-
- if ($sid <= 0) {
- continue;
- }
-
- if (!isset($studentMap[$sid])) {
- $studentMap[$sid] = [
- 'firstname' => $sr['firstname'] ?? '',
- 'lastname' => $sr['lastname'] ?? '',
- 'class_section_name' => $sr['class_section_name'] ?? '',
- 'fall_score' => null,
- 'spring_score' => null,
- ];
- }
-
- $semKey = strtolower(trim((string)($sr['sem_key'] ?? '')));
- $val = is_numeric($sr['semester_score']) ? (float)$sr['semester_score'] : null;
-
- if ($semKey === 'fall') {
- $studentMap[$sid]['fall_score'] = $val;
- } elseif ($semKey === 'spring') {
- $studentMap[$sid]['spring_score'] = $val;
- }
- }
-
- // Pull below-60 manual decisions for this school year.
- $belowDecModel = new BelowSixtyDecisionModel();
-
- $belowRows = $belowDecModel
- ->where('school_year', $schoolYear)
- ->findAll();
-
- $belowMap = [];
-
- foreach ($belowRows as $b) {
- $sid = (int)($b['student_id'] ?? 0);
-
- if ($sid <= 0) {
- continue;
- }
-
- if (!isset($belowMap[$sid]) || trim((string)($belowMap[$sid]['decision'] ?? '')) === '') {
- $belowMap[$sid] = $b;
- }
- }
-
- // Load existing year decisions so we update instead of duplicating.
- // New structure: one row per student per school_year.
- $decModel = new StudentDecisionModel();
-
- $existing = $decModel
- ->where('school_year', $schoolYear)
- ->findAll();
-
- $existingMap = [];
-
- foreach ($existing as $e) {
- $sid = (int)($e['student_id'] ?? 0);
-
- if ($sid > 0) {
- $existingMap[$sid] = $e;
- }
- }
-
- $userId = (int)(session()->get('user_id') ?? 0) ?: null;
- $savedCount = 0;
-
- foreach ($studentMap as $sid => $info) {
- $fall = $info['fall_score'];
- $spring = $info['spring_score'];
-
- if ($fall !== null && $spring !== null) {
- $yearScore = round(($fall + $spring) / 2, 2);
- } elseif ($fall !== null) {
- $yearScore = round((float)$fall, 2);
- } elseif ($spring !== null) {
- $yearScore = round((float)$spring, 2);
- } else {
- continue;
- }
-
- if ($yearScore >= 60) {
- $decision = 'Pass';
- $source = 'auto';
- $notes = null;
- } elseif (isset($belowMap[$sid]) && trim((string)($belowMap[$sid]['decision'] ?? '')) !== '') {
- $decision = trim((string)$belowMap[$sid]['decision']);
- $source = 'manual';
- $notes = trim((string)($belowMap[$sid]['notes'] ?? ''));
- $notes = $notes !== '' ? $notes : null;
- } else {
- $decision = null;
- $source = 'pending';
- $notes = null;
- }
-
- // Important fix:
- // use year_score, not semester_score.
- // do not save semester = 'year'.
- $payload = [
- 'student_id' => $sid,
- 'school_year' => $schoolYear,
- 'class_section_name' => $info['class_section_name'] ?? null,
- 'year_score' => $yearScore,
- 'decision' => $decision,
- 'source' => $source,
- 'notes' => $notes,
- 'generated_by' => $userId,
- ];
-
- if (isset($existingMap[$sid])) {
- $decModel->update((int)$existingMap[$sid]['id'], $payload);
- } else {
- $decModel->insert($payload);
- }
-
- $savedCount++;
- }
-
- $query = http_build_query(['school_year' => $schoolYear]);
-
- return redirect()->to(base_url('grading/decisions') . '?' . $query)
- ->with('status', "Decisions generated for {$savedCount} students.");
-}
-
-private function nextYearPlacementLabel(
- ?string $decision,
- string $currentClassName,
- string $currentClassSectionName,
- string $dob,
- string $schoolYear
-): string
-{
- $normalizedDecision = DeliberationDecision::normalize($decision);
- $classLabel = $this->classOnlyLabel($currentClassName !== '' ? $currentClassName : $currentClassSectionName);
-
- if ($this->isKgClass($classLabel)) {
- $kgPlacement = $this->kgPlacementByComingSeptember($dob, $schoolYear);
- if ($kgPlacement !== '') {
- return $kgPlacement;
- }
- }
-
- if ($normalizedDecision === DeliberationDecision::REPEAT_CLASS) {
- return $classLabel;
- }
-
- return '';
-}
-
-private function classOnlyLabel(string $className): string
-{
- return trim((string) preg_replace('/-.+$/', '', $className));
-}
-
-private function isKgClass(string $className): bool
-{
- $value = strtoupper(trim($className));
-
- return preg_match('/(^|[^A-Z0-9])KG([^A-Z0-9]|$)/', $value) === 1 || str_contains($value, 'KINDERGARTEN');
-}
-
-private function kgPlacementByComingSeptember(string $dob, string $schoolYear): string
-{
- $dob = trim($dob);
- if ($dob === '') {
- return '';
- }
-
- try {
- $birthDate = new \DateTimeImmutable($dob);
- $cutoff = $this->comingSeptemberFirstCutoff($schoolYear);
- } catch (\Throwable) {
- return '';
- }
-
- if ($birthDate > $cutoff) {
- return '';
- }
-
- return $birthDate->diff($cutoff)->y >= 6 ? '1' : 'KG';
-}
-
-private function comingSeptemberFirstCutoff(string $schoolYear): \DateTimeImmutable
-{
- if (preg_match('/^(\d{4})-\d{4}$/', $schoolYear, $matches) === 1) {
- return new \DateTimeImmutable(((int) $matches[1] + 1) . '-09-01');
- }
-
- $today = new \DateTimeImmutable('today');
- $cutoff = new \DateTimeImmutable($today->format('Y') . '-09-01');
-
- return $today <= $cutoff ? $cutoff : $cutoff->modify('+1 year');
-}
-
- private function calculateTrophyThreshold(array $scores, float $percentile = 75.0): array
- {
- $scores = array_values(array_filter(
- $scores,
- static fn ($value): bool => is_numeric($value) && $value !== null
- ));
- $scores = array_map('floatval', $scores);
- sort($scores);
-
- $count = count($scores);
-
- if ($count === 0) {
- return ['threshold' => null, 'winners' => 0, 'method' => 'empty'];
- }
-
- $minWinners = 3;
- $maxWinners = max($minWinners, (int) floor($count * (1 - $percentile / 100)));
-
- $threshold = $this->empiricalTrophyPercentile($scores, $percentile);
- $winners = $this->countScoresAtOrAbove($scores, $threshold);
-
- if ($winners < $minWinners) {
- $target = min($minWinners, $count);
- $descending = array_reverse($scores);
- $threshold = $descending[$target - 1];
- $winners = $this->countScoresAtOrAbove($scores, $threshold);
-
- return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_reduced'];
- }
-
- if ($winners <= $maxWinners) {
- return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'empirical_percentile'];
- }
-
- $result = $this->capTrophyThresholdByRank($scores, $maxWinners);
-
- if ($result['winners'] < $minWinners) {
- $target = min($minWinners, $count);
- $descending = array_reverse($scores);
- $threshold = $descending[$target - 1];
- $winners = $this->countScoresAtOrAbove($scores, $threshold);
-
- return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_after_cap'];
- }
-
- return $result;
- }
-
- private function capTrophyThresholdByRank(array $sortedScores, int $max): array
- {
- $descending = array_reverse($sortedScores);
- $threshold = $descending[$max - 1];
- $winners = $this->countScoresAtOrAbove($sortedScores, $threshold);
-
- if ($winners <= $max) {
- return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'capped_25pct'];
- }
-
- $uniqueHigherScores = array_values(array_unique(array_filter(
- $sortedScores,
- static fn ($score): bool => $score > $threshold
- )));
- sort($uniqueHigherScores);
-
- foreach ($uniqueHigherScores as $candidate) {
- $winnerCount = $this->countScoresAtOrAbove($sortedScores, $candidate);
-
- if ($winnerCount <= $max) {
- return ['threshold' => $candidate, 'winners' => $winnerCount, 'method' => 'capped_25pct'];
- }
- }
-
- return ['threshold' => $sortedScores[0], 'winners' => count($sortedScores), 'method' => 'all_equal'];
- }
-
- private function empiricalTrophyPercentile(array $sortedScores, float $percentile): float
- {
- $count = count($sortedScores);
-
- if ($count === 0) {
- return 0.0;
- }
-
- $index = ($percentile / 100.0) * ($count - 1);
- $lower = (int) floor($index);
- $upper = (int) ceil($index);
-
- if ($lower === $upper) {
- return $sortedScores[$lower];
- }
-
- return $sortedScores[$lower] + ($index - $lower) * ($sortedScores[$upper] - $sortedScores[$lower]);
- }
-
- private function countScoresAtOrAbove(array $scores, float $threshold): int
- {
- return count(array_filter(
- $scores,
- static fn ($score): bool => $score >= $threshold
- ));
}
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;
+ return $this->response->setJSON(
+ $this->scoreService()->getScoreComment($this->requestParams())
+ );
}
- /**
- * 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)
+ public function updatePlacementLevel()
{
- // Validate input parameters
- if (empty($studentIds) || !is_array($studentIds)) {
- return [];
- }
+ return $this->respondGrading(
+ $this->placementService()->updatePlacementLevel($this->requestParams())
+ );
+ }
- if (empty($this->semester) || empty($this->schoolYear)) {
- throw new \InvalidArgumentException('Semester and school year must be provided');
- }
+ public function placement()
+ {
+ return $this->respondGrading(
+ $this->placementService()->placementPage($this->requestParams())
+ );
+ }
- // 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()
- ];
+ public function updatePlacementLevels()
+ {
+ return $this->respondGrading(
+ $this->placementService()->updatePlacementLevels($this->requestParams())
+ );
+ }
- // Get semester days configuration
- $semesterKey = strtolower($semester) === 'fall' ? 'total_semester1_days' : 'total_semester2_days';
- $totalSemesterDays = $models['config']->getConfig($semesterKey) ?? 0;
+ public function updatePlacementLevelsAll()
+ {
+ return $this->respondGrading(
+ $this->placementService()->updatePlacementLevelsAll($this->requestParams())
+ );
+ }
- // Common query conditions
- $conditions = [
- 'semester' => $this->semester,
- 'school_year' => $this->schoolYear
- ];
+ public function editPlacementBatch(int $batchId)
+ {
+ return $this->respondGrading(
+ $this->placementService()->editPlacementBatch($batchId, $this->requestParams())
+ );
+ }
- // Fetch all student data first
- $students = $models['student']->whereIn('id', $studentIds)
- ->where('school_year', $this->schoolYear)
- ->findAll();
+ public function updatePlacementBatch(int $batchId)
+ {
+ return $this->respondGrading(
+ $this->placementService()->updatePlacementBatch($batchId, $this->requestParams())
+ );
+ }
- if (empty($students)) {
- return [];
- }
+ public function belowSixty()
+ {
+ $params = $this->requestParams();
+ $params['school_year'] = $this->currentSchoolYearName((string) $this->schoolYear);
+ return $this->respondGrading($this->belowSixtyService()->belowSixtyPage($params));
+ }
- // 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);
+ public function editBelowSixtyEmail()
+ {
+ return $this->respondGrading(
+ $this->belowSixtyService()->editBelowSixtyEmail($this->requestParams())
+ );
+ }
- $allScores[$student['id']] = [
- 'school_id' => $student['school_id'],
- 'firstname' => $student['firstname'],
- 'lastname' => $student['lastname'],
- 'class_name' => $className,
- //'teacherId' => $updatedBy,
- 'comments' => [] // Initialize empty comments array
- ];
- }
+ public function sendBelowSixtyEmail()
+ {
+ return $this->respondGrading(
+ $this->belowSixtyService()->sendBelowSixtyEmail($this->requestParams())
+ );
+ }
- // Fetch and process attendance data
- foreach ($studentIds as $studentId) {
- if (!isset($allScores[$studentId])) continue;
+ public function updateBelowSixtyStatus()
+ {
+ return $this->respondGrading(
+ $this->belowSixtyService()->updateBelowSixtyStatus($this->requestParams())
+ );
+ }
- $absences = $models['attendance']->getTotalAbsences($studentId, $this->semester, $this->schoolYear);
- $attendance = min((($totalSemesterDays - $absences + 1) / $totalSemesterDays) * 100, 100);
+ public function scheduleBelowSixty()
+ {
+ return $this->respondGrading(
+ $this->belowSixtyService()->scheduleBelowSixtyPage($this->requestParams())
+ );
+ }
- $allScores[$studentId]['attendance'] = [
- 'score' => round($attendance, 2),
- 'absences' => $absences,
- 'total_days' => $totalSemesterDays
- ];
- }
+ public function saveBelowSixtyMeeting()
+ {
+ return $this->respondGrading(
+ $this->belowSixtyService()->saveBelowSixtyMeeting($this->requestParams())
+ );
+ }
- // 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']
- ];
+ public function belowSixtyDecisions()
+ {
+ $context = $this->resolveSchoolYearContext();
+ $params = $this->requestParams();
+ $params['school_year'] = $context->yearName();
+ $params['is_readonly'] = $context->isReadonly();
+ return $this->respondGrading($this->belowSixtyService()->belowSixtyDecisionsPage($params));
+ }
- foreach ($scoreTypes as $type => $model) {
- $scores = $model->whereIn('student_id', $studentIds)
- ->where($conditions)
- ->findAll();
+ public function saveBelowSixtyDecision()
+ {
+ $context = $this->resolveSchoolYearContext();
+ $params = $this->requestParams();
+ $params['school_year'] = $context->yearName();
+ return $this->respondGrading($this->belowSixtyService()->saveBelowSixtyDecision($params));
+ }
- 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;
- }
- }
- }
+ public function studentDecisionDetails()
+ {
+ return $this->respondGrading(
+ $this->belowSixtyService()->studentDecisionDetails($this->requestParams())
+ );
+ }
- // Fetch and process comments
- $comments = $models['comments']->whereIn('student_id', $studentIds)
- ->where($conditions)
- ->findAll();
+ public function previewBelowSixtyDecisionEmail()
+ {
+ return $this->respondGrading(
+ $this->belowSixtyService()->previewBelowSixtyDecisionEmail($this->requestParams())
+ );
+ }
- foreach ($comments as $comment) {
- $allScores[$comment['student_id']]['comments'][] = $comment;
- }
+ public function sendBelowSixtyDecisionEmail()
+ {
+ return $this->respondGrading(
+ $this->belowSixtyService()->sendBelowSixtyDecisionEmail($this->requestParams())
+ );
+ }
- return $allScores;
+ public function previewDecisionEmail()
+ {
+ return $this->respondGrading(
+ $this->decisionService()->previewDecisionEmail($this->requestParams())
+ );
+ }
+
+ public function editDecisionEmail()
+ {
+ return $this->respondGrading(
+ $this->decisionService()->editDecisionEmail($this->requestParams())
+ );
+ }
+
+ public function sendDecisionEmail()
+ {
+ return $this->respondGrading(
+ $this->decisionService()->sendDecisionEmail($this->requestParams())
+ );
+ }
+
+ public function allDecisions()
+ {
+ return $this->respondGrading(
+ $this->decisionService()->allDecisionsPage($this->requestParams())
+ );
+ }
+
+ public function generateAllDecisions()
+ {
+ return $this->respondGrading(
+ $this->decisionService()->generateAllDecisions($this->requestParams())
+ );
}
}
diff --git a/app/Services/AdminNotificationSettingsService.php b/app/Services/AdminNotificationSettingsService.php
new file mode 100644
index 0000000..964670a
--- /dev/null
+++ b/app/Services/AdminNotificationSettingsService.php
@@ -0,0 +1,280 @@
+db = $db;
+ $this->adminNotificationSubjectModel = $adminNotificationSubjectModel;
+ }
+
+public function alertsPage(): array
+{
+ $admins = $this->eligibleUsers();
+ $subjects = $this->subjectOptions();
+ $assignedSubjects = [];
+ $tableReady = $this->db->tableExists('admin_notification_subjects');
+
+ if ($tableReady && !empty($admins)) {
+ $adminIds = array_map('intval', array_column($admins, 'id'));
+ $rows = $this->adminNotificationSubjectModel
+ ->select('id, admin_id, subject')
+ ->whereIn('admin_id', $adminIds)
+ ->findAll();
+
+ foreach ($rows as $row) {
+ $adminId = (int) ($row['admin_id'] ?? 0);
+ $subject = (string) ($row['subject'] ?? '');
+ if ($adminId <= 0 || $subject === '') {
+ continue;
+ }
+ if (!isset($assignedSubjects[$adminId])) {
+ $assignedSubjects[$adminId] = [];
+ }
+ $assignedSubjects[$adminId][$subject] = true;
+ }
+ }
+
+ return [
+ 'admins' => $admins,
+ 'subjects' => $subjects,
+ 'assignedSubjects' => $assignedSubjects,
+ 'tableReady' => $tableReady,
+ ];
+}
+
+public function saveSubjects($posted): array
+{
+ if (!$this->db->tableExists('admin_notification_subjects')) {
+ return ['type' => 'error', 'message' => 'Notification subject storage is missing. Run migrations first.'];
+ }
+
+ if (!is_array($posted)) {
+ return ['type' => 'error', 'message' => 'No selections were submitted.'];
+ }
+
+ $subjects = $this->subjectOptions();
+ $allowed = array_keys($subjects);
+
+ $admins = $this->eligibleUsers();
+ $adminIds = array_map('intval', array_column($admins, 'id'));
+ if (empty($adminIds)) {
+ return ['type' => 'info', 'message' => 'No admins found to update.'];
+ }
+
+ $existing = $this->adminNotificationSubjectModel
+ ->select('id, admin_id, subject')
+ ->whereIn('admin_id', $adminIds)
+ ->findAll();
+
+ $existingMap = [];
+ foreach ($existing as $row) {
+ $adminId = (int) ($row['admin_id'] ?? 0);
+ $subject = (string) ($row['subject'] ?? '');
+ if ($adminId <= 0 || $subject === '') {
+ continue;
+ }
+ if (!isset($existingMap[$adminId])) {
+ $existingMap[$adminId] = [];
+ }
+ $existingMap[$adminId][$subject] = (int) ($row['id'] ?? 0);
+ }
+
+ $updates = 0;
+
+ foreach ($adminIds as $adminId) {
+ $subjectRaw = $posted[$adminId] ?? [];
+
+ $selected = [];
+ if (is_array($subjectRaw)) {
+ foreach ($subjectRaw as $key => $value) {
+ $candidate = is_string($key) ? $key : $value;
+ if (!is_string($candidate)) {
+ continue;
+ }
+ $candidate = trim($candidate);
+ if ($candidate !== '') {
+ $selected[] = $candidate;
+ }
+ }
+ }
+
+ $selected = array_values(array_unique(array_filter($selected, function ($value) use ($allowed) {
+ return in_array($value, $allowed, true);
+ })));
+
+ $current = array_keys($existingMap[$adminId] ?? []);
+ $toDelete = array_values(array_diff($current, $selected));
+ $toInsert = array_values(array_diff($selected, $current));
+
+ if (!empty($toDelete)) {
+ $this->adminNotificationSubjectModel
+ ->where('admin_id', $adminId)
+ ->whereIn('subject', $toDelete)
+ ->delete();
+ $updates += count($toDelete);
+ }
+
+ if (!empty($toInsert)) {
+ $batch = [];
+ foreach ($toInsert as $subject) {
+ $batch[] = [
+ 'admin_id' => $adminId,
+ 'subject' => $subject,
+ ];
+ }
+ $this->adminNotificationSubjectModel->insertBatch($batch);
+ $updates += count($toInsert);
+ }
+ }
+
+ return ['type' => 'success', 'message' => $updates > 0 ? 'Notification subjects updated.' : 'No changes were made.'];
+}
+
+public function printRecipientsPage(): array
+{
+ $admins = $this->eligibleUsers();
+ $tableReady = $this->db->tableExists('admin_notification_subjects');
+ $assigned = [];
+
+ if ($tableReady && !empty($admins)) {
+ $adminIds = array_map('intval', array_column($admins, 'id'));
+ $rows = $this->adminNotificationSubjectModel
+ ->select('admin_id')
+ ->where('subject', 'print_requests')
+ ->whereIn('admin_id', $adminIds)
+ ->findAll();
+
+ foreach ($rows as $row) {
+ $adminId = (int) ($row['admin_id'] ?? 0);
+ if ($adminId <= 0) {
+ continue;
+ }
+ $assigned[$adminId] = true;
+ }
+ }
+
+ return [
+ 'admins' => $admins,
+ 'assigned' => $assigned,
+ 'tableReady' => $tableReady,
+ ];
+}
+
+public function savePrintRecipients(array $posted): array
+{
+ if (!$this->db->tableExists('admin_notification_subjects')) {
+ return ['type' => 'error', 'message' => 'Notification subject storage is missing. Run migrations first.'];
+ }
+
+ $admins = $this->eligibleUsers();
+ $adminIds = array_map('intval', array_column($admins, 'id'));
+ if (empty($adminIds)) {
+ return ['type' => 'info', 'message' => 'No admins found to update.'];
+ }
+
+ $selected = [];
+ foreach ($posted as $key => $value) {
+ $adminId = (int) $key;
+ if ($adminId <= 0) {
+ continue;
+ }
+ if (!in_array($adminId, $adminIds, true)) {
+ continue;
+ }
+ $selected[] = $adminId;
+ }
+ $selected = array_values(array_unique($selected));
+
+ $existingRows = $this->adminNotificationSubjectModel
+ ->select('admin_id')
+ ->where('subject', 'print_requests')
+ ->whereIn('admin_id', $adminIds)
+ ->findAll();
+
+ $current = array_values(array_unique(array_map(
+ fn($row) => (int) ($row['admin_id'] ?? 0),
+ $existingRows
+ )));
+
+ $toDelete = array_values(array_diff($current, $selected));
+ $toInsert = array_values(array_diff($selected, $current));
+
+ if (!empty($toDelete)) {
+ $this->adminNotificationSubjectModel
+ ->where('subject', 'print_requests')
+ ->whereIn('admin_id', $toDelete)
+ ->delete();
+ }
+
+ if (!empty($toInsert)) {
+ $batch = [];
+ foreach ($toInsert as $adminId) {
+ $batch[] = [
+ 'admin_id' => $adminId,
+ 'subject' => 'print_requests',
+ ];
+ }
+ $this->adminNotificationSubjectModel->insertBatch($batch);
+ }
+
+ $changes = count($toDelete) + count($toInsert);
+ return ['type' => 'success', 'message' => $changes > 0 ? 'Print notification recipients updated.' : 'No changes were made.'];
+}
+
+public function subjectOptions(): array
+{
+ return [
+ 'academics' => 'Academics',
+ 'attendance' => 'Attendance',
+ 'events' => 'Events',
+ 'finance' => 'Finance',
+ 'general' => 'General',
+ 'print_requests' => 'Print Requests',
+ ];
+}
+
+public function excludedRoles(): array
+{
+ return [
+ 'parent',
+ 'student',
+ 'guest',
+ 'teacher',
+ 'assistant teacher',
+ 'teacher assistant',
+ 'teacher_assistant',
+ 'assistant_teacher',
+ 'ta',
+ 'authorized_user',
+ ];
+}
+
+public function eligibleUsers(): array
+{
+ $excluded = $this->excludedRoles();
+ $excludedList = "'" . implode("','", $excluded) . "'";
+
+ return $this->db->table('users u')
+ ->select('u.id, u.firstname, u.lastname, u.email')
+ ->join('user_roles ur', 'ur.user_id = u.id', 'inner')
+ ->join('roles r', 'r.id = ur.role_id', 'inner')
+ ->where('r.name IS NOT NULL', null, false)
+ ->where('ur.deleted_at', null)
+ ->where("LOWER(r.name) NOT IN ({$excludedList})", null, false)
+ ->groupBy('u.id, u.firstname, u.lastname, u.email')
+ ->orderBy('u.lastname', 'ASC')
+ ->orderBy('u.firstname', 'ASC')
+ ->get()
+ ->getResultArray();
+}
+}
diff --git a/app/Services/AdministratorDashboardService.php b/app/Services/AdministratorDashboardService.php
new file mode 100644
index 0000000..6bf2ab3
--- /dev/null
+++ b/app/Services/AdministratorDashboardService.php
@@ -0,0 +1,250 @@
+db = $db;
+ $this->userModel = $userModel;
+ $this->loginActivityModel = $loginActivityModel;
+ }
+
+public function metrics(string $schoolYear, string $semester): array
+{
+ $this->schoolYear = $schoolYear;
+ $this->semester = $semester;
+ $recentActivities = $this->loginActivityModel->getLastActivities(4);
+ if (!is_array($recentActivities)) {
+ $recentActivities = [];
+ }
+
+ $totalAdmins = (int) ($this->userModel->countAdminsBySchoolYear($this->schoolYear) ?? 0);
+
+ $teachers = $this->userModel->getUsersByRoleAndSchoolYear('teacher', $this->schoolYear);
+ $totalTeachers = $this->countUniqueEntities($teachers);
+
+ $teacherAssistants = $this->userModel->getUsersByRoleAndSchoolYear('teacher_assistant', $this->schoolYear);
+ $totalTeacherAssistants = $this->countUniqueEntities($teacherAssistants);
+
+ $parents = $this->userModel->getUsersByRoleAndSchoolYear('parent', $this->schoolYear);
+ $totalParents = $this->countUniqueEntities($parents);
+
+ // Count only students that have a class assigned and exist in student_class for the current school year
+ $totalStudents = (int) (
+ $this->db->table('student_class')
+ ->select('COUNT(DISTINCT student_class.student_id) AS cnt')
+ ->join('students', 'students.id = student_class.student_id', 'inner')
+ ->where('student_class.school_year', $this->schoolYear)
+ ->where('student_class.class_section_id IS NOT NULL', null, false)
+ ->where('students.is_active', 1)
+ ->get()
+ ->getRow('cnt')
+ ?? 0
+ );
+
+ return [
+ 'counts' => [
+ 'students' => $totalStudents,
+ 'teachers' => $totalTeachers,
+ 'teacherAssistants' => $totalTeacherAssistants,
+ 'admins' => $totalAdmins,
+ 'parents' => $totalParents,
+ ],
+ 'recentActivities' => array_map(static function ($activity) {
+ if (!is_array($activity)) {
+ return [];
+ }
+ return [
+ 'login_time' => $activity['login_time'] ?? null,
+ 'email' => $activity['email'] ?? null,
+ ];
+ }, $recentActivities),
+ 'meta' => [
+ 'schoolYear' => $this->schoolYear,
+ 'semester' => $this->semester,
+ ],
+ ];
+}
+
+private function countUniqueEntities($rows): int
+{
+ if (!is_array($rows) || $rows === []) {
+ return 0;
+ }
+
+ $ids = [];
+ foreach ($rows as $row) {
+ if (!is_array($row)) {
+ continue;
+ }
+ if (isset($row['id'])) {
+ $ids[] = (int) $row['id'];
+ continue;
+ }
+ if (isset($row['user_id'])) {
+ $ids[] = (int) $row['user_id'];
+ }
+ }
+
+ return count(array_unique($ids));
+}
+
+public function search(string $query): array
+{
+ $q = trim($query);
+
+ if ($q === '') {
+ return [
+ 'query' => '',
+ 'results' => [],
+ 'scope_used' => 'unscoped-raw',
+ 'scope_label' => 'all years/semesters (raw)',
+ 'total_found' => 0,
+ ];
+ }
+
+ $db = $this->db;
+
+ // 1) Tokenize input: split by whitespace and punctuation, keep meaningful pieces
+ $rawTokens = preg_split('/[,\s]+/u', $q, -1, PREG_SPLIT_NO_EMPTY) ?: [];
+ $tokens = array_values(array_filter(array_map('trim', $rawTokens)));
+
+ // 2) Build phone variants for any token that looks numeric-ish
+ $phoneMap = []; // token => variants[]
+ foreach ($tokens as $t) {
+ $digits = preg_replace('/\D+/', '', $t);
+ if ($digits === '') {
+ continue;
+ }
+
+ $v = [];
+ if (strlen($digits) >= 7) {
+ // base forms
+ $v[] = $digits;
+ if (strlen($digits) === 10) {
+ $v[] = sprintf('(%s)-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
+ $v[] = sprintf('%s-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
+ $v[] = sprintf('%s %s %s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
+ // country-code forms
+ $v[] = '1' . $digits;
+ $v[] = '+1' . $digits;
+ $v[] = '+1 ' . sprintf('(%s) %s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
+ $v[] = '+1-' . sprintf('%s-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
+ } elseif (strlen($digits) === 11 && str_starts_with($digits, '1')) {
+ $ten = substr($digits, 1);
+ $v[] = $ten;
+ $v[] = sprintf('(%s)-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
+ $v[] = sprintf('%s-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
+ $v[] = sprintf('%s %s %s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
+ $v[] = '+1' . $ten;
+ $v[] = '+1 ' . sprintf('(%s) %s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
+ $v[] = '+1-' . sprintf('%s-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
+ } else {
+ // 7-9 digits: keep as-is (partial phone fragment)
+ $v[] = $digits;
+ }
+ }
+ if (!empty($v)) {
+ $phoneMap[$t] = array_values(array_unique($v));
+ }
+ }
+
+ // Helper: ( token1 AND token2 AND ... ), each token may match ANY of $columns,
+ // and phone variants are only applied to the provided $phoneCols for THIS table.
+ $applyMultiTokenLike = function ($qb, array $columns, array $tokens, array $phoneCols = []) use ($phoneMap) {
+ foreach ($tokens as $t) {
+ $qb->groupStart(); // OR across columns for this token
+ foreach ($columns as $i => $col) {
+ if ($i === 0) {
+ $qb->like($col, $t);
+ } else {
+ $qb->orLike($col, $t);
+ }
+ }
+ if (!empty($phoneMap[$t]) && !empty($phoneCols)) {
+ foreach ($phoneMap[$t] as $pv) {
+ foreach ($phoneCols as $pcol) {
+ $qb->orLike($pcol, $pv);
+ }
+ }
+ }
+ $qb->groupEnd();
+ }
+ return $qb;
+ };
+
+ // ===== RAW UNscoped searches (flat arrays) =====
+
+ // USERS (phone col: cellphone)
+ $uCols = ['firstname', 'lastname', 'email', 'cellphone', 'school_id', 'city', 'state'];
+ $uQB = $db->table('users')
+ ->select('id, firstname, lastname, email, cellphone, school_id, city, state');
+ $applyMultiTokenLike($uQB, $uCols, $tokens, ['cellphone']);
+ $users = $uQB->limit(150)->get()->getResultArray();
+
+ // STUDENTS (no phone column to search)
+ $sCols = ['firstname', 'lastname', 'school_id', 'rfid_tag', 'dob', 'gender'];
+ $sQB = $db->table('students')
+ ->select('id, parent_id, school_id, firstname, lastname, dob, gender, rfid_tag');
+ $applyMultiTokenLike($sQB, $sCols, $tokens, []);
+ $students = $sQB->limit(150)->get()->getResultArray();
+
+ // PARENTS (phone col: secondparent_phone)
+ $pCols = ['secondparent_firstname', 'secondparent_lastname', 'secondparent_email', 'secondparent_phone'];
+ $pQB = $db->table('parents')
+ ->select('id, firstparent_id, secondparent_firstname, secondparent_lastname, secondparent_email, secondparent_phone');
+ $applyMultiTokenLike($pQB, $pCols, $tokens, ['secondparent_phone']);
+ foreach ($tokens as $t) {
+ if (ctype_digit($t)) {
+ $pQB->orWhere('firstparent_id', (int) $t)->orWhere('id', (int) $t);
+ }
+ }
+ $parents = $pQB->limit(150)->get()->getResultArray();
+
+ // STAFF (phone col: phone)
+ $stCols = ['firstname', 'lastname', 'email', 'role_name', 'phone'];
+ $stQB = $db->table('staff')
+ ->select('id, user_id, firstname, lastname, email, phone, role_name, active_role');
+ $applyMultiTokenLike($stQB, $stCols, $tokens, ['phone']);
+ $staff = $stQB->limit(150)->get()->getResultArray();
+
+ // EMERGENCY CONTACTS (phone col: cellphone)
+ $ecCols = ['emergency_contact_name', 'relation', 'email', 'cellphone'];
+ $ecQB = $db->table('emergency_contacts')
+ ->select('id, parent_id, emergency_contact_name, relation, cellphone, email');
+ $applyMultiTokenLike($ecQB, $ecCols, $tokens, ['cellphone']);
+ $emergency = $ecQB->limit(150)->get()->getResultArray();
+
+ $raw = [
+ 'users' => $users,
+ 'students' => $students,
+ 'parents' => $parents,
+ 'staff' => $staff,
+ 'emergency_contacts' => $emergency,
+ ];
+
+ $total = count($users) + count($students) + count($parents) + count($staff) + count($emergency);
+
+ return [
+ 'query' => $q,
+ 'results' => $raw,
+ 'scope_used' => 'unscoped-raw',
+ 'scope_label' => 'all years/semesters (raw, tokenized)',
+ 'total_found' => $total,
+ ];
+}
+}
diff --git a/app/Services/AdministratorDirectoryService.php b/app/Services/AdministratorDirectoryService.php
new file mode 100644
index 0000000..2a4f0b3
--- /dev/null
+++ b/app/Services/AdministratorDirectoryService.php
@@ -0,0 +1,224 @@
+db = $db;
+ $this->studentClassModel = $studentClassModel;
+ $this->userModel = $userModel;
+ $this->userRoleModel = $userRoleModel;
+ $this->invoiceModel = $invoiceModel;
+ $this->studentModel = $studentModel;
+ }
+
+public function studentProfiles(string $selectedYear): array
+{
+ $db = db_connect();
+ $isPg = ($db->getPlatform() === 'Postgre'); // 'MySQLi', 'Postgre', 'SQLSRV', ...
+
+ // In MySQL, avoid truncation of long lists
+ if (!$isPg) {
+ $db->query('SET SESSION group_concat_max_len = 8192');
+ }
+
+ $b = $db->table('students');
+
+ if ($isPg) {
+ // Postgres: use subqueries for aggregates to avoid GROUP BY on students.*
+ $students = $b->select([
+ 'students.*',
+ 'users.firstname AS parent_firstname',
+ 'users.lastname AS parent_lastname',
+ 'users.email AS parent_email',
+ 'users.cellphone AS parent_phone',
+
+ // Pick one emergency contact (first by id)
+ "(SELECT ec.emergency_contact_name FROM emergency_contacts ec
+ WHERE ec.parent_id = students.parent_id
+ ORDER BY ec.id ASC LIMIT 1) AS emergency_name",
+ "(SELECT ec.relation FROM emergency_contacts ec
+ WHERE ec.parent_id = students.parent_id
+ ORDER BY ec.id ASC LIMIT 1) AS emergency_relationship",
+ "(SELECT ec.cellphone FROM emergency_contacts ec
+ WHERE ec.parent_id = students.parent_id
+ ORDER BY ec.id ASC LIMIT 1) AS emergency_phone",
+ "(SELECT ec.email FROM emergency_contacts ec
+ WHERE ec.parent_id = students.parent_id
+ ORDER BY ec.id ASC LIMIT 1) AS emergency_email",
+
+ // Aggregated lists
+ "(SELECT STRING_AGG(DISTINCT sa.allergy, ', ' ORDER BY sa.allergy)
+ FROM student_allergies sa WHERE sa.student_id = students.id) AS allergies",
+ "(SELECT STRING_AGG(DISTINCT smc.condition_name, ', ' ORDER BY smc.condition_name)
+ FROM student_medical_conditions smc WHERE smc.student_id = students.id) AS medical_conditions",
+ ])
+ ->join('users', 'users.id = students.parent_id', 'left')
+ ->orderBy('students.lastname', 'ASC')
+ ->orderBy('students.firstname', 'ASC')
+ ->get()
+ ->getResultArray();
+ } else {
+ // MySQL: GROUP_CONCAT + MIN() for emergency_* to satisfy ONLY_FULL_GROUP_BY
+ $students = $b->select([
+ 'students.*',
+ 'users.firstname AS parent_firstname',
+ 'users.lastname AS parent_lastname',
+ 'users.email AS parent_email',
+ 'users.cellphone AS parent_phone',
+
+ 'MIN(emergency_contacts.emergency_contact_name) AS emergency_name',
+ 'MIN(emergency_contacts.relation) AS emergency_relationship',
+ 'MIN(emergency_contacts.cellphone) AS emergency_phone',
+ 'MIN(emergency_contacts.email) AS emergency_email',
+
+ "GROUP_CONCAT(DISTINCT student_allergies.allergy
+ ORDER BY student_allergies.allergy SEPARATOR ', ') AS allergies",
+ "GROUP_CONCAT(DISTINCT student_medical_conditions.condition_name
+ ORDER BY student_medical_conditions.condition_name SEPARATOR ', ') AS medical_conditions",
+ ])
+ ->join('users', 'users.id = students.parent_id', 'left')
+ ->join('emergency_contacts', 'emergency_contacts.parent_id = students.parent_id', 'left')
+ ->join('student_allergies', 'student_allergies.student_id = students.id', 'left')
+ ->join('student_medical_conditions', 'student_medical_conditions.student_id = students.id', 'left')
+ ->groupBy('students.id')
+ ->orderBy('students.lastname', 'ASC')
+ ->orderBy('students.firstname', 'ASC')
+ ->get()
+ ->getResultArray();
+ }
+
+ $enrollmentStatusByStudentId = [];
+ $studentIds = array_values(array_unique(array_filter(array_map(
+ static fn (array $row): int => (int) ($row['id'] ?? 0),
+ $students
+ ))));
+ if ($selectedYear !== '' && !empty($studentIds)) {
+ $enrollmentRows = $db->table('enrollments')
+ ->select('student_id, enrollment_status')
+ ->whereIn('student_id', $studentIds)
+ ->where('school_year', $selectedYear)
+ ->orderBy('student_id', 'ASC')
+ ->orderBy('updated_at', 'DESC')
+ ->orderBy('enrollment_date', 'DESC')
+ ->orderBy('id', 'DESC')
+ ->get()
+ ->getResultArray();
+
+ foreach ($enrollmentRows as $enrollmentRow) {
+ $studentId = (int) ($enrollmentRow['student_id'] ?? 0);
+ if ($studentId > 0 && !isset($enrollmentStatusByStudentId[$studentId])) {
+ $enrollmentStatusByStudentId[$studentId] = (string) ($enrollmentRow['enrollment_status'] ?? '');
+ }
+ }
+ }
+
+ // === Inject current-year class_section_name from student_class and replace grade ===
+ foreach ($students as $i => $row) {
+ $sid = (int) ($row['id'] ?? 0);
+ if ($sid > 0) {
+ $classSectionName = (string) ($this->studentClassModel->getClassSectionNameByStudentId($sid, $selectedYear) ?? '');
+
+ $students[$i]['class_section_name'] = $classSectionName;
+ $students[$i]['enrollment_status'] = $enrollmentStatusByStudentId[$sid] ?? '';
+ } else {
+ // Keep keys consistent even if id missing
+ $students[$i]['class_section_name'] = '';
+ $students[$i]['enrollment_status'] = '';
+ }
+
+ $studentYear = trim((string) ($row['school_year'] ?? ''));
+ $students[$i]['age'] = EnrollmentEligibility::ageOnSeptemberFirst(
+ $row['dob'] ?? null,
+ $studentYear !== '' ? $studentYear : $selectedYear
+ );
+ }
+ // === end injection ===
+
+ return [
+ 'students' => $students,
+ 'gradeOptions' => ['K', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'Youth'],
+ 'genderOptions' => ['Male', 'Female', 'Other'],
+ 'selectedYear' => $selectedYear,
+ ];
+}
+
+public function parentProfiles(): array
+{
+ $allUsers = $this->userModel->findAll();
+ $parents = [];
+
+ // Fetch roles for users in one query to avoid looping over all users
+ foreach ($allUsers as $user) {
+ // Get the roles of the user directly
+ $roles = $this->userRoleModel->getRolesByUserId($user['id']);
+ $isParent = false;
+
+ // Check if $roles is iterable and contains the 'parent' role
+ if (is_array($roles)) {
+ foreach ($roles as $role) {
+ if (isset($role['role_name']) && $role['role_name'] === 'parent') {
+ $isParent = true;
+ break;
+ }
+ }
+ }
+
+ if ($isParent) {
+ // Retrieve the latest paid amount and balance for this parent
+ $paidAmount = $this->invoiceModel->getLatestInvoicePaidAmount($user['id']) ?? 0;
+ $balance = $this->invoiceModel->getLatestInvoiceBalance($user['id']) ?? 0;
+
+ // Get students for this parent
+ $studentsData = $this->studentModel->where('parent_id', $user['id'])->findAll();
+ $students = [];
+ foreach ($studentsData as $student) {
+ $classSectionName = $this->studentClassModel->getClassSectionNameByStudentId($student['id']) ?? 'N/A';
+ $students[] = [
+ 'name' => $student['firstname'] . ' ' . $student['lastname'],
+ 'class_section' => $classSectionName
+ ];
+ }
+
+ // Add parent data to the array
+ $parents[] = [
+ 'id' => $user['id'],
+ 'school_id' => $user['school_id'],
+ 'firstname' => $user['firstname'],
+ 'lastname' => $user['lastname'],
+ 'email' => $user['email'],
+ 'cellphone' => $user['cellphone'],
+ 'gender' => $user['gender'],
+ 'created_at' => $user['created_at'],
+ 'paid_amount' => $paidAmount,
+ 'balance' => $balance,
+ 'students' => $students,
+ ];
+ }
+ }
+
+ return ['parents' => $parents];
+}
+}
diff --git a/app/Services/BelowSixtyService.php b/app/Services/BelowSixtyService.php
new file mode 100644
index 0000000..77dc110
--- /dev/null
+++ b/app/Services/BelowSixtyService.php
@@ -0,0 +1,1931 @@
+db = $db;
+ $this->configModel = $configModel;
+ $this->studentModel = $studentModel;
+ $this->studentClassModel = $studentClassModel;
+ $this->parentMeetingModel = $parentMeetingModel;
+ $this->userModel = $userModel;
+ $this->schoolYear = $schoolYear;
+ $this->semester = $semester;
+ }
+
+ public function setTerm(string $schoolYear, string $semester): void
+ {
+ $this->schoolYear = $schoolYear;
+ $this->semester = $semester;
+ }
+
+public function belowSixtyPage(array $params = [])
+{
+ $get = $params['get'] ?? [];
+ $post = $params['post'] ?? [];
+
+$schoolYear = (string) ($params['school_year'] ?? $this->schoolYear);
+
+// This page is Fall only.
+$semester = 'fall';
+$isYearMode = false;
+
+$schoolYears = $this->getSchoolYearsForScores($schoolYear);
+
+/*
+ * Use your existing below-60 fetcher.
+ * Do NOT query below_sixty_status. That table does not exist.
+ */
+$rows = $this->fetchBelowSixtyRows($schoolYear, $semester);
+
+/*
+ * Hard guard:
+ * Keep only Fall semester rows with semester_score < 60.
+ * This prevents whole-year rows or accidental other semester rows
+ * from sneaking into this Fall-only page.
+ */
+$rows = array_values(array_filter($rows, static function ($row) {
+ $semesterValue = strtolower(trim((string)($row['semester'] ?? 'fall')));
+ $scoreRaw = $row['semester_score'] ?? null;
+
+ if ($semesterValue !== '' && $semesterValue !== 'fall') {
+ return false;
+ }
+
+ if (!is_numeric($scoreRaw)) {
+ return false;
+ }
+
+ return (float)$scoreRaw < 60;
+}));
+
+foreach ($rows as &$row) {
+ $row['status'] = $row['status'] ?? 'Open';
+ $row['note'] = $row['note'] ?? '';
+}
+
+unset($row);
+
+$canViewGrading = $this->userHasMenuUrl('grading');
+
+return ['kind' => 'view', 'view' => 'grading/below_sixty', 'data' => [
+ 'rows' => $rows,
+ 'semester' => $semester,
+ 'schoolYear' => $schoolYear,
+ 'schoolYears' => $schoolYears,
+ 'isYearMode' => $isYearMode,
+ 'canViewGrading' => $canViewGrading,
+]];
+}
+
+public function editBelowSixtyEmail(array $params = [])
+{
+ $get = $params['get'] ?? [];
+ $post = $params['post'] ?? [];
+
+ $studentId = (int)($get['student_id'] ?? null);
+ $semester = trim((string)($get['semester'] ?? null));
+ $schoolYear = trim((string)($get['school_year'] ?? null));
+
+ if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing student or term.'];
+ }
+
+ $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
+ if (empty($row)) {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Student record not found for the selected term.'];
+ }
+
+ $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
+ $subject = $this->buildBelowSixtySubject($studentName, $semester, $schoolYear);
+ $parentName = $this->fetchBelowSixtyParentName($studentId);
+
+ $scores = [
+ 'homework_avg' => $row['homework_avg'] ?? null,
+ 'project_avg' => $row['project_avg'] ?? null,
+ 'participation_score' => $row['participation_score'] ?? null,
+ 'test_avg' => $row['test_avg'] ?? null,
+ 'ptap_score' => $row['ptap_score'] ?? null,
+ 'attendance_score' => $row['attendance_score'] ?? null,
+ 'midterm_exam_score' => $row['midterm_exam_score'] ?? null,
+ 'semester_score' => $row['semester_score'] ?? null,
+ ];
+
+ $emailData = [
+ 'title' => $subject,
+ 'parent_name' => $parentName,
+ 'student_name' => $studentName !== '' ? $studentName : 'your student',
+ 'class_section_name' => $row['class_section_name'] ?? '',
+ 'semester' => $semester,
+ 'school_year' => $schoolYear,
+ 'scores' => $scores,
+ 'comment' => $row['comment'] ?? '',
+ 'sent_at' => utc_now(),
+ ];
+
+ $html = view('emails/below_sixty_performance', $emailData, ['saveData' => true]);
+
+ return ['kind' => 'view', 'view' => 'grading/below_sixty_email_editor', 'data' => [
+ 'studentId' => $studentId,
+ 'studentName' => $studentName,
+ 'semester' => $semester,
+ 'schoolYear' => $schoolYear,
+ 'subject' => $subject,
+ 'html' => $html,
+ ]];
+}
+
+public function sendBelowSixtyEmail(array $params = [])
+{
+ $get = $params['get'] ?? [];
+ $post = $params['post'] ?? [];
+
+ $studentId = (int)($post['student_id'] ?? null);
+ $semester = trim((string)($post['semester'] ?? null));
+ $schoolYear = trim((string)($post['school_year'] ?? null));
+ $subjectInput = trim((string)($post['subject'] ?? null));
+ $htmlInput = (string)(($post['html'] ?? null) ?? '');
+
+ if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing student or term.'];
+ }
+
+ $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
+ if (empty($row)) {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Student record not found for the selected term.'];
+ }
+
+ $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
+ $subject = $subjectInput !== ''
+ ? $subjectInput
+ : $this->buildBelowSixtySubject($studentName, $semester, $schoolYear);
+ $scores = [
+ 'homework_avg' => $row['homework_avg'] ?? null,
+ 'project_avg' => $row['project_avg'] ?? null,
+ 'participation_score' => $row['participation_score'] ?? null,
+ 'test_avg' => $row['test_avg'] ?? null,
+ 'ptap_score' => $row['ptap_score'] ?? null,
+ 'attendance_score' => $row['attendance_score'] ?? null,
+ 'midterm_exam_score' => $row['midterm_exam_score'] ?? null,
+ 'semester_score' => $row['semester_score'] ?? null,
+ ];
+
+ $payload = [
+ 'student_id' => $studentId,
+ 'student_name' => $studentName,
+ 'class_section_name' => $row['class_section_name'] ?? '',
+ 'semester' => $semester,
+ 'school_year' => $schoolYear,
+ 'scores' => $scores,
+ 'comment' => $row['comment'] ?? '',
+ 'subject' => $subject,
+ ];
+
+ if (trim($htmlInput) !== '') {
+ $payload['html'] = $htmlInput;
+ }
+
+ Events::trigger('below60.email', $payload);
+
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'status', 'message' => 'Email sent to parent(s).'];
+}
+
+public function updateBelowSixtyStatus(array $params = [])
+{
+ $get = $params['get'] ?? [];
+ $post = $params['post'] ?? [];
+
+ $studentId = (int)($post['student_id'] ?? null);
+ $semester = trim((string)($post['semester'] ?? null));
+ $schoolYear = trim((string)($post['school_year'] ?? null));
+ $status = trim((string)($post['status'] ?? null));
+ $note = trim((string)($post['note'] ?? null));
+
+ if ($studentId <= 0 || $semester === '' || $schoolYear === '' || $status === '') {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing required status data.'];
+ }
+
+ $status = ucfirst(strtolower($status));
+ if (!in_array($status, ['Open', 'Closed'], true)) {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Invalid status.'];
+ }
+
+ $flagModel = new CurrentFlagModel();
+ $semKey = strtolower(trim($semester));
+ $redirectUrl = base_url('grading/below-60');
+ $query = http_build_query([
+ 'semester' => $semester,
+ 'school_year' => $schoolYear,
+ ]);
+ if ($query !== '') {
+ $redirectUrl .= '?' . $query;
+ }
+
+ $existing = $flagModel
+ ->where('student_id', $studentId)
+ ->where('flag', 'grade')
+ ->where('school_year', $schoolYear)
+ ->where('LOWER(TRIM(semester))', $semKey)
+ ->first();
+
+ $userId = (int)(session()->get('user_id') ?? 0) ?: null;
+ $now = utc_now();
+ $ok = true;
+
+ if ($existing) {
+ $data = [
+ 'flag_state' => $status,
+ 'flag_datetime' => $now,
+ 'semester' => $semester,
+ 'school_year' => $schoolYear,
+ 'updated_at' => $now,
+ ];
+ if ($status === 'Open') {
+ $data['updated_by_open'] = $userId;
+ if ($note !== '') {
+ $prev = (string)($existing['open_description'] ?? '');
+ $data['open_description'] = trim($prev . PHP_EOL . $note);
+ }
+ } else {
+ $data['updated_by_closed'] = $userId;
+ if ($note !== '') {
+ $prev = (string)($existing['close_description'] ?? '');
+ $data['close_description'] = trim($prev . PHP_EOL . $note);
+ }
+ }
+ $ok = (bool) $flagModel->update((int)$existing['id'], $data);
+ } else {
+ $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
+ $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
+ $grade = (string)($row['class_section_name'] ?? '');
+ $data = [
+ 'student_id' => $studentId,
+ 'student_name' => $studentName !== '' ? $studentName : 'Student',
+ 'grade' => $grade,
+ 'flag' => 'grade',
+ 'flag_datetime' => $now,
+ 'flag_state' => $status,
+ 'semester' => $semester,
+ 'school_year' => $schoolYear,
+ 'updated_at' => $now,
+ ];
+ if ($status === 'Open') {
+ $data['updated_by_open'] = $userId;
+ if ($note !== '') $data['open_description'] = $note;
+ } else {
+ $data['updated_by_closed'] = $userId;
+ if ($note !== '') $data['close_description'] = $note;
+ }
+ $ok = (bool) $flagModel->insert($data);
+ }
+
+ if (!$ok) {
+ log_message('error', 'updateBelowSixtyStatus failed', [
+ 'student_id' => $studentId,
+ 'semester' => $semester,
+ 'school_year' => $schoolYear,
+ 'status' => $status,
+ 'errors' => $flagModel->errors(),
+ ]);
+ return ['kind' => 'flash', 'redirect' => $redirectUrl, 'type' => 'error', 'message' => 'Failed to update status.'];
+ }
+
+ return ['kind' => 'flash', 'redirect' => $redirectUrl, 'type' => 'status', 'message' => 'Status updated.'];
+}
+
+public function scheduleBelowSixtyPage(array $params = [])
+{
+ $get = $params['get'] ?? [];
+ $post = $params['post'] ?? [];
+
+ $studentId = (int)(($get['student_id'] ?? null) ?? 0);
+ $semester = trim((string)(($get['semester'] ?? null) ?? ''));
+ $schoolYear = trim((string)(($get['school_year'] ?? null) ?? ''));
+
+ if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing student or term.'];
+ }
+
+ $context = $this->fetchBelowSixtyMeetingContext($studentId, $schoolYear, $semester);
+ if (empty($context)) {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Student record not found for the selected term.'];
+ }
+
+ return ['kind' => 'view', 'view' => 'grading/schedule_meeting', 'data' => [
+ 'studentId' => $studentId,
+ 'studentName' => $context['student_name'],
+ 'parentName' => $context['parent_name'],
+ 'classSection' => $context['class_section_name'],
+ 'semester' => $semester,
+ 'schoolYear' => $schoolYear,
+ ]];
+}
+
+public function saveBelowSixtyMeeting(array $params = [])
+{
+ $get = $params['get'] ?? [];
+ $post = $params['post'] ?? [];
+
+ $studentId = (int)($post['student_id'] ?? null);
+ $semester = trim((string)($post['semester'] ?? null));
+ $schoolYear = trim((string)($post['school_year'] ?? null));
+ $date = trim((string)($post['date'] ?? null));
+ $time = trim((string)($post['time'] ?? null));
+ $notes = trim((string)($post['notes'] ?? null));
+
+ if ($studentId <= 0 || $semester === '' || $schoolYear === '' || $date === '') {
+ return ['kind' => 'flash', 'redirect' => 'back', 'withInput' => true, 'type' => 'error', 'message' => 'Missing required fields.'];
+ }
+
+ $context = $this->fetchBelowSixtyMeetingContext($studentId, $schoolYear, $semester);
+ if (empty($context)) {
+ return ['kind' => 'flash', 'redirect' => 'back', 'withInput' => true, 'type' => 'error', 'message' => 'Student record not found for the selected term.'];
+ }
+
+ $studentName = $context['student_name'];
+ $parentName = $context['parent_name'];
+ $classSection = $context['class_section_name'];
+ $timeLabel = $time !== '' ? (' ' . $time) : '';
+
+ $title = 'Parent Meeting: ' . $parentName . ' — ' . $studentName;
+ if ($time !== '') {
+ $title .= ' (' . $time . ')';
+ }
+
+ $descriptionParts = [];
+ $descriptionParts[] = 'Student: ' . $studentName;
+ $descriptionParts[] = 'Parent: ' . $parentName;
+ if ($classSection !== '') {
+ $descriptionParts[] = 'Section: ' . $classSection;
+ }
+ $descriptionParts[] = 'Date/Time: ' . $date . $timeLabel;
+ if ($notes !== '') {
+ $descriptionParts[] = 'Notes: ' . $notes;
+ }
+ $description = implode("\n", $descriptionParts);
+
+ $data = [
+ 'student_id' => $studentId,
+ 'parent_user_id' => $context['parent_user_id'] ?? null,
+ 'parent_name' => $parentName,
+ 'student_name' => $studentName,
+ 'class_section_name' => $classSection,
+ 'date' => $date,
+ 'time' => $time !== '' ? $time : null,
+ 'notes' => $notes !== '' ? $notes : null,
+ 'semester' => $semester,
+ 'school_year' => $schoolYear,
+ 'status' => 'scheduled',
+ 'created_by' => (int)(session()->get('user_id') ?? 0) ?: null,
+ ];
+
+ $ok = $this->parentMeetingModel->insert($data);
+ if ($ok) {
+ $query = http_build_query([
+ 'semester' => $semester,
+ 'school_year' => $schoolYear,
+ ]);
+ return ['kind' => 'flash', 'redirect' => base_url('grading/below-60') . ($query ? ('?' . $query) : ''), 'type' => 'status', 'message' => 'Meeting scheduled and added to calendars.'];
+ }
+
+ return ['kind' => 'flash', 'redirect' => 'back', 'withInput' => true, 'type' => 'error', 'message' => 'Failed to schedule the meeting.'];
+}
+
+ public function belowSixtyDecisionsPage(array $params = [])
+{
+ $get = $params['get'] ?? [];
+ $post = $params['post'] ?? [];
+
+$configuredYear = (string) ($params['school_year'] ?? $this->schoolYear);
+$isReadonly = (bool) ($params['is_readonly'] ?? false);
+
+$schoolYear = trim((string)(($get['school_year'] ?? null) ?? ''));
+
+if ($schoolYear === '') {
+ $schoolYear = $configuredYear;
+}
+
+// This page is whole-year only.
+$semester = 'year';
+
+$schoolYears = $this->getSchoolYearsForScores($schoolYear);
+
+$db = $this->db;
+
+/*
+ * Whole-year score source:
+ * Fall semester_score + Spring semester_score / 2
+ *
+ * Only students with BOTH Fall and Spring scores are included.
+ * Only students with year_score < 60 are listed.
+ */
+$scoreRows = $db->table('semester_scores ss')
+ ->select([
+ 's.id AS student_id',
+ 's.firstname',
+ 's.lastname',
+ 's.age',
+ 's.school_id',
+ 's.is_active',
+ 'cs.class_section_name',
+ 'e.enrollment_status',
+ 'e.is_withdrawn',
+ 'LOWER(TRIM(ss.semester)) AS sem_key',
+ 'ss.semester_score',
+ ])
+ ->join('students s', 's.id = ss.student_id', 'inner')
+ ->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $this->db->escape($schoolYear), 'left')
+ ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
+ ->groupStart()
+ ->where('s.is_active', 1)
+ ->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
+ ->orWhere('e.is_withdrawn', 1)
+ ->groupEnd()
+ ->where('ss.school_year', $schoolYear)
+ ->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring'])
+ ->where('ss.semester_score IS NOT NULL', null, false)
+ ->orderBy('cs.class_section_name', 'ASC')
+ ->orderBy('s.lastname', 'ASC')
+ ->orderBy('s.firstname', 'ASC')
+ ->get()
+ ->getResultArray();
+
+$studentMap = [];
+
+foreach ($scoreRows as $row) {
+ $sid = (int)($row['student_id'] ?? 0);
+
+ if ($sid <= 0) {
+ continue;
+ }
+
+ if (!isset($studentMap[$sid])) {
+ $studentMap[$sid] = [
+ 'student_id' => $sid,
+ 'school_id' => $row['school_id'] ?? '',
+ 'firstname' => $row['firstname'] ?? '',
+ 'lastname' => $row['lastname'] ?? '',
+ 'age' => $row['age'] ?? null,
+ 'class_section_name' => $row['class_section_name'] ?? '',
+ 'fall_score' => null,
+ 'spring_score' => null,
+ 'year_score' => null,
+ ];
+ }
+
+ $semKey = strtolower(trim((string)($row['sem_key'] ?? '')));
+ $score = is_numeric($row['semester_score']) ? (float)$row['semester_score'] : null;
+
+ if ($score === null) {
+ continue;
+ }
+
+ if ($semKey === 'fall') {
+ $studentMap[$sid]['fall_score'] = $score;
+ } elseif ($semKey === 'spring') {
+ $studentMap[$sid]['spring_score'] = $score;
+ }
+}
+
+$rows = [];
+
+foreach ($studentMap as $sid => $student) {
+ $fall = $student['fall_score'];
+ $spring = $student['spring_score'];
+
+ // Whole-year result requires both semesters.
+ if ($fall === null || $spring === null) {
+ continue;
+ }
+
+ $yearScore = round(($fall + $spring) / 2, 2);
+
+ if ($yearScore >= 60) {
+ continue;
+ }
+
+ $student['year_score'] = $yearScore;
+ $rows[$sid] = $student;
+}
+
+$studentIds = array_keys($rows);
+
+/*
+ * Load saved below-60 manual decisions.
+ * These are year-level decisions now.
+ */
+$decisionMap = [];
+
+if (!empty($studentIds)) {
+ $belowDecModel = new BelowSixtyDecisionModel();
+
+ $decisionRows = $belowDecModel
+ ->whereIn('student_id', $studentIds)
+ ->where('semester', 'year')
+ ->where('school_year', $schoolYear)
+ ->findAll();
+
+ foreach ($decisionRows as $d) {
+ $sid = (int)($d['student_id'] ?? 0);
+
+ if ($sid > 0) {
+ $decisionMap[$sid] = $d;
+ }
+ }
+}
+
+foreach ($rows as $sid => &$row) {
+ $row['decision'] = $decisionMap[$sid]['decision'] ?? '';
+ $row['decision_notes'] = $decisionMap[$sid]['notes'] ?? '';
+}
+
+unset($row);
+
+/*
+ * Load final generated whole-year decisions from student_decisions.
+ * This table is now year-based, so do NOT filter by semester.
+ */
+$finalDecisionMap = [];
+
+if (!empty($studentIds)) {
+ $finalRows = $db->table('student_decisions')
+ ->whereIn('student_id', $studentIds)
+ ->where('school_year', $schoolYear)
+ ->get()
+ ->getResultArray();
+
+ foreach ($finalRows as $fr) {
+ $sid = (int)($fr['student_id'] ?? 0);
+
+ if ($sid > 0) {
+ $finalDecisionMap[$sid] = $fr;
+ }
+ }
+}
+
+foreach ($rows as $sid => &$row) {
+ $row['consolidated_decision'] = $finalDecisionMap[$sid]['decision'] ?? null;
+
+ if (
+ isset($finalDecisionMap[$sid]['year_score'])
+ && $finalDecisionMap[$sid]['year_score'] !== ''
+ && is_numeric($finalDecisionMap[$sid]['year_score'])
+ ) {
+ $row['year_score'] = round((float)$finalDecisionMap[$sid]['year_score'], 2);
+ }
+}
+
+unset($row);
+
+/*
+ * Load certificate numbers.
+ */
+$certMap = [];
+
+if (!empty($studentIds)) {
+ $certRows = $db->table('certificate_records')
+ ->select('student_id, certificate_number, issued_at')
+ ->where('school_year', $schoolYear)
+ ->whereIn('student_id', $studentIds)
+ ->orderBy('issued_at', 'DESC')
+ ->get()
+ ->getResultArray();
+
+ foreach ($certRows as $cr) {
+ $sid = (int)($cr['student_id'] ?? 0);
+
+ if ($sid > 0 && !isset($certMap[$sid])) {
+ $certMap[$sid] = (string)($cr['certificate_number'] ?? '');
+ }
+ }
+}
+
+foreach ($rows as $sid => &$row) {
+ $row['certificate_number'] = $certMap[$sid] ?? '';
+}
+
+unset($row);
+
+// Re-index for the view.
+$rows = array_values($rows);
+
+$canViewGrading = $this->userHasMenuUrl('grading');
+
+return ['kind' => 'view', 'view' => 'grading/below_sixty_decisions', 'data' => [
+ 'rows' => $rows,
+ 'semester' => $semester,
+ 'schoolYear' => $schoolYear,
+ 'schoolYears' => $schoolYears,
+ 'canViewGrading' => $canViewGrading,
+ 'isEditable' => ! $isReadonly,
+]];
+}
+
+public function saveBelowSixtyDecision(array $params = [])
+{
+ $get = $params['get'] ?? [];
+ $post = $params['post'] ?? [];
+
+$configuredYear = (string) ($params['school_year'] ?? $this->schoolYear);
+$studentId = (int)(($post['student_id'] ?? null) ?? 0);
+$semester = strtolower(trim((string)(($post['semester'] ?? null) ?? 'year')));
+$schoolYear = trim((string)(($post['school_year'] ?? null) ?? ''));
+$decision = trim((string)(($post['decision'] ?? null) ?? ''));
+$notes = trim((string)(($post['notes'] ?? null) ?? ''));
+
+if ($studentId <= 0 || $schoolYear === '') {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing student or school year.'];
+}
+
+if ($schoolYear !== $configuredYear) {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Selected school year does not match the submitted decision.'];
+}
+
+$schoolYearContext = service('schoolYearContext')->forYearName($schoolYear);
+service('schoolYearWriteGuard')->assertWritable($schoolYearContext);
+
+// This decision page should feed certificate decisions as whole-year decisions.
+// Force year mode here so certificate logic receives final year decision.
+$semester = 'year';
+
+$db = \Config\Database::connect();
+
+/*
+ * 1. Save/update the manual decision in below_sixty_decisions.
+ * This keeps your below-60 page history working.
+ */
+$belowModel = new \App\Models\BelowSixtyDecisionModel();
+
+$existingBelow = $belowModel
+ ->where('student_id', $studentId)
+ ->where('semester', $semester)
+ ->where('school_year', $schoolYear)
+ ->first();
+
+$belowPayload = [
+ 'student_id' => $studentId,
+ 'semester' => $semester,
+ 'school_year' => $schoolYear,
+ 'decision' => $decision,
+ 'notes' => $notes,
+];
+
+if ($existingBelow) {
+ $belowModel->update((int)$existingBelow['id'], $belowPayload);
+} else {
+ $belowModel->insert($belowPayload);
+}
+
+/*
+ * 2. Calculate the student's whole-year score.
+ * Certificate page uses student_decisions.year_score.
+ */
+$scoreRows = $db->table('semester_scores ss')
+ ->select([
+ 'LOWER(TRIM(ss.semester)) AS sem_key',
+ 'ss.semester_score',
+ 'cs.class_section_name',
+ ])
+ ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
+ ->where('ss.student_id', $studentId)
+ ->where('ss.school_year', $schoolYear)
+ ->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring'])
+ ->where('ss.semester_score IS NOT NULL', null, false)
+ ->orderBy('ss.updated_at', 'DESC')
+ ->orderBy('ss.id', 'DESC')
+ ->get()
+ ->getResultArray();
+
+$fallScore = null;
+$springScore = null;
+$classSectionName = null;
+
+foreach ($scoreRows as $sr) {
+ $semKey = strtolower(trim((string)($sr['sem_key'] ?? '')));
+ $score = is_numeric($sr['semester_score'] ?? null) ? (float)$sr['semester_score'] : null;
+
+ if ($classSectionName === null && !empty($sr['class_section_name'])) {
+ $classSectionName = (string)$sr['class_section_name'];
+ }
+
+ if ($semKey === 'fall' && $fallScore === null) {
+ $fallScore = $score;
+ }
+
+ if ($semKey === 'spring' && $springScore === null) {
+ $springScore = $score;
+ }
+}
+
+if ($fallScore !== null && $springScore !== null) {
+ $yearScore = round(($fallScore + $springScore) / 2, 2);
+} elseif ($fallScore !== null) {
+ $yearScore = round($fallScore, 2);
+} elseif ($springScore !== null) {
+ $yearScore = round($springScore, 2);
+} else {
+ $yearScore = null;
+}
+
+/*
+ * 3. Sync into student_decisions.
+ * This is the part your certificate page needs.
+ *
+ * student_decisions is now year-based:
+ * - no semester
+ * - no semester_score
+ * - uses year_score
+ */
+$source = $decision === '' ? 'pending' : 'manual';
+
+$studentDecisionPayload = [
+ 'student_id' => $studentId,
+ 'school_year' => $schoolYear,
+ 'class_section_name' => $classSectionName,
+ 'year_score' => $yearScore,
+ 'decision' => $decision !== '' ? $decision : null,
+ 'source' => $source,
+ 'notes' => $notes !== '' ? $notes : null,
+ 'generated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
+];
+
+$existingStudentDecision = $db->table('student_decisions')
+ ->where('student_id', $studentId)
+ ->where('school_year', $schoolYear)
+ ->get()
+ ->getRowArray();
+
+if ($existingStudentDecision) {
+ $db->table('student_decisions')
+ ->where('id', (int)$existingStudentDecision['id'])
+ ->update($studentDecisionPayload);
+} else {
+ $db->table('student_decisions')
+ ->insert($studentDecisionPayload);
+}
+
+$query = http_build_query([
+ 'semester' => 'year',
+ 'school_year' => $schoolYear,
+]);
+
+return ['kind' => 'flash', 'redirect' => base_url('grading/below-60/decisions') . '?' . $query, 'type' => 'status', 'message' => 'Decision saved and certificate decision updated.'];
+}
+
+public function studentDecisionDetails(array $params = [])
+{
+ $get = $params['get'] ?? [];
+ $post = $params['post'] ?? [];
+
+ $studentId = (int)($get['student_id'] ?? null);
+ $schoolYear = trim((string)($get['school_year'] ?? null));
+
+ if ($studentId <= 0 || $schoolYear === '') {
+ return ['kind' => 'json', 'status' => 400, 'data' => ['error' => 'Missing student or school year.']];
+ }
+
+ return ['kind' => 'json', 'status' => 200, 'data' => [
+ 'semesters' => $this->fetchAllSemestersForStudent($studentId, $schoolYear),
+ ]];
+}
+
+public function previewBelowSixtyDecisionEmail(array $params = [])
+{
+ $get = $params['get'] ?? [];
+ $post = $params['post'] ?? [];
+
+$studentId = (int)(($get['student_id'] ?? null) ?? 0);
+$schoolYear = trim((string)(($get['school_year'] ?? null) ?? ''));
+
+if ($studentId <= 0 || $schoolYear === '') {
+ return ['kind' => 'json', 'status' => 200, 'data' => [
+ 'error' => 'Missing student or school year.',
+ ]];
+}
+
+/*
+ * Whole-year decision email.
+ * Do NOT query semester_scores.semester = "year".
+ * The Details button uses fetchAllSemestersForStudent(), so this email does too.
+ */
+$context = $this->getBelowSixtyDecisionEmailContext($studentId, $schoolYear);
+
+if (empty($context['student'])) {
+ return ['kind' => 'json', 'status' => 200, 'data' => [
+ 'error' => 'Student not found.',
+ ]];
+}
+
+if (empty($context['decision_row']) || trim((string)($context['decision_row']['decision'] ?? '')) === '') {
+ return ['kind' => 'json', 'status' => 200, 'data' => [
+ 'error' => 'No saved decision found for this student. Save a decision first.',
+ ]];
+}
+
+$studentName = (string)($context['student_name'] ?? 'Student');
+$classSectionName = (string)($context['class_section_name'] ?? '');
+$decisionRow = $context['decision_row'];
+
+$decision = trim((string)($decisionRow['decision'] ?? ''));
+$notes = trim((string)($decisionRow['notes'] ?? ''));
+
+$fallScore = $context['fall_score'] ?? null;
+$springScore = $context['spring_score'] ?? null;
+$yearScore = $context['year_score'] ?? null;
+
+/*
+ * Same data used by the Details modal.
+ */
+$allSemesters = $context['all_semesters'] ?? [];
+
+if (empty($allSemesters)) {
+ $allSemesters = $this->fetchAllSemestersForStudent($studentId, $schoolYear);
+}
+
+$fallText = $fallScore !== null
+ ? number_format((float)$fallScore, 2)
+ : 'N/A';
+
+$springText = $springScore !== null
+ ? number_format((float)$springScore, 2)
+ : 'N/A';
+
+$yearText = $yearScore !== null
+ ? number_format((float)$yearScore, 2)
+ : 'N/A';
+
+$subject = 'Whole Year Academic Decision — ' . $studentName . ' (' . $schoolYear . ')';
+
+$html = '
+ Dear Parent/Guardian,
+
+
+ This message is regarding ' . esc($studentName) . '
+ for the ' . esc($schoolYear) . ' school year.
+
+
+
+ Class: ' . esc($classSectionName !== '' ? $classSectionName : 'N/A') . '
+ Fall Score: ' . esc($fallText) . '
+ Spring Score: ' . esc($springText) . '
+ Whole-Year Score: ' . esc($yearText) . '
+ Decision: ' . esc($decision) . '
+
+';
+
+if ($notes !== '') {
+ $html .= '
+
+ Decision Notes:
+ ' . nl2br(esc($notes)) . '
+
+ ';
+}
+
+/*
+ * Add the exact Details-button score/comment content into the email.
+ */
+$html .= $this->buildDecisionEmailDetailsHtml($allSemesters);
+
+$html .= '
+
+ Please contact the school administration if you have any questions.
+
+
+
+ Regards,
+ Al Rahma Sunday School
+
+';
+
+return ['kind' => 'json', 'status' => 200, 'data' => [
+ 'ok' => true,
+ 'subject' => $subject,
+ 'html' => $html,
+ 'decision' => $decision,
+ 'score' => $yearScore,
+]];
+}
+
+public function sendBelowSixtyDecisionEmail(array $params = [])
+{
+ $get = $params['get'] ?? [];
+ $post = $params['post'] ?? [];
+
+$studentId = (int)(($post['student_id'] ?? null) ?? 0);
+$schoolYear = trim((string)(($post['school_year'] ?? null) ?? ''));
+$subjectInput = trim((string)(($post['subject'] ?? null) ?? ''));
+$htmlInput = (string)(($post['html'] ?? null) ?? '');
+
+if ($studentId <= 0 || $schoolYear === '') {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing student or school year.'];
+}
+
+/*
+ * Whole-year decision email.
+ * Do NOT use sendBelowSixtyEmail(), because that one is semester-score based.
+ */
+$semester = 'year';
+
+$context = $this->getBelowSixtyDecisionEmailContext($studentId, $schoolYear);
+
+if (empty($context['student'])) {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Student not found.'];
+}
+
+if (empty($context['decision_row']) || trim((string)($context['decision_row']['decision'] ?? '')) === '') {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'No saved decision found for this student.'];
+}
+
+$studentName = $context['student_name'];
+$classSectionName = $context['class_section_name'];
+$decisionRow = $context['decision_row'];
+
+$subject = $subjectInput !== ''
+ ? $subjectInput
+ : 'Whole Year Academic Decision — ' . $studentName . ' (' . $schoolYear . ')';
+
+$payload = [
+ 'student_id' => $studentId,
+ 'student_name' => $studentName,
+ 'class_section_name' => $classSectionName,
+ 'semester' => 'year',
+ 'school_year' => $schoolYear,
+ 'decision' => (string)($decisionRow['decision'] ?? ''),
+ 'notes' => (string)($decisionRow['notes'] ?? ''),
+ 'subject' => $subject,
+ 'scores' => [
+ 'fall_score' => $context['fall_score'],
+ 'spring_score' => $context['spring_score'],
+ 'year_score' => $context['year_score'],
+ ],
+ 'all_semesters' => $context['all_semesters'],
+];
+
+if (trim($htmlInput) !== '') {
+ $payload['html'] = $htmlInput;
+}
+
+Events::trigger('below60.decision_email', $payload);
+
+$query = http_build_query([
+ 'semester' => 'year',
+ 'school_year' => $schoolYear,
+]);
+
+return ['kind' => 'flash', 'redirect' => base_url('grading/below-60/decisions') . '?' . $query, 'type' => 'status', 'message' => 'Decision email sent to parent(s).'];
+}
+
+public function fetchBelowSixtyRows(string $schoolYear, string $semester): array
+{
+ $isYearMode = strtolower(trim($semester)) === 'year';
+
+ if ($isYearMode) {
+ $rows = $this->db->table('semester_scores ss')
+ ->select('s.id AS student_id')
+ ->select('s.school_id')
+ ->select('s.firstname')
+ ->select('s.lastname')
+ ->select('MAX(s.is_active) AS is_active', false)
+ ->select('cs.class_section_name')
+ ->select("'year' AS semester", false)
+ ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.homework_avg END) AS fall_homework_avg", false)
+ ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.homework_avg END) AS spring_homework_avg", false)
+ ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.project_avg END) AS fall_project_avg", false)
+ ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.project_avg END) AS spring_project_avg", false)
+ ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.participation_score END) AS fall_participation_score", false)
+ ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.participation_score END) AS spring_participation_score", false)
+ ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN COALESCE(ss.test_avg, ss.quiz_avg) END) AS fall_test_avg", false)
+ ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN COALESCE(ss.test_avg, ss.quiz_avg) END) AS spring_test_avg", false)
+ ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.ptap_score END) AS fall_ptap_score", false)
+ ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.ptap_score END) AS spring_ptap_score", false)
+ ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.attendance_score END) AS fall_attendance_score", false)
+ ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.attendance_score END) AS spring_attendance_score", false)
+ ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.midterm_exam_score END) AS fall_midterm_exam_score", false)
+ ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.midterm_exam_score END) AS spring_midterm_exam_score", false)
+ ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.final_exam_score END) AS fall_final_exam_score", false)
+ ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.final_exam_score END) AS spring_final_exam_score", false)
+ ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.semester_score END) AS fall_score", false)
+ ->select("MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.semester_score END) AS spring_score", false)
+ ->select("(
+ MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.semester_score END)
+ + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.semester_score END)
+ ) / 2 AS semester_score", false)
+ ->select("(
+ MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.homework_avg END)
+ + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.homework_avg END)
+ ) / 2 AS homework_avg", false)
+ ->select("(
+ MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.project_avg END)
+ + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.project_avg END)
+ ) / 2 AS project_avg", false)
+ ->select("(
+ MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.participation_score END)
+ + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.participation_score END)
+ ) / 2 AS participation_score", false)
+ ->select("(
+ MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN COALESCE(ss.test_avg, ss.quiz_avg) END)
+ + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN COALESCE(ss.test_avg, ss.quiz_avg) END)
+ ) / 2 AS test_avg", false)
+ ->select("(
+ MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.ptap_score END)
+ + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.ptap_score END)
+ ) / 2 AS ptap_score", false)
+ ->select("(
+ MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.attendance_score END)
+ + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.attendance_score END)
+ ) / 2 AS attendance_score", false)
+ ->select("(
+ MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.midterm_exam_score END)
+ + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.midterm_exam_score END)
+ ) / 2 AS midterm_exam_score", false)
+ ->select("(
+ MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'fall' THEN ss.final_exam_score END)
+ + MAX(CASE WHEN LOWER(TRIM(ss.semester)) = 'spring' THEN ss.final_exam_score END)
+ ) / 2 AS final_exam_score", false)
+ ->select('MAX(e.enrollment_status) AS enrollment_status, MAX(e.is_withdrawn) AS is_withdrawn', false)
+ ->join('students s', 's.id = ss.student_id', 'inner')
+ ->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $this->db->escape($schoolYear), 'left')
+ ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
+ ->groupStart()
+ ->where('s.is_active', 1)
+ ->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
+ ->orWhere('e.is_withdrawn', 1)
+ ->groupEnd()
+ ->where('ss.school_year', $schoolYear)
+ ->where('ss.semester_score IS NOT NULL', null, false)
+ ->where("LOWER(TRIM(ss.semester)) IN ('fall', 'spring')", null, false)
+ ->groupBy('s.id, s.school_id, s.firstname, s.lastname, ss.class_section_id, cs.class_section_name')
+ ->having('fall_score IS NOT NULL', null, false)
+ ->having('spring_score IS NOT NULL', null, false)
+ ->having('semester_score <', 60)
+ ->orderBy('s.lastname', 'ASC')
+ ->orderBy('s.firstname', 'ASC')
+ ->get()
+ ->getResultArray();
+
+ foreach ($rows as &$row) {
+ $row['comment'] = '';
+ $row['status'] = 'Open';
+ $row['note'] = '';
+ }
+ unset($row);
+
+ return $rows;
+ }
+
+ $semesterKey = 'fall';
+ $builder = $this->db->table('semester_scores ss')
+ ->select([
+ 's.id AS student_id',
+ 's.school_id',
+ 's.firstname',
+ 's.lastname',
+ 's.is_active',
+ 'cs.class_section_name',
+ 'ss.semester',
+ 'ss.homework_avg',
+ 'ss.project_avg',
+ 'ss.participation_score',
+ 'COALESCE(ss.test_avg, ss.quiz_avg) AS test_avg',
+ 'ss.ptap_score',
+ 'ss.attendance_score',
+ 'ss.midterm_exam_score',
+ 'ss.final_exam_score',
+ 'ss.semester_score',
+ 'e.enrollment_status',
+ 'e.is_withdrawn',
+ ])
+ ->join('students s', 's.id = ss.student_id', 'inner')
+ ->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $this->db->escape($schoolYear), 'left')
+ ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
+ ->groupStart()
+ ->where('s.is_active', 1)
+ ->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
+ ->orWhere('e.is_withdrawn', 1)
+ ->groupEnd()
+ ->where('ss.school_year', $schoolYear)
+ ->where('ss.semester_score IS NOT NULL', null, false)
+ ->where('ss.semester_score <', 60)
+ ->where("LOWER(TRIM(ss.semester))", $semesterKey);
+
+ $rows = $builder
+ ->orderBy('s.lastname', 'ASC')
+ ->orderBy('s.firstname', 'ASC')
+ ->orderBy('ss.semester', 'ASC')
+ ->get()
+ ->getResultArray();
+
+ if (empty($rows)) {
+ return [];
+ }
+
+ $studentIds = array_values(array_unique(array_map(
+ static fn($r) => (int)($r['student_id'] ?? 0),
+ $rows
+ )));
+ $studentIds = array_values(array_filter($studentIds, static fn($id) => $id > 0));
+
+ $commentMap = [];
+ if (!empty($studentIds)) {
+ $commentRows = $this->db->table('score_comments')
+ ->select('student_id, semester, comment, created_at')
+ ->where('score_type', 'general')
+ ->where('school_year', $schoolYear)
+ ->whereIn('student_id', $studentIds)
+ ->where("LOWER(TRIM(semester))", $semesterKey)
+ ->orderBy('created_at', 'DESC')
+ ->get()
+ ->getResultArray();
+ foreach ($commentRows as $row) {
+ $sid = (int)($row['student_id'] ?? 0);
+ $sem = strtolower(trim((string)($row['semester'] ?? '')));
+ $key = $sid . '_' . $sem;
+ if ($sid > 0 && !isset($commentMap[$key])) {
+ $commentMap[$key] = (string)($row['comment'] ?? '');
+ }
+ }
+ }
+
+ $statusMap = [];
+ $noteMap = [];
+ if (!empty($studentIds)) {
+ $flagRows = $this->db->table('current_flag')
+ ->select('student_id, semester, flag_state, open_description, close_description')
+ ->where('flag', 'grade')
+ ->where('school_year', $schoolYear)
+ ->whereIn('student_id', $studentIds)
+ ->where("LOWER(TRIM(semester))", $semesterKey)
+ ->get()
+ ->getResultArray();
+ foreach ($flagRows as $row) {
+ $sid = (int)($row['student_id'] ?? 0);
+ if ($sid <= 0) continue;
+ $sem = strtolower(trim((string)($row['semester'] ?? '')));
+ $key = $sid . '_' . $sem;
+ $statusMap[$key] = (string)($row['flag_state'] ?? '');
+ $openNote = trim((string)($row['open_description'] ?? ''));
+ $closeNote = trim((string)($row['close_description'] ?? ''));
+ $noteMap[$key] = [
+ 'open' => $openNote,
+ 'closed' => $closeNote,
+ ];
+ }
+ }
+
+ foreach ($rows as &$row) {
+ $sid = (int)($row['student_id'] ?? 0);
+ $sem = strtolower(trim((string)($row['semester'] ?? '')));
+ $key = $sid . '_' . $sem;
+ $row['comment'] = $commentMap[$key] ?? '';
+ $flagState = strtolower(trim((string)($statusMap[$key] ?? '')));
+ $row['status'] = ($flagState === 'closed' || $flagState === 'canceled') ? 'Closed' : 'Open';
+ $noteBag = $noteMap[$key] ?? ['open' => '', 'closed' => ''];
+ $rawNote = $row['status'] === 'Closed' ? (string)$noteBag['closed'] : (string)$noteBag['open'];
+ if ($rawNote !== '') {
+ $lines = preg_split('/\R/', $rawNote);
+ $lines = array_values(array_filter(array_map('trim', $lines), static fn($val) => $val !== ''));
+ $row['note'] = $lines ? end($lines) : '';
+ } else {
+ $row['note'] = '';
+ }
+ }
+ unset($row);
+
+ return $rows;
+}
+
+public function fetchBelowSixtyEmailRow(int $studentId, string $schoolYear, string $semester): array
+{
+ $semesterKey = strtolower(trim($semester));
+ $row = $this->db->table('semester_scores ss')
+ ->select([
+ 's.id AS student_id',
+ 's.firstname',
+ 's.lastname',
+ 'cs.class_section_name',
+ 'ss.homework_avg',
+ 'ss.project_avg',
+ 'ss.participation_score',
+ 'COALESCE(ss.test_avg, ss.quiz_avg) AS test_avg',
+ 'ss.ptap_score',
+ 'ss.attendance_score',
+ 'ss.midterm_exam_score',
+ 'ss.semester_score',
+ ])
+ ->join('students s', 's.id = ss.student_id', 'inner')
+ ->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $this->db->escape($schoolYear), 'left')
+ ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
+ ->where('ss.school_year', $schoolYear)
+ ->where('ss.student_id', $studentId)
+ ->groupStart()
+ ->where('s.is_active', 1)
+ ->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
+ ->orWhere('e.is_withdrawn', 1)
+ ->groupEnd()
+ ->where("LOWER(TRIM(ss.semester))", $semesterKey)
+ ->get()
+ ->getRowArray();
+
+ if (!$row) return [];
+
+ $commentRow = $this->db->table('score_comments')
+ ->select('comment')
+ ->where('score_type', 'general')
+ ->where('school_year', $schoolYear)
+ ->where("LOWER(TRIM(semester))", $semesterKey)
+ ->where('student_id', $studentId)
+ ->orderBy('created_at', 'DESC')
+ ->get()
+ ->getRowArray();
+ $row['comment'] = (string)($commentRow['comment'] ?? '');
+
+ return $row;
+}
+
+public function fetchAllSemestersForStudent(int $studentId, string $schoolYear): array
+{
+ $rows = $this->db->table('semester_scores ss')
+ ->select([
+ 'ss.semester',
+ 'cs.class_section_name',
+ 'ss.homework_avg',
+ 'ss.project_avg',
+ 'ss.participation_score',
+ 'COALESCE(ss.test_avg, ss.quiz_avg) AS test_avg',
+ 'ss.ptap_score',
+ 'ss.attendance_score',
+ 'ss.midterm_exam_score',
+ 'ss.final_exam_score',
+ 'ss.semester_score',
+ ])
+ ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
+ ->where('ss.student_id', $studentId)
+ ->where('ss.school_year', $schoolYear)
+ ->orderBy('ss.semester', 'ASC')
+ ->get()
+ ->getResultArray();
+
+ $semesters = [];
+ foreach ($rows as $sr) {
+ $sem = ucfirst(strtolower(trim((string)($sr['semester'] ?? ''))));
+ $semesters[$sem] = [
+ 'semester' => $sem,
+ 'class_section_name' => $sr['class_section_name'] ?? '',
+ 'homework_avg' => $sr['homework_avg'] ?? null,
+ 'project_avg' => $sr['project_avg'] ?? null,
+ 'participation_score' => $sr['participation_score'] ?? null,
+ 'test_avg' => $sr['test_avg'] ?? null,
+ 'ptap_score' => $sr['ptap_score'] ?? null,
+ 'attendance_score' => $sr['attendance_score'] ?? null,
+ 'midterm_exam_score' => $sr['midterm_exam_score'] ?? null,
+ 'final_exam_score' => $sr['final_exam_score'] ?? null,
+ 'semester_score' => $sr['semester_score'] ?? null,
+ 'comments' => [],
+ ];
+ }
+
+ if (!empty($semesters)) {
+ $commentRows = $this->db->table('score_comments')
+ ->select('semester, score_type, comment, created_at')
+ ->where('student_id', $studentId)
+ ->where('school_year', $schoolYear)
+ ->where('comment IS NOT NULL', null, false)
+ ->where('comment !=', '')
+ ->orderBy('semester', 'ASC')
+ ->orderBy('score_type', 'ASC')
+ ->orderBy('created_at', 'DESC')
+ ->get()
+ ->getResultArray();
+
+ foreach ($commentRows as $c) {
+ $sem = ucfirst(strtolower(trim((string)($c['semester'] ?? ''))));
+ $type = strtolower(trim((string)($c['score_type'] ?? 'general')));
+ if (isset($semesters[$sem]) && !isset($semesters[$sem]['comments'][$type])) {
+ $semesters[$sem]['comments'][$type] = (string)($c['comment'] ?? '');
+ }
+ }
+ }
+
+ return array_values($semesters);
+}
+
+public function fetchBelowSixtyParentName(int $studentId): string
+{
+ $parentName = 'Parent/Guardian';
+ try {
+ $rows = $this->db->query(
+ "SELECT u.firstname, u.lastname
+ FROM family_students fs
+ JOIN family_guardians fg ON fg.family_id = fs.family_id
+ JOIN users u ON u.id = fg.user_id
+ WHERE fs.student_id = ?
+ ORDER BY fg.is_primary DESC, u.lastname, u.firstname
+ LIMIT 1",
+ [$studentId]
+ )->getResultArray();
+ if (!empty($rows[0])) {
+ $candidate = trim((string)($rows[0]['firstname'] ?? '') . ' ' . (string)($rows[0]['lastname'] ?? ''));
+ if ($candidate !== '') {
+ $parentName = $candidate;
+ }
+ }
+ } catch (\Throwable $e) {
+ }
+ return $parentName;
+}
+
+public function buildBelowSixtySubject(string $studentName, string $semester, string $schoolYear): string
+{
+ $subject = 'Student Performance Alert';
+ if ($studentName !== '') {
+ $subject .= ' — ' . $studentName;
+ }
+ if ($semester !== '' || $schoolYear !== '') {
+ $subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')';
+ }
+ return $subject;
+}
+
+public function fetchBelowSixtyMeetingContext(int $studentId, string $schoolYear, string $semester): array
+{
+ $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
+ if (empty($row)) {
+ return [];
+ }
+
+ $db = $this->db;
+ $parentName = 'Parent/Guardian';
+ $parentUserId = null;
+ try {
+ $pRows = $db->query(
+ "SELECT u.id AS user_id, u.firstname, u.lastname
+ FROM family_students fs
+ JOIN family_guardians fg ON fg.family_id = fs.family_id
+ JOIN users u ON u.id = fg.user_id
+ WHERE fs.student_id = ?
+ ORDER BY fg.is_primary DESC, u.lastname, u.firstname
+ LIMIT 1",
+ [$studentId]
+ )->getResultArray();
+ if (!empty($pRows[0])) {
+ $parentUserId = (int)($pRows[0]['user_id'] ?? 0) ?: null;
+ $parentName = trim((string)($pRows[0]['firstname'] ?? '') . ' ' . (string)($pRows[0]['lastname'] ?? ''));
+ if ($parentName === '') {
+ $parentName = 'Parent/Guardian';
+ }
+ }
+ } catch (\Throwable $e) {
+ }
+
+ // Legacy fallback: students.parent_id
+ if ($parentUserId === null) {
+ try {
+ $srow = $db->query(
+ "SELECT s.parent_id, u.firstname, u.lastname
+ FROM students s
+ LEFT JOIN users u ON u.id = s.parent_id
+ WHERE s.id = ?
+ LIMIT 1",
+ [$studentId]
+ )->getRowArray();
+ if (!empty($srow)) {
+ $parentUserId = (int)($srow['parent_id'] ?? 0) ?: null;
+ $fallbackName = trim((string)($srow['firstname'] ?? '') . ' ' . (string)($srow['lastname'] ?? ''));
+ if ($fallbackName !== '') {
+ $parentName = $fallbackName;
+ }
+ }
+ } catch (\Throwable $e) {
+ }
+ }
+
+ $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
+ return [
+ 'student_name' => $studentName !== '' ? $studentName : 'Student',
+ 'parent_name' => $parentName,
+ 'parent_user_id' => $parentUserId,
+ 'class_section_name' => (string)($row['class_section_name'] ?? ''),
+ ];
+}
+
+public function buildDecisionEmailDetailsHtml(array $semesters): string
+{
+if (empty($semesters)) {
+ return '
+ Detailed Scores and Comments
+ No detailed score data found.
+ ';
+}
+
+$scoreLabels = [
+ 'homework_avg' => 'Homework Avg',
+ 'project_avg' => 'Project Avg',
+ 'participation_score' => 'Participation',
+ 'test_avg' => 'Test Avg',
+ 'ptap_score' => 'PTAP Score',
+ 'attendance_score' => 'Attendance',
+ 'midterm_exam_score' => 'Midterm Score',
+ 'final_exam_score' => 'Final Exam',
+ 'semester_score' => 'Semester Score',
+];
+
+$commentTypeLabels = [
+ 'general' => 'General',
+ 'attendance' => 'Attendance',
+ 'attendance_comment' => 'Attendance',
+ 'midterm' => 'Midterm',
+ 'final' => 'Final Exam',
+ 'ptap' => 'PTAP',
+];
+
+$html = '
+
+ Detailed Scores and Comments
+';
+
+foreach ($semesters as $sem) {
+ $semesterName = trim((string)($sem['semester'] ?? ''));
+
+ if ($semesterName === '') {
+ $semesterName = 'Semester';
+ }
+
+ $classSectionName = trim((string)($sem['class_section_name'] ?? ''));
+
+ $html .= '
+
+ ' . esc($semesterName) . ' Semester ';
+
+ if ($classSectionName !== '') {
+ $html .= ' — ' . esc($classSectionName);
+ }
+
+ $html .= '
+
+
+
+
+
+ Item
+ Score
+
+
+
+ ';
+
+ $hasScoreRow = false;
+
+ foreach ($scoreLabels as $key => $label) {
+ if (!array_key_exists($key, $sem)) {
+ continue;
+ }
+
+ $value = $sem[$key];
+
+ if ($value === null || $value === '') {
+ continue;
+ }
+
+ $scoreText = is_numeric($value)
+ ? number_format((float)$value, 2)
+ : (string)$value;
+
+ $fontWeight = $key === 'semester_score' ? 'font-weight:bold;' : '';
+
+ $html .= '
+
+ ' . esc($label) . '
+ ' . esc($scoreText) . '
+
+ ';
+
+ $hasScoreRow = true;
+ }
+
+ if (!$hasScoreRow) {
+ $html .= '
+
+ No scores recorded.
+
+ ';
+ }
+
+ $html .= '
+
+
+ ';
+
+ $comments = $sem['comments'] ?? [];
+
+ if (is_array($comments) && !empty($comments)) {
+ $deduped = [];
+ $seen = [];
+
+ foreach ($comments as $type => $text) {
+ $text = trim((string)$text);
+
+ if ($text === '') {
+ continue;
+ }
+
+ $label = $commentTypeLabels[$type] ?? (string)$type;
+ $key = $label . '|' . $text;
+
+ if (isset($seen[$key])) {
+ continue;
+ }
+
+ $seen[$key] = true;
+ $deduped[] = [
+ 'label' => $label,
+ 'text' => $text,
+ ];
+ }
+
+ if (!empty($deduped)) {
+ $html .= '
+ Comments
+ ';
+
+ foreach ($deduped as $comment) {
+ $html .= '
+
+ ' . esc($comment['label']) . ':
+ ' . nl2br(esc($comment['text'])) . '
+
+ ';
+ }
+ }
+ }
+}
+
+return $html;
+}
+
+public function getBelowSixtyDecisionEmailContext(int $studentId, string $schoolYear): array
+{
+$student = $this->db->table('students s')
+ ->select([
+ 's.id',
+ 's.firstname',
+ 's.lastname',
+ 's.is_active',
+ ])
+ ->where('s.id', $studentId)
+ ->get()
+ ->getRowArray();
+
+/*
+ * Do not require is_active = 1 here.
+ * You were getting "Student not found" even though the student ID exists.
+ * If the record exists, let the email preview work.
+ */
+if (!$student) {
+ return [
+ 'student' => null,
+ 'decision_row' => null,
+ 'student_name' => '',
+ 'class_section_name' => '',
+ 'fall_score' => null,
+ 'spring_score' => null,
+ 'year_score' => null,
+ 'all_semesters' => [],
+ ];
+}
+
+$studentName = trim((string)($student['firstname'] ?? '') . ' ' . (string)($student['lastname'] ?? ''));
+
+if ($studentName === '') {
+ $studentName = 'Student';
+}
+
+$decisionRow = $this->db->table('below_sixty_decisions')
+ ->where('student_id', $studentId)
+ ->where('school_year', $schoolYear)
+ ->where('semester', 'year')
+ ->get()
+ ->getRowArray();
+
+$scoreRows = $this->db->table('semester_scores ss')
+ ->select([
+ 'LOWER(TRIM(ss.semester)) AS sem_key',
+ 'ss.semester',
+ 'ss.semester_score',
+ 'ss.class_section_id',
+ 'cs.class_section_name',
+ ])
+ ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
+ ->where('ss.student_id', $studentId)
+ ->where('ss.school_year', $schoolYear)
+ ->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring'])
+ ->where('ss.semester_score IS NOT NULL', null, false)
+ ->orderBy('ss.updated_at', 'DESC')
+ ->orderBy('ss.id', 'DESC')
+ ->get()
+ ->getResultArray();
+
+$fallScore = null;
+$springScore = null;
+$classSectionName = '';
+
+foreach ($scoreRows as $sr) {
+ $semKey = strtolower(trim((string)($sr['sem_key'] ?? '')));
+ $score = is_numeric($sr['semester_score'] ?? null) ? (float)$sr['semester_score'] : null;
+
+ if ($classSectionName === '' && !empty($sr['class_section_name'])) {
+ $classSectionName = (string)$sr['class_section_name'];
+ }
+
+ if ($score === null) {
+ continue;
+ }
+
+ if ($semKey === 'fall' && $fallScore === null) {
+ $fallScore = $score;
+ }
+
+ if ($semKey === 'spring' && $springScore === null) {
+ $springScore = $score;
+ }
+}
+
+if ($classSectionName === '') {
+ $enrollment = $this->db->table('student_class sc')
+ ->select('cs.class_section_name')
+ ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
+ ->where('sc.student_id', $studentId)
+ ->where('sc.school_year', $schoolYear)
+ ->orderBy('sc.id', 'DESC')
+ ->get()
+ ->getRowArray();
+
+ $classSectionName = (string)($enrollment['class_section_name'] ?? '');
+}
+
+if ($fallScore !== null && $springScore !== null) {
+ $yearScore = round(($fallScore + $springScore) / 2, 2);
+} elseif ($fallScore !== null) {
+ $yearScore = round($fallScore, 2);
+} elseif ($springScore !== null) {
+ $yearScore = round($springScore, 2);
+} else {
+ $yearScore = null;
+}
+
+return [
+ 'student' => $student,
+ 'decision_row' => $decisionRow,
+ 'student_name' => $studentName,
+ 'class_section_name' => $classSectionName,
+ 'fall_score' => $fallScore,
+ 'spring_score' => $springScore,
+ 'year_score' => $yearScore,
+ 'all_semesters' => $this->fetchAllSemestersForStudent($studentId, $schoolYear),
+];
+}
+
+public function normalizeSemesterInput(?string $semester): ?string
+{
+ if (!is_string($semester)) {
+ return null;
+ }
+ $trimmed = trim($semester);
+ return $trimmed === '' ? null : $trimmed;
+}
+
+public function resolveSemesterSelection(?string $requestedSemester, array $semesterOptions, ?string $fallbackSemester): string
+{
+ if ($requestedSemester !== null) {
+ foreach ($semesterOptions as $option) {
+ if (strcasecmp($option, $requestedSemester) === 0) {
+ return $option;
+ }
+ }
+ return $requestedSemester;
+ }
+ if ($fallbackSemester !== null && $fallbackSemester !== '') {
+ foreach ($semesterOptions as $option) {
+ if (strcasecmp($option, $fallbackSemester) === 0) {
+ return $option;
+ }
+ }
+ return $fallbackSemester;
+ }
+ return $semesterOptions[0] ?? '';
+}
+
+public function getSemestersForSchoolYear(string $schoolYear, ?string $fallbackSemester = null): array
+{
+ $rows = $this->db->table('semester_scores')
+ ->select('DISTINCT semester', false)
+ ->where('semester IS NOT NULL', null, false)
+ ->where('semester != ""', null, false)
+ ->where('school_year', $schoolYear)
+ ->orderBy('semester', 'ASC')
+ ->get()
+ ->getResultArray();
+
+ $semesters = [];
+ foreach ($rows as $row) {
+ $value = trim((string) ($row['semester'] ?? ''));
+ if ($value === '') continue;
+ $semesters[] = $value;
+ }
+
+ if (empty($semesters)) {
+ $semesters = ['Fall', 'Spring'];
+ }
+
+ if ($fallbackSemester !== null && $fallbackSemester !== '' && !in_array($fallbackSemester, $semesters, true)) {
+ array_unshift($semesters, $fallbackSemester);
+ }
+
+ return array_values(array_unique($semesters));
+}
+
+public function getSchoolYearsForScores(?string $fallback = null): array
+{
+ $schoolYears = [];
+ try {
+ $rows = $this->db->table('semester_scores')
+ ->select('DISTINCT school_year', false)
+ ->where('school_year IS NOT NULL', null, false)
+ ->where('school_year != ""', null, false)
+ ->orderBy('school_year', 'DESC')
+ ->get()
+ ->getResultArray();
+ foreach ($rows as $row) {
+ $val = (string)($row['school_year'] ?? '');
+ if ($val !== '') $schoolYears[] = $val;
+ }
+ } catch (\Throwable $e) {
+ }
+ try {
+ $rows2 = $this->db->table('student_class')
+ ->select('DISTINCT school_year', false)
+ ->where('school_year IS NOT NULL', null, false)
+ ->where('school_year != ""', null, false)
+ ->orderBy('school_year', 'DESC')
+ ->get()
+ ->getResultArray();
+ foreach ($rows2 as $row) {
+ $val = (string)($row['school_year'] ?? '');
+ if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
+ }
+ } catch (\Throwable $e) {
+ }
+ if ($fallback && !in_array($fallback, $schoolYears, true)) {
+ array_unshift($schoolYears, $fallback);
+ }
+ return array_values(array_unique($schoolYears));
+}
+
+public function userHasMenuUrl(string $needle): bool
+{
+ $needle = strtolower(trim($needle));
+ if ($needle === '') {
+ return false;
+ }
+
+ $rawRole = session()->get('role');
+ $roles = is_array($rawRole) ? $rawRole : [$rawRole ?? 'guest'];
+ $roles = array_values(array_filter(array_map('strval', $roles)));
+ if (empty($roles)) {
+ return false;
+ }
+
+ $service = new NavbarService();
+ $menu = $service->getMenuForRoles($roles);
+ if (empty($menu)) {
+ return false;
+ }
+
+ $normalize = static function (string $url) use ($needle): string {
+ $url = strtolower(trim($url));
+ if ($url === '') return '';
+ $url = preg_replace('#^https?://[^/]+/#i', '', $url);
+ $url = ltrim($url, '/');
+ return $url;
+ };
+
+ $target = $normalize($needle);
+ $stack = $menu;
+ while (!empty($stack)) {
+ $node = array_shift($stack);
+ if (!empty($node['url'])) {
+ $url = $normalize((string)$node['url']);
+ if ($url !== '' && $url === $target) {
+ return true;
+ }
+ }
+ if (!empty($node['children']) && is_array($node['children'])) {
+ foreach ($node['children'] as $child) {
+ $stack[] = $child;
+ }
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Parses a classSection name into a (classId, label) pair compatible with your Attendance view.
+ * - KG -> (0, 'KG')
+ * - Youth-> (13, 'Youth')
+ * - Grade N -> (N, 'Grade N')
+ * Fallback: tries to extract first number; else returns (99, original).
+ */
+public function parseClassIdFromSectionName(string $name): array
+{
+ $n = trim($name);
+
+ // Common patterns used in your data
+ if (preg_match('/\bKG\b/i', $n)) {
+ return [0, 'KG'];
+ }
+ if (preg_match('/\bYouth\b/i', $n)) {
+ return [13, 'Youth'];
+ }
+ if (preg_match('/Grade\s*(\d+)/i', $n, $m)) {
+ $g = (int)$m[1];
+ return [$g, 'Grade ' . $g];
+ }
+
+ // Fallback: any number present becomes the grade id
+ if (preg_match('/(\d+)/', $n, $m2)) {
+ $g = (int)$m2[1];
+ return [$g, 'Grade ' . $g];
+ }
+
+ // Last fallback
+ return [99, $n];
+}
+
+/**
+ * Resolve class_section_id for a student in the current term.
+ */
+public function resolveClassSectionIdForStudent(int $studentId): int
+{
+ $row = $this->db->table('student_class')
+ ->select('class_section_id')
+ ->where('student_id', $studentId)
+ ->where('school_year', $this->schoolYear)
+ ->where('semester', $this->semester)
+ ->get()->getRow();
+
+ return $row ? (int)$row->class_section_id : 0;
+}
+
+/** Simple slugifier used as array keys for tabs */
+public function slugify(string $s): string
+{
+ $s = strtolower($s);
+ $s = preg_replace('/[^a-z0-9]+/i', '-', $s);
+ return trim($s, '-');
+}
+
+/** Rank: KG first, numbers ascending, Youth last */
+public function rankOf(string $name): int
+{
+ $k = strtolower(trim($name));
+ if ($k === 'kg' || $k === 'kindergarten') return -100;
+ if ($k === 'youth') return 100000;
+ if (preg_match('/\d+/', $k, $m)) return (int)$m[0];
+ return 50000;
+}
+}
diff --git a/app/Services/EnrollmentWithdrawalService.php b/app/Services/EnrollmentWithdrawalService.php
new file mode 100644
index 0000000..37806ac
--- /dev/null
+++ b/app/Services/EnrollmentWithdrawalService.php
@@ -0,0 +1,1013 @@
+db = $db;
+ $this->studentModel = $studentModel;
+ $this->enrollmentModel = $enrollmentModel;
+ $this->studentClassModel = $studentClassModel;
+ $this->classSectionModel = $classSectionModel;
+ $this->userModel = $userModel;
+ $this->invoiceModel = $invoiceModel;
+ $this->refundModel = $refundModel;
+ }
+
+public function buildRoster(string $selectedYear, string $semester): array
+{
+ $this->schoolYear = $selectedYear;
+ $this->semester = $semester;
+ $this->syncReviewDecisionEnrollments($selectedYear);
+
+ $students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
+
+ $removedPriorStatuses = $this->removedPriorYearStudentStatuses($selectedYear);
+ $returningStudentIds = $this->priorYearStudentIds($selectedYear);
+
+ foreach ($students as &$s) {
+ // ===== Ensure IDs needed by the modal =====
+ $s['student_id'] = (int)($s['id'] ?? 0);
+ $priorRemovedStatus = $removedPriorStatuses[$s['student_id']] ?? null;
+ $s['removed_previous_year'] = $priorRemovedStatus !== null ? 'Yes' : 'No';
+ $s['prior_removed_status'] = $priorRemovedStatus;
+
+ // Prefer parent_id; fallback to secondparent_user_id if present
+ if (empty($s['parent_id']) && !empty($s['secondparent_user_id'])) {
+ $s['parent_id'] = (int)$s['secondparent_user_id'];
+ } else {
+ $s['parent_id'] = (int)($s['parent_id'] ?? 0);
+ }
+
+ // ===== Parent display + sort keys (keep existing behavior) =====
+ $pf = trim((string)($s['parent_firstname'] ?? ''));
+ $pl = trim((string)($s['parent_lastname'] ?? ''));
+
+ // Fallback: if only a single full name exists
+ if ($pf === '' && $pl === '' && !empty($s['parent_fullname'])) {
+ $parts = preg_split('/\s+/', trim((string)$s['parent_fullname']), 2);
+ $pf = $parts[0] ?? '';
+ $pl = $parts[1] ?? '';
+ }
+
+ $s['parent_label'] = trim($pf . ' ' . $pl);
+ $s['parent_sort'] = trim(($pl !== '' ? $pl : $pf) . ' ' . $pf);
+
+ if ($s['parent_label'] === '') {
+ $s['parent_label'] = 'Unknown Parent';
+ $s['parent_sort'] = 'ZZZ Unknown Parent';
+ }
+
+ // ===== New-student flags =====
+ $s['is_new'] = (int) ($s['is_new'] ?? 0);
+ if (isset($returningStudentIds[$s['student_id']])) {
+ $s['is_new'] = 0;
+ }
+ $s['new_student'] = $s['is_new'] === 1 ? 'Yes' : 'No';
+
+ // ===== Admission override =====
+ // Enrollment status for selected year
+ $statusForYear = $this->enrollmentModel->getEnrollmentStatus((int)$s['student_id'], $selectedYear);
+ if (!empty($priorRemovedStatus)) {
+ $s['enrollment_status'] = $priorRemovedStatus;
+ } elseif (!empty($statusForYear)) {
+ $s['enrollment_status'] = $statusForYear;
+ } elseif (($s['admission_status'] ?? null) === 'denied') {
+ $s['enrollment_status'] = 'denied';
+ } else {
+ $s['enrollment_status'] = 'admission under review';
+ $s['admission_status'] = 'pending';
+ }
+
+ // ===== Class section name for the selected year =====
+ $name = $this->studentClassModel->getClassSectionsByStudentId((int)$s['student_id'], $selectedYear);
+ $s['class_section'] = $name ?: 'Class not Assigned';
+ $calculatedAge = EnrollmentEligibility::ageOnSeptemberFirst($s['dob'] ?? null, $selectedYear);
+ $s['age'] = $calculatedAge ?? ($s['age'] ?? null);
+
+ // ===== Sortable registration date (for data-order in view) =====
+ $s['registration_date_order'] = !empty($s['registration_date'])
+ ? date('Y-m-d', strtotime($s['registration_date']))
+ : '';
+ }
+ unset($s); // break reference
+
+ // ===== Sort by parent, then student (lastname, firstname) =====
+ usort($students, function (array $a, array $b) {
+ $pa = $a['parent_sort'] ?? '';
+ $pb = $b['parent_sort'] ?? '';
+ if (strcasecmp($pa, $pb) === 0) {
+ $la = $a['lastname'] ?? '';
+ $lb = $b['lastname'] ?? '';
+ $cmp = strcasecmp($la, $lb);
+ if ($cmp !== 0) return $cmp;
+ return strcasecmp($a['firstname'] ?? '', $b['firstname'] ?? '');
+ }
+ return strcasecmp($pa, $pb);
+ });
+
+ $classes = $this->enrollmentClassOptions((string)$selectedYear);
+
+ return [
+ 'students' => $students,
+ 'classes' => $classes,
+ 'selectedYear' => $selectedYear,
+ ];
+}
+
+/**
+ * Show only newly registered students, with contact modal support.
+ *
+ * Route example:
+ * $routes->get('admin/enrollment/new-students', 'EnrollmentController::showNewStudents', ['filter' => 'auth:view_new_students']);
+ */
+public function newStudents(string $schoolYear): array
+{
+ $rows = $this->studentModel->getStudentsWithParentsAndEmergency($schoolYear);
+
+ $newStudents = [];
+ foreach ($rows as $r) {
+ $r['new_student'] = 'Yes';
+ $classSection = $this->studentClassModel->getClassSectionsByStudentId($r['id'], $schoolYear);
+ $enrollmentstatus = $this->enrollmentModel->getEnrollmentStatus($r['id'], $schoolYear);
+ // robust default
+ $r['class_section'] = (isset($classSection) && trim((string)$classSection) !== '')
+ ? $classSection
+ : 'Class not Assigned';
+
+ $r['is_new'] = (int) $r['is_new'];
+ $r['new_student'] = $r['is_new'] === 1 ? "Yes" : "No";
+ $r['modalIdContact'] = 'contact_' . (int)($r['id'] ?? 0);
+ $r['enrollment_status'] = $enrollmentstatus;
+
+ // ✅ Format registration_date for display
+ if (!empty($r['registration_date'])) {
+ try {
+ $r['registration_date'] = (new \DateTime($r['registration_date']))->format('Y-m-d');
+ } catch (\Throwable $e) {
+ $r['registration_date'] = '';
+ }
+ }
+
+ // Age comes directly from DB (already stored in students.age)
+ $newStudents[] = $r;
+ }
+
+ return [
+ 'new_students' => $newStudents,
+ 'total_new' => count($newStudents),
+ ];
+}
+
+//update enrollment status
+public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, string $semester, ?int $performedBy): array
+{
+ $refundService = new FeeCalculationService();
+ $enrollmentStatusService = \Config\Services::enrollmentStatus(false);
+ $performedBy = $performedBy ?: ((int) (session()->get('user_id') ?? 0) ?: null);
+ $this->schoolYear = $schoolYear;
+ $this->semester = $semester;
+
+ if (empty($enrollmentStatuses)) {
+ return ['ok' => false, 'message' => 'No enrollment statuses were submitted.'];
+ }
+
+ $this->db->transStart();
+
+ try {
+ $errors = [];
+
+ // For batching emails: parent -> status -> [students...]
+ $groupsByParentStatus = []; // [parent_id][status][] = ['student_id'=>, 'student_name'=>]
+ $parentInfo = []; // [parent_id] = ['user_id','email','firstname','lastname']
+ $refundParents = []; // parent_id => true (for refund calc)
+ $refundAmountByParent = []; // parent_id => amount
+
+ $validStatuses = EnrollmentStatusService::VALID_STATUSES;
+
+ foreach ($enrollmentStatuses as $studentId => $newEnrollmentStatus) {
+ if (!in_array($newEnrollmentStatus, $validStatuses, true)) {
+ $errors[] = "Invalid enrollment status '$newEnrollmentStatus' for student ID $studentId.";
+ continue;
+ }
+
+ // Map admission_status based on the desired new status (needed for create-or-update)
+ if ($newEnrollmentStatus === 'denied') {
+ $admissionStatus = 'denied';
+ } elseif (in_array($newEnrollmentStatus, ['enrolled', 'payment pending'], true)) {
+ $admissionStatus = 'accepted';
+ } else {
+ $admissionStatus = 'pending';
+ }
+
+ // Current enrollment row
+ $enrollmentRow = $this->db->table('enrollments')
+ ->where('student_id', $studentId)
+ ->where('school_year', $this->schoolYear)
+ ->get()
+ ->getRowArray();
+
+ // If no enrollment found for this student/year, create one so invoice generation has data
+ if (!$enrollmentRow) {
+ $stu = $this->studentModel->find((int)$studentId) ?? [];
+ $parentId = (int)($stu['parent_id'] ?? ($stu['secondparent_user_id'] ?? 0));
+ if (!$parentId) {
+ $errors[] = "No parent ID found for student ID $studentId.";
+ continue;
+ }
+
+ $isWithdrawn = in_array($newEnrollmentStatus, ['withdrawn', 'refund pending', 'withdraw under review'], true) ? 1 : 0;
+
+ $result = $enrollmentStatusService->upsertStatus([
+ 'student_id' => (int)$studentId,
+ 'parent_id' => $parentId,
+ 'school_year' => (string)$this->schoolYear,
+ 'semester' => (string)$this->semester,
+ 'enrollment_date' => local_date(utc_now(), 'Y-m-d'),
+ 'is_withdrawn' => $isWithdrawn,
+ 'enrollment_status' => $newEnrollmentStatus,
+ 'admission_status' => $admissionStatus,
+ 'created_at' => utc_now(),
+ 'updated_at' => utc_now(),
+ ], $performedBy, 'admin_enrollment_withdrawal_handler');
+
+ if ((int) ($result['id'] ?? 0) <= 0) {
+ $errors[] = "Failed to create enrollment for student ID $studentId.";
+ continue;
+ }
+
+ // Group this newly-created change for notifications
+ $studentRow = $this->studentModel->find($studentId) ?? [];
+ $studentName = trim(($studentRow['firstname'] ?? '') . ' ' . ($studentRow['lastname'] ?? '')) ?: "Student #{$studentId}";
+
+ if (!isset($parentInfo[$parentId])) {
+ $p = $this->userModel->find($parentId) ?? [];
+ $parentInfo[$parentId] = [
+ 'user_id' => $p['id'] ?? $parentId,
+ 'email' => $p['email'] ?? null,
+ 'firstname' => $p['firstname'] ?? '',
+ 'lastname' => $p['lastname'] ?? '',
+ ];
+ }
+ $groupsByParentStatus[$parentId][$newEnrollmentStatus][] = [
+ 'student_id' => (int) $studentId,
+ 'student_name' => $studentName,
+ ];
+
+ if ($newEnrollmentStatus === 'refund pending') {
+ $refundParents[$parentId] = true;
+ }
+
+ log_message('info', "Created enrollment for student ID {$studentId} with status {$newEnrollmentStatus} and admission {$admissionStatus}.");
+ if (in_array($newEnrollmentStatus, ['payment pending', 'enrolled'], true)) {
+ $this->applyDistributionDraftToStudentClass((int)$studentId, (string)$this->schoolYear);
+ }
+ continue; // go to next student
+ }
+
+ $oldStatus = $enrollmentRow['enrollment_status'] ?? null;
+ $parentId = $enrollmentRow['parent_id'] ?? null;
+
+ if (!$parentId) {
+ $errors[] = "No parent ID found for student ID $studentId.";
+ continue;
+ }
+
+ // admissionStatus computed above
+
+ if ($oldStatus === $newEnrollmentStatus) {
+ $enrollmentStatusService->upsertStatus([
+ 'id' => (int) $enrollmentRow['id'],
+ 'student_id' => (int) $studentId,
+ 'parent_id' => (int) $parentId,
+ 'school_year' => (string) $this->schoolYear,
+ 'semester' => (string) ($enrollmentRow['semester'] ?? $this->semester),
+ 'enrollment_status' => $newEnrollmentStatus,
+ 'admission_status' => $admissionStatus,
+ 'updated_at' => utc_now(),
+ ], $performedBy, 'admin_enrollment_status_repair');
+ log_message('debug', "No status change for student {$studentId} ({$oldStatus}); repaired activity flag.");
+ if (in_array($newEnrollmentStatus, ['payment pending', 'enrolled'], true)) {
+ $this->applyDistributionDraftToStudentClass((int)$studentId, (string)$this->schoolYear);
+ }
+ continue;
+ }
+
+ $result = $enrollmentStatusService->upsertStatus([
+ 'id' => (int) $enrollmentRow['id'],
+ 'student_id' => (int) $studentId,
+ 'parent_id' => (int) $parentId,
+ 'school_year' => (string) $this->schoolYear,
+ 'semester' => (string) ($enrollmentRow['semester'] ?? $this->semester),
+ 'enrollment_status' => $newEnrollmentStatus,
+ 'admission_status' => $admissionStatus,
+ 'updated_at' => utc_now(),
+ ], $performedBy, 'admin_enrollment_withdrawal_handler');
+
+ if ((int) ($result['id'] ?? 0) <= 0) {
+ $errors[] = "Failed to update enrollment for student ID $studentId.";
+ continue;
+ }
+
+ log_message('info', "Updated enrollment for student ID $studentId: {$oldStatus} → {$newEnrollmentStatus} (admission: {$admissionStatus})");
+ if (in_array($newEnrollmentStatus, ['payment pending', 'enrolled'], true)) {
+ $this->applyDistributionDraftToStudentClass((int)$studentId, (string)$this->schoolYear);
+ }
+
+ // Student name
+ $studentRow = $this->studentModel->find($studentId);
+ $studentName = trim(($studentRow['firstname'] ?? '') . ' ' . ($studentRow['lastname'] ?? '')) ?: "Student #{$studentId}";
+
+ // Cache parent info once
+ if (!isset($parentInfo[$parentId])) {
+ $p = $this->userModel->find($parentId) ?? [];
+ $parentInfo[$parentId] = [
+ 'user_id' => $p['id'] ?? $parentId, // assuming parent_id == user_id
+ 'email' => $p['email'] ?? null,
+ 'firstname' => $p['firstname'] ?? '',
+ 'lastname' => $p['lastname'] ?? '',
+ ];
+ }
+
+ // Group by parent & status for minimal emails
+ $groupsByParentStatus[$parentId][$newEnrollmentStatus][] = [
+ 'student_id' => (int) $studentId,
+ 'student_name' => $studentName,
+ ];
+
+ // Mark for refund calc
+ if ($newEnrollmentStatus === 'refund pending') {
+ $refundParents[$parentId] = true;
+ }
+ }
+
+ // Compute refunds ONCE per parent needing it
+ foreach (array_keys($refundParents) as $pid) {
+ $students = $this->enrollmentModel
+ ->where('parent_id', $pid)
+ ->where('school_year', $this->schoolYear)
+ ->findAll();
+
+ if (empty($students)) {
+ // If a parent is marked for refund but has no enrollments, just log and continue.
+ log_message('info', "No enrollments found for parent ID {$pid} (for refund calc); skipping refund.");
+ continue;
+ }
+
+ $invoice = $this->invoiceModel->where('parent_id', $pid)
+ ->where('school_year', $this->schoolYear)
+ ->orderBy('created_at', 'DESC')
+ ->first();
+
+ if (!$invoice) {
+ $errors[] = "No invoice found for parent ID $pid (for refund calc).";
+ continue;
+ }
+
+ $refundAmount = $refundService->calculateRefund($students, $pid);
+ $refundAmountByParent[$pid] = $refundAmount;
+
+ $existingRefund = $this->refundModel->where('invoice_id', $invoice['id'])->first();
+
+ if ($existingRefund) {
+ $refundId = (int)$existingRefund['id'];
+ $status = strtolower((string)($existingRefund['status'] ?? ''));
+ $isApprovedState = in_array($status, ['approved', 'partial', 'paid', 'partially_paid'], true);
+ $calculatedCents = max(0, (int)round($refundAmount * 100));
+ $paidCents = (new RefundEligibilityService())->getCompletedPayoutTotalCentsForRefund($refundId);
+ $targetCents = $isApprovedState ? max($calculatedCents, $paidCents) : $calculatedCents;
+ $update = [
+ 'refund_amount' => $targetCents / 100,
+ 'updated_by' => session()->get('user_id') ?? null,
+ ];
+ if ($isApprovedState) {
+ $update['approved_amount_cents'] = $targetCents;
+ } else {
+ $update['status'] = 'Pending';
+ $update['requested_amount_cents'] = $targetCents;
+ }
+ if ($isApprovedState && $paidCents > $calculatedCents) {
+ $message = sprintf(
+ 'Completed payouts (%0.2f) exceed recalculated refundable credit (%0.2f).',
+ $paidCents / 100,
+ $calculatedCents / 100
+ );
+ $update['reconciliation_status'] = 'requires_review';
+ $update['reconciliation_reason'] = $message;
+ $update['reconciliation_required_at'] = utc_now();
+ log_message('critical', 'Refund reconciliation required for refund #' . $refundId . ': ' . $message);
+ } else {
+ $update['reconciliation_status'] = null;
+ $update['reconciliation_reason'] = null;
+ $update['reconciliation_required_at'] = null;
+ }
+ $this->refundModel->update($refundId, $update);
+ } else {
+ $this->refundModel->insert([
+ 'parent_id' => $pid,
+ 'school_year' => $invoice['school_year'],
+ 'invoice_id' => $invoice['id'],
+ 'refund_amount' => $refundAmount,
+ 'requested_amount_cents' => (int)round($refundAmount * 100),
+ 'approved_amount_cents' => null,
+ 'currency' => 'USD',
+ 'refund_paid_amount' => 0.0,
+ 'status' => 'Pending',
+ 'source_type' => 'tuition_withdrawal',
+ 'source_id' => (int)$invoice['id'],
+ 'requested_at' => utc_now(),
+ 'updated_by' => session()->get('user_id') ?? null,
+ ]);
+ }
+
+ log_message('info', "Refund of $refundAmount created/updated for invoice ID {$invoice['id']} (parent {$pid}).");
+ }
+
+ $this->db->transComplete();
+
+ if (!$this->db->transStatus()) {
+ return ['ok' => false, 'message' => 'A database error occurred. Changes were rolled back.'];
+ }
+
+ // === AFTER COMMIT: fire specific events, batched per parent/status ===
+ $eventMap = [
+ 'admission under review' => 'admissionUnderReview',
+ 'review & decision' => 'admissionUnderReview',
+ 'payment pending' => 'paymentPending',
+ 'enrolled' => 'studentEnrolled',
+ 'withdraw under review' => 'withdrawUnderReview',
+ 'refund pending' => 'refundPending',
+ 'withdrawn' => 'withdrawn',
+ 'denied' => 'denied',
+ 'waitlist' => 'waitlist',
+ ];
+
+ foreach ($groupsByParentStatus as $pid => $byStatus) {
+ // Common parent data
+ $p = $parentInfo[$pid] ?? ['user_id' => $pid, 'email' => null, 'firstname' => '', 'lastname' => ''];
+
+ // Fetch invoice once for this parent (for payment pending email data if needed)
+ $invoice = $this->invoiceModel->where('parent_id', $pid)
+ ->where('school_year', $this->schoolYear)
+ ->orderBy('created_at', 'DESC')
+ ->first();
+
+ foreach ($byStatus as $status => $studentsArr) {
+ if (empty($eventMap[$status])) {
+ continue; // unknown mapping
+ }
+
+ // Build second arg: student list
+ $studentData = [];
+ foreach ($studentsArr as $s) {
+ $studentData[] = ['name' => $s['student_name'], 'student_id' => $s['student_id']];
+ }
+
+ // Parent payload (first arg)
+ $parentData = [
+ 'user_id' => $p['user_id'],
+ 'email' => $p['email'],
+ 'firstname' => $p['firstname'],
+ 'lastname' => $p['lastname'],
+ 'school_year' => $this->schoolYear,
+ 'portalLink' => base_url('/login'),
+ ];
+
+ // Enrich with status-specific fields
+ if ($status === 'payment pending') {
+ if ($invoice) {
+ $parentData['amount'] = (float) ($invoice['balance'] ?? $invoice['amount_due'] ?? $invoice['total_amount'] ?? 0);
+ $parentData['due_date'] = $invoice['due_date'] ?? null;
+ }
+ } elseif ($status === 'refund pending') {
+ $parentData['amount'] = $refundAmountByParent[$pid] ?? null;
+ }
+
+ $eventName = $eventMap[$status];
+
+ log_message('info', "Triggering event '{$eventName}' for parent {$pid} with " . count($studentData) . " student(s).");
+ Events::trigger($eventName, $parentData, $studentData);
+ }
+ }
+
+ // === Server-side safety net: generate/update invoices for parents whose statuses require it ===
+ try {
+ $needsInvoiceFor = ['payment pending', 'enrolled', 'withdrawn', 'refund pending'];
+ $invCtl = new InvoiceController();
+ foreach ($groupsByParentStatus as $pid => $byStatus) {
+ $statuses = array_keys($byStatus);
+ $requires = array_intersect($statuses, $needsInvoiceFor);
+ if (!empty($requires)) {
+ // Best-effort; ignore response object
+ try {
+ $invCtl->generateInvoice((string)$pid);
+ } catch (\Throwable $e) {
+ log_message('error', 'Invoice fallback generation failed for parent {pid}: {err}', ['pid' => $pid, 'err' => $e->getMessage()]);
+ }
+ }
+ }
+ } catch (\Throwable $e) {
+ log_message('error', 'Invoice fallback block error: ' . $e->getMessage());
+ }
+
+ if (!empty($errors)) {
+ return ['ok' => false, 'message' => implode(' ', $errors)];
+ }
+
+ return ['ok' => true, 'message' => 'Enrollment statuses updated and notifications sent.'];
+ } catch (\Throwable $e) {
+ $this->db->transRollback();
+ log_message('error', 'Enrollment withdrawal error: ' . $e->getMessage());
+ return ['ok' => false, 'message' => 'An unexpected error occurred while processing enrollments.'];
+ }
+}
+
+private function getPreviousSchoolYear(string $schoolYear): string
+{
+ $schoolYear = trim($schoolYear);
+ if ($schoolYear === '') {
+ return '';
+ }
+
+ if (preg_match('/^(\d{4})\s*-\s*(\d{4})$/', $schoolYear, $m)) {
+ return ((int)$m[1] - 1) . '-' . ((int)$m[2] - 1);
+ }
+
+ if (preg_match('/^(\d{4})\s*-\s*(\d{2})$/', $schoolYear, $m)) {
+ $start = (int)$m[1] - 1;
+ $end = (int)$m[2] - 1;
+ if ($end < 0) {
+ $end += 100;
+ }
+ return sprintf('%04d-%02d', $start, $end);
+ }
+
+ if (preg_match('/^\d{4}$/', $schoolYear)) {
+ return (string)((int)$schoolYear - 1);
+ }
+
+ return '';
+}
+
+private function getSchoolYearStartYear(string $schoolYear): ?int
+{
+ $schoolYear = trim($schoolYear);
+ if ($schoolYear === '') {
+ return null;
+ }
+
+ if (preg_match('/^(\d{4})\s*-\s*(\d{4})$/', $schoolYear, $m)) {
+ return (int)$m[1];
+ }
+
+ if (preg_match('/^(\d{4})\s*-\s*(\d{2})$/', $schoolYear, $m)) {
+ return (int)$m[1];
+ }
+
+ if (preg_match('/^\d{4}$/', $schoolYear)) {
+ return (int)$schoolYear;
+ }
+
+ return null;
+}
+
+public function syncReviewDecisionEnrollments(string $selectedYear): void
+{
+ $selectedYear = trim($selectedYear);
+ $sourceYear = $this->getPreviousSchoolYear($selectedYear);
+ if ($selectedYear === '' || $sourceYear === '' || ! $this->db->tableExists('enrollments')) {
+ return;
+ }
+
+ $studentIds = $this->sourceYearStudentIds($sourceYear);
+ if ($studentIds === []) {
+ return;
+ }
+
+ $transitionService = service('enrollmentTransition');
+ $now = utc_now();
+
+ foreach ($studentIds as $studentId) {
+ try {
+ $evaluation = $transitionService->evaluate((int) $studentId, $sourceYear, $selectedYear, 'parent');
+ } catch (\Throwable $e) {
+ log_message('error', 'Review & Decision enrollment sync evaluation failed for student {studentId}: {message}', [
+ 'studentId' => $studentId,
+ 'message' => $e->getMessage(),
+ ]);
+ continue;
+ }
+
+ if (! $this->needsReviewDecisionEnrollment($evaluation)) {
+ continue;
+ }
+
+ $student = $this->studentModel->find((int) $studentId);
+ if (! is_array($student)) {
+ continue;
+ }
+
+ $parentId = (int) ($student['parent_id'] ?? ($student['secondparent_user_id'] ?? 0));
+ if ($parentId <= 0) {
+ log_message('warning', 'Review & Decision enrollment sync skipped student {studentId}: no parent ID.', [
+ 'studentId' => $studentId,
+ ]);
+ continue;
+ }
+
+ $existing = $this->db->table('enrollments')
+ ->select('id, enrollment_status')
+ ->where('student_id', (int) $studentId)
+ ->where('school_year', $selectedYear)
+ ->orderBy('updated_at', 'DESC')
+ ->orderBy('id', 'DESC')
+ ->limit(1)
+ ->get()
+ ->getRowArray();
+
+ if ($existing !== null) {
+ $existingStatus = (string) ($existing['enrollment_status'] ?? '');
+ if ($existingStatus === 'review & decision' || ! in_array($existingStatus, ['', 'admission under review'], true)) {
+ continue;
+ }
+ }
+
+ $payload = [
+ 'student_id' => (int) $studentId,
+ 'parent_id' => $parentId,
+ 'school_year' => $selectedYear,
+ 'semester' => (string) $this->semester,
+ 'source_school_year' => $sourceYear,
+ 'deliberation_decision' => $evaluation['deliberation_decision'] ?? null,
+ 'source_grade_id' => $evaluation['source_grade_id'] ?? null,
+ 'assigned_grade_id' => $evaluation['assigned_grade_id'] ?? null,
+ 'source_class_section_id' => $evaluation['source_class_section_id'] ?? null,
+ 'assigned_class_section_id' => $evaluation['assigned_class_section_id'] ?? null,
+ 'placement_status' => $evaluation['placement_status'] ?? 'not_created',
+ 'age_reference_date' => $evaluation['age_reference_date'] ?? null,
+ 'age_on_reference_date' => $evaluation['age_on_reference_date'] ?? null,
+ 'adult_student' => ! empty($evaluation['adult_student']) ? 1 : 0,
+ 'parent_enrollment_allowed' => ! empty($evaluation['parent_enrollment_allowed']) ? 1 : 0,
+ 'student_self_enrollment_allowed' => ! empty($evaluation['student_self_enrollment_allowed']) ? 1 : 0,
+ 'exception_required' => 1,
+ 'exception_reason' => implode(', ', array_filter(array_column($evaluation['flags'] ?? [], 'flag_type'))) ?: implode(' ', array_map('strval', $evaluation['blockers'] ?? [])),
+ 'enrollment_date' => local_date(utc_now(), 'Y-m-d'),
+ 'enrollment_status' => 'review & decision',
+ 'admission_status' => 'pending',
+ 'is_withdrawn' => 0,
+ 'updated_at' => $now,
+ ];
+ $payload = $this->filterEnrollmentPayloadByColumns($payload);
+
+ if ($existing !== null) {
+ $payload['id'] = (int) $existing['id'];
+ } else {
+ $payload['created_at'] = $now;
+ }
+
+ \Config\Services::enrollmentStatus(false)->upsertStatus(
+ $this->filterEnrollmentPayloadByColumns($payload),
+ (int) (session()->get('user_id') ?? 0) ?: null,
+ 'admin_review_decision_enrollment'
+ );
+ }
+}
+
+private function needsReviewDecisionEnrollment(array $evaluation): bool
+{
+ $decision = (string) ($evaluation['deliberation_decision'] ?? '');
+
+ if (in_array($decision, [
+ DeliberationDecision::EXPELLED,
+ DeliberationDecision::WITHDRAWN,
+ DeliberationDecision::DEFERRED_DECISION,
+ ], true)) {
+ return true;
+ }
+
+ $hasSourceAssignment = (int) ($evaluation['source_class_section_id'] ?? 0) > 0
+ || (int) ($evaluation['source_grade_id'] ?? 0) > 0;
+
+ return $decision === ''
+ && $hasSourceAssignment
+ && array_filter($evaluation['blockers'] ?? []) !== [];
+}
+
+private function sourceYearStudentIds(string $sourceYear): array
+{
+ $studentIds = [];
+
+ foreach (['student_class', 'enrollments', 'student_decisions'] as $table) {
+ if (! $this->db->tableExists($table) || ! $this->db->fieldExists('student_id', $table)) {
+ continue;
+ }
+
+ $yearColumn = match ($table) {
+ 'student_class', 'student_decisions' => 'school_year',
+ default => 'school_year',
+ };
+
+ if (! $this->db->fieldExists($yearColumn, $table)) {
+ continue;
+ }
+
+ $rows = $this->db->table($table)
+ ->select('student_id')
+ ->where($yearColumn, $sourceYear)
+ ->where('student_id IS NOT NULL', null, false)
+ ->get()
+ ->getResultArray();
+
+ foreach ($rows as $row) {
+ $studentId = (int) ($row['student_id'] ?? 0);
+ if ($studentId > 0) {
+ $studentIds[$studentId] = true;
+ }
+ }
+ }
+
+ return array_keys($studentIds);
+}
+
+private function filterEnrollmentPayloadByColumns(array $payload): array
+{
+ foreach (array_keys($payload) as $column) {
+ if (! $this->db->fieldExists($column, 'enrollments')) {
+ unset($payload[$column]);
+ }
+ }
+
+ return $payload;
+}
+
+private function enrollmentClassOptions(string $selectedYear): array
+{
+ $select = ['id', 'class_section_id', 'class_section_name'];
+ $hasSchoolYear = $this->db->fieldExists('school_year', 'classSection');
+ $hasSemester = $this->db->fieldExists('semester', 'classSection');
+
+ if ($hasSchoolYear) {
+ $select[] = 'school_year';
+ }
+ if ($hasSemester) {
+ $select[] = 'semester';
+ }
+
+ $query = $this->classSectionModel
+ ->select(implode(', ', $select))
+ ->orderBy('class_section_name', 'ASC');
+
+ if ($hasSchoolYear && $selectedYear !== '') {
+ $query->where('school_year', $selectedYear);
+ }
+ if ($hasSemester) {
+ $query->where('semester', (string)$this->semester);
+ }
+
+ $classes = $query->findAll();
+
+ if (! empty($classes) || (! $hasSchoolYear && ! $hasSemester)) {
+ return $classes;
+ }
+
+ return $this->classSectionModel
+ ->select('id, class_section_id, class_section_name')
+ ->orderBy('class_section_name', 'ASC')
+ ->findAll();
+}
+
+private function removedPriorYearStudentStatuses(string $selectedYear): array
+{
+ $selectedStartYear = $this->getSchoolYearStartYear($selectedYear);
+ if ($selectedStartYear === null || ! $this->db->tableExists('enrollments')) {
+ return [];
+ }
+
+ $select = ['student_id', 'school_year'];
+ $hasIsWithdrawn = $this->db->fieldExists('is_withdrawn', 'enrollments');
+ $hasEnrollmentStatus = $this->db->fieldExists('enrollment_status', 'enrollments');
+ $hasAdmissionStatus = $this->db->fieldExists('admission_status', 'enrollments');
+
+ if ($hasIsWithdrawn) {
+ $select[] = 'is_withdrawn';
+ }
+ if ($hasEnrollmentStatus) {
+ $select[] = 'enrollment_status';
+ }
+ if ($hasAdmissionStatus) {
+ $select[] = 'admission_status';
+ }
+
+ if (! $hasIsWithdrawn && ! $hasEnrollmentStatus && ! $hasAdmissionStatus) {
+ return [];
+ }
+
+ $builder = $this->db->table('enrollments')
+ ->select(implode(', ', $select))
+ ->where('student_id IS NOT NULL', null, false)
+ ->where('school_year IS NOT NULL', null, false)
+ ->groupStart();
+
+ $hasRemovalCondition = false;
+ if ($hasIsWithdrawn) {
+ $builder->orWhere('is_withdrawn', 1);
+ $hasRemovalCondition = true;
+ }
+
+ if ($hasEnrollmentStatus) {
+ $builder->orWhereIn('enrollment_status', ['withdrawn', 'denied']);
+ $hasRemovalCondition = true;
+ }
+
+ if ($hasAdmissionStatus) {
+ $builder->orWhere('admission_status', 'denied');
+ $hasRemovalCondition = true;
+ }
+
+ $builder->groupEnd();
+ if (! $hasRemovalCondition) {
+ return [];
+ }
+
+ $removedPriorStatuses = [];
+ foreach ($builder->get()->getResultArray() as $row) {
+ $rowYear = $this->getSchoolYearStartYear((string)($row['school_year'] ?? ''));
+ $studentId = (int)($row['student_id'] ?? 0);
+ if ($studentId <= 0 || $rowYear === null || $rowYear >= $selectedStartYear) {
+ continue;
+ }
+
+ $status = $this->priorRemovedEnrollmentStatus($row);
+ if ($status === null) {
+ continue;
+ }
+
+ if (
+ !isset($removedPriorStatuses[$studentId])
+ || $rowYear > (int)$removedPriorStatuses[$studentId]['year']
+ ) {
+ $removedPriorStatuses[$studentId] = [
+ 'year' => $rowYear,
+ 'status' => $status,
+ ];
+ }
+ }
+
+ $statusByStudentId = [];
+ foreach ($removedPriorStatuses as $studentId => $row) {
+ $statusByStudentId[(int)$studentId] = (string)$row['status'];
+ }
+
+ return $statusByStudentId;
+}
+
+private function priorRemovedEnrollmentStatus(array $row): ?string
+{
+ $enrollmentStatus = strtolower(trim((string)($row['enrollment_status'] ?? '')));
+ $admissionStatus = strtolower(trim((string)($row['admission_status'] ?? '')));
+
+ if ($enrollmentStatus === 'denied' || $admissionStatus === 'denied') {
+ return 'denied';
+ }
+
+ if ($enrollmentStatus === 'withdrawn' || (int)($row['is_withdrawn'] ?? 0) === 1) {
+ return 'withdrawn';
+ }
+
+ return null;
+}
+
+private function priorYearStudentIds(string $selectedYear): array
+{
+ $selectedStartYear = $this->getSchoolYearStartYear($selectedYear);
+ if ($selectedStartYear === null) {
+ return [];
+ }
+
+ $studentIds = [];
+ foreach (['enrollments', 'student_class'] as $table) {
+ if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) {
+ continue;
+ }
+
+ $rows = $this->db->table($table)
+ ->select('student_id, school_year')
+ ->where('student_id IS NOT NULL', null, false)
+ ->where('school_year IS NOT NULL', null, false)
+ ->get()
+ ->getResultArray();
+
+ foreach ($rows as $row) {
+ $rowStartYear = $this->getSchoolYearStartYear((string) ($row['school_year'] ?? ''));
+ $studentId = (int) ($row['student_id'] ?? 0);
+ if ($studentId > 0 && $rowStartYear !== null && $rowStartYear < $selectedStartYear) {
+ $studentIds[$studentId] = true;
+ }
+ }
+ }
+
+ return $studentIds;
+}
+
+private function applyDistributionDraftToStudentClass(int $studentId, string $year): void
+{
+ try {
+ $draftModel = new StudentSectionDistributionDraftModel();
+ $draft = $draftModel->where('student_id', $studentId)
+ ->where('school_year', $year)
+ ->where('status', 'pending')
+ ->first();
+
+ if (!$draft) {
+ return;
+ }
+
+ $targetSectionId = (int)($draft['class_section_id'] ?? 0);
+ if ($targetSectionId <= 0) {
+ return;
+ }
+
+ $studentClass = new StudentClassModel();
+ $exists = $studentClass->where('student_id', $studentId)
+ ->where('school_year', $year)
+ ->first();
+
+ $payload = [
+ 'student_id' => $studentId,
+ 'class_section_id' => $targetSectionId,
+ 'school_year' => $year,
+ 'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
+ 'updated_at' => utc_now(),
+ ];
+
+ if ($exists) {
+ $studentClass->update((int)$exists['id'], $payload);
+ } else {
+ $payload['created_at'] = utc_now();
+ $studentClass->insert($payload);
+ }
+
+ $this->db->table('enrollments')
+ ->where('student_id', $studentId)
+ ->where('school_year', $year)
+ ->whereIn('enrollment_status', ['payment pending', 'enrolled'])
+ ->update([
+ 'class_section_id' => $targetSectionId,
+ 'updated_at' => utc_now(),
+ ]);
+
+ $this->db->table('promotion_queue')
+ ->where('student_id', $studentId)
+ ->where('school_year_to', $year)
+ ->update([
+ 'to_class_section_id' => $targetSectionId,
+ 'status' => 'applied',
+ 'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
+ 'updated_at' => utc_now(),
+ ]);
+
+ $draftModel->update((int)$draft['id'], [
+ 'status' => 'applied',
+ 'applied_at' => utc_now(),
+ 'updated_at' => utc_now(),
+ ]);
+ } catch (\Throwable $e) {
+ log_message('error', 'applyDistributionDraftToStudentClass failed: ' . $e->getMessage());
+ }
+}
+}
diff --git a/app/Services/GradingScoreService.php b/app/Services/GradingScoreService.php
new file mode 100644
index 0000000..2e6fcf3
--- /dev/null
+++ b/app/Services/GradingScoreService.php
@@ -0,0 +1,1457 @@
+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;
+}
+}
diff --git a/app/Services/PlacementGradingService.php b/app/Services/PlacementGradingService.php
new file mode 100644
index 0000000..fdda419
--- /dev/null
+++ b/app/Services/PlacementGradingService.php
@@ -0,0 +1,437 @@
+db = $db;
+ $this->studentModel = $studentModel;
+ $this->placementLevelModel = $placementLevelModel;
+ $this->placementBatchModel = $placementBatchModel;
+ $this->placementScoreModel = $placementScoreModel;
+ $this->schoolYear = $schoolYear;
+ }
+
+ public function setSchoolYear(string $schoolYear): void
+ {
+ $this->schoolYear = $schoolYear;
+ }
+
+public function updatePlacementLevel(array $params = [])
+{
+ $get = $params['get'] ?? [];
+ $post = $params['post'] ?? [];
+
+ $studentId = (int) (($post['student_id'] ?? null) ?? 0);
+ $levelRaw = trim((string) (($post['placement_level'] ?? null) ?? ''));
+ $schoolYear = (string) (($post['school_year'] ?? null) ?? $this->schoolYear);
+
+ if ($studentId <= 0 || $schoolYear === '') {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing student or school year.'];
+ }
+
+ $level = $levelRaw === '' ? null : (int) $levelRaw;
+ if ($level !== null && !in_array($level, [1, 2, 3], true)) {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Invalid placement level.'];
+ }
+
+ $existing = $this->placementLevelModel
+ ->where('student_id', $studentId)
+ ->first();
+
+ if ($level === null) {
+ if ($existing) {
+ $this->placementLevelModel->delete($existing['id']);
+ }
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'status', 'message' => 'Placement level cleared.'];
+ }
+
+ $payload = [
+ 'student_id' => $studentId,
+ '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 ['kind' => 'flash', 'redirect' => 'back', 'type' => 'status', 'message' => 'Placement level updated.'];
+}
+
+public function placementPage(array $params = [])
+{
+ $get = $params['get'] ?? [];
+ $post = $params['post'] ?? [];
+
+ $classSectionId = (int) (($get['class_section_id'] ?? null) ?? 0);
+ $schoolYear = (string) (($get['school_year'] ?? null) ?? $this->schoolYear);
+ $placementTest = (string) (($get['placement_test'] ?? null) ?? '');
+ $openFlag = (string) (($get['open'] ?? null) ?? '');
+
+ if ($classSectionId <= 0 || $schoolYear === '') {
+ $showStudents = ($placementTest !== '' && $openFlag === '1');
+ $students = $showStudents ? $this->fetchActiveStudentsWithSection($schoolYear) : [];
+ $batches = $this->fetchPlacementBatches($schoolYear);
+ $batchDetails = $this->fetchPlacementBatchDetails($batches, $schoolYear);
+ return ['kind' => 'view', 'view' => 'grading/placement_index', 'data' => [
+ '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)
+ ->findAll();
+ foreach ($rows as $row) {
+ $levels[(int) $row['student_id']] = $row['level'] ?? null;
+ }
+ }
+
+ return ['kind' => 'view', 'view' => 'grading/placement', 'data' => [
+ 'classSectionId' => $classSectionId,
+ 'classSectionName' => $sectionName,
+ 'classId' => $classId,
+ 'schoolYear' => $schoolYear,
+ 'students' => $students,
+ 'levels' => $levels,
+ ]];
+}
+
+public function updatePlacementLevels(array $params = [])
+{
+ $get = $params['get'] ?? [];
+ $post = $params['post'] ?? [];
+
+ $classSectionId = (int) (($post['class_section_id'] ?? null) ?? 0);
+ $schoolYear = (string) (($post['school_year'] ?? null) ?? $this->schoolYear);
+ $levels = ($post['placement_level'] ?? null) ?? [];
+
+ if ($classSectionId <= 0 || $schoolYear === '') {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => '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)
+ ->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,
+ '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 ['kind' => 'flash', 'redirect' => 'back', 'type' => 'status', 'message' => 'Placement levels updated.'];
+}
+
+public function updatePlacementLevelsAll(array $params = [])
+{
+ $get = $params['get'] ?? [];
+ $post = $params['post'] ?? [];
+
+ $schoolYear = (string) (($post['school_year'] ?? null) ?? $this->schoolYear);
+ $placementTest = (string) (($post['placement_test'] ?? null) ?? '');
+ $levels = ($post['placement_level'] ?? null) ?? [];
+
+ if ($schoolYear === '' || $placementTest === '') {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => '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 ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => '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 ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'No scores entered. Batch not saved.'];
+ }
+
+ return ['kind' => 'flash', 'redirect' => base_url('grading/placement'), 'type' => 'status', 'message' => 'Placement batch saved.'];
+}
+
+public function editPlacementBatch(int $batchId, array $params = [])
+{
+ $get = $params['get'] ?? [];
+ $post = $params['post'] ?? [];
+
+ $batch = $this->placementBatchModel->find($batchId);
+ if (!$batch) {
+ return ['kind' => 'flash', 'redirect' => base_url('grading/placement'), 'type' => 'error', 'message' => 'Placement batch not found.'];
+ }
+
+ $schoolYear = (string) ($batch['school_year'] ?? $this->schoolYear);
+ $students = $this->fetchActiveStudentsWithSection($schoolYear);
+ $scores = $this->fetchPlacementScoresForBatch($batchId);
+
+ return ['kind' => 'view', 'view' => 'grading/placement_batch', 'data' => [
+ 'batch' => $batch,
+ 'students' => $students,
+ 'scores' => $scores,
+ ]];
+}
+
+public function updatePlacementBatch(int $batchId, array $params = [])
+{
+ $get = $params['get'] ?? [];
+ $post = $params['post'] ?? [];
+
+ $batch = $this->placementBatchModel->find($batchId);
+ if (!$batch) {
+ return ['kind' => 'flash', 'redirect' => base_url('grading/placement'), 'type' => 'error', 'message' => 'Placement batch not found.'];
+ }
+
+ $schoolYear = (string) ($batch['school_year'] ?? $this->schoolYear);
+ $levels = ($post['placement_level'] ?? null) ?? [];
+
+ $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 ['kind' => 'flash', 'redirect' => base_url('grading/placement'), 'type' => 'status', 'message' => 'Placement batch updated.'];
+}
+
+public function fetchActiveStudentsWithSection(string $schoolYear): array
+{
+ return $this->db->table('students s')
+ ->select('s.id AS student_id, s.school_id, s.firstname, s.lastname, s.is_active, sc.class_section_id, cs.class_section_name, c.class_name, e.enrollment_status, e.is_withdrawn')
+ ->join('student_class sc', 'sc.student_id = s.id AND sc.school_year = ' . $this->db->escape($schoolYear), 'left')
+ ->join('enrollments e', 'e.student_id = s.id AND e.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')
+ ->groupStart()
+ ->where('s.is_active', 1)
+ ->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
+ ->orWhere('e.is_withdrawn', 1)
+ ->groupEnd()
+ ->orderBy('s.lastname', 'ASC')
+ ->orderBy('s.firstname', 'ASC')
+ ->get()
+ ->getResultArray();
+}
+
+public function fetchPlacementBatches(string $schoolYear): array
+{
+ return $this->placementBatchModel
+ ->where('school_year', $schoolYear)
+ ->orderBy('created_at', 'DESC')
+ ->findAll();
+}
+
+public 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.student_id, ps.score, s.school_id, s.firstname, s.lastname, s.is_active, e.enrollment_status, e.is_withdrawn, 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('enrollments e', 'e.student_id = s.id AND e.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)
+ ->groupStart()
+ ->where('s.is_active', 1)
+ ->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
+ ->orWhere('e.is_withdrawn', 1)
+ ->groupEnd()
+ ->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;
+}
+
+public 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;
+}
+}
diff --git a/app/Services/StudentDecisionService.php b/app/Services/StudentDecisionService.php
new file mode 100644
index 0000000..0cb83b5
--- /dev/null
+++ b/app/Services/StudentDecisionService.php
@@ -0,0 +1,1234 @@
+db = $db;
+ $this->configModel = $configModel;
+ $this->studentModel = $studentModel;
+ $this->studentClassModel = $studentClassModel;
+ $this->userModel = $userModel;
+ $this->schoolYear = $schoolYear;
+ $this->semester = $semester;
+ }
+
+ public function setTerm(string $schoolYear, string $semester): void
+ {
+ $this->schoolYear = $schoolYear;
+ $this->semester = $semester;
+ }
+
+
+public function previewDecisionEmail(array $params = [])
+{
+ $get = $params['get'] ?? [];
+ $post = $params['post'] ?? [];
+
+ $studentId = (int)($get['student_id'] ?? null);
+ $semester = trim((string)($get['semester'] ?? null));
+ $schoolYear = trim((string)($get['school_year'] ?? null));
+
+ if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
+ return ['kind' => 'json', 'status' => 400, 'data' => ['error' => 'Missing student or term.']];
+ }
+
+ $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
+ if (empty($row)) {
+ return ['kind' => 'json', 'status' => 404, 'data' => ['error' => 'Student not found.']];
+ }
+
+ $decisionModel = new BelowSixtyDecisionModel();
+ $decisionRow = $decisionModel
+ ->where('student_id', $studentId)
+ ->where('semester', $semester)
+ ->where('school_year', $schoolYear)
+ ->first();
+
+ $decision = (string)($decisionRow['decision'] ?? '');
+ $notes = (string)($decisionRow['notes'] ?? '');
+ $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
+ $parentName = $this->fetchBelowSixtyParentName($studentId);
+
+ $subject = 'Academic Decision';
+ if ($studentName !== '') $subject .= ' — ' . $studentName;
+ if ($semester !== '' || $schoolYear !== '') {
+ $subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')';
+ }
+
+ $scores = [
+ 'homework_avg' => $row['homework_avg'] ?? null,
+ 'project_avg' => $row['project_avg'] ?? null,
+ 'participation_score' => $row['participation_score'] ?? null,
+ 'test_avg' => $row['test_avg'] ?? null,
+ 'ptap_score' => $row['ptap_score'] ?? null,
+ 'attendance_score' => $row['attendance_score'] ?? null,
+ 'midterm_exam_score' => $row['midterm_exam_score'] ?? null,
+ 'semester_score' => $row['semester_score'] ?? null,
+ ];
+
+ // Fetch all semesters' scores + comments for the email
+ $allSemesters = $this->fetchAllSemestersForStudent($studentId, $schoolYear);
+
+ $html = view('emails/below_sixty_decision', [
+ 'title' => $subject,
+ 'parent_name' => $parentName,
+ 'student_name' => $studentName !== '' ? $studentName : 'your student',
+ 'class_section_name' => $row['class_section_name'] ?? '',
+ 'semester' => $semester,
+ 'school_year' => $schoolYear,
+ 'decision' => $decision,
+ 'notes' => $notes,
+ 'scores' => $scores,
+ 'all_semesters' => array_values($allSemesters),
+ ], ['saveData' => true]);
+
+ return ['kind' => 'json', 'status' => 200, 'data' => [
+ 'subject' => $subject,
+ 'html' => $html,
+ 'student_id' => $studentId,
+ ]];
+}
+
+
+public function editDecisionEmail(array $params = [])
+{
+ $get = $params['get'] ?? [];
+ $post = $params['post'] ?? [];
+
+ $studentId = (int)($get['student_id'] ?? null);
+ $semester = trim((string)($get['semester'] ?? null));
+ $schoolYear = trim((string)($get['school_year'] ?? null));
+
+ if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing student or term.'];
+ }
+
+ $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
+ if (empty($row)) {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Student record not found for the selected term.'];
+ }
+
+ $decisionModel = new BelowSixtyDecisionModel();
+ $decisionRow = $decisionModel
+ ->where('student_id', $studentId)
+ ->where('semester', $semester)
+ ->where('school_year', $schoolYear)
+ ->first();
+
+ $decision = (string)($decisionRow['decision'] ?? '');
+ $notes = (string)($decisionRow['notes'] ?? '');
+
+ $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
+ $parentName = $this->fetchBelowSixtyParentName($studentId);
+
+ $subject = 'Academic Decision';
+ if ($studentName !== '') $subject .= ' — ' . $studentName;
+ if ($semester !== '' || $schoolYear !== '') {
+ $subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')';
+ }
+
+ $scores = [
+ 'homework_avg' => $row['homework_avg'] ?? null,
+ 'project_avg' => $row['project_avg'] ?? null,
+ 'participation_score' => $row['participation_score'] ?? null,
+ 'test_avg' => $row['test_avg'] ?? null,
+ 'ptap_score' => $row['ptap_score'] ?? null,
+ 'attendance_score' => $row['attendance_score'] ?? null,
+ 'midterm_exam_score' => $row['midterm_exam_score'] ?? null,
+ 'semester_score' => $row['semester_score'] ?? null,
+ ];
+
+ $html = view('emails/below_sixty_decision', [
+ 'title' => $subject,
+ 'parent_name' => $parentName,
+ 'student_name' => $studentName !== '' ? $studentName : 'your student',
+ 'class_section_name' => $row['class_section_name'] ?? '',
+ 'semester' => $semester,
+ 'school_year' => $schoolYear,
+ 'decision' => $decision,
+ 'notes' => $notes,
+ 'scores' => $scores,
+ ], ['saveData' => true]);
+
+ return ['kind' => 'view', 'view' => 'grading/below_sixty_decision_email_editor', 'data' => [
+ 'studentId' => $studentId,
+ 'studentName' => $studentName,
+ 'semester' => $semester,
+ 'schoolYear' => $schoolYear,
+ 'subject' => $subject,
+ 'html' => $html,
+ 'decision' => $decision,
+ ]];
+}
+
+
+public function sendDecisionEmail(array $params = [])
+{
+ $get = $params['get'] ?? [];
+ $post = $params['post'] ?? [];
+
+ $studentId = (int)($post['student_id'] ?? null);
+ $semester = trim((string)($post['semester'] ?? null));
+ $schoolYear = trim((string)($post['school_year'] ?? null));
+ $subjectInput= trim((string)($post['subject'] ?? null));
+ $htmlInput = (string)(($post['html'] ?? null) ?? '');
+
+ if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing student or term.'];
+ }
+
+ $row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
+ if (empty($row)) {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Student record not found for the selected term.'];
+ }
+
+ $decisionModel = new BelowSixtyDecisionModel();
+ $decisionRow = $decisionModel
+ ->where('student_id', $studentId)
+ ->where('semester', $semester)
+ ->where('school_year', $schoolYear)
+ ->first();
+
+ $studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
+ $subject = $subjectInput !== '' ? $subjectInput : ('Academic Decision — ' . $studentName . ' (' . trim($semester . ' ' . $schoolYear) . ')');
+
+ $allSemesters = $this->fetchAllSemestersForStudent($studentId, $schoolYear);
+
+ $payload = [
+ 'student_id' => $studentId,
+ 'student_name' => $studentName,
+ 'class_section_name' => $row['class_section_name'] ?? '',
+ 'semester' => $semester,
+ 'school_year' => $schoolYear,
+ 'decision' => (string)($decisionRow['decision'] ?? ''),
+ 'notes' => (string)($decisionRow['notes'] ?? ''),
+ 'subject' => $subject,
+ 'all_semesters' => $allSemesters,
+ 'scores' => [
+ 'homework_avg' => $row['homework_avg'] ?? null,
+ 'project_avg' => $row['project_avg'] ?? null,
+ 'participation_score' => $row['participation_score'] ?? null,
+ 'test_avg' => $row['test_avg'] ?? null,
+ 'ptap_score' => $row['ptap_score'] ?? null,
+ 'attendance_score' => $row['attendance_score'] ?? null,
+ 'midterm_exam_score' => $row['midterm_exam_score'] ?? null,
+ 'semester_score' => $row['semester_score'] ?? null,
+ ],
+ ];
+
+ if (trim($htmlInput) !== '') {
+ $payload['html'] = $htmlInput;
+ }
+
+ \CodeIgniter\Events\Events::trigger('below60.decision_email', $payload);
+
+ $query = http_build_query(['semester' => $semester, 'school_year' => $schoolYear]);
+ return ['kind' => 'flash', 'redirect' => base_url('grading/below-60/decisions') . ($query ? '?' . $query : ''), 'type' => 'status', 'message' => 'Decision email sent to parent(s).'];
+}
+
+
+public function allDecisionsPage(array $params = [])
+{
+ $get = $params['get'] ?? [];
+ $post = $params['post'] ?? [];
+
+$configuredYear = (string)$this->schoolYear;
+
+$schoolYear = trim((string)(($get['school_year'] ?? null) ?? ''));
+if ($schoolYear === '') {
+ $schoolYear = $configuredYear;
+}
+
+$schoolYears = $this->getSchoolYearsForScores($schoolYear);
+
+// Load saved YEAR decisions for this school year.
+// New structure:
+// one row per student per school_year
+// uses year_score, not semester_score
+// does not use semester = 'year'
+$decModel = new StudentDecisionModel();
+
+$saved = $decModel
+ ->where('school_year', $schoolYear)
+ ->findAll();
+
+$savedMap = [];
+foreach ($saved as $s) {
+ $sid = (int)($s['student_id'] ?? 0);
+ if ($sid > 0) {
+ $savedMap[$sid] = $s;
+ }
+}
+
+// Fetch Fall and Spring semester scores per student.
+// These raw semester scores are only used to calculate the final year_score.
+$allScoreRows = $this->db->table('semester_scores ss')
+ ->select([
+ 's.id AS student_id',
+ 's.school_id',
+ 's.firstname',
+ 's.lastname',
+ 's.gender',
+ 's.dob',
+ 's.is_active',
+ 'ss.class_section_id',
+ 'cs.class_section_name',
+ 'c.class_name',
+ 'e.enrollment_status',
+ 'e.is_withdrawn',
+ 'LOWER(TRIM(ss.semester)) AS sem_key',
+ 'ss.semester_score',
+ ])
+ ->join('students s', 's.id = ss.student_id', 'inner')
+ ->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $this->db->escape($schoolYear), 'left')
+ ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
+ ->join('classes c', 'c.id = cs.class_id', 'left')
+ ->groupStart()
+ ->where('s.is_active', 1)
+ ->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
+ ->orWhere('e.is_withdrawn', 1)
+ ->groupEnd()
+ ->where('ss.school_year', $schoolYear)
+ ->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring'])
+ ->where('ss.semester_score IS NOT NULL', null, false)
+ ->orderBy('cs.class_section_name', 'ASC')
+ ->orderBy('s.lastname', 'ASC')
+ ->orderBy('s.firstname', 'ASC')
+ ->get()
+ ->getResultArray();
+
+// Group Fall/Spring scores by student.
+$studentMap = [];
+
+foreach ($allScoreRows as $sr) {
+ $sid = (int)($sr['student_id'] ?? 0);
+
+ if ($sid <= 0) {
+ continue;
+ }
+
+ if (!isset($studentMap[$sid])) {
+ $studentMap[$sid] = [
+ 'school_id' => $sr['school_id'] ?? '',
+ 'firstname' => $sr['firstname'] ?? '',
+ 'lastname' => $sr['lastname'] ?? '',
+ 'gender' => $sr['gender'] ?? '',
+ 'dob' => $sr['dob'] ?? '',
+ 'is_active' => (int)($sr['is_active'] ?? 1),
+ 'enrollment_status' => $sr['enrollment_status'] ?? '',
+ 'is_withdrawn' => (int)($sr['is_withdrawn'] ?? 0),
+ 'class_section_id' => (int)($sr['class_section_id'] ?? 0),
+ 'class_name' => $sr['class_name'] ?? '',
+ 'class_section_name' => $sr['class_section_name'] ?? '',
+ 'fall_score' => null,
+ 'spring_score' => null,
+ ];
+ }
+
+ $semKey = strtolower(trim((string)($sr['sem_key'] ?? '')));
+ $val = is_numeric($sr['semester_score']) ? (float)$sr['semester_score'] : null;
+
+ if ($semKey === 'fall') {
+ $studentMap[$sid]['fall_score'] = $val;
+ } elseif ($semKey === 'spring') {
+ $studentMap[$sid]['spring_score'] = $val;
+ }
+}
+
+// Pull below-60 manual decisions for this school year.
+// Used only when the calculated year_score is below 60.
+$belowDecModel = new BelowSixtyDecisionModel();
+
+$belowRows = $belowDecModel
+ ->where('school_year', $schoolYear)
+ ->findAll();
+
+$belowMap = [];
+
+foreach ($belowRows as $b) {
+ $sid = (int)($b['student_id'] ?? 0);
+
+ if ($sid <= 0) {
+ continue;
+ }
+
+ // Keep the first non-empty decision found for this student.
+ if (!isset($belowMap[$sid]) || trim((string)($belowMap[$sid]['decision'] ?? '')) === '') {
+ $belowMap[$sid] = $b;
+ }
+}
+
+// Build final display rows.
+$rows = [];
+
+foreach ($studentMap as $sid => $info) {
+ $fall = $info['fall_score'];
+ $spring = $info['spring_score'];
+
+ if ($fall !== null && $spring !== null) {
+ $yearScore = round(($fall + $spring) / 2, 2);
+ } elseif ($fall !== null) {
+ $yearScore = round((float)$fall, 2);
+ } elseif ($spring !== null) {
+ $yearScore = round((float)$spring, 2);
+ } else {
+ $yearScore = null;
+ }
+
+ if (isset($savedMap[$sid])) {
+ $savedRow = $savedMap[$sid];
+
+ $decision = trim((string)($savedRow['decision'] ?? ''));
+ $source = trim((string)($savedRow['source'] ?? 'pending'));
+ $notes = (string)($savedRow['notes'] ?? '');
+
+ if (isset($savedRow['year_score']) && $savedRow['year_score'] !== '' && is_numeric($savedRow['year_score'])) {
+ $yearScore = round((float)$savedRow['year_score'], 2);
+ }
+ } elseif ($yearScore !== null && $yearScore >= 60) {
+ $decision = 'Pass';
+ $source = 'auto';
+ $notes = '';
+ } elseif ($yearScore !== null && isset($belowMap[$sid])) {
+ $decision = trim((string)($belowMap[$sid]['decision'] ?? ''));
+ $source = $decision !== '' ? 'manual' : 'pending';
+ $notes = (string)($belowMap[$sid]['notes'] ?? '');
+ } else {
+ $decision = '';
+ $source = 'pending';
+ $notes = '';
+ }
+
+ $currentClassSectionName = trim((string)($info['class_section_name'] ?? ''));
+ if ($currentClassSectionName === '' && isset($savedMap[$sid])) {
+ $currentClassSectionName = trim((string)($savedMap[$sid]['class_section_name'] ?? ''));
+ }
+ $currentClassName = trim((string)($info['class_name'] ?? ''));
+ if ($currentClassName === '') {
+ $currentClassName = $this->classOnlyLabel($currentClassSectionName);
+ }
+
+ $rows[] = [
+ 'student_id' => $sid,
+ 'school_id' => $info['school_id'],
+ 'firstname' => $info['firstname'],
+ 'lastname' => $info['lastname'],
+ 'gender' => $info['gender'] ?? '',
+ 'dob' => $info['dob'] ?? '',
+ 'is_active' => (int)($info['is_active'] ?? 1),
+ 'enrollment_status' => $info['enrollment_status'] ?? '',
+ 'is_withdrawn' => (int)($info['is_withdrawn'] ?? 0),
+ 'class_section_id' => (int)($info['class_section_id'] ?? 0),
+ 'class_name' => $currentClassName,
+ 'class_section_name' => $currentClassSectionName,
+ 'fall_score' => $fall,
+ 'spring_score' => $spring,
+ 'year_score' => $yearScore,
+ 'decision' => $decision,
+ 'next_year_placement' => $this->nextYearPlacementLabel($decision, $currentClassName, $currentClassSectionName, (string)($info['dob'] ?? ''), $schoolYear),
+ 'source' => $source,
+ 'notes' => $notes,
+ 'saved' => isset($savedMap[$sid]),
+ 'is_trophy' => false,
+ ];
+}
+
+$rowsByClass = [];
+
+foreach ($rows as $index => $row) {
+ $classSectionId = (int)($row['class_section_id'] ?? 0);
+
+ if ($classSectionId <= 0) {
+ continue;
+ }
+
+ $rowsByClass[$classSectionId][] = $index;
+}
+
+foreach ($rowsByClass as $classIndexes) {
+ $scores = [];
+
+ foreach ($classIndexes as $rowIndex) {
+ $yearScore = $rows[$rowIndex]['year_score'] ?? null;
+
+ if (is_numeric($yearScore)) {
+ $scores[] = (float)$yearScore;
+ }
+ }
+
+ $thresholdInfo = $this->calculateTrophyThreshold($scores, 75.0);
+ $threshold = $thresholdInfo['threshold'];
+
+ if ($threshold === null) {
+ continue;
+ }
+
+ foreach ($classIndexes as $rowIndex) {
+ $yearScore = $rows[$rowIndex]['year_score'] ?? null;
+
+ $rows[$rowIndex]['is_trophy'] = is_numeric($yearScore) && (float)$yearScore >= $threshold;
+ }
+}
+
+$generated = !empty($saved);
+
+return ['kind' => 'view', 'view' => 'grading/all_decisions', 'data' => [
+ 'rows' => $rows,
+ 'schoolYear' => $schoolYear,
+ 'schoolYears' => $schoolYears,
+ 'generated' => $generated,
+]];
+}
+
+
+
+public function generateAllDecisions(array $params = [])
+{
+ $get = $params['get'] ?? [];
+ $post = $params['post'] ?? [];
+
+$schoolYear = trim((string)($post['school_year'] ?? null));
+
+if ($schoolYear === '') {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing school year.'];
+}
+
+// Fetch Fall and Spring scores per student.
+$allScoreRows = $this->db->table('semester_scores ss')
+ ->select([
+ 's.id AS student_id',
+ 's.firstname',
+ 's.lastname',
+ 's.is_active',
+ 'cs.class_section_name',
+ 'LOWER(TRIM(ss.semester)) AS sem_key',
+ 'ss.semester_score',
+ ])
+ ->join('students s', 's.id = ss.student_id', 'inner')
+ ->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $this->db->escape($schoolYear), 'left')
+ ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
+ ->groupStart()
+ ->where('s.is_active', 1)
+ ->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
+ ->orWhere('e.is_withdrawn', 1)
+ ->groupEnd()
+ ->where('ss.school_year', $schoolYear)
+ ->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring'])
+ ->where('ss.semester_score IS NOT NULL', null, false)
+ ->get()
+ ->getResultArray();
+
+if (empty($allScoreRows)) {
+ return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'No semester scores found for this school year.'];
+}
+
+// Group Fall/Spring scores by student.
+$studentMap = [];
+
+foreach ($allScoreRows as $sr) {
+ $sid = (int)($sr['student_id'] ?? 0);
+
+ if ($sid <= 0) {
+ continue;
+ }
+
+ if (!isset($studentMap[$sid])) {
+ $studentMap[$sid] = [
+ 'firstname' => $sr['firstname'] ?? '',
+ 'lastname' => $sr['lastname'] ?? '',
+ 'class_section_name' => $sr['class_section_name'] ?? '',
+ 'fall_score' => null,
+ 'spring_score' => null,
+ ];
+ }
+
+ $semKey = strtolower(trim((string)($sr['sem_key'] ?? '')));
+ $val = is_numeric($sr['semester_score']) ? (float)$sr['semester_score'] : null;
+
+ if ($semKey === 'fall') {
+ $studentMap[$sid]['fall_score'] = $val;
+ } elseif ($semKey === 'spring') {
+ $studentMap[$sid]['spring_score'] = $val;
+ }
+}
+
+// Pull below-60 manual decisions for this school year.
+$belowDecModel = new BelowSixtyDecisionModel();
+
+$belowRows = $belowDecModel
+ ->where('school_year', $schoolYear)
+ ->findAll();
+
+$belowMap = [];
+
+foreach ($belowRows as $b) {
+ $sid = (int)($b['student_id'] ?? 0);
+
+ if ($sid <= 0) {
+ continue;
+ }
+
+ if (!isset($belowMap[$sid]) || trim((string)($belowMap[$sid]['decision'] ?? '')) === '') {
+ $belowMap[$sid] = $b;
+ }
+}
+
+// Load existing year decisions so we update instead of duplicating.
+// New structure: one row per student per school_year.
+$decModel = new StudentDecisionModel();
+
+$existing = $decModel
+ ->where('school_year', $schoolYear)
+ ->findAll();
+
+$existingMap = [];
+
+foreach ($existing as $e) {
+ $sid = (int)($e['student_id'] ?? 0);
+
+ if ($sid > 0) {
+ $existingMap[$sid] = $e;
+ }
+}
+
+$userId = (int)(session()->get('user_id') ?? 0) ?: null;
+$savedCount = 0;
+
+foreach ($studentMap as $sid => $info) {
+ $fall = $info['fall_score'];
+ $spring = $info['spring_score'];
+
+ if ($fall !== null && $spring !== null) {
+ $yearScore = round(($fall + $spring) / 2, 2);
+ } elseif ($fall !== null) {
+ $yearScore = round((float)$fall, 2);
+ } elseif ($spring !== null) {
+ $yearScore = round((float)$spring, 2);
+ } else {
+ continue;
+ }
+
+ if ($yearScore >= 60) {
+ $decision = 'Pass';
+ $source = 'auto';
+ $notes = null;
+ } elseif (isset($belowMap[$sid]) && trim((string)($belowMap[$sid]['decision'] ?? '')) !== '') {
+ $decision = trim((string)$belowMap[$sid]['decision']);
+ $source = 'manual';
+ $notes = trim((string)($belowMap[$sid]['notes'] ?? ''));
+ $notes = $notes !== '' ? $notes : null;
+ } else {
+ $decision = null;
+ $source = 'pending';
+ $notes = null;
+ }
+
+ // Important fix:
+ // use year_score, not semester_score.
+ // do not save semester = 'year'.
+ $payload = [
+ 'student_id' => $sid,
+ 'school_year' => $schoolYear,
+ 'class_section_name' => $info['class_section_name'] ?? null,
+ 'year_score' => $yearScore,
+ 'decision' => $decision,
+ 'source' => $source,
+ 'notes' => $notes,
+ 'generated_by' => $userId,
+ ];
+
+ if (isset($existingMap[$sid])) {
+ $decModel->update((int)$existingMap[$sid]['id'], $payload);
+ } else {
+ $decModel->insert($payload);
+ }
+
+ $savedCount++;
+}
+
+$query = http_build_query(['school_year' => $schoolYear]);
+
+return ['kind' => 'flash', 'redirect' => base_url('grading/decisions') . '?' . $query, 'type' => 'status', 'message' => "Decisions generated for {$savedCount} students."];
+}
+
+
+private function nextYearPlacementLabel(
+?string $decision,
+string $currentClassName,
+string $currentClassSectionName,
+string $dob,
+string $schoolYear
+): string
+{
+$normalizedDecision = DeliberationDecision::normalize($decision);
+$classLabel = $this->classOnlyLabel($currentClassName !== '' ? $currentClassName : $currentClassSectionName);
+
+if ($this->isKgClass($classLabel)) {
+ $kgPlacement = $this->kgPlacementByComingSeptember($dob, $schoolYear);
+ if ($kgPlacement !== '') {
+ return $kgPlacement;
+ }
+}
+
+if ($normalizedDecision === DeliberationDecision::REPEAT_CLASS) {
+ return $classLabel;
+}
+
+return '';
+}
+
+
+private function classOnlyLabel(string $className): string
+{
+return trim((string) preg_replace('/-.+$/', '', $className));
+}
+
+
+private function isKgClass(string $className): bool
+{
+$value = strtoupper(trim($className));
+
+return preg_match('/(^|[^A-Z0-9])KG([^A-Z0-9]|$)/', $value) === 1 || str_contains($value, 'KINDERGARTEN');
+}
+
+
+private function kgPlacementByComingSeptember(string $dob, string $schoolYear): string
+{
+$dob = trim($dob);
+if ($dob === '') {
+ return '';
+}
+
+try {
+ $birthDate = new \DateTimeImmutable($dob);
+ $cutoff = $this->comingSeptemberFirstCutoff($schoolYear);
+} catch (\Throwable) {
+ return '';
+}
+
+if ($birthDate > $cutoff) {
+ return '';
+}
+
+return $birthDate->diff($cutoff)->y >= 6 ? '1' : 'KG';
+}
+
+
+private function comingSeptemberFirstCutoff(string $schoolYear): \DateTimeImmutable
+{
+if (preg_match('/^(\d{4})-\d{4}$/', $schoolYear, $matches) === 1) {
+ return new \DateTimeImmutable(((int) $matches[1] + 1) . '-09-01');
+}
+
+$today = new \DateTimeImmutable('today');
+$cutoff = new \DateTimeImmutable($today->format('Y') . '-09-01');
+
+return $today <= $cutoff ? $cutoff : $cutoff->modify('+1 year');
+}
+
+
+private function normalizeSemesterInput(?string $semester): ?string
+{
+ if (!is_string($semester)) {
+ return null;
+ }
+ $trimmed = trim($semester);
+ return $trimmed === '' ? null : $trimmed;
+}
+
+
+private function resolveSemesterSelection(?string $requestedSemester, array $semesterOptions, ?string $fallbackSemester): string
+{
+ if ($requestedSemester !== null) {
+ foreach ($semesterOptions as $option) {
+ if (strcasecmp($option, $requestedSemester) === 0) {
+ return $option;
+ }
+ }
+ return $requestedSemester;
+ }
+ if ($fallbackSemester !== null && $fallbackSemester !== '') {
+ foreach ($semesterOptions as $option) {
+ if (strcasecmp($option, $fallbackSemester) === 0) {
+ return $option;
+ }
+ }
+ return $fallbackSemester;
+ }
+ return $semesterOptions[0] ?? '';
+}
+
+
+private function getSemestersForSchoolYear(string $schoolYear, ?string $fallbackSemester = null): array
+{
+ $rows = $this->db->table('semester_scores')
+ ->select('DISTINCT semester', false)
+ ->where('semester IS NOT NULL', null, false)
+ ->where('semester != ""', null, false)
+ ->where('school_year', $schoolYear)
+ ->orderBy('semester', 'ASC')
+ ->get()
+ ->getResultArray();
+
+ $semesters = [];
+ foreach ($rows as $row) {
+ $value = trim((string) ($row['semester'] ?? ''));
+ if ($value === '') continue;
+ $semesters[] = $value;
+ }
+
+ if (empty($semesters)) {
+ $semesters = ['Fall', 'Spring'];
+ }
+
+ if ($fallbackSemester !== null && $fallbackSemester !== '' && !in_array($fallbackSemester, $semesters, true)) {
+ array_unshift($semesters, $fallbackSemester);
+ }
+
+ return array_values(array_unique($semesters));
+}
+
+
+private function getSchoolYearsForScores(?string $fallback = null): array
+{
+ $schoolYears = [];
+ try {
+ $rows = $this->db->table('semester_scores')
+ ->select('DISTINCT school_year', false)
+ ->where('school_year IS NOT NULL', null, false)
+ ->where('school_year != ""', null, false)
+ ->orderBy('school_year', 'DESC')
+ ->get()
+ ->getResultArray();
+ foreach ($rows as $row) {
+ $val = (string)($row['school_year'] ?? '');
+ if ($val !== '') $schoolYears[] = $val;
+ }
+ } catch (\Throwable $e) {
+ }
+ try {
+ $rows2 = $this->db->table('student_class')
+ ->select('DISTINCT school_year', false)
+ ->where('school_year IS NOT NULL', null, false)
+ ->where('school_year != ""', null, false)
+ ->orderBy('school_year', 'DESC')
+ ->get()
+ ->getResultArray();
+ foreach ($rows2 as $row) {
+ $val = (string)($row['school_year'] ?? '');
+ if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
+ }
+ } catch (\Throwable $e) {
+ }
+ if ($fallback && !in_array($fallback, $schoolYears, true)) {
+ array_unshift($schoolYears, $fallback);
+ }
+ return array_values(array_unique($schoolYears));
+}
+
+
+private function userHasMenuUrl(string $needle): bool
+{
+ $needle = strtolower(trim($needle));
+ if ($needle === '') {
+ return false;
+ }
+
+ $rawRole = session()->get('role');
+ $roles = is_array($rawRole) ? $rawRole : [$rawRole ?? 'guest'];
+ $roles = array_values(array_filter(array_map('strval', $roles)));
+ if (empty($roles)) {
+ return false;
+ }
+
+ $service = new NavbarService();
+ $menu = $service->getMenuForRoles($roles);
+ if (empty($menu)) {
+ return false;
+ }
+
+ $normalize = static function (string $url) use ($needle): string {
+ $url = strtolower(trim($url));
+ if ($url === '') return '';
+ $url = preg_replace('#^https?://[^/]+/#i', '', $url);
+ $url = ltrim($url, '/');
+ return $url;
+ };
+
+ $target = $normalize($needle);
+ $stack = $menu;
+ while (!empty($stack)) {
+ $node = array_shift($stack);
+ if (!empty($node['url'])) {
+ $url = $normalize((string)$node['url']);
+ if ($url !== '' && $url === $target) {
+ return true;
+ }
+ }
+ if (!empty($node['children']) && is_array($node['children'])) {
+ foreach ($node['children'] as $child) {
+ $stack[] = $child;
+ }
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Parses a classSection name into a (classId, label) pair compatible with your Attendance view.
+ * - KG -> (0, 'KG')
+ * - Youth-> (13, 'Youth')
+ * - Grade N -> (N, 'Grade N')
+ * Fallback: tries to extract first number; else returns (99, original).
+ */
+private function parseClassIdFromSectionName(string $name): array
+{
+ $n = trim($name);
+
+ // Common patterns used in your data
+ if (preg_match('/\bKG\b/i', $n)) {
+ return [0, 'KG'];
+ }
+ if (preg_match('/\bYouth\b/i', $n)) {
+ return [13, 'Youth'];
+ }
+ if (preg_match('/Grade\s*(\d+)/i', $n, $m)) {
+ $g = (int)$m[1];
+ return [$g, 'Grade ' . $g];
+ }
+
+ // Fallback: any number present becomes the grade id
+ if (preg_match('/(\d+)/', $n, $m2)) {
+ $g = (int)$m2[1];
+ return [$g, 'Grade ' . $g];
+ }
+
+ // Last fallback
+ return [99, $n];
+}
+
+/**
+ * Resolve class_section_id for a student in the current term.
+ */
+private function resolveClassSectionIdForStudent(int $studentId): int
+{
+ $row = $this->db->table('student_class')
+ ->select('class_section_id')
+ ->where('student_id', $studentId)
+ ->where('school_year', $this->schoolYear)
+ ->where('semester', $this->semester)
+ ->get()->getRow();
+
+ return $row ? (int)$row->class_section_id : 0;
+}
+
+/** Simple slugifier used as array keys for tabs */
+private function slugify(string $s): string
+{
+ $s = strtolower($s);
+ $s = preg_replace('/[^a-z0-9]+/i', '-', $s);
+ return trim($s, '-');
+}
+
+/** Rank: KG first, numbers ascending, Youth last */
+private function rankOf(string $name): int
+{
+ $k = strtolower(trim($name));
+ if ($k === 'kg' || $k === 'kindergarten') return -100;
+ if ($k === 'youth') return 100000;
+ if (preg_match('/\d+/', $k, $m)) return (int)$m[0];
+ return 50000;
+}
+
+
+
+private function fetchBelowSixtyEmailRow(int $studentId, string $schoolYear, string $semester): array
+{
+ $semesterKey = strtolower(trim($semester));
+ $row = $this->db->table('semester_scores ss')
+ ->select([
+ 's.id AS student_id',
+ 's.firstname',
+ 's.lastname',
+ 'cs.class_section_name',
+ 'ss.homework_avg',
+ 'ss.project_avg',
+ 'ss.participation_score',
+ 'COALESCE(ss.test_avg, ss.quiz_avg) AS test_avg',
+ 'ss.ptap_score',
+ 'ss.attendance_score',
+ 'ss.midterm_exam_score',
+ 'ss.semester_score',
+ ])
+ ->join('students s', 's.id = ss.student_id', 'inner')
+ ->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $this->db->escape($schoolYear), 'left')
+ ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
+ ->where('ss.school_year', $schoolYear)
+ ->where('ss.student_id', $studentId)
+ ->groupStart()
+ ->where('s.is_active', 1)
+ ->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
+ ->orWhere('e.is_withdrawn', 1)
+ ->groupEnd()
+ ->where("LOWER(TRIM(ss.semester))", $semesterKey)
+ ->get()
+ ->getRowArray();
+
+ if (!$row) return [];
+
+ $commentRow = $this->db->table('score_comments')
+ ->select('comment')
+ ->where('score_type', 'general')
+ ->where('school_year', $schoolYear)
+ ->where("LOWER(TRIM(semester))", $semesterKey)
+ ->where('student_id', $studentId)
+ ->orderBy('created_at', 'DESC')
+ ->get()
+ ->getRowArray();
+ $row['comment'] = (string)($commentRow['comment'] ?? '');
+
+ return $row;
+}
+
+
+private function fetchBelowSixtyParentName(int $studentId): string
+{
+ $parentName = 'Parent/Guardian';
+ try {
+ $rows = $this->db->query(
+ "SELECT u.firstname, u.lastname
+ FROM family_students fs
+ JOIN family_guardians fg ON fg.family_id = fs.family_id
+ JOIN users u ON u.id = fg.user_id
+ WHERE fs.student_id = ?
+ ORDER BY fg.is_primary DESC, u.lastname, u.firstname
+ LIMIT 1",
+ [$studentId]
+ )->getResultArray();
+ if (!empty($rows[0])) {
+ $candidate = trim((string)($rows[0]['firstname'] ?? '') . ' ' . (string)($rows[0]['lastname'] ?? ''));
+ if ($candidate !== '') {
+ $parentName = $candidate;
+ }
+ }
+ } catch (\Throwable $e) {
+ }
+ return $parentName;
+}
+
+
+private function buildBelowSixtySubject(string $studentName, string $semester, string $schoolYear): string
+{
+ $subject = 'Student Performance Alert';
+ if ($studentName !== '') {
+ $subject .= ' — ' . $studentName;
+ }
+ if ($semester !== '' || $schoolYear !== '') {
+ $subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')';
+ }
+ return $subject;
+}
+
+
+private function fetchAllSemestersForStudent(int $studentId, string $schoolYear): array
+{
+ $rows = $this->db->table('semester_scores ss')
+ ->select([
+ 'ss.semester',
+ 'cs.class_section_name',
+ 'ss.homework_avg',
+ 'ss.project_avg',
+ 'ss.participation_score',
+ 'COALESCE(ss.test_avg, ss.quiz_avg) AS test_avg',
+ 'ss.ptap_score',
+ 'ss.attendance_score',
+ 'ss.midterm_exam_score',
+ 'ss.final_exam_score',
+ 'ss.semester_score',
+ ])
+ ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
+ ->where('ss.student_id', $studentId)
+ ->where('ss.school_year', $schoolYear)
+ ->orderBy('ss.semester', 'ASC')
+ ->get()
+ ->getResultArray();
+
+ $semesters = [];
+ foreach ($rows as $sr) {
+ $sem = ucfirst(strtolower(trim((string)($sr['semester'] ?? ''))));
+ $semesters[$sem] = [
+ 'semester' => $sem,
+ 'class_section_name' => $sr['class_section_name'] ?? '',
+ 'homework_avg' => $sr['homework_avg'] ?? null,
+ 'project_avg' => $sr['project_avg'] ?? null,
+ 'participation_score' => $sr['participation_score'] ?? null,
+ 'test_avg' => $sr['test_avg'] ?? null,
+ 'ptap_score' => $sr['ptap_score'] ?? null,
+ 'attendance_score' => $sr['attendance_score'] ?? null,
+ 'midterm_exam_score' => $sr['midterm_exam_score'] ?? null,
+ 'final_exam_score' => $sr['final_exam_score'] ?? null,
+ 'semester_score' => $sr['semester_score'] ?? null,
+ 'comments' => [],
+ ];
+ }
+
+ if (!empty($semesters)) {
+ $commentRows = $this->db->table('score_comments')
+ ->select('semester, score_type, comment, created_at')
+ ->where('student_id', $studentId)
+ ->where('school_year', $schoolYear)
+ ->where('comment IS NOT NULL', null, false)
+ ->where('comment !=', '')
+ ->orderBy('semester', 'ASC')
+ ->orderBy('score_type', 'ASC')
+ ->orderBy('created_at', 'DESC')
+ ->get()
+ ->getResultArray();
+
+ foreach ($commentRows as $c) {
+ $sem = ucfirst(strtolower(trim((string)($c['semester'] ?? ''))));
+ $type = strtolower(trim((string)($c['score_type'] ?? 'general')));
+ if (isset($semesters[$sem]) && !isset($semesters[$sem]['comments'][$type])) {
+ $semesters[$sem]['comments'][$type] = (string)($c['comment'] ?? '');
+ }
+ }
+ }
+
+ return array_values($semesters);
+}
+
+
+private function calculateTrophyThreshold(array $scores, float $percentile = 75.0): array
+{
+ $scores = array_values(array_filter(
+ $scores,
+ static fn ($value): bool => is_numeric($value) && $value !== null
+ ));
+ $scores = array_map('floatval', $scores);
+ sort($scores);
+
+ $count = count($scores);
+
+ if ($count === 0) {
+ return ['threshold' => null, 'winners' => 0, 'method' => 'empty'];
+ }
+
+ $minWinners = 3;
+ $maxWinners = max($minWinners, (int) floor($count * (1 - $percentile / 100)));
+
+ $threshold = $this->empiricalTrophyPercentile($scores, $percentile);
+ $winners = $this->countScoresAtOrAbove($scores, $threshold);
+
+ if ($winners < $minWinners) {
+ $target = min($minWinners, $count);
+ $descending = array_reverse($scores);
+ $threshold = $descending[$target - 1];
+ $winners = $this->countScoresAtOrAbove($scores, $threshold);
+
+ return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_reduced'];
+ }
+
+ if ($winners <= $maxWinners) {
+ return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'empirical_percentile'];
+ }
+
+ $result = $this->capTrophyThresholdByRank($scores, $maxWinners);
+
+ if ($result['winners'] < $minWinners) {
+ $target = min($minWinners, $count);
+ $descending = array_reverse($scores);
+ $threshold = $descending[$target - 1];
+ $winners = $this->countScoresAtOrAbove($scores, $threshold);
+
+ return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_after_cap'];
+ }
+
+ return $result;
+}
+
+
+private function capTrophyThresholdByRank(array $sortedScores, int $max): array
+{
+ $descending = array_reverse($sortedScores);
+ $threshold = $descending[$max - 1];
+ $winners = $this->countScoresAtOrAbove($sortedScores, $threshold);
+
+ if ($winners <= $max) {
+ return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'capped_25pct'];
+ }
+
+ $uniqueHigherScores = array_values(array_unique(array_filter(
+ $sortedScores,
+ static fn ($score): bool => $score > $threshold
+ )));
+ sort($uniqueHigherScores);
+
+ foreach ($uniqueHigherScores as $candidate) {
+ $winnerCount = $this->countScoresAtOrAbove($sortedScores, $candidate);
+
+ if ($winnerCount <= $max) {
+ return ['threshold' => $candidate, 'winners' => $winnerCount, 'method' => 'capped_25pct'];
+ }
+ }
+
+ return ['threshold' => $sortedScores[0], 'winners' => count($sortedScores), 'method' => 'all_equal'];
+}
+
+
+private function empiricalTrophyPercentile(array $sortedScores, float $percentile): float
+{
+ $count = count($sortedScores);
+
+ if ($count === 0) {
+ return 0.0;
+ }
+
+ $index = ($percentile / 100.0) * ($count - 1);
+ $lower = (int) floor($index);
+ $upper = (int) ceil($index);
+
+ if ($lower === $upper) {
+ return $sortedScores[$lower];
+ }
+
+ return $sortedScores[$lower] + ($index - $lower) * ($sortedScores[$upper] - $sortedScores[$lower]);
+}
+
+
+private function countScoresAtOrAbove(array $scores, float $threshold): int
+{
+ return count(array_filter(
+ $scores,
+ static fn ($score): bool => $score >= $threshold
+ ));
+}
+}
diff --git a/app/Services/TeacherSubmissionReportService.php b/app/Services/TeacherSubmissionReportService.php
new file mode 100644
index 0000000..0f51698
--- /dev/null
+++ b/app/Services/TeacherSubmissionReportService.php
@@ -0,0 +1,1041 @@
+db = $db;
+ $this->configModel = $configModel;
+ $this->studentClassModel = $studentClassModel;
+ $this->classSectionModel = $classSectionModel;
+ $this->userModel = $userModel;
+ }
+
+public function buildReport(string $semester, string $schoolYear, array $lowProgressSectionIds = []): array
+{
+ $this->schoolYear = $schoolYear;
+ $this->semester = $semester;
+ $semesterResolver = new SemesterRangeService($this->configModel);
+ $semesterNorm = $semesterResolver->normalizeSemester($semester);
+ $semesterFilter = $semesterNorm !== '' ? $semesterNorm : $semester;
+ $semesterCandidates = $this->buildSemesterCandidates($semesterFilter);
+
+ $scoreComments = new ScoreCommentModel();
+ $semesterScores = new SemesterScoreModel();
+ $attendanceDays = new AttendanceDayModel();
+ $examDrafts = new ExamDraftModel();
+ $homeworkModel = new HomeworkModel();
+ $historyModel = new TeacherSubmissionNotificationHistoryModel();
+
+ $assignmentQuery = $this->db->table('teacher_class tc')
+ ->select([
+ 'tc.class_section_id',
+ 'cs.class_section_name',
+ 'tc.teacher_id',
+ 'u.firstname',
+ 'u.lastname',
+ 'tc.position',
+ ])
+ ->join('classSection cs', 'cs.class_section_id = tc.class_section_id', 'left')
+ ->join('users u', 'u.id = tc.teacher_id', 'left')
+ ->orderBy('cs.class_section_name', 'ASC');
+
+ // teacher_class assignments are scoped by school year only.
+ // The table has no semester column; semester filtering belongs on
+ // semester-specific records such as scores, comments, attendance,
+ // homework, and exam drafts.
+ if ($schoolYear !== '') {
+ $assignmentQuery->where('tc.school_year', $schoolYear);
+ }
+
+ $assignmentRows = $assignmentQuery->get()->getResultArray();
+
+ $studentCounts = $this->studentClassModel->getStudentCountsBySection($schoolYear !== '' ? $schoolYear : null);
+ $sectionRows = $this->classSectionModel
+ ->select('class_section_id, class_section_name')
+ ->orderBy('class_section_name', 'ASC')
+ ->findAll();
+ $sectionMap = [];
+ foreach ($sectionRows as $sectionRow) {
+ $sectionId = (int) ($sectionRow['class_section_id'] ?? 0);
+ if ($sectionId <= 0) {
+ continue;
+ }
+ if (empty($studentCounts[$sectionId])) {
+ continue;
+ }
+ $sectionMap[$sectionId] = $sectionRow['class_section_name'] ?? "Section {$sectionId}";
+ }
+ $sectionIds = array_keys($sectionMap);
+
+ [$progressExpectedWeeks, $progressSubmittedBySection] = $this->buildClassProgressStats($sectionIds);
+ $examDraftCounts = [];
+ $examDraftDeadline = $this->resolveTeacherDashboardExamDraftDeadline($semester, $schoolYear);
+ $examDraftDeadlineConfig = trim((string) ($this->configModel->getConfig('exam_draft_deadline') ?? ''));
+ $examDraftDeadlineFormatted = '';
+ if ($examDraftDeadlineConfig !== '') {
+ $parsedUi = $this->parseExamDraftDeadlineConfigValue();
+ $examDraftDeadlineFormatted = $parsedUi !== null ? $parsedUi->format('M j, Y') : '';
+ }
+ $homeworkCounts = [];
+ if (! empty($sectionIds)) {
+ $draftBuilder = $examDrafts
+ ->select('class_section_id')
+ ->whereIn('class_section_id', $sectionIds);
+ if ($schoolYear !== '') {
+ $draftBuilder->where('school_year', $schoolYear);
+ }
+ if (!empty($semesterCandidates)) {
+ $draftBuilder->whereIn('semester', $semesterCandidates);
+ }
+ if ($this->db->fieldExists('is_legacy', 'exam_drafts')) {
+ $draftBuilder->where('is_legacy', 0);
+ }
+ $draftRows = $draftBuilder->findAll();
+ foreach ($draftRows as $draft) {
+ $sectionId = (int) ($draft['class_section_id'] ?? 0);
+ if ($sectionId <= 0) {
+ continue;
+ }
+ $examDraftCounts[$sectionId] = ($examDraftCounts[$sectionId] ?? 0) + 1;
+ }
+
+ $homeworkBuilder = $homeworkModel
+ ->select('class_section_id, homework_index')
+ ->whereIn('class_section_id', $sectionIds);
+ if ($schoolYear !== '') {
+ $homeworkBuilder->where('school_year', $schoolYear);
+ }
+ if (!empty($semesterCandidates)) {
+ $homeworkBuilder->whereIn('semester', $semesterCandidates);
+ }
+ $homeworkRows = $homeworkBuilder
+ ->where('score IS NOT NULL', null, false)
+ ->where('score !=', '')
+ ->groupBy('class_section_id, homework_index')
+ ->findAll();
+ foreach ($homeworkRows as $row) {
+ $sectionId = (int) ($row['class_section_id'] ?? 0);
+ if ($sectionId <= 0) {
+ continue;
+ }
+ $homeworkCounts[$sectionId] = ($homeworkCounts[$sectionId] ?? 0) + 1;
+ }
+ }
+
+ if (empty($lowProgressSectionIds)) {
+ $lowProgressSectionIds = $this->resolveLowProgressSectionIds($sectionIds);
+ }
+
+ $teachersBySection = [];
+ foreach ($assignmentRows as $assignment) {
+ $sectionId = (int)($assignment['class_section_id'] ?? 0);
+ if ($sectionId <= 0) {
+ continue;
+ }
+
+ $positionKey = strtolower(trim((string)($assignment['position'] ?? '')));
+ $roleKey = $positionKey !== '' ? $positionKey : 'teacher';
+ $positionLabel = match ($roleKey) {
+ 'ta' => 'TA',
+ 'main' => 'Main',
+ default => $roleKey !== '' ? ucfirst($roleKey) : 'Teacher',
+ };
+
+ $teacherFullName = trim(($assignment['firstname'] ?? '') . ' ' . ($assignment['lastname'] ?? ''));
+ $teacherId = (int)($assignment['teacher_id'] ?? 0);
+ if ($teacherFullName === '' || $teacherId <= 0) {
+ continue;
+ }
+
+ $entry = &$teachersBySection[$sectionId];
+ if (!isset($entry)) {
+ $entry = [
+ 'class_section' => $assignment['class_section_name'] ?? ($sectionMap[$sectionId] ?? "Section {$sectionId}"),
+ 'teachers' => [],
+ ];
+ }
+
+ $entry['teachers'][] = [
+ 'id' => $teacherId,
+ 'label' => "{$positionLabel}: {$teacherFullName}",
+ 'role_key' => $roleKey,
+ ];
+ unset($entry);
+ }
+
+ $today = (new \DateTimeImmutable('now', new \DateTimeZone(date_default_timezone_get() ?: 'UTC')))->format('Y-m-d');
+
+ $rows = [];
+ $totalStatuses = 0;
+ $missingItemCount = 0;
+ $allTeacherIds = [];
+ $allClassSectionIds = [];
+ $examTerm = $this->resolveExamTermLabel($semester);
+ $examScoreField = $examTerm === 'final' ? 'final_exam_score' : 'midterm_exam_score';
+
+ foreach ($sectionMap as $classSectionId => $sectionName) {
+ $classSectionId = (int)$classSectionId;
+ if ($classSectionId <= 0) {
+ continue;
+ }
+
+ $studentEntries = $this->db->table('student_class')
+ ->select('student_id')
+ ->where('class_section_id', $classSectionId)
+ ->where('school_year', $schoolYear)
+ ->get()
+ ->getResultArray();
+ if (empty($studentEntries)) {
+ $studentEntries = $this->studentClassModel
+ ->select('student_id')
+ ->where('class_section_id', $classSectionId)
+ ->where('school_year', $schoolYear)
+ ->findAll();
+ }
+ $studentIds = array_filter(array_map(static fn($entry) => (int)($entry['student_id'] ?? 0), $studentEntries));
+ $expected = count($studentIds);
+
+ $midtermStudents = [];
+ $participationStudents = [];
+ if ($classSectionId > 0) {
+ $scoreQuery = $semesterScores
+ ->where('class_section_id', $classSectionId)
+ ->where('school_year', $schoolYear);
+ if (!empty($semesterCandidates)) {
+ $scoreQuery->whereIn('semester', $semesterCandidates);
+ }
+ $scoreRecords = $scoreQuery->findAll();
+ foreach ($scoreRecords as $score) {
+ $sid = (int)($score['student_id'] ?? 0);
+ if ($sid <= 0 || ($expected > 0 && !in_array($sid, $studentIds, true))) {
+ continue;
+ }
+ $midtermValue = trim((string)($score[$examScoreField] ?? ''));
+ if ($midtermValue !== '') {
+ $midtermStudents[$sid] = true;
+ }
+ $participationValue = trim((string)($score['participation_score'] ?? ''));
+ if ($participationValue !== '') {
+ $participationStudents[$sid] = true;
+ }
+ }
+ }
+
+ $midtermCommentStudents = [];
+ $ptapCommentStudents = [];
+ if (!empty($studentIds)) {
+ $commentQuery = $scoreComments
+ ->select('student_id, score_type, comment')
+ ->whereIn('student_id', $studentIds)
+ ->where('school_year', $schoolYear)
+ ->whereIn('score_type', [$examTerm, 'ptap']);
+ if (!empty($semesterCandidates)) {
+ $commentQuery->whereIn('semester', $semesterCandidates);
+ }
+ $comments = $commentQuery->findAll();
+ foreach ($comments as $comment) {
+ $sid = (int)($comment['student_id'] ?? 0);
+ if ($sid <= 0) {
+ continue;
+ }
+ $text = trim((string)($comment['comment'] ?? ''));
+ if ($text === '') {
+ continue;
+ }
+ $type = strtolower(trim((string)($comment['score_type'] ?? '')));
+ if ($type === $examTerm) {
+ $midtermCommentStudents[$sid] = true;
+ }
+ if ($type === 'ptap') {
+ $ptapCommentStudents[$sid] = true;
+ }
+ }
+ }
+
+ $attendanceQuery = $attendanceDays
+ ->where('class_section_id', $classSectionId)
+ ->where('school_year', $schoolYear)
+ ->where('date', $today);
+ if (!empty($semesterCandidates)) {
+ $attendanceQuery->whereIn('semester', $semesterCandidates);
+ }
+ $attendanceRow = $attendanceQuery->first();
+ $attendanceSubmitted = $attendanceRow && in_array(strtolower((string)($attendanceRow['status'] ?? '')), ['submitted', 'published', 'finalized'], true);
+
+ $section = $teachersBySection[$classSectionId] ?? ['teachers' => []];
+ $teacherList = $section['teachers'] ?? [];
+ if (!empty($teacherList)) {
+ usort($teacherList, function ($a, $b) {
+ return $this->teacherRolePriority($a['role_key'] ?? 'teacher') <=> $this->teacherRolePriority($b['role_key'] ?? 'teacher');
+ });
+ $teacherList = array_values($teacherList);
+ }
+
+ foreach ($teacherList as $teacherEntry) {
+ if (!empty($teacherEntry['id'])) {
+ $allTeacherIds[] = $teacherEntry['id'];
+ }
+ }
+ $allClassSectionIds[] = $classSectionId;
+
+ $midtermScoreStatus = $this->submissionStatus(count($midtermStudents), $expected);
+ $midtermCommentStatus = $this->submissionStatus(count($midtermCommentStudents), $expected);
+ $participationStatus = $this->submissionStatus(count($participationStudents), $expected);
+ $ptapCommentStatus = $this->submissionStatus(count($ptapCommentStudents), $expected);
+ $attendanceStatus = $this->attendanceStatus($attendanceSubmitted);
+ $progressSubmitted = (int) ($progressSubmittedBySection[$classSectionId] ?? 0);
+ $classProgressStatus = $this->progressStatus($progressSubmitted, $progressExpectedWeeks);
+ $draftSubmitted = (int) ($examDraftCounts[$classSectionId] ?? 0);
+ $examDraftStatus = $this->draftStatus($draftSubmitted, $examDraftDeadline);
+ $homeworkSubmitted = (int) ($homeworkCounts[$classSectionId] ?? 0);
+ $homeworkStatus = $this->homeworkStatus($homeworkSubmitted);
+ $statusDetails = [
+ 'midterm_score_status' => $midtermScoreStatus,
+ 'midterm_comment_status' => $midtermCommentStatus,
+ 'participation_status' => $participationStatus,
+ 'ptap_comment_status' => $ptapCommentStatus,
+ 'class_progress_status' => $classProgressStatus,
+ 'exam_draft_status' => $examDraftStatus,
+ 'homework_status' => $homeworkStatus,
+ ];
+ $missingItemsForSection = $this->buildMissingItems($statusDetails, $semester);
+ $missingItemCount += count($missingItemsForSection);
+ $totalStatuses += count($statusDetails);
+
+ $rows[] = [
+ 'class_section' => $sectionMap[$classSectionId] ?? ($section['class_section'] ?? "Section {$classSectionId}"),
+ 'class_section_id' => $classSectionId,
+ 'teachers' => $teacherList,
+ 'midterm_score_status' => $midtermScoreStatus,
+ 'midterm_comment_status' => $midtermCommentStatus,
+ 'participation_status' => $participationStatus,
+ 'ptap_comment_status' => $ptapCommentStatus,
+ 'attendance_status' => $attendanceStatus,
+ 'class_progress_status' => $classProgressStatus,
+ 'exam_draft_status' => $examDraftStatus,
+ 'homework_status' => $homeworkStatus,
+ 'missing_items' => $missingItemsForSection,
+ 'student_count' => $expected,
+ ];
+ }
+
+ $historyMap = [];
+ $teacherIds = array_values(array_unique($allTeacherIds));
+ $classSectionIds = array_values(array_unique($allClassSectionIds));
+ if (!empty($teacherIds) && !empty($classSectionIds)) {
+ $historyRecords = $historyModel
+ ->select('teacher_submission_notification_history.*, u.firstname, u.lastname')
+ ->join('users u', 'u.id = teacher_submission_notification_history.admin_id', 'left')
+ ->where('notification_category', 'teacher_submissions')
+ ->whereIn('teacher_submission_notification_history.teacher_id', $teacherIds)
+ ->whereIn('teacher_submission_notification_history.class_section_id', $classSectionIds)
+ ->orderBy('sent_at', 'DESC')
+ ->findAll();
+
+ foreach ($historyRecords as $record) {
+ $sectionId = (int)($record['class_section_id'] ?? 0);
+ $teacherId = (int)($record['teacher_id'] ?? 0);
+ if ($sectionId <= 0 || $teacherId <= 0) {
+ continue;
+ }
+ $sentAt = $record['sent_at'] ?? null;
+ $sentAtText = $sentAt ? local_datetime($sentAt, 'M j, Y g:i A') : '';
+ $adminName = trim(($record['firstname'] ?? '') . ' ' . ($record['lastname'] ?? ''));
+ if ($adminName === '') {
+ $adminName = 'Administrator';
+ }
+ $historyMap[$sectionId][$teacherId][] = [
+ 'sent_at_text' => $sentAtText,
+ 'admin_name' => $adminName,
+ 'status' => strtolower((string)($record['status'] ?? 'sent')),
+ ];
+ }
+
+ foreach ($historyMap as &$teachersHistory) {
+ foreach ($teachersHistory as &$entries) {
+ $entries = array_slice($entries, 0, 3);
+ }
+ unset($entries);
+ }
+ unset($teachersHistory);
+ }
+
+ $summary = [
+ 'total_items' => $totalStatuses,
+ 'missing_items' => $missingItemCount,
+ 'submitted_items' => max(0, $totalStatuses - $missingItemCount),
+ 'submission_percentage' => $totalStatuses > 0
+ ? (int)round((($totalStatuses - $missingItemCount) / $totalStatuses) * 100)
+ : 100,
+ ];
+
+ return [
+ 'rows' => $rows,
+ 'semester' => $semester,
+ 'schoolYear' => $schoolYear,
+ 'notificationHistory' => $historyMap,
+ 'summary' => $summary,
+ 'lowProgressSectionIds' => $lowProgressSectionIds,
+ 'examDraftDeadlineConfig' => $examDraftDeadlineConfig,
+ 'examDraftDeadlineFormatted' => $examDraftDeadlineFormatted,
+ ];
+}
+
+public function sendNotifications(array $post, string $semester, int $adminId): array
+{
+ $notify = $post['notify'] ?? null;
+ if (!is_array($notify)) {
+ return ['redirect' => 'back', 'type' => 'info', 'message' => 'Select at least one teacher to notify.'];
+ }
+ $semester = (string)(getSemester() ?? $this->semester ?? '');
+ $missingItemsPayload = $post['missing_items'] ?? [];
+ $homeworkNotifyAll = (bool) ($post['homework_notify_all'] ?? false);
+ $examTerm = $this->resolveExamTermLabel($semester);
+ $examScoreLabel = $examTerm === 'final' ? 'final scores' : 'midterm scores';
+ $examCommentLabel = $examTerm === 'final' ? 'final comments' : 'midterm comments';
+ $forcedItems = [];
+ if (!empty($post['notify_midterm_score'])) {
+ $forcedItems[] = $examScoreLabel;
+ }
+ if (!empty($post['notify_midterm_comment'])) {
+ $forcedItems[] = $examCommentLabel;
+ }
+ if (!empty($post['notify_participation'])) {
+ $forcedItems[] = 'participation';
+ }
+ if (!empty($post['notify_ptap_comment'])) {
+ $forcedItems[] = 'PTAP comments';
+ }
+ if (!empty($post['notify_class_progress'])) {
+ $forcedItems[] = 'class progress';
+ }
+ if (!empty($post['notify_exam_draft'])) {
+ $forcedItems[] = 'exam draft';
+ }
+
+ $targets = [];
+ foreach ($notify as $sectionIdRaw => $teachers) {
+ $sectionId = (int)$sectionIdRaw;
+ if ($sectionId <= 0 || !is_array($teachers)) {
+ continue;
+ }
+ foreach ($teachers as $teacherIdRaw => $value) {
+ $teacherId = (int)$teacherIdRaw;
+ if ($teacherId <= 0 || $value === null || $value === '') {
+ continue;
+ }
+ $key = "{$sectionId}_{$teacherId}";
+ $targets[$key] = [
+ 'class_section_id' => $sectionId,
+ 'teacher_id' => $teacherId,
+ ];
+ }
+ }
+
+ if (empty($targets)) {
+ return ['redirect' => 'back', 'type' => 'info', 'message' => 'Select at least one teacher to notify.'];
+ }
+
+ $targets = array_values($targets);
+ $teacherIds = array_values(array_unique(array_column($targets, 'teacher_id')));
+ $classSectionIds = array_values(array_unique(array_column($targets, 'class_section_id')));
+
+ $classSections = $this->classSectionModel
+ ->select('class_section_id, class_section_name')
+ ->whereIn('class_section_id', $classSectionIds)
+ ->findAll();
+ $classSectionMap = [];
+ foreach ($classSections as $section) {
+ $classSectionMap[(int)($section['class_section_id'] ?? 0)] = $section['class_section_name'] ?? '';
+ }
+
+ $teachers = $this->userModel
+ ->select('id, firstname, lastname, email')
+ ->whereIn('id', $teacherIds)
+ ->findAll();
+ $teacherLookup = [];
+ foreach ($teachers as $teacher) {
+ $teacherLookup[(int)$teacher['id']] = $teacher;
+ }
+
+ $mailer = new EmailController();
+ if ($adminId <= 0) {
+ return ['redirect' => 'login', 'type' => 'error', 'message' => ''];
+ }
+ $adminUser = $this->userModel->find($adminId);
+ $adminName = trim(($adminUser['firstname'] ?? '') . ' ' . ($adminUser['lastname'] ?? '')) ?: 'Administrator';
+
+ $historyModel = new TeacherSubmissionNotificationHistoryModel();
+ $scoreUrl = site_url('/');
+ $progressUrl = site_url('teacher/progress/history');
+ $examDraftUrl = site_url('teacher/exam-drafts');
+ $homeworkUrl = site_url('teacher/addHomework');
+ $examDraftDeadlineEmailHtml = $this->buildExamDraftDeadlineEmailHtml();
+ $sentCount = 0;
+ $failCount = 0;
+
+ foreach ($targets as $target) {
+ $classSectionId = (int)$target['class_section_id'];
+ $teacherId = (int)$target['teacher_id'];
+ $teacher = $teacherLookup[$teacherId] ?? null;
+ $sectionName = $classSectionMap[$classSectionId] ?? "Section {$classSectionId}";
+ $teacherName = $teacher ? trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? '')) : '';
+ if ($teacherName === '') {
+ $teacherName = 'Teacher';
+ }
+ $subject = "Reminder: Complete submissions for {$sectionName}";
+ $missingPayload = $missingItemsPayload[$classSectionId][$teacherId] ?? '';
+ $missingItems = $this->parseMissingItemsPayload((string)$missingPayload);
+ $selectedItems = $forcedItems;
+ if ($homeworkNotifyAll && !in_array('homework', $selectedItems, true)) {
+ $selectedItems[] = 'homework';
+ }
+ if (!empty($selectedItems)) {
+ $missingItems = array_values(array_unique($selectedItems));
+ }
+ if (!empty($missingItems)) {
+ $missingText = htmlspecialchars(
+ $this->formatMissingItemsText($missingItems),
+ ENT_QUOTES,
+ 'UTF-8'
+ );
+ $missingNote = "Outstanding items: {$missingText}.
";
+ } else {
+ $missingNote = "Our records show no outstanding submissions for this section, but please verify if anything still needs attention.
";
+ }
+
+ $subject = "Reminder: Complete submissions for {$sectionName}";
+ $progressNote = '';
+ if (in_array('class progress', $missingItems, true)) {
+ $progressNote = "Class progress submissions can be updated at Teacher Progress History .
";
+ }
+ $examDraftNote = '';
+ if (in_array('exam draft', $missingItems, true)) {
+ $semesterLabel = strtolower(trim((string) $semester));
+ if ($semesterLabel === 'fall') {
+ $draftLabel = 'midterm exam draft';
+ } elseif ($semesterLabel === 'spring') {
+ $draftLabel = 'final exam draft';
+ } else {
+ $draftLabel = 'exam draft';
+ }
+ $examDraftNote = "" . ucfirst($draftLabel) . " submissions can be updated at Teacher Exam Drafts .
"
+ . $examDraftDeadlineEmailHtml;
+ }
+ $homeworkNote = '';
+ if (in_array('homework', $missingItems, true)) {
+ $homeworkNote = "Homework scores can be submitted at Teacher Homework .
";
+ }
+ $hasScoreItems = (bool) array_intersect($missingItems, [
+ 'midterm scores',
+ 'midterm comments',
+ 'final scores',
+ 'final comments',
+ 'participation',
+ 'PTAP comments',
+ 'homework',
+ ]);
+ $nonScoreOnly = ! empty($missingItems) && ! $hasScoreItems;
+ $body = "Dear {$teacherName},
"
+ . "Administration is gently reminding you to wrap up any remaining "
+ . ($nonScoreOnly ? "submissions for {$sectionName}." : "score submissions, comments, and related items for {$sectionName}.")
+ . "
"
+ . $missingNote
+ . $progressNote
+ . $examDraftNote
+ . $homeworkNote
+ . ($nonScoreOnly ? '' : "Visit Teacher Score Submission to address any remaining items.
")
+ . "Thank you, Al Rahma Administration
";
+
+ $email = $teacher['email'] ?? '';
+ $status = 'failed';
+ if (!empty($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ $ok = $mailer->sendEmail($email, $subject, $body, 'notifications');
+ $status = $ok ? 'sent' : 'failed';
+ }
+
+ if ($status === 'sent') {
+ $sentCount++;
+ } else {
+ $failCount++;
+ }
+
+ $historyModel->insert([
+ 'teacher_id' => $teacherId,
+ 'class_section_id' => $classSectionId,
+ 'admin_id' => $adminId,
+ 'notification_category' => 'teacher_submissions',
+ 'message' => $this->truncateNotificationMessage($body),
+ 'status' => $status,
+ 'school_year' => $this->schoolYear,
+ 'semester' => $this->semester,
+ 'sent_at' => utc_now(),
+ ]);
+ }
+
+ $statusParts = [];
+ if ($sentCount > 0) {
+ $statusParts[] = $sentCount . ' reminder' . ($sentCount === 1 ? '' : 's') . ' sent';
+ }
+ if ($failCount > 0) {
+ $statusParts[] = $failCount . ' reminder' . ($failCount === 1 ? '' : 's') . ' failed';
+ }
+
+ $message = !empty($statusParts) ? implode(' and ', $statusParts) : 'No notifications were sent.';
+ $flashType = $failCount === 0 ? 'success' : 'warning';
+
+ return ['redirect' => 'back', 'type' => $flashType, 'message' => $message];
+}
+
+private function resolveLowProgressSectionIds(array $sectionIds): array
+{
+ [$expectedWeeks, $submittedBySection] = $this->buildClassProgressStats($sectionIds);
+ if ($expectedWeeks <= 0) {
+ return [];
+ }
+
+ $lowProgressSectionIds = [];
+ foreach ($sectionIds as $sectionId) {
+ $submitted = (int) ($submittedBySection[$sectionId] ?? 0);
+ $percent = ($submitted / $expectedWeeks) * 100;
+ if ($percent < 50) {
+ $lowProgressSectionIds[] = $sectionId;
+ }
+ }
+
+ return $lowProgressSectionIds;
+}
+
+private function buildClassProgressStats(array $sectionIds): array
+{
+ $sectionIds = array_values(array_unique(array_filter(array_map('intval', $sectionIds))));
+ if (empty($sectionIds)) {
+ return [0, []];
+ }
+
+ $semesterResolver = new SemesterRangeService($this->configModel);
+ $schoolYear = (string) ($this->schoolYear ?? '');
+ $semester = (string)(getSemester() ?? '');
+ $schoolYearForRange = $schoolYear !== '' ? $schoolYear : (string) ($this->schoolYear ?? '');
+ [$rangeStart, $rangeEnd] = $semesterResolver->getSchoolYearRange($schoolYearForRange);
+ $semesterNorm = $semesterResolver->normalizeSemester($semester);
+ if ($semesterNorm !== '' && $schoolYearForRange !== '') {
+ $semRange = $semesterResolver->getSemesterRange($schoolYearForRange, $semesterNorm);
+ if ($semRange) {
+ [$rangeStart, $rangeEnd] = $semRange;
+ }
+ }
+
+ $dateList = [];
+ try {
+ $start = new \DateTimeImmutable($rangeStart);
+ $end = new \DateTimeImmutable($rangeEnd);
+ $cursor = $start;
+ $w = (int) $cursor->format('w');
+ if ($w !== 0) {
+ $cursor = $cursor->modify('next sunday');
+ }
+ while ($cursor <= $end) {
+ $dateList[] = $cursor->format('Y-m-d');
+ $cursor = $cursor->modify('+7 days');
+ }
+ } catch (\Throwable $e) {
+ $dateList = [];
+ }
+
+ $noSchoolDays = [];
+ $events = [];
+ try {
+ $calendarModel = new \App\Models\CalendarModel();
+ $events = $calendarModel->getEvents();
+ } catch (\Throwable $e) {
+ $events = [];
+ }
+ foreach ($events as $event) {
+ $d = substr((string) ($event['date'] ?? ''), 0, 10);
+ if ($d === '' || empty($event['no_school'])) {
+ continue;
+ }
+ if ($d < $rangeStart || $d > $rangeEnd) {
+ continue;
+ }
+ $eventYear = trim((string) ($event['school_year'] ?? ''));
+ if ($schoolYearForRange !== '' && $eventYear !== '' && $eventYear !== $schoolYearForRange) {
+ continue;
+ }
+ $noSchoolDays[$d] = true;
+ }
+
+ $anchorSundayYmd = '';
+ try {
+ $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
+ $tzObj = new \DateTimeZone($tzName ?: 'UTC');
+ } catch (\Throwable $e) {
+ try {
+ $tzObj = new \DateTimeZone(user_timezone() ?: 'UTC');
+ } catch (\Throwable $e2) {
+ $tzObj = new \DateTimeZone('UTC');
+ }
+ }
+ try {
+ $nowDate = new \DateTime('now', $tzObj);
+ } catch (\Throwable $e) {
+ $nowDate = new \DateTime('now');
+ }
+ $weekday = (int) $nowDate->format('w');
+ $anchorSundayYmd = $weekday === 0
+ ? $nowDate->format('Y-m-d')
+ : $nowDate->modify('next sunday')->format('Y-m-d');
+
+ $activeDatesSet = [];
+ if (! empty($dateList) && $anchorSundayYmd !== '') {
+ foreach ($dateList as $d) {
+ if ($d <= $anchorSundayYmd && empty($noSchoolDays[$d])) {
+ $activeDatesSet[$d] = true;
+ }
+ }
+ }
+ $expectedWeeks = count($activeDatesSet);
+ if ($expectedWeeks === 0) {
+ return [0, []];
+ }
+
+ $builder = $this->db->table('class_progress_reports')
+ ->select('class_section_id, week_start')
+ ->whereIn('class_section_id', $sectionIds);
+ if (! empty($activeDatesSet)) {
+ $builder->whereIn('week_start', array_keys($activeDatesSet));
+ }
+ $rows = $builder->get()->getResultArray();
+
+ $submittedBySection = [];
+ foreach ($rows as $row) {
+ $sectionId = (int) ($row['class_section_id'] ?? 0);
+ $weekStart = (string) ($row['week_start'] ?? '');
+ if ($sectionId === 0 || $weekStart === '' || empty($activeDatesSet[$weekStart])) {
+ continue;
+ }
+ $submittedBySection[$sectionId][$weekStart] = true;
+ }
+
+ $counts = [];
+ foreach ($sectionIds as $sectionId) {
+ $counts[$sectionId] = isset($submittedBySection[$sectionId])
+ ? count($submittedBySection[$sectionId])
+ : 0;
+ }
+
+ return [$expectedWeeks, $counts];
+}
+
+private function submissionStatus(int $filled, int $expected): array
+{
+ if ($expected <= 0) {
+ return [
+ 'label' => 'No students',
+ 'badge' => 'bg-secondary',
+ 'detail' => '',
+ 'completed' => true,
+ ];
+ }
+ $completed = $filled >= $expected;
+ return [
+ 'label' => $completed ? 'Submitted' : 'Missing',
+ 'badge' => $completed ? 'bg-success' : 'bg-danger',
+ 'detail' => "{$filled}/{$expected}",
+ 'completed' => $completed,
+ ];
+}
+
+private function progressStatus(int $submitted, int $expected): array
+{
+ if ($expected <= 0) {
+ return [
+ 'label' => 'N/A',
+ 'badge' => 'bg-secondary',
+ 'detail' => '',
+ 'completed' => true,
+ ];
+ }
+ $completed = $submitted >= $expected;
+ return [
+ 'label' => $completed ? 'Submitted' : 'Missing',
+ 'badge' => $completed ? 'bg-success' : 'bg-danger',
+ 'detail' => "{$submitted}/{$expected}",
+ 'completed' => $completed,
+ ];
+}
+
+private function homeworkStatus(int $submitted): array
+{
+ $completed = $submitted > 0;
+ return [
+ 'label' => $completed ? 'Submitted' : 'Missing',
+ 'badge' => $completed ? 'bg-success' : 'bg-danger',
+ 'detail' => $completed ? (string) $submitted : '0',
+ 'completed' => $completed,
+ ];
+}
+
+private function draftStatus(int $submitted, ?\DateTimeImmutable $deadline): array
+{
+ if ($deadline !== null) {
+ $today = new \DateTimeImmutable('today');
+ if ($today < $deadline) {
+ return [
+ 'label' => 'Pending',
+ 'badge' => 'bg-secondary',
+ 'detail' => 'Not due',
+ 'completed' => true,
+ ];
+ }
+ }
+ $completed = $submitted > 0;
+ return [
+ 'label' => $completed ? 'Submitted' : 'Missing',
+ 'badge' => $completed ? 'bg-success' : 'bg-danger',
+ 'detail' => $completed ? (string) $submitted : '0',
+ 'completed' => $completed,
+ ];
+}
+
+/**
+ * Exam draft due date for the teacher submissions dashboard: prefers the configuration key
+ * `exam_draft_deadline` (same as automated reminders); otherwise fall/spring exam deadlines.
+ */
+private function resolveTeacherDashboardExamDraftDeadline(string $semester, string $schoolYear): ?\DateTimeImmutable
+{
+ $fromExamDraftKey = $this->parseExamDraftDeadlineConfigValue();
+ if ($fromExamDraftKey !== null) {
+ return $fromExamDraftKey;
+ }
+
+ return $this->resolveExamDraftDeadline($semester, $schoolYear);
+}
+
+/**
+ * Parses the `exam_draft_deadline` configuration value using the application timezone (midnight that calendar day).
+ */
+private function parseExamDraftDeadlineConfigValue(): ?\DateTimeImmutable
+{
+ $raw = trim((string) ($this->configModel->getConfig('exam_draft_deadline') ?? ''));
+ if ($raw === '') {
+ return null;
+ }
+ $tz = new \DateTimeZone(config('App')->appTimezone ?? 'UTC');
+ try {
+ $deadline = new \DateTimeImmutable($raw, $tz);
+ } catch (\Throwable $e) {
+ return null;
+ }
+
+ return $deadline->setTime(0, 0, 0);
+}
+
+/**
+ * HTML snippet for reminder emails when exam draft is included (deadline from exam_draft_deadline config).
+ */
+private function buildExamDraftDeadlineEmailHtml(): string
+{
+ $raw = trim((string) ($this->configModel->getConfig('exam_draft_deadline') ?? ''));
+ if ($raw === '') {
+ return '';
+ }
+ $parsed = $this->parseExamDraftDeadlineConfigValue();
+ $display = $parsed !== null
+ ? htmlspecialchars($parsed->format('l, F j, Y'), ENT_QUOTES, 'UTF-8')
+ : htmlspecialchars($raw, ENT_QUOTES, 'UTF-8');
+ $rawEsc = htmlspecialchars($raw, ENT_QUOTES, 'UTF-8');
+
+ return 'Exam draft submission deadline (exam_draft_deadline): '
+ . "{$display} "
+ . ($parsed !== null && $rawEsc !== $display ? " (configured value: {$rawEsc}) " : '')
+ . '.
';
+}
+
+private function resolveExamDraftDeadline(string $semester, string $schoolYear): ?\DateTimeImmutable
+{
+ $semesterKey = strtolower(trim($semester));
+ if ($semesterKey === 'fall') {
+ $deadlineValue = (string)($this->configModel->getConfig('fall_exam_deadline') ?? '');
+ } elseif ($semesterKey === 'spring') {
+ $deadlineValue = (string)($this->configModel->getConfig('spring_exam_deadline') ?? '');
+ } else {
+ return null;
+ }
+ $deadlineValue = trim($deadlineValue);
+ if ($deadlineValue === '') {
+ return null;
+ }
+ try {
+ $deadline = new \DateTimeImmutable($deadlineValue);
+ } catch (\Throwable $e) {
+ return null;
+ }
+ if ($schoolYear !== '' && preg_match('/^\d{4}-\d{4}$/', $schoolYear)) {
+ $deadlineYear = $deadline->format('Y');
+ if ($deadlineYear === '1970') {
+ return null;
+ }
+ }
+ return $deadline->setTime(0, 0, 0);
+}
+
+private function resolveExamTermLabel(string $semester): string
+{
+ $semesterKey = strtolower(trim($semester));
+ if ($semesterKey === '') {
+ return 'midterm';
+ }
+ if (str_contains($semesterKey, 'spring')) {
+ return 'final';
+ }
+ if (str_contains($semesterKey, 'fall')) {
+ return 'midterm';
+ }
+ return 'midterm';
+}
+
+private function buildSemesterCandidates(string $semester): array
+{
+ $semester = trim((string) $semester);
+ if ($semester === '') {
+ return [];
+ }
+ $candidates = [
+ $semester,
+ strtolower($semester),
+ strtoupper($semester),
+ ucfirst(strtolower($semester)),
+ ];
+ $candidates = array_values(array_unique(array_filter($candidates, static fn ($v) => $v !== '')));
+ return $candidates;
+}
+
+private function attendanceStatus(bool $submitted): array
+{
+ return [
+ 'label' => $submitted ? 'Submitted' : 'Missing',
+ 'badge' => $submitted ? 'bg-success' : 'bg-danger',
+ 'completed' => $submitted,
+ ];
+}
+
+private function buildMissingItems(array $statusMap, string $semester): array
+{
+ $examTerm = $this->resolveExamTermLabel($semester);
+ $examScoreLabel = $examTerm === 'final' ? 'final scores' : 'midterm scores';
+ $examCommentLabel = $examTerm === 'final' ? 'final comments' : 'midterm comments';
+ $labels = [
+ 'midterm_score_status' => $examScoreLabel,
+ 'midterm_comment_status' => $examCommentLabel,
+ 'participation_status' => 'participation',
+ 'ptap_comment_status' => 'PTAP comments',
+ 'attendance_status' => 'attendance',
+ 'class_progress_status' => 'class progress',
+ 'exam_draft_status' => 'exam draft',
+ 'homework_status' => 'homework',
+ ];
+
+ $items = [];
+ foreach ($statusMap as $key => $status) {
+ $completed = $status['completed'] ?? true;
+ if (!$completed && isset($labels[$key])) {
+ $items[] = $labels[$key];
+ }
+ }
+
+ return array_values($items);
+}
+
+private function formatMissingItemsText(array $items): string
+{
+ $items = array_values(array_filter(array_map('trim', $items), static fn($v) => $v !== ''));
+ $count = count($items);
+ if ($count === 0) {
+ return '';
+ }
+ if ($count === 1) {
+ return $items[0];
+ }
+ if ($count === 2) {
+ return $items[0] . ' and ' . $items[1];
+ }
+ $last = array_pop($items);
+ return implode(', ', $items) . ' and ' . $last;
+}
+
+private function teacherRolePriority(string $roleKey): int
+{
+ switch (strtolower($roleKey)) {
+ case 'main':
+ return 1;
+ case 'ta':
+ return 2;
+ default:
+ return 3;
+ }
+}
+
+private function truncateNotificationMessage(string $html, int $limit = 1000): string
+{
+ $text = trim(strip_tags($html));
+ if ($text === '') {
+ return '';
+ }
+ if (mb_strlen($text) <= $limit) {
+ return $text;
+ }
+ return mb_substr($text, 0, $limit) . '…';
+}
+
+private function parseMissingItemsPayload(string $payload): array
+{
+ if ($payload === '') {
+ return [];
+ }
+ $decoded = @json_decode(base64_decode($payload, true) ?: '', true);
+ if (!is_array($decoded)) {
+ return [];
+ }
+
+ $items = [];
+ foreach ($decoded as $item) {
+ $item = trim((string)$item);
+ if ($item === '') {
+ continue;
+ }
+ $items[] = $item;
+ }
+
+ return array_values(array_unique($items));
+}
+}