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('
Student not found.
'); } $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, ]); } }