mirror of
https://github.com/itflow-org/itflow
synced 2026-11-11 07:01:16 +00:00
Rebuild notifications: bell panel and full page
- Bell opens a dropdown panel instead of a modal: Unread/All tabs, grouped by day, type icons, relative times, click-through marks read, per-row read/unread, mark all as read - Unread badge refreshes every minute while the tab is visible - Notifications page rebuilt on the same backend: search, type filter, date range, read/unread toggles, proper empty states - Hide notifications for clients/modules the user cannot access - Normalise relative notification links so they work from /admin pages - Remove the old notifications modal and dismiss handlers
This commit is contained in:
@@ -382,6 +382,52 @@ if (isset($_GET['get_active_clients'])) {
|
||||
echo json_encode($response);
|
||||
}
|
||||
|
||||
/*
|
||||
* The bell panel in the top bar (js/notifications.js).
|
||||
*
|
||||
* notifications_feed - the latest notifications for the panel, and the unread count
|
||||
* notifications_count - just the count, polled for the badge
|
||||
* notifications_mark - POST: mark ids read or unread, or all=1 for everything
|
||||
*
|
||||
* All of it goes through functions/notifications.php, which only ever reads or
|
||||
* changes the current user's own rows. Reading one is not audited; marking all
|
||||
* read is, as the old "Dismiss all" was.
|
||||
*/
|
||||
if (isset($_GET['notifications_feed'])) {
|
||||
$notifications_view = ($_GET['view'] ?? '') === 'all' ? 'all' : 'unread';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'unread_count' => notificationUnreadCount(),
|
||||
'items' => notificationsFetch($notifications_view, 20),
|
||||
], JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (isset($_GET['notifications_count'])) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['unread_count' => notificationUnreadCount()]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (isset($_POST['notifications_mark'])) {
|
||||
|
||||
validateCSRFToken();
|
||||
|
||||
if (!empty($_POST['all'])) {
|
||||
$notifications_changed = notificationsMarkAllRead();
|
||||
if ($notifications_changed) {
|
||||
logAudit("Notification", "Dismiss", "$session_name marked $notifications_changed notifications as read");
|
||||
}
|
||||
} else {
|
||||
notificationsMark($_POST['ids'] ?? [], ($_POST['state'] ?? 'read') !== 'unread');
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['unread_count' => notificationUnreadCount()]);
|
||||
exit;
|
||||
}
|
||||
|
||||
/*
|
||||
* Live results for the search palette in the top bar (js/global_search.js).
|
||||
*
|
||||
|
||||
@@ -112,7 +112,7 @@ function searchResultBadge($badge) {
|
||||
<p class="mb-0">
|
||||
Names, emails, phone numbers, IP and MAC addresses, serials, ticket, invoice and quote numbers -
|
||||
plus ticket conversations and document text.<br>
|
||||
Press <kbd>/</kbd> on any page to search without leaving it.
|
||||
Press <kbd>Ctrl</kbd> + <kbd>K</kbd> on any page to search without leaving it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,160 +1,192 @@
|
||||
<?php
|
||||
|
||||
// Default Column Sortby Filter
|
||||
$sort = "notification_timestamp";
|
||||
/*
|
||||
* All notifications - where "View all notifications" in the bell panel lands.
|
||||
*
|
||||
* Same rows, same rules and same row behaviour as the panel (functions/
|
||||
* notifications.php, js/notifications.js), with paging, search, a type filter
|
||||
* and a date range on top. Unread by default; "All" includes the read ones,
|
||||
* which nightly_tasks.php clears after 90 days.
|
||||
*/
|
||||
|
||||
// Newest first, always - filter_header.php reads this before the include
|
||||
$sort = "notification_id";
|
||||
$order = "DESC";
|
||||
|
||||
require_once "includes/inc_all.php";
|
||||
|
||||
// Dismissed Filter
|
||||
if (isset($_GET['dismissed'])) {
|
||||
$dismissed_query = 'AND notification_dismissed_at IS NOT NULL';
|
||||
$dismissed_filter = 1;
|
||||
} else {
|
||||
// Default - any
|
||||
$dismissed_query = 'AND notification_dismissed_at IS NULL';
|
||||
$dismissed_filter = 0;
|
||||
}
|
||||
// ?dismissed is the old "Dismissed notifications" link; those are the read ones now
|
||||
$view = (($_GET['view'] ?? '') === 'all' || isset($_GET['dismissed'])) ? 'all' : 'unread';
|
||||
|
||||
$type_filter = trim((string) ($_GET['type'] ?? ''));
|
||||
$type_query = $type_filter !== '' ? "AND notification_type = '" . escapeSql($type_filter) . "'" : '';
|
||||
$unread_query = $view === 'unread' ? 'AND notification_dismissed_at IS NULL' : '';
|
||||
|
||||
$sql = mysqli_query(
|
||||
$mysqli,
|
||||
"SELECT SQL_CALC_FOUND_ROWS client_id, client_name, notification, notification_dismissed_at, notification_id,
|
||||
notification_timestamp, notification_type FROM notifications
|
||||
"SELECT SQL_CALC_FOUND_ROWS notification_id, notification_type, notification, notification_action,
|
||||
notification_timestamp, notification_dismissed_at, notification_client_id, client_name
|
||||
FROM notifications
|
||||
LEFT JOIN clients ON notification_client_id = client_id
|
||||
WHERE (notification_type LIKE '%$q%' OR notification LIKE '%$q%')
|
||||
WHERE " . notificationVisibilitySql() . "
|
||||
$unread_query
|
||||
$type_query
|
||||
AND (notification_type LIKE '%$q%' OR notification LIKE '%$q%' OR client_name LIKE '%$q%')
|
||||
AND DATE(notification_timestamp) BETWEEN '$dtf' AND '$dtt'
|
||||
AND notification_user_id = $session_user_id
|
||||
$dismissed_query
|
||||
ORDER BY $sort $order
|
||||
LIMIT $record_from, $record_to
|
||||
");
|
||||
ORDER BY notification_id DESC
|
||||
LIMIT $record_from, $record_to"
|
||||
);
|
||||
|
||||
$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
|
||||
|
||||
$unread_count = notificationUnreadCount();
|
||||
|
||||
$sql_types = mysqli_query($mysqli, "SELECT DISTINCT notification_type FROM notifications WHERE " . notificationVisibilitySql() . " ORDER BY notification_type");
|
||||
|
||||
// Links that keep every other filter and change one thing
|
||||
function notificationsPageUrl($changes) {
|
||||
$params = array_merge($_GET, $changes);
|
||||
unset($params['page'], $params['dismissed']);
|
||||
$params = array_filter($params, function ($value) {
|
||||
return $value !== null && $value !== '';
|
||||
});
|
||||
return 'notifications.php' . ($params ? '?' . http_build_query($params) : '');
|
||||
}
|
||||
|
||||
function notificationsDayLabel($timestamp) {
|
||||
$day = date('Y-m-d', $timestamp);
|
||||
if ($day === date('Y-m-d')) {
|
||||
return 'Today';
|
||||
}
|
||||
if ($day === date('Y-m-d', strtotime('-1 day'))) {
|
||||
return 'Yesterday';
|
||||
}
|
||||
return date('l, F j' . (date('Y', $timestamp) === date('Y') ? '' : ', Y'), $timestamp);
|
||||
}
|
||||
|
||||
$notifications_date_filtered = $dtf !== '1970-01-01' || $dtt !== '2099-12-31';
|
||||
$notifications_filtered = $q !== '' || $type_filter !== '' || $notifications_date_filtered;
|
||||
|
||||
// This page draws its own empty state - "all caught up" is not "nothing added yet"
|
||||
$empty_state_skip = true;
|
||||
|
||||
?>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header bg-dark py-2">
|
||||
<h3 class="card-title mt-2">
|
||||
<i class="fas fa-fw fa-bell me-2"></i><?php if($dismissed_filter) { echo "Dismissed "; } ?>Notifications
|
||||
</h3>
|
||||
<h3 class="card-title mt-2"><i class="fas fa-fw fa-bell me-2"></i>Notifications</h3>
|
||||
<div class="card-tools">
|
||||
<?php if($dismissed_filter) { ?>
|
||||
<a href="notifications.php" class="btn btn-primary"><i class="fas fa-fw fa-history me-2"></i>Dismissed</a>
|
||||
<?php } else { ?>
|
||||
<a href="notifications.php?dismissed" class="btn btn-outline-secondary"><i class="fas fa-fw fa-history me-2"></i>Dismissed</a>
|
||||
<?php } ?>
|
||||
<button type="button" class="btn btn-light" data-notify-all data-notify-reload <?= $unread_count ? '' : 'disabled' ?>>
|
||||
<i class="fas fa-check-double me-2"></i>Mark all as read
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-header py-3">
|
||||
<form autocomplete="off">
|
||||
<?php if ($dismissed_filter) { ?>
|
||||
<input type="hidden" name="dismissed" value="">
|
||||
<?php } ?>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-sm-4">
|
||||
<input type="hidden" name="view" value="<?= $view ?>">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-md-5">
|
||||
<div class="input-group">
|
||||
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(escapeHtml($q)); } ?>" placeholder="Search <?php if($dismissed_filter) { echo "Dismissed "; } ?>Notifications">
|
||||
<button class="btn btn-primary text-strong" title="Search"><i class="fa fa-search"></i></button>
|
||||
<button class="btn btn-secondary" title="More filters" type="button" data-bs-toggle="collapse" data-bs-target="#advancedFilter"><i class="fas fa-filter"></i></button>
|
||||
<input type="search" class="form-control" name="q" value="<?= escapeHtml(stripslashes($q)) ?>" placeholder="Search notifications">
|
||||
<button class="btn btn-primary" title="Search"><i class="fa fa-search"></i></button>
|
||||
<button class="btn btn-secondary" title="More filters" type="button" data-bs-toggle="collapse" data-bs-target="#advancedFilter"><i class="fas fa-filter"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-8">
|
||||
|
||||
|
||||
<div class="col-md-3">
|
||||
<select class="form-select" name="type" onchange="this.form.submit()" aria-label="Notification type">
|
||||
<option value="">All types</option>
|
||||
<?php while ($row = mysqli_fetch_assoc($sql_types)) {
|
||||
$type_option = escapeHtml($row['notification_type']); ?>
|
||||
<option <?php if ($type_filter === $row['notification_type']) { echo "selected"; } ?>><?= $type_option ?></option>
|
||||
<?php } ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="btn-group float-end" role="group" aria-label="Show">
|
||||
<a href="<?= escapeHtml(notificationsPageUrl(['view' => null])) ?>" class="btn <?= $view === 'unread' ? 'btn-primary' : 'btn-outline-primary' ?>">
|
||||
Unread <span class="badge rounded-pill text-bg-light ms-1" data-notify-count <?= $unread_count ? '' : 'hidden' ?>><?= $unread_count > 99 ? '99+' : $unread_count ?></span>
|
||||
</a>
|
||||
<a href="<?= escapeHtml(notificationsPageUrl(['view' => 'all'])) ?>" class="btn <?= $view === 'all' ? 'btn-primary' : 'btn-outline-primary' ?>">All</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse mt-3 <?php if (!empty($_GET['dtf'])) { echo "show"; } ?>" id="advancedFilter">
|
||||
<div class="row">
|
||||
<div class="col-md-2">
|
||||
<div class="mb-3">
|
||||
<label>Date From</label>
|
||||
<input type="date" class="form-control" name="dtf" max="2999-12-31" value="<?= escapeHtml($dtf) ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="mb-3">
|
||||
<label>Date To</label>
|
||||
<input type="date" class="form-control" name="dtt" max="2999-12-31" value="<?= escapeHtml($dtt) ?>">
|
||||
</div>
|
||||
<div class="collapse mt-3 <?php if ($notifications_date_filtered) { echo "show"; } ?>" id="advancedFilter">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Date range</label>
|
||||
<input type="text" id="dateFilter" class="form-control" autocomplete="off">
|
||||
<input type="hidden" name="canned_date" id="canned_date" value="<?= escapeHtml($_GET['canned_date'] ?? '') ?>">
|
||||
<input type="hidden" name="dtf" id="dtf" value="<?= escapeHtml($dtf ?? '') ?>">
|
||||
<input type="hidden" name="dtt" id="dtt" value="<?= escapeHtml($dtt ?? '') ?>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive-sm">
|
||||
<table class="table table-striped table-borderless table-hover mb-0">
|
||||
<thead class="text-dark <?php if ($num_rows[0] == 0) { echo "d-none"; } ?>">
|
||||
<tr>
|
||||
<th class="ps-3">
|
||||
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=notification_timestamp&order=<?= $disp ?>">
|
||||
Timestamp <?php if ($sort == 'notification_timestamp') { echo $order_icon; } ?>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=notification_type&order=<?= $disp ?>">
|
||||
Type <?php if ($sort == 'notification_type') { echo $order_icon; } ?>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=notification&order=<?= $disp ?>">
|
||||
Notification <?php if ($sort == 'notification') { echo $order_icon; } ?>
|
||||
</a>
|
||||
</th>
|
||||
<?php if($dismissed_filter) { ?>
|
||||
<th>
|
||||
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=notification_dismissed_at&order=<?= $disp ?>">
|
||||
Dismissed At <?php if ($sort == 'notification_dismissed_at') { echo $order_icon; } ?>
|
||||
</a>
|
||||
</th>
|
||||
<?php } ?>
|
||||
<?php if(!$dismissed_filter) { ?>
|
||||
<th class="text-center p-0">
|
||||
<?php if (mysqli_num_rows($sql) > 0) { ?>
|
||||
<a href="post.php?dismiss_all_notifications&csrf_token=<?= $_SESSION["csrf_token"] ?>"
|
||||
class="btn btn-sm btn-dark mb-2" title="Dismiss All">
|
||||
<i class="fas fa-fw fa-check-double"></i>
|
||||
</a>
|
||||
<?php } ?>
|
||||
</th>
|
||||
<?php } ?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ($num_rows[0] == 0) { ?>
|
||||
|
||||
<div class="itflow-notify-empty py-5">
|
||||
<?php if ($notifications_filtered) { ?>
|
||||
<i class="fas fa-filter fa-3x mb-3 d-block" aria-hidden="true"></i>
|
||||
<h5>No notifications match the current filters</h5>
|
||||
<a href="<?= escapeHtml('notifications.php' . ($view === 'all' ? '?view=all' : '')) ?>" class="btn btn-sm btn-outline-secondary mt-2">
|
||||
<i class="fas fa-times me-2"></i>Clear filters
|
||||
</a>
|
||||
<?php } elseif ($view === 'unread') { ?>
|
||||
<i class="fas fa-check-circle fa-3x mb-3 d-block text-success" aria-hidden="true"></i>
|
||||
<h5>You're all caught up</h5>
|
||||
<p class="mb-2">Nothing new since you last looked.</p>
|
||||
<a href="notifications.php?view=all" class="btn btn-sm btn-outline-secondary">See all notifications</a>
|
||||
<?php } else { ?>
|
||||
<i class="fas fa-bell-slash fa-3x mb-3 d-block" aria-hidden="true"></i>
|
||||
<h5>No notifications yet</h5>
|
||||
<?php } ?>
|
||||
</div>
|
||||
|
||||
<?php } else { ?>
|
||||
|
||||
<div class="itflow-notify-page" role="list" aria-label="Notifications">
|
||||
<?php
|
||||
|
||||
$notification_day = null;
|
||||
while ($row = mysqli_fetch_assoc($sql)) {
|
||||
$notification_id = intval($row['notification_id']);
|
||||
$notification_timestamp = escapeHtml($row['notification_timestamp']);
|
||||
$notification_type = escapeHtml($row['notification_type']);
|
||||
$notification = escapeHtml($row['notification']);
|
||||
$notification_dismissed_at = escapeHtml($row['notification_dismissed_at']);
|
||||
$client_name = escapeHtml($row['client_name']);
|
||||
$client_id = intval($row['client_id']);
|
||||
$item = notificationItem($row);
|
||||
$item_timestamp = strtotime($row['notification_timestamp']);
|
||||
$item_day = notificationsDayLabel($item_timestamp);
|
||||
$item_tone = in_array($item['tone'], ['primary', 'secondary', 'success', 'danger', 'warning', 'info'], true) ? $item['tone'] : 'secondary';
|
||||
|
||||
?>
|
||||
<tr>
|
||||
<td class="font-monospace ps-3"><?= $notification_timestamp ?></td>
|
||||
<td><?= $notification_type ?></td>
|
||||
<td><?= $notification ?></td>
|
||||
<?php if($dismissed_filter) { ?>
|
||||
<td class="font-monospace"><?= $notification_dismissed_at ?></td>
|
||||
if ($item_day !== $notification_day) {
|
||||
$notification_day = $item_day; ?>
|
||||
<div class="itflow-notify-group" role="presentation"><?= escapeHtml($item_day) ?></div>
|
||||
<?php } ?>
|
||||
<?php if(!$dismissed_filter) { ?>
|
||||
<td class="text-center"><a class="btn btn-secondary btn-sm" href="post.php?dismiss_notification=<?= $notification_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>" title="Dismiss"><i class="fas fa-check"></i></a></td>
|
||||
<?php } ?>
|
||||
</tr>
|
||||
|
||||
<div class="itflow-notify-item <?= $item['unread'] ? 'is-unread' : '' ?>" role="listitem" data-id="<?= $item['id'] ?>">
|
||||
<a class="itflow-notify-link" href="<?= $item['url'] ? escapeHtml($item['url']) : '#' ?>"<?= $item['url'] ? '' : ' data-no-link="1"' ?>>
|
||||
<span class="itflow-notify-icon bg-<?= $item_tone ?>-subtle text-<?= $item_tone ?>-emphasis"><i class="fas fa-fw fa-<?= escapeHtml($item['icon']) ?>" aria-hidden="true"></i></span>
|
||||
<span class="itflow-notify-body">
|
||||
<span class="itflow-notify-top">
|
||||
<span class="itflow-notify-type"><?= escapeHtml($item['type']) ?></span>
|
||||
<time class="itflow-notify-time" datetime="<?= escapeHtml($item['time']) ?>" title="<?= escapeHtml($item['time_full']) ?>"><?= date('g:i A', $item_timestamp) ?></time>
|
||||
</span>
|
||||
<span class="itflow-notify-text"><?= escapeHtml($item['text']) ?></span>
|
||||
<?php if ($item['client']) { ?>
|
||||
<span class="itflow-notify-client"><i class="fas fa-fw fa-building" aria-hidden="true"></i><?= escapeHtml($item['client']) ?></span>
|
||||
<?php } ?>
|
||||
</span>
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm itflow-notify-toggle" title="<?= $item['unread'] ? 'Mark as read' : 'Mark as unread' ?>" aria-label="<?= $item['unread'] ? 'Mark as read' : 'Mark as unread' ?>">
|
||||
<i class="fas fa-fw fa-<?= $item['unread'] ? 'check' : 'undo' ?>" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<?php } ?>
|
||||
</div>
|
||||
|
||||
<?php } ?>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php require_once "../includes/filter_footer.php"; ?>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
|
||||
require_once "../includes/footer.php";
|
||||
|
||||
@@ -1571,7 +1571,7 @@ html {
|
||||
|
||||
/* Search palette - js/global_search.js
|
||||
|
||||
Opens over the page on / or Ctrl+K. Sits high rather than centred, so the
|
||||
Opens over the page on Ctrl+K. Sits high rather than centred, so the
|
||||
results grow downwards from the box instead of the box jumping as they
|
||||
arrive. Everything takes its colour from the Bootstrap variables, so dark
|
||||
mode needs nothing extra; the highlighted row uses the theme accent. */
|
||||
@@ -1763,7 +1763,7 @@ html {
|
||||
}
|
||||
}
|
||||
|
||||
/* The / hint inside the top bar search box. Pointer events off so a click on
|
||||
/* The Ctrl K hint inside the top bar search box. Pointer events off so a click on
|
||||
it still lands on the box underneath and opens the palette. */
|
||||
.navbar-search .navbar-search-field .navbar-search-kbd {
|
||||
position: absolute;
|
||||
@@ -1793,3 +1793,207 @@ html {
|
||||
.global-search-group {
|
||||
scroll-margin-top: 4rem;
|
||||
}
|
||||
|
||||
/* Notifications - js/notifications.js, the bell panel and agent/notifications.php
|
||||
|
||||
One row design for both. The icon tile takes Bootstrap's subtle colour
|
||||
utilities (bg-*-subtle / text-*-emphasis), so each tone already has a dark
|
||||
mode version. Unread rows carry a dot and a heavier title; read rows step
|
||||
back. The read / unread button shows on hover and keyboard focus, and
|
||||
always on touch screens, where there is no hover. */
|
||||
.itflow-notify-panel {
|
||||
width: min(25rem, calc(100vw - 1rem));
|
||||
overflow: hidden;
|
||||
box-shadow: 0 .75rem 2rem rgba(0, 0, 0, .25);
|
||||
}
|
||||
.itflow-notify-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: .75rem 1rem .25rem;
|
||||
}
|
||||
.itflow-notify-tabs {
|
||||
gap: 1rem;
|
||||
padding: 0 1rem;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
font-size: .875rem;
|
||||
}
|
||||
.itflow-notify-tabs .nav-link {
|
||||
padding: .4rem 0;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
.itflow-notify-tabs .nav-link.active {
|
||||
color: var(--bs-emphasis-color);
|
||||
border-bottom-color: var(--itflow-accent, #007bff);
|
||||
}
|
||||
.itflow-notify-list {
|
||||
max-height: min(65vh, 32rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.itflow-notify-group {
|
||||
padding: .6rem 1rem .2rem;
|
||||
font-size: .7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
.itflow-notify-item {
|
||||
position: relative;
|
||||
}
|
||||
.itflow-notify-item .itflow-notify-link {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: .75rem;
|
||||
padding: .6rem 2.75rem .6rem 1.75rem;
|
||||
white-space: normal;
|
||||
color: var(--bs-body-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
.itflow-notify-item .itflow-notify-link:hover,
|
||||
.itflow-notify-item .itflow-notify-link:focus {
|
||||
color: var(--bs-body-color);
|
||||
background-color: rgba(var(--bs-emphasis-color-rgb), .05);
|
||||
}
|
||||
.itflow-notify-icon {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
font-size: .85rem;
|
||||
}
|
||||
.itflow-notify-body {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.itflow-notify-top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: .5rem;
|
||||
}
|
||||
.itflow-notify-type {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: .875rem;
|
||||
}
|
||||
.itflow-notify-time {
|
||||
flex: 0 0 auto;
|
||||
font-size: .75rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
.itflow-notify-text {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
font-size: .8125rem;
|
||||
color: var(--bs-secondary-color);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.itflow-notify-client {
|
||||
margin-top: .15rem;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: .75rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
.itflow-notify-client .fa-fw {
|
||||
margin-right: .25rem;
|
||||
opacity: .7;
|
||||
}
|
||||
.itflow-notify-item.is-unread .itflow-notify-type {
|
||||
font-weight: 600;
|
||||
color: var(--bs-emphasis-color);
|
||||
}
|
||||
.itflow-notify-item.is-unread::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 1.35rem;
|
||||
left: .7rem;
|
||||
width: .5rem;
|
||||
height: .5rem;
|
||||
border-radius: 50%;
|
||||
background-color: var(--itflow-accent, #007bff);
|
||||
}
|
||||
.itflow-notify-item:not(.is-unread) .itflow-notify-icon {
|
||||
opacity: .6;
|
||||
}
|
||||
.itflow-notify-toggle {
|
||||
position: absolute;
|
||||
top: .55rem;
|
||||
right: .5rem;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: var(--bs-secondary-color);
|
||||
opacity: 0;
|
||||
}
|
||||
.itflow-notify-toggle:hover,
|
||||
.itflow-notify-toggle:focus-visible {
|
||||
color: var(--bs-emphasis-color);
|
||||
background-color: rgba(var(--bs-emphasis-color-rgb), .1);
|
||||
}
|
||||
.itflow-notify-item:hover .itflow-notify-toggle,
|
||||
.itflow-notify-item:focus-within .itflow-notify-toggle {
|
||||
opacity: 1;
|
||||
}
|
||||
@media (hover: none) {
|
||||
.itflow-notify-toggle {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.itflow-notify-empty {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
.itflow-notify-foot {
|
||||
display: block;
|
||||
padding: .6rem 1rem;
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
font-size: .875rem;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
.itflow-notify-foot:hover {
|
||||
background-color: rgba(var(--bs-emphasis-color-rgb), .05);
|
||||
}
|
||||
|
||||
/* The full page lists the same rows inside a card */
|
||||
.itflow-notify-page .itflow-notify-item + .itflow-notify-item {
|
||||
border-top: 1px solid var(--bs-border-color-translucent);
|
||||
}
|
||||
.itflow-notify-page .itflow-notify-link {
|
||||
padding-top: .75rem;
|
||||
padding-bottom: .75rem;
|
||||
}
|
||||
.itflow-notify-page .itflow-notify-text {
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
.itflow-notify-page .itflow-notify-group {
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
border-top: 1px solid var(--bs-border-color-translucent);
|
||||
}
|
||||
/* Bootstrap does not use Popper for dropdowns inside a navbar, so the panel is
|
||||
pinned to the bell's right edge and a phone screen cuts off its left side.
|
||||
On narrow screens it spans the width under the top bar instead. */
|
||||
@media (max-width: 575.98px) {
|
||||
.app-header .navbar-nav .itflow-notify-panel[data-bs-popper] {
|
||||
position: fixed;
|
||||
top: 3rem;
|
||||
right: .5rem;
|
||||
left: .5rem;
|
||||
width: auto;
|
||||
margin-top: .25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,3 +28,4 @@ require_once __DIR__ . '/functions/export.php';
|
||||
require_once __DIR__ . '/functions/calendar.php';
|
||||
require_once __DIR__ . '/functions/backup.php';
|
||||
require_once __DIR__ . '/functions/search.php';
|
||||
require_once __DIR__ . '/functions/notifications.php';
|
||||
|
||||
218
functions/notifications.php
Normal file
218
functions/notifications.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* In-app notifications - reading and marking them.
|
||||
*
|
||||
* Writing them is appNotify() in functions/logging.php. Everything that reads
|
||||
* them goes through here: the bell panel in the top bar (js/notifications.js,
|
||||
* via agent/ajax.php) and the full page, agent/notifications.php. Both use the
|
||||
* same visibility rule, so the badge count, the panel and the page always agree.
|
||||
*
|
||||
* "Read" is notification_dismissed_at being set. The column predates the read /
|
||||
* unread wording; nightly_tasks.php still clears read notifications after 90 days.
|
||||
*
|
||||
* Values come back RAW, not HTML-escaped. The panel renders them as text, the
|
||||
* page escapes them at output.
|
||||
*/
|
||||
|
||||
/*
|
||||
* appNotify() sends to every active agent, so a notification about a client's
|
||||
* invoice reaches people who cannot open that invoice. These are the types that
|
||||
* belong to a module; a user without read access to the module does not see them.
|
||||
* Types not listed (mail failures, backups, updates, settings) stay visible to all,
|
||||
* as before.
|
||||
*/
|
||||
function notificationTypeModules() {
|
||||
return [
|
||||
'module_sales' => [
|
||||
'Invoice', 'Invoice Paid', 'Invoice Viewed', 'Invoice Overdue', 'Invoice Late Charge',
|
||||
'Draft Invoices', 'Recurring Sent', 'Recurring Generated',
|
||||
'Quote Viewed', 'Quote Accepted', 'Quote Declined', 'Quote File',
|
||||
],
|
||||
'module_financial' => ['Expense', 'Expense Created'],
|
||||
'module_support' => [
|
||||
'Ticket', 'Ticket SLA', 'Pending Tickets', 'Feedback', 'Recurring Ticket',
|
||||
'Domain Expiring', 'Certificate Expiring', 'Asset Warranty Expiring',
|
||||
],
|
||||
'module_client' => ['Share Viewed'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The WHERE conditions for "notifications this user may see": their own, for a
|
||||
* client they can access (or no client), and of a type their role can read.
|
||||
*/
|
||||
function notificationVisibilitySql() {
|
||||
global $session_user_id;
|
||||
|
||||
$sql = 'notification_user_id = ' . intval($session_user_id) . clientScopeSql('notification_client_id');
|
||||
|
||||
foreach (notificationTypeModules() as $module => $types) {
|
||||
if (lookupUserPermission($module) < 1) {
|
||||
$sql .= " AND notification_type NOT IN ('" . implode("','", array_map('escapeSql', $types)) . "')";
|
||||
}
|
||||
}
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
/** Icon and colour for a notification type, matched on its wording. */
|
||||
function notificationTypeStyle($type) {
|
||||
$t = strtolower((string) $type);
|
||||
|
||||
$rules = [
|
||||
// [words in the type, icon, tone]
|
||||
[['sla'], 'stopwatch', 'danger'],
|
||||
[['failed', 'fail'], 'exclamation-circle', 'danger'],
|
||||
[['declined'], 'times-circle', 'danger'],
|
||||
[['feedback'], 'thumbs-down', 'danger'],
|
||||
[['paid'], 'check-circle', 'success'],
|
||||
[['accepted'], 'check-circle', 'success'],
|
||||
[['overdue', 'late charge'], 'exclamation-triangle', 'warning'],
|
||||
[['expiring'], 'hourglass-half', 'warning'],
|
||||
[['viewed'], 'eye', 'info'],
|
||||
[['quote'], 'comment-dollar', 'primary'],
|
||||
[['invoice', 'recurring sent', 'recurring generated'], 'file-invoice-dollar', 'primary'],
|
||||
[['expense'], 'receipt', 'secondary'],
|
||||
[['ticket'], 'life-ring', 'primary'],
|
||||
[['mail'], 'envelope', 'danger'],
|
||||
[['backup'], 'database', 'secondary'],
|
||||
[['master key'], 'key', 'warning'],
|
||||
[['update'], 'download', 'info'],
|
||||
[['share'], 'share-square', 'info'],
|
||||
[['settings'], 'cog', 'secondary'],
|
||||
];
|
||||
|
||||
foreach ($rules as [$words, $icon, $tone]) {
|
||||
foreach ($words as $word) {
|
||||
if (strpos($t, $word) !== false) {
|
||||
return ['icon' => $icon, 'tone' => $tone];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['icon' => 'bell', 'tone' => 'secondary'];
|
||||
}
|
||||
|
||||
/*
|
||||
* Where clicking a notification goes. Stored actions are a mix of
|
||||
* "/agent/ticket.php?..." and a bare "ticket.php?..." that only worked from
|
||||
* pages under /agent/. Only same-site paths are returned - anything with a
|
||||
* scheme or host comes back as null, which renders as "no link".
|
||||
*/
|
||||
function notificationActionUrl($action) {
|
||||
$action = trim((string) $action);
|
||||
|
||||
if ($action === '' || $action === '#' || strtoupper($action) === 'NULL') {
|
||||
return null;
|
||||
}
|
||||
if (preg_match('#^[a-z][a-z0-9+.-]*:#i', $action) || strpos($action, '//') === 0 || strpos($action, '\\') !== false) {
|
||||
return null;
|
||||
}
|
||||
if ($action[0] !== '/') {
|
||||
$action = '/agent/' . $action;
|
||||
}
|
||||
|
||||
return $action;
|
||||
}
|
||||
|
||||
function notificationItem($row) {
|
||||
$style = notificationTypeStyle($row['notification_type']);
|
||||
|
||||
// Backups, updates and the like share one type for good news and bad;
|
||||
// the wording is what says it went wrong
|
||||
if (preg_match('/\bfail(ed|ure|s)?\b/i', (string) $row['notification'])) {
|
||||
$style['tone'] = 'danger';
|
||||
}
|
||||
|
||||
$timestamp = strtotime($row['notification_timestamp']);
|
||||
|
||||
return [
|
||||
'id' => intval($row['notification_id']),
|
||||
'type' => $row['notification_type'],
|
||||
'text' => $row['notification'],
|
||||
'url' => notificationActionUrl($row['notification_action']),
|
||||
'client' => $row['client_name'] ?: null,
|
||||
'client_id' => intval($row['notification_client_id']),
|
||||
'unread' => $row['notification_dismissed_at'] === null,
|
||||
'time' => $timestamp ? date('c', $timestamp) : null,
|
||||
'time_full' => $timestamp ? date('D, M j Y g:i A', $timestamp) : '',
|
||||
'icon' => $style['icon'],
|
||||
'tone' => $style['tone'],
|
||||
];
|
||||
}
|
||||
|
||||
function notificationUnreadCount() {
|
||||
global $mysqli;
|
||||
|
||||
$row = mysqli_fetch_row(mysqli_query(
|
||||
$mysqli,
|
||||
"SELECT COUNT(*) FROM notifications WHERE " . notificationVisibilitySql() . " AND notification_dismissed_at IS NULL"
|
||||
));
|
||||
|
||||
return intval($row[0]);
|
||||
}
|
||||
|
||||
/** Newest first. $view is 'unread' or 'all'. */
|
||||
function notificationsFetch($view = 'unread', $limit = 20) {
|
||||
global $mysqli;
|
||||
|
||||
$limit = max(1, min(100, intval($limit)));
|
||||
$unread_only = $view === 'unread' ? 'AND notification_dismissed_at IS NULL' : '';
|
||||
|
||||
$sql = mysqli_query(
|
||||
$mysqli,
|
||||
"SELECT notification_id, notification_type, notification, notification_action, notification_timestamp,
|
||||
notification_dismissed_at, notification_client_id, client_name
|
||||
FROM notifications
|
||||
LEFT JOIN clients ON notification_client_id = client_id
|
||||
WHERE " . notificationVisibilitySql() . " $unread_only
|
||||
ORDER BY notification_id DESC
|
||||
LIMIT $limit"
|
||||
);
|
||||
|
||||
$items = [];
|
||||
while ($row = mysqli_fetch_assoc($sql)) {
|
||||
$items[] = notificationItem($row);
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the given notifications read (or unread again). Only the current user's
|
||||
* own rows are touched, whatever ids are sent. Returns the number changed.
|
||||
*/
|
||||
function notificationsMark($ids, $read = true) {
|
||||
global $mysqli, $session_user_id;
|
||||
|
||||
$ids = array_values(array_filter(array_map('intval', (array) $ids)));
|
||||
if (!$ids) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$user_id = intval($session_user_id);
|
||||
$id_list = implode(',', $ids);
|
||||
|
||||
if ($read) {
|
||||
mysqli_query($mysqli, "UPDATE notifications SET notification_dismissed_at = NOW(), notification_dismissed_by = $user_id
|
||||
WHERE notification_user_id = $user_id AND notification_dismissed_at IS NULL AND notification_id IN ($id_list)");
|
||||
} else {
|
||||
mysqli_query($mysqli, "UPDATE notifications SET notification_dismissed_at = NULL, notification_dismissed_by = NULL
|
||||
WHERE notification_user_id = $user_id AND notification_dismissed_at IS NOT NULL AND notification_id IN ($id_list)");
|
||||
}
|
||||
|
||||
return mysqli_affected_rows($mysqli);
|
||||
}
|
||||
|
||||
/** Every unread notification of the current user, including any their role hides. */
|
||||
function notificationsMarkAllRead() {
|
||||
global $mysqli, $session_user_id;
|
||||
|
||||
$user_id = intval($session_user_id);
|
||||
|
||||
mysqli_query($mysqli, "UPDATE notifications SET notification_dismissed_at = NOW(), notification_dismissed_by = $user_id
|
||||
WHERE notification_user_id = $user_id AND notification_dismissed_at IS NULL");
|
||||
|
||||
return mysqli_affected_rows($mysqli);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* Global search.
|
||||
*
|
||||
* One search behind two surfaces:
|
||||
* - the live search palette in the top bar (press / or Ctrl+K), which asks
|
||||
* - the live search palette in the top bar (press Ctrl+K), which asks
|
||||
* agent/ajax.php?global_search as you type
|
||||
* - the full results page, agent/global_search.php, reached by pressing Enter
|
||||
*
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
<script src="/js/confirm_modal.js"></script>
|
||||
<script src="/js/date_filter.js"></script>
|
||||
<script src="/js/global_search.js"></script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
* with nothing to maintain.
|
||||
*/
|
||||
|
||||
if (!isset($num_rows) || intval($num_rows[0]) > 0) {
|
||||
// A page that draws its own empty state sets $empty_state_skip
|
||||
if (!isset($num_rows) || intval($num_rows[0]) > 0 || !empty($empty_state_skip)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,18 +9,18 @@
|
||||
</ul>
|
||||
|
||||
<!-- SEARCH - js/global_search.js turns this box into the opener for the
|
||||
search palette (also on / and Ctrl+K). Without the script it is still
|
||||
search palette (also on Ctrl+K). Without the script it is still
|
||||
a plain form posting to the full results page. -->
|
||||
<form class="navbar-search ms-auto" role="search" action="/agent/global_search.php">
|
||||
<label for="navbar-search-input" class="visually-hidden">Search everywhere</label>
|
||||
<div class="navbar-search-field">
|
||||
<input class="form-control" type="search" id="navbar-search-input" name="query"
|
||||
placeholder="Search everywhere" autocomplete="off" aria-keyshortcuts="/ Control+K"
|
||||
placeholder="Search everywhere" autocomplete="off" aria-keyshortcuts="Control+K Meta+K"
|
||||
value="<?php if (isset($_GET['query'])) { echo escapeHtml($_GET['query']); } ?>">
|
||||
<button class="navbar-search-submit" type="submit" aria-label="Submit search">
|
||||
<i class="fas fa-search" aria-hidden="true"></i>
|
||||
</button>
|
||||
<kbd class="navbar-search-kbd d-none d-lg-inline" aria-hidden="true">/</kbd>
|
||||
<kbd class="navbar-search-kbd d-none d-lg-inline" aria-hidden="true">Ctrl K</kbd>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -55,22 +55,20 @@
|
||||
<?php } ?>
|
||||
<!-- End Custom Nav Links -->
|
||||
|
||||
<!-- New Notifications Dropdown -->
|
||||
<?php
|
||||
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('notification_id') AS num FROM notifications WHERE notification_user_id = $session_user_id AND notification_dismissed_at IS NULL"));
|
||||
$num_notifications = $row['num'];
|
||||
<!-- Notifications - js/notifications.js fills the panel when the bell is
|
||||
opened and keeps the badge current. Without the script the bell is a
|
||||
plain link to the full page. The panel follows the page theme, not
|
||||
the always-dark navbar it sits in. -->
|
||||
<?php $num_notifications = notificationUnreadCount(); ?>
|
||||
|
||||
?>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link ajax-modal" href="#" data-modal-url="/modals/notifications.php">
|
||||
<i class="fas fa-bell"></i>
|
||||
<?php if ($num_notifications) { ?>
|
||||
<span class="badge bg-light text-dark rounded-pill navbar-badge">
|
||||
<?= $num_notifications ?>
|
||||
</span>
|
||||
<?php } ?>
|
||||
<li class="nav-item dropdown" id="itflowNotify" data-csrf="<?= $_SESSION['csrf_token'] ?>">
|
||||
<a class="nav-link" href="/agent/notifications.php" role="button" data-bs-toggle="dropdown" data-bs-auto-close="outside"
|
||||
aria-expanded="false" title="Notifications"
|
||||
aria-label="Notifications<?= $num_notifications ? ", $num_notifications unread" : '' ?>">
|
||||
<i class="fas fa-bell" aria-hidden="true"></i>
|
||||
<span class="badge bg-light text-dark rounded-pill navbar-badge" data-notify-badge <?= $num_notifications ? '' : 'hidden' ?>><?= $num_notifications > 99 ? '99+' : $num_notifications ?></span>
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end p-0 itflow-notify-panel" data-bs-theme="<?= $user_config_theme_dark ? 'dark' : 'light' ?>"></div>
|
||||
</li>
|
||||
|
||||
<li class="nav-item dropdown user-menu">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* ITFlow - the search palette.
|
||||
*
|
||||
* Press / (or Ctrl+K / Cmd+K) anywhere, or click the search box in the top bar,
|
||||
* Press Ctrl+K (Cmd+K on a Mac) anywhere, or click the search box in the top bar,
|
||||
* and a search window opens over the page. Results arrive as you type, grouped by
|
||||
* record type, from agent/ajax.php?global_search - the same search the full
|
||||
* results page runs (functions/search.php), so neither can show a record the
|
||||
@@ -211,7 +211,7 @@
|
||||
'<span><kbd>↑</kbd> <kbd>↓</kbd> to move</span>' +
|
||||
'<span><kbd>↵</kbd> to open</span>' +
|
||||
'<span><kbd class="itflow-search-mod"></kbd> + <kbd>↵</kbd> new tab</span>' +
|
||||
'<span class="ms-auto"><kbd>/</kbd> or <kbd class="itflow-search-mod"></kbd> + <kbd>K</kbd> from any page</span>' +
|
||||
'<span class="ms-auto"><kbd class="itflow-search-mod"></kbd> + <kbd>K</kbd> from any page</span>' +
|
||||
'</div>' +
|
||||
'<div class="visually-hidden" aria-live="polite"></div>' +
|
||||
'</div>' +
|
||||
@@ -243,9 +243,8 @@
|
||||
});
|
||||
|
||||
modalEl.addEventListener('hidden.bs.modal', function () {
|
||||
// Chrome can leave focus on the now-hidden box for a moment, and the
|
||||
// next / would count as typing in it. Hand focus back to where it was -
|
||||
// except the top bar box, which would just open the palette again.
|
||||
// Hand focus back to where it was rather than leave it on the now-hidden
|
||||
// box - except the top bar box, which would just open the palette again.
|
||||
input.blur();
|
||||
if (returnFocus && returnFocus !== navbarBox && document.contains(returnFocus) && typeof returnFocus.focus === 'function') {
|
||||
returnFocus.focus({ preventScroll: true });
|
||||
@@ -370,22 +369,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function isTyping(target) {
|
||||
if (!target || !(target instanceof Element) || (modalEl && modalEl.contains(target))) {
|
||||
return false;
|
||||
}
|
||||
if (target.isContentEditable || target.closest('.tox, [contenteditable="true"], [contenteditable=""]')) {
|
||||
return true;
|
||||
}
|
||||
if (target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') {
|
||||
return true;
|
||||
}
|
||||
if (target.tagName === 'INPUT') {
|
||||
return ['checkbox', 'radio', 'button', 'submit', 'reset', 'range', 'color', 'file'].indexOf(target.type) === -1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function anotherModalOpen() {
|
||||
return !!document.querySelector('.modal.show:not(#itflowSearch)');
|
||||
}
|
||||
@@ -404,12 +387,6 @@
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey && !isOpen() && !anotherModalOpen() && !isTyping(e.target)) {
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -614,6 +591,12 @@
|
||||
navbarBox = box;
|
||||
const form = box.form;
|
||||
|
||||
// The shortcut hint in the box: the markup says Ctrl K, a Mac gets Cmd
|
||||
const hint = form ? form.querySelector('.navbar-search-kbd') : null;
|
||||
if (hint) {
|
||||
hint.textContent = MOD_KEY + ' K';
|
||||
}
|
||||
|
||||
box.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault();
|
||||
open(box.value || undefined);
|
||||
|
||||
520
js/notifications.js
Normal file
520
js/notifications.js
Normal file
@@ -0,0 +1,520 @@
|
||||
/*
|
||||
* ITFlow - notifications.
|
||||
*
|
||||
* The bell in the top bar opens a panel (a Bootstrap dropdown) with the latest
|
||||
* notifications, Unread and All tabs, grouped by day. Clicking one opens what it
|
||||
* is about and marks it read; the button on each row flips read / unread; "Mark
|
||||
* all as read" clears the lot. The unread badge refreshes every minute while the
|
||||
* tab is visible, and straight away when you come back to it.
|
||||
*
|
||||
* The full page, agent/notifications.php, renders the same rows in PHP; the row
|
||||
* behaviour below is delegated on document so it covers both.
|
||||
*
|
||||
* Data comes from agent/ajax.php (functions/notifications.php) and is rendered
|
||||
* with textContent only - notification text carries client names, email
|
||||
* subjects and other user-entered values.
|
||||
*
|
||||
* Loaded once per page from includes/footer.php.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (window.itflowNotifications || typeof bootstrap === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const ENDPOINT = '/agent/ajax.php';
|
||||
const POLL_MS = 60000;
|
||||
const TONES = ['primary', 'secondary', 'success', 'danger', 'warning', 'info'];
|
||||
|
||||
const root = document.getElementById('itflowNotify');
|
||||
const csrf = root ? root.dataset.csrf : '';
|
||||
|
||||
let panel = null;
|
||||
let list = null;
|
||||
let view = 'unread';
|
||||
let loadSeq = 0;
|
||||
let lastPoll = 0;
|
||||
let pollingStopped = false;
|
||||
|
||||
/* ---------- small helpers ---------- */
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) {
|
||||
node.className = className;
|
||||
}
|
||||
if (text !== undefined && text !== null) {
|
||||
node.textContent = text;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function icon(name) {
|
||||
const i = el('i', 'fas fa-fw fa-' + (/^[a-z0-9-]+$/.test(name || '') ? name : 'bell'));
|
||||
i.setAttribute('aria-hidden', 'true');
|
||||
return i;
|
||||
}
|
||||
|
||||
// Same-site paths only; the server already filters, this is belt and braces
|
||||
function safeUrl(url) {
|
||||
return typeof url === 'string' && url.charAt(0) === '/' && url.charAt(1) !== '/' && url.charAt(1) !== '\\' ? url : null;
|
||||
}
|
||||
|
||||
function sameDay(a, b) {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
function relativeTime(date) {
|
||||
const now = new Date();
|
||||
const minutes = Math.round((now - date) / 60000);
|
||||
if (minutes < 1) {
|
||||
return 'now';
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return minutes + 'm';
|
||||
}
|
||||
if (minutes < 24 * 60 && sameDay(date, now)) {
|
||||
return Math.floor(minutes / 60) + 'h';
|
||||
}
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
if (sameDay(date, yesterday)) {
|
||||
return 'Yesterday';
|
||||
}
|
||||
if (minutes < 6 * 24 * 60) {
|
||||
return date.toLocaleDateString(undefined, { weekday: 'short' });
|
||||
}
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: date.getFullYear() === now.getFullYear() ? undefined : 'numeric' });
|
||||
}
|
||||
|
||||
function dayGroup(date) {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
if (sameDay(date, now)) {
|
||||
return 'Today';
|
||||
}
|
||||
if (sameDay(date, yesterday)) {
|
||||
return 'Yesterday';
|
||||
}
|
||||
return 'Earlier';
|
||||
}
|
||||
|
||||
function refreshTimes(scope) {
|
||||
(scope || document).querySelectorAll('.itflow-notify-item time[datetime]').forEach(function (node) {
|
||||
const date = new Date(node.getAttribute('datetime'));
|
||||
if (!isNaN(date)) {
|
||||
node.textContent = relativeTime(date);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- server calls ---------- */
|
||||
|
||||
function getJson(params) {
|
||||
return fetch(ENDPOINT + '?' + new URLSearchParams(params).toString(), {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
}).then(function (res) {
|
||||
// An expired session is redirected to the login page, which is HTML
|
||||
if (!res.ok || (res.headers.get('Content-Type') || '').indexOf('application/json') === -1) {
|
||||
throw new Error(res.ok ? 'session' : 'HTTP ' + res.status);
|
||||
}
|
||||
return res.json();
|
||||
});
|
||||
}
|
||||
|
||||
function markForm(ids, state, all) {
|
||||
const form = new FormData();
|
||||
form.append('notifications_mark', '1');
|
||||
form.append('csrf_token', csrf);
|
||||
if (all) {
|
||||
form.append('all', '1');
|
||||
} else {
|
||||
form.append('state', state);
|
||||
ids.forEach(function (id) {
|
||||
form.append('ids[]', id);
|
||||
});
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
function postMark(ids, state, all) {
|
||||
return fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
body: markForm(ids, state, all)
|
||||
}).then(function (res) {
|
||||
if (!res.ok) {
|
||||
throw new Error('HTTP ' + res.status);
|
||||
}
|
||||
return res.json();
|
||||
}).then(function (data) {
|
||||
setCount(data.unread_count);
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- badge and counts ---------- */
|
||||
|
||||
function setCount(count) {
|
||||
count = Math.max(0, parseInt(count, 10) || 0);
|
||||
const label = count > 99 ? '99+' : String(count);
|
||||
|
||||
document.querySelectorAll('[data-notify-badge]').forEach(function (badge) {
|
||||
badge.textContent = label;
|
||||
badge.hidden = count === 0;
|
||||
});
|
||||
document.querySelectorAll('[data-notify-count]').forEach(function (node) {
|
||||
node.textContent = label;
|
||||
node.hidden = count === 0;
|
||||
});
|
||||
document.querySelectorAll('[data-notify-all]').forEach(function (button) {
|
||||
button.disabled = count === 0;
|
||||
});
|
||||
|
||||
const toggle = root && root.querySelector('[data-bs-toggle="dropdown"]');
|
||||
if (toggle) {
|
||||
toggle.setAttribute('aria-label', count ? 'Notifications, ' + count + ' unread' : 'Notifications');
|
||||
}
|
||||
}
|
||||
|
||||
function poll(force) {
|
||||
if (pollingStopped || !root) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (!force && now - lastPoll < 10000) {
|
||||
return;
|
||||
}
|
||||
lastPoll = now;
|
||||
|
||||
const before = currentCount();
|
||||
getJson({ notifications_count: 1 })
|
||||
.then(function (data) {
|
||||
setCount(data.unread_count);
|
||||
// Something new arrived while the panel is open: show it
|
||||
if (panel && root.querySelector('.dropdown-menu.show') && data.unread_count !== before) {
|
||||
load();
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (err.message === 'session') {
|
||||
pollingStopped = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function currentCount() {
|
||||
const badge = document.querySelector('[data-notify-badge]');
|
||||
if (!badge || badge.hidden) {
|
||||
return 0;
|
||||
}
|
||||
return parseInt(badge.textContent, 10) || 0;
|
||||
}
|
||||
|
||||
/* ---------- one notification row ---------- */
|
||||
|
||||
function renderItem(item) {
|
||||
const row = el('div', 'itflow-notify-item' + (item.unread ? ' is-unread' : ''));
|
||||
row.dataset.id = item.id;
|
||||
row.setAttribute('role', 'listitem');
|
||||
|
||||
const url = safeUrl(item.url);
|
||||
const link = el('a', 'itflow-notify-link dropdown-item');
|
||||
link.href = url || '#';
|
||||
if (!url) {
|
||||
link.dataset.noLink = '1';
|
||||
}
|
||||
|
||||
const tone = TONES.indexOf(item.tone) !== -1 ? item.tone : 'secondary';
|
||||
const badge = el('span', 'itflow-notify-icon bg-' + tone + '-subtle text-' + tone + '-emphasis');
|
||||
badge.append(icon(item.icon));
|
||||
|
||||
const body = el('span', 'itflow-notify-body');
|
||||
const top = el('span', 'itflow-notify-top');
|
||||
top.append(el('span', 'itflow-notify-type', item.type));
|
||||
if (item.time) {
|
||||
const time = el('time', 'itflow-notify-time');
|
||||
time.setAttribute('datetime', item.time);
|
||||
time.title = item.time_full || '';
|
||||
time.textContent = relativeTime(new Date(item.time));
|
||||
top.append(time);
|
||||
}
|
||||
body.append(top, el('span', 'itflow-notify-text', item.text));
|
||||
if (item.client) {
|
||||
const client = el('span', 'itflow-notify-client');
|
||||
client.append(icon('building'), document.createTextNode(item.client));
|
||||
body.append(client);
|
||||
}
|
||||
|
||||
link.append(badge, body);
|
||||
|
||||
const toggle = el('button', 'btn btn-sm itflow-notify-toggle');
|
||||
toggle.type = 'button';
|
||||
setToggle(toggle, item.unread);
|
||||
toggle.append(icon(item.unread ? 'check' : 'undo'));
|
||||
|
||||
row.append(link, toggle);
|
||||
return row;
|
||||
}
|
||||
|
||||
function setToggle(button, unread) {
|
||||
const label = unread ? 'Mark as read' : 'Mark as unread';
|
||||
button.title = label;
|
||||
button.setAttribute('aria-label', label);
|
||||
const i = button.querySelector('i');
|
||||
if (i) {
|
||||
i.className = 'fas fa-fw fa-' + (unread ? 'check' : 'undo');
|
||||
}
|
||||
}
|
||||
|
||||
function setRowRead(row, read) {
|
||||
row.classList.toggle('is-unread', !read);
|
||||
const button = row.querySelector('.itflow-notify-toggle');
|
||||
if (button) {
|
||||
setToggle(button, !read);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- the panel ---------- */
|
||||
|
||||
function buildPanel() {
|
||||
if (panel || !root) {
|
||||
return;
|
||||
}
|
||||
panel = root.querySelector('.itflow-notify-panel');
|
||||
|
||||
// Static skeleton only - no data goes through this string
|
||||
panel.innerHTML =
|
||||
'<div class="itflow-notify-head">' +
|
||||
'<span class="fw-semibold">Notifications</span>' +
|
||||
'<button type="button" class="btn btn-link btn-sm p-0 ms-auto text-decoration-none" data-notify-all>' +
|
||||
'<i class="fas fa-check-double me-1" aria-hidden="true"></i>Mark all as read</button>' +
|
||||
'</div>' +
|
||||
'<div class="nav nav-underline itflow-notify-tabs" role="tablist">' +
|
||||
'<button type="button" class="nav-link active" role="tab" aria-selected="true" data-notify-view="unread">' +
|
||||
'Unread <span class="badge rounded-pill text-bg-secondary ms-1" data-notify-count></span></button>' +
|
||||
'<button type="button" class="nav-link" role="tab" aria-selected="false" data-notify-view="all">All</button>' +
|
||||
'</div>' +
|
||||
'<div class="itflow-notify-list" role="list" aria-label="Notifications"></div>' +
|
||||
'<a class="itflow-notify-foot" href="/agent/notifications.php">View all notifications</a>';
|
||||
|
||||
list = panel.querySelector('.itflow-notify-list');
|
||||
|
||||
panel.querySelectorAll('[data-notify-view]').forEach(function (tab) {
|
||||
tab.addEventListener('click', function () {
|
||||
if (view === tab.dataset.notifyView) {
|
||||
return;
|
||||
}
|
||||
view = tab.dataset.notifyView;
|
||||
panel.querySelectorAll('[data-notify-view]').forEach(function (t) {
|
||||
const on = t === tab;
|
||||
t.classList.toggle('active', on);
|
||||
t.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
});
|
||||
load();
|
||||
});
|
||||
});
|
||||
|
||||
setCount(currentCount());
|
||||
}
|
||||
|
||||
function message(iconName, title, text, retry) {
|
||||
const box = el('div', 'itflow-notify-empty');
|
||||
const i = icon(iconName);
|
||||
i.classList.add('fa-2x', 'mb-2');
|
||||
box.append(i, el('div', 'fw-semibold', title));
|
||||
if (text) {
|
||||
box.append(el('div', 'small', text));
|
||||
}
|
||||
if (retry) {
|
||||
const button = el('button', 'btn btn-sm btn-outline-secondary mt-2', 'Try again');
|
||||
button.type = 'button';
|
||||
button.addEventListener('click', load);
|
||||
box.append(button);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function load() {
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
const mine = ++loadSeq;
|
||||
const spinner = setTimeout(function () {
|
||||
if (mine === loadSeq && !list.children.length) {
|
||||
list.replaceChildren(el('div', 'itflow-notify-empty', 'Loading…'));
|
||||
}
|
||||
}, 150);
|
||||
|
||||
getJson({ notifications_feed: 1, view: view })
|
||||
.then(function (data) {
|
||||
if (mine !== loadSeq) {
|
||||
return;
|
||||
}
|
||||
setCount(data.unread_count);
|
||||
render(data.items || []);
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (mine !== loadSeq) {
|
||||
return;
|
||||
}
|
||||
list.replaceChildren(err.message === 'session'
|
||||
? message('lock', 'Your session has ended', 'Reload the page to sign in again.')
|
||||
: message('exclamation-triangle', 'Couldn’t load notifications', null, true));
|
||||
})
|
||||
.finally(function () {
|
||||
clearTimeout(spinner);
|
||||
});
|
||||
}
|
||||
|
||||
function render(items) {
|
||||
list.replaceChildren();
|
||||
|
||||
if (!items.length) {
|
||||
list.append(view === 'unread'
|
||||
? message('check-circle', 'You’re all caught up', 'New notifications will show up here.')
|
||||
: message('bell-slash', 'No notifications yet'));
|
||||
return;
|
||||
}
|
||||
|
||||
let group = null;
|
||||
items.forEach(function (item) {
|
||||
const label = item.time ? dayGroup(new Date(item.time)) : 'Earlier';
|
||||
if (label !== group) {
|
||||
group = label;
|
||||
const heading = el('div', 'itflow-notify-group', label);
|
||||
heading.setAttribute('role', 'presentation');
|
||||
list.append(heading);
|
||||
}
|
||||
list.append(renderItem(item));
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- row behaviour, on the panel and the full page ---------- */
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
const toggle = e.target.closest('.itflow-notify-item .itflow-notify-toggle');
|
||||
if (toggle) {
|
||||
e.preventDefault();
|
||||
const row = toggle.closest('.itflow-notify-item');
|
||||
const makeRead = row.classList.contains('is-unread');
|
||||
setRowRead(row, makeRead);
|
||||
postMark([row.dataset.id], makeRead ? 'read' : 'unread').catch(function () {
|
||||
setRowRead(row, !makeRead);
|
||||
if (typeof itflowToast === 'function') {
|
||||
itflowToast('Couldn’t update the notification', 'danger');
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const link = e.target.closest('.itflow-notify-item .itflow-notify-link');
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
const row = link.closest('.itflow-notify-item');
|
||||
const unread = row.classList.contains('is-unread');
|
||||
|
||||
if (link.dataset.noLink) {
|
||||
// Nothing to open - clicking it is how you acknowledge it
|
||||
e.preventDefault();
|
||||
if (unread) {
|
||||
setRowRead(row, true);
|
||||
postMark([row.dataset.id], 'read').catch(function () {
|
||||
setRowRead(row, false);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!unread) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newTab = e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1;
|
||||
setRowRead(row, true);
|
||||
if (newTab) {
|
||||
postMark([row.dataset.id], 'read').catch(function () {
|
||||
setRowRead(row, false);
|
||||
});
|
||||
} else if (navigator.sendBeacon) {
|
||||
// This page is about to unload; a beacon still gets there
|
||||
navigator.sendBeacon(ENDPOINT, markForm([row.dataset.id], 'read', false));
|
||||
} else {
|
||||
postMark([row.dataset.id], 'read');
|
||||
}
|
||||
});
|
||||
|
||||
// Middle click does not fire "click"
|
||||
document.addEventListener('auxclick', function (e) {
|
||||
if (e.button !== 1) {
|
||||
return;
|
||||
}
|
||||
const link = e.target.closest('.itflow-notify-item .itflow-notify-link');
|
||||
if (link && !link.dataset.noLink) {
|
||||
const row = link.closest('.itflow-notify-item');
|
||||
if (row.classList.contains('is-unread')) {
|
||||
setRowRead(row, true);
|
||||
postMark([row.dataset.id], 'read').catch(function () {
|
||||
setRowRead(row, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
const button = e.target.closest('[data-notify-all]');
|
||||
if (!button || button.disabled) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
button.disabled = true;
|
||||
postMark([], 'read', true)
|
||||
.then(function () {
|
||||
document.querySelectorAll('.itflow-notify-item.is-unread').forEach(function (row) {
|
||||
setRowRead(row, true);
|
||||
});
|
||||
// The full page lists unread by default; reload so it matches
|
||||
if (button.dataset.notifyReload) {
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
button.disabled = false;
|
||||
if (typeof itflowToast === 'function') {
|
||||
itflowToast('Couldn’t mark notifications as read', 'danger');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- wiring ---------- */
|
||||
|
||||
if (root) {
|
||||
root.addEventListener('show.bs.dropdown', function () {
|
||||
buildPanel();
|
||||
load();
|
||||
});
|
||||
|
||||
setInterval(function () {
|
||||
if (document.visibilityState === 'visible') {
|
||||
poll(true);
|
||||
}
|
||||
}, POLL_MS);
|
||||
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.visibilityState === 'visible') {
|
||||
poll(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
refreshTimes(document);
|
||||
setInterval(function () {
|
||||
refreshTimes(document);
|
||||
}, 60000);
|
||||
|
||||
window.itflowNotifications = { refresh: function () { poll(true); } };
|
||||
})();
|
||||
@@ -1,157 +0,0 @@
|
||||
<?php
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/includes/modal_header.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$sql = mysqli_query(
|
||||
$mysqli,
|
||||
"SELECT * FROM notifications
|
||||
WHERE notification_user_id = $session_user_id
|
||||
AND notification_dismissed_at IS NULL
|
||||
ORDER BY notification_id DESC"
|
||||
);
|
||||
|
||||
$num_notifications = mysqli_num_rows($sql);
|
||||
|
||||
// Generate the HTML form content using output buffering.
|
||||
ob_start();
|
||||
?>
|
||||
|
||||
<div class="modal-header bg-dark">
|
||||
<h5 class="modal-title"><i class='fas fa-bell me-2'></i>Notifications<span class='badge bg-secondary rounded-pill px-3 ms-3'><?= $num_notifications ?><span></h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<?php if ($num_notifications) { ?>
|
||||
<table class="table table-sm table-hover table-borderless">
|
||||
|
||||
|
||||
<?php while ($row = mysqli_fetch_assoc($sql)) {
|
||||
|
||||
$notification_id = intval($row["notification_id"]);
|
||||
$notification_type = escapeHtml($row["notification_type"]);
|
||||
$notification_details = escapeHtml($row["notification"]);
|
||||
$notification_action = escapeHtml(
|
||||
$row["notification_action"]
|
||||
);
|
||||
$notification_timestamp_formated = date(
|
||||
"M d g:ia",
|
||||
strtotime($row["notification_timestamp"])
|
||||
);
|
||||
$notification_client_id = intval($row["notification_client_id"]);
|
||||
if (empty($notification_action)) {
|
||||
$notification_action = "#";
|
||||
}
|
||||
?>
|
||||
|
||||
<tr class="notification-item">
|
||||
<th>
|
||||
<a class="text-dark" href="<?= $notification_action ?>">
|
||||
<i class="fas fa-bullhorn me-2"></i><?= $notification_type ?>
|
||||
<small class="text-muted float-end">
|
||||
<?= $notification_timestamp_formated ?>
|
||||
</small>
|
||||
<br>
|
||||
<small class="text-secondary text-wrap"><?= $notification_details ?></small>
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
</table>
|
||||
<div class="text-center mt-2">
|
||||
<button id="prev-btn" class="btn btn-sm btn-outline-secondary me-2" title="Previous page"><i class="fas fa-caret-left"></i></button>
|
||||
<span id="page-indicator" class="small text-muted mx-2"></span>
|
||||
<button id="next-btn" class="btn btn-sm btn-outline-secondary ms-2" title="Next page"><i class="fas fa-caret-right"></i></button>
|
||||
</div>
|
||||
<?php } else { ?>
|
||||
<div class="text-center text-secondary pt-3">
|
||||
<i class='far fa-6x fa-bell-slash'></i>
|
||||
<h3 class="mt-3">No Notifications</h3>
|
||||
</div>
|
||||
<?php } ?>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<?php if ($num_notifications) { ?>
|
||||
|
||||
<a href="/agent/post.php?dismiss_all_notifications&csrf_token=<?= $_SESSION["csrf_token"] ?>" class="btn btn-primary">
|
||||
<span class="text-white text-bold"><i class="fas fa-check me-2"></i>Dismiss all</span>
|
||||
</a>
|
||||
<a href="/agent/notifications.php" class="btn btn-secondary">
|
||||
<span class="text-white">See all Notifications</span>
|
||||
</a>
|
||||
<?php } else { ?>
|
||||
<a href="/agent/notifications.php?dismissed" class="btn btn-dark">
|
||||
<span class="text-white text-bold">See Dismissed Notifications</span>
|
||||
</a>
|
||||
<?php } ?>
|
||||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-2"></i>Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
itflowReady(function () {
|
||||
var perPage = 8;
|
||||
var items = Array.from(document.querySelectorAll('.notification-item'));
|
||||
var totalItems = items.length;
|
||||
var totalPages = Math.ceil(totalItems / perPage);
|
||||
var currentPage = 0;
|
||||
|
||||
var prevBtn = document.getElementById('prev-btn');
|
||||
var nextBtn = document.getElementById('next-btn');
|
||||
var indicator = document.getElementById('page-indicator');
|
||||
|
||||
function showPage(page) {
|
||||
items.forEach(function (item, i) {
|
||||
var visible = i >= page * perPage && i < (page + 1) * perPage;
|
||||
item.style.display = visible ? '' : 'none';
|
||||
});
|
||||
if (prevBtn) {
|
||||
prevBtn.disabled = page === 0;
|
||||
}
|
||||
if (nextBtn) {
|
||||
nextBtn.disabled = page >= totalPages - 1;
|
||||
}
|
||||
if (indicator) {
|
||||
indicator.textContent = `Page ${page + 1} of ${totalPages} (${totalItems} total)`;
|
||||
}
|
||||
}
|
||||
|
||||
if (prevBtn) {
|
||||
prevBtn.addEventListener('click', function () {
|
||||
if (currentPage > 0) {
|
||||
currentPage--;
|
||||
showPage(currentPage);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (nextBtn) {
|
||||
nextBtn.addEventListener('click', function () {
|
||||
if (currentPage < totalPages - 1) {
|
||||
currentPage++;
|
||||
showPage(currentPage);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (totalItems <= perPage) {
|
||||
[prevBtn, nextBtn, indicator].forEach(function (el) {
|
||||
if (el) {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
showPage(currentPage);
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/includes/modal_footer.php';
|
||||
@@ -16,50 +16,6 @@ if(isset($_POST['change_records_per_page'])){
|
||||
|
||||
}
|
||||
|
||||
// In app notifications
|
||||
|
||||
if (isset($_GET['dismiss_notification'])) {
|
||||
|
||||
validateCSRFToken();
|
||||
|
||||
$notification_id = intval($_GET['dismiss_notification']);
|
||||
|
||||
mysqli_query($mysqli,"UPDATE notifications SET notification_dismissed_at = NOW(), notification_dismissed_by = $session_user_id WHERE notification_user_id = $session_user_id AND notification_id = $notification_id");
|
||||
|
||||
// Logging
|
||||
logAudit("Notification", "Dismiss", "$session_name dismissed notification");
|
||||
|
||||
$_SESSION['alert_message'] = "Notification Dismissed";
|
||||
|
||||
redirect();
|
||||
|
||||
}
|
||||
|
||||
if (isset($_GET['dismiss_all_notifications'])) {
|
||||
|
||||
validateCSRFToken();
|
||||
|
||||
$sql = mysqli_query($mysqli,"SELECT notification_dismissed_at, notification_id FROM notifications WHERE notification_user_id = $session_user_id AND notification_dismissed_at IS NULL");
|
||||
|
||||
$num_notifications = mysqli_num_rows($sql);
|
||||
|
||||
while($row = mysqli_fetch_assoc($sql)) {
|
||||
$notification_id = intval($row['notification_id']);
|
||||
$notification_dismissed_at = escapeSql($row['notification_dismissed_at']);
|
||||
|
||||
mysqli_query($mysqli,"UPDATE notifications SET notification_dismissed_at = NOW(), notification_dismissed_by = $session_user_id WHERE notification_id = $notification_id");
|
||||
|
||||
}
|
||||
|
||||
// Logging
|
||||
logAudit("Notification", "Dismiss", "$session_name dismissed $num_notifications notifications");
|
||||
|
||||
$_SESSION['alert_message'] = "<strong>$num_notifications</strong> Notifications Dismissed";
|
||||
|
||||
redirect();
|
||||
|
||||
}
|
||||
|
||||
// Revoke sharing (sharing itself is done via ajax.php)
|
||||
if (isset($_GET['deactivate_shared_item'])) {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user