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:
johnnyq
2026-10-09 15:52:40 -04:00
parent f7e5655e05
commit bab3d73c86
14 changed files with 1166 additions and 363 deletions

View File

@@ -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).
*

View File

@@ -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>

View File

@@ -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";

View File

@@ -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;
}
}

View File

@@ -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
View 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);
}

View File

@@ -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
*

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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">

View File

@@ -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>&uarr;</kbd> <kbd>&darr;</kbd> to move</span>' +
'<span><kbd>&crarr;</kbd> to open</span>' +
'<span><kbd class="itflow-search-mod"></kbd> + <kbd>&crarr;</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
View 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); } };
})();

View File

@@ -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';

View File

@@ -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'])) {