Files
alrahma_sunday_school/app/Services/BelowSixtyService.php
T
root 2b0206e7f2
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Successful in 1m16s
move service logic from grading and administrator controller
2026-08-20 16:59:51 -04:00

1932 lines
65 KiB
PHP

<?php
namespace App\Services;
use App\Models\BelowSixtyDecisionModel;
use App\Models\ConfigurationModel;
use App\Models\ParentMeetingScheduleModel;
use App\Models\StudentClassModel;
use App\Models\StudentDecisionModel;
use App\Models\StudentModel;
use App\Models\UserModel;
use App\Services\NavbarService;
use App\Support\Enrollment\DeliberationDecision;
use CodeIgniter\Events\Events;
class BelowSixtyService
{
protected $db;
protected $configModel;
protected $studentModel;
protected $studentClassModel;
protected $parentMeetingModel;
protected $userModel;
protected string $schoolYear = '';
protected string $semester = '';
public function __construct(
\CodeIgniter\Database\BaseConnection $db,
ConfigurationModel $configModel,
StudentModel $studentModel,
StudentClassModel $studentClassModel,
ParentMeetingScheduleModel $parentMeetingModel,
UserModel $userModel,
string $schoolYear = '',
string $semester = ''
) {
$this->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 = '
<p>Dear Parent/Guardian,</p>
<p>
This message is regarding <strong>' . esc($studentName) . '</strong>
for the <strong>' . esc($schoolYear) . '</strong> school year.
</p>
<p>
Class: <strong>' . esc($classSectionName !== '' ? $classSectionName : 'N/A') . '</strong><br>
Fall Score: <strong>' . esc($fallText) . '</strong><br>
Spring Score: <strong>' . esc($springText) . '</strong><br>
Whole-Year Score: <strong>' . esc($yearText) . '</strong><br>
Decision: <strong>' . esc($decision) . '</strong>
</p>
';
if ($notes !== '') {
$html .= '
<p>
<strong>Decision Notes:</strong><br>
' . nl2br(esc($notes)) . '
</p>
';
}
/*
* Add the exact Details-button score/comment content into the email.
*/
$html .= $this->buildDecisionEmailDetailsHtml($allSemesters);
$html .= '
<p>
Please contact the school administration if you have any questions.
</p>
<p>
Regards,<br>
Al Rahma Sunday School
</p>
';
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 '
<p><strong>Detailed Scores and Comments</strong></p>
<p>No detailed score data found.</p>
';
}
$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 = '
<hr>
<p><strong>Detailed Scores and Comments</strong></p>
';
foreach ($semesters as $sem) {
$semesterName = trim((string)($sem['semester'] ?? ''));
if ($semesterName === '') {
$semesterName = 'Semester';
}
$classSectionName = trim((string)($sem['class_section_name'] ?? ''));
$html .= '
<p style="margin-top:16px;margin-bottom:6px;">
<strong>' . esc($semesterName) . ' Semester</strong>';
if ($classSectionName !== '') {
$html .= ' — ' . esc($classSectionName);
}
$html .= '
</p>
<table border="1"
cellpadding="6"
cellspacing="0"
style="border-collapse:collapse;width:100%;margin-bottom:10px;">
<thead>
<tr>
<th align="left" style="background:#f2f2f2;">Item</th>
<th align="right" style="background:#f2f2f2;">Score</th>
</tr>
</thead>
<tbody>
';
$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 .= '
<tr>
<td>' . esc($label) . '</td>
<td align="right" style="' . $fontWeight . '">' . esc($scoreText) . '</td>
</tr>
';
$hasScoreRow = true;
}
if (!$hasScoreRow) {
$html .= '
<tr>
<td colspan="2">No scores recorded.</td>
</tr>
';
}
$html .= '
</tbody>
</table>
';
$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 .= '
<p style="margin:8px 0 4px;"><strong>Comments</strong></p>
';
foreach ($deduped as $comment) {
$html .= '
<p style="margin:4px 0 8px;padding:8px;background:#f8f9fa;border-left:4px solid #999;">
<strong>' . esc($comment['label']) . ':</strong><br>
' . nl2br(esc($comment['text'])) . '
</p>
';
}
}
}
}
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;
}
}