rolePermissionModel = new RolePermissionModel();
$this->permissionModel = new PermissionModel();
$this->roleModel = new RoleModel();
$this->userModel = new UserModel();
$this->configModel = new ConfigurationModel();
$this->studentModel = new StudentModel();
$this->loginActivityModel = new LoginActivityModel();
$this->userRoleModel = new UserRoleModel();
$this->invoiceModel = new InvoiceModel();
$this->refundModel = new RefundModel();
$this->enrollmentModel = new EnrollmentModel();
$this->classSectionModel = new ClassSectionModel();
$this->adminNotificationSubjectModel = new AdminNotificationSubjectModel();
$this->semester = $this->configModel->getConfig('semester');
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->studentClassModel = new StudentClassModel();
$this->staffAttendanceModel = new StaffAttendanceModel();
// Load the database service
$this->db = \Config\Database::connect();
// Check if the database connection is established
if (!$this->db->connect()) {
log_message('error', 'Database connection failed.');
throw new \Exception('Database connection failed.');
} else {
log_message('info', 'Database connection successful.');
}
}
/**
* Compute all allowed absence dates (future Sundays within Sep..May for the configured school year)
*/
private function allowedAbsenceDates(): array
{
$todayStr = local_date(utc_now(), 'Y-m-d');
try { $today = new \DateTimeImmutable($todayStr); } catch (\Throwable $e) { $today = new \DateTimeImmutable(); }
$sy = (string)($this->schoolYear ?? '');
$startYear = null; $endYear = null;
if (preg_match('/^(\d{4})\D+(\d{4})$/', $sy, $m)) {
$startYear = (int)$m[1];
$endYear = (int)$m[2];
} else {
$cy = (int)date('Y');
$cm = (int)date('n');
if ($cm >= 9) { // Sep-Dec
$startYear = $cy;
$endYear = $cy + 1;
} else {
$startYear = $cy - 1;
$endYear = $cy;
}
}
try {
$start = new \DateTimeImmutable(sprintf('%04d-09-01', $startYear));
$end = new \DateTimeImmutable(sprintf('%04d-05-31', $endYear));
} catch (\Throwable $e) {
return [];
}
if ($start < $today) {
$start = $today;
}
$dates = [];
for ($cursor = $start; $cursor <= $end; $cursor = $cursor->modify('+1 day')) {
if ((int)$cursor->format('w') === 0) { // Sunday
$dates[] = $cursor->format('Y-m-d');
}
}
return $dates;
}
private function getPreviousSchoolYear(string $schoolYear): string
{
$schoolYear = trim($schoolYear);
if ($schoolYear === '') {
return '';
}
if (preg_match('/^(\d{4})\s*-\s*(\d{4})$/', $schoolYear, $m)) {
return ((int)$m[1] - 1) . '-' . ((int)$m[2] - 1);
}
if (preg_match('/^(\d{4})\s*-\s*(\d{2})$/', $schoolYear, $m)) {
$start = (int)$m[1] - 1;
$end = (int)$m[2] - 1;
if ($end < 0) {
$end += 100;
}
return sprintf('%04d-%02d', $start, $end);
}
if (preg_match('/^\d{4}$/', $schoolYear)) {
return (string)((int)$schoolYear - 1);
}
return '';
}
private function getSchoolYearStartYear(string $schoolYear): ?int
{
$schoolYear = trim($schoolYear);
if ($schoolYear === '') {
return null;
}
if (preg_match('/^(\d{4})\s*-\s*(\d{4})$/', $schoolYear, $m)) {
return (int)$m[1];
}
if (preg_match('/^(\d{4})\s*-\s*(\d{2})$/', $schoolYear, $m)) {
return (int)$m[1];
}
if (preg_match('/^\d{4}$/', $schoolYear)) {
return (int)$schoolYear;
}
return null;
}
/**
* Admin self-service absence/vacation page (same features as teacher page)
*/
public function absenceFormAdmin()
{
$userId = (int)(session()->get('user_id') ?? 0);
if ($userId <= 0) {
return redirect()->to('login')->with('error', 'Please log in first.');
}
$admin = $this->userModel->find($userId);
$displayName = $admin ? trim(($admin['firstname'] ?? '') . ' ' . ($admin['lastname'] ?? '')) : 'Administrator';
$semester = (string)($this->semester ?? '');
$schoolYear = (string)($this->schoolYear ?? '');
$existing = $this->staffAttendanceModel
->where('user_id', $userId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->orderBy('date', 'DESC')
->findAll();
return view('administrator/absence_vacation', [
'admin_name' => $displayName,
'semester' => $semester,
'schoolYear' => $schoolYear,
'existing' => $existing,
'availableDates' => $this->allowedAbsenceDates(),
]);
}
/**
* Handle admin absence submission (upserts staff_attendance like the teacher flow)
*/
public function submitAbsenceAdmin()
{
$userId = (int)(session()->get('user_id') ?? 0);
if ($userId <= 0) {
return redirect()->to('login')->with('error', 'Please log in first.');
}
$semester = (string)($this->semester ?? '');
$schoolYear = (string)($this->schoolYear ?? '');
if ($schoolYear === '') {
return redirect()->to('/administrator/absence')->with('status', 'error')->with('message', 'Semester or school year not configured.');
}
$semesterResolver = new SemesterRangeService($this->configModel);
$dates = (array)($this->request->getPost('dates') ?? []);
$reasonType = trim((string)($this->request->getPost('reason_type') ?? ''));
$reasonText = trim((string)($this->request->getPost('reason') ?? ''));
if ($reasonText === '') {
return redirect()->to('/administrator/absence')
->with('status', 'error')
->with('message', 'Reason is required.');
}
$reasonBase = ($reasonType !== '') ? strtolower($reasonType) : '';
$reason = $reasonBase !== '' ? ($reasonBase . ': ' . $reasonText) : $reasonText;
$allowedDates = $this->allowedAbsenceDates();
$allowedSet = array_fill_keys($allowedDates, true);
$roleName = $this->userModel->getUserRole($userId) ?: null;
$saved = 0; $invalid = [];
$savedDates = [];
$dates = array_values(array_unique(array_map('strval', $dates)));
foreach ($dates as $d) {
$d = trim((string)$d);
if ($d === '') continue;
$dt = date_create_from_format('Y-m-d', $d);
if (!$dt || $dt->format('Y-m-d') !== $d || empty($allowedSet[$d])) {
$invalid[] = $d;
continue;
}
$semesterForDate = $semesterResolver->getSemesterForDate($d);
if ($semesterForDate === '') {
$semesterForDate = $semester;
}
$ok = $this->staffAttendanceModel->upsertOne(
userId: $userId,
roleName: $roleName,
date: $d,
semester: $semesterForDate,
schoolYear: $schoolYear,
status: StaffAttendanceModel::STATUS_ABSENT,
reason: $reason,
editorId: $userId
);
if ($ok) { $saved++; $savedDates[] = $d; }
}
if (!empty($invalid)) {
return redirect()->to('/administrator/absence')
->with('status', 'error')
->with('message', 'Invalid dates: ' . implode(', ', $invalid));
}
// Send notification email to principal with request details
try {
$user = $this->userModel->find($userId) ?: [];
$fullName = trim(($user['firstname'] ?? '') . ' ' . ($user['lastname'] ?? '')) ?: 'Administrator';
$userEmail = $user['email'] ?? '';
$role = $roleName ?: 'admin';
$dateList = !empty($savedDates) ? implode(', ', $savedDates) : implode(', ', $dates);
$subject = sprintf('TimeOff Request: %s (%s) — %s', $fullName, ucfirst((string)$role), $dateList ?: 'No dates');
$submittedAt = utc_now();
$body = '
'
. '
New TimeOff Request '
. '
A staff time-off request was submitted from the administrator portal.
'
. '
'
. 'Name ' . esc($fullName) . ' '
. 'Email ' . esc($userEmail) . ' '
. 'Role ' . esc((string)$role) . ' '
. 'Semester ' . esc($semester) . ' '
. 'School Year ' . esc($schoolYear) . ' '
. 'Reason Type ' . esc($reasonType ?: '-') . ' '
. 'Reason ' . esc($reasonText) . ' '
. 'Dates ' . esc($dateList ?: '-') . ' '
. 'Submitted At ' . esc($submittedAt) . ' '
. '
'
. '
';
$linkService = new StaffTimeOffLinkService();
$token = $linkService->createToken([
'uid' => $userId,
'email' => $userEmail,
'name' => $fullName,
'role' => $role,
'dates' => $dateList ?: '-',
'reason' => $reasonText,
'reason_type' => $reasonType,
'submitted_at' => $submittedAt,
'origin' => 'administrator portal',
]);
$notifyUrl = base_url('timeoff/notify/' . rawurlencode($token));
$body .= 'Click Send confirmation email to '
. esc($fullName) . ' so the staff member is notified automatically. '
. 'This link expires in 14 days.
';
$mailer = \Config\Services::emailService();
$principalEmail = env('PRINCIPAL_EMAIL') ?: 'principal@alrahmaisgl.org';
$mailer->send($principalEmail, $subject, $body, 'notifications');
} catch (\Throwable $e) {
log_message('error', 'Failed to send TimeOff email (admin): ' . $e->getMessage());
}
return redirect()->to('/administrator/absence')
->with('status', 'success')
->with('message', $saved . ' day(s) saved as absent.');
}
public function index()
{
$data = [
'users' => $this->userModel->findAll(),
'roles' => $this->roleModel->findAll(),
];
return view('administrator/dashboard', $data);
}
public function isadministrator()
{
$session = session();
$allowedRoles = [
'administrator',
'admin',
'principal',
//'vice_principal',
//'vice principal',
];
$role = strtolower((string) $session->get('role'));
if ($role !== '' && in_array($role, $allowedRoles, true)) {
return true;
}
$roles = $session->get('roles');
if (is_array($roles)) {
foreach ($roles as $candidate) {
$candidate = strtolower((string) $candidate);
if ($candidate !== '' && in_array($candidate, $allowedRoles, true)) {
return true;
}
}
}
return false;
}
public function administratorDashboard()
{
helper('url');
$searchData = $this->buildUserSearchData((string) $this->request->getGet('query'));
return view('administrator/administratordashboard', array_merge($searchData, [
'dashboardEndpoint' => site_url('api/administrator/dashboard'),
]));
}
public function dashboardMetrics()
{
return $this->response->setJSON($this->buildDashboardMetrics());
}
private function buildDashboardMetrics(): array
{
$recentActivities = $this->loginActivityModel->getLastActivities(4);
if (!is_array($recentActivities)) {
$recentActivities = [];
}
$totalAdmins = (int) ($this->userModel->countAdminsBySchoolYear($this->schoolYear) ?? 0);
$teachers = $this->userModel->getUsersByRoleAndSchoolYear('teacher', $this->schoolYear);
$totalTeachers = $this->countUniqueEntities($teachers);
$teacherAssistants = $this->userModel->getUsersByRoleAndSchoolYear('teacher_assistant', $this->schoolYear);
$totalTeacherAssistants = $this->countUniqueEntities($teacherAssistants);
$parents = $this->userModel->getUsersByRoleAndSchoolYear('parent', $this->schoolYear);
$totalParents = $this->countUniqueEntities($parents);
// Count only students that have a class assigned and exist in student_class for the current school year
$totalStudents = (int) (
$this->db->table('student_class')
->select('COUNT(DISTINCT student_class.student_id) AS cnt')
->join('students', 'students.id = student_class.student_id', 'inner')
->where('student_class.school_year', $this->schoolYear)
->where('student_class.class_section_id IS NOT NULL', null, false)
->where('students.is_active', 1)
->get()
->getRow('cnt')
?? 0
);
return [
'counts' => [
'students' => $totalStudents,
'teachers' => $totalTeachers,
'teacherAssistants' => $totalTeacherAssistants,
'admins' => $totalAdmins,
'parents' => $totalParents,
],
'recentActivities' => array_map(static function ($activity) {
if (!is_array($activity)) {
return [];
}
return [
'login_time' => $activity['login_time'] ?? null,
'email' => $activity['email'] ?? null,
];
}, $recentActivities),
'meta' => [
'schoolYear' => $this->schoolYear,
'semester' => $this->semester,
],
];
}
private function countUniqueEntities($rows): int
{
if (!is_array($rows) || $rows === []) {
return 0;
}
$ids = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
if (isset($row['id'])) {
$ids[] = (int) $row['id'];
continue;
}
if (isset($row['user_id'])) {
$ids[] = (int) $row['user_id'];
}
}
return count(array_unique($ids));
}
public function userSearch()
{
$data = $this->buildUserSearchData((string) $this->request->getGet('query'));
return view('administrator/search_results', $data);
}
private function buildUserSearchData(string $query): array
{
$q = trim($query);
if ($q === '') {
return [
'query' => '',
'results' => [],
'scope_used' => 'unscoped-raw',
'scope_label' => 'all years/semesters (raw)',
'total_found' => 0,
];
}
$db = $this->db;
// 1) Tokenize input: split by whitespace and punctuation, keep meaningful pieces
$rawTokens = preg_split('/[,\s]+/u', $q, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$tokens = array_values(array_filter(array_map('trim', $rawTokens)));
// 2) Build phone variants for any token that looks numeric-ish
$phoneMap = []; // token => variants[]
foreach ($tokens as $t) {
$digits = preg_replace('/\D+/', '', $t);
if ($digits === '') {
continue;
}
$v = [];
if (strlen($digits) >= 7) {
// base forms
$v[] = $digits;
if (strlen($digits) === 10) {
$v[] = sprintf('(%s)-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
$v[] = sprintf('%s-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
$v[] = sprintf('%s %s %s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
// country-code forms
$v[] = '1' . $digits;
$v[] = '+1' . $digits;
$v[] = '+1 ' . sprintf('(%s) %s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
$v[] = '+1-' . sprintf('%s-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
} elseif (strlen($digits) === 11 && str_starts_with($digits, '1')) {
$ten = substr($digits, 1);
$v[] = $ten;
$v[] = sprintf('(%s)-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
$v[] = sprintf('%s-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
$v[] = sprintf('%s %s %s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
$v[] = '+1' . $ten;
$v[] = '+1 ' . sprintf('(%s) %s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
$v[] = '+1-' . sprintf('%s-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
} else {
// 7-9 digits: keep as-is (partial phone fragment)
$v[] = $digits;
}
}
if (!empty($v)) {
$phoneMap[$t] = array_values(array_unique($v));
}
}
// Helper: ( token1 AND token2 AND ... ), each token may match ANY of $columns,
// and phone variants are only applied to the provided $phoneCols for THIS table.
$applyMultiTokenLike = function ($qb, array $columns, array $tokens, array $phoneCols = []) use ($phoneMap) {
foreach ($tokens as $t) {
$qb->groupStart(); // OR across columns for this token
foreach ($columns as $i => $col) {
if ($i === 0) {
$qb->like($col, $t);
} else {
$qb->orLike($col, $t);
}
}
if (!empty($phoneMap[$t]) && !empty($phoneCols)) {
foreach ($phoneMap[$t] as $pv) {
foreach ($phoneCols as $pcol) {
$qb->orLike($pcol, $pv);
}
}
}
$qb->groupEnd();
}
return $qb;
};
// ===== RAW UNscoped searches (flat arrays) =====
// USERS (phone col: cellphone)
$uCols = ['firstname', 'lastname', 'email', 'cellphone', 'school_id', 'city', 'state'];
$uQB = $db->table('users')
->select('id, firstname, lastname, email, cellphone, school_id, city, state, school_year, semester');
$applyMultiTokenLike($uQB, $uCols, $tokens, ['cellphone']);
$users = $uQB->limit(150)->get()->getResultArray();
// STUDENTS (no phone column to search)
$sCols = ['firstname', 'lastname', 'school_id', 'rfid_tag', 'dob', 'gender'];
$sQB = $db->table('students')
->select('id, parent_id, school_id, firstname, lastname, dob, gender, school_year, semester, rfid_tag');
$applyMultiTokenLike($sQB, $sCols, $tokens, []);
$students = $sQB->limit(150)->get()->getResultArray();
// PARENTS (phone col: secondparent_phone)
$pCols = ['secondparent_firstname', 'secondparent_lastname', 'secondparent_email', 'secondparent_phone'];
$pQB = $db->table('parents')
->select('id, firstparent_id, secondparent_firstname, secondparent_lastname, secondparent_email, secondparent_phone, school_year, semester');
$applyMultiTokenLike($pQB, $pCols, $tokens, ['secondparent_phone']);
foreach ($tokens as $t) {
if (ctype_digit($t)) {
$pQB->orWhere('firstparent_id', (int) $t)->orWhere('id', (int) $t);
}
}
$parents = $pQB->limit(150)->get()->getResultArray();
// STAFF (phone col: phone)
$stCols = ['firstname', 'lastname', 'email', 'role_name', 'phone'];
$stQB = $db->table('staff')
->select('id, user_id, firstname, lastname, email, phone, role_name, school_year, active_role');
$applyMultiTokenLike($stQB, $stCols, $tokens, ['phone']);
$staff = $stQB->limit(150)->get()->getResultArray();
// EMERGENCY CONTACTS (phone col: cellphone)
$ecCols = ['emergency_contact_name', 'relation', 'email', 'cellphone'];
$ecQB = $db->table('emergency_contacts')
->select('id, parent_id, emergency_contact_name, relation, cellphone, email, school_year, semester');
$applyMultiTokenLike($ecQB, $ecCols, $tokens, ['cellphone']);
$emergency = $ecQB->limit(150)->get()->getResultArray();
$raw = [
'users' => $users,
'students' => $students,
'parents' => $parents,
'staff' => $staff,
'emergency_contacts' => $emergency,
];
$total = count($users) + count($students) + count($parents) + count($staff) + count($emergency);
return [
'query' => $q,
'results' => $raw,
'scope_used' => 'unscoped-raw',
'scope_label' => 'all years/semesters (raw, tokenized)',
'total_found' => $total,
];
}
public function teachers()
{
return view('administrator/teachers'); // This is the correct view path
}
public function classes()
{
return view('administrator/classes');
}
public function subjects()
{
return view('administrator/subjects');
}
public function reports()
{
return view('administrator/reports');
}
public function teacherDashboard()
{
return view('teacher/dashboard');
}
public function parentDashboard()
{
return view('parent/dashboard');
}
public function CourseMaterials()
{
return view('administrator/course_materials');
}
public function administratorProfiles()
{
return view('administrator/administrator_profiles');
}
public function payrollManagement()
{
return view('administrator/payroll_management');
}
public function administratorAttendanceRecords()
{
return view('administrator/administrator_attendance_records');
}
public function performanceReviews()
{
return view('administrator/performance_reviews');
}
public function communicationLogs()
{
return view('administrator/communication_logs');
}
public function feePaymentRecords()
{
return view('administrator/fee_payment_records');
}
public function feedbackComplaints()
{
return view('administrator/feedback_complaints');
}
public function teacherSubmissionsReport()
{
$semester = (string)($this->configModel->getConfig('semester') ?? $this->semester ?? '');
$schoolYear = (string)($this->configModel->getConfig('school_year') ?? $this->schoolYear ?? '');
$semesterResolver = new SemesterRangeService($this->configModel);
$semesterNorm = $semesterResolver->normalizeSemester($semester);
$semesterFilter = $semesterNorm !== '' ? $semesterNorm : $semester;
$semesterCandidates = $this->buildSemesterCandidates($semesterFilter);
$lowProgressRaw = (string) $this->request->getGet('low_progress_sections');
$lowProgressSectionIds = array_values(array_unique(array_filter(array_map(
'intval',
preg_split('/\s*,\s*/', $lowProgressRaw, -1, PREG_SPLIT_NO_EMPTY)
))));
$scoreComments = new ScoreCommentModel();
$semesterScores = new SemesterScoreModel();
$attendanceDays = new AttendanceDayModel();
$examDrafts = new ExamDraftModel();
$homeworkModel = new HomeworkModel();
$historyModel = new TeacherSubmissionNotificationHistoryModel();
$assignmentQuery = $this->db->table('teacher_class tc')
->select([
'tc.class_section_id',
'cs.class_section_name',
'tc.teacher_id',
'u.firstname',
'u.lastname',
'tc.position',
])
->join('classSection cs', 'cs.class_section_id = tc.class_section_id', 'left')
->join('users u', 'u.id = tc.teacher_id', 'left')
->orderBy('cs.class_section_name', 'ASC');
$filteredQuery = clone $assignmentQuery;
if ($schoolYear !== '') {
$filteredQuery = $filteredQuery->where('tc.school_year', $schoolYear);
}
if (!empty($semesterCandidates)) {
$filteredQuery = $filteredQuery->whereIn('tc.semester', $semesterCandidates);
}
$assignmentRows = $filteredQuery->get()->getResultArray();
if (empty($assignmentRows) && ($schoolYear !== '' || $semester !== '')) {
$assignmentRows = $assignmentQuery->get()->getResultArray();
}
$studentCounts = $this->studentClassModel->getStudentCountsBySection($schoolYear !== '' ? $schoolYear : null);
$sectionRows = $this->classSectionModel
->select('class_section_id, class_section_name')
->orderBy('class_section_name', 'ASC')
->findAll();
$sectionMap = [];
foreach ($sectionRows as $sectionRow) {
$sectionId = (int) ($sectionRow['class_section_id'] ?? 0);
if ($sectionId <= 0) {
continue;
}
if (empty($studentCounts[$sectionId])) {
continue;
}
$sectionMap[$sectionId] = $sectionRow['class_section_name'] ?? "Section {$sectionId}";
}
$sectionIds = array_keys($sectionMap);
[$progressExpectedWeeks, $progressSubmittedBySection] = $this->buildClassProgressStats($sectionIds);
$examDraftCounts = [];
$examDraftDeadline = $this->resolveTeacherDashboardExamDraftDeadline($semester, $schoolYear);
$examDraftDeadlineConfig = trim((string) ($this->configModel->getConfig('exam_draft_deadline') ?? ''));
$examDraftDeadlineFormatted = '';
if ($examDraftDeadlineConfig !== '') {
$parsedUi = $this->parseExamDraftDeadlineConfigValue();
$examDraftDeadlineFormatted = $parsedUi !== null ? $parsedUi->format('M j, Y') : '';
}
$homeworkCounts = [];
if (! empty($sectionIds)) {
$draftBuilder = $examDrafts
->select('class_section_id')
->whereIn('class_section_id', $sectionIds);
if ($schoolYear !== '') {
$draftBuilder->where('school_year', $schoolYear);
}
if (!empty($semesterCandidates)) {
$draftBuilder->whereIn('semester', $semesterCandidates);
}
if ($this->db->fieldExists('is_legacy', 'exam_drafts')) {
$draftBuilder->where('is_legacy', 0);
}
$draftRows = $draftBuilder->findAll();
foreach ($draftRows as $draft) {
$sectionId = (int) ($draft['class_section_id'] ?? 0);
if ($sectionId <= 0) {
continue;
}
$examDraftCounts[$sectionId] = ($examDraftCounts[$sectionId] ?? 0) + 1;
}
$homeworkBuilder = $homeworkModel
->select('class_section_id, homework_index')
->whereIn('class_section_id', $sectionIds);
if ($schoolYear !== '') {
$homeworkBuilder->where('school_year', $schoolYear);
}
if (!empty($semesterCandidates)) {
$homeworkBuilder->whereIn('semester', $semesterCandidates);
}
$homeworkRows = $homeworkBuilder
->where('score IS NOT NULL', null, false)
->where('score !=', '')
->groupBy('class_section_id, homework_index')
->findAll();
foreach ($homeworkRows as $row) {
$sectionId = (int) ($row['class_section_id'] ?? 0);
if ($sectionId <= 0) {
continue;
}
$homeworkCounts[$sectionId] = ($homeworkCounts[$sectionId] ?? 0) + 1;
}
}
if (empty($lowProgressSectionIds)) {
$lowProgressSectionIds = $this->resolveLowProgressSectionIds($sectionIds);
}
$teachersBySection = [];
foreach ($assignmentRows as $assignment) {
$sectionId = (int)($assignment['class_section_id'] ?? 0);
if ($sectionId <= 0) {
continue;
}
$positionKey = strtolower(trim((string)($assignment['position'] ?? '')));
$roleKey = $positionKey !== '' ? $positionKey : 'teacher';
$positionLabel = match ($roleKey) {
'ta' => 'TA',
'main' => 'Main',
default => $roleKey !== '' ? ucfirst($roleKey) : 'Teacher',
};
$teacherFullName = trim(($assignment['firstname'] ?? '') . ' ' . ($assignment['lastname'] ?? ''));
$teacherId = (int)($assignment['teacher_id'] ?? 0);
if ($teacherFullName === '' || $teacherId <= 0) {
continue;
}
$entry = &$teachersBySection[$sectionId];
if (!isset($entry)) {
$entry = [
'class_section' => $assignment['class_section_name'] ?? ($sectionMap[$sectionId] ?? "Section {$sectionId}"),
'teachers' => [],
];
}
$entry['teachers'][] = [
'id' => $teacherId,
'label' => "{$positionLabel}: {$teacherFullName}",
'role_key' => $roleKey,
];
unset($entry);
}
$today = (new \DateTimeImmutable('now', new \DateTimeZone(date_default_timezone_get() ?: 'UTC')))->format('Y-m-d');
$rows = [];
$totalStatuses = 0;
$missingItemCount = 0;
$allTeacherIds = [];
$allClassSectionIds = [];
$examTerm = $this->resolveExamTermLabel($semester);
$examScoreField = $examTerm === 'final' ? 'final_exam_score' : 'midterm_exam_score';
foreach ($sectionMap as $classSectionId => $sectionName) {
$classSectionId = (int)$classSectionId;
if ($classSectionId <= 0) {
continue;
}
$studentQuery = $this->studentClassModel
->select('student_id')
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear);
if (!empty($semesterCandidates)) {
$studentQuery->whereIn('semester', $semesterCandidates);
}
$studentEntries = $studentQuery->findAll();
if (empty($studentEntries)) {
$studentEntries = $this->studentClassModel
->select('student_id')
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->findAll();
}
$studentIds = array_filter(array_map(static fn($entry) => (int)($entry['student_id'] ?? 0), $studentEntries));
$expected = count($studentIds);
$midtermStudents = [];
$participationStudents = [];
if ($classSectionId > 0) {
$scoreQuery = $semesterScores
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear);
if (!empty($semesterCandidates)) {
$scoreQuery->whereIn('semester', $semesterCandidates);
}
$scoreRecords = $scoreQuery->findAll();
foreach ($scoreRecords as $score) {
$sid = (int)($score['student_id'] ?? 0);
if ($sid <= 0 || ($expected > 0 && !in_array($sid, $studentIds, true))) {
continue;
}
$midtermValue = trim((string)($score[$examScoreField] ?? ''));
if ($midtermValue !== '') {
$midtermStudents[$sid] = true;
}
$participationValue = trim((string)($score['participation_score'] ?? ''));
if ($participationValue !== '') {
$participationStudents[$sid] = true;
}
}
}
$midtermCommentStudents = [];
$ptapCommentStudents = [];
if (!empty($studentIds)) {
$commentQuery = $scoreComments
->select('student_id, score_type, comment')
->whereIn('student_id', $studentIds)
->where('school_year', $schoolYear)
->whereIn('score_type', [$examTerm, 'ptap']);
if (!empty($semesterCandidates)) {
$commentQuery->whereIn('semester', $semesterCandidates);
}
$comments = $commentQuery->findAll();
foreach ($comments as $comment) {
$sid = (int)($comment['student_id'] ?? 0);
if ($sid <= 0) {
continue;
}
$text = trim((string)($comment['comment'] ?? ''));
if ($text === '') {
continue;
}
$type = strtolower(trim((string)($comment['score_type'] ?? '')));
if ($type === $examTerm) {
$midtermCommentStudents[$sid] = true;
}
if ($type === 'ptap') {
$ptapCommentStudents[$sid] = true;
}
}
}
$attendanceQuery = $attendanceDays
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->where('date', $today);
if (!empty($semesterCandidates)) {
$attendanceQuery->whereIn('semester', $semesterCandidates);
}
$attendanceRow = $attendanceQuery->first();
$attendanceSubmitted = $attendanceRow && in_array(strtolower((string)($attendanceRow['status'] ?? '')), ['submitted', 'published', 'finalized'], true);
$section = $teachersBySection[$classSectionId] ?? ['teachers' => []];
$teacherList = $section['teachers'] ?? [];
if (!empty($teacherList)) {
usort($teacherList, function ($a, $b) {
return $this->teacherRolePriority($a['role_key'] ?? 'teacher') <=> $this->teacherRolePriority($b['role_key'] ?? 'teacher');
});
$teacherList = array_values($teacherList);
}
foreach ($teacherList as $teacherEntry) {
if (!empty($teacherEntry['id'])) {
$allTeacherIds[] = $teacherEntry['id'];
}
}
$allClassSectionIds[] = $classSectionId;
$midtermScoreStatus = $this->submissionStatus(count($midtermStudents), $expected);
$midtermCommentStatus = $this->submissionStatus(count($midtermCommentStudents), $expected);
$participationStatus = $this->submissionStatus(count($participationStudents), $expected);
$ptapCommentStatus = $this->submissionStatus(count($ptapCommentStudents), $expected);
$attendanceStatus = $this->attendanceStatus($attendanceSubmitted);
$progressSubmitted = (int) ($progressSubmittedBySection[$classSectionId] ?? 0);
$classProgressStatus = $this->progressStatus($progressSubmitted, $progressExpectedWeeks);
$draftSubmitted = (int) ($examDraftCounts[$classSectionId] ?? 0);
$examDraftStatus = $this->draftStatus($draftSubmitted, $examDraftDeadline);
$homeworkSubmitted = (int) ($homeworkCounts[$classSectionId] ?? 0);
$homeworkStatus = $this->homeworkStatus($homeworkSubmitted);
$statusDetails = [
'midterm_score_status' => $midtermScoreStatus,
'midterm_comment_status' => $midtermCommentStatus,
'participation_status' => $participationStatus,
'ptap_comment_status' => $ptapCommentStatus,
'class_progress_status' => $classProgressStatus,
'exam_draft_status' => $examDraftStatus,
'homework_status' => $homeworkStatus,
];
$missingItemsForSection = $this->buildMissingItems($statusDetails, $semester);
$missingItemCount += count($missingItemsForSection);
$totalStatuses += count($statusDetails);
$rows[] = [
'class_section' => $sectionMap[$classSectionId] ?? ($section['class_section'] ?? "Section {$classSectionId}"),
'class_section_id' => $classSectionId,
'teachers' => $teacherList,
'midterm_score_status' => $midtermScoreStatus,
'midterm_comment_status' => $midtermCommentStatus,
'participation_status' => $participationStatus,
'ptap_comment_status' => $ptapCommentStatus,
'attendance_status' => $attendanceStatus,
'class_progress_status' => $classProgressStatus,
'exam_draft_status' => $examDraftStatus,
'homework_status' => $homeworkStatus,
'missing_items' => $missingItemsForSection,
'student_count' => $expected,
];
}
$historyMap = [];
$teacherIds = array_values(array_unique($allTeacherIds));
$classSectionIds = array_values(array_unique($allClassSectionIds));
if (!empty($teacherIds) && !empty($classSectionIds)) {
$historyRecords = $historyModel
->select('teacher_submission_notification_history.*, u.firstname, u.lastname')
->join('users u', 'u.id = teacher_submission_notification_history.admin_id', 'left')
->where('notification_category', 'teacher_submissions')
->whereIn('teacher_submission_notification_history.teacher_id', $teacherIds)
->whereIn('teacher_submission_notification_history.class_section_id', $classSectionIds)
->orderBy('sent_at', 'DESC')
->findAll();
foreach ($historyRecords as $record) {
$sectionId = (int)($record['class_section_id'] ?? 0);
$teacherId = (int)($record['teacher_id'] ?? 0);
if ($sectionId <= 0 || $teacherId <= 0) {
continue;
}
$sentAt = $record['sent_at'] ?? null;
$sentAtText = $sentAt ? local_datetime($sentAt, 'M j, Y g:i A') : '';
$adminName = trim(($record['firstname'] ?? '') . ' ' . ($record['lastname'] ?? ''));
if ($adminName === '') {
$adminName = 'Administrator';
}
$historyMap[$sectionId][$teacherId][] = [
'sent_at_text' => $sentAtText,
'admin_name' => $adminName,
'status' => strtolower((string)($record['status'] ?? 'sent')),
];
}
foreach ($historyMap as &$teachersHistory) {
foreach ($teachersHistory as &$entries) {
$entries = array_slice($entries, 0, 3);
}
unset($entries);
}
unset($teachersHistory);
}
$summary = [
'total_items' => $totalStatuses,
'missing_items' => $missingItemCount,
'submitted_items' => max(0, $totalStatuses - $missingItemCount),
'submission_percentage' => $totalStatuses > 0
? (int)round((($totalStatuses - $missingItemCount) / $totalStatuses) * 100)
: 100,
];
return view('administrator/teacher_submissions', [
'rows' => $rows,
'semester' => $semester,
'schoolYear' => $schoolYear,
'notificationHistory' => $historyMap,
'summary' => $summary,
'lowProgressSectionIds' => $lowProgressSectionIds,
'examDraftDeadlineConfig' => $examDraftDeadlineConfig,
'examDraftDeadlineFormatted' => $examDraftDeadlineFormatted,
]);
}
private function resolveLowProgressSectionIds(array $sectionIds): array
{
[$expectedWeeks, $submittedBySection] = $this->buildClassProgressStats($sectionIds);
if ($expectedWeeks <= 0) {
return [];
}
$lowProgressSectionIds = [];
foreach ($sectionIds as $sectionId) {
$submitted = (int) ($submittedBySection[$sectionId] ?? 0);
$percent = ($submitted / $expectedWeeks) * 100;
if ($percent < 50) {
$lowProgressSectionIds[] = $sectionId;
}
}
return $lowProgressSectionIds;
}
private function buildClassProgressStats(array $sectionIds): array
{
$sectionIds = array_values(array_unique(array_filter(array_map('intval', $sectionIds))));
if (empty($sectionIds)) {
return [0, []];
}
$semesterResolver = new SemesterRangeService($this->configModel);
$schoolYear = (string)($this->configModel->getConfig('school_year') ?? '');
$semester = (string)($this->configModel->getConfig('semester') ?? '');
$schoolYearForRange = $schoolYear !== '' ? $schoolYear : (string)($this->configModel->getConfig('school_year') ?? '');
[$rangeStart, $rangeEnd] = $semesterResolver->getSchoolYearRange($schoolYearForRange);
$semesterNorm = $semesterResolver->normalizeSemester($semester);
if ($semesterNorm !== '' && $schoolYearForRange !== '') {
$semRange = $semesterResolver->getSemesterRange($schoolYearForRange, $semesterNorm);
if ($semRange) {
[$rangeStart, $rangeEnd] = $semRange;
}
}
$dateList = [];
try {
$start = new \DateTimeImmutable($rangeStart);
$end = new \DateTimeImmutable($rangeEnd);
$cursor = $start;
$w = (int) $cursor->format('w');
if ($w !== 0) {
$cursor = $cursor->modify('next sunday');
}
while ($cursor <= $end) {
$dateList[] = $cursor->format('Y-m-d');
$cursor = $cursor->modify('+7 days');
}
} catch (\Throwable $e) {
$dateList = [];
}
$noSchoolDays = [];
$events = [];
try {
$calendarModel = new \App\Models\CalendarModel();
$events = $calendarModel->getEvents();
} catch (\Throwable $e) {
$events = [];
}
foreach ($events as $event) {
$d = substr((string) ($event['date'] ?? ''), 0, 10);
if ($d === '' || empty($event['no_school'])) {
continue;
}
if ($d < $rangeStart || $d > $rangeEnd) {
continue;
}
$eventYear = trim((string) ($event['school_year'] ?? ''));
if ($schoolYearForRange !== '' && $eventYear !== '' && $eventYear !== $schoolYearForRange) {
continue;
}
$noSchoolDays[$d] = true;
}
$anchorSundayYmd = '';
try {
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$tzObj = new \DateTimeZone($tzName ?: 'UTC');
} catch (\Throwable $e) {
try {
$tzObj = new \DateTimeZone(user_timezone() ?: 'UTC');
} catch (\Throwable $e2) {
$tzObj = new \DateTimeZone('UTC');
}
}
try {
$nowDate = new \DateTime('now', $tzObj);
} catch (\Throwable $e) {
$nowDate = new \DateTime('now');
}
$weekday = (int) $nowDate->format('w');
$anchorSundayYmd = $weekday === 0
? $nowDate->format('Y-m-d')
: $nowDate->modify('next sunday')->format('Y-m-d');
$activeDatesSet = [];
if (! empty($dateList) && $anchorSundayYmd !== '') {
foreach ($dateList as $d) {
if ($d <= $anchorSundayYmd && empty($noSchoolDays[$d])) {
$activeDatesSet[$d] = true;
}
}
}
$expectedWeeks = count($activeDatesSet);
if ($expectedWeeks === 0) {
return [0, []];
}
$builder = $this->db->table('class_progress_reports')
->select('class_section_id, week_start')
->whereIn('class_section_id', $sectionIds);
if (! empty($activeDatesSet)) {
$builder->whereIn('week_start', array_keys($activeDatesSet));
}
$rows = $builder->get()->getResultArray();
$submittedBySection = [];
foreach ($rows as $row) {
$sectionId = (int) ($row['class_section_id'] ?? 0);
$weekStart = (string) ($row['week_start'] ?? '');
if ($sectionId === 0 || $weekStart === '' || empty($activeDatesSet[$weekStart])) {
continue;
}
$submittedBySection[$sectionId][$weekStart] = true;
}
$counts = [];
foreach ($sectionIds as $sectionId) {
$counts[$sectionId] = isset($submittedBySection[$sectionId])
? count($submittedBySection[$sectionId])
: 0;
}
return [$expectedWeeks, $counts];
}
public function sendTeacherSubmissionNotifications()
{$notify = $this->request->getPost('notify');
if (!is_array($notify)) {
return redirect()->back()->with('info', 'Select at least one teacher to notify.');
}
$semester = (string)($this->configModel->getConfig('semester') ?? $this->semester ?? '');
$missingItemsPayload = $this->request->getPost('missing_items') ?? [];
$homeworkNotifyAll = (bool) $this->request->getPost('homework_notify_all');
$examTerm = $this->resolveExamTermLabel($semester);
$examScoreLabel = $examTerm === 'final' ? 'final scores' : 'midterm scores';
$examCommentLabel = $examTerm === 'final' ? 'final comments' : 'midterm comments';
$forcedItems = [];
if ($this->request->getPost('notify_midterm_score')) {
$forcedItems[] = $examScoreLabel;
}
if ($this->request->getPost('notify_midterm_comment')) {
$forcedItems[] = $examCommentLabel;
}
if ($this->request->getPost('notify_participation')) {
$forcedItems[] = 'participation';
}
if ($this->request->getPost('notify_ptap_comment')) {
$forcedItems[] = 'PTAP comments';
}
if ($this->request->getPost('notify_class_progress')) {
$forcedItems[] = 'class progress';
}
if ($this->request->getPost('notify_exam_draft')) {
$forcedItems[] = 'exam draft';
}
$targets = [];
foreach ($notify as $sectionIdRaw => $teachers) {
$sectionId = (int)$sectionIdRaw;
if ($sectionId <= 0 || !is_array($teachers)) {
continue;
}
foreach ($teachers as $teacherIdRaw => $value) {
$teacherId = (int)$teacherIdRaw;
if ($teacherId <= 0 || $value === null || $value === '') {
continue;
}
$key = "{$sectionId}_{$teacherId}";
$targets[$key] = [
'class_section_id' => $sectionId,
'teacher_id' => $teacherId,
];
}
}
if (empty($targets)) {
return redirect()->back()->with('info', 'Select at least one teacher to notify.');
}
$targets = array_values($targets);
$teacherIds = array_values(array_unique(array_column($targets, 'teacher_id')));
$classSectionIds = array_values(array_unique(array_column($targets, 'class_section_id')));
$classSections = $this->classSectionModel
->select('class_section_id, class_section_name')
->whereIn('class_section_id', $classSectionIds)
->findAll();
$classSectionMap = [];
foreach ($classSections as $section) {
$classSectionMap[(int)($section['class_section_id'] ?? 0)] = $section['class_section_name'] ?? '';
}
$teachers = $this->userModel
->select('id, firstname, lastname, email')
->whereIn('id', $teacherIds)
->findAll();
$teacherLookup = [];
foreach ($teachers as $teacher) {
$teacherLookup[(int)$teacher['id']] = $teacher;
}
$mailer = new EmailController();
$adminId = (int)(session()->get('user_id') ?? 0);
if ($adminId <= 0) {
return redirect()->to('/login');
}
$adminUser = $this->userModel->find($adminId);
$adminName = trim(($adminUser['firstname'] ?? '') . ' ' . ($adminUser['lastname'] ?? '')) ?: 'Administrator';
$historyModel = new TeacherSubmissionNotificationHistoryModel();
$scoreUrl = site_url('/');
$progressUrl = site_url('teacher/progress/history');
$examDraftUrl = site_url('teacher/exam-drafts');
$homeworkUrl = site_url('teacher/addHomework');
$examDraftDeadlineEmailHtml = $this->buildExamDraftDeadlineEmailHtml();
$sentCount = 0;
$failCount = 0;
foreach ($targets as $target) {
$classSectionId = (int)$target['class_section_id'];
$teacherId = (int)$target['teacher_id'];
$teacher = $teacherLookup[$teacherId] ?? null;
$sectionName = $classSectionMap[$classSectionId] ?? "Section {$classSectionId}";
$teacherName = $teacher ? trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? '')) : '';
if ($teacherName === '') {
$teacherName = 'Teacher';
}
$subject = "Reminder: Complete submissions for {$sectionName}";
$missingPayload = $missingItemsPayload[$classSectionId][$teacherId] ?? '';
$missingItems = $this->parseMissingItemsPayload((string)$missingPayload);
$selectedItems = $forcedItems;
if ($homeworkNotifyAll && !in_array('homework', $selectedItems, true)) {
$selectedItems[] = 'homework';
}
if (!empty($selectedItems)) {
$missingItems = array_values(array_unique($selectedItems));
}
if (!empty($missingItems)) {
$missingText = htmlspecialchars(
$this->formatMissingItemsText($missingItems),
ENT_QUOTES,
'UTF-8'
);
$missingNote = "Outstanding items: {$missingText}.
";
} else {
$missingNote = "Our records show no outstanding submissions for this section, but please verify if anything still needs attention.
";
}
$subject = "Reminder: Complete submissions for {$sectionName}";
$progressNote = '';
if (in_array('class progress', $missingItems, true)) {
$progressNote = "Class progress submissions can be updated at Teacher Progress History .
";
}
$examDraftNote = '';
if (in_array('exam draft', $missingItems, true)) {
$semesterLabel = strtolower(trim((string) $semester));
if ($semesterLabel === 'fall') {
$draftLabel = 'midterm exam draft';
} elseif ($semesterLabel === 'spring') {
$draftLabel = 'final exam draft';
} else {
$draftLabel = 'exam draft';
}
$examDraftNote = "" . ucfirst($draftLabel) . " submissions can be updated at Teacher Exam Drafts .
"
. $examDraftDeadlineEmailHtml;
}
$homeworkNote = '';
if (in_array('homework', $missingItems, true)) {
$homeworkNote = "Homework scores can be submitted at Teacher Homework .
";
}
$hasScoreItems = (bool) array_intersect($missingItems, [
'midterm scores',
'midterm comments',
'final scores',
'final comments',
'participation',
'PTAP comments',
'homework',
]);
$nonScoreOnly = ! empty($missingItems) && ! $hasScoreItems;
$body = "Dear {$teacherName},
"
. "Administration is gently reminding you to wrap up any remaining "
. ($nonScoreOnly ? "submissions for {$sectionName}." : "score submissions, comments, and related items for {$sectionName}.")
. "
"
. $missingNote
. $progressNote
. $examDraftNote
. $homeworkNote
. ($nonScoreOnly ? '' : "Visit Teacher Score Submission to address any remaining items.
")
. "Thank you, Al Rahma Administration
";
$email = $teacher['email'] ?? '';
$status = 'failed';
if (!empty($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$ok = $mailer->sendEmail($email, $subject, $body, 'notifications');
$status = $ok ? 'sent' : 'failed';
}
if ($status === 'sent') {
$sentCount++;
} else {
$failCount++;
}
$historyModel->insert([
'teacher_id' => $teacherId,
'class_section_id' => $classSectionId,
'admin_id' => $adminId,
'notification_category' => 'teacher_submissions',
'message' => $this->truncateNotificationMessage($body),
'status' => $status,
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'sent_at' => utc_now(),
]);
}
$statusParts = [];
if ($sentCount > 0) {
$statusParts[] = $sentCount . ' reminder' . ($sentCount === 1 ? '' : 's') . ' sent';
}
if ($failCount > 0) {
$statusParts[] = $failCount . ' reminder' . ($failCount === 1 ? '' : 's') . ' failed';
}
$message = !empty($statusParts) ? implode(' and ', $statusParts) : 'No notifications were sent.';
$flashType = $failCount === 0 ? 'success' : 'warning';
return redirect()->back()->with($flashType, $message);
}
private function submissionStatus(int $filled, int $expected): array
{
if ($expected <= 0) {
return [
'label' => 'No students',
'badge' => 'bg-secondary',
'detail' => '',
'completed' => true,
];
}
$completed = $filled >= $expected;
return [
'label' => $completed ? 'Submitted' : 'Missing',
'badge' => $completed ? 'bg-success' : 'bg-danger',
'detail' => "{$filled}/{$expected}",
'completed' => $completed,
];
}
private function progressStatus(int $submitted, int $expected): array
{
if ($expected <= 0) {
return [
'label' => 'N/A',
'badge' => 'bg-secondary',
'detail' => '',
'completed' => true,
];
}
$completed = $submitted >= $expected;
return [
'label' => $completed ? 'Submitted' : 'Missing',
'badge' => $completed ? 'bg-success' : 'bg-danger',
'detail' => "{$submitted}/{$expected}",
'completed' => $completed,
];
}
private function homeworkStatus(int $submitted): array
{
$completed = $submitted > 0;
return [
'label' => $completed ? 'Submitted' : 'Missing',
'badge' => $completed ? 'bg-success' : 'bg-danger',
'detail' => $completed ? (string) $submitted : '0',
'completed' => $completed,
];
}
private function draftStatus(int $submitted, ?\DateTimeImmutable $deadline): array
{
if ($deadline !== null) {
$today = new \DateTimeImmutable('today');
if ($today < $deadline) {
return [
'label' => 'Pending',
'badge' => 'bg-secondary',
'detail' => 'Not due',
'completed' => true,
];
}
}
$completed = $submitted > 0;
return [
'label' => $completed ? 'Submitted' : 'Missing',
'badge' => $completed ? 'bg-success' : 'bg-danger',
'detail' => $completed ? (string) $submitted : '0',
'completed' => $completed,
];
}
/**
* Exam draft due date for the teacher submissions dashboard: prefers the configuration key
* `exam_draft_deadline` (same as automated reminders); otherwise fall/spring exam deadlines.
*/
private function resolveTeacherDashboardExamDraftDeadline(string $semester, string $schoolYear): ?\DateTimeImmutable
{
$fromExamDraftKey = $this->parseExamDraftDeadlineConfigValue();
if ($fromExamDraftKey !== null) {
return $fromExamDraftKey;
}
return $this->resolveExamDraftDeadline($semester, $schoolYear);
}
/**
* Parses the `exam_draft_deadline` configuration value using the application timezone (midnight that calendar day).
*/
private function parseExamDraftDeadlineConfigValue(): ?\DateTimeImmutable
{
$raw = trim((string) ($this->configModel->getConfig('exam_draft_deadline') ?? ''));
if ($raw === '') {
return null;
}
$tz = new \DateTimeZone(config('App')->appTimezone ?? 'UTC');
try {
$deadline = new \DateTimeImmutable($raw, $tz);
} catch (\Throwable $e) {
return null;
}
return $deadline->setTime(0, 0, 0);
}
/**
* HTML snippet for reminder emails when exam draft is included (deadline from exam_draft_deadline config).
*/
private function buildExamDraftDeadlineEmailHtml(): string
{
$raw = trim((string) ($this->configModel->getConfig('exam_draft_deadline') ?? ''));
if ($raw === '') {
return '';
}
$parsed = $this->parseExamDraftDeadlineConfigValue();
$display = $parsed !== null
? htmlspecialchars($parsed->format('l, F j, Y'), ENT_QUOTES, 'UTF-8')
: htmlspecialchars($raw, ENT_QUOTES, 'UTF-8');
$rawEsc = htmlspecialchars($raw, ENT_QUOTES, 'UTF-8');
return 'Exam draft submission deadline (exam_draft_deadline): '
. "{$display} "
. ($parsed !== null && $rawEsc !== $display ? " (configured value: {$rawEsc}) " : '')
. '.
';
}
private function resolveExamDraftDeadline(string $semester, string $schoolYear): ?\DateTimeImmutable
{
$semesterKey = strtolower(trim($semester));
if ($semesterKey === 'fall') {
$deadlineValue = (string)($this->configModel->getConfig('fall_exam_deadline') ?? '');
} elseif ($semesterKey === 'spring') {
$deadlineValue = (string)($this->configModel->getConfig('spring_exam_deadline') ?? '');
} else {
return null;
}
$deadlineValue = trim($deadlineValue);
if ($deadlineValue === '') {
return null;
}
try {
$deadline = new \DateTimeImmutable($deadlineValue);
} catch (\Throwable $e) {
return null;
}
if ($schoolYear !== '' && preg_match('/^\d{4}-\d{4}$/', $schoolYear)) {
$deadlineYear = $deadline->format('Y');
if ($deadlineYear === '1970') {
return null;
}
}
return $deadline->setTime(0, 0, 0);
}
private function resolveExamTermLabel(string $semester): string
{
$semesterKey = strtolower(trim($semester));
if ($semesterKey === '') {
return 'midterm';
}
if (str_contains($semesterKey, 'spring')) {
return 'final';
}
if (str_contains($semesterKey, 'fall')) {
return 'midterm';
}
return 'midterm';
}
private function buildSemesterCandidates(string $semester): array
{
$semester = trim((string) $semester);
if ($semester === '') {
return [];
}
$candidates = [
$semester,
strtolower($semester),
strtoupper($semester),
ucfirst(strtolower($semester)),
];
$candidates = array_values(array_unique(array_filter($candidates, static fn ($v) => $v !== '')));
return $candidates;
}
private function attendanceStatus(bool $submitted): array
{
return [
'label' => $submitted ? 'Submitted' : 'Missing',
'badge' => $submitted ? 'bg-success' : 'bg-danger',
'completed' => $submitted,
];
}
private function buildMissingItems(array $statusMap, string $semester): array
{
$examTerm = $this->resolveExamTermLabel($semester);
$examScoreLabel = $examTerm === 'final' ? 'final scores' : 'midterm scores';
$examCommentLabel = $examTerm === 'final' ? 'final comments' : 'midterm comments';
$labels = [
'midterm_score_status' => $examScoreLabel,
'midterm_comment_status' => $examCommentLabel,
'participation_status' => 'participation',
'ptap_comment_status' => 'PTAP comments',
'attendance_status' => 'attendance',
'class_progress_status' => 'class progress',
'exam_draft_status' => 'exam draft',
'homework_status' => 'homework',
];
$items = [];
foreach ($statusMap as $key => $status) {
$completed = $status['completed'] ?? true;
if (!$completed && isset($labels[$key])) {
$items[] = $labels[$key];
}
}
return array_values($items);
}
private function formatMissingItemsText(array $items): string
{
$items = array_values(array_filter(array_map('trim', $items), static fn($v) => $v !== ''));
$count = count($items);
if ($count === 0) {
return '';
}
if ($count === 1) {
return $items[0];
}
if ($count === 2) {
return $items[0] . ' and ' . $items[1];
}
$last = array_pop($items);
return implode(', ', $items) . ' and ' . $last;
}
private function teacherRolePriority(string $roleKey): int
{
switch (strtolower($roleKey)) {
case 'main':
return 1;
case 'ta':
return 2;
default:
return 3;
}
}
private function truncateNotificationMessage(string $html, int $limit = 1000): string
{
$text = trim(strip_tags($html));
if ($text === '') {
return '';
}
if (mb_strlen($text) <= $limit) {
return $text;
}
return mb_substr($text, 0, $limit) . '…';
}
private function parseMissingItemsPayload(string $payload): array
{
if ($payload === '') {
return [];
}
$decoded = @json_decode(base64_decode($payload, true) ?: '', true);
if (!is_array($decoded)) {
return [];
}
$items = [];
foreach ($decoded as $item) {
$item = trim((string)$item);
if ($item === '') {
continue;
}
$items[] = $item;
}
return array_values(array_unique($items));
}
public function notificationsAlerts()
{
if (!$this->canManageAdminNotifications()) {
return redirect()->to('/login');
}
$admins = $this->fetchAdminNotificationUsers();
$subjects = $this->notificationSubjectOptions();
$assignedSubjects = [];
$tableReady = $this->db->tableExists('admin_notification_subjects');
if ($tableReady && !empty($admins)) {
$adminIds = array_map('intval', array_column($admins, 'id'));
$rows = $this->adminNotificationSubjectModel
->select('id, admin_id, subject')
->whereIn('admin_id', $adminIds)
->findAll();
foreach ($rows as $row) {
$adminId = (int) ($row['admin_id'] ?? 0);
$subject = (string) ($row['subject'] ?? '');
if ($adminId <= 0 || $subject === '') {
continue;
}
if (!isset($assignedSubjects[$adminId])) {
$assignedSubjects[$adminId] = [];
}
$assignedSubjects[$adminId][$subject] = true;
}
}
return view('administrator/notifications_alerts', [
'admins' => $admins,
'subjects' => $subjects,
'assignedSubjects' => $assignedSubjects,
'tableReady' => $tableReady,
]);
}
public function saveNotificationSubjects()
{
if (!$this->canManageAdminNotifications()) {
return redirect()->to('/login');
}
if (!$this->db->tableExists('admin_notification_subjects')) {
return redirect()->to('/administrator/notifications_alerts')
->with('error', 'Notification subject storage is missing. Run migrations first.');
}
$posted = $this->request->getPost('subjects');
if (!is_array($posted)) {
return redirect()->to('/administrator/notifications_alerts')
->with('error', 'No selections were submitted.');
}
$subjects = $this->notificationSubjectOptions();
$allowed = array_keys($subjects);
$admins = $this->fetchAdminNotificationUsers();
$adminIds = array_map('intval', array_column($admins, 'id'));
if (empty($adminIds)) {
return redirect()->to('/administrator/notifications_alerts')
->with('info', 'No admins found to update.');
}
$existing = $this->adminNotificationSubjectModel
->select('id, admin_id, subject')
->whereIn('admin_id', $adminIds)
->findAll();
$existingMap = [];
foreach ($existing as $row) {
$adminId = (int) ($row['admin_id'] ?? 0);
$subject = (string) ($row['subject'] ?? '');
if ($adminId <= 0 || $subject === '') {
continue;
}
if (!isset($existingMap[$adminId])) {
$existingMap[$adminId] = [];
}
$existingMap[$adminId][$subject] = (int) ($row['id'] ?? 0);
}
$updates = 0;
foreach ($adminIds as $adminId) {
$subjectRaw = $posted[$adminId] ?? [];
$selected = [];
if (is_array($subjectRaw)) {
foreach ($subjectRaw as $key => $value) {
$candidate = is_string($key) ? $key : $value;
if (!is_string($candidate)) {
continue;
}
$candidate = trim($candidate);
if ($candidate !== '') {
$selected[] = $candidate;
}
}
}
$selected = array_values(array_unique(array_filter($selected, function ($value) use ($allowed) {
return in_array($value, $allowed, true);
})));
$current = array_keys($existingMap[$adminId] ?? []);
$toDelete = array_values(array_diff($current, $selected));
$toInsert = array_values(array_diff($selected, $current));
if (!empty($toDelete)) {
$this->adminNotificationSubjectModel
->where('admin_id', $adminId)
->whereIn('subject', $toDelete)
->delete();
$updates += count($toDelete);
}
if (!empty($toInsert)) {
$batch = [];
foreach ($toInsert as $subject) {
$batch[] = [
'admin_id' => $adminId,
'subject' => $subject,
];
}
$this->adminNotificationSubjectModel->insertBatch($batch);
$updates += count($toInsert);
}
}
return redirect()->to('/administrator/notifications_alerts')
->with('success', $updates > 0 ? 'Notification subjects updated.' : 'No changes were made.');
}
public function printNotificationRecipients()
{
if (!$this->canManageAdminNotifications()) {
return redirect()->to('/login');
}
$admins = $this->fetchAdminNotificationUsers();
$tableReady = $this->db->tableExists('admin_notification_subjects');
$assigned = [];
if ($tableReady && !empty($admins)) {
$adminIds = array_map('intval', array_column($admins, 'id'));
$rows = $this->adminNotificationSubjectModel
->select('admin_id')
->where('subject', 'print_requests')
->whereIn('admin_id', $adminIds)
->findAll();
foreach ($rows as $row) {
$adminId = (int) ($row['admin_id'] ?? 0);
if ($adminId <= 0) {
continue;
}
$assigned[$adminId] = true;
}
}
return view('administrator/print_notification_admins', [
'admins' => $admins,
'assigned' => $assigned,
'tableReady' => $tableReady,
]);
}
public function savePrintNotificationRecipients()
{
if (!$this->canManageAdminNotifications()) {
return redirect()->to('/login');
}
if (!$this->db->tableExists('admin_notification_subjects')) {
return redirect()->to('/administrator/print-notifications')
->with('error', 'Notification subject storage is missing. Run migrations first.');
}
$admins = $this->fetchAdminNotificationUsers();
$adminIds = array_map('intval', array_column($admins, 'id'));
if (empty($adminIds)) {
return redirect()->to('/administrator/print-notifications')
->with('info', 'No admins found to update.');
}
$posted = (array) $this->request->getPost('notify');
$selected = [];
foreach ($posted as $key => $value) {
$adminId = (int) $key;
if ($adminId <= 0) {
continue;
}
if (!in_array($adminId, $adminIds, true)) {
continue;
}
$selected[] = $adminId;
}
$selected = array_values(array_unique($selected));
$existingRows = $this->adminNotificationSubjectModel
->select('admin_id')
->where('subject', 'print_requests')
->whereIn('admin_id', $adminIds)
->findAll();
$current = array_values(array_unique(array_map(
fn($row) => (int) ($row['admin_id'] ?? 0),
$existingRows
)));
$toDelete = array_values(array_diff($current, $selected));
$toInsert = array_values(array_diff($selected, $current));
if (!empty($toDelete)) {
$this->adminNotificationSubjectModel
->where('subject', 'print_requests')
->whereIn('admin_id', $toDelete)
->delete();
}
if (!empty($toInsert)) {
$batch = [];
foreach ($toInsert as $adminId) {
$batch[] = [
'admin_id' => $adminId,
'subject' => 'print_requests',
];
}
$this->adminNotificationSubjectModel->insertBatch($batch);
}
$changes = count($toDelete) + count($toInsert);
return redirect()->to('/administrator/print-notifications')
->with('success', $changes > 0 ? 'Print notification recipients updated.' : 'No changes were made.');
}
private function notificationSubjectOptions(): array
{
return [
'academics' => 'Academics',
'attendance' => 'Attendance',
'events' => 'Events',
'finance' => 'Finance',
'general' => 'General',
'print_requests' => 'Print Requests',
];
}
private function getAdminNotificationExcludedRoles(): array
{
return [
'parent',
'student',
'guest',
'teacher',
'assistant teacher',
'teacher assistant',
'teacher_assistant',
'assistant_teacher',
'ta',
'authorized_user',
];
}
private function canManageAdminNotifications(): bool
{
$session = session();
if (! $session->get('is_logged_in')) {
return false;
}
$role = trim((string) ($session->get('role') ?? ''));
if ($role === '') {
return false;
}
$excluded = array_map(
fn ($value) => strtolower(trim((string) $value)),
$this->getAdminNotificationExcludedRoles()
);
return !in_array(strtolower($role), $excluded, true);
}
private function fetchAdminNotificationUsers(): array
{
$excluded = $this->getAdminNotificationExcludedRoles();
$excludedList = "'" . implode("','", $excluded) . "'";
return $this->db->table('users u')
->select('u.id, u.firstname, u.lastname, u.email')
->join('user_roles ur', 'ur.user_id = u.id', 'inner')
->join('roles r', 'r.id = ur.role_id', 'inner')
->where('r.name IS NOT NULL', null, false)
->where('ur.deleted_at', null)
->where("LOWER(r.name) NOT IN ({$excludedList})", null, false)
->groupBy('u.id, u.firstname, u.lastname, u.email')
->orderBy('u.lastname', 'ASC')
->orderBy('u.firstname', 'ASC')
->get()
->getResultArray();
}
public function feeCollection()
{
return view('administrator/fee_collection');
}
public function expenseManagement()
{
return view('administrator/expense_management');
}
public function budgetReports()
{
return view('administrator/budget_reports');
}
public function scholarshipInformation()
{
return view('administrator/scholarship_information');
}
public function attendanceRecords()
{
return view('administrator/attendance_records');
}
public function academicPerformance()
{
return view('administrator/academic_performance');
}
public function behaviorReports()
{
return view('administrator/behavior_reports');
}
public function healthRecords()
{
return view('administrator/health_records');
}
public function contactInformation()
{
return view('administrator/contact_information');
}
public function studentProfiles()
{
$db = db_connect();
$isPg = ($db->getPlatform() === 'Postgre'); // 'MySQLi', 'Postgre', 'SQLSRV', ...
// In MySQL, avoid truncation of long lists
if (!$isPg) {
$db->query('SET SESSION group_concat_max_len = 8192');
}
$b = $db->table('students');
if ($isPg) {
// Postgres: use subqueries for aggregates to avoid GROUP BY on students.*
$students = $b->select([
'students.*',
'users.firstname AS parent_firstname',
'users.lastname AS parent_lastname',
'users.email AS parent_email',
'users.cellphone AS parent_phone',
// Pick one emergency contact (first by id)
"(SELECT ec.emergency_contact_name FROM emergency_contacts ec
WHERE ec.parent_id = students.parent_id
ORDER BY ec.id ASC LIMIT 1) AS emergency_name",
"(SELECT ec.relation FROM emergency_contacts ec
WHERE ec.parent_id = students.parent_id
ORDER BY ec.id ASC LIMIT 1) AS emergency_relationship",
"(SELECT ec.cellphone FROM emergency_contacts ec
WHERE ec.parent_id = students.parent_id
ORDER BY ec.id ASC LIMIT 1) AS emergency_phone",
"(SELECT ec.email FROM emergency_contacts ec
WHERE ec.parent_id = students.parent_id
ORDER BY ec.id ASC LIMIT 1) AS emergency_email",
// Aggregated lists
"(SELECT STRING_AGG(DISTINCT sa.allergy, ', ' ORDER BY sa.allergy)
FROM student_allergies sa WHERE sa.student_id = students.id) AS allergies",
"(SELECT STRING_AGG(DISTINCT smc.condition_name, ', ' ORDER BY smc.condition_name)
FROM student_medical_conditions smc WHERE smc.student_id = students.id) AS medical_conditions",
])
->join('users', 'users.id = students.parent_id', 'left')
->orderBy('students.lastname', 'ASC')
->orderBy('students.firstname', 'ASC')
->get()
->getResultArray();
} else {
// MySQL: GROUP_CONCAT + MIN() for emergency_* to satisfy ONLY_FULL_GROUP_BY
$students = $b->select([
'students.*',
'users.firstname AS parent_firstname',
'users.lastname AS parent_lastname',
'users.email AS parent_email',
'users.cellphone AS parent_phone',
'MIN(emergency_contacts.emergency_contact_name) AS emergency_name',
'MIN(emergency_contacts.relation) AS emergency_relationship',
'MIN(emergency_contacts.cellphone) AS emergency_phone',
'MIN(emergency_contacts.email) AS emergency_email',
"GROUP_CONCAT(DISTINCT student_allergies.allergy
ORDER BY student_allergies.allergy SEPARATOR ', ') AS allergies",
"GROUP_CONCAT(DISTINCT student_medical_conditions.condition_name
ORDER BY student_medical_conditions.condition_name SEPARATOR ', ') AS medical_conditions",
])
->join('users', 'users.id = students.parent_id', 'left')
->join('emergency_contacts', 'emergency_contacts.parent_id = students.parent_id', 'left')
->join('student_allergies', 'student_allergies.student_id = students.id', 'left')
->join('student_medical_conditions', 'student_medical_conditions.student_id = students.id', 'left')
->groupBy('students.id')
->orderBy('students.lastname', 'ASC')
->orderBy('students.firstname', 'ASC')
->get()
->getResultArray();
}
// === Inject class_section_name from student_class via your method and replace grade ===
foreach ($students as $i => $row) {
$sid = (int) ($row['id'] ?? 0);
if ($sid > 0) {
// Fetch class_section_name for this student
// Assumes your method signature: getClassSectionNameByStudentId(int $studentId): ?string
$classSectionName = (string) ($this->studentClassModel->getClassSectionNameByStudentId($sid) ?? '');
// Expose it explicitly and replace original grade if present
$students[$i]['class_section_name'] = $classSectionName;
} else {
// Keep keys consistent even if id missing
$students[$i]['class_section_name'] = '';
}
}
// === end injection ===
return view('administrator/student_profiles', [
'students' => $students,
'gradeOptions' => ['K', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'Youth'],
'genderOptions' => ['Male', 'Female', 'Other'],
]);
}
public function parentProfiles()
{
// Fetch all users with their roles in one go
$allUsers = $this->userModel->findAll();
$parents = [];
// Fetch roles for users in one query to avoid looping over all users
foreach ($allUsers as $user) {
// Get the roles of the user directly
$roles = $this->userRoleModel->getRolesByUserId($user['id']);
$isParent = false;
// Check if $roles is iterable and contains the 'parent' role
if (is_array($roles)) {
foreach ($roles as $role) {
if (isset($role['role_name']) && $role['role_name'] === 'parent') {
$isParent = true;
break;
}
}
}
if ($isParent) {
// Retrieve the latest paid amount and balance for this parent
$paidAmount = $this->invoiceModel->getLatestInvoicePaidAmount($user['id']) ?? 0;
$balance = $this->invoiceModel->getLatestInvoiceBalance($user['id']) ?? 0;
// Get students for this parent
$studentsData = $this->studentModel->where('parent_id', $user['id'])->findAll();
$students = [];
foreach ($studentsData as $student) {
$classSectionName = $this->studentClassModel->getClassSectionNameByStudentId($student['id']) ?? 'N/A';
$students[] = [
'name' => $student['firstname'] . ' ' . $student['lastname'],
'class_section' => $classSectionName
];
}
// Add parent data to the array
$parents[] = [
'id' => $user['id'],
'school_id' => $user['school_id'],
'firstname' => $user['firstname'],
'lastname' => $user['lastname'],
'email' => $user['email'],
'cellphone' => $user['cellphone'],
'gender' => $user['gender'],
'created_at' => $user['created_at'],
'school_year' => $user['school_year'],
'paid_amount' => $paidAmount,
'balance' => $balance,
'students' => $students,
];
}
}
return view('administrator/parent_profile', ['parents' => $parents]);
}
public function manageUsers()
{// Fetch all users
$users = $this->userModel->findAll();
// Fetch roles for each user
foreach ($users as &$user) {
$userRoles = ($this->userRoleModel)->where('user_id', $user['id'])->findAll();
$user['roles'] = [];
foreach ($userRoles as $userRole) {
$role = $this->roleModel->find($userRole['role_id']);
if ($role) {
$user['roles'][] = $role['name'];
}
}
}
$data['users'] = $users;
return view('administrator/manage_users', $data);
}
public function editUser($userId)
{$user = $this->userModel->find($userId);
// Fetch roles assigned to the user
$userRoles = $this->userRoleModel->where('user_id', $userId)->findAll();
$assignedRoles = [];
foreach ($userRoles as $userRole) {
$role = $this->roleModel->find($userRole['role_id']);
if ($role) {
$assignedRoles[] = $role['id'];
}
}
$roles = $this->roleModel->findAll();
$data = [
'user' => $user,
'roles' => $roles,
'assignedRoles' => $assignedRoles,
];
return view('administrator/edit_user', $data);
}
public function updateUser()
{$userId = $this->request->getPost('id');
$roleIds = $this->request->getPost('role_ids');
// Update user details
$data = [
'id' => $userId,
'firstname' => $this->request->getPost('firstname'),
'lastname' => $this->request->getPost('lastname'),
'email' => $this->request->getPost('email'),
];
$this->userModel->save($data);
// Update user roles
$this->userRoleModel->where('user_id', $userId)->delete();
foreach ($roleIds as $roleId) {
$this->userRoleModel->save([
'user_id' => $userId,
'role_id' => $roleId,
]);
}
return redirect()->to('/administrator/manage-users');
}
public function deleteUser($userId)
{// Delete user roles first
$this->userRoleModel->where('user_id', $userId)->delete();
// Delete the user
$this->userModel->delete($userId);
return redirect()->to('/administrator/manage-users');
}
public function showEnrollmentWithdrawalPage()
{
try {
$selectedYear = trim((string)($this->request->getGet('schoolYear') ?? ''));
if ($selectedYear === '') {
$selectedYear = (string)$this->schoolYear;
}
// Distinct school years from enrollments (for selector)
$yearsRows = $this->db->table('enrollments')
->distinct()
->select('school_year')
->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)));
$students = $this->studentModel->getStudentsWithClassAndEnrollment();
$selectedStartYear = $this->getSchoolYearStartYear((string)$selectedYear);
$removedPriorIds = [];
if ($selectedStartYear !== null) {
$removedRows = $this->db->table('enrollments')
->select('student_id, school_year')
->where('is_withdrawn', 1)
->get()->getResultArray();
foreach ($removedRows as $row) {
$rowYear = $this->getSchoolYearStartYear((string)($row['school_year'] ?? ''));
if ($rowYear !== null && $rowYear < $selectedStartYear) {
$removedPriorIds[(int)($row['student_id'] ?? 0)] = true;
}
}
}
foreach ($students as &$s) {
// ===== Ensure IDs needed by the modal =====
$s['student_id'] = (int)($s['id'] ?? 0);
$s['removed_previous_year'] = isset($removedPriorIds[$s['student_id']]) ? 'Yes' : 'No';
// Prefer parent_id; fallback to secondparent_user_id if present
if (empty($s['parent_id']) && !empty($s['secondparent_user_id'])) {
$s['parent_id'] = (int)$s['secondparent_user_id'];
} else {
$s['parent_id'] = (int)($s['parent_id'] ?? 0);
}
// ===== Parent display + sort keys (keep existing behavior) =====
$pf = trim((string)($s['parent_firstname'] ?? ''));
$pl = trim((string)($s['parent_lastname'] ?? ''));
// Fallback: if only a single full name exists
if ($pf === '' && $pl === '' && !empty($s['parent_fullname'])) {
$parts = preg_split('/\s+/', trim((string)$s['parent_fullname']), 2);
$pf = $parts[0] ?? '';
$pl = $parts[1] ?? '';
}
$s['parent_label'] = trim($pf . ' ' . $pl);
$s['parent_sort'] = trim(($pl !== '' ? $pl : $pf) . ' ' . $pf);
if ($s['parent_label'] === '') {
$s['parent_label'] = 'Unknown Parent';
$s['parent_sort'] = 'ZZZ Unknown Parent';
}
// ===== New-student flags =====
$s['is_new'] = (int) ($s['is_new'] ?? 0);
$s['new_student'] = $s['is_new'] === 1 ? 'Yes' : 'No';
// ===== Admission override =====
// Enrollment status for selected year
$statusForYear = $this->enrollmentModel->getEnrollmentStatus((int)$s['student_id'], $selectedYear);
if (!empty($statusForYear)) {
$s['enrollment_status'] = $statusForYear;
} elseif (($s['admission_status'] ?? null) === 'denied') {
$s['enrollment_status'] = 'denied';
}
// ===== Class section name for the selected year =====
$name = $this->studentClassModel->getClassSectionsByStudentId((int)$s['student_id'], $selectedYear);
$s['class_section'] = $name ?: 'Class not Assigned';
// ===== Sortable registration date (for data-order in view) =====
$s['registration_date_order'] = !empty($s['registration_date'])
? date('Y-m-d', strtotime($s['registration_date']))
: '';
}
unset($s); // break reference
// ===== Sort by parent, then student (lastname, firstname) =====
usort($students, function (array $a, array $b) {
$pa = $a['parent_sort'] ?? '';
$pb = $b['parent_sort'] ?? '';
if (strcasecmp($pa, $pb) === 0) {
$la = $a['lastname'] ?? '';
$lb = $b['lastname'] ?? '';
$cmp = strcasecmp($la, $lb);
if ($cmp !== 0) return $cmp;
return strcasecmp($a['firstname'] ?? '', $b['firstname'] ?? '');
}
return strcasecmp($pa, $pb);
});
// ===== Provide classes for the modal select (like studentClassAssignment) =====
// Filter to current term; loosen if your table doesn’t store these fields.
$classes = $this->classSectionModel
->select('id, class_section_id, class_section_name, school_year, semester')
->where('school_year', (string)$selectedYear)
->where('semester', (string)$this->semester)
->orderBy('class_section_name', 'ASC')
->findAll();
// Fallback so the dropdown never shows empty if filters are too strict
if (empty($classes)) {
$classes = $this->classSectionModel
->select('id, class_section_id, class_section_name')
->orderBy('class_section_name', 'ASC')
->findAll();
}
return view('enroll_withdraw/enrollment_withdrawal', [
'students' => $students,
'classes' => $classes, // <-- used by the modal
'schoolYears' => $schoolYears,
'selectedYear' => $selectedYear,
'currentYear' => (string)$this->schoolYear,
'isCurrentYear' => ((string)$selectedYear === (string)$this->schoolYear),
'missingYear' => empty($this->schoolYear),
]);
} catch (\Throwable $e) {
log_message('error', 'Enrollment/Withdrawal page error: {msg}', ['msg' => $e->getMessage()]);
return view('errors/html/error_500');
}
}
// API: Enrollment/Withdrawal data for admin page
public function enrollmentWithdrawalData()
{
try {
$selectedYear = trim((string)($this->request->getGet('schoolYear') ?? ''));
if ($selectedYear === '') {
$selectedYear = (string)$this->schoolYear;
}
// Distinct school years
$yearsRows = $this->db->table('enrollments')
->distinct()
->select('school_year')
->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)));
$students = $this->studentModel->getStudentsWithClassAndEnrollment();
$selectedStartYear = $this->getSchoolYearStartYear((string)$selectedYear);
$removedPriorIds = [];
if ($selectedStartYear !== null) {
$removedRows = $this->db->table('enrollments')
->select('student_id, school_year')
->where('is_withdrawn', 1)
->get()->getResultArray();
foreach ($removedRows as $row) {
$rowYear = $this->getSchoolYearStartYear((string)($row['school_year'] ?? ''));
if ($rowYear !== null && $rowYear < $selectedStartYear) {
$removedPriorIds[(int)($row['student_id'] ?? 0)] = true;
}
}
}
foreach ($students as &$s) {
$s['student_id'] = (int)($s['id'] ?? 0);
$s['removed_previous_year'] = isset($removedPriorIds[$s['student_id']]) ? 'Yes' : 'No';
if (empty($s['parent_id']) && !empty($s['secondparent_user_id'])) {
$s['parent_id'] = (int)$s['secondparent_user_id'];
} else {
$s['parent_id'] = (int)($s['parent_id'] ?? 0);
}
$pf = trim((string)($s['parent_firstname'] ?? ''));
$pl = trim((string)($s['parent_lastname'] ?? ''));
if ($pf === '' && $pl === '' && !empty($s['parent_fullname'])) {
$parts = preg_split('/\s+/', trim((string)$s['parent_fullname']), 2);
$pf = $parts[0] ?? '';
$pl = $parts[1] ?? '';
}
$s['parent_label'] = trim($pf . ' ' . $pl) ?: 'Unknown Parent';
$s['parent_sort'] = trim(($pl !== '' ? $pl : $pf) . ' ' . $pf) ?: 'ZZZ Unknown Parent';
$s['is_new'] = (int) ($s['is_new'] ?? 0);
$s['new_student'] = $s['is_new'] === 1 ? 'Yes' : 'No';
$statusForYear = $this->enrollmentModel->getEnrollmentStatus((int)$s['student_id'], $selectedYear);
if (!empty($statusForYear)) {
$s['enrollment_status'] = $statusForYear;
} elseif (($s['admission_status'] ?? null) === 'denied') {
$s['enrollment_status'] = 'denied';
}
$className = $this->studentClassModel->getClassSectionsByStudentId((int)$s['student_id'], $selectedYear);
$s['class_section'] = $className ?: 'Class not Assigned';
$s['registration_date_order'] = !empty($s['registration_date'])
? date('Y-m-d', strtotime($s['registration_date']))
: '';
}
unset($s);
usort($students, function (array $a, array $b) {
$pa = $a['parent_sort'] ?? '';
$pb = $b['parent_sort'] ?? '';
if (strcasecmp($pa, $pb) === 0) {
$la = $a['lastname'] ?? '';
$lb = $b['lastname'] ?? '';
$cmp = strcasecmp($la, $lb);
if ($cmp !== 0) return $cmp;
return strcasecmp($a['firstname'] ?? '', $b['firstname'] ?? '');
}
return strcasecmp($pa, $pb);
});
$classes = $this->classSectionModel
->select('id, class_section_id, class_section_name, school_year, semester')
->where('school_year', (string)$selectedYear)
->where('semester', (string)$this->semester)
->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();
}
return $this->response->setJSON([
'students' => $students,
'classes' => $classes,
'csrfHash' => csrf_hash(),
'semester' => (string)$this->semester,
'school_year' => (string)$selectedYear,
'schoolYears' => $schoolYears,
]);
} catch (\Throwable $e) {
log_message('error', 'enrollmentWithdrawalData error: {msg}', ['msg' => $e->getMessage()]);
return $this->response->setStatusCode(500)->setJSON(['error' => 'Server error']);
}
}
/**
* Show only newly registered students, with contact modal support.
*
* Route example:
* $routes->get('admin/enrollment/new-students', 'EnrollmentController::showNewStudents', ['filter' => 'auth:view_new_students']);
*/
public function showNewStudents(): string
{
$rows = $this->studentModel->getStudentsWithParentsAndEmergency($this->schoolYear);
$newStudents = [];
foreach ($rows as $r) {
$r['new_student'] = 'Yes';
$classSection = $this->studentClassModel->getClassSectionsByStudentId($r['id'], $this->schoolYear);
$enrollmentstatus = $this->enrollmentModel->getEnrollmentStatus($r['id'], $this->schoolYear);
// robust default
$r['class_section'] = (isset($classSection) && trim((string)$classSection) !== '')
? $classSection
: 'Class not Assigned';
$r['is_new'] = (int) $r['is_new'];
$r['new_student'] = $r['is_new'] === 1 ? "Yes" : "No";
$r['modalIdContact'] = 'contact_' . (int)($r['id'] ?? 0);
$r['enrollment_status'] = $enrollmentstatus;
// ✅ Format registration_date for display
if (!empty($r['registration_date'])) {
try {
$r['registration_date'] = (new \DateTime($r['registration_date']))->format('Y-m-d');
} catch (\Throwable $e) {
$r['registration_date'] = '';
}
}
// Age comes directly from DB (already stored in students.age)
$newStudents[] = $r;
}
return view('enroll_withdraw/new-students', [
'new_students' => $newStudents,
'total_new' => count($newStudents),
]);
}
//update enrollment status
public function adminEnrollmentWithdrawalHandler()
{
$refundService = new FeeCalculationService();
$this->db->transStart();
try {
$enrollmentStatuses = $this->request->getPost('enrollment_status');
if (empty($enrollmentStatuses)) {
return redirect()->to(base_url('enroll_withdraw/enrollment_withdrawal'))
->with('error', 'No enrollment statuses were submitted.');
}
$errors = [];
// For batching emails: parent -> status -> [students...]
$groupsByParentStatus = []; // [parent_id][status][] = ['student_id'=>, 'student_name'=>]
$parentInfo = []; // [parent_id] = ['user_id','email','firstname','lastname']
$refundParents = []; // parent_id => true (for refund calc)
$refundAmountByParent = []; // parent_id => amount
$validStatuses = [
'admission under review',
'payment pending',
'enrolled',
'withdraw under review',
'refund pending',
'withdrawn',
'denied',
'waitlist',
];
foreach ($enrollmentStatuses as $studentId => $newEnrollmentStatus) {
if (!in_array($newEnrollmentStatus, $validStatuses, true)) {
$errors[] = "Invalid enrollment status '$newEnrollmentStatus' for student ID $studentId.";
continue;
}
// Map admission_status based on the desired new status (needed for create-or-update)
if ($newEnrollmentStatus === 'denied') {
$admissionStatus = 'denied';
} elseif (in_array($newEnrollmentStatus, ['enrolled', 'payment pending'], true)) {
$admissionStatus = 'accepted';
} else {
$admissionStatus = 'pending';
}
// Current enrollment row
$enrollmentRow = $this->db->table('enrollments')
->where('student_id', $studentId)
->where('school_year', $this->schoolYear)
->get()
->getRowArray();
// If no enrollment found for this student/year, create one so invoice generation has data
if (!$enrollmentRow) {
$stu = $this->studentModel->find((int)$studentId) ?? [];
$parentId = (int)($stu['parent_id'] ?? ($stu['secondparent_user_id'] ?? 0));
if (!$parentId) {
$errors[] = "No parent ID found for student ID $studentId.";
continue;
}
$isWithdrawn = in_array($newEnrollmentStatus, ['withdrawn', 'refund pending', 'withdraw under review'], true) ? 1 : 0;
$ok = $this->db->table('enrollments')->insert([
'student_id' => (int)$studentId,
'parent_id' => $parentId,
'school_year' => (string)$this->schoolYear,
'semester' => (string)$this->semester,
'enrollment_date' => local_date(utc_now(), 'Y-m-d'),
'is_withdrawn' => $isWithdrawn,
'enrollment_status' => $newEnrollmentStatus,
'admission_status' => $admissionStatus,
'created_at' => utc_now(),
'updated_at' => utc_now(),
]);
if (!$ok) {
$errors[] = "Failed to create enrollment for student ID $studentId.";
continue;
}
// Group this newly-created change for notifications
$studentRow = $this->studentModel->find($studentId) ?? [];
$studentName = trim(($studentRow['firstname'] ?? '') . ' ' . ($studentRow['lastname'] ?? '')) ?: "Student #{$studentId}";
if (!isset($parentInfo[$parentId])) {
$p = $this->userModel->find($parentId) ?? [];
$parentInfo[$parentId] = [
'user_id' => $p['id'] ?? $parentId,
'email' => $p['email'] ?? null,
'firstname' => $p['firstname'] ?? '',
'lastname' => $p['lastname'] ?? '',
];
}
$groupsByParentStatus[$parentId][$newEnrollmentStatus][] = [
'student_id' => (int) $studentId,
'student_name' => $studentName,
];
if ($newEnrollmentStatus === 'refund pending') {
$refundParents[$parentId] = true;
}
log_message('info', "Created enrollment for student ID {$studentId} with status {$newEnrollmentStatus} and admission {$admissionStatus}.");
continue; // go to next student
}
$oldStatus = $enrollmentRow['enrollment_status'] ?? null;
$parentId = $enrollmentRow['parent_id'] ?? null;
if (!$parentId) {
$errors[] = "No parent ID found for student ID $studentId.";
continue;
}
// admissionStatus computed above
// Skip if no actual change
if ($oldStatus === $newEnrollmentStatus) {
log_message('debug', "No status change for student {$studentId} ({$oldStatus}) — skipping.");
continue;
}
// Update enrollment
$updated = $this->db->table('enrollments')
->where('student_id', $studentId)
->where('school_year', $this->schoolYear)
->update([
'enrollment_status' => $newEnrollmentStatus,
'admission_status' => $admissionStatus,
'updated_at' => utc_now(),
]);
if (!$updated) {
$errors[] = "Failed to update enrollment for student ID $studentId.";
continue;
}
log_message('info', "Updated enrollment for student ID $studentId: {$oldStatus} → {$newEnrollmentStatus} (admission: {$admissionStatus})");
// Student name
$studentRow = $this->studentModel->find($studentId);
$studentName = trim(($studentRow['firstname'] ?? '') . ' ' . ($studentRow['lastname'] ?? '')) ?: "Student #{$studentId}";
// Cache parent info once
if (!isset($parentInfo[$parentId])) {
$p = $this->userModel->find($parentId) ?? [];
$parentInfo[$parentId] = [
'user_id' => $p['id'] ?? $parentId, // assuming parent_id == user_id
'email' => $p['email'] ?? null,
'firstname' => $p['firstname'] ?? '',
'lastname' => $p['lastname'] ?? '',
];
}
// Group by parent & status for minimal emails
$groupsByParentStatus[$parentId][$newEnrollmentStatus][] = [
'student_id' => (int) $studentId,
'student_name' => $studentName,
];
// Mark for refund calc
if ($newEnrollmentStatus === 'refund pending') {
$refundParents[$parentId] = true;
}
}
// Compute refunds ONCE per parent needing it
foreach (array_keys($refundParents) as $pid) {
$students = $this->enrollmentModel
->where('parent_id', $pid)
->where('school_year', $this->schoolYear)
->findAll();
if (empty($students)) {
// If a parent is marked for refund but has no enrollments, just log and continue.
log_message('info', "No enrollments found for parent ID {$pid} (for refund calc); skipping refund.");
continue;
}
$invoice = $this->invoiceModel->where('parent_id', $pid)
->where('school_year', $this->schoolYear)
->orderBy('created_at', 'DESC')
->first();
if (!$invoice) {
$errors[] = "No invoice found for parent ID $pid (for refund calc).";
continue;
}
$refundAmount = $refundService->calculateRefund($students, $pid);
$refundAmountByParent[$pid] = $refundAmount;
$existingRefund = $this->refundModel->where('invoice_id', $invoice['id'])->first();
if ($existingRefund) {
$this->refundModel->update($existingRefund['id'], [
'refund_amount' => $refundAmount,
'status' => 'Pending',
'updated_by' => session()->get('user_id') ?? null,
]);
} else {
$this->refundModel->insert([
'parent_id' => $pid,
'school_year' => $invoice['school_year'],
'invoice_id' => $invoice['id'],
'refund_amount' => $refundAmount,
'refund_paid_amount' => 0.0,
'status' => 'Pending',
'requested_at' => utc_now(),
'updated_by' => session()->get('user_id') ?? null,
]);
}
log_message('info', "Refund of $refundAmount created/updated for invoice ID {$invoice['id']} (parent {$pid}).");
}
$this->db->transComplete();
if (!$this->db->transStatus()) {
return redirect()->to(base_url('enroll_withdraw/enrollment_withdrawal'))
->with('error', 'A database error occurred. Changes were rolled back.');
}
// === AFTER COMMIT: fire specific events, batched per parent/status ===
$eventMap = [
'admission under review' => 'admissionUnderReview',
'payment pending' => 'paymentPending',
'enrolled' => 'studentEnrolled',
'withdraw under review' => 'withdrawUnderReview',
'refund pending' => 'refundPending',
'withdrawn' => 'withdrawn',
'denied' => 'denied',
'waitlist' => 'waitlist',
];
foreach ($groupsByParentStatus as $pid => $byStatus) {
// Common parent data
$p = $parentInfo[$pid] ?? ['user_id' => $pid, 'email' => null, 'firstname' => '', 'lastname' => ''];
// Fetch invoice once for this parent (for payment pending email data if needed)
$invoice = $this->invoiceModel->where('parent_id', $pid)
->where('school_year', $this->schoolYear)
->orderBy('created_at', 'DESC')
->first();
foreach ($byStatus as $status => $studentsArr) {
if (empty($eventMap[$status])) {
continue; // unknown mapping
}
// Build second arg: student list
$studentData = [];
foreach ($studentsArr as $s) {
$studentData[] = ['name' => $s['student_name'], 'student_id' => $s['student_id']];
}
// Parent payload (first arg)
$parentData = [
'user_id' => $p['user_id'],
'email' => $p['email'],
'firstname' => $p['firstname'],
'lastname' => $p['lastname'],
'school_year' => $this->schoolYear,
'portalLink' => base_url('/login'),
];
// Enrich with status-specific fields
if ($status === 'payment pending') {
if ($invoice) {
$parentData['amount'] = (float) ($invoice['balance'] ?? $invoice['amount_due'] ?? $invoice['total_amount'] ?? 0);
$parentData['due_date'] = $invoice['due_date'] ?? null;
}
} elseif ($status === 'refund pending') {
$parentData['amount'] = $refundAmountByParent[$pid] ?? null;
}
$eventName = $eventMap[$status];
log_message('info', "Triggering event '{$eventName}' for parent {$pid} with " . count($studentData) . " student(s).");
Events::trigger($eventName, $parentData, $studentData);
}
}
// === Server-side safety net: generate/update invoices for parents whose statuses require it ===
try {
$needsInvoiceFor = ['payment pending', 'enrolled', 'withdrawn', 'refund pending'];
$invCtl = new InvoiceController();
foreach ($groupsByParentStatus as $pid => $byStatus) {
$statuses = array_keys($byStatus);
$requires = array_intersect($statuses, $needsInvoiceFor);
if (!empty($requires)) {
// Best-effort; ignore response object
try {
$invCtl->generateInvoice((string)$pid);
} catch (\Throwable $e) {
log_message('error', 'Invoice fallback generation failed for parent {pid}: {err}', ['pid' => $pid, 'err' => $e->getMessage()]);
}
}
}
} catch (\Throwable $e) {
log_message('error', 'Invoice fallback block error: ' . $e->getMessage());
}
if (!empty($errors)) {
return redirect()->to(base_url('enroll_withdraw/enrollment_withdrawal'))
->with('error', implode(' ', $errors));
}
return redirect()->to(base_url('enroll_withdraw/enrollment_withdrawal'))
->with('success', 'Enrollment statuses updated and notifications sent.');
} catch (\Throwable $e) {
$this->db->transRollback();
log_message('error', 'Enrollment withdrawal error: ' . $e->getMessage());
return redirect()->to(base_url('enroll_withdraw/enrollment_withdrawal'))
->with('error', 'An unexpected error occurred while processing enrollments.');
}
}
}