Files
2026-05-16 13:44:12 -04:00

2920 lines
116 KiB
PHP
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\UserModel;
use App\Models\RoleModel;
use App\Models\PermissionModel;
use App\Models\RolePermissionModel;
use App\Models\StudentModel;
use App\Models\LoginActivityModel;
use CodeIgniter\Controller;
use App\Models\InvoiceModel;
use App\Models\ClassSectionModel;
use App\Models\UserRoleModel;
use App\Models\ConfigurationModel;
use App\Models\RefundModel;
use App\Models\EnrollmentModel;
use App\Models\AdminNotificationSubjectModel;
use Doctrine\DBAL\Configuration;
use App\Services\FeeCalculationService;
use App\Models\StudentClassModel;
use App\Controllers\View\EmailController;
use App\Controllers\View\InvoiceController;
use App\Models\StaffAttendanceModel;
use App\Libraries\StaffTimeOffLinkService;
use App\Models\AttendanceDayModel;
use App\Models\ScoreCommentModel;
use App\Models\SemesterScoreModel;
use App\Models\TeacherClassModel;
use App\Models\TeacherSubmissionNotificationHistoryModel;
use App\Models\ExamDraftModel;
use App\Models\HomeworkModel;
use App\Services\SemesterRangeService;
use CodeIgniter\Events\Events;
class AdministratorController extends BaseController
{
protected $permissionModel;
protected $rolePermissionModel;
protected $roleModel;
protected $userModel;
protected $userRoleModel;
protected $db;
protected $configModel;
protected $semester;
protected $schoolYear;
protected $studentModel;
protected $loginActivityModel;
protected $invoiceModel;
protected $refundModel;
protected $enrollmentModel;
protected $classSectionModel;
protected $studentClassModel;
protected $staffAttendanceModel;
protected $adminNotificationSubjectModel;
public function __construct()
{
helper('auth');
// Load models
$this->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 = '<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">'
. '<h3 style="margin:0 0 8px;">New TimeOff Request</h3>'
. '<p style="margin:0 0 12px;">A staff time-off request was submitted from the administrator portal.</p>'
. '<table cellpadding="6" cellspacing="0" style="border-collapse:collapse;">'
. '<tr><td><strong>Name</strong></td><td>' . esc($fullName) . '</td></tr>'
. '<tr><td><strong>Email</strong></td><td>' . esc($userEmail) . '</td></tr>'
. '<tr><td><strong>Role</strong></td><td>' . esc((string)$role) . '</td></tr>'
. '<tr><td><strong>Semester</strong></td><td>' . esc($semester) . '</td></tr>'
. '<tr><td><strong>School Year</strong></td><td>' . esc($schoolYear) . '</td></tr>'
. '<tr><td><strong>Reason Type</strong></td><td>' . esc($reasonType ?: '-') . '</td></tr>'
. '<tr><td><strong>Reason</strong></td><td>' . esc($reasonText) . '</td></tr>'
. '<tr><td><strong>Dates</strong></td><td>' . esc($dateList ?: '-') . '</td></tr>'
. '<tr><td><strong>Submitted At</strong></td><td>' . esc($submittedAt) . '</td></tr>'
. '</table>'
. '</div>';
$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 .= '<p style="margin-top:16px;">Click <a href="' . esc($notifyUrl) . '">Send confirmation email to '
. esc($fullName) . '</a> so the staff member is notified automatically. '
. 'This link expires in 14 days.</p>';
$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 = "<p>Outstanding items: {$missingText}.</p>";
} else {
$missingNote = "<p>Our records show no outstanding submissions for this section, but please verify if anything still needs attention.</p>";
}
$subject = "Reminder: Complete submissions for {$sectionName}";
$progressNote = '';
if (in_array('class progress', $missingItems, true)) {
$progressNote = "<p>Class progress submissions can be updated at <a href=\"{$progressUrl}\">Teacher Progress History</a>.</p>";
}
$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 = "<p>" . ucfirst($draftLabel) . " submissions can be updated at <a href=\"{$examDraftUrl}\">Teacher Exam Drafts</a>.</p>"
. $examDraftDeadlineEmailHtml;
}
$homeworkNote = '';
if (in_array('homework', $missingItems, true)) {
$homeworkNote = "<p>Homework scores can be submitted at <a href=\"{$homeworkUrl}\">Teacher Homework</a>.</p>";
}
$hasScoreItems = (bool) array_intersect($missingItems, [
'midterm scores',
'midterm comments',
'final scores',
'final comments',
'participation',
'PTAP comments',
'homework',
]);
$nonScoreOnly = ! empty($missingItems) && ! $hasScoreItems;
$body = "<p>Dear {$teacherName},</p>"
. "<p>Administration is gently reminding you to wrap up any remaining "
. ($nonScoreOnly ? "submissions for {$sectionName}." : "score submissions, comments, and related items for {$sectionName}.")
. "</p>"
. $missingNote
. $progressNote
. $examDraftNote
. $homeworkNote
. ($nonScoreOnly ? '' : "<p>Visit <a href=\"{$scoreUrl}\">Teacher Score Submission</a> to address any remaining items.</p>")
. "<p>Thank you,<br>Al Rahma Administration</p>";
$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 '<p><strong>Exam draft submission deadline</strong> (<code>exam_draft_deadline</code>): '
. "<strong>{$display}</strong>"
. ($parsed !== null && $rawEsc !== $display ? " <span style=\"color:#555;\">(configured value: {$rawEsc})</span>" : '')
. '.</p>';
}
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 doesnt 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 <select>
'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.');
}
}
}