From dd57a0a41ae43ddb71af8d088b6ab2d20fb343e6 Mon Sep 17 00:00:00 2001 From: Sam Machin Date: Mon, 30 Mar 2026 15:24:23 +0100 Subject: [PATCH] Add badge to alerts (#593) * first pass at alert notifications * sp alerts * add all accounts alerts * fix polling timer * update style * show count and change polling to 60s * check alerts on login * Delete settings.local.json * poll for max 10 results in 30days and use last viewed * make polling interval configurable uses VITE_APP_ALERT_POLL_INTERVAL default is 60s setting to 0 will disable polling * don't sent days and start * add to .env --- .env | 4 +- src/api/index.ts | 13 +++ src/containers/internal/navi/index.tsx | 11 ++- src/containers/internal/navi/styles.scss | 16 ++++ .../internal/views/alerts/index.tsx | 34 ++++++-- src/hooks/useAlertStatus.ts | 79 +++++++++++++++++++ src/store/index.tsx | 2 + src/store/localStore.ts | 14 ++++ src/store/types.ts | 2 + 9 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 src/hooks/useAlertStatus.ts diff --git a/.env b/.env index 9479d82..12668ee 100644 --- a/.env +++ b/.env @@ -33,4 +33,6 @@ ## enable lazy loading for phone numbers (improves performance when managing large quantities) # VITE_APP_ENABLE_PHONE_NUMBER_LAZY_LOAD=true # hides controlls to add Carrier and Phone number from non Admin/SP Users (also need to set flag on API server to block API calls) -#VITE_ADMIN_CARRIER=1 \ No newline at end of file +#VITE_ADMIN_CARRIER=1 +# Controls alert polling interval time in seconds, default 60, set to 0 to disable polling +#VITE_APP_ALERT_POLL_INTERVAL=60 \ No newline at end of file diff --git a/src/api/index.ts b/src/api/index.ts index 09b6c64..c553f10 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -920,6 +920,19 @@ export const getAlerts = (sid: string, query: Partial) => { ); }; +export const getServiceProviderAlerts = ( + sid: string, + query: Partial, +) => { + const qryStr = getQuery>(query); + + return getFetch>( + import.meta.env.DEV + ? `${DEV_BASE_URL}/ServiceProviders/${sid}/Alerts?${qryStr}` + : `${API_SERVICE_PROVIDERS}/${sid}/Alerts?${qryStr}`, + ); +}; + export const getPrice = () => { return getFetch(API_PRICE); }; diff --git a/src/containers/internal/navi/index.tsx b/src/containers/internal/navi/index.tsx index e558a27..93e6989 100644 --- a/src/containers/internal/navi/index.tsx +++ b/src/containers/internal/navi/index.tsx @@ -12,6 +12,7 @@ import { setActiveSP, } from "src/store/localStore"; import { postServiceProviders } from "src/api"; +import { useAlertStatus } from "src/hooks/useAlertStatus"; import type { NaviItem } from "./items"; @@ -37,9 +38,10 @@ type ItemProps = CommonProps & { item: NaviItem; user?: UserData; lcr?: Lcr; + badge?: number; }; -const Item = ({ item, user, lcr, handleMenu }: ItemProps) => { +const Item = ({ item, user, lcr, handleMenu, badge }: ItemProps) => { const location = useLocation(); const active = location.pathname.includes(item.route(user)); @@ -52,6 +54,9 @@ const Item = ({ item, user, lcr, handleMenu }: ItemProps) => { > {item.label} + {badge !== undefined && badge > 0 && ( + {badge >= 10 ? "9+" : badge} + )} ); @@ -71,10 +76,13 @@ export const Navi = ({ const accessControl = useSelectState("accessControl"); const serviceProviders = useSelectState("serviceProviders"); const currentServiceProvider = useSelectState("currentServiceProvider"); + const unreadAlerts = useSelectState("unreadAlerts"); const [sid, setSid] = useState(""); const [modal, setModal] = useState(false); const [name, setName] = useState(""); + useAlertStatus(); + const naviByoFiltered = useMemo(() => { return naviByo.filter( (item) => !item.acl || (item.acl && accessControl[item.acl]), @@ -213,6 +221,7 @@ export const Navi = ({ user={user} item={item} handleMenu={handleMenu} + badge={item.label === "Alerts" ? unreadAlerts : undefined} /> ); })} diff --git a/src/containers/internal/navi/styles.scss b/src/containers/internal/navi/styles.scss index d1579ff..f3ffa96 100644 --- a/src/containers/internal/navi/styles.scss +++ b/src/containers/internal/navi/styles.scss @@ -129,6 +129,22 @@ } } + &__badge { + margin-left: auto; + background-color: ui-vars.$jambonz; + color: ui-vars.$white; + font-size: 11px; + font-weight: 600; + line-height: 1; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + display: inline-flex; + align-items: center; + justify-content: center; + } + &__byo { margin-top: auto; color: ui-vars.$grey; diff --git a/src/containers/internal/views/alerts/index.tsx b/src/containers/internal/views/alerts/index.tsx index 192d0f0..5a993d4 100644 --- a/src/containers/internal/views/alerts/index.tsx +++ b/src/containers/internal/views/alerts/index.tsx @@ -2,13 +2,17 @@ import React, { useEffect, useMemo, useState } from "react"; import { ButtonGroup, H1, M, MS } from "@jambonz/ui-kit"; import dayjs from "dayjs"; -import { getAlerts, useServiceProviderData } from "src/api"; +import { + getAlerts, + getServiceProviderAlerts, + useServiceProviderData, +} from "src/api"; import { DATE_SELECTION, PER_PAGE_SELECTION, USER_ACCOUNT, } from "src/api/constants"; -import { useSelectState } from "src/store"; +import { useSelectState, useDispatch } from "src/store"; import { hasLength, hasValue } from "src/utils"; import { AccountFilter, @@ -25,13 +29,16 @@ import { getAccountFilter, getQueryFilter, setLocation, + setAlertsLastViewed, } from "src/store/localStore"; import AlertDetailItem from "./alert-detail-item"; import { useToast } from "src/components/toast/toast-provider"; export const Alerts = () => { const { toastError } = useToast(); + const dispatch = useDispatch(); const user = useSelectState("user"); + const currentServiceProvider = useSelectState("currentServiceProvider"); const [accounts] = useServiceProviderData("Accounts"); const [accountSid, setAccountSid] = useState(""); const [dateFilter, setDateFilter] = useState("today"); @@ -57,7 +64,18 @@ export const Alerts = () => { : { days: Number(dateFilter) }), }; - getAlerts(accountSid, payload) + const fetchAlerts = accountSid + ? getAlerts(accountSid, payload) + : currentServiceProvider?.service_provider_sid + ? getServiceProviderAlerts( + currentServiceProvider.service_provider_sid, + payload, + ) + : null; + + if (!fetchAlerts) return; + + fetchAlerts .then(({ json }) => { setAlerts(json.data); setAlertsTotal(json.total); @@ -78,16 +96,21 @@ export const Alerts = () => { } }, [accountSid]); + useEffect(() => { + setAlertsLastViewed(new Date().toISOString()); + dispatch({ type: "unreadAlerts", payload: 0 }); + }, []); + useEffect(() => { setLocation(); if (user?.account_sid && user.scope === USER_ACCOUNT) { setAccountSid(user?.account_sid); } - if (accountSid) { + if (accountSid || currentServiceProvider?.service_provider_sid) { handleFilterChange(); } - }, [user, accountSid, pageNumber, dateFilter]); + }, [user, accountSid, pageNumber, dateFilter, currentServiceProvider]); /** Reset page number when filters change */ useEffect(() => { @@ -104,6 +127,7 @@ export const Alerts = () => { { + const user = useSelectState("user"); + const currentServiceProvider = useSelectState("currentServiceProvider"); + const dispatch = useDispatch(); + + const dispatchRef = useRef(dispatch); + + useEffect(() => { + dispatchRef.current = dispatch; + }, [dispatch]); + + const checkAlerts = useCallback(() => { + const lastViewed = getAlertsLastViewed(); + const query = { + page: 1, + count: 10, + ...(lastViewed ? { start: lastViewed } : { days: 30 }), + }; + + // Account-scoped users: check their own account + if (user?.access === Scope.account && user.account_sid) { + getAlerts(user.account_sid, query) + .then(({ json }) => { + dispatchRef.current({ type: "unreadAlerts", payload: json.total }); + }) + .catch(() => {}); + return; + } + + // Admin/SP users: check all alerts under the service provider + if (currentServiceProvider?.service_provider_sid) { + getServiceProviderAlerts( + currentServiceProvider.service_provider_sid, + query, + ) + .then(({ json }) => { + dispatchRef.current({ type: "unreadAlerts", payload: json.total }); + }) + .catch(() => {}); + } + }, [ + user?.access, + user?.account_sid, + currentServiceProvider?.service_provider_sid, + ]); + + // Check immediately when user or SP changes + useEffect(() => { + checkAlerts(); + }, [checkAlerts]); + + // Set up polling interval (stable, doesn't restart on state changes) + const checkAlertsRef = useRef(checkAlerts); + + useEffect(() => { + checkAlertsRef.current = checkAlerts; + }, [checkAlerts]); + + useEffect(() => { + if (!POLL_INTERVAL) return; + + const interval = setInterval(() => checkAlertsRef.current(), POLL_INTERVAL); + return () => clearInterval(interval); + }, []); +}; diff --git a/src/store/index.tsx b/src/store/index.tsx index 41b7c1c..cbf86c0 100644 --- a/src/store/index.tsx +++ b/src/store/index.tsx @@ -29,6 +29,7 @@ export const initialState: State = { hasMSTeamsFqdn: false, }, serviceProviders: [], + unreadAlerts: 0, }; const reducer: React.Reducer> = (state, action) => { @@ -36,6 +37,7 @@ const reducer: React.Reducer> = (state, action) => { case "user": case "lcr": case "toast": + case "unreadAlerts": return genericAction(state, action); case "serviceProviders": return serviceProvidersAction(state, action); diff --git a/src/store/localStore.ts b/src/store/localStore.ts index ea57129..47b528c 100644 --- a/src/store/localStore.ts +++ b/src/store/localStore.ts @@ -100,6 +100,20 @@ export const removeRootDomain = () => { return localStorage.removeItem(rootDomainKey); }; +/** + * Methods to get/set the alerts last viewed timestamp from local storage + */ + +const storeAlertsLastViewed = "alertsLastViewed"; + +export const getAlertsLastViewed = () => { + return localStorage.getItem(storeAlertsLastViewed) || ""; +}; + +export const setAlertsLastViewed = (timestamp: string) => { + localStorage.setItem(storeAlertsLastViewed, timestamp); +}; + /** * Methods to get/set the location from local storage */ diff --git a/src/store/types.ts b/src/store/types.ts index e60ed77..42d3b28 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -44,6 +44,8 @@ export interface State { lcr?: Lcr; /** current selected service provider */ currentServiceProvider?: ServiceProvider; + /** count of unread alerts */ + unreadAlerts: number; } /** Generic interface enforces type-safety with global dispatch */