add feature do not show again job for parent portal
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Successful in 1m21s

This commit is contained in:
root
2026-09-03 00:02:07 -04:00
parent 46769e8b27
commit 02fd6e4863
6 changed files with 386 additions and 40 deletions
+1
View File
@@ -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');
+259 -38
View File
@@ -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');
@@ -0,0 +1,42 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddHideJobOpeningsPopupToPreferences extends Migration
{
public function up()
{
if (! $this->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');
}
}
}
+1
View File
@@ -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',
+1
View File
@@ -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
];
+82 -2
View File
@@ -71,14 +71,18 @@
<ul class="mb-0 ps-3">
<?php foreach ($notifications as $n): ?>
<li class="mb-2">
<strong><?= esc($n['title']) ?></strong><br>
<strong><?= esc($n['title'] ?? 'Notification') ?></strong><br>
<small class="text-muted">
<?= isset($n['created_at']) && strtotime($n['created_at'])
? esc(local_datetime($n['created_at'], 'm-d-Y H:i'))
: 'No date' ?>
</small>
<?php if (!empty($n['message'] ?? '')): ?>
<br>
<span><?= esc($n['message']) ?></span>
<?php endif; ?>
<br>
<span class="badge bg-light text-dark"><?= ucfirst($n['notification_type'] ?? 'broadcast') ?></span>
<span class="badge bg-light text-dark"><?= esc(ucfirst((string) ($n['notification_type'] ?? 'notice'))) ?></span>
</li>
<?php endforeach; ?>
</ul>
@@ -256,6 +260,20 @@
<?php endforeach; ?>
</div>
<div class="modal-footer">
<div class="form-check me-auto text-start">
<input
class="form-check-input"
type="checkbox"
value="1"
id="hideJobOpeningsPopup"
data-save-url="<?= esc(site_url('parent_dashboard/job-openings-popup')) ?>"
data-csrf-name="<?= esc(csrf_token()) ?>"
data-csrf-value="<?= esc(csrf_hash()) ?>">
<label class="form-check-label" for="hideJobOpeningsPopup">
Do not show again
</label>
<div class="small text-muted d-none" id="hideJobOpeningsPopupStatus"></div>
</div>
<a class="btn btn-outline-secondary" href="<?= site_url('careers') ?>">View all openings</a>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
@@ -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.';
}
}
});
});
</script>
<?= $this->endSection() ?>