Files
2026-02-10 22:11:06 -05:00

1725 lines
73 KiB
PHP

<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\UserModel; // Ensure this is the correct namespace for UserModel
use App\Models\StudentModel; // Ensure this is the correct namespace for StudentModel
use App\Models\StudentClassModel;
use App\Models\ClassSectionModel;
use App\Models\EmergencyContactModel;
use App\Models\EnrollmentModel;
use App\Models\ConfigurationModel;
use App\Models\StudentAllergyModel;
use App\Models\StudentMedicalConditionModel;
use CodeIgniter\Database\Exceptions\DataException;
use Config\Services;
use Throwable;
class StudentController extends BaseController
{
protected $configModel;
protected $semester;
protected $schoolYear;
protected $teacherClassModel;
protected $studentClassModel;
protected $studentModel;
protected $teacherModel;
protected $userModel;
protected $db;
protected $conditionModel;
protected $allergyModel;
protected $classSectionModel;
protected $emergencyContact;
protected $enrollmentModel;
public function __construct()
{
// Load the database service
$this->db = \Config\Database::connect();
// Check if the database connection is established
if (!$this->db->connect()) {
log_message('error', 'Database connection failed.');
throw new \Exception('Database connection failed.');
} else {
log_message('info', 'Database connection successful.');
}
$this->userModel = new UserModel();
$this->configModel = new ConfigurationModel();
$this->studentClassModel = new StudentClassModel();
$this->studentModel = new StudentModel();
$this->allergyModel = new StudentAllergyModel();
$this->conditionModel = new StudentMedicalConditionModel();
$this->classSectionModel = new ClassSectionModel();
$this->emergencyContact = new EmergencyContactModel();
$this->enrollmentModel = new EnrollmentModel();
$this->semester = $this->configModel->getConfig('semester');
$this->schoolYear = $this->configModel->getConfig('school_year');
helper(['url', 'form']);
}
public function assignClassStudent()
{
$isAjax = $this->request->isAJAX();
$studentId = (int) $this->request->getPost('student_id');
$rawSections = $this->request->getPost('class_section_id');
$isEventOnly = $this->request->getPost('is_event_only') ? 1 : 0;
$classSectionIds = [];
// Normalize any input into an array of unique positive IDs
if (is_array($rawSections)) {
$classSectionIds = array_map('intval', $rawSections);
} elseif ($rawSections !== null && $rawSections !== '') {
$parts = is_string($rawSections) ? preg_split('/[,\s]+/', $rawSections) : [$rawSections];
$classSectionIds = array_map('intval', $parts);
}
$classSectionIds = array_values(array_unique(array_filter($classSectionIds, static fn($v) => $v > 0)));
$userId = (int) (session()->get('user_id') ?? 0);
$now = utc_now();
$jsonOut = function (array $payload, int $code = 200) {
// Always return a fresh CSRF for next request (works with csrfRegenerate = true)
$payload['csrfTokenName'] = csrf_token();
$payload['csrfHash'] = csrf_hash();
$payload[csrf_token()] = csrf_hash();
return $this->response->setStatusCode($code)->setJSON($payload);
};
// Validate input
if (!$studentId || empty($classSectionIds)) {
$msg = 'Missing required data (student/class section).';
return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 400)
: redirect()->back()->with('error', $msg);
}
// Check student
$student = $this->studentModel->find($studentId);
if (!$student) {
$msg = 'Student not found.';
return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 404)
: redirect()->back()->with('error', $msg);
}
// Robust section lookup: allow id OR class_section_id
$sections = $this->classSectionModel
->groupStart()
->whereIn('class_section_id', $classSectionIds)
->orWhereIn('id', $classSectionIds)
->groupEnd()
->findAll();
if (empty($sections)) {
$msg = 'Class/Section not found.';
return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 404)
: redirect()->back()->with('error', $msg);
}
// Map for quick lookup; preserve requested order
$sectionMap = [];
foreach ($sections as $sec) {
$sectionMap[(int)($sec['class_section_id'] ?? 0)] = $sec;
}
$existing = $this->studentClassModel
->where('student_id', $studentId)
->where('school_year', (string)$this->schoolYear)
->findAll();
$existingBySection = [];
foreach ($existing as $row) {
$existingBySection[(int)($row['class_section_id'] ?? 0)] = $row;
}
$displayNames = [];
foreach ($classSectionIds as $cid) {
if (!isset($sectionMap[$cid])) continue;
$eventFlag = $isEventOnly ? 1 : 0;
if (isset($existingBySection[$cid])) {
$eventFlag = (int)($existingBySection[$cid]['is_event_only'] ?? 0);
}
$name = $this->formatClassSectionDisplayName($sectionMap[$cid], $cid);
if ($eventFlag) {
$name .= ' (Event)';
}
$displayNames[] = $name;
}
if (empty($displayNames)) {
$msg = 'Class/Section not found.';
return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 404)
: redirect()->back()->with('error', $msg);
}
if (count($displayNames) !== count($classSectionIds)) {
$msg = 'One or more selected classes do not exist.';
return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 404)
: redirect()->back()->with('error', $msg);
}
$primarySectionId = $classSectionIds[0];
$primarySection = $sectionMap[$primarySectionId] ?? reset($sectionMap);
// 🔎 Derive parent class_id from the section row (fallback to raw query if needed)
$parentClassId = (int)($primarySection['class_id'] ?? 0);
if (!$parentClassId) {
// Adjust table name if your schema uses snake_case like 'class_section'
$row = $this->db->table('classSection')
->select('class_id')
->groupStart()
->where('id', $primarySection['id'] ?? $primarySectionId)
->orWhere('class_section_id', $primarySectionId)
->groupEnd()
->get()->getRowArray();
if ($row && isset($row['class_id'])) {
$parentClassId = (int)$row['class_id'];
}
}
$this->db->transBegin();
try {
// Upsert student_class entries per selection
$scPk = $this->studentClassModel->primaryKey ?? 'id';
foreach ($classSectionIds as $cid) {
$eventFlag = $isEventOnly ? 1 : 0;
if (isset($existingBySection[$cid])) {
$eventFlag = (int)($existingBySection[$cid]['is_event_only'] ?? 0);
}
$payload = [
'student_id' => $studentId,
'class_section_id' => $cid,
'school_year' => (string)$this->schoolYear,
'is_event_only' => $eventFlag,
'updated_by' => $userId ?: null,
'updated_at' => $now,
];
if (isset($existingBySection[$cid])) {
if (!$this->studentClassModel->update($existingBySection[$cid][$scPk], $payload)) {
throw new \RuntimeException('Failed to update assignment: ' . json_encode($this->studentClassModel->errors()));
}
} else {
$payload['created_at'] = $now;
if (!$this->studentClassModel->insert($payload)) {
throw new \RuntimeException('Failed to insert assignment: ' . json_encode($this->studentClassModel->errors()));
}
}
}
$attnStats = [];
$scoreStats = [];
if (!$isEventOnly) {
// Update enrollment for current term (if exists)
$enroll = $this->enrollmentModel
->where('student_id', $studentId)
->where('school_year', (string)$this->schoolYear)
->where('semester', (string)$this->semester)
->first();
if ($enroll) {
$enPk = $this->enrollmentModel->primaryKey ?? 'id';
if (!$this->enrollmentModel->update($enroll[$enPk], [
'class_section_id' => $primarySectionId,
'enrollment_status' => 'payment pending',
// Ensure admission is marked accepted once moved out of review
'admission_status' => 'accepted',
'updated_at' => $now,
])) {
throw new \RuntimeException('Failed to update enrollment: ' . json_encode($this->enrollmentModel->errors()));
}
}
// 🔄 Re-tag attendance rows for this student in the current term (section + class)
$attnStats = $this->updateStudentAttendanceSection(
$studentId,
$primarySectionId,
$parentClassId ?: 0, // safe default if missing
(string)$this->semester,
(string)$this->schoolYear,
$userId ?: null
);
// 🔄 Re-tag score rows (quiz, project, homework, participation, exams, aggregates) for current term
$scoreStats = $this->updateStudentScoresSection(
$studentId,
$primarySectionId,
(string)$this->semester,
(string)$this->schoolYear,
$userId ?: null
);
}
if ($this->db->transStatus() === false) {
throw new \RuntimeException('Transaction failed.');
}
$this->db->transCommit();
$resp = [
'ok' => true,
'student_id' => $studentId,
'class_section_id' => $primarySectionId,
'class_section_ids' => $classSectionIds,
'class_section_name' => implode(', ', $displayNames),
'class_section_names'=> $displayNames,
'attendance_updates' => $attnStats, // {attendance_data_updated, attendance_record_updated}
'score_updates' => $scoreStats, // per-table updated counts
'message' => 'Assignment saved.',
];
return $isAjax ? $jsonOut($resp, 200)
: redirect()->to(base_url('administrator/student_class_assignment'))->with('success', $resp['message']);
} catch (\Throwable $e) {
$this->db->transRollback();
$msg = 'Unable to assign class: ' . $e->getMessage();
return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 500)
: redirect()->back()->with('error', $msg);
}
}
/**
* Remove a student-class assignment for the current term.
*/
public function removeClassStudent()
{
$isAjax = $this->request->isAJAX();
$studentId = (int) $this->request->getPost('student_id');
$classSectionId = (int) $this->request->getPost('class_section_id');
$userId = (int) (session()->get('user_id') ?? 0);
$now = utc_now();
$jsonOut = function (array $payload, int $code = 200) {
$payload['csrfTokenName'] = csrf_token();
$payload['csrfHash'] = csrf_hash();
$payload[csrf_token()] = csrf_hash();
return $this->response->setStatusCode($code)->setJSON($payload);
};
if (!$studentId || !$classSectionId) {
$msg = 'Missing required data (student/class section).';
return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 400)
: redirect()->back()->with('error', $msg);
}
// Verify assignment exists for this term
$row = $this->studentClassModel
->where('student_id', $studentId)
->where('class_section_id', $classSectionId)
->where('school_year', (string)$this->schoolYear)
->first();
if (!$row) {
$msg = 'Assignment not found for this student/class.';
return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 404)
: redirect()->back()->with('error', $msg);
}
// Remaining assignments BEFORE delete (for enrollment swap)
$beforeIds = $this->studentClassModel->getClassSectionIdsByStudentId($studentId, (string)$this->schoolYear);
$this->db->transBegin();
try {
// Delete the assignment
if (!$this->studentClassModel
->where('student_id', $studentId)
->where('class_section_id', $classSectionId)
->where('school_year', (string)$this->schoolYear)
->delete()) {
throw new \RuntimeException('Failed to remove assignment.');
}
// Determine remaining assignments AFTER delete
$remainingIds = $this->studentClassModel->getClassSectionIdsByStudentId($studentId, (string)$this->schoolYear);
$remainingNames = $this->studentClassModel->getClassSectionsByStudentId($studentId, (string)$this->schoolYear, true);
$remainingDisplay = !empty($remainingNames) ? implode(', ', $remainingNames) : '';
// If enrollment pointed to removed class, move to another remaining class or null
$enroll = $this->enrollmentModel
->where('student_id', $studentId)
->where('school_year', (string)$this->schoolYear)
->where('semester', (string)$this->semester)
->first();
if ($enroll) {
$enPk = $this->enrollmentModel->primaryKey ?? 'id';
$newEnrollmentClass = null;
if (!empty($remainingIds)) {
$newEnrollmentClass = $remainingIds[0];
}
if ((int)($enroll['class_section_id'] ?? 0) === $classSectionId || $newEnrollmentClass !== null) {
$this->enrollmentModel->update($enroll[$enPk], [
'class_section_id' => $newEnrollmentClass,
'updated_at' => $now,
'updated_by' => $userId ?: null,
]);
}
}
if ($this->db->transStatus() === false) {
throw new \RuntimeException('Transaction failed.');
}
$this->db->transCommit();
$resp = [
'ok' => true,
'student_id' => $studentId,
'removed_class_id' => $classSectionId,
'remaining_ids' => $remainingIds,
'remaining_names' => $remainingNames,
'remaining_display' => $remainingDisplay !== '' ? $remainingDisplay : 'No class assigned',
'message' => 'Class removed.',
];
return $isAjax ? $jsonOut($resp, 200)
: redirect()->to(base_url('administrator/student_class_assignment'))->with('success', $resp['message']);
} catch (\Throwable $e) {
$this->db->transRollback();
$msg = 'Unable to remove class: ' . $e->getMessage();
return $isAjax ? $jsonOut(['ok' => false, 'message' => $msg], 500)
: redirect()->back()->with('error', $msg);
}
}
public function removedStudents()
{
$schoolYear = (string)($this->schoolYear ?? '');
$activeStudents = $this->studentModel
->select('id, school_id, firstname, lastname, gender, age')
->where('is_active', 1)
->orderBy('lastname', 'ASC')
->orderBy('firstname', 'ASC')
->findAll();
$removedStudents = $this->studentModel
->select('id, school_id, firstname, lastname, gender, age')
->where('is_active', 0)
->orderBy('lastname', 'ASC')
->orderBy('firstname', 'ASC')
->findAll();
$classMap = [];
$classQuery = $this->db->table('student_class sc')
->select('sc.student_id, cs.class_section_name')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left');
if ($schoolYear !== '') {
$classQuery->where('sc.school_year', $schoolYear);
}
$classRows = $classQuery->get()->getResultArray();
foreach ($classRows as $row) {
$sid = (int)($row['student_id'] ?? 0);
$name = trim((string)($row['class_section_name'] ?? ''));
if ($sid <= 0 || $name === '') continue;
$classMap[$sid][] = $name;
}
$attachClassNames = static function (array $students) use ($classMap): array {
foreach ($students as &$student) {
$sid = (int)($student['id'] ?? 0);
$names = $classMap[$sid] ?? [];
$names = array_values(array_unique(array_filter($names)));
$student['class_sections'] = $names;
$student['class_section_name'] = !empty($names) ? implode(', ', $names) : 'No class assigned';
}
unset($student);
return $students;
};
return view('administrator/removed_students', [
'active_students' => $attachClassNames($activeStudents),
'removed_students' => $attachClassNames($removedStudents),
'school_year' => $schoolYear,
]);
}
public function setStudentActive()
{
$studentId = (int) $this->request->getPost('student_id');
$isActiveRaw = (string) $this->request->getPost('is_active');
$isActive = $isActiveRaw === '1' ? 1 : 0;
$now = utc_now();
$userId = (int) (session()->get('user_id') ?? 0);
if ($studentId <= 0) {
return redirect()->back()->with('error', 'Invalid student ID.');
}
$student = $this->studentModel->find($studentId);
if (!$student) {
return redirect()->back()->with('error', 'Student not found.');
}
if (!$this->studentModel->update($studentId, ['is_active' => $isActive])) {
return redirect()->back()->with('error', 'Unable to update student status.');
}
$message = $isActive ? 'Student restored successfully.' : 'Student removed successfully.';
if ($isActive === 1) {
$hasCurrentClass = $this->studentClassModel
->where('student_id', $studentId)
->where('school_year', (string)$this->schoolYear)
->where('semester', (string)$this->semester)
->first();
if (!$hasCurrentClass) {
$lastClass = $this->studentClassModel
->where('student_id', $studentId)
->orderBy('updated_at', 'DESC')
->orderBy('created_at', 'DESC')
->orderBy('id', 'DESC')
->first();
$restoreClassId = (int)($lastClass['class_section_id'] ?? 0);
if ($restoreClassId > 0) {
$inserted = $this->studentClassModel->insert([
'student_id' => $studentId,
'class_section_id' => $restoreClassId,
'semester' => (string)$this->semester,
'school_year' => (string)$this->schoolYear,
'description' => $lastClass['description'] ?? null,
'updated_by' => $userId ?: null,
'updated_at' => $now,
'created_at' => $now,
]);
if ($inserted) {
$classLabel = (string)($this->classSectionModel->getClassSectionNameBySectionId($restoreClassId) ?? '');
if ($classLabel !== '') {
$message .= ' Class assignment restored to ' . $classLabel . '.';
}
} else {
$message .= ' No class assignment found for the current term.';
return redirect()->to(base_url('administrator/removed_students'))->with('warning', $message);
}
} else {
$message .= ' No class assignment found for the current term.';
return redirect()->to(base_url('administrator/removed_students'))->with('warning', $message);
}
}
}
if ($isActive === 0) {
if (!$this->notifyParentOfStudentRemoval($studentId)) {
$message .= ' Parent notification email could not be sent.';
}
}
return redirect()->to(base_url('administrator/removed_students'))->with('success', $message);
}
private function notifyParentOfStudentRemoval(int $studentId): bool
{
try {
$studentRow = $this->studentModel
->select([
'students.firstname AS student_firstname',
'students.lastname AS student_lastname',
'students.school_id',
'users.id AS parent_id',
'users.firstname AS parent_firstname',
'users.lastname AS parent_lastname',
'users.email AS parent_email',
])
->join('users', 'users.id = students.parent_id', 'left')
->where('students.id', $studentId)
->first();
if (empty($studentRow)) {
log_message('warning', "Student removal email skipped: student {$studentId} not found.");
return false;
}
$parentEmail = trim((string)($studentRow['parent_email'] ?? ''));
if ($parentEmail === '') {
log_message('warning', "Student removal email skipped: missing parent email for student {$studentId}.");
return false;
}
$studentName = trim(trim((string)($studentRow['student_firstname'] ?? '')) . ' ' . trim((string)($studentRow['student_lastname'] ?? '')));
$parentName = trim(trim((string)($studentRow['parent_firstname'] ?? '')) . ' ' . trim((string)($studentRow['parent_lastname'] ?? '')));
if ($parentName === '') {
$parentName = 'Parent/Guardian';
}
$subject = 'Withdrawal Notice for ' . ($studentName !== '' ? $studentName : 'Your Student');
$html = view('emails/student_removed', [
'student_name' => $studentName,
'school_id' => $studentRow['school_id'] ?? '',
'parent_name' => $parentName,
'signature' => 'AlRahma School Administration',
], ['saveData' => true]);
$sent = $this->sendHtmlEmail($parentEmail, $subject, $html);
$logLevel = $sent ? 'info' : 'warning';
log_message($logLevel, "Student removal email " . ($sent ? '' : 'not ') . "sent (studentId: {$studentId}, parent: {$parentEmail}).");
return $sent;
} catch (\Throwable $e) {
log_message('error', 'Student removal notification failed: ' . $e->getMessage());
return false;
}
}
private function sendHtmlEmail(string $to, string $subject, string $html): bool
{
try {
$service = function_exists('service') ? service('emailService') : null;
if (!$service && method_exists(Services::class, 'emailService')) {
$service = Services::emailService();
}
if ($service && method_exists($service, 'send')) {
$result = $service->send($to, $subject, $html, 'student-removal');
if ($result) {
return true;
}
log_message('debug', 'Custom emailService failed to send student removal notice.');
}
$email = Services::email();
$cfg = config('Email');
$fromEmail = $cfg->fromEmail ?? $cfg->SMTPUser ?? 'no-reply@example.com';
$fromName = $cfg->fromName ?? 'Al Rahma Sunday School';
$email->setTo($to);
$email->setFrom($fromEmail, $fromName);
$email->setSubject($subject);
$email->setMessage($html);
$email->setMailType('html');
$ok = $email->send();
if (!$ok) {
$debug = method_exists($email, 'printDebugger') ? $email->printDebugger(['headers', 'subject']) : 'no debugger';
log_message('debug', 'CI Email send failed for student removal: ' . print_r($debug, true));
}
return $ok;
} catch (\Throwable $e) {
log_message('debug', 'sendHtmlEmail exception for student removal: ' . $e->getMessage());
return false;
}
}
private function updateStudentAttendanceSection(
int $studentId,
int $newClassSectionId,
int $newClassId,
string $semester,
string $schoolYear,
?int $modifiedBy = null
): array {
$now = utc_now();
// ---- attendance_data: set class_section_id + class_id ----
$builderData = $this->db->table('attendance_data');
$builderData
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->set([
'class_section_id' => $newClassSectionId,
'class_id' => $newClassId,
'updated_at' => $now,
]);
if ($modifiedBy) {
$builderData->set('modified_by', $modifiedBy);
}
if ($builderData->update() === false) {
$err = $this->db->error();
throw new \RuntimeException('Failed to update attendance_data: ' . ($err['message'] ?? 'unknown DB error'));
}
$dataUpdated = $this->db->affectedRows();
// ---- attendance_record: set class_section_id (table has no class_id) ----
$builderRec = $this->db->table('attendance_record');
$builderRec
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->set([
'class_section_id' => $newClassSectionId,
'updated_at' => $now,
]);
if ($modifiedBy) {
$builderRec->set('modified_by', $modifiedBy);
}
if ($builderRec->update() === false) {
$err = $this->db->error();
throw new \RuntimeException('Failed to update attendance_record: ' . ($err['message'] ?? 'unknown DB error'));
}
$recUpdated = $this->db->affectedRows();
return [
'attendance_data_updated' => $dataUpdated,
'attendance_record_updated' => $recUpdated,
];
}
/**
* Update all score-related tables for a student to the new class_section_id
* within the provided semester and school year.
* Affected tables: homework, quiz, project, participation, midterm_exam,
* final_exam, final_score, semester_scores.
*/
private function updateStudentScoresSection(
int $studentId,
int $newClassSectionId,
string $semester,
string $schoolYear,
?int $modifiedBy = null
): array {
$now = utc_now();
$tables = [
'homework',
'quiz',
'project',
'participation',
'midterm_exam',
'final_exam',
'final_score',
'semester_scores',
];
$results = [];
foreach ($tables as $tbl) {
$builder = $this->db->table($tbl);
$builder
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->set([
'class_section_id' => $newClassSectionId,
'updated_at' => $now,
]);
// Only set updated_by if the column exists in table schema. For most it does.
if ($modifiedBy !== null) {
$builder->set('updated_by', $modifiedBy);
}
if ($builder->update() === false) {
$err = $this->db->error();
throw new \RuntimeException('Failed to update ' . $tbl . ': ' . ($err['message'] ?? 'unknown DB error'));
}
$results[$tbl . '_updated'] = $this->db->affectedRows();
}
return $results;
}
/**
* Build a display name from whatever fields the section row has.
* Supports: class_section_name, section_name, name, title, grade/letter combos.
*/
private function formatClassSectionDisplayName(array $row, int $fallbackId): string
{
if (!empty($row['class_section_name'])) return (string) $row['class_section_name'];
if (!empty($row['section_name'])) return (string) $row['section_name'];
if (!empty($row['name'])) return (string) $row['name'];
if (!empty($row['title'])) return (string) $row['title'];
$grade = $row['grade_name'] ?? $row['grade'] ?? $row['class_name'] ?? null;
$letter = $row['section'] ?? $row['section_letter'] ?? $row['letter'] ?? null;
if ($grade && $letter) return "{$grade} - {$letter}";
if ($grade) return (string) $grade;
return 'Section #' . $fallbackId;
}
public function studentClassAssignment()
{
// Resolve selected year and available years
$selectedYear = trim((string)($this->request->getGet('schoolYear') ?? ''));
if ($selectedYear === '') $selectedYear = (string)($this->schoolYear ?? '');
$yearsRows = $this->db->table('enrollments')
->select('DISTINCT school_year', false)
->orderBy('school_year', 'DESC')
->get()->getResultArray();
$schoolYears = array_values(array_filter(array_map(static function ($r) {
return isset($r['school_year']) ? (string)$r['school_year'] : null;
}, $yearsRows)));
// Retrieve students with an enrollment in the selected year (any status)
$students = $this->studentModel
->select('students.id, students.firstname, students.lastname, students.registration_date, students.is_new, students.age, students.parent_id, students.registration_grade')
->join('enrollments e', 'e.student_id = students.id', 'inner')
->where('e.school_year', $selectedYear)
->groupBy('students.id')
->orderBy('students.lastname', 'ASC')
->findAll();
// Fallback: if none found (data inconsistency), include students even without an enrollment row
if (empty($students)) {
$students = $this->studentModel
->select('students.id, students.firstname, students.lastname, students.registration_date, students.is_new, students.age, students.parent_id, students.registration_grade')
->orderBy('students.lastname', 'ASC')
->findAll();
}
$studentData = [];
foreach ($students as $student) {
$sectionNames = $this->studentClassModel->getClassSectionsByStudentIdWithFlags((int)$student['id'], $selectedYear, true);
$sectionIds = $this->studentClassModel->getClassSectionIdsByStudentId((int)$student['id'], $selectedYear);
$sectionDisplay = !empty($sectionNames) ? implode(', ', $sectionNames) : '';
// Use primary parent or fallback to second
$pid = (int)($student['parent_id'] ?? 0);
if ($pid <= 0) $pid = (int)($student['secondparent_user_id'] ?? 0);
$emergencyInfo = $pid > 0 ? ($this->emergencyContact->getEmergencyContactByParentId($pid) ?? []) : [];
$emergencyContactName = $emergencyInfo['emergency_contact_name'] ?? '';
$emergencyContactPhone = $emergencyInfo['cellphone'] ?? '';
$studentData[] = [
'student_id' => (int)$student['id'],
'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
'age' => $student['age'] ?? 'N/A',
'email' => $emergencyContactName,
'phone' => $emergencyContactPhone,
'registration_grade' => $student['registration_grade'] ?? 'N/A',
'class_section_name' => $sectionDisplay !== '' ? $sectionDisplay : 'No class assigned',
'class_section_names' => $sectionNames,
'class_section_ids' => $sectionIds,
'new_student' => ((int)($student['is_new'] ?? 0) === 1) ? 'Yes' : 'No',
'registration_date' => $student['registration_date'] ?? '',
'school_year' => $selectedYear,
'semester' => (string)($this->semester ?? ''),
];
}
// Classes for selected year (fallback to all)
$classes = $this->classSectionModel
->select('id, class_section_id, class_section_name, school_year')
->where('school_year', $selectedYear)
->orderBy('class_section_name','ASC')
->findAll();
if (empty($classes)) $classes = $this->classSectionModel->select('id, class_section_id, class_section_name')->orderBy('class_section_name','ASC')->findAll();
// Prepare data for view
return view('administrator/student_class_assignment', [
'students' => $studentData,
'classes' => $classes,
'schoolYears' => $schoolYears,
'selectedYear' => $selectedYear,
'currentYear' => (string)($this->schoolYear ?? ''),
'isCurrentYear'=> ((string)$selectedYear === (string)($this->schoolYear ?? '')),
]);
}
/**
* GET admin view: page to trigger auto-distribution for a class/year.
*/
public function autoDistributePage()
{
// Resolve selected year and available years
$selectedYear = trim((string)($this->request->getGet('schoolYear') ?? ''));
if ($selectedYear === '') $selectedYear = (string)($this->schoolYear ?? '');
$yearsRows = $this->db->table('enrollments')
->select('DISTINCT school_year', false)
->orderBy('school_year', 'DESC')
->get()->getResultArray();
$schoolYears = array_values(array_filter(array_map(static function ($r) {
return isset($r['school_year']) ? (string)$r['school_year'] : null;
}, $yearsRows)));
// Classes for selected year (fallback to all)
$classes = $this->classSectionModel
->select('id, class_id, class_section_id, class_section_name, school_year')
->where('school_year', $selectedYear)
->orderBy('class_section_name','ASC')
->findAll();
if (empty($classes)) $classes = $this->classSectionModel->select('id, class_id, class_section_id, class_section_name')->orderBy('class_section_name','ASC')->findAll();
return view('administrator/sections_auto_distribute', [
'classes' => $classes,
'schoolYears' => $schoolYears,
'selectedYear' => $selectedYear,
'currentYear' => (string)($this->schoolYear ?? ''),
]);
}
/**
* POST admin endpoint: auto distribute students into lettered sections for a class
* Input: class_id (int), students_per_section (int), school_year (optional)
* Uses promotion_queue for the selected year (students who passed last year to this class).
* Balances male/female per section and respects capacity.
*/
public function autoDistributeSections()
{
$isAjax = $this->request->isAJAX();
$json = function (array $p, int $code = 200) {
$p['csrfTokenName'] = csrf_token();
$p['csrfHash'] = csrf_hash();
$p[csrf_token()] = csrf_hash();
return $this->response->setStatusCode($code)->setJSON($p);
};
try {
$classId = (int) $this->request->getPost('class_id');
$classSectionId = (int) $this->request->getPost('class_section_id');
$perSec = (int) $this->request->getPost('students_per_section');
$year = trim((string) ($this->request->getPost('school_year') ?? $this->schoolYear));
if ($classId <= 0 && $classSectionId > 0) {
$cid = $this->classSectionModel->getClassId($classSectionId);
$classId = (int) ($cid ?? 0);
}
if ($classId <= 0 || $perSec <= 0) {
$msg = 'Invalid class_id or students_per_section.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
$promo = new \App\Models\PromotionQueueModel();
// Candidates from promotion queue for this class/year and enrolled (or payment pending)
$cands = $promo->select('promotion_queue.*, students.gender')
->join('students', 'students.id = promotion_queue.student_id', 'left')
->where('promotion_queue.to_class_id', $classId)
->where('promotion_queue.school_year_to', $year)
->whereIn('promotion_queue.status', ['queued','assigned'])
->findAll();
if (empty($cands)) {
$msg = 'No students found in promotion queue for selected class/year.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 200) : redirect()->back()->with('info', $msg);
}
// Filter to those with enrollment for this year in acceptable statuses
$studentIds = array_map(static fn($r) => (int)$r['student_id'], $cands);
$enrolledIds = [];
if (!empty($studentIds)) {
$rows = $this->db->table('enrollments')
->select('student_id')
->whereIn('student_id', $studentIds)
->where('school_year', $year)
->whereIn('enrollment_status', ['payment pending', 'enrolled'])
->groupBy('student_id')
->get()->getResultArray();
$enrolledIds = array_map(static fn($r) => (int)$r['student_id'], $rows);
}
$cands = array_values(array_filter($cands, static function ($r) use ($enrolledIds) {
return in_array((int)$r['student_id'], $enrolledIds, true);
}));
if (empty($cands)) {
$msg = 'No eligible enrolled students found to distribute.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 200) : redirect()->back()->with('info', $msg);
}
$total = count($cands);
$sectionsNeeded = (int) ceil($total / $perSec);
// Fetch lettered sections for this class
$letters = $this->classSectionModel->getLetterSectionsByClassId($classId);
if (empty($letters)) {
$msg = 'No lettered sections found for the selected class.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
if (count($letters) < $sectionsNeeded) {
$msg = 'Not enough sections available. Needed: ' . $sectionsNeeded . ', available: ' . count($letters);
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
// Keep only required number of sections
$letters = array_slice($letters, 0, $sectionsNeeded);
// Prepare buckets
$buckets = [];
foreach ($letters as $idx => $sec) {
$buckets[$idx] = [
'class_section_id' => (int)$sec['class_section_id'],
'assigned' => [],
'male' => 0,
'female' => 0,
];
}
// Split by gender
$males = [];
$females = [];
foreach ($cands as $r) {
$g = (string)($r['gender'] ?? '');
if (strcasecmp($g, 'Female') === 0) $females[] = $r; else $males[] = $r; // default non-female -> male bucket
}
// Helper to pick next bucket with available capacity and least of a gender
$pickBucket = function (string $gender) use (&$buckets, $perSec): ?int {
$bestIdx = null;
$bestCnt = PHP_INT_MAX;
foreach ($buckets as $i => $b) {
if (count($b['assigned']) >= $perSec) continue;
$cnt = ($gender === 'female') ? $b['female'] : $b['male'];
if ($cnt < $bestCnt) {
$bestCnt = $cnt;
$bestIdx = $i;
}
}
return $bestIdx;
};
// Assign males then females for balance
foreach ($males as $r) {
$bi = $pickBucket('male');
if ($bi === null) break;
$buckets[$bi]['assigned'][] = (int)$r['student_id'];
$buckets[$bi]['male']++;
}
foreach ($females as $r) {
$bi = $pickBucket('female');
if ($bi === null) break;
$buckets[$bi]['assigned'][] = (int)$r['student_id'];
$buckets[$bi]['female']++;
}
// Persist: set to_class_section_id on queue and upsert student_class
$promoIdsBySid = [];
foreach ($cands as $r) {
$promoIdsBySid[(int)$r['student_id']] = (int)$r['id'];
}
$studentClass = new StudentClassModel();
$updatedBy = (int)(session()->get('user_id') ?? 0) ?: null;
$now = utc_now();
foreach ($buckets as $b) {
$secId = (int)$b['class_section_id'];
foreach ($b['assigned'] as $sid) {
// Update promotion queue
if (isset($promoIdsBySid[$sid])) {
$promo->update($promoIdsBySid[$sid], [
'to_class_section_id' => $secId,
'status' => 'assigned',
'updated_by' => $updatedBy,
'updated_at' => $now,
]);
}
// Upsert student_class
$exists = $studentClass->where('student_id', $sid)
->where('school_year', $year)
->where('semester', (string)$this->semester)
->first();
$payload = [
'student_id' => $sid,
'class_section_id' => $secId,
'school_year' => $year,
'semester' => (string)$this->semester,
'updated_by' => $updatedBy,
'updated_at' => $now,
];
if ($exists) {
$studentClass->update((int)$exists['id'], $payload);
} else {
$payload['created_at'] = $now;
$studentClass->insert($payload);
}
}
}
// Build a map for section id -> name (for friendly headers)
$nameById = [];
foreach ($letters as $secRow) {
$nameById[(int)$secRow['class_section_id']] = (string)($secRow['class_section_name'] ?? '');
}
$summary = [];
foreach ($buckets as $b) {
$secId = (int)$b['class_section_id'];
$summary[] = [
'class_section_id' => $secId,
'class_section_name' => $nameById[$secId] ?? (string)$secId,
'total' => count($b['assigned']),
'male' => $b['male'],
'female' => $b['female'],
];
}
return $isAjax
? $json(['ok' => true, 'message' => 'Auto distribution completed.', 'sections' => $summary])
: redirect()->back()->with('success', 'Auto distribution completed.');
} catch (\Throwable $e) {
$msg = 'Auto distribution failed: ' . $e->getMessage();
return $isAjax ? $json(['ok' => false, 'message' => $msg], 500) : redirect()->back()->with('error', $msg);
}
}
/**
* API: Return totals per base class (KG, 1..9, Youth) for promotion_queue in selected year
* Only counts students with enrollment status 'payment pending' or 'enrolled'.
*/
public function promotionTotalsApi()
{
try {
$year = trim((string)($this->request->getGet('school_year') ?? $this->schoolYear));
// Fetch base sections (no dash) and filter to KG, 1..9, youth
$bases = $this->classSectionModel
->where("class_section_name NOT LIKE '%-%'", null, false)
->orderBy('class_id', 'ASC')
->findAll();
$wanted = [];
foreach ($bases as $r) {
$nameRaw = (string)($r['class_section_name'] ?? '');
$name = strtolower($nameRaw);
// Only KG, 1..9, Youth per request
if ($name === 'kg' || $name === 'youth') {
$wanted[] = $r;
continue;
}
if (ctype_digit($name)) {
$num = (int)$name;
if ($num >= 1 && $num <= 9) {
$wanted[] = $r;
}
}
}
$out = [];
foreach ($wanted as $r) {
$classId = (int)$r['class_id'];
// candidates from promotion_queue for this base class in the target year
$cands = $this->db->table('promotion_queue pq')
->select('pq.student_id')
->join('enrollments e', 'e.student_id = pq.student_id AND e.school_year = ' . $this->db->escape($year), 'left')
->where('pq.to_class_id', $classId)
->where('pq.school_year_to', $year)
->whereIn('pq.status', ['queued','assigned','applied'])
->whereIn('e.enrollment_status', ['payment pending', 'enrolled'])
->groupBy('pq.student_id')
->get()->getResultArray();
$out[] = [
'class_id' => $classId,
'class_section_id' => (int)($r['class_section_id'] ?? 0),
'class_section_name'=> (string)($r['class_section_name'] ?? ''),
'total' => count($cands),
];
}
return $this->response->setJSON(['ok' => true, 'year' => $year, 'rows' => $out]);
} catch (\Throwable $e) {
return $this->response->setStatusCode(500)->setJSON(['ok' => false, 'message' => $e->getMessage()]);
}
}
/**
* POST /students/update/{id}
*/
public function editStudentData(?int $id = null)
{
$request = $this->request;
if ($id === null) {
$id = (int) $request->getPost('id');
}
if (!$id) {
return redirect()->back()->with('error', 'Invalid student ID.');
}
// Keep original post to detect presence of keys
$rawPost = $request->getPost();
// Validate all columns we edit (except id)
$rules = [
'school_id' => "permit_empty|alpha_numeric_punct|max_length[100]|is_unique[students.school_id,id,{$id}]",
'firstname' => 'required|regex_match[/^[A-Za-z\s\-]{2,30}$/]',
'lastname' => 'required|regex_match[/^[A-Za-z\s\-]{2,30}$/]',
'dob' => 'required|valid_date[Y-m-d]',
'age' => 'permit_empty|integer', // we compute it anyway
'gender' => 'required|in_list[Male,Female,Other]',
'registration_grade' => 'required|max_length[50]',
'photo_consent' => 'permit_empty|in_list[0,1]',
'parent_id' => 'required|integer|greater_than[0]',
'registration_date' => 'permit_empty', // accept Y-m-d or Y-m-d\TH:i; parse manually
'tuition_paid' => 'permit_empty|in_list[0,1]',
'year_of_registration' => 'permit_empty|regex_match[/^\d{4}(-\d{4})?$/]',
'school_year' => 'permit_empty|regex_match[/^\d{4}-\d{4}$/]',
'rfid_tag' => 'permit_empty|max_length[100]',
'semester' => 'permit_empty|in_list[Fall,Spring,Summer]',
'is_new' => 'required|in_list[0,1]',
'is_active' => 'permit_empty|in_list[0,1]',
// Free-text lists; parsed later
'medical_conditions' => 'permit_empty',
'allergies' => 'permit_empty',
// Touch flags from UI; used to decide whether to sync health lists
'medical_touched' => 'permit_empty|in_list[0,1]',
'allergies_touched' => 'permit_empty|in_list[0,1]',
];
if (!$this->validate($rules)) {
return redirect()->back()
->withInput()
->with('error', 'Please correct the highlighted errors.')
->with('errors', $this->validator->getErrors());
}
// Gather & sanitize inputs
$in = [
'school_id' => trim((string) $request->getPost('school_id')),
'firstname' => $this->titleCase((string) $request->getPost('firstname')),
'lastname' => $this->titleCase((string) $request->getPost('lastname')),
'dob' => trim((string) $request->getPost('dob')), // Y-m-d
'gender' => trim((string) $request->getPost('gender')),
'registration_grade' => trim((string) $request->getPost('registration_grade')),
'photo_consent' => (string) $request->getPost('photo_consent', FILTER_SANITIZE_NUMBER_INT),
'parent_id' => (string) $request->getPost('parent_id', FILTER_SANITIZE_NUMBER_INT),
'registration_date' => trim((string) $request->getPost('registration_date')), // '' or 'Y-m-d' or 'Y-m-d\TH:i'
'tuition_paid' => (string) $request->getPost('tuition_paid', FILTER_SANITIZE_NUMBER_INT),
'year_of_registration' => trim((string) $request->getPost('year_of_registration')),
'school_year' => trim((string) $request->getPost('school_year')),
'rfid_tag' => trim((string) $request->getPost('rfid_tag')),
'semester' => trim((string) $request->getPost('semester')),
'is_new' => (string) $request->getPost('is_new', FILTER_SANITIZE_NUMBER_INT),
'is_active' => (string) ($rawPost['is_active'] ?? ''),
// raw lists (may be missing from POST entirely)
'medical_conditions' => (string) ($rawPost['medical_conditions'] ?? ''),
'allergies' => (string) ($rawPost['allergies'] ?? ''),
// touch flags ("1" when user interacted)
'medical_touched' => (string) $request->getPost('medical_touched', FILTER_SANITIZE_NUMBER_INT),
'allergies_touched' => (string) $request->getPost('allergies_touched', FILTER_SANITIZE_NUMBER_INT),
];
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$tzLocal = new \DateTimeZone($tzName);
$tzUtc = new \DateTimeZone('UTC');
try {
// DOB -> Y-m-d and compute age
$dob = \DateTimeImmutable::createFromFormat('Y-m-d', $in['dob'], $tzLocal);
if ($dob === false) {
throw new \RuntimeException('Invalid date of birth.');
}
$dobStr = $dob->format('Y-m-d');
$today = new \DateTimeImmutable('today', $tzLocal);
$age = (int)$today->format('Y') - (int)$dob->format('Y');
if ((int)$today->format('md') < (int)$dob->format('md')) $age--;
if ($age < 0) throw new \RuntimeException('DOB results in negative age.');
// registration_date: accept date or datetime-local; store as UTC DATETIME
$regDateStr = null;
if ($in['registration_date'] !== '') {
$rd = null;
foreach (['Y-m-d\TH:i', 'Y-m-d'] as $fmt) {
$tmp = \DateTimeImmutable::createFromFormat($fmt, $in['registration_date'], $tzLocal);
if ($tmp !== false) {
$rd = $tmp;
break;
}
}
if ($rd === null) {
throw new \RuntimeException('Invalid registration date.');
}
// normalize to full minute precision in UTC
$regDateStr = $rd->setTimezone($tzUtc)->format('Y-m-d H:i:00');
}
// Build payload strictly to StudentModel::$allowedFields
$studentData = [
'school_id' => $in['school_id'] ?: null,
'firstname' => $in['firstname'],
'lastname' => $in['lastname'],
'dob' => $dobStr,
'age' => $age,
'gender' => $in['gender'],
'registration_grade' => $in['registration_grade'],
'photo_consent' => (int) ($in['photo_consent'] === '1'),
'parent_id' => (int) $in['parent_id'],
'registration_date' => $regDateStr, // nullable
'tuition_paid' => (int) ($in['tuition_paid'] === '1'),
'year_of_registration' => $in['year_of_registration'] ?: null,
'school_year' => $in['school_year'] ?: null,
'rfid_tag' => $in['rfid_tag'] ?: null,
'semester' => $in['semester'] ?: null,
'is_new' => (int) ($in['is_new'] === '1'),
];
if (array_key_exists('is_active', $rawPost)) {
$studentData['is_active'] = (int) ($in['is_active'] === '1');
}
// Normalize health lists (server-side safety)
$normConditions = $this->normalizeHealthList($in['medical_conditions'], 100); // -> condition_name
$normAllergies = $this->normalizeHealthList($in['allergies'], 100); // -> allergy
$this->db->transStart();
$row = $this->studentModel->find($id);
if (!$row) {
$this->db->transRollback();
return redirect()->back()->with('error', 'Student not found.');
}
if (!$this->studentModel->update($id, $studentData)) {
$this->db->transRollback();
return redirect()->back()
->withInput()
->with('error', 'Could not update student.')
->with('errors', $this->studentModel->errors() ?: []);
}
// Only sync health lists when user actually changed them (touched=1)
// Fallback: if no touch flag but a non-empty value was explicitly posted, also sync.
$medTouched = ($in['medical_touched'] === '1');
$allTouched = ($in['allergies_touched'] === '1');
if ($medTouched || (array_key_exists('medical_conditions', $rawPost) && trim($in['medical_conditions']) !== '')) {
$this->syncHealthList($id, $this->conditionModel, 'condition_name', $normConditions);
}
if ($allTouched || (array_key_exists('allergies', $rawPost) && trim($in['allergies']) !== '')) {
$this->syncHealthList($id, $this->allergyModel, 'allergy', $normAllergies);
}
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return redirect()->back()->withInput()->with('error', 'Transaction failed while updating student.');
}
return redirect()->to('/administrator/student_profiles')->with('success', 'Student updated successfully.');
} catch (\CodeIgniter\Database\Exceptions\DataException $e) {
log_message('error', '[Students:update] DataException: {msg}', ['msg' => $e->getMessage()]);
return redirect()->back()->withInput()->with('error', 'Database error while updating student.');
} catch (\Throwable $e) {
log_message('error', '[Students:update] Exception: {msg}', ['msg' => $e->getMessage()]);
return redirect()->back()->withInput()->with('error', $e->getMessage());
}
}
/**
* Normalize a comma/semicolon/newline list:
* - Trim & collapse spaces
* - Drop placeholders like "none", "n/a", "na", "null", "nil"
* - Deduplicate case-insensitively
* - Truncate to $maxLen
*/
private function normalizeHealthList(?string $s, int $maxLen = 100): array
{
$s = (string)$s;
if ($s === '') return []; // empty means "no change" unless key missing (handled by caller)
$parts = preg_split('/[,\n;]+/u', $s, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$outAssoc = [];
foreach ($parts as $p) {
$v = trim(preg_replace('/\s+/u', ' ', $p));
if ($v === '') continue;
if (preg_match('/^(none|n\/a|na|null|nil|no)$/i', $v)) continue;
$v = mb_substr($v, 0, $maxLen, 'UTF-8');
$outAssoc[mb_strtolower($v, 'UTF-8')] = $v; // dedupe case-insensitively
}
return array_values($outAssoc);
}
/**
* Diff-sync helper:
* - Reads existing rows for student
* - Deletes removed values
* - Inserts only new values
* Requires $model to have columns: id, student_id, and $field (e.g., allergy / condition_name)
*/
private function syncHealthList(int $studentId, \CodeIgniter\Model $model, string $field, array $newValues): void
{
// Fetch current values
$rows = $model->where('student_id', $studentId)->select("id, {$field}")->findAll();
$current = [];
$byId = [];
foreach ($rows as $r) {
$val = (string)($r[$field] ?? '');
$key = mb_strtolower(trim($val), 'UTF-8');
if ($key === '') continue;
$current[$key] = $val;
$byId[$key] = (int)$r['id'];
}
// Build new set
$incoming = [];
foreach ($newValues as $v) {
$key = mb_strtolower(trim($v), 'UTF-8');
if ($key === '') continue;
$incoming[$key] = $v;
}
// Compute diffs
$toDeleteKeys = array_diff(array_keys($current), array_keys($incoming));
$toInsertKeys = array_diff(array_keys($incoming), array_keys($current));
// Delete removed
if (!empty($toDeleteKeys)) {
$ids = array_map(fn($k) => $byId[$k], $toDeleteKeys);
if (!empty($ids)) {
$model->whereIn('id', $ids)->delete();
}
}
// Insert new
if (!empty($toInsertKeys)) {
$batch = [];
foreach ($toInsertKeys as $k) {
$batch[] = [
'student_id' => $studentId,
$field => $incoming[$k],
];
}
if (!empty($batch)) {
// Use insertBatch; ignore duplicates if unique index exists
$model->insertBatch($batch);
}
}
}
/**
* Split a free-text list by commas/semicolons/newlines, trim items,
* de-duplicate (case-insensitive), and drop empties. Truncates to $maxLen.
*/
private function parseList(string $input, int $maxLen = 100): array
{
if ($input === '') return [];
$parts = preg_split('/[,\n;]+/u', $input);
if (!$parts) return [];
$seen = [];
$out = [];
foreach ($parts as $raw) {
$item = trim(strip_tags($raw));
if ($item === '') continue;
// Case-insensitive de-dup
$key = mb_strtolower($item, 'UTF-8');
if (isset($seen[$key])) continue;
$seen[$key] = true;
// Enforce maxLen to align with model validation
$out[] = mb_substr($item, 0, $maxLen, 'UTF-8');
}
return $out;
}
private function titleCase(string $s): string
{
$s = strip_tags($s);
$s = mb_strtolower($s ?: '', 'UTF-8');
return mb_convert_case($s, MB_CASE_TITLE_SIMPLE, 'UTF-8');
}
public function scoreCard()
{
$studentId = (int)($this->request->getPost('student_id') ?? 0);
if ($studentId <= 0) {
return redirect()->back()->with('error', 'Invalid student id.');
}
$student = $this->studentModel
->select('id, firstname, lastname, school_id')
->where('id', $studentId)
->first();
if (!$student) {
return $this->response->setStatusCode(404)->setBody('<div class="p-3 text-danger">Student not found.</div>');
}
$rows = $this->db->table('semester_scores ss')
->select([
'ss.school_year',
'ss.semester',
'ss.homework_avg',
'ss.project_avg',
'ss.participation_score',
'ss.quiz_avg',
'ss.test_avg',
'ss.attendance_score',
'ss.ptap_score',
'ss.midterm_exam_score',
'ss.semester_score',
'cs.class_section_name',
])
->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
->where('ss.student_id', $studentId)
->get()
->getResultArray();
$semesterOrder = static function (string $semester): int {
$s = strtolower(trim($semester));
if ($s === 'fall') return 1;
if ($s === 'spring') return 2;
return 3;
};
usort($rows, static function (array $a, array $b) use ($semesterOrder) {
$ay = (string)($a['school_year'] ?? '');
$by = (string)($b['school_year'] ?? '');
if ($ay !== $by) return strcmp($by, $ay);
$as = $semesterOrder((string)($a['semester'] ?? ''));
$bs = $semesterOrder((string)($b['semester'] ?? ''));
if ($as !== $bs) return $as <=> $bs;
return 0;
});
$yearScoreMap = [];
foreach ($rows as $r) {
$year = (string)($r['school_year'] ?? '');
if ($year === '') continue;
$semKey = strtolower(trim((string)($r['semester'] ?? '')));
$scoreVal = is_numeric($r['semester_score'] ?? null) ? (float)$r['semester_score'] : null;
if ($scoreVal === null) continue;
if (!isset($yearScoreMap[$year])) $yearScoreMap[$year] = [];
$yearScoreMap[$year][$semKey] = $scoreVal;
}
foreach ($yearScoreMap as $year => $vals) {
$scores = [];
if (isset($vals['fall'])) $scores[] = $vals['fall'];
if (isset($vals['spring'])) $scores[] = $vals['spring'];
if (empty($scores)) {
$yearScoreMap[$year]['avg'] = null;
} else {
$yearScoreMap[$year]['avg'] = round(array_sum($scores) / count($scores), 1);
}
}
$commentRows = $this->db->table('score_comments')
->select('school_year, semester, score_type, comment, comment_review, created_at')
->where('student_id', $studentId)
->orderBy('created_at', 'DESC')
->get()
->getResultArray();
$commentMap = [];
foreach ($commentRows as $row) {
$year = (string)($row['school_year'] ?? '');
$semester = strtolower(trim((string)($row['semester'] ?? '')));
$typeRaw = strtolower(trim((string)($row['score_type'] ?? '')));
if ($year === '' || $typeRaw === '') continue;
if ($typeRaw === 'attendance_comment') $typeRaw = 'attendance';
$commentVal = trim((string)($row['comment'] ?? ''));
$reviewVal = trim((string)($row['comment_review'] ?? ''));
if (in_array($typeRaw, ['midterm', 'final', 'ptap'], true)) {
$commentVal = $reviewVal !== '' ? $reviewVal : $commentVal;
} elseif ($typeRaw === 'attendance' && $commentVal === '' && $reviewVal !== '') {
$commentVal = $reviewVal;
}
if ($commentVal === '') continue;
$key = $year . '|' . $semester;
if (!isset($commentMap[$key])) $commentMap[$key] = [];
if (!isset($commentMap[$key][$typeRaw])) {
$commentMap[$key][$typeRaw] = $commentVal;
}
}
foreach ($rows as &$r) {
$year = (string)($r['school_year'] ?? '');
$semKey = strtolower(trim((string)($r['semester'] ?? '')));
$key = $year . '|' . $semKey;
$r['comments'] = $commentMap[$key] ?? [];
$r['year_score'] = $yearScoreMap[$year]['avg'] ?? null;
}
unset($r);
$rowsByYear = [];
foreach ($rows as $r) {
$year = (string)($r['school_year'] ?? '');
if ($year === '') continue;
$semKey = strtolower(trim((string)($r['semester'] ?? '')));
if ($semKey === '') continue;
if (!isset($rowsByYear[$year])) $rowsByYear[$year] = [];
$rowsByYear[$year][$semKey] = $r;
}
$expandedRows = [];
foreach ($rowsByYear as $year => $bySem) {
$fallbackGrade = $bySem['fall']['class_section_name'] ?? ($bySem['spring']['class_section_name'] ?? null);
foreach (['fall', 'spring'] as $semKey) {
if (isset($bySem[$semKey])) {
$expandedRows[] = $bySem[$semKey];
continue;
}
$expandedRows[] = [
'school_year' => $year,
'semester' => ucfirst($semKey),
'homework_avg' => null,
'project_avg' => null,
'participation_score' => null,
'quiz_avg' => null,
'test_avg' => null,
'attendance_score' => null,
'ptap_score' => null,
'midterm_exam_score' => null,
'semester_score' => null,
'class_section_name' => $fallbackGrade,
'comments' => [],
'year_score' => $yearScoreMap[$year]['avg'] ?? null,
];
}
}
if (!empty($expandedRows)) {
$rows = $expandedRows;
}
return view('student/score_card', [
'student' => $student,
'rows' => $rows,
]);
}
public function scoreCardIndex()
{
return redirect()->to('/student/score-card/list');
}
public function scoreCardAdmin()
{
$query = trim((string)($this->request->getGet('q') ?? ''));
$students = [];
$suggestions = [];
if ($query !== '') {
$schoolIdPart = '';
$namePart = $query;
if (preg_match('/^\s*([^-\s]+)\s*-\s*(.+)\s*$/', $query, $m)) {
$schoolIdPart = trim((string)$m[1]);
$namePart = trim((string)$m[2]);
}
$students = $this->studentModel
->select('id, school_id, firstname, lastname, is_active')
->groupStart()
->like('firstname', $namePart)
->orLike('lastname', $namePart)
->orLike('school_id', $query)
->groupEnd()
->orderBy('lastname', 'ASC')
->orderBy('firstname', 'ASC')
->limit(50)
->findAll();
if ($schoolIdPart !== '' && empty($students)) {
$students = $this->studentModel
->select('id, school_id, firstname, lastname, is_active')
->groupStart()
->like('school_id', $schoolIdPart)
->orLike("CONCAT_WS(' ', firstname, lastname)", $namePart, 'both', null, false)
->groupEnd()
->orderBy('lastname', 'ASC')
->orderBy('firstname', 'ASC')
->limit(50)
->findAll();
}
}
try {
$suggestions = $this->studentModel
->select('id, school_id, firstname, lastname')
->where('is_active', 1)
->orderBy('lastname', 'ASC')
->orderBy('firstname', 'ASC')
->limit(1000)
->findAll();
} catch (\Throwable $e) {
$suggestions = [];
}
return view('admin/student_score_card', [
'query' => $query,
'students' => $students,
'suggestions' => $suggestions,
]);
}
public function scoreCardList()
{
$roleRaw = session()->get('role');
if (is_array($roleRaw)) {
$roleRaw = $roleRaw[0] ?? 'guest';
}
$role = strtolower((string)($roleRaw ?? 'guest'));
$students = [];
if (in_array($role, ['parent', 'parent_dashboard'], true)) {
$parentId = (int)(session()->get('user_id') ?? 0);
if ($parentId > 0) {
$students = $this->studentModel
->select('id, school_id, firstname, lastname')
->where('parent_id', $parentId)
->orderBy('lastname', 'ASC')
->orderBy('firstname', 'ASC')
->findAll();
}
} elseif (in_array($role, ['teacher', 'teacher_assistant', 'teacher_dashboard'], true)) {
$configModel = new \App\Models\ConfigurationModel();
$schoolYear = session()->get('school_year') ?? $configModel->getConfig('school_year');
$classSectionId = (int)(session()->get('class_section_id') ?? 0);
if ($classSectionId > 0 && !empty($schoolYear)) {
$students = $this->studentModel->getByClassAndYear($classSectionId, $schoolYear);
}
if (empty($students)) {
$userId = (int)(session()->get('user_id') ?? 0);
if ($userId > 0 && !empty($schoolYear)) {
$db = \Config\Database::connect();
$sectionRows = $db->table('teacher_class')
->select('class_section_id')
->where('teacher_id', $userId)
->where('school_year', (string)$schoolYear)
->get()
->getResultArray();
$sectionIds = array_values(array_filter(array_unique(array_map(
static fn($r) => (int)($r['class_section_id'] ?? 0),
$sectionRows
)), static fn($v) => $v > 0));
if (!empty($sectionIds)) {
$studentClassModel = new \App\Models\StudentClassModel();
$rows = $studentClassModel->getStudentsByClassSectionIds($sectionIds);
$unique = [];
foreach ($rows as $r) {
$sid = (int)($r['student_id'] ?? 0);
if ($sid > 0) {
$unique[$sid] = [
'id' => $sid,
'school_id' => $r['school_id'] ?? '',
'firstname' => $r['firstname'] ?? '',
'lastname' => $r['lastname'] ?? '',
];
}
}
$students = array_values($unique);
}
}
}
} else {
return redirect()->to('/login')->with('error', 'Access denied.');
}
return view('student/score_card_list', [
'students' => $students,
'role' => $role,
]);
}
}