diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 4b9cfaa..3a86e50 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -1290,6 +1290,7 @@ $routes->get('/landing_page/admin_dashboard', 'View\LandingPageController::admin $routes->get('/teacher_dashboard', 'View\LandingPageController::teacher', ['filter' => 'auth:teacher_dashboard,read']); $routes->get('/landing_page/student_dashboard', 'View\LandingPageController::student', ['filter' => 'auth:student_dashboard,read']); $routes->get('/parent_dashboard', 'View\LandingPageController::parentDashboard', ['filter' => 'auth:parent_dashboard,read']); +$routes->post('/parent_dashboard/job-openings-popup', 'View\LandingPageController::hideJobOpeningsPopup', ['filter' => 'auth:parent_dashboard,read']); $routes->get('/landing_page/guest_dashboard', 'View\LandingPageController::guest', ['filter' => 'auth:guest_dashboard,read']); $routes->get('/dashboard', 'View\LandingPageController::index'); $routes->get('/access_denied', 'ErrorController::accessDenied'); diff --git a/app/Controllers/View/LandingPageController.php b/app/Controllers/View/LandingPageController.php index c751061..fb0c398 100644 --- a/app/Controllers/View/LandingPageController.php +++ b/app/Controllers/View/LandingPageController.php @@ -14,6 +14,7 @@ use App\Models\AttendanceRecordModel; use App\Models\AttendanceDayModel; use App\Models\CalendarModel; use App\Models\JobPositionModel; +use App\Models\PreferencesModel; use \Config\Database; use DateTimeImmutable; use DateTimeZone; @@ -775,39 +776,6 @@ class LandingPageController extends BaseController } - // Fetch Notifications (only active, non-expired, non-deleted) - $notifications = $this->db->table('notifications') - ->select([ - 'notifications.id', - 'notifications.title', - 'notifications.message', - 'notifications.target_group', - 'notifications.created_at', - 'notifications.expires_at', - 'user_notifications.user_id', - "CASE - WHEN user_notifications.user_id IS NOT NULL THEN 'personal' - ELSE 'broadcast' - END as notification_type" - ]) - ->join( - 'user_notifications', - 'user_notifications.notification_id = notifications.id AND user_notifications.user_id = ' . (int) $parentId, - 'left' - ) - ->groupStart() - ->where('notifications.target_group', 'parent') - ->orWhere('user_notifications.user_id', $parentId) - ->groupEnd() - ->where('notifications.deleted_at IS NULL') // Exclude soft-deleted notifications - ->groupStart() - ->where('notifications.expires_at IS NULL') - ->orWhere('notifications.expires_at > NOW()') // Exclude expired - ->groupEnd() - ->orderBy('notifications.created_at', 'DESC') - ->get() - ->getResultArray(); - // Fetch Student Information (no filtering needed by school year or semester) $students = $this->db->table('students') @@ -823,6 +791,8 @@ class LandingPageController extends BaseController } unset($student); + $notifications = $this->parentDashboardNotifications((int) $parentId, (int) session()->get('user_id')); + // Fetch Attendance Records (filtered by most recent school year and semester) $attendanceData = $this->db->table('attendance_data') @@ -870,12 +840,14 @@ class LandingPageController extends BaseController $paymentBalance = (float) ($paymentRow['account_balance'] ?? 0); $openPositions = []; - try { - if ($this->db->tableExists('job_positions')) { - $openPositions = (new JobPositionModel())->openPositions(); + if (! $this->parentHidesJobOpeningsPopup($parentId)) { + try { + if ($this->db->tableExists('job_positions')) { + $openPositions = (new JobPositionModel())->openPositions(); + } + } catch (\Throwable $e) { + log_message('error', 'Unable to load parent dashboard job positions: ' . $e->getMessage()); } - } catch (\Throwable $e) { - log_message('error', 'Unable to load parent dashboard job positions: ' . $e->getMessage()); } // Pass data to the view, including the deadlines @@ -892,6 +864,255 @@ class LandingPageController extends BaseController ]); } + public function hideJobOpeningsPopup() + { + $userId = (int) (session()->get('user_id') ?? 0); + if ($userId <= 0) { + return $this->response->setStatusCode(401)->setJSON([ + 'ok' => false, + 'error' => 'Please log in to update this setting.', + 'csrf_token' => csrf_token(), + 'csrf_hash' => csrf_hash(), + ]); + } + + if ( + ! $this->db->tableExists('user_preferences') + || ! $this->db->fieldExists('hide_job_openings_popup', 'user_preferences') + ) { + return $this->response->setStatusCode(500)->setJSON([ + 'ok' => false, + 'error' => 'Preference storage is not ready.', + 'csrf_token' => csrf_token(), + 'csrf_hash' => csrf_hash(), + ]); + } + + $hide = (string) $this->request->getPost('hide_job_openings_popup') === '1' ? 1 : 0; + $preferencesModel = new PreferencesModel(); + $existing = $preferencesModel->where('user_id', $userId)->first(); + + if ($existing) { + $preferencesModel->update((int) $existing['id'], [ + 'hide_job_openings_popup' => $hide, + ]); + } else { + $preferencesModel->insert([ + 'user_id' => $userId, + 'hide_job_openings_popup' => $hide, + ]); + } + + return $this->response->setJSON([ + 'ok' => true, + 'hide_job_openings_popup' => $hide, + 'csrf_token' => csrf_token(), + 'csrf_hash' => csrf_hash(), + ]); + } + + private function parentHidesJobOpeningsPopup(int $parentId): bool + { + if ($parentId <= 0) { + return false; + } + + try { + if ( + ! $this->db->tableExists('user_preferences') + || ! $this->db->fieldExists('hide_job_openings_popup', 'user_preferences') + ) { + return false; + } + + $preferences = (new PreferencesModel())->where('user_id', $parentId)->first(); + + return ! empty($preferences['hide_job_openings_popup']); + } catch (\Throwable $e) { + log_message('error', 'Unable to load parent job openings popup preference: ' . $e->getMessage()); + + return false; + } + } + + private function parentDashboardNotifications(int $parentId, int $userId): array + { + $notifications = array_merge( + $this->parentAttendanceNotifications($parentId), + $this->activeParentBroadcastNotifications($parentId, $userId) + ); + + usort($notifications, static function (array $a, array $b): int { + return strtotime((string) ($b['created_at'] ?? '')) <=> strtotime((string) ($a['created_at'] ?? '')); + }); + + return array_slice($notifications, 0, 25); + } + + private function parentAttendanceNotifications(int $parentId): array + { + if ( + $parentId <= 0 + || ! $this->db->tableExists('parent_notifications') + || ! $this->db->tableExists('students') + ) { + return []; + } + + $rows = $this->db->table('parent_notifications pn') + ->select([ + 'pn.id', + 'pn.student_id', + 'pn.code', + 'pn.incident_date', + 'pn.channel', + 'pn.subject', + 'pn.status', + 'pn.response', + 'pn.semester', + 'pn.school_year', + 'pn.created_at', + 'pn.updated_at', + 'students.firstname', + 'students.lastname', + ]) + ->join('students', 'students.id = pn.student_id') + ->where('students.parent_id', $parentId) + ->where('pn.school_year', (string) $this->schoolYear) + ->where('pn.semester', (string) $this->semester) + ->orderBy('COALESCE(pn.updated_at, pn.created_at)', 'DESC', false) + ->orderBy('pn.id', 'DESC') + ->limit(100) + ->get() + ->getResultArray(); + + $notifications = []; + $seen = []; + + foreach ($rows as $row) { + $key = implode('|', [ + (string) ($row['student_id'] ?? ''), + (string) ($row['code'] ?? ''), + (string) ($row['incident_date'] ?? ''), + (string) ($row['subject'] ?? ''), + ]); + + if (isset($seen[$key])) { + continue; + } + + $seen[$key] = true; + $studentName = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')); + $code = strtoupper((string) ($row['code'] ?? '')); + $incidentDate = (string) ($row['incident_date'] ?? ''); + $subject = trim((string) ($row['subject'] ?? '')); + + $title = $subject !== '' ? $subject : $this->parentNotificationCodeLabel($code); + if ($studentName !== '') { + $title .= ' - ' . $studentName; + } + + $messageParts = []; + if ($incidentDate !== '') { + $messageParts[] = 'Incident date: ' . $incidentDate; + } + if (!empty($row['status'])) { + $messageParts[] = 'Status: ' . ucfirst((string) $row['status']); + } + + $notifications[] = [ + 'id' => $row['id'] ?? null, + 'title' => $title, + 'message' => implode(' | ', $messageParts), + 'created_at' => $row['updated_at'] ?: ($row['created_at'] ?? null), + 'notification_type' => $this->parentNotificationCodeLabel($code), + 'status' => $row['status'] ?? null, + 'code' => $code, + 'incident_date' => $incidentDate, + 'student_name' => $studentName, + ]; + + if (count($notifications) >= 25) { + break; + } + } + + return $notifications; + } + + private function activeParentBroadcastNotifications(int $parentId, int $userId): array + { + if ($parentId <= 0 || ! $this->db->tableExists('notifications')) { + return []; + } + + $builder = $this->db->table('notifications') + ->select([ + 'notifications.id', + 'notifications.title', + 'notifications.message', + 'notifications.target_group', + 'notifications.created_at', + 'notifications.expires_at', + "CASE + WHEN user_notifications.user_id IS NOT NULL THEN 'personal' + ELSE 'broadcast' + END as notification_type", + ]) + ->join( + 'user_notifications', + 'user_notifications.notification_id = notifications.id AND user_notifications.user_id IN (' . implode(',', array_unique([$parentId, $userId])) . ')', + 'left' + ) + ->groupStart() + ->whereIn('notifications.target_group', ['parent', 'everyone']) + ->orWhere('user_notifications.user_id IS NOT NULL') + ->groupEnd() + ->where('notifications.deleted_at IS NULL') + ->groupStart() + ->where('notifications.scheduled_at IS NULL') + ->orWhere('notifications.scheduled_at <=', utc_now()) + ->groupEnd() + ->groupStart() + ->where('notifications.expires_at IS NULL') + ->orWhere('notifications.expires_at >', utc_now()) + ->groupEnd(); + + if ($this->db->fieldExists('school_year', 'notifications')) { + $builder->where('notifications.school_year', (string) $this->schoolYear); + } + + if ($this->db->fieldExists('semester', 'notifications')) { + $builder->groupStart() + ->where('notifications.semester', null) + ->orWhere('notifications.semester', '') + ->orWhere('notifications.semester', (string) $this->semester) + ->groupEnd(); + } + + return $builder + ->groupBy('notifications.id') + ->orderBy('notifications.created_at', 'DESC') + ->limit(25) + ->get() + ->getResultArray(); + } + + private function parentNotificationCodeLabel(string $code): string + { + return match ($code) { + 'ABS_1' => 'Unreported absence', + 'ABS_2' => 'Repeated absences', + 'ABS_3' => 'Attendance warning', + 'ABS_4' => 'Attendance review', + 'LATE_2' => 'Repeated lateness', + 'LATE_3' => 'Lateness warning', + 'LATE_4' => 'Lateness review', + 'MIX_L2A1' => 'Attendance warning', + default => 'Parent notice', + }; + } + public function guest() { return view('/landing_page/guest_dashboard'); diff --git a/app/Database/Migrations/2026-09-02-000100_AddHideJobOpeningsPopupToPreferences.php b/app/Database/Migrations/2026-09-02-000100_AddHideJobOpeningsPopupToPreferences.php new file mode 100644 index 0000000..91b280b --- /dev/null +++ b/app/Database/Migrations/2026-09-02-000100_AddHideJobOpeningsPopupToPreferences.php @@ -0,0 +1,42 @@ +db->tableExists('user_preferences')) { + return; + } + + if (! $this->db->fieldExists('hide_job_openings_popup', 'user_preferences')) { + $definition = [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + 'null' => false, + ]; + + if ($this->db->fieldExists('menu_custom_mode', 'user_preferences')) { + $definition['after'] = 'menu_custom_mode'; + } + + $this->forge->addColumn('user_preferences', [ + 'hide_job_openings_popup' => $definition, + ]); + } + } + + public function down() + { + if ( + $this->db->tableExists('user_preferences') + && $this->db->fieldExists('hide_job_openings_popup', 'user_preferences') + ) { + $this->forge->dropColumn('user_preferences', 'hide_job_openings_popup'); + } + } +} diff --git a/app/Filters/SchoolYearWritableFilter.php b/app/Filters/SchoolYearWritableFilter.php index 1363806..5bf1761 100644 --- a/app/Filters/SchoolYearWritableFilter.php +++ b/app/Filters/SchoolYearWritableFilter.php @@ -28,6 +28,7 @@ final class SchoolYearWritableFilter implements FilterInterface 'api/register', 'user/select_role', 'set-role', + 'parent_dashboard/job-openings-popup', 'processForgotPassword', 'user/forgot_password', 'user/processResetPassword', diff --git a/app/Models/PreferencesModel.php b/app/Models/PreferencesModel.php index f0b4c47..0ceb9d3 100644 --- a/app/Models/PreferencesModel.php +++ b/app/Models/PreferencesModel.php @@ -22,6 +22,7 @@ class PreferencesModel extends Model 'menu_custom_bg', // Custom menu background color 'menu_custom_text', // Custom menu text color 'menu_custom_mode', // Custom menu mode: light|dark + 'hide_job_openings_popup', // Parent dismissed volunteer openings popup 'created_at', // Timestamp of when the record was created 'updated_at' // Timestamp of when the record was last updated ]; diff --git a/app/Views/landing_page/parent_dashboard.php b/app/Views/landing_page/parent_dashboard.php index 9b4168e..a571367 100644 --- a/app/Views/landing_page/parent_dashboard.php +++ b/app/Views/landing_page/parent_dashboard.php @@ -71,14 +71,18 @@ @@ -256,6 +260,20 @@ @@ -276,6 +294,68 @@ } bootstrap.Modal.getOrCreateInstance(modalEl).show(); + + const checkbox = document.getElementById('hideJobOpeningsPopup'); + const statusEl = document.getElementById('hideJobOpeningsPopupStatus'); + if (!checkbox) { + return; + } + + checkbox.addEventListener('change', async function () { + if (!checkbox.checked) { + return; + } + + checkbox.disabled = true; + if (statusEl) { + statusEl.classList.remove('d-none', 'text-danger'); + statusEl.classList.add('text-muted'); + statusEl.textContent = 'Saving preference...'; + } + + const body = new FormData(); + let csrfName = checkbox.dataset.csrfName || ''; + let csrfValue = checkbox.dataset.csrfValue || ''; + body.append('hide_job_openings_popup', '1'); + if (csrfName && csrfValue) { + body.append(csrfName, csrfValue); + } + + try { + const response = await fetch(checkbox.dataset.saveUrl || '', { + method: 'POST', + headers: { + 'Accept': 'application/json', + ...(csrfValue ? { 'X-CSRF-TOKEN': csrfValue } : {}) + }, + body + }); + const data = await response.json().catch(function () { + return {}; + }); + + if (data.csrf_token && data.csrf_hash) { + checkbox.dataset.csrfName = data.csrf_token; + checkbox.dataset.csrfValue = data.csrf_hash; + } + + if (!response.ok || !data.ok) { + throw new Error(data.error || 'Unable to save preference.'); + } + + if (statusEl) { + statusEl.textContent = 'Saved.'; + } + } catch (error) { + checkbox.checked = false; + checkbox.disabled = false; + if (statusEl) { + statusEl.classList.remove('d-none', 'text-muted'); + statusEl.classList.add('text-danger'); + statusEl.textContent = error.message || 'Unable to save preference.'; + } + } + }); }); endSection() ?>