feature/IO-3096-GlobalNotifications - Checkpoint - Header finalized, scenarioParser now uses ENV var for FILTER_SELF from watchers.

This commit is contained in:
Dave Richer
2025-02-28 17:17:13 -05:00
parent a5904f55aa
commit f51fa08961
2 changed files with 221 additions and 346 deletions

View File

@@ -1,4 +1,16 @@
import Icon, {
import { Badge, Layout, Menu, Spin } from "antd";
import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { Link } from "react-router-dom";
import { useQuery } from "@apollo/client";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import {
BankFilled,
BarChartOutlined,
BellFilled,
@@ -26,35 +38,21 @@ import Icon, {
UnorderedListOutlined,
UserOutlined
} from "@ant-design/icons";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Badge, Layout, Menu, Space, Spin } from "antd";
import { useTranslation } from "react-i18next";
import { BsKanban } from "react-icons/bs";
import { FaCalendarAlt, FaCarCrash, FaCreditCard, FaFileInvoiceDollar, FaTasks } from "react-icons/fa";
import { FiLogOut } from "react-icons/fi";
import { GiPayMoney, GiPlayerTime, GiSettingsKnobs } from "react-icons/gi";
import { IoBusinessOutline } from "react-icons/io5";
import { RiSurveyLine } from "react-icons/ri";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
import { selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors";
import { setModalContext } from "../../redux/modals/modals.actions";
import { signOutStart } from "../../redux/user/user.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
import { useState, useEffect } from "react";
import { debounce } from "lodash";
import { useQuery } from "@apollo/client";
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
// Used to Determine if the Header is in Mobile Mode, and to toggle the multiple menus
const HEADER_MOBILE_BREAKPOINT = 576;
import day from "../../utils/day.js";
// Redux mappings
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
recentItems: selectRecentItems,
@@ -63,43 +61,13 @@ const mapStateToProps = createStructuredSelector({
});
const mapDispatchToProps = (dispatch) => ({
setBillEnterContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "billEnter"
})
),
setTimeTicketContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "timeTicket"
})
),
setPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "payment" })),
setReportCenterContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "reportCenter"
})
),
setBillEnterContext: (context) => dispatch(setModalContext({ context, modal: "billEnter" })),
setTimeTicketContext: (context) => dispatch(setModalContext({ context, modal: "timeTicket" })),
setPaymentContext: (context) => dispatch(setModalContext({ context, modal: "payment" })),
setReportCenterContext: (context) => dispatch(setModalContext({ context, modal: "reportCenter" })),
signOutStart: () => dispatch(signOutStart()),
setCardPaymentContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "cardPayment"
})
),
setTaskUpsertContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "taskUpsert"
})
)
setCardPaymentContext: (context) => dispatch(setModalContext({ context, modal: "cardPayment" })),
setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" }))
});
function Header({
@@ -125,12 +93,10 @@ function Header({
});
const { t } = useTranslation();
const { isConnected } = useSocket();
const [notificationVisible, setNotificationVisible] = useState(false);
const userAssociationId = bodyshop?.associations?.[0]?.id;
const {
data: unreadData,
refetch: refetchUnread,
@@ -138,22 +104,21 @@ function Header({
} = useQuery(GET_UNREAD_COUNT, {
variables: { associationid: userAssociationId },
fetchPolicy: "network-only",
pollInterval: isConnected ? 0 : 30000, // Poll only if socket is down
skip: !userAssociationId // Skip query if no userAssociationId
pollInterval: isConnected ? 0 : day.duration(60, "seconds").asMilliseconds(),
skip: !userAssociationId
});
const unreadCount = unreadData?.notifications_aggregate?.aggregate?.count ?? 0;
// Initial fetch and socket status handling
useEffect(() => {
if (userAssociationId) {
refetchUnread().catch((e) => console.error(`Something went wrong fetching unread notifications: ${e?.message}`));
refetchUnread().catch((e) => console.error(`Error fetching unread notifications: ${e?.message}`));
}
}, [refetchUnread, userAssociationId]);
useEffect(() => {
if (!isConnected && !unreadLoading && userAssociationId) {
refetchUnread().catch((e) => console.error(`Something went wrong fetching unread notifications: ${e?.message}`));
refetchUnread().catch((e) => console.error(`Error fetching unread notifications: ${e?.message}`));
}
}, [isConnected, unreadLoading, refetchUnread, userAssociationId]);
@@ -162,33 +127,11 @@ function Header({
if (handleMenuClick) handleMenuClick(e);
};
const [isMobile, setIsMobile] = useState(() => {
const effectiveWidth = window.innerWidth / (window.devicePixelRatio || 1);
return effectiveWidth <= HEADER_MOBILE_BREAKPOINT;
});
const handleResize = debounce(() => {
const effectiveWidth = window.innerWidth / (window.devicePixelRatio || 1);
setIsMobile(effectiveWidth <= HEADER_MOBILE_BREAKPOINT);
}, 200);
useEffect(() => {
window.addEventListener("resize", handleResize);
window.addEventListener("orientationchange", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
window.removeEventListener("orientationchange", handleResize);
handleResize.cancel(); // Cancel any pending debounced calls on cleanup
};
}, [handleResize]);
// Accounting children setup (unchanged)
const accountingChildren = [];
accountingChildren.push(
const accountingChildren = [
{
key: "bills",
id: "header-accounting-bills",
icon: <Icon component={FaFileInvoiceDollar} />,
icon: <FaFileInvoiceDollar />,
label: (
<Link to="/manage/bills">
<LockWrapper featureName="bills" bodyshop={bodyshop}>
@@ -200,42 +143,31 @@ function Header({
{
key: "enterbills",
id: "header-accounting-enterbills",
icon: <Icon component={GiPayMoney} />,
icon: <GiPayMoney />,
label: (
<Space>
<LockWrapper featureName="bills" bodyshop={bodyshop}>
{t("menus.header.enterbills")}
</LockWrapper>
</Space>
<LockWrapper featureName="bills" bodyshop={bodyshop}>
{t("menus.header.enterbills")}
</LockWrapper>
),
onClick: () => {
onClick: () =>
HasFeatureAccess({ featureName: "bills", bodyshop }) &&
setBillEnterContext({
actions: {},
context: {}
});
}
}
);
if (Simple_Inventory.treatment === "on") {
accountingChildren.push(
{
type: "divider"
},
{
key: "inventory",
id: "header-accounting-inventory",
icon: <Icon component={FaFileInvoiceDollar} />,
label: <Link to="/manage/inventory">{t("menus.header.inventory")}</Link>
}
);
}
accountingChildren.push(
{
type: "divider"
setBillEnterContext({
actions: {},
context: {}
})
},
...(Simple_Inventory.treatment === "on"
? [
{ type: "divider" },
{
key: "inventory",
id: "header-accounting-inventory",
icon: <FaFileInvoiceDollar />,
label: <Link to="/manage/inventory">{t("menus.header.inventory")}</Link>
}
]
: []),
{ type: "divider" },
{
key: "allpayments",
id: "header-accounting-allpayments",
@@ -251,41 +183,31 @@ function Header({
{
key: "enterpayments",
id: "header-accounting-enterpayments",
icon: <Icon component={FaCreditCard} />,
icon: <FaCreditCard />,
label: (
<LockWrapper featureName="payments" bodyshop={bodyshop}>
{t("menus.header.enterpayment")}
</LockWrapper>
),
onClick: () => {
onClick: () =>
HasFeatureAccess({ featureName: "payments", bodyshop }) &&
setPaymentContext({
actions: {},
context: null
});
}
}
);
if (ImEXPay.treatment === "on") {
accountingChildren.push({
key: "entercardpayments",
id: "header-accounting-entercardpayments",
icon: <Icon component={FaCreditCard} />,
label: t("menus.header.entercardpayment"),
onClick: () => {
setCardPaymentContext({
setPaymentContext({
actions: {},
context: {}
});
}
});
}
accountingChildren.push(
{
type: "divider"
context: null
})
},
...(ImEXPay.treatment === "on"
? [
{
key: "entercardpayments",
id: "header-accounting-entercardpayments",
icon: <FaCreditCard />,
label: t("menus.header.entercardpayment"),
onClick: () => setCardPaymentContext({ actions: {}, context: {} })
}
]
: []),
{ type: "divider" },
{
key: "timetickets",
id: "header-accounting-timetickets",
@@ -297,133 +219,124 @@ function Header({
</LockWrapper>
</Link>
)
}
);
if (bodyshop?.md_tasks_presets?.use_approvals) {
accountingChildren.push({
key: "ttapprovals",
id: "header-accounting-ttapprovals",
icon: <FieldTimeOutlined />,
label: <Link to="/manage/ttapprovals">{t("menus.header.ttapprovals")}</Link>
});
}
accountingChildren.push(
},
...(bodyshop?.md_tasks_presets?.use_approvals
? [
{
key: "ttapprovals",
id: "header-accounting-ttapprovals",
icon: <FieldTimeOutlined />,
label: <Link to="/manage/ttapprovals">{t("menus.header.ttapprovals")}</Link>
}
]
: []),
{
key: "entertimetickets",
icon: <Icon component={GiPlayerTime} />,
id: "header-accounting-entertimetickets",
icon: <GiPlayerTime />,
label: (
<LockWrapper featureName="timetickets" bodyshop={bodyshop}>
{t("menus.header.entertimeticket")}
</LockWrapper>
),
id: "header-accounting-entertimetickets",
onClick: () => {
onClick: () =>
HasFeatureAccess({ featureName: "timetickets", bodyshop }) &&
setTimeTicketContext({
actions: {},
context: {
created_by: currentUser.displayName
? currentUser.email.concat(" | ", currentUser.displayName)
: currentUser.email
}
});
}
setTimeTicketContext({
actions: {},
context: {
created_by: currentUser.displayName
? `${currentUser.email} | ${currentUser.displayName}`
: currentUser.email
}
})
},
{ type: "divider" },
{
type: "divider"
}
);
const accountingExportChildren = [
{
key: "receivables",
id: "header-accounting-receivables",
key: "accountingexport",
id: "header-accounting-export",
icon: <ExportOutlined />,
label: (
<Link to="/manage/accounting/receivables">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-receivables")}
</LockWrapper>
</Link>
)
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.export")}
</LockWrapper>
),
children: [
{
key: "receivables",
id: "header-accounting-receivables",
label: (
<Link to="/manage/accounting/receivables">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-receivables")}
</LockWrapper>
</Link>
)
},
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) ||
DmsAp.treatment === "on"
? [
{
key: "payables",
id: "header-accounting-payables",
label: (
<Link to="/manage/accounting/payables">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-payables")}
</LockWrapper>
</Link>
)
}
]
: []),
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber))
? [
{
key: "payments",
id: "header-accounting-payments",
label: (
<Link to="/manage/accounting/payments">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-payments")}
</LockWrapper>
</Link>
)
}
]
: []),
{ type: "divider" },
{
key: "exportlogs",
id: "header-accounting-exportlogs",
label: (
<Link to="/manage/accounting/exportlogs">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.export-logs")}
</LockWrapper>
</Link>
)
}
]
}
];
if (!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) || DmsAp.treatment === "on") {
accountingExportChildren.push({
key: "payables",
id: "header-accounting-payables",
label: (
<Link to="/manage/accounting/payables">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-payables")}
</LockWrapper>
</Link>
)
});
}
if (!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber))) {
accountingExportChildren.push({
key: "payments",
id: "header-accounting-payments",
label: (
<Link to="/manage/accounting/payments">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-payments")}
</LockWrapper>
</Link>
)
});
}
accountingExportChildren.push(
{
type: "divider"
},
{
key: "exportlogs",
id: "header-accounting-exportlogs",
label: (
<Link to="/manage/accounting/exportlogs">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.export-logs")}
</LockWrapper>
</Link>
)
}
);
accountingChildren.push({
key: "accountingexport",
id: "header-accounting-export",
icon: <ExportOutlined />,
label: (
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.export")}
</LockWrapper>
),
children: accountingExportChildren
});
// Define all menu items
const menuItems = [
// Left menu items (includes original navigation items)
const leftMenuItems = [
{
key: "home",
icon: <HomeFilled />,
id: "header-home",
icon: <HomeFilled />,
label: <Link to="/manage/">{t("menus.header.home")}</Link>
},
{
key: "schedule",
id: "header-schedule",
icon: <Icon component={FaCalendarAlt} />,
icon: <FaCalendarAlt />,
label: <Link to="/manage/schedule">{t("menus.header.schedule")}</Link>
},
{
key: "jobssubmenu",
id: "header-jobs",
icon: <Icon component={FaCarCrash} />,
icon: <FaCarCrash />,
label: t("menus.header.jobs"),
children: [
{
@@ -456,20 +369,14 @@ function Header({
icon: <FileAddOutlined />,
label: <Link to="/manage/jobs/new">{t("menus.header.newjob")}</Link>
},
{
type: "divider",
id: "header-jobs-divider"
},
{ type: "divider" },
{
key: "alljobs",
id: "header-all-jobs",
icon: <UnorderedListOutlined />,
label: <Link to="/manage/jobs/all">{t("menus.header.alljobs")}</Link>
},
{
type: "divider",
id: "header-jobs-divider2"
},
{ type: "divider" },
{
key: "productionlist",
id: "header-production-list",
@@ -479,7 +386,7 @@ function Header({
{
key: "productionboard",
id: "header-production-board",
icon: <Icon component={BsKanban} />,
icon: <BsKanban />,
label: (
<Link to="/manage/production/board">
<LockWrapper featureName="visualboard" bodyshop={bodyshop}>
@@ -488,10 +395,7 @@ function Header({
</Link>
)
},
{
type: "divider",
id: "header-jobs-divider3"
},
{ type: "divider" },
{
key: "scoreboard",
id: "header-scoreboard",
@@ -508,8 +412,8 @@ function Header({
},
{
key: "customers",
icon: <UserOutlined />,
id: "header-customers",
icon: <UserOutlined />,
label: t("menus.header.customers"),
children: [
{
@@ -614,12 +518,7 @@ function Header({
id: "header-create-task",
icon: <PlusCircleOutlined />,
label: t("menus.header.create_task"),
onClick: () => {
setTaskUpsertContext({
actions: {},
context: {}
});
}
onClick: () => setTaskUpsertContext({ actions: {}, context: {} })
},
{
key: "mytasks",
@@ -644,7 +543,7 @@ function Header({
{
key: "shop",
id: "header-shop",
icon: <Icon component={GiSettingsKnobs} />,
icon: <GiSettingsKnobs />,
label: <Link to="/manage/shop?tab=info">{t("menus.header.shop_config")}</Link>
},
{
@@ -662,23 +561,18 @@ function Header({
id: "header-reportcenter",
icon: <BarChartOutlined />,
label: t("menus.header.reportcenter"),
onClick: () => {
setReportCenterContext({
actions: {},
context: {}
});
}
onClick: () => setReportCenterContext({ actions: {}, context: {} })
},
{
key: "shop-vendors",
id: "header-shop-vendors",
icon: <Icon component={IoBusinessOutline} />,
icon: <IoBusinessOutline />,
label: <Link to="/manage/shop/vendors">{t("menus.header.shop_vendors")}</Link>
},
{
key: "shop-csi",
id: "header-shop-csi",
icon: <Icon component={RiSurveyLine} />,
icon: <RiSurveyLine />,
label: (
<Link to="/manage/shop/csi">
<LockWrapper featureName="export" bodyshop={bodyshop}>
@@ -689,23 +583,10 @@ function Header({
}
]
},
// Right-aligned items on desktop, merged on mobile
{
key: "notifications",
icon: unreadLoading ? (
<Spin size="small" />
) : (
<Badge count={unreadCount}>
<BellFilled />
</Badge>
),
id: "header-notifications",
onClick: handleNotificationClick
},
{
key: "recent",
icon: <ClockCircleFilled />,
id: "header-recent",
icon: <ClockCircleFilled />,
children: recentItems.map((i, idx) => ({
key: idx,
id: `header-recent-${idx}`,
@@ -714,13 +595,13 @@ function Header({
},
{
key: "user",
id: "header-user",
icon: <UserOutlined />,
// label: currentUser.displayName || currentUser.email || t("general.labels.unknown"),
children: [
{
key: "signout",
id: "header-signout",
icon: <Icon component={FiLogOut} />,
icon: <FiLogOut />,
danger: true,
label: t("user.actions.signout"),
onClick: () => signOutStart()
@@ -728,32 +609,25 @@ function Header({
{
key: "help",
id: "header-help",
icon: <Icon component={QuestionCircleFilled} />,
icon: <QuestionCircleFilled />,
label: t("menus.header.help"),
onClick: () => {
window.open("https://help.imex.online/", "_blank");
}
onClick: () => window.open("https://help.imex.online/", "_blank")
},
...(InstanceRenderManager({
imex: true,
rome: false
})
...(InstanceRenderManager({ imex: true, rome: false })
? [
{
key: "rescue",
id: "header-rescue",
icon: <Icon component={CarFilled} />,
icon: <CarFilled />,
label: t("menus.header.rescueme"),
onClick: () => {
window.open("https://imexrescue.com/", "_blank");
}
onClick: () => window.open("https://imexrescue.com/", "_blank")
}
]
: []),
{
key: "shiftclock",
id: "header-shiftclock",
icon: <Icon component={GiPlayerTime} />,
icon: <GiPlayerTime />,
label: (
<Link to="/manage/shiftclock">
<LockWrapper featureName="export" bodyshop={bodyshop}>
@@ -772,58 +646,59 @@ function Header({
}
];
// Notifications item (always on the right)
const notificationItem = [
{
key: "notifications",
id: "header-notifications",
icon: unreadLoading ? (
<Spin size="small" />
) : (
<Badge count={unreadCount}>
<BellFilled />
</Badge>
),
onClick: handleNotificationClick
}
];
return (
<Layout.Header style={{ padding: 0 }}>
{isMobile ? (
<Layout.Header style={{ padding: 0, background: "#001529" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
height: "100%",
overflow: "hidden"
}}
>
<Menu
mode="horizontal"
theme="dark"
selectedKeys={[selectedHeader]}
onClick={handleMenuClick}
subMenuCloseDelay={0.3}
items={menuItems}
style={{ width: "100%" }}
/>
) : (
<div
items={leftMenuItems}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
width: "100%"
flex: "1 1 auto",
minWidth: 0,
overflowX: "auto",
borderBottom: "none",
background: "transparent"
}}
>
<Menu
mode="horizontal"
theme="dark"
selectedKeys={[selectedHeader]}
onClick={handleMenuClick}
subMenuCloseDelay={0.3}
items={menuItems.slice(0, -3)}
style={{
flex: "0 1 auto",
justifyContent: "flex-start",
minWidth: 0,
overflow: "visible"
}}
/>
<div style={{ flex: "1 0 0" }} />
<Menu
mode="horizontal"
theme="dark"
selectedKeys={[selectedHeader]}
onClick={handleMenuClick}
subMenuCloseDelay={0.3}
items={menuItems.slice(-3)}
style={{
flex: "0 0 auto",
justifyContent: "flex-end",
overflow: "visible"
}}
/>
<NotificationCenterContainer visible={notificationVisible} onClose={() => setNotificationVisible(false)} />
</div>
)}
/>
<Menu
mode="horizontal"
theme="dark"
selectedKeys={[selectedHeader]}
onClick={handleMenuClick}
subMenuCloseDelay={0.3}
items={notificationItem}
style={{ flex: "0 0 auto", minWidth: 0, borderBottom: "none", background: "transparent" }}
/>
</div>
<NotificationCenterContainer visible={notificationVisible} onClose={() => setNotificationVisible(false)} />
</Layout.Header>
);
}

View File

@@ -16,7 +16,7 @@ const { dispatchEmailsToQueue } = require("./queues/emailQueue");
const { dispatchAppsToQueue } = require("./queues/appQueue");
// If true, the user who commits the action will NOT receive notifications; if false, they will.
const FILTER_SELF_FROM_WATCHERS = (() => process.env.NODE_ENV === "production")();
const FILTER_SELF_FROM_WATCHERS = process.env?.FILTER_SELF_FROM_WATCHERS === "true";
/**
* Parses an event and determines matching scenarios for notifications.