mirror of
https://github.com/jambonz/jambonz-webapp.git
synced 2026-05-06 08:46:58 +00:00
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
This commit is contained in:
@@ -33,4 +33,6 @@
|
|||||||
## enable lazy loading for phone numbers (improves performance when managing large quantities)
|
## enable lazy loading for phone numbers (improves performance when managing large quantities)
|
||||||
# VITE_APP_ENABLE_PHONE_NUMBER_LAZY_LOAD=true
|
# 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)
|
# 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
|
#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
|
||||||
@@ -920,6 +920,19 @@ export const getAlerts = (sid: string, query: Partial<PageQuery>) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getServiceProviderAlerts = (
|
||||||
|
sid: string,
|
||||||
|
query: Partial<PageQuery>,
|
||||||
|
) => {
|
||||||
|
const qryStr = getQuery<Partial<PageQuery>>(query);
|
||||||
|
|
||||||
|
return getFetch<PagedResponse<Alert>>(
|
||||||
|
import.meta.env.DEV
|
||||||
|
? `${DEV_BASE_URL}/ServiceProviders/${sid}/Alerts?${qryStr}`
|
||||||
|
: `${API_SERVICE_PROVIDERS}/${sid}/Alerts?${qryStr}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const getPrice = () => {
|
export const getPrice = () => {
|
||||||
return getFetch<PriceInfo[]>(API_PRICE);
|
return getFetch<PriceInfo[]>(API_PRICE);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
setActiveSP,
|
setActiveSP,
|
||||||
} from "src/store/localStore";
|
} from "src/store/localStore";
|
||||||
import { postServiceProviders } from "src/api";
|
import { postServiceProviders } from "src/api";
|
||||||
|
import { useAlertStatus } from "src/hooks/useAlertStatus";
|
||||||
|
|
||||||
import type { NaviItem } from "./items";
|
import type { NaviItem } from "./items";
|
||||||
|
|
||||||
@@ -37,9 +38,10 @@ type ItemProps = CommonProps & {
|
|||||||
item: NaviItem;
|
item: NaviItem;
|
||||||
user?: UserData;
|
user?: UserData;
|
||||||
lcr?: Lcr;
|
lcr?: Lcr;
|
||||||
|
badge?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Item = ({ item, user, lcr, handleMenu }: ItemProps) => {
|
const Item = ({ item, user, lcr, handleMenu, badge }: ItemProps) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const active = location.pathname.includes(item.route(user));
|
const active = location.pathname.includes(item.route(user));
|
||||||
|
|
||||||
@@ -52,6 +54,9 @@ const Item = ({ item, user, lcr, handleMenu }: ItemProps) => {
|
|||||||
>
|
>
|
||||||
<item.icon />
|
<item.icon />
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
|
{badge !== undefined && badge > 0 && (
|
||||||
|
<span className="navi__badge">{badge >= 10 ? "9+" : badge}</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -71,10 +76,13 @@ export const Navi = ({
|
|||||||
const accessControl = useSelectState("accessControl");
|
const accessControl = useSelectState("accessControl");
|
||||||
const serviceProviders = useSelectState("serviceProviders");
|
const serviceProviders = useSelectState("serviceProviders");
|
||||||
const currentServiceProvider = useSelectState("currentServiceProvider");
|
const currentServiceProvider = useSelectState("currentServiceProvider");
|
||||||
|
const unreadAlerts = useSelectState("unreadAlerts");
|
||||||
const [sid, setSid] = useState("");
|
const [sid, setSid] = useState("");
|
||||||
const [modal, setModal] = useState(false);
|
const [modal, setModal] = useState(false);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
|
||||||
|
useAlertStatus();
|
||||||
|
|
||||||
const naviByoFiltered = useMemo(() => {
|
const naviByoFiltered = useMemo(() => {
|
||||||
return naviByo.filter(
|
return naviByo.filter(
|
||||||
(item) => !item.acl || (item.acl && accessControl[item.acl]),
|
(item) => !item.acl || (item.acl && accessControl[item.acl]),
|
||||||
@@ -213,6 +221,7 @@ export const Navi = ({
|
|||||||
user={user}
|
user={user}
|
||||||
item={item}
|
item={item}
|
||||||
handleMenu={handleMenu}
|
handleMenu={handleMenu}
|
||||||
|
badge={item.label === "Alerts" ? unreadAlerts : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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 {
|
&__byo {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
color: ui-vars.$grey;
|
color: ui-vars.$grey;
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ import React, { useEffect, useMemo, useState } from "react";
|
|||||||
import { ButtonGroup, H1, M, MS } from "@jambonz/ui-kit";
|
import { ButtonGroup, H1, M, MS } from "@jambonz/ui-kit";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import { getAlerts, useServiceProviderData } from "src/api";
|
import {
|
||||||
|
getAlerts,
|
||||||
|
getServiceProviderAlerts,
|
||||||
|
useServiceProviderData,
|
||||||
|
} from "src/api";
|
||||||
import {
|
import {
|
||||||
DATE_SELECTION,
|
DATE_SELECTION,
|
||||||
PER_PAGE_SELECTION,
|
PER_PAGE_SELECTION,
|
||||||
USER_ACCOUNT,
|
USER_ACCOUNT,
|
||||||
} from "src/api/constants";
|
} from "src/api/constants";
|
||||||
import { useSelectState } from "src/store";
|
import { useSelectState, useDispatch } from "src/store";
|
||||||
import { hasLength, hasValue } from "src/utils";
|
import { hasLength, hasValue } from "src/utils";
|
||||||
import {
|
import {
|
||||||
AccountFilter,
|
AccountFilter,
|
||||||
@@ -25,13 +29,16 @@ import {
|
|||||||
getAccountFilter,
|
getAccountFilter,
|
||||||
getQueryFilter,
|
getQueryFilter,
|
||||||
setLocation,
|
setLocation,
|
||||||
|
setAlertsLastViewed,
|
||||||
} from "src/store/localStore";
|
} from "src/store/localStore";
|
||||||
import AlertDetailItem from "./alert-detail-item";
|
import AlertDetailItem from "./alert-detail-item";
|
||||||
import { useToast } from "src/components/toast/toast-provider";
|
import { useToast } from "src/components/toast/toast-provider";
|
||||||
|
|
||||||
export const Alerts = () => {
|
export const Alerts = () => {
|
||||||
const { toastError } = useToast();
|
const { toastError } = useToast();
|
||||||
|
const dispatch = useDispatch();
|
||||||
const user = useSelectState("user");
|
const user = useSelectState("user");
|
||||||
|
const currentServiceProvider = useSelectState("currentServiceProvider");
|
||||||
const [accounts] = useServiceProviderData<Account[]>("Accounts");
|
const [accounts] = useServiceProviderData<Account[]>("Accounts");
|
||||||
const [accountSid, setAccountSid] = useState("");
|
const [accountSid, setAccountSid] = useState("");
|
||||||
const [dateFilter, setDateFilter] = useState("today");
|
const [dateFilter, setDateFilter] = useState("today");
|
||||||
@@ -57,7 +64,18 @@ export const Alerts = () => {
|
|||||||
: { days: Number(dateFilter) }),
|
: { 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 }) => {
|
.then(({ json }) => {
|
||||||
setAlerts(json.data);
|
setAlerts(json.data);
|
||||||
setAlertsTotal(json.total);
|
setAlertsTotal(json.total);
|
||||||
@@ -78,16 +96,21 @@ export const Alerts = () => {
|
|||||||
}
|
}
|
||||||
}, [accountSid]);
|
}, [accountSid]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAlertsLastViewed(new Date().toISOString());
|
||||||
|
dispatch({ type: "unreadAlerts", payload: 0 });
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocation();
|
setLocation();
|
||||||
if (user?.account_sid && user.scope === USER_ACCOUNT) {
|
if (user?.account_sid && user.scope === USER_ACCOUNT) {
|
||||||
setAccountSid(user?.account_sid);
|
setAccountSid(user?.account_sid);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accountSid) {
|
if (accountSid || currentServiceProvider?.service_provider_sid) {
|
||||||
handleFilterChange();
|
handleFilterChange();
|
||||||
}
|
}
|
||||||
}, [user, accountSid, pageNumber, dateFilter]);
|
}, [user, accountSid, pageNumber, dateFilter, currentServiceProvider]);
|
||||||
|
|
||||||
/** Reset page number when filters change */
|
/** Reset page number when filters change */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -104,6 +127,7 @@ export const Alerts = () => {
|
|||||||
<AccountFilter
|
<AccountFilter
|
||||||
account={[accountSid, setAccountSid]}
|
account={[accountSid, setAccountSid]}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
|
defaultOption
|
||||||
/>
|
/>
|
||||||
</ScopedAccess>
|
</ScopedAccess>
|
||||||
<SelectFilter
|
<SelectFilter
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
import { getAlerts, getServiceProviderAlerts } from "src/api";
|
||||||
|
import { useSelectState, useDispatch } from "src/store";
|
||||||
|
import { getAlertsLastViewed } from "src/store/localStore";
|
||||||
|
import { Scope } from "src/store/types";
|
||||||
|
|
||||||
|
const DEFAULT_POLL_INTERVAL = 60; // seconds
|
||||||
|
const envPollInterval = import.meta.env.VITE_APP_ALERT_POLL_INTERVAL;
|
||||||
|
const POLL_INTERVAL =
|
||||||
|
envPollInterval !== undefined
|
||||||
|
? Number(envPollInterval) * 1000
|
||||||
|
: DEFAULT_POLL_INTERVAL * 1000;
|
||||||
|
|
||||||
|
export const useAlertStatus = () => {
|
||||||
|
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);
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
@@ -29,6 +29,7 @@ export const initialState: State = {
|
|||||||
hasMSTeamsFqdn: false,
|
hasMSTeamsFqdn: false,
|
||||||
},
|
},
|
||||||
serviceProviders: [],
|
serviceProviders: [],
|
||||||
|
unreadAlerts: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const reducer: React.Reducer<State, Action<keyof State>> = (state, action) => {
|
const reducer: React.Reducer<State, Action<keyof State>> = (state, action) => {
|
||||||
@@ -36,6 +37,7 @@ const reducer: React.Reducer<State, Action<keyof State>> = (state, action) => {
|
|||||||
case "user":
|
case "user":
|
||||||
case "lcr":
|
case "lcr":
|
||||||
case "toast":
|
case "toast":
|
||||||
|
case "unreadAlerts":
|
||||||
return genericAction(state, action);
|
return genericAction(state, action);
|
||||||
case "serviceProviders":
|
case "serviceProviders":
|
||||||
return serviceProvidersAction(state, action);
|
return serviceProvidersAction(state, action);
|
||||||
|
|||||||
@@ -100,6 +100,20 @@ export const removeRootDomain = () => {
|
|||||||
return localStorage.removeItem(rootDomainKey);
|
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
|
* Methods to get/set the location from local storage
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export interface State {
|
|||||||
lcr?: Lcr;
|
lcr?: Lcr;
|
||||||
/** current selected service provider */
|
/** current selected service provider */
|
||||||
currentServiceProvider?: ServiceProvider;
|
currentServiceProvider?: ServiceProvider;
|
||||||
|
/** count of unread alerts */
|
||||||
|
unreadAlerts: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generic interface enforces type-safety with global dispatch */
|
/** Generic interface enforces type-safety with global dispatch */
|
||||||
|
|||||||
Reference in New Issue
Block a user