system notification

This commit is contained in:
Thanakorn S
2026-05-26 13:26:57 +07:00
parent d93abb0b81
commit 5687b562fb
3 changed files with 162 additions and 0 deletions
+19
View File
@@ -35,6 +35,25 @@ function notify_node(string $event, array $data, int $company_id): void
curl_close($ch); curl_close($ch);
} }
/**
* emit_notification — push a real-time notification to all browser tabs in a company.
*
* The browser shows a toast immediately and adds the item to the bell dropdown.
*
* @param int $company_id Target company
* @param string $title Short heading, e.g. 'PO Approved'
* @param string $message Detail line, e.g. 'PO-2605-00042 has been approved.'
* @param string $type 'success' | 'info' | 'warning' | 'danger' (default 'info')
*/
function emit_notification(int $company_id, string $title, string $message, string $type = 'info'): void
{
notify_node('notification', [
'title' => $title,
'message' => $message,
'type' => $type,
], $company_id);
}
function gl_posted_payload(string $doc_type, int $id, string $action, array $lines): array function gl_posted_payload(string $doc_type, int $id, string $action, array $lines): array
{ {
$has_revenue = false; $has_revenue = false;
+62
View File
@@ -29,7 +29,69 @@
window._socket.on('disconnect', function () { window._socket.on('disconnect', function () {
console.log('[socket] disconnected'); console.log('[socket] disconnected');
}); });
// ── Real-time notifications ───────────────────────────────────────────────
// PHP emits { title, message, type } where type is 'success'|'info'|'warning'|'danger'
window._socket.on('notification', function (data) {
var title = data.title || '';
var message = data.message || '';
var type = data.type || 'info';
show_toast(title, message, type);
if (typeof add_notification === 'function') {
add_notification(title, message, type);
}
});
})(); })();
// ── show_toast(title, message, type) ─────────────────────────────────────────
// Renders a Bootstrap 5 toast in the top-right corner.
// type: 'success' | 'info' | 'warning' | 'danger'
function show_toast(title, message, type) {
type = type || 'info';
var icon_map = {
success: 'ti-circle-check text-success',
info: 'ti-info-circle text-info',
warning: 'ti-alert-triangle text-warning',
danger: 'ti-circle-x text-danger',
};
var icon = icon_map[type] || icon_map.info;
var container = document.getElementById('_toast_container');
if (!container) {
container = document.createElement('div');
container.id = '_toast_container';
container.className = 'toast-container position-fixed top-0 end-0 p-3';
container.style.zIndex = 9999;
document.body.appendChild(container);
}
var id = '_toast_' + Date.now();
var el = document.createElement('div');
el.id = id;
el.className = 'toast align-items-center border-0 shadow-sm';
el.setAttribute('role', 'alert');
el.setAttribute('aria-live', 'assertive');
el.innerHTML = [
'<div class="d-flex">',
' <div class="toast-body d-flex align-items-start gap-2">',
' <i class="ti ' + icon + ' fs-5 mt-1 flex-shrink-0"></i>',
' <div>',
(title ? '<div class="fw-semibold lh-sm">' + title + '</div>' : ''),
(message ? '<div class="small text-muted">' + message + '</div>' : ''),
' </div>',
' </div>',
' <button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast"></button>',
'</div>',
].join('');
container.appendChild(el);
var toast = new bootstrap.Toast(el, { delay: 5000 });
toast.show();
el.addEventListener('hidden.bs.toast', function () { el.remove(); });
}
</script> </script>
<!-- Stock rows modal — used by: order/order.php, order/return.php, po/po.php, po/supplier_returns.php (via show_stock_rows() in custom.js) --> <!-- Stock rows modal — used by: order/order.php, order/return.php, po/po.php, po/supplier_returns.php (via show_stock_rows() in custom.js) -->
+81
View File
@@ -208,6 +208,25 @@ $_usage_full = $_usage_max_pct >= 100;
</li> </li>
<?php endif; ?> <?php endif; ?>
<!-- Bell notifications -->
<li class="dropdown">
<a class="position-relative btn-icon btn-sm btn-light btn rounded-circle" href="#" role="button"
data-bs-toggle="dropdown" aria-expanded="false" onclick="clear_notif_badge()">
<i class="ti ti-bell" style="font-size:18px;"></i>
<span id="notif_badge" class="badge bg-danger position-absolute top-0 start-100 translate-middle rounded-pill d-none"
style="font-size:9px;min-width:16px;padding:2px 4px;"></span>
</a>
<div class="dropdown-menu dropdown-menu-end p-0" style="min-width:300px;">
<div class="px-3 py-2 border-bottom d-flex align-items-center justify-content-between">
<span class="fw-semibold small">Notifications</span>
<a href="javascript:;" class="small text-muted" onclick="clear_all_notifs()">Clear all</a>
</div>
<div id="notif_list" style="max-height:360px;overflow-y:auto;">
<div class="text-center text-muted small py-4">No notifications</div>
</div>
</div>
</li>
<!-- User dropdown --> <!-- User dropdown -->
<li class="ms-3 dropdown"> <li class="ms-3 dropdown">
<a class="position-relative btn-icon btn-sm btn-primary btn rounded-circle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false"> <a class="position-relative btn-icon btn-sm btn-primary btn rounded-circle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
@@ -258,6 +277,68 @@ var prop_limit = '<?php echo $prop['limit']; ?>';
var user_role = document.getElementById('session-context')?.dataset.role || 'viewer'; var user_role = document.getElementById('session-context')?.dataset.role || 'viewer';
var company_id = parseInt(document.getElementById('session-context')?.dataset.companyId || '0', 10); var company_id = parseInt(document.getElementById('session-context')?.dataset.companyId || '0', 10);
// ── Bell notifications ────────────────────────────────────────────────────────
var _notif_items = [];
var _notif_unread = 0;
var _notif_icon_map = {
success: 'ti-circle-check text-success',
info: 'ti-info-circle text-info',
warning: 'ti-alert-triangle text-warning',
danger: 'ti-circle-x text-danger',
};
function add_notification(title, message, type) {
var time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
_notif_items.unshift({ title: title, message: message, type: type || 'info', time: time });
if (_notif_items.length > 20) _notif_items.pop();
_notif_unread++;
_render_notif_badge();
_render_notif_list();
}
function clear_notif_badge() {
_notif_unread = 0;
_render_notif_badge();
}
function clear_all_notifs() {
_notif_items = [];
_notif_unread = 0;
_render_notif_badge();
_render_notif_list();
}
function _render_notif_badge() {
var badge = document.getElementById('notif_badge');
if (!badge) return;
if (_notif_unread > 0) {
badge.textContent = _notif_unread > 99 ? '99+' : _notif_unread;
badge.classList.remove('d-none');
} else {
badge.classList.add('d-none');
}
}
function _render_notif_list() {
var el = document.getElementById('notif_list');
if (!el) return;
if (_notif_items.length === 0) {
el.innerHTML = '<div class="text-center text-muted small py-4">No notifications</div>';
return;
}
el.innerHTML = _notif_items.map(function(n) {
var icon = _notif_icon_map[n.type] || _notif_icon_map.info;
return '<div class="d-flex align-items-start gap-2 px-3 py-2 border-bottom">' +
'<i class="ti ' + icon + ' fs-5 flex-shrink-0 mt-1"></i>' +
'<div class="flex-grow-1 overflow-hidden">' +
(n.title ? '<div class="fw-semibold small text-truncate">' + n.title + '</div>' : '') +
(n.message ? '<div class="small text-muted text-truncate">' + n.message + '</div>' : '') +
'<div class="small text-muted opacity-75 mt-1">' + n.time + '</div>' +
'</div></div>';
}).join('');
}
// ── Log out ─────────────────────────────────────────────────────────────────── // ── Log out ───────────────────────────────────────────────────────────────────
function log_out() { function log_out() {
return ajax_request({ return ajax_request({