2252 lines
93 KiB
PHP
2252 lines
93 KiB
PHP
<?php
|
|
|
|
namespace App\Controllers\View;
|
|
|
|
use App\Models\CalendarModel;
|
|
use App\Models\AttendanceDataModel;
|
|
use App\Models\ReportCardAcknowledgementModel;
|
|
use App\Services\SemesterRangeService;
|
|
|
|
class ReportCardsController extends PrintablesBaseController
|
|
{
|
|
protected $calendarModel;
|
|
protected $semesterRangeService;
|
|
protected $ackModel;
|
|
|
|
public function __construct()
|
|
{
|
|
parent::__construct();
|
|
$this->calendarModel = new CalendarModel();
|
|
$this->semesterRangeService = new SemesterRangeService($this->configModel);
|
|
$this->ackModel = new ReportCardAcknowledgementModel();
|
|
}
|
|
|
|
public function report_card()
|
|
{
|
|
return view('printables_reports/report_card', [
|
|
'schoolYear' => (string)($this->schoolYear ?? ''),
|
|
'semester' => (string)($this->semester ?? ''),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* API: report card meta for UI hydration
|
|
* GET params: school_year
|
|
* Returns: { ok, schoolYears, selectedYear, students:[{id,firstname,lastname}], classSections:[{id,name}] }
|
|
*/
|
|
public function reportCardMeta()
|
|
{
|
|
$year = trim((string)($this->request->getGet('school_year') ?? $this->schoolYear ?? ''));
|
|
$sem = trim((string)($this->request->getGet('semester') ?? $this->semester ?? ''));
|
|
if ($year === '') {
|
|
// fallback to latest present in classSection or semester_score
|
|
$yr = $this->db->table('classSection')->select('school_year')->orderBy('school_year', 'DESC')->get(1)->getRowArray();
|
|
$year = (string)($yr['school_year'] ?? '');
|
|
}
|
|
|
|
// Build schoolYears from classSection and semester_scores
|
|
$schoolYears = [];
|
|
try {
|
|
$q1 = $this->db->table('classSection')->select('DISTINCT school_year', false)->where('school_year IS NOT NULL', null, false)->orderBy('school_year', 'DESC')->get()->getResultArray();
|
|
$schoolYears = array_values(array_filter(array_map(static fn($r) => (string)($r['school_year'] ?? ''), $q1)));
|
|
} catch (\Throwable $e) {
|
|
}
|
|
try {
|
|
$q2 = $this->db->table('semester_scores')->select('DISTINCT school_year', false)->where('school_year IS NOT NULL', null, false)->orderBy('school_year', 'DESC')->get()->getResultArray();
|
|
foreach ($q2 as $r) {
|
|
$val = (string)($r['school_year'] ?? '');
|
|
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
}
|
|
rsort($schoolYears);
|
|
|
|
// Semesters available for the selected year (from scores + rosters), with defaults
|
|
$semesters = [];
|
|
try {
|
|
$rs = $this->db->table('semester_scores')
|
|
->select('DISTINCT semester', false)
|
|
->where('school_year', $year)
|
|
->where('semester IS NOT NULL', null, false)
|
|
->orderBy('semester', 'ASC')
|
|
->get()->getResultArray();
|
|
$semesters = array_values(array_filter(array_map(static fn($r) => (string)($r['semester'] ?? ''), $rs)));
|
|
} catch (\Throwable $e) {
|
|
}
|
|
// Ensure standard options are present even if data is missing (so Spring can be selected)
|
|
$defaults = array_values(array_filter([(string)($this->semester ?? ''), 'Fall', 'Spring'], static fn($v) => $v !== ''));
|
|
foreach ($defaults as $d) {
|
|
if (!in_array($d, $semesters, true)) $semesters[] = $d;
|
|
}
|
|
$semesters = array_values(array_unique($semesters));
|
|
// Preserve existing selected semester if provided, otherwise pick the first
|
|
if ($sem === '' && !empty($semesters)) {
|
|
$sem = $semesters[0];
|
|
} elseif ($sem !== '' && !in_array($sem, $semesters, true)) {
|
|
array_unshift($semesters, $sem);
|
|
$semesters = array_values(array_unique($semesters));
|
|
}
|
|
|
|
// Students: prefer those with scores in selected year (+semester if provided); fallback to all
|
|
$students = [];
|
|
try {
|
|
$builder = $this->db->table('semester_scores ss')
|
|
->select('s.id, s.firstname, s.lastname, GROUP_CONCAT(DISTINCT cs.class_section_name ORDER BY cs.class_section_name SEPARATOR ", ") AS class_section_name', false)
|
|
->join('students s', 's.id = ss.student_id', 'inner')
|
|
->join('student_class sc', 'sc.student_id = s.id AND sc.school_year = ss.school_year', 'left')
|
|
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
|
|
->where('s.is_active', 1)
|
|
->where('ss.school_year', $year);
|
|
if ($sem !== '') {
|
|
$this->applySemesterFilter($builder, $sem, 'ss.semester');
|
|
}
|
|
$stuRows = $builder
|
|
->groupBy('s.id, s.firstname, s.lastname')
|
|
->orderBy('class_section_name', 'ASC')
|
|
->orderBy('s.firstname', 'ASC')
|
|
->orderBy('s.lastname', 'ASC')
|
|
->get()
|
|
->getResultArray();
|
|
if (!empty($stuRows)) $students = $stuRows;
|
|
} catch (\Throwable $e) {
|
|
}
|
|
if (empty($students)) {
|
|
$fallback = $this->studentModel
|
|
->select('students.id, students.firstname, students.lastname, GROUP_CONCAT(DISTINCT cs.class_section_name ORDER BY cs.class_section_name SEPARATOR ", ") AS class_section_name', false)
|
|
->join('student_class sc', 'sc.student_id = students.id', 'left')
|
|
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left');
|
|
if ($year !== '') {
|
|
$fallback->where('sc.school_year', $year);
|
|
}
|
|
$students = $fallback
|
|
->where('students.is_active', 1)
|
|
->groupBy('students.id, students.firstname, students.lastname')
|
|
->orderBy('class_section_name', 'ASC')
|
|
->orderBy('students.firstname', 'ASC')
|
|
->orderBy('students.lastname', 'ASC')
|
|
->findAll();
|
|
}
|
|
|
|
// Class sections with at least one student for the selected year/semester
|
|
$classSections = [];
|
|
try {
|
|
$builder = $this->db->table('classSection cs')
|
|
->select('cs.id, cs.class_section_id, cs.class_section_name, cs.school_year')
|
|
->join('student_class sc', 'sc.class_section_id = cs.class_section_id', 'inner')
|
|
->join('students s', 's.id = sc.student_id', 'inner')
|
|
->where('s.is_active', 1)
|
|
->where('sc.school_year', $year);
|
|
$classSections = $builder
|
|
->groupBy('cs.id, cs.class_section_id, cs.class_section_name, cs.school_year')
|
|
->orderBy('cs.class_section_name', 'ASC')
|
|
->get()
|
|
->getResultArray();
|
|
|
|
// If no matches for the semester filter, retry for the year only
|
|
if (empty($classSections) && $sem !== '') {
|
|
$classSections = $this->db->table('classSection cs')
|
|
->select('cs.id, cs.class_section_id, cs.class_section_name, cs.school_year')
|
|
->join('student_class sc', 'sc.class_section_id = cs.class_section_id', 'inner')
|
|
->join('students s', 's.id = sc.student_id', 'inner')
|
|
->where('s.is_active', 1)
|
|
->where('sc.school_year', $year)
|
|
->groupBy('cs.id, cs.class_section_id, cs.class_section_name, cs.school_year')
|
|
->orderBy('cs.class_section_name', 'ASC')
|
|
->get()
|
|
->getResultArray();
|
|
}
|
|
} catch (\Throwable $e) {
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'ok' => true,
|
|
'schoolYears' => $schoolYears,
|
|
'selectedYear' => $year,
|
|
'semesters' => $semesters,
|
|
'selectedSemester' => $sem,
|
|
'students' => $students,
|
|
'classSections' => array_map(static fn($r) => [
|
|
'id' => (int)($r['id'] ?? 0),
|
|
'class_section_id' => (int)($r['class_section_id'] ?? ($r['id'] ?? 0)),
|
|
'class_section_name' => (string)($r['class_section_name'] ?? 'Section'),
|
|
'school_year' => (string)($r['school_year'] ?? ''),
|
|
], $classSections),
|
|
'csrf_token' => csrf_token(),
|
|
'csrf_hash' => csrf_hash(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* API: report card completeness check for a class section.
|
|
* GET params: class_section_id, school_year, semester
|
|
* Returns: { ok, summary, students:[{id,name,missing[],warnings[],complete}] }
|
|
*/
|
|
public function reportCardCompleteness()
|
|
{
|
|
$year = trim((string)($this->request->getGet('school_year') ?? $this->schoolYear ?? ''));
|
|
$sem = trim((string)($this->request->getGet('semester') ?? $this->semester ?? ''));
|
|
$sectionInput = (int)($this->request->getGet('class_section_id') ?? 0);
|
|
|
|
if ($year === '' || $sectionInput <= 0) {
|
|
return $this->response->setJSON([
|
|
'ok' => false,
|
|
'message' => 'Missing school year or class section.',
|
|
]);
|
|
}
|
|
|
|
$sectionCode = $sectionInput;
|
|
$sectionId = null;
|
|
$sectionName = '';
|
|
try {
|
|
$row = $this->db->table('classSection')
|
|
->select('id, class_section_id, class_section_name')
|
|
->where('class_section_id', $sectionInput)
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray();
|
|
if ($row) {
|
|
$sectionId = (int)$row['id'];
|
|
$sectionCode = (int)$row['class_section_id'];
|
|
$sectionName = (string)($row['class_section_name'] ?? '');
|
|
} else {
|
|
$row = $this->db->table('classSection')
|
|
->select('id, class_section_id, class_section_name')
|
|
->where('id', $sectionInput)
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray();
|
|
if ($row) {
|
|
$sectionId = (int)$row['id'];
|
|
$sectionCode = (int)$row['class_section_id'];
|
|
$sectionName = (string)($row['class_section_name'] ?? '');
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
}
|
|
|
|
if ($sectionName === '') {
|
|
$sectionName = (string)($this->resolveClassName($this->db, $sectionCode) ?? '');
|
|
}
|
|
|
|
$students = $this->fetchStudentsByClass($sectionCode, $year);
|
|
if (empty($students) && $sectionId && $sectionId !== $sectionCode) {
|
|
$students = $this->fetchStudentsByClass($sectionId, $year);
|
|
}
|
|
$rosterSemester = $sem;
|
|
$usedFallback = false;
|
|
if (empty($students) && $sem !== '') {
|
|
$students = $this->fetchStudentsByClass($sectionCode, $year);
|
|
if (empty($students) && $sectionId && $sectionId !== $sectionCode) {
|
|
$students = $this->fetchStudentsByClass($sectionId, $year);
|
|
}
|
|
$rosterSemester = '';
|
|
$usedFallback = !empty($students);
|
|
}
|
|
|
|
if (empty($students)) {
|
|
return $this->response->setJSON([
|
|
'ok' => true,
|
|
'message' => 'No students found for the selected class and year.',
|
|
'summary' => [
|
|
'total' => 0,
|
|
'complete' => 0,
|
|
'incomplete' => 0,
|
|
'warnings' => 0,
|
|
],
|
|
'students' => [],
|
|
'roster_semester' => $rosterSemester,
|
|
'used_fallback' => $usedFallback,
|
|
]);
|
|
}
|
|
|
|
$studentIds = array_values(array_filter(array_map(
|
|
static fn($s) => (int)($s['id'] ?? 0),
|
|
$students
|
|
), static fn($v) => $v > 0));
|
|
|
|
if (empty($studentIds)) {
|
|
return $this->response->setJSON([
|
|
'ok' => false,
|
|
'message' => 'No valid students found.',
|
|
]);
|
|
}
|
|
|
|
$scoreRows = $this->semesterScoreModel
|
|
->whereIn('student_id', $studentIds)
|
|
->where('school_year', $year);
|
|
if ($sem !== '') {
|
|
$this->applySemesterFilter($scoreRows, $sem, 'semester');
|
|
}
|
|
$scoreRows = $scoreRows
|
|
->orderBy('updated_at', 'DESC')
|
|
->orderBy('id', 'DESC')
|
|
->findAll();
|
|
|
|
$scoreByStudent = [];
|
|
foreach ($scoreRows as $row) {
|
|
$sid = (int)($row['student_id'] ?? 0);
|
|
if ($sid > 0 && !isset($scoreByStudent[$sid])) {
|
|
$scoreByStudent[$sid] = $row;
|
|
}
|
|
}
|
|
|
|
$semLower = strtolower(trim($sem));
|
|
$isFirst = in_array($semLower, ['fall', 'first', 'semester 1', '1'], true);
|
|
$isSecond = in_array($semLower, ['spring', 'second', 'semester 2', '2'], true);
|
|
|
|
$firstScoreByStudent = [];
|
|
if ($isSecond) {
|
|
$otherRows = $this->semesterScoreModel
|
|
->whereIn('student_id', $studentIds)
|
|
->where('school_year', $year);
|
|
if ($sem !== '') {
|
|
$this->applySemesterExclusion($otherRows, $sem, 'semester');
|
|
}
|
|
$otherRows = $otherRows
|
|
->orderBy('updated_at', 'DESC')
|
|
->orderBy('id', 'DESC')
|
|
->findAll();
|
|
foreach ($otherRows as $row) {
|
|
$sid = (int)($row['student_id'] ?? 0);
|
|
if ($sid > 0 && !isset($firstScoreByStudent[$sid])) {
|
|
$firstScoreByStudent[$sid] = $row;
|
|
}
|
|
}
|
|
}
|
|
|
|
$examCommentTypes = $isSecond
|
|
? ['final']
|
|
: ($isFirst ? ['midterm'] : ['midterm', 'final']);
|
|
$wantedCommentTypes = array_merge($examCommentTypes, ['ptap', 'attendance']);
|
|
$hasCommentReview = $this->db->fieldExists('comment_review', 'score_comments');
|
|
$hasCommentSemester = $this->db->fieldExists('semester', 'score_comments');
|
|
$hasCommentUpdatedAt = $this->db->fieldExists('updated_at', 'score_comments');
|
|
$commentSelectParts = ['student_id', 'score_type', 'comment'];
|
|
if ($hasCommentReview) {
|
|
$commentSelectParts[] = 'comment_review';
|
|
}
|
|
if ($hasCommentSemester) {
|
|
$commentSelectParts[] = 'semester';
|
|
}
|
|
|
|
// Do NOT filter comments by score_type or semester here.
|
|
// A single bad row like "Attendance Comment", "attendance-comment", or a blank semester
|
|
// should not make one student look incomplete while the PDF can still print the comment.
|
|
$commentBuilder = $this->scoreCommentModel
|
|
->select(implode(', ', $commentSelectParts))
|
|
->whereIn('student_id', $studentIds)
|
|
->where('school_year', $year);
|
|
if ($hasCommentUpdatedAt) {
|
|
$commentBuilder->orderBy('updated_at', 'DESC');
|
|
}
|
|
$commentRows = [];
|
|
try {
|
|
$commentRows = $commentBuilder->findAll();
|
|
} catch (\Throwable $e) {
|
|
}
|
|
|
|
$cleanComment = static function ($value): string {
|
|
$text = (string)($value ?? '');
|
|
$text = str_replace("\xc2\xa0", ' ', $text); // normalize non-breaking spaces
|
|
return trim($text);
|
|
};
|
|
|
|
$normalizeCommentType = static function ($value) use ($cleanComment): string {
|
|
$type = strtolower($cleanComment($value));
|
|
$type = preg_replace('/[^a-z0-9]+/', '_', $type);
|
|
$type = trim((string)$type, '_');
|
|
|
|
$map = [
|
|
'attendance_comment' => 'attendance',
|
|
'attendance_comments' => 'attendance',
|
|
'attendance' => 'attendance',
|
|
'attendence' => 'attendance',
|
|
'attendence_comment' => 'attendance',
|
|
'attendence_comments' => 'attendance',
|
|
'ptap_comment' => 'ptap',
|
|
'ptap_comments' => 'ptap',
|
|
'ptap' => 'ptap',
|
|
'midterm_comment' => 'midterm',
|
|
'midterm_comments' => 'midterm',
|
|
'midterm' => 'midterm',
|
|
'final_comment' => 'final',
|
|
'final_comments' => 'final',
|
|
'final' => 'final',
|
|
];
|
|
|
|
return $map[$type] ?? $type;
|
|
};
|
|
|
|
$normalizeSemester = static function ($value) use ($cleanComment): string {
|
|
$v = strtolower($cleanComment($value));
|
|
$v = preg_replace('/[^a-z0-9]+/', ' ', $v);
|
|
$v = trim((string)$v);
|
|
if (in_array($v, ['fall', 'first', 'first semester', 'semester 1', '1'], true)) {
|
|
return 'fall';
|
|
}
|
|
if (in_array($v, ['spring', 'second', 'second semester', 'semester 2', '2'], true)) {
|
|
return 'spring';
|
|
}
|
|
return $v;
|
|
};
|
|
$wantedSemester = $normalizeSemester($sem);
|
|
|
|
$commentsByStudent = [];
|
|
$commentPriorityByStudent = [];
|
|
foreach ($commentRows as $row) {
|
|
$sid = (int)($row['student_id'] ?? 0);
|
|
if ($sid <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$typeRaw = $normalizeCommentType($row['score_type'] ?? '');
|
|
if ($typeRaw === '' || !in_array($typeRaw, $wantedCommentTypes, true)) {
|
|
continue;
|
|
}
|
|
|
|
$rawVal = $cleanComment($row['comment'] ?? '');
|
|
if ($typeRaw === 'attendance') {
|
|
// Attendance comments are stored in score_comments.comment.
|
|
// Do not use comment_review here, because it can be blank while the PDF comment exists.
|
|
$commentVal = $rawVal;
|
|
} else {
|
|
// For non-attendance comments, prefer reviewed text but fall back to the saved comment.
|
|
$reviewVal = $hasCommentReview ? $cleanComment($row['comment_review'] ?? '') : '';
|
|
$commentVal = $reviewVal !== '' ? $reviewVal : $rawVal;
|
|
}
|
|
if ($commentVal === '') {
|
|
continue;
|
|
}
|
|
|
|
$rowSemester = $hasCommentSemester ? $normalizeSemester($row['semester'] ?? '') : '';
|
|
$priority = 1;
|
|
if ($wantedSemester !== '' && $rowSemester === $wantedSemester) {
|
|
$priority = 3;
|
|
} elseif ($rowSemester === '') {
|
|
$priority = 2;
|
|
}
|
|
|
|
$existingPriority = $commentPriorityByStudent[$sid][$typeRaw] ?? 0;
|
|
if (!isset($commentsByStudent[$sid][$typeRaw]) || $priority >= $existingPriority) {
|
|
$commentsByStudent[$sid][$typeRaw] = $commentVal;
|
|
$commentPriorityByStudent[$sid][$typeRaw] = $priority;
|
|
}
|
|
}
|
|
|
|
$isNumeric = static fn($v) => $v !== null && $v !== '' && is_numeric($v);
|
|
|
|
$ackMap = [];
|
|
try {
|
|
$ackRows = $this->ackModel
|
|
->whereIn('student_id', $studentIds)
|
|
->where('school_year', $year)
|
|
->where('semester', $sem)
|
|
->orderBy('updated_at', 'DESC')
|
|
->findAll();
|
|
foreach ($ackRows as $row) {
|
|
$sid = (int) ($row['student_id'] ?? 0);
|
|
if ($sid > 0 && ! isset($ackMap[$sid])) {
|
|
$ackMap[$sid] = $row;
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$ackMap = [];
|
|
}
|
|
|
|
$results = [];
|
|
$completeCount = 0;
|
|
$warningCount = 0;
|
|
foreach ($students as $student) {
|
|
$sid = (int)($student['id'] ?? 0);
|
|
$name = trim(((string)($student['firstname'] ?? '')) . ' ' . ((string)($student['lastname'] ?? '')));
|
|
$missing = [];
|
|
$warnings = [];
|
|
|
|
if ($sectionName === '') {
|
|
$missing[] = 'Grade/Section';
|
|
}
|
|
|
|
$score = $sid > 0 ? ($scoreByStudent[$sid] ?? null) : null;
|
|
$examScore = null;
|
|
$attendanceScore = null;
|
|
$ptapScore = null;
|
|
$semesterScore = null;
|
|
$hasAllComponents = false;
|
|
|
|
if (!$score) {
|
|
$missing[] = 'Score record';
|
|
} else {
|
|
$attendanceScore = $score['attendance_score'] ?? null;
|
|
$ptapScore = $score['ptap_score'] ?? null;
|
|
$semesterScore = $score['semester_score'] ?? null;
|
|
|
|
$components = [
|
|
$score['homework_avg'] ?? null,
|
|
$score['quiz_avg'] ?? null,
|
|
$score['project_avg'] ?? null,
|
|
$score['test_avg'] ?? null,
|
|
];
|
|
$hasAllComponents = !in_array(false, array_map($isNumeric, $components), true);
|
|
|
|
if ($isSecond) {
|
|
$examScore = $score['final_exam_score'] ?? null;
|
|
if (!$isNumeric($examScore) && $isNumeric($score['midterm_exam_score'] ?? null)) {
|
|
$examScore = $score['midterm_exam_score'];
|
|
$warnings[] = 'Final exam stored as midterm';
|
|
}
|
|
} elseif ($isFirst) {
|
|
$examScore = $score['midterm_exam_score'] ?? null;
|
|
} else {
|
|
$examScore = $score['final_exam_score'] ?? ($score['midterm_exam_score'] ?? null);
|
|
}
|
|
|
|
if (!$isNumeric($examScore)) {
|
|
$missing[] = $isSecond ? 'Final exam score' : ($isFirst ? 'Midterm exam score' : 'Exam score');
|
|
}
|
|
if (!$isNumeric($attendanceScore)) {
|
|
$missing[] = 'Attendance score';
|
|
}
|
|
if (!$isNumeric($ptapScore)) {
|
|
if ($hasAllComponents) {
|
|
$warnings[] = 'PTAP computed from components';
|
|
} else {
|
|
$missing[] = 'PTAP score';
|
|
}
|
|
}
|
|
|
|
$canComputeSemester = $isNumeric($examScore) && $isNumeric($attendanceScore) && ($isNumeric($ptapScore) || $hasAllComponents);
|
|
if (!$isNumeric($semesterScore)) {
|
|
if ($canComputeSemester) {
|
|
$warnings[] = 'Semester score computed';
|
|
} else {
|
|
$missing[] = 'Semester score';
|
|
}
|
|
}
|
|
|
|
if ($isSecond) {
|
|
$firstScore = $firstScoreByStudent[$sid]['semester_score'] ?? null;
|
|
if (!$isNumeric($firstScore)) {
|
|
$missing[] = 'First semester score';
|
|
}
|
|
}
|
|
}
|
|
|
|
$commentKey = $isSecond ? 'final' : ($isFirst ? 'midterm' : '');
|
|
$commentLabel = $isSecond ? 'Final' : ($isFirst ? 'Midterm' : 'Exam');
|
|
$commentSet = $commentsByStudent[$sid] ?? [];
|
|
$examComment = $commentKey !== ''
|
|
? ($commentSet[$commentKey] ?? '')
|
|
: ($commentSet['final'] ?? ($commentSet['midterm'] ?? ''));
|
|
if (trim((string)$examComment) === '') {
|
|
$missing[] = $commentLabel . ' comment';
|
|
}
|
|
if (trim((string)($commentSet['ptap'] ?? '')) === '') {
|
|
$missing[] = 'PTAP comment';
|
|
}
|
|
|
|
// Keep completeness consistent with the PDF report generation:
|
|
// the report can auto-generate an attendance comment from attendance_score.
|
|
if (trim((string)($commentSet['attendance'] ?? '')) === '' && $isNumeric($attendanceScore)) {
|
|
$autoAttendance = attendance_comment_from_score(
|
|
(float)$attendanceScore,
|
|
trim((string)($student['firstname'] ?? ''))
|
|
);
|
|
if ($autoAttendance !== null && trim((string)$autoAttendance) !== '') {
|
|
$commentSet['attendance'] = $autoAttendance;
|
|
$warnings[] = 'Attendance comment computed from attendance score';
|
|
}
|
|
}
|
|
|
|
if (trim((string)($commentSet['attendance'] ?? '')) === '') {
|
|
$missing[] = 'Attendance comment';
|
|
}
|
|
|
|
$isComplete = empty($missing);
|
|
if ($isComplete) {
|
|
$completeCount++;
|
|
}
|
|
if (!empty($warnings)) {
|
|
$warningCount++;
|
|
}
|
|
|
|
$ack = $sid > 0 ? ($ackMap[$sid] ?? null) : null;
|
|
$results[] = [
|
|
'id' => $sid,
|
|
'name' => $name !== '' ? $name : 'N/A',
|
|
'missing' => $missing,
|
|
'warnings' => $warnings,
|
|
'complete' => $isComplete,
|
|
'viewed_at' => $ack['viewed_at'] ?? null,
|
|
'signed_at' => $ack['signed_at'] ?? null,
|
|
'signed_name' => $ack['signed_name'] ?? null,
|
|
];
|
|
}
|
|
|
|
$total = count($results);
|
|
$incomplete = $total - $completeCount;
|
|
|
|
return $this->response->setJSON([
|
|
'ok' => true,
|
|
'summary' => [
|
|
'total' => $total,
|
|
'complete' => $completeCount,
|
|
'incomplete' => $incomplete,
|
|
'warnings' => $warningCount,
|
|
],
|
|
'students' => $results,
|
|
'class_section_name' => $sectionName,
|
|
'class_section_id' => $sectionCode,
|
|
'school_year' => $year,
|
|
'semester' => $sem,
|
|
'roster_semester' => $rosterSemester,
|
|
'used_fallback' => $usedFallback,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* API: report card parent acknowledgement status
|
|
* GET params: student_id, school_year, semester
|
|
*/
|
|
public function reportCardAcknowledgement()
|
|
{
|
|
$studentId = (int) ($this->request->getGet('student_id') ?? 0);
|
|
if ($studentId <= 0) {
|
|
return $this->response->setJSON(['ok' => false, 'error' => 'Missing student_id'])->setStatusCode(400);
|
|
}
|
|
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->schoolYear ?? ''));
|
|
$semester = trim((string) ($this->request->getGet('semester') ?? $this->semester ?? ''));
|
|
|
|
$row = $this->ackModel
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $schoolYear)
|
|
->where('semester', $semester)
|
|
->orderBy('updated_at', 'DESC')
|
|
->first();
|
|
|
|
return $this->response->setJSON([
|
|
'ok' => true,
|
|
'student_id' => $studentId,
|
|
'school_year' => $schoolYear,
|
|
'semester' => $semester,
|
|
'viewed_at' => $row['viewed_at'] ?? null,
|
|
'signed_at' => $row['signed_at'] ?? null,
|
|
'signed_name' => $row['signed_name'] ?? null,
|
|
]);
|
|
}
|
|
|
|
protected function fetchStudentsByClass(int $sectionId, ?string $schoolYear)
|
|
{
|
|
$b = $this->studentClassModel
|
|
->select('s.id, s.firstname, s.lastname, s.registration_grade, s.gender')
|
|
->join('students s', 's.id = student_class.student_id', 'inner')
|
|
->where('student_class.class_section_id', $sectionId)
|
|
->where('s.is_active', 1)
|
|
->orderBy('s.firstname', 'ASC')
|
|
->orderBy('s.lastname', 'ASC');
|
|
|
|
if (!empty($schoolYear)) {
|
|
$b->where('student_class.school_year', $schoolYear);
|
|
}
|
|
|
|
return $b->findAll();
|
|
}
|
|
|
|
protected function formatReportPDF($pdf, $data)
|
|
{
|
|
$w = $pdf->GetPageWidth();
|
|
// Layout helpers (mirroring the shared StudentReportPDF style but using DB data)
|
|
$pdf->SetMargins(10, 10, 10);
|
|
$bottomMarginVal = 3;
|
|
$pdf->SetAutoPageBreak(true, $bottomMarginVal);
|
|
$pdf->AddPage('P', 'Letter');
|
|
|
|
$leftMargin = 10;
|
|
|
|
$leftLogo = $this->firstExisting([
|
|
FCPATH . 'images/Isgl_logo.png',
|
|
FCPATH . 'images/isgl_logo.png',
|
|
FCPATH . 'assets/images/isgl_logo.png',
|
|
FCPATH . 'assets/images/isgl.png',
|
|
]);
|
|
$rightLogo = $this->firstExisting([
|
|
FCPATH . 'images/logo.png',
|
|
FCPATH . 'assets/images/logo.png',
|
|
FCPATH . 'assets/images/school_logo.png',
|
|
]);
|
|
|
|
if ($leftLogo) { $pdf->Image($leftLogo, 5, 5, 32); }
|
|
if ($rightLogo) { $pdf->Image($rightLogo, $w - 33, 5, 32); }
|
|
|
|
$pdf->SetFont('Times', 'B', 24);
|
|
$pdf->Cell(0, 12, 'Al Rahma Sunday School', 0, 1, 'C');
|
|
$pdf->Cell(0, 12, 'Student Report Card', 0, 1, 'C');
|
|
$pdf->Ln(4);
|
|
|
|
$numFmt = static function ($val) {
|
|
if ($val === null || $val === '') return 'N/A';
|
|
if (is_numeric($val)) return number_format((float)$val, 1);
|
|
return (string)$val;
|
|
};
|
|
$splitText = static function ($text, int $limit = 55): array {
|
|
$text = trim((string)$text);
|
|
if ($text === '') {
|
|
return [' ', '']; // keep an extra blank line for spacing
|
|
}
|
|
$lines = explode("\n", wordwrap($text, $limit));
|
|
$lines[] = ''; // extra empty line after the comment
|
|
return $lines;
|
|
};
|
|
$drawLabeledCell = static function (\FPDF $pdf, string $label, string $value, float $width = 95) {
|
|
$pdf->SetFont('Helvetica', 'B', 11);
|
|
$pdf->Cell($width, 10, $label, 1, 0);
|
|
$pdf->SetFont('Helvetica', '', 12);
|
|
$pdf->Cell($width, 10, $value, 1, 1);
|
|
};
|
|
$firstNonNull = static function (...$items) {
|
|
foreach ($items as $item) {
|
|
if ($item !== null) {
|
|
return $item;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
$wrapLinesToWidth = static function (\FPDF $pdf, string $text, float $width) {
|
|
$text = trim((string)$text);
|
|
if ($text === '') {
|
|
return [' '];
|
|
}
|
|
if ($width <= 0) {
|
|
return [$text];
|
|
}
|
|
$segments = preg_split("/\\r?\\n+/", $text);
|
|
$lines = [];
|
|
foreach ($segments as $seg) {
|
|
$seg = trim($seg);
|
|
if ($seg === '') {
|
|
$lines[] = ' ';
|
|
continue;
|
|
}
|
|
$words = preg_split('/\\s+/u', $seg);
|
|
$current = '';
|
|
foreach ($words as $word) {
|
|
$trial = trim($current === '' ? $word : ($current . ' ' . $word));
|
|
if ($pdf->GetStringWidth($trial) <= $width) {
|
|
$current = $trial;
|
|
} else {
|
|
if ($current !== '') $lines[] = $current;
|
|
$current = $word;
|
|
}
|
|
}
|
|
if ($current !== '') $lines[] = $current;
|
|
}
|
|
return $lines;
|
|
};
|
|
$draw3ColumnRow = static function (\FPDF $pdf, string $label, $score, $feedback) use ($wrapLinesToWidth) {
|
|
$w1 = 55;
|
|
$w2 = 30;
|
|
$w3 = 110; // comments column width
|
|
$commentWrapW = $w3 - 4; // allow more horizontal space for comments
|
|
$lineHeight = 6;
|
|
|
|
// Wrap using the same font settings as the comment cell to avoid odd line breaks
|
|
$pdf->SetFont('Helvetica', '', 11);
|
|
$cellMargin = 1.0; // FPDF default internal cell margin (protected property)
|
|
$wrapWidth = max(1, $commentWrapW - (2 * $cellMargin));
|
|
$lines = $wrapLinesToWidth($pdf, (string)$feedback, $wrapWidth);
|
|
if (trim((string)$feedback) !== '') {
|
|
$lines[] = ' ';
|
|
}
|
|
$linesCount = max(1, count($lines));
|
|
$rowHeight = max($lineHeight, $linesCount * $lineHeight);
|
|
$textOffsetY = ($rowHeight - ($linesCount * $lineHeight)) / 2;
|
|
|
|
$startX = $pdf->GetX();
|
|
$startY = $pdf->GetY();
|
|
$labelPad = 2;
|
|
|
|
// Label cell
|
|
$pdf->SetFont('Helvetica', 'B', 11);
|
|
$pdf->SetXY($startX + $labelPad, $startY + $textOffsetY);
|
|
$pdf->Cell($w1 - $labelPad, $lineHeight, $label, 0, 0);
|
|
|
|
// Score cell
|
|
$pdf->SetFont('Helvetica', '', 12);
|
|
$scoreStr = is_numeric($score) ? number_format((float)$score, 1) : ((string)$score !== '' ? (string)$score : 'N/A');
|
|
$pdf->SetXY($startX + $w1, $startY + $textOffsetY);
|
|
$pdf->Cell($w2, $lineHeight, $scoreStr, 0, 0, 'C');
|
|
|
|
// Comments cell
|
|
$commentX = $pdf->GetX();
|
|
$pdf->SetFont('Helvetica', '', 11);
|
|
$pdf->SetXY($commentX + 2, $startY + $textOffsetY);
|
|
$pdf->MultiCell($commentWrapW, $lineHeight, implode("\n", $lines), 0, 'L');
|
|
|
|
// Advance to next row
|
|
$pdf->SetXY($startX, $startY + $rowHeight);
|
|
};
|
|
$drawInfoRows = static function (\FPDF $pdf, array $rows, float $height = 12, ?float $startY = null) use ($leftMargin) {
|
|
$leftW = 130; // Teacher/Name column widened by 10
|
|
$rightW = 65; // School Year column reduced by 10
|
|
$startX = $leftMargin; // ensure all rows start at left margin
|
|
$currentY = $startY !== null ? $startY : $pdf->GetY();
|
|
$lineH = 6;
|
|
$wrapLines = static function (string $text, float $width) use ($pdf) {
|
|
$text = trim($text);
|
|
if ($text === '') {
|
|
return [' '];
|
|
}
|
|
$segments = preg_split('/\\r?\\n+/', $text);
|
|
$out = [];
|
|
foreach ($segments as $seg) {
|
|
$seg = trim($seg);
|
|
if ($seg === '') {
|
|
// Preserve intentional blank line
|
|
$out[] = ' ';
|
|
continue;
|
|
}
|
|
$words = preg_split('/\\s+/u', $seg);
|
|
$current = '';
|
|
foreach ($words as $word) {
|
|
$trial = $current === '' ? $word : ($current . ' ' . $word);
|
|
if ($pdf->GetStringWidth($trial) <= ($width - 2)) {
|
|
$current = $trial;
|
|
} else {
|
|
if ($current !== '') {
|
|
$out[] = $current;
|
|
}
|
|
$current = $word;
|
|
}
|
|
}
|
|
if ($current !== '') {
|
|
$out[] = $current;
|
|
}
|
|
}
|
|
return !empty($out) ? $out : [' '];
|
|
};
|
|
|
|
if ($startY !== null) {
|
|
$pdf->SetY($startY);
|
|
}
|
|
foreach ($rows as $row) {
|
|
if (!is_array($row) || count($row) < 4) {
|
|
continue;
|
|
}
|
|
[$labelLeft, $valueLeft, $labelRight, $valueRight] = $row;
|
|
$y = $currentY;
|
|
$labelY = $y+2; // align titles and contents vertically
|
|
$isTeacherRow = stripos($labelLeft, 'teacher') !== false;
|
|
if ($isTeacherRow) {
|
|
$labelY += 1;
|
|
}
|
|
// For teacher rows, respect the explicit line breaks (two names per line) without extra wrapping
|
|
$leftLines = $isTeacherRow
|
|
? array_values(array_filter(array_map('rtrim', explode("\n", (string)$valueLeft)), static fn($v) => $v !== ''))
|
|
: $wrapLines((string)$valueLeft, $leftW - 6);
|
|
$rightLines = $wrapLines((string)$valueRight, $rightW - 6);
|
|
$rowHeight = max($height, max(count($leftLines), count($rightLines)) * $lineH + 4);
|
|
// If teacher names span multiple lines, add a small breathing room (half line) after the second line
|
|
if (stripos($labelLeft, 'teacher') !== false && count($leftLines) > 1) {
|
|
$rowHeight += ($lineH / 4);
|
|
}
|
|
$pdf->SetXY($startX, $y);
|
|
$pdf->Rect($startX, $y, $leftW, $rowHeight);
|
|
$pdf->Rect($startX + $leftW, $y, $rightW, $rowHeight);
|
|
$pdf->SetFont('Helvetica', 'B', 11);
|
|
$pdf->SetXY($startX + 3, $labelY);
|
|
$pdf->Write($lineH - 1, $labelLeft);
|
|
$pdf->SetFont('Arial', '', 11);
|
|
$labelLeftWidth = $pdf->GetStringWidth($labelLeft);
|
|
$pad = 2; // small gap after the label
|
|
$valLeftWidth = $leftW - 6 - $labelLeftWidth - $pad;
|
|
if ($valLeftWidth < 10) {
|
|
$valLeftWidth = $leftW - 6 - $pad;
|
|
}
|
|
$pdf->SetXY($startX + 3 + $labelLeftWidth + $pad, $labelY);
|
|
if (count($leftLines) === 1) {
|
|
$pdf->Write($lineH - 1, $leftLines[0]);
|
|
} else {
|
|
$pdf->MultiCell($valLeftWidth, $lineH, implode("\n", $leftLines), 0, 'L');
|
|
}
|
|
$pdf->SetFont('Helvetica', 'B', 11);
|
|
$pdf->SetXY($startX + $leftW + 3, $labelY);
|
|
$pdf->Write($lineH - 1, $labelRight);
|
|
$pdf->SetFont('Arial', '', 11);
|
|
$labelRightWidth = $pdf->GetStringWidth($labelRight);
|
|
$valRightWidth = $rightW - 6 - $labelRightWidth - $pad;
|
|
if ($valRightWidth < 10) {
|
|
$valRightWidth = $rightW - 6 - $pad;
|
|
}
|
|
$pdf->SetXY($startX + $leftW + 3 + $labelRightWidth + $pad, $labelY);
|
|
if (count($rightLines) === 1) {
|
|
$pdf->Write($lineH - 1, $rightLines[0]);
|
|
} else {
|
|
$pdf->MultiCell($valRightWidth, $lineH, implode("\n", $rightLines), 0, 'L');
|
|
}
|
|
$currentY += $rowHeight;
|
|
$pdf->SetY($currentY);
|
|
}
|
|
return $currentY;
|
|
};
|
|
|
|
$semRaw = (string)($data['score']['semester'] ?? ($data['selected_semester'] ?? ''));
|
|
$semNum = null;
|
|
$semFriendly = 'Second Semester';
|
|
$s = strtolower(trim($semRaw));
|
|
if ($s === 'fall' || $s === 'first' || $s === 'semester 1' || $s === '1') { $semNum = 1; $semFriendly = 'First Semester'; }
|
|
elseif ($s === 'spring' || $s === 'second' || $s === 'semester 2' || $s === '2') { $semNum = 2; $semFriendly = 'Second Semester'; }
|
|
|
|
// Teacher names (support multiple main teachers + wrap to a second line if needed)
|
|
$teacherNamesList = $data['teacher_names'] ?? [];
|
|
if (empty($teacherNamesList) && !empty($data['teacher_name'])) {
|
|
$teacherNamesList = [trim((string)$data['teacher_name'])];
|
|
}
|
|
$teacherNamesList = array_values(array_filter(
|
|
array_map('trim', $teacherNamesList),
|
|
static fn($v) => $v !== '' && strcasecmp($v, 'N/A') !== 0
|
|
));
|
|
$tas = array_values(array_filter(
|
|
array_map(static fn($v) => trim((string)$v), $data['ta_names'] ?? []),
|
|
static fn($v) => $v !== '' && strcasecmp($v, 'N/A') !== 0
|
|
));
|
|
$allTeacherNames = array_values(array_unique(array_filter(
|
|
array_merge($teacherNamesList, $tas),
|
|
static fn($v) => $v !== ''
|
|
)));
|
|
$teacherNameLines = [];
|
|
$teacherCount = count($allTeacherNames);
|
|
for ($i = 0; $i < $teacherCount; $i += 2) {
|
|
$pair = array_slice($allTeacherNames, $i, 2);
|
|
$isLastPair = ($i + 2) >= $teacherCount;
|
|
if (count($pair) === 2) {
|
|
$teacherNameLines[] = implode($isLastPair ? ' & ' : ', ', $pair);
|
|
} else {
|
|
$teacherNameLines[] = $pair[0];
|
|
}
|
|
}
|
|
if (empty($teacherNameLines)) {
|
|
$teacherNameLines[] = 'N/A';
|
|
}
|
|
$teacherNames = implode("\n", $teacherNameLines);
|
|
|
|
$schoolYear = (string)($data['score']['school_year'] ?? ($data['score']['year'] ?? 'N/A'));
|
|
if (preg_match('/^(\\d{4})-(\\d{4})$/', $schoolYear, $m)) {
|
|
$schoolYear = $m[1] . '/' . $m[2];
|
|
}
|
|
$studentName = trim(($data['student']['firstname'] ?? '') . ' ' . ($data['student']['lastname'] ?? ''));
|
|
$gradeLabel = (string)($data['grade'] ?? ($data['class_section_name'] ?? 'N/A'));
|
|
$today = (string)($data['report_date_display'] ?? date('m-d-Y'));
|
|
$termRank = trim((string)($data['term_rank_display'] ?? ''));
|
|
$firstSemScore = $data['first_semester_score'] ?? null;
|
|
$secondSemScore = $data['second_semester_score'] ?? ($data['total_score'] ?? null);
|
|
$finalAverage = $data['final_average'] ?? null;
|
|
$comments = $data['comments'] ?? [];
|
|
|
|
$infoRow = function ($labelLeft, $valueLeft, $labelRight, $valueRight, $height = 12) use ($pdf) {
|
|
$x = $pdf->GetX();
|
|
$y = $pdf->GetY();
|
|
$leftW = 115;
|
|
$rightW = 68;
|
|
$pdf->Rect($x, $y, $leftW, $height);
|
|
$pdf->Rect($x + $leftW, $y, $rightW, $height);
|
|
$pdf->SetFont('Helvetica', 'B', 11);
|
|
$pdf->SetXY($x + 3, $y + 3);
|
|
$pdf->Write(5, $labelLeft);
|
|
$pdf->SetFont('Arial', '', 11);
|
|
$pdf->Write(5, $valueLeft);
|
|
$pdf->SetFont('Helvetica', 'B', 11);
|
|
$pdf->SetXY($x + $leftW + 3, $y + 3);
|
|
$pdf->Write(5, $labelRight);
|
|
$pdf->SetFont('Arial', '', 11);
|
|
$pdf->Write(5, $valueRight);
|
|
$pdf->Ln($height);
|
|
};
|
|
|
|
// Student + teacher + grade (three stacked rows, edges touching)
|
|
$afterInfoY = $drawInfoRows($pdf, [
|
|
["Teachers' Names: ", ' ' . ($teacherNames !== '' ? $teacherNames : 'N/A'), 'School Year: ', ' ' . $schoolYear],
|
|
['Student Name: ', ' ' . ($studentName !== '' ? $studentName : 'N/A'), 'Grade: ', ' ' . ($gradeLabel !== '' ? $gradeLabel : 'N/A')],
|
|
['Term: ', ' ' . $semFriendly, 'Date: ', ' ' . $today],
|
|
]);
|
|
$pdf->SetY($afterInfoY);
|
|
// No extra spacing to keep rectangles touching the next block
|
|
|
|
$pdf->Ln(2);
|
|
// Scores and comments rows
|
|
$finalExamScore = $data['final_exam_score'] ?? null;
|
|
if ($finalExamScore === null) {
|
|
if ($semNum === 1) {
|
|
$finalExamScore = $data['score']['midterm_exam_score'] ?? null;
|
|
} elseif ($semNum === 2) {
|
|
$finalExamScore = $data['score']['final_exam_score'] ?? ($data['score']['midterm_exam_score'] ?? null);
|
|
} else {
|
|
$finalExamScore = $data['score']['final_exam_score'] ?? ($data['score']['midterm_exam_score'] ?? null);
|
|
}
|
|
}
|
|
$ptapScore = $data['ptap'] ?? $data['score']['ptap_score'] ?? null;
|
|
$attendance = $data['score']['attendance_score'] ?? null;
|
|
|
|
$examLabel = ($semNum === 1) ? 'Midterm Exam*' : 'Final Exam*';
|
|
// Header for subject table
|
|
$pdf->SetFont('Helvetica', 'B', 11);
|
|
$headerX = $pdf->GetX();
|
|
$headerY = $pdf->GetY();
|
|
$pdf->Cell(55, 15, 'Subject', 1, 0, 'C');
|
|
$scoreHeader = $semNum === 1 ? "1st Semester\nScore (/100)" : "2nd Semester\nScore (/100)";
|
|
$pdf->SetXY($headerX + 55, $headerY);
|
|
$pdf->MultiCell(30, 7.5, $scoreHeader, 1, 'C');
|
|
$pdf->SetXY($headerX + 85, $headerY);
|
|
$pdf->Cell(110, 15, "Teachers' Comments", 1, 1, 'C');
|
|
|
|
// Match comment to the correct row
|
|
$examComment = $semNum === 1
|
|
? $firstNonNull($comments['midterm'] ?? null, $data['score']['midterm_comment'] ?? null, $data['score']['comment'] ?? null)
|
|
: $firstNonNull($comments['final'] ?? null, $data['score']['final_comment'] ?? null, $data['score']['comment'] ?? null);
|
|
$ptapComment = $firstNonNull($comments['ptap'] ?? null, $data['score']['ptap_comment'] ?? null);
|
|
$attendanceComment = $firstNonNull(
|
|
$comments['attendance'] ?? null,
|
|
$comments['attendance_comment'] ?? null,
|
|
$data['score']['attendance_comment'] ?? null
|
|
);
|
|
|
|
// Add a blank line before the exam row to separate header from data
|
|
$pdf->Ln(2);
|
|
$draw3ColumnRow($pdf, $examLabel, $numFmt($finalExamScore), $examComment ?? '');
|
|
$draw3ColumnRow($pdf, 'PTAP**', $numFmt($ptapScore), $ptapComment ?? '');
|
|
$attendanceComment = $firstNonNull(
|
|
$comments['attendance'] ?? null,
|
|
$comments['attendance_comment'] ?? null,
|
|
$data['score']['attendance_comment'] ?? null
|
|
);
|
|
$draw3ColumnRow($pdf, 'Attendance***', $numFmt($attendance), $attendanceComment ?? '');
|
|
|
|
// Draw outer box and vertical dividers around the score/comment block (no horizontal separators)
|
|
$rowsStartX = $headerX;
|
|
$rowsStartY = $headerY + 15; // header height
|
|
$rowsEndY = $pdf->GetY();
|
|
$blockHeight = $rowsEndY - $rowsStartY;
|
|
$blockWidth = 195; // 55 + 30 + 110
|
|
$pdf->Rect($rowsStartX, $rowsStartY, $blockWidth, $blockHeight);
|
|
$pdf->Line($rowsStartX + 55, $rowsStartY, $rowsStartX + 55, $rowsStartY + $blockHeight);
|
|
$pdf->Line($rowsStartX + 85, $rowsStartY, $rowsStartX + 85, $rowsStartY + $blockHeight);
|
|
|
|
// Attendance summary (absences / tardies)
|
|
$totalDays = $data['total_attendance_days'] ?? $data['score']['total_semester_days'] ?? $data['score']['total_days'] ?? '-';
|
|
$absences = $data['score']['total_absences'] ?? $data['score']['absences'] ?? '0';
|
|
$tardies = $data['score']['total_tardies'] ?? $data['score']['tardies'] ?? '0';
|
|
|
|
$pdf->Ln(2);
|
|
// Attendance summary on a single row (three columns)
|
|
$statW = 65;
|
|
$statH = 12;
|
|
$startX = $pdf->GetX();
|
|
$startY = $pdf->GetY();
|
|
$stats = [
|
|
['Total Semester Days: ', (string)$totalDays],
|
|
['Total Absences: ', (string)$absences],
|
|
['Tardies (Unjustified): ', (string)$tardies],
|
|
];
|
|
foreach ($stats as $i => [$lbl, $val]) {
|
|
$x = $startX + ($i * $statW);
|
|
$pdf->Rect($x, $startY, $statW, $statH);
|
|
$pdf->SetXY($x + 2, $startY + 3);
|
|
$pdf->SetFont('Helvetica', 'B', 11);
|
|
$pdf->Write(5, $lbl . ' ');
|
|
$pdf->SetFont('Helvetica', '', 11);
|
|
$pdf->Write(5, $val);
|
|
}
|
|
$pdf->SetY($startY + $statH);
|
|
|
|
// Semester scores + final
|
|
$effectiveSecond = $secondSemScore ?? $data['score']['semester_score'] ?? $data['total_score'] ?? null;
|
|
$finalScoreVal = $finalAverage;
|
|
if (!is_numeric($finalScoreVal)) {
|
|
$finalScoreVal = (is_numeric($firstSemScore) && is_numeric($effectiveSecond))
|
|
? number_format(((float)$firstSemScore + (float)$effectiveSecond) / 2, 1)
|
|
: $numFmt($effectiveSecond);
|
|
}
|
|
// Keep grade label and value within bordered cells
|
|
$gradeCellWidth = 65; // match Total Semester Days column width
|
|
$gradeCellHeight = 12;
|
|
$gradeX = $pdf->GetX();
|
|
$gradeY = $pdf->GetY();
|
|
$gradePad = max(0, 2 - (4 * 0.3528)); // reduce indentation by 4pt
|
|
|
|
$drawGradeCell = static function (
|
|
\FPDF $pdf,
|
|
float $x,
|
|
float $y,
|
|
float $w,
|
|
float $h,
|
|
string $label,
|
|
string $value,
|
|
float $pad
|
|
) {
|
|
$pdf->Rect($x, $y, $w, $h);
|
|
$pdf->SetXY($x + $pad, $y + 3);
|
|
$pdf->SetFont('Helvetica', 'B', 11);
|
|
$pdf->Write(5, $label . ' ');
|
|
$labelWidth = $pdf->GetStringWidth($label . ' ');
|
|
$pdf->SetFont('Helvetica', '', 12);
|
|
$pdf->SetXY($x + 2 + $labelWidth, $y + 3);
|
|
$pdf->Write(5, $value);
|
|
};
|
|
$drawRankCell = static function (
|
|
\FPDF $pdf,
|
|
float $x,
|
|
float $y,
|
|
float $w,
|
|
float $h,
|
|
string $rankValue,
|
|
float $pad
|
|
) {
|
|
$rankValue = trim($rankValue) !== '' ? trim($rankValue) : 'N/A';
|
|
|
|
$pdf->Rect($x, $y, $w, $h);
|
|
$pdf->SetXY($x + $pad, $y + 3);
|
|
$pdf->SetFont('Helvetica', 'B', 11);
|
|
$pdf->Write(5, ' Class Rank: ');
|
|
|
|
$labelWidth = $pdf->GetStringWidth(' Class Rank: ');
|
|
$pdf->SetFont('Helvetica', '', 12);
|
|
$pdf->SetXY($x + 2 + $labelWidth, $y + 3);
|
|
$pdf->Write(5, $rankValue);
|
|
};
|
|
if ($semNum === 2) {
|
|
$firstLabel = ' 1st Semester Grade:';
|
|
$firstValue = $numFmt($firstSemScore) . '/100';
|
|
|
|
$secondLabel = ' 2nd Semester Grade:';
|
|
$secondValue = $numFmt($effectiveSecond) . '/100';
|
|
|
|
$drawGradeCell($pdf, $gradeX, $gradeY, $gradeCellWidth, $gradeCellHeight, $firstLabel, $firstValue, $gradePad);
|
|
$drawGradeCell($pdf, $gradeX + $gradeCellWidth, $gradeY, $gradeCellWidth, $gradeCellHeight, $secondLabel, $secondValue, $gradePad);
|
|
} else {
|
|
$gradeLabel = ' 1st Semester Grade:';
|
|
$gradeValue = $numFmt($effectiveSecond) . '/100';
|
|
|
|
$drawGradeCell($pdf, $gradeX, $gradeY, $gradeCellWidth, $gradeCellHeight, $gradeLabel, $gradeValue, $gradePad);
|
|
|
|
// Fall: Ranking goes to the right of 1st Semester Grade.
|
|
$drawRankCell($pdf, $gradeX + $gradeCellWidth, $gradeY, $gradeCellWidth, $gradeCellHeight, $termRank, $gradePad);
|
|
}
|
|
|
|
$pdf->SetY($gradeY + $gradeCellHeight);
|
|
|
|
$finalScoreRowHeight = 12;
|
|
$finalScoreEndY = null;
|
|
|
|
// Show Final Score only for second semester / Spring.
|
|
if ($semNum === 2) {
|
|
$finalLabel = 'Final Score****:';
|
|
$finalValue = (is_string($finalScoreVal) ? $finalScoreVal : $numFmt($finalScoreVal)) . '/100';
|
|
|
|
$finalCellWidth = 65;
|
|
$finalCellHeight = $finalScoreRowHeight;
|
|
$finalX = $pdf->GetX();
|
|
$finalY = $pdf->GetY();
|
|
$finalPad = max(0, 2 - (4 * 0.3528));
|
|
|
|
$drawGradeCell($pdf, $finalX, $finalY, $finalCellWidth, $finalCellHeight, ' ' . $finalLabel, $finalValue, $finalPad);
|
|
|
|
// Spring: Ranking goes to the right of Final Score.
|
|
$drawRankCell($pdf, $finalX + $finalCellWidth, $finalY, $finalCellWidth, $finalCellHeight, $termRank, $finalPad);
|
|
|
|
$pdf->SetY($finalY + $finalCellHeight);
|
|
$finalScoreEndY = $finalY + $finalCellHeight;
|
|
}
|
|
|
|
$scoresEndY = $pdf->GetY();
|
|
|
|
// Legend anchored near bottom with a 3mm buffer (dynamic placement)
|
|
$examLegendLabel = ($semNum === 1) ? 'Midterm Exam' : 'Final Exam';
|
|
$legendLines = [
|
|
"(*) {$examLegendLabel} (Weight: 60% of semester score)",
|
|
"(**) PTAP - Participation, Tests, Assignments, Projects (Weight: 20%)",
|
|
"(***) Attendance = ({$totalDays} - Total Absences + One Sick Day) / {$totalDays} ) x 100 (Weight: 20%)",
|
|
];
|
|
if ($semNum === 2) {
|
|
$legendLines[] = "(****) Final Score = (1st Semester Score + 2nd Semester Score) / 2";
|
|
}
|
|
$legendLines[] = ['text' => "If you have any questions, please contact the school via email at alrahma.isgl@gmail.com", 'bold' => true];
|
|
$lineHeight = 4;
|
|
$legendHeight = count($legendLines) * $lineHeight;
|
|
$bottomPad = 0.1; // distance from bottom of the page
|
|
|
|
// Respect the configured bottom margin so legend stays on-page
|
|
$targetY = $pdf->GetPageHeight() - $bottomMarginVal - $bottomPad - $legendHeight;
|
|
$targetY = max($pdf->GetY(), $targetY); // avoid overlapping prior content
|
|
$legendY = $targetY;
|
|
|
|
// Two-column layout: legend on left, signature block on right
|
|
$marginL = 10;
|
|
$sigWidth = 40;
|
|
$sigHeight = $finalScoreRowHeight;
|
|
$sigX = $w - $sigWidth - 15;
|
|
$legendWidth = $sigX - $marginL - 5; // leave gutter before signature
|
|
|
|
// Legend (left column)
|
|
$pdf->SetXY($marginL, $legendY);
|
|
foreach ($legendLines as $line) {
|
|
$isBold = is_array($line) ? ($line['bold'] ?? false) : false;
|
|
$text = is_array($line) ? ($line['text'] ?? '') : $line;
|
|
$pdf->SetFont('Arial', $isBold ? 'B' : '', 8);
|
|
$pdf->MultiCell($legendWidth, $lineHeight, $text, 0, 'L');
|
|
}
|
|
$legendEndY = $pdf->GetY();
|
|
|
|
// Signature block (right column), bottom-aligned to the score block
|
|
$pdf->SetFont('Times', 'BU', 16);
|
|
$labelHeight = $finalScoreRowHeight;
|
|
|
|
$sigPath = $this->firstExisting([
|
|
FCPATH . 'images/signature.png',
|
|
FCPATH . 'assets/images/signature.png',
|
|
WRITEPATH . 'report_assets/signature.png',
|
|
]);
|
|
$signatureBlockHeight = $labelHeight + ($sigPath ? $sigHeight : 0);
|
|
$anchorEndY = ($semNum === 2 && $finalScoreEndY !== null) ? $finalScoreEndY : $scoresEndY;
|
|
$sigGap = 2;
|
|
$sigY = $anchorEndY + $sigGap;
|
|
|
|
$pdf->SetXY($sigX, $sigY);
|
|
$pdf->Cell($sigWidth + 5, $labelHeight, "School Official", 0, 0, "R");
|
|
|
|
$signatureEndY = $sigY + $labelHeight;
|
|
if ($sigPath) {
|
|
$imgY = $sigY + $labelHeight;
|
|
$pdf->Image($sigPath, $sigX+3, $imgY-2, $sigWidth+2, $sigHeight+5);
|
|
$signatureEndY = $imgY + $sigHeight;
|
|
}
|
|
|
|
// Advance cursor to the lower of legend or signature blocks plus small padding
|
|
$pdf->SetY(max($legendEndY, $signatureEndY) + 4);
|
|
}
|
|
|
|
public function generateSingleReport($studentId)
|
|
{
|
|
// Prevent Debug Toolbar from corrupting PDF
|
|
if (ENVIRONMENT !== 'production') {
|
|
// Turn off toolbar manually by unsetting the collector
|
|
unset(service('toolbar')->collectors['CodeIgniter\Debug\Toolbar\Collectors\Timers']);
|
|
}
|
|
|
|
$download = $this->request->getGet('download') === '1';
|
|
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
|
|
$semester = $this->request->getGet('semester') ?? $this->semester;
|
|
$reportDate = $this->sanitizeReportDate($this->request->getGet('report_date'));
|
|
$data = $this->prepareStudentReportData((int)$studentId, (string)$schoolYear, (string)$semester);
|
|
|
|
if (!$data) {
|
|
return "Student not found or missing scores.";
|
|
}
|
|
$data['report_date'] = $reportDate['iso'];
|
|
$data['report_date_display'] = $reportDate['display'];
|
|
|
|
$pdf = new \FPDF('P', 'mm', 'Letter');
|
|
$this->formatReportPDF($pdf, $data);
|
|
|
|
$filename = 'ReportCard_' . $data['student']['lastname'] . '.pdf';
|
|
|
|
// Important: Clean output buffer to prevent PDF corruption
|
|
if (ob_get_length()) {
|
|
ob_end_clean();
|
|
}
|
|
$pdfContent = $pdf->Output('S');
|
|
$disposition = $download ? 'attachment' : 'inline';
|
|
return $this->response
|
|
->setHeader('Content-Type', 'application/pdf')
|
|
->setHeader('Content-Disposition', $disposition . '; filename="' . $filename . '"')
|
|
->setHeader('Cache-Control', 'private, max-age=0, must-revalidate')
|
|
->setHeader('Pragma', 'public')
|
|
->setHeader('Content-Length', (string) strlen($pdfContent))
|
|
->setBody($pdfContent);
|
|
}
|
|
|
|
public function generateClassReport($classSectionId)
|
|
{
|
|
// Disable toolbar output to prevent PDF corruption
|
|
if (ENVIRONMENT !== 'production') {
|
|
unset(service('toolbar')->collectors['CodeIgniter\Debug\Toolbar\Collectors\Timers']);
|
|
}
|
|
|
|
|
|
$download = $this->request->getGet('download') === '1';
|
|
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
|
|
$semester = $this->request->getGet('semester') ?? $this->semester;
|
|
$reportDate = $this->sanitizeReportDate($this->request->getGet('report_date'));
|
|
|
|
$q = $this->semesterScoreModel
|
|
->select('semester_scores.*')
|
|
->join('students s', 's.id = semester_scores.student_id', 'inner')
|
|
->where('s.is_active', 1)
|
|
->where('semester_scores.class_section_id', (int)$classSectionId);
|
|
if (!empty($schoolYear)) $q->where('semester_scores.school_year', (string)$schoolYear);
|
|
if (!empty($semester)) {
|
|
$this->applySemesterFilter($q, (string)$semester, 'semester_scores.semester');
|
|
}
|
|
$scores = $q
|
|
->orderBy('s.firstname', 'ASC')
|
|
->orderBy('s.lastname', 'ASC')
|
|
->findAll();
|
|
|
|
if (!$scores) {
|
|
return "No students found for this class section.";
|
|
}
|
|
|
|
$pdf = new \FPDF('P', 'mm', 'Letter');
|
|
|
|
foreach ($scores as $score) {
|
|
$studentId = $score['student_id'];
|
|
|
|
$data = $this->prepareStudentReportData((int)$studentId, (string)$schoolYear, (string)$semester);
|
|
|
|
if ($data) {
|
|
$data['report_date'] = $reportDate['iso'];
|
|
$data['report_date_display'] = $reportDate['display'];
|
|
$this->formatReportPDF($pdf, $data);
|
|
}
|
|
}
|
|
|
|
$filename = 'ClassReport_Section_' . $classSectionId . '.pdf';
|
|
|
|
// Clean any existing output buffer
|
|
if (ob_get_length()) {
|
|
ob_end_clean();
|
|
}
|
|
$pdfContent = $pdf->Output('S');
|
|
$disposition = $download ? 'attachment' : 'inline';
|
|
return $this->response
|
|
->setHeader('Content-Type', 'application/pdf')
|
|
->setHeader('Content-Disposition', $disposition . '; filename="' . $filename . '"')
|
|
->setHeader('Cache-Control', 'private, max-age=0, must-revalidate')
|
|
->setHeader('Pragma', 'public')
|
|
->setHeader('Content-Length', (string) strlen($pdfContent))
|
|
->setBody($pdfContent);
|
|
}
|
|
|
|
|
|
protected function normalizeSemester(?string $input): string
|
|
{
|
|
$s = strtolower(trim((string)$input));
|
|
if ($s === 'fall' || $s === 'first' || str_contains($s, 'fall') || str_contains($s, '1')) return 'fall';
|
|
if ($s === 'spring' || $s === 'second' || str_contains($s, 'spring') || str_contains($s, '2')) return 'spring';
|
|
return '';
|
|
}
|
|
|
|
protected function semesterVariants(?string $semester): array
|
|
{
|
|
$raw = trim((string)$semester);
|
|
if ($raw === '') {
|
|
return [];
|
|
}
|
|
|
|
$norm = $this->normalizeSemester($raw);
|
|
if ($norm === 'fall') {
|
|
$vals = ['Fall', 'fall', 'First', 'first', 'Semester 1', 'semester 1', 'Semester1', 'semester1', 'Sem 1', 'sem 1', '1', '01', 'S1', 's1'];
|
|
} elseif ($norm === 'spring') {
|
|
$vals = ['Spring', 'spring', 'Second', 'second', 'Semester 2', 'semester 2', 'Semester2', 'semester2', 'Sem 2', 'sem 2', '2', '02', 'S2', 's2'];
|
|
} else {
|
|
$vals = [$raw];
|
|
}
|
|
$vals[] = $raw;
|
|
|
|
return array_values(array_unique($vals));
|
|
}
|
|
|
|
protected function applySemesterFilter($builder, ?string $semester, string $field = 'semester'): void
|
|
{
|
|
$vals = $this->semesterVariants($semester);
|
|
if (!empty($vals)) {
|
|
$builder->whereIn($field, $vals);
|
|
}
|
|
}
|
|
|
|
protected function applySemesterExclusion($builder, ?string $semester, string $field = 'semester'): void
|
|
{
|
|
$vals = $this->semesterVariants($semester);
|
|
if (!empty($vals)) {
|
|
$builder->whereNotIn($field, $vals);
|
|
}
|
|
}
|
|
|
|
protected function listSundays(string $startDate, string $endDate): array
|
|
{
|
|
try {
|
|
$start = new \DateTimeImmutable($startDate);
|
|
} catch (\Throwable) {
|
|
return [];
|
|
}
|
|
try {
|
|
$end = new \DateTimeImmutable($endDate);
|
|
} catch (\Throwable) {
|
|
return [];
|
|
}
|
|
|
|
$sundays = [];
|
|
$cursor = $start;
|
|
$w = (int)$cursor->format('w');
|
|
if ($w !== 0) {
|
|
$cursor = $cursor->modify('next sunday');
|
|
}
|
|
|
|
while ($cursor <= $end) {
|
|
$sundays[] = $cursor->format('Y-m-d');
|
|
$cursor = $cursor->modify('+7 days');
|
|
}
|
|
return $sundays;
|
|
}
|
|
|
|
protected function resolveAnchorSunday(): string
|
|
{
|
|
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
|
try {
|
|
$tzObj = new \DateTimeZone($tzName ?: 'UTC');
|
|
} catch (\Throwable) {
|
|
try {
|
|
$tzObj = new \DateTimeZone(user_timezone() ?: 'UTC');
|
|
} catch (\Throwable) {
|
|
$tzObj = new \DateTimeZone('UTC');
|
|
}
|
|
}
|
|
try {
|
|
$now = new \DateTime('now', $tzObj);
|
|
} catch (\Throwable) {
|
|
$now = new \DateTime('now');
|
|
}
|
|
$weekday = (int)$now->format('w');
|
|
if ($weekday === 0) {
|
|
return $now->format('Y-m-d');
|
|
}
|
|
return $now->modify('next sunday')->format('Y-m-d');
|
|
}
|
|
protected function prepareStudentReportData(int $studentId, ?string $schoolYear = null, ?string $semester = null)
|
|
{
|
|
// 0) Student
|
|
$student = $this->studentModel->find($studentId);
|
|
if (!$student || (int)($student['is_active'] ?? 1) !== 1) return null;
|
|
|
|
helper('attendance_comment');
|
|
|
|
// 1) Resolve year/semester
|
|
$refYear = $schoolYear ?: ($this->schoolYear ?? '');
|
|
$refSemester = $semester ?: ($this->semester ?? '');
|
|
|
|
// 2) Grade(s) for the year (best effort)
|
|
$grade = $this->studentClassModel->getClassSectionsByStudentId($studentId, $refYear);
|
|
|
|
// 3) Pull the semester score (filtered if provided)
|
|
$qb = $this->semesterScoreModel->where('student_id', $studentId);
|
|
if ($refYear !== '') $qb->where('school_year', $refYear);
|
|
if ($refSemester !== '') {
|
|
$this->applySemesterFilter($qb, $refSemester, 'semester');
|
|
}
|
|
$score = $qb->orderBy('updated_at', 'DESC')->orderBy('id', 'DESC')->first();
|
|
if (!$score) return null;
|
|
|
|
// ----------------------------
|
|
// Resolve section PK and code
|
|
// ----------------------------
|
|
$sectionCode = (int)($score['class_section_id'] ?? 0); // may be code or PK (legacy)
|
|
$sectionId = null;
|
|
|
|
if ($sectionCode > 0) {
|
|
// Try as CODE first
|
|
$row = $this->db->table('classSection')
|
|
->select('id, class_section_id, class_section_name')
|
|
->where('class_section_id', $sectionCode)->limit(1)->get()->getRowArray();
|
|
if ($row) {
|
|
$sectionId = (int)$row['id'];
|
|
$sectionCode = (int)$row['class_section_id'];
|
|
} else {
|
|
// Maybe score stored PK directly
|
|
$row2 = $this->db->table('classSection')
|
|
->select('id, class_section_id, class_section_name')
|
|
->where('id', $sectionCode)->limit(1)->get()->getRowArray();
|
|
if ($row2) {
|
|
$sectionId = (int)$row2['id'];
|
|
$sectionCode = (int)$row2['class_section_id'];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!$sectionId || $sectionCode <= 0) {
|
|
// Fallback to latest student_class for same year
|
|
try {
|
|
$scRow = $this->db->table('student_class')
|
|
->select('class_section_id')
|
|
->where('student_id', $studentId)
|
|
->where('school_year', (string)($score['school_year'] ?? $refYear))
|
|
->orderBy('COALESCE(updated_at, created_at)', 'DESC', false)
|
|
->limit(1)->get()->getRowArray();
|
|
if (!empty($scRow['class_section_id'])) {
|
|
$sectionCode = (int)$scRow['class_section_id'];
|
|
$row = $this->db->table('classSection')
|
|
->select('id')->where('class_section_id', $sectionCode)
|
|
->limit(1)->get()->getRowArray();
|
|
if ($row) $sectionId = (int)$row['id'];
|
|
}
|
|
} catch (\Throwable $e) { /* ignore */ }
|
|
}
|
|
|
|
// ----------------------------
|
|
// Teacher & TA lookup (single source of truth)
|
|
// ----------------------------
|
|
$teacherName = 'N/A';
|
|
$teacherNamesList = [];
|
|
$taNames = [];
|
|
|
|
// Prefer the section CODE (what teacher_class stores) and fall back to PK if needed
|
|
if ($sectionCode > 0) {
|
|
$names = $this->resolveTeacherAndTAs((int)$sectionCode, $refYear, $refSemester);
|
|
$teacherName = $names['teacher_name'];
|
|
$taNames = $names['ta_names'];
|
|
$teacherNamesList = $names['teacher_names'] ?? [];
|
|
}
|
|
// If code lookup failed or is empty, try the PK (legacy data may store either)
|
|
if (($teacherName === 'N/A' || empty($taNames)) && $sectionId) {
|
|
$names = $this->resolveTeacherAndTAs((int)$sectionId, $refYear, $refSemester);
|
|
if ($teacherName === 'N/A') {
|
|
$teacherName = $names['teacher_name'];
|
|
}
|
|
if (empty($taNames)) {
|
|
$taNames = $names['ta_names'];
|
|
}
|
|
if (empty($teacherNamesList) && !empty($names['teacher_names'])) {
|
|
$teacherNamesList = $names['teacher_names'];
|
|
}
|
|
}
|
|
|
|
$taNames = array_values(array_unique(array_filter($taNames)));
|
|
$taLine = implode(', ', $taNames);
|
|
|
|
// Fallback: if no teacher assignment found, try the user who updated the score
|
|
if (($teacherName === 'N/A' || trim((string)$teacherName) === '') && !empty($score['updated_by'])) {
|
|
try {
|
|
$updId = (int)$score['updated_by'];
|
|
if ($updId > 0) {
|
|
$u = $this->userModel->find($updId);
|
|
if ($u) {
|
|
$fallbackName = trim(((string)($u['firstname'] ?? '')) . ' ' . ((string)($u['lastname'] ?? '')));
|
|
if ($fallbackName !== '') {
|
|
log_message(
|
|
'warning',
|
|
'ReportCard: No teacher assignment found; using updated_by as teacher. section_id={sectionId} section_code={sectionCode} year={year} sem={sem} updated_by={uid} name={name}',
|
|
[
|
|
'sectionId' => $sectionId,
|
|
'sectionCode' => $sectionCode,
|
|
'year' => (string)($score['school_year'] ?? $refYear),
|
|
'sem' => (string)($score['semester'] ?? $refSemester),
|
|
'uid' => $updId,
|
|
'name' => $fallbackName,
|
|
]
|
|
);
|
|
$teacherName = $fallbackName;
|
|
}
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// ignore fallback errors
|
|
}
|
|
}
|
|
|
|
// ----------------------------
|
|
// Class section name (display)
|
|
// ----------------------------
|
|
$classSectionName = 'N/A';
|
|
if ($sectionId) {
|
|
$cs = $this->classSectionModel->find($sectionId);
|
|
if (!empty($cs['class_section_name'])) $classSectionName = $cs['class_section_name'];
|
|
} elseif ($sectionCode > 0) {
|
|
$cs = $this->db->table('classSection')->select('class_section_name')
|
|
->where('class_section_id', $sectionCode)->limit(1)->get()->getRowArray();
|
|
if (!empty($cs['class_section_name'])) $classSectionName = $cs['class_section_name'];
|
|
}
|
|
|
|
// ----------------------------
|
|
// Names & scores
|
|
// ----------------------------
|
|
$studentName = trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? ''));
|
|
$studentFirstName = trim((string)($student['firstname'] ?? ''));
|
|
|
|
$num = fn($v) => is_numeric($v) ? (float)$v : null;
|
|
$hw = $num($score['homework_avg'] ?? null) ?? 0.0;
|
|
$qz = $num($score['quiz_avg'] ?? null) ?? 0.0;
|
|
$prj = $num($score['project_avg'] ?? null) ?? 0.0;
|
|
$tst = $num($score['test_avg'] ?? null) ?? 0.0;
|
|
$semesterForAttendance = $refSemester !== '' ? $refSemester : (string)($score['semester'] ?? '');
|
|
$normSemester = $this->normalizeSemester($semesterForAttendance);
|
|
if ($normSemester === 'spring') {
|
|
$finalExam = $num($score['final_exam_score'] ?? null);
|
|
if ($finalExam === null) {
|
|
$finalExam = $num($score['midterm_exam_score'] ?? null);
|
|
}
|
|
} elseif ($normSemester === 'fall') {
|
|
$finalExam = $num($score['midterm_exam_score'] ?? null);
|
|
} else {
|
|
$finalExam = $num($score['final_exam_score'] ?? $score['midterm_exam_score'] ?? null);
|
|
}
|
|
$att = $num($score['attendance_score'] ?? null);
|
|
|
|
$ptap = $num($score['ptap_score'] ?? null);
|
|
if ($ptap === null) {
|
|
$ptap = round((($hw + $qz + $prj + $tst) / 4), 1);
|
|
}
|
|
$ptap = max(0, min(100, $ptap));
|
|
|
|
$secondSemesterScore = $num($score['semester_score'] ?? null);
|
|
if ($secondSemesterScore === null && $finalExam !== null && $att !== null) {
|
|
$secondSemesterScore = round(($finalExam * 0.6) + ($ptap * 0.2) + ($att * 0.2), 1);
|
|
}
|
|
if ($secondSemesterScore !== null) {
|
|
$secondSemesterScore = max(0, min(100, $secondSemesterScore));
|
|
}
|
|
|
|
// Pick up comments from score_comments if available
|
|
$commentMap = [];
|
|
try {
|
|
$commentBuilder = $this->scoreCommentModel
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $refYear)
|
|
->whereIn('score_type', ['final', 'midterm', 'ptap', 'attendance', 'attendance_comment']);
|
|
if ($refSemester !== '') {
|
|
$this->applySemesterFilter($commentBuilder, $refSemester, 'semester');
|
|
}
|
|
$commentRows = $commentBuilder->findAll();
|
|
foreach ($commentRows as $row) {
|
|
$typeRaw = strtolower(trim((string)($row['score_type'] ?? '')));
|
|
if ($typeRaw === '') {
|
|
continue;
|
|
}
|
|
// Normalize common variants to a single key
|
|
if ($typeRaw === 'attendance_comment') {
|
|
$typeRaw = 'attendance';
|
|
}
|
|
$rawComment = trim((string)($row['comment'] ?? ''));
|
|
if ($typeRaw === 'attendance') {
|
|
// Attendance comments must come from score_comments.comment.
|
|
$commentVal = $rawComment;
|
|
} else {
|
|
$reviewVal = trim((string)($row['comment_review'] ?? ''));
|
|
$commentVal = $this->db->fieldExists('comment_review', 'score_comments') && $reviewVal !== ''
|
|
? $reviewVal
|
|
: $rawComment;
|
|
}
|
|
if ($commentVal === '') {
|
|
continue;
|
|
}
|
|
$commentMap[$typeRaw] = $commentVal;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// ignore comment failures
|
|
}
|
|
|
|
// Expose attendance comment alongside scores for easier PDF fallback
|
|
if (isset($commentMap['attendance']) && !isset($score['attendance_comment'])) {
|
|
$score['attendance_comment'] = $commentMap['attendance'];
|
|
}
|
|
|
|
// If attendance comment is still missing, auto-generate from attendance score for the PDF
|
|
$attendanceCommentVal = $commentMap['attendance'] ?? $score['attendance_comment'] ?? null;
|
|
if ((!is_string($attendanceCommentVal) || trim($attendanceCommentVal) === '') && $att !== null) {
|
|
$autoAttendance = attendance_comment_from_score($att, $studentFirstName ?? '');
|
|
if ($autoAttendance !== null) {
|
|
$commentMap['attendance'] = $autoAttendance;
|
|
if (!isset($score['attendance_comment'])) {
|
|
$score['attendance_comment'] = $autoAttendance;
|
|
}
|
|
}
|
|
}
|
|
|
|
// First semester score (for second-semester reports) by looking for any other term in the same year
|
|
$firstSemesterScore = null;
|
|
try {
|
|
$other = $this->semesterScoreModel
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $refYear);
|
|
if ($refSemester !== '') {
|
|
$this->applySemesterExclusion($other, $refSemester, 'semester');
|
|
}
|
|
$other = $other
|
|
->orderBy('updated_at', 'DESC')
|
|
->orderBy('id', 'DESC')
|
|
->first();
|
|
if ($other && is_numeric($other['semester_score'] ?? null)) {
|
|
$firstSemesterScore = (float)$other['semester_score'];
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// ignore
|
|
}
|
|
if ($firstSemesterScore === null && in_array(strtolower(trim((string)$refSemester)), ['fall', 'first', '1', 'semester 1'], true)) {
|
|
$firstSemesterScore = $secondSemesterScore;
|
|
}
|
|
|
|
$rankingScore = $secondSemesterScore;
|
|
if ($normSemester === 'spring' && is_numeric($firstSemesterScore) && is_numeric($secondSemesterScore)) {
|
|
$rankingScore = round(((float)$firstSemesterScore + (float)$secondSemesterScore) / 2, 1);
|
|
}
|
|
|
|
$termRanking = $this->calculateTermRanking(
|
|
$studentId,
|
|
$sectionCode,
|
|
$sectionId,
|
|
$refYear,
|
|
$refSemester !== '' ? $refSemester : (string)($score['semester'] ?? ''),
|
|
$rankingScore
|
|
);
|
|
|
|
// Total semester days from attendance records (max total_attendance within same term)
|
|
$totalSemesterDays = null;
|
|
|
|
if ($refYear !== '' && $normSemester !== '') {
|
|
$semRange = $this->semesterRangeService->getSemesterRange($refYear, ucfirst($normSemester));
|
|
if ($semRange) {
|
|
try {
|
|
// Fetch all events for the year (no semester filter) so that
|
|
// no-school events stored with a different semester label or
|
|
// NULL semester are still picked up. Date-range filtering below.
|
|
$events = $this->calendarModel->getEventsBySchoolYearAndSemester($refYear, null);
|
|
$noSchoolDays = [];
|
|
foreach ($events as $event) {
|
|
if (empty($event['no_school'])) {
|
|
continue;
|
|
}
|
|
$dateStr = substr((string)($event['date'] ?? ''), 0, 10);
|
|
if ($dateStr === '' || $dateStr < $semRange[0] || $dateStr > $semRange[1]) {
|
|
continue;
|
|
}
|
|
$noSchoolDays[$dateStr] = true;
|
|
}
|
|
$sundays = $this->listSundays($semRange[0], $semRange[1]);
|
|
$limit = $semRange[1];
|
|
// Only cap at "today" while the semester is still in progress.
|
|
// Once the semester end date has passed, count all its Sundays.
|
|
if ($semRange[1] > date('Y-m-d')) {
|
|
$anchor = $this->resolveAnchorSunday();
|
|
if ($anchor !== '' && $anchor >= $semRange[0]) {
|
|
$limit = min($anchor, $semRange[1]);
|
|
}
|
|
}
|
|
$totalSemesterDays = 0;
|
|
foreach ($sundays as $date) {
|
|
if ($date > $limit) {
|
|
continue;
|
|
}
|
|
if (!empty($noSchoolDays[$date])) {
|
|
continue;
|
|
}
|
|
$totalSemesterDays++;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
log_message('error', 'Failed to count sundays for report card: ' . $e->getMessage());
|
|
$totalSemesterDays = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($totalSemesterDays === null) {
|
|
try {
|
|
$attRows = $this->attendanceRecordModel
|
|
->select('total_attendance')
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $refYear);
|
|
if ($semesterForAttendance !== '') {
|
|
$this->applySemesterFilter($attRows, $semesterForAttendance, 'semester');
|
|
}
|
|
$vals = array_values(array_filter(array_map(
|
|
static fn($r) => isset($r['total_attendance']) ? (int)$r['total_attendance'] : null,
|
|
$attRows->findAll()
|
|
), static fn($v) => $v !== null));
|
|
if (!empty($vals)) {
|
|
$totalSemesterDays = max($vals);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// ignore attendance lookup errors
|
|
}
|
|
}
|
|
|
|
// Load attendance sums (absences, lates)
|
|
$attendanceTotals = [
|
|
'total_absences' => 0,
|
|
'total_tardies' => 0,
|
|
];
|
|
try {
|
|
$attendanceDataModel = new AttendanceDataModel();
|
|
$absences = $attendanceDataModel
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $refYear)
|
|
->where('status', 'absent');
|
|
if ($semesterForAttendance !== '') {
|
|
$this->applySemesterFilter($absences, $semesterForAttendance, 'semester');
|
|
}
|
|
$attendanceTotals['total_absences'] = $absences->countAllResults();
|
|
|
|
$lates = $attendanceDataModel
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $refYear)
|
|
->where('status', 'late');
|
|
if ($semesterForAttendance !== '') {
|
|
$this->applySemesterFilter($lates, $semesterForAttendance, 'semester');
|
|
}
|
|
$attendanceTotals['total_tardies'] = $lates->countAllResults();
|
|
} catch (\Throwable $e) {
|
|
// ignore attendance lookup errors
|
|
}
|
|
|
|
if ($score) {
|
|
$score['total_absences'] = $attendanceTotals['total_absences'];
|
|
$score['total_tardies'] = $attendanceTotals['total_tardies'];
|
|
}
|
|
|
|
$finalAverage = null;
|
|
if (is_numeric($firstSemesterScore) && is_numeric($secondSemesterScore)) {
|
|
$finalAverage = number_format(((float)$firstSemesterScore + (float)$secondSemesterScore) / 2, 1);
|
|
}
|
|
|
|
return [
|
|
'student' => $student,
|
|
'student_name' => ($studentName !== '' ? $studentName : 'N/A'),
|
|
'teacher_name' => ($teacherName !== '' ? $teacherName : 'N/A'),
|
|
'teacher_names' => array_values(array_unique(array_filter($teacherNamesList))),
|
|
'ta_names' => $taNames,
|
|
'ta_names_line' => ($taLine !== '' ? $taLine : 'N/A'),
|
|
'class_section_name' => $classSectionName,
|
|
'class_section_code' => $sectionCode ?: null,
|
|
'class_section_id' => $sectionId ?: null,
|
|
'selected_semester' => $refSemester,
|
|
'grade' => $grade,
|
|
'score' => $score,
|
|
'ptap' => $ptap,
|
|
'final_exam_score' => $finalExam,
|
|
'second_semester_score' => $secondSemesterScore,
|
|
'first_semester_score' => $firstSemesterScore,
|
|
'final_average' => $finalAverage,
|
|
'term_rank' => $termRanking,
|
|
'term_rank_display' => $termRanking['display'] ?? null,
|
|
'comments' => $commentMap,
|
|
'total_score' => $secondSemesterScore,
|
|
'total_attendance_days' => $totalSemesterDays,
|
|
];
|
|
}
|
|
|
|
private function calculateTermRanking(
|
|
int $studentId,
|
|
int $sectionCode,
|
|
?int $sectionId,
|
|
string $schoolYear,
|
|
?string $semester,
|
|
?float $studentScore
|
|
): ?array {
|
|
if ($studentId <= 0 || $schoolYear === '' || $studentScore === null) {
|
|
return null;
|
|
}
|
|
|
|
$sectionIds = array_values(array_unique(array_filter([
|
|
$sectionCode > 0 ? $sectionCode : null,
|
|
$sectionId && $sectionId > 0 ? $sectionId : null,
|
|
])));
|
|
|
|
if (empty($sectionIds)) {
|
|
return null;
|
|
}
|
|
|
|
/*
|
|
* Rank must be limited to the actual selected class roster.
|
|
* Do NOT rank by every semester_scores row that happens to share class_section_id,
|
|
* because old/mismatched score rows can inflate the denominator (for example 18 students
|
|
* in 3-A showing as "out of 32"). The roster is the source of truth for class size.
|
|
*/
|
|
$rosterRows = $this->fetchStudentsByClass($sectionCode, $schoolYear);
|
|
if (empty($rosterRows) && $sectionId && $sectionId !== $sectionCode) {
|
|
$rosterRows = $this->fetchStudentsByClass($sectionId, $schoolYear);
|
|
}
|
|
|
|
$rosterByStudent = [];
|
|
foreach ($rosterRows as $row) {
|
|
$sid = (int)($row['id'] ?? 0);
|
|
if ($sid <= 0) {
|
|
continue;
|
|
}
|
|
$rosterByStudent[$sid] = [
|
|
'firstname' => trim((string)($row['firstname'] ?? '')),
|
|
'lastname' => trim((string)($row['lastname'] ?? '')),
|
|
];
|
|
}
|
|
|
|
if (empty($rosterByStudent) || !isset($rosterByStudent[$studentId])) {
|
|
return null;
|
|
}
|
|
|
|
$rosterStudentIds = array_keys($rosterByStudent);
|
|
|
|
$semesterForRank = trim((string)$semester);
|
|
$rankByFinalScore = $this->normalizeSemester($semesterForRank) === 'spring';
|
|
|
|
$builder = $this->db->table('semester_scores ss')
|
|
->select('ss.student_id, ss.semester, ss.semester_score, ss.updated_at, ss.id')
|
|
->where('ss.school_year', $schoolYear)
|
|
->whereIn('ss.student_id', $rosterStudentIds)
|
|
->orderBy('ss.updated_at', 'DESC')
|
|
->orderBy('ss.id', 'DESC');
|
|
|
|
if ($semesterForRank !== '') {
|
|
$this->applySemesterFilter($builder, $semesterForRank, 'ss.semester');
|
|
}
|
|
|
|
$rows = $builder->get()->getResultArray();
|
|
|
|
if (empty($rows)) {
|
|
return null;
|
|
}
|
|
|
|
$scoresByStudent = [];
|
|
|
|
foreach ($rows as $row) {
|
|
$sid = (int)($row['student_id'] ?? 0);
|
|
|
|
if ($sid <= 0 || !isset($rosterByStudent[$sid]) || isset($scoresByStudent[$sid])) {
|
|
continue;
|
|
}
|
|
|
|
$scoreVal = $row['semester_score'] ?? null;
|
|
|
|
if (!is_numeric($scoreVal)) {
|
|
continue;
|
|
}
|
|
|
|
$rawScore = (float)$scoreVal;
|
|
|
|
$scoresByStudent[$sid] = [
|
|
'student_id' => $sid,
|
|
'score' => $rawScore,
|
|
'rank_score' => round($rawScore, 1),
|
|
'firstname' => $rosterByStudent[$sid]['firstname'],
|
|
'lastname' => $rosterByStudent[$sid]['lastname'],
|
|
];
|
|
}
|
|
|
|
if (empty($scoresByStudent) || !isset($scoresByStudent[$studentId])) {
|
|
return null;
|
|
}
|
|
|
|
// For Spring, rank by final year average:
|
|
// (first semester score + second semester score) / 2.
|
|
if ($rankByFinalScore) {
|
|
$studentIds = array_keys($scoresByStudent);
|
|
|
|
$firstRowsBuilder = $this->db->table('semester_scores ss')
|
|
->select('ss.student_id, ss.semester, ss.semester_score, ss.updated_at, ss.id')
|
|
->where('ss.school_year', $schoolYear)
|
|
->whereIn('ss.student_id', $studentIds)
|
|
->orderBy('ss.updated_at', 'DESC')
|
|
->orderBy('ss.id', 'DESC');
|
|
|
|
if ($semesterForRank !== '') {
|
|
$this->applySemesterExclusion($firstRowsBuilder, $semesterForRank, 'ss.semester');
|
|
}
|
|
|
|
$firstRows = $firstRowsBuilder->get()->getResultArray();
|
|
|
|
$firstScoresByStudent = [];
|
|
|
|
foreach ($firstRows as $row) {
|
|
$sid = (int)($row['student_id'] ?? 0);
|
|
|
|
if ($sid <= 0 || !isset($rosterByStudent[$sid]) || isset($firstScoresByStudent[$sid])) {
|
|
continue;
|
|
}
|
|
|
|
$scoreVal = $row['semester_score'] ?? null;
|
|
|
|
if (!is_numeric($scoreVal)) {
|
|
continue;
|
|
}
|
|
|
|
$firstScoresByStudent[$sid] = (float)$scoreVal;
|
|
}
|
|
|
|
foreach ($scoresByStudent as $sid => &$rankRow) {
|
|
if (!isset($firstScoresByStudent[$sid])) {
|
|
unset($scoresByStudent[$sid]);
|
|
continue;
|
|
}
|
|
|
|
$avg = ((float)$firstScoresByStudent[$sid] + (float)$rankRow['score']) / 2;
|
|
|
|
$rankRow['score'] = $avg;
|
|
$rankRow['rank_score'] = round($avg, 1);
|
|
}
|
|
|
|
unset($rankRow);
|
|
|
|
if (empty($scoresByStudent) || !isset($scoresByStudent[$studentId])) {
|
|
return null;
|
|
}
|
|
|
|
// Force the selected student to use the exact score already computed for the report.
|
|
// Then rank using the displayed precision: one decimal.
|
|
$scoresByStudent[$studentId]['score'] = (float)$studentScore;
|
|
$scoresByStudent[$studentId]['rank_score'] = round((float)$studentScore, 1);
|
|
} else {
|
|
// Fall: also force selected student to match the report-card computed score.
|
|
$scoresByStudent[$studentId]['score'] = (float)$studentScore;
|
|
$scoresByStudent[$studentId]['rank_score'] = round((float)$studentScore, 1);
|
|
}
|
|
|
|
$rankable = array_values($scoresByStudent);
|
|
|
|
usort($rankable, static function (array $a, array $b): int {
|
|
// Highest displayed/rank score first.
|
|
$scoreCmp = $b['rank_score'] <=> $a['rank_score'];
|
|
|
|
if ($scoreCmp !== 0) {
|
|
return $scoreCmp;
|
|
}
|
|
|
|
// Tie-breakers only control display order.
|
|
// They do NOT change rank.
|
|
$lastCmp = strcasecmp($a['lastname'], $b['lastname']);
|
|
|
|
if ($lastCmp !== 0) {
|
|
return $lastCmp;
|
|
}
|
|
|
|
$firstCmp = strcasecmp($a['firstname'], $b['firstname']);
|
|
|
|
if ($firstCmp !== 0) {
|
|
return $firstCmp;
|
|
}
|
|
|
|
return $a['student_id'] <=> $b['student_id'];
|
|
});
|
|
|
|
$position = null;
|
|
$previousScore = null;
|
|
|
|
foreach ($rankable as $index => $row) {
|
|
$currentScore = (float)$row['rank_score'];
|
|
|
|
// Competition ranking:
|
|
// 1, 1, 3, 4...
|
|
// Same score = same rank.
|
|
if ($previousScore === null || abs($currentScore - $previousScore) > 0.0001) {
|
|
$position = $index + 1;
|
|
$previousScore = $currentScore;
|
|
}
|
|
|
|
if ((int)$row['student_id'] === $studentId) {
|
|
$total = count($rankable);
|
|
|
|
return [
|
|
'position' => $position,
|
|
'total' => $total,
|
|
'score' => $currentScore,
|
|
'display' => $this->formatOrdinal($position) . ' out of ' . $total,
|
|
];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function formatOrdinal(?int $value): string
|
|
{
|
|
$n = (int)$value;
|
|
if ($n <= 0) {
|
|
return '';
|
|
}
|
|
|
|
$mod100 = $n % 100;
|
|
if ($mod100 >= 11 && $mod100 <= 13) {
|
|
return $n . 'th';
|
|
}
|
|
|
|
return match ($n % 10) {
|
|
1 => $n . 'st',
|
|
2 => $n . 'nd',
|
|
3 => $n . 'rd',
|
|
default => $n . 'th',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve teacher & TA names for a class section (by PK or code),
|
|
* relaxing semester/year if needed. Returns ['teacher_name' => string, 'ta_names' => string[]].
|
|
*/
|
|
private function resolveTeacherAndTAs(int $sectionIdOrCode, ?string $schoolYear = null, ?string $semester = null): array
|
|
{
|
|
$teacherName = 'N/A';
|
|
$teacherNames = [];
|
|
$taNames = [];
|
|
|
|
$withPrefix = static function (array $row): string {
|
|
$first = trim((string)($row['firstname'] ?? ''));
|
|
$last = trim((string)($row['lastname'] ?? ''));
|
|
$gender = strtolower(trim((string)($row['gender'] ?? '')));
|
|
$name = trim($first . ' ' . $last);
|
|
if ($name === '') return '';
|
|
$prefix = ($gender === 'female') ? 'Sr ' : (($gender === 'male') ? 'Br ' : '');
|
|
return trim($prefix . $name);
|
|
};
|
|
|
|
$normalizePos = static function (?string $pos): string {
|
|
$p = strtolower(trim((string)$pos));
|
|
if ($p === 'teacher' || str_contains($p, 'main') || str_contains($p, 'co-teach')) return 'main';
|
|
if ($p === 'ta' || str_contains($p, 'assist')) return 'ta';
|
|
return 'other';
|
|
};
|
|
|
|
$consume = static function (array $rows) use (&$teacherName, &$teacherNames, &$taNames, $normalizePos, $withPrefix) {
|
|
$any = false;
|
|
$first = null;
|
|
foreach ($rows as $r) {
|
|
$full = $withPrefix($r);
|
|
if ($full === '') continue;
|
|
if ($first === null) $first = $full;
|
|
$any = true;
|
|
|
|
$cls = $normalizePos($r['position'] ?? null);
|
|
if ($cls === 'main') {
|
|
if ($teacherName === 'N/A') {
|
|
$teacherName = $full;
|
|
}
|
|
$teacherNames[] = $full;
|
|
} elseif ($cls === 'ta') {
|
|
$taNames[] = $full;
|
|
}
|
|
}
|
|
if ($teacherName === 'N/A' && $any && $first) {
|
|
$teacherName = $first;
|
|
$teacherNames[] = $first;
|
|
}
|
|
};
|
|
|
|
// 1) Try your model if available
|
|
if (method_exists($this->teacherClassModel, 'getTeacherTABySection')) {
|
|
$rows = $this->teacherClassModel->getTeacherTABySection($sectionIdOrCode, (string)$semester, (string)$schoolYear) ?? [];
|
|
if (!empty($rows)) {
|
|
if (isset($rows['teacher_names']) || isset($rows['teacher_name']) || isset($rows['tas'])) {
|
|
$aggTeacherNames = $rows['teacher_names'] ?? [];
|
|
if (empty($aggTeacherNames) && !empty($rows['teacher_name'])) {
|
|
$aggTeacherNames = [$rows['teacher_name']];
|
|
}
|
|
foreach ($aggTeacherNames as $nm) {
|
|
$nm = trim((string)$nm);
|
|
if ($nm !== '') {
|
|
if ($teacherName === 'N/A') $teacherName = $nm;
|
|
$teacherNames[] = $nm;
|
|
}
|
|
}
|
|
foreach (($rows['tas'] ?? []) as $taRow) {
|
|
if (is_array($taRow) && isset($taRow['name'])) {
|
|
$taNm = trim((string)$taRow['name']);
|
|
if ($taNm !== '') $taNames[] = $taNm;
|
|
} elseif (is_string($taRow)) {
|
|
$taNm = trim($taRow);
|
|
if ($taNm !== '') $taNames[] = $taNm;
|
|
}
|
|
}
|
|
} else {
|
|
$consume($rows);
|
|
}
|
|
}
|
|
}
|
|
|
|
// If still unknown, do robust DB queries that cover PK/code variants and relaxed filters
|
|
if ($teacherName === 'N/A') {
|
|
$ids = [$sectionIdOrCode];
|
|
|
|
$runner = function (array $ids, string $yr, string $sm) {
|
|
$qb = $this->db->table('teacher_class tc')
|
|
->select('tc.teacher_id, tc.position, u.firstname, u.lastname, u.gender')
|
|
->join('users u', 'u.id = tc.teacher_id', 'inner')
|
|
->whereIn('tc.class_section_id', array_map('intval', $ids))
|
|
->orderBy("CASE WHEN LOWER(tc.position) IN ('main','teacher','main teacher') THEN 0 ELSE 1 END", 'ASC', false)
|
|
->orderBy('COALESCE(tc.updated_at, tc.id)', 'DESC', false);
|
|
if ($yr !== '') $qb->where('tc.school_year', $yr);
|
|
return $qb->get()->getResultArray();
|
|
};
|
|
|
|
$yr = (string)($schoolYear ?? '');
|
|
$sm = (string)($semester ?? '');
|
|
|
|
$rows = $runner($ids, $yr, $sm);
|
|
if (empty($rows)) $rows = $runner($ids, $yr, '');
|
|
if (empty($rows)) $rows = $runner($ids, '', '');
|
|
if (!empty($rows)) $consume($rows);
|
|
}
|
|
|
|
// 3) Final bridge via name-join if PK/code are mixed in tc
|
|
if ($teacherName === 'N/A') {
|
|
// Resolve section name from either PK or code
|
|
$csName = null;
|
|
$byId = $this->db->table('classSection')->select('class_section_name')->where('id', $sectionIdOrCode)->get()->getRowArray();
|
|
if (!empty($byId['class_section_name'])) {
|
|
$csName = $byId['class_section_name'];
|
|
} else {
|
|
$byCode = $this->db->table('classSection')->select('class_section_name')->where('class_section_id', $sectionIdOrCode)->get()->getRowArray();
|
|
if (!empty($byCode['class_section_name'])) $csName = $byCode['class_section_name'];
|
|
}
|
|
|
|
if ($csName) {
|
|
$base = $this->db->table('teacher_class tc')
|
|
->select('tc.teacher_id, tc.position, u.firstname, u.lastname, u.gender')
|
|
->join('users u', 'u.id = tc.teacher_id', 'inner')
|
|
// unescaped join to allow OR; CI4: escape=false is the 4th arg
|
|
->join('classSection cs', 'cs.id = tc.class_section_id OR cs.class_section_id = tc.class_section_id', 'inner', false)
|
|
->where('cs.class_section_name', $csName)
|
|
->orderBy("CASE WHEN LOWER(tc.position) IN ('main','teacher','main teacher') THEN 0 ELSE 1 END", 'ASC', false)
|
|
->orderBy('COALESCE(tc.updated_at, tc.id)', 'DESC', false);
|
|
|
|
$yr = (string)($schoolYear ?? '');
|
|
$sm = (string)($semester ?? '');
|
|
|
|
$qb1 = clone $base;
|
|
if ($yr !== '') $qb1->where('tc.school_year', $yr);
|
|
$rows = $qb1->get()->getResultArray();
|
|
|
|
if (empty($rows) && $yr !== '') {
|
|
$qb2 = clone $base;
|
|
$qb2->where('tc.school_year', $yr);
|
|
$rows = $qb2->get()->getResultArray();
|
|
}
|
|
if (empty($rows)) $rows = $base->get()->getResultArray();
|
|
|
|
if (!empty($rows)) $consume($rows);
|
|
}
|
|
}
|
|
|
|
// Clean names
|
|
$taNames = array_values(array_unique(array_filter(array_map('trim', $taNames))));
|
|
$teacherNames = array_values(array_unique(array_filter(array_map('trim', $teacherNames))));
|
|
if (empty($teacherNames) && $teacherName !== 'N/A') {
|
|
$teacherNames[] = $teacherName;
|
|
}
|
|
|
|
return [
|
|
'teacher_name' => $teacherName,
|
|
'teacher_names' => $teacherNames,
|
|
'ta_names' => $taNames,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Normalize a report date (YYYY-MM-DD) from input; falls back to today.
|
|
* Returns ['iso' => 'Y-m-d', 'display' => 'F j, Y'].
|
|
*/
|
|
private function sanitizeReportDate($input): array
|
|
{
|
|
$raw = trim((string)$input);
|
|
$ts = null;
|
|
if ($raw !== '') {
|
|
$ts = strtotime($raw);
|
|
}
|
|
if ($ts === false || $ts === null) {
|
|
$ts = time();
|
|
}
|
|
$iso = date('Y-m-d', $ts);
|
|
$display = date('F j, Y', $ts);
|
|
return ['iso' => $iso, 'display' => $display];
|
|
}
|
|
|
|
}
|