Merged in feature/IO-3228-Notifications-1.6-and-Deprecations (pull request #2308)

feature/IO-3228-Notifications-1.6-and-Deprecations
This commit is contained in:
Dave Richer
2025-05-09 14:39:59 +00:00
41 changed files with 486 additions and 377 deletions

View File

@@ -25,9 +25,9 @@
"@sentry/vite-plugin": "^3.4.0", "@sentry/vite-plugin": "^3.4.0",
"@splitsoftware/splitio-react": "^2.1.1", "@splitsoftware/splitio-react": "^2.1.1",
"@tanem/react-nprogress": "^5.0.53", "@tanem/react-nprogress": "^5.0.53",
"antd": "^5.25.0", "antd": "^5.25.1",
"apollo-link-logger": "^2.0.1", "apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^4.2.0", "apollo-link-sentry": "^4.3.0",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.8.4", "axios": "^1.8.4",
"classnames": "^2.5.1", "classnames": "^2.5.1",
@@ -6088,9 +6088,9 @@
} }
}, },
"node_modules/antd": { "node_modules/antd": {
"version": "5.25.0", "version": "5.25.1",
"resolved": "https://registry.npmjs.org/antd/-/antd-5.25.0.tgz", "resolved": "https://registry.npmjs.org/antd/-/antd-5.25.1.tgz",
"integrity": "sha512-p9d8Kuj/bipjNdg9NrTu1VmTrhcwIhURu2NfK6qaBMbb+LRyFdAUoseT+7J4a+5z3jNVjxH5zaYv/45Zf8Coyg==", "integrity": "sha512-4KC7KuPCjr0z3Vuw9DsF+ceqJaPLbuUI3lOX1sY8ix25ceamp+P8yxOmk3Y2JHCD2ZAhq+5IQ/DTJRN2adWYKQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ant-design/colors": "^7.2.0", "@ant-design/colors": "^7.2.0",
@@ -6128,7 +6128,7 @@
"rc-rate": "~2.13.1", "rc-rate": "~2.13.1",
"rc-resize-observer": "^1.4.3", "rc-resize-observer": "^1.4.3",
"rc-segmented": "~2.7.0", "rc-segmented": "~2.7.0",
"rc-select": "~14.16.6", "rc-select": "~14.16.7",
"rc-slider": "~11.1.8", "rc-slider": "~11.1.8",
"rc-steps": "~6.0.1", "rc-steps": "~6.0.1",
"rc-switch": "~4.1.0", "rc-switch": "~4.1.0",
@@ -6232,9 +6232,9 @@
} }
}, },
"node_modules/apollo-link-sentry": { "node_modules/apollo-link-sentry": {
"version": "4.2.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/apollo-link-sentry/-/apollo-link-sentry-4.2.0.tgz", "resolved": "https://registry.npmjs.org/apollo-link-sentry/-/apollo-link-sentry-4.3.0.tgz",
"integrity": "sha512-w8EUM4aEw1/VxIB3KOP11T8qz44oWRcbXRd2vJq/qHnfRMKS5HkMerSIYwKN2e8k9H8ubfkwBvStH51CVf4wwg==", "integrity": "sha512-C3WK4iwIzW5vC5BoY3VPdKjm16P6ca/LGKFnxg6PvUuboxPlqs7LHQCYvEsdAxBkoY+8kRXd8Q3+3oU+HHUceA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"deepmerge": "^4.2.2", "deepmerge": "^4.2.2",
@@ -14024,9 +14024,9 @@
} }
}, },
"node_modules/rc-select": { "node_modules/rc-select": {
"version": "14.16.6", "version": "14.16.7",
"resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.6.tgz", "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.7.tgz",
"integrity": "sha512-YPMtRPqfZWOm2XGTbx5/YVr1HT0vn//8QS77At0Gjb3Lv+Lbut0IORJPKLWu1hQ3u4GsA0SrDzs7nI8JG7Zmyg==", "integrity": "sha512-lT9kO5gFHQdJzu9a0btcOtNaJHkhenSl8H5mcpgXN9VIMXP59rnkpbdHmPrteixWs1D5zFOTyoTYX3b7joADIQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.10.1", "@babel/runtime": "^7.10.1",

View File

@@ -24,9 +24,9 @@
"@sentry/vite-plugin": "^3.4.0", "@sentry/vite-plugin": "^3.4.0",
"@splitsoftware/splitio-react": "^2.1.1", "@splitsoftware/splitio-react": "^2.1.1",
"@tanem/react-nprogress": "^5.0.53", "@tanem/react-nprogress": "^5.0.53",
"antd": "^5.25.0", "antd": "^5.25.1",
"apollo-link-logger": "^2.0.1", "apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^4.2.0", "apollo-link-sentry": "^4.3.0",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.8.4", "axios": "^1.8.4",
"classnames": "^2.5.1", "classnames": "^2.5.1",

View File

@@ -14,8 +14,21 @@ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setPartsOrderContext: (context) => dispatch(setModalContext({ context: context, modal: "partsOrder" })), setPartsOrderContext: (context) =>
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type })) dispatch(
setModalContext({
context: context,
modal: "partsOrder"
})
),
insertAuditTrail: ({ jobid, operation, type }) =>
dispatch(
insertAuditTrail({
jobid,
operation,
type
})
)
}); });
export default connect(mapStateToProps, mapDispatchToProps)(BillDetailEditReturn); export default connect(mapStateToProps, mapDispatchToProps)(BillDetailEditReturn);
@@ -69,7 +82,7 @@ export function BillDetailEditReturn({ setPartsOrderContext, insertAuditTrail, b
<Modal <Modal
open={open} open={open}
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
destroyOnClose destroyOnHidden
title={t("bills.actions.return")} title={t("bills.actions.return")}
onOk={() => form.submit()} onOk={() => form.submit()}
> >

View File

@@ -29,7 +29,7 @@ export default function BillDetailEditcontainer() {
delete search.billid; delete search.billid;
history({ search: queryString.stringify(search) }); history({ search: queryString.stringify(search) });
}} }}
destroyOnClose destroyOnHidden
open={search.billid} open={search.billid}
> >
<BillDetailEditComponent /> <BillDetailEditComponent />

View File

@@ -412,7 +412,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
)} )}
</Space> </Space>
} }
destroyOnClose destroyOnHidden
> >
<Form <Form
onFinish={handleFinish} onFinish={handleFinish}

View File

@@ -75,7 +75,7 @@ export function ContractsFindModalContainer({ caBcEtfTableModal, toggleModalVisi
title={t("payments.labels.findermodal")} title={t("payments.labels.findermodal")}
onCancel={() => toggleModalVisible()} onCancel={() => toggleModalVisible()}
onOk={() => toggleModalVisible()} onOk={() => toggleModalVisible()}
destroyOnClose destroyOnHidden
forceRender forceRender
> >
<Form form={form} layout="vertical" autoComplete="no" onFinish={handleFinish}> <Form form={form} layout="vertical" autoComplete="no" onFinish={handleFinish}>

View File

@@ -40,7 +40,7 @@ function CardPaymentModalContainer({ cardPaymentModal, toggleModalVisible, bodys
</Button> </Button>
]} ]}
width="80%" width="80%"
destroyOnClose destroyOnHidden
> >
<CardPaymentModalComponent /> <CardPaymentModalComponent />
</Modal> </Modal>

View File

@@ -63,7 +63,7 @@ export function ContractsFindModalContainer({
title={t("contracts.labels.findermodal")} title={t("contracts.labels.findermodal")}
onCancel={() => toggleModalVisible()} onCancel={() => toggleModalVisible()}
onOk={() => toggleModalVisible()} onOk={() => toggleModalVisible()}
destroyOnClose destroyOnHidden
forceRender forceRender
> >
<Form form={form} layout="vertical" autoComplete="no" onFinish={handleFinish}> <Form form={form} layout="vertical" autoComplete="no" onFinish={handleFinish}>

View File

@@ -152,7 +152,7 @@ export function EmailOverlayContainer({ emailConfig, modalVisible, toggleEmailOv
}, [modalVisible]); // eslint-disable-line react-hooks/exhaustive-deps }, [modalVisible]); // eslint-disable-line react-hooks/exhaustive-deps
return ( return (
<Modal <Modal
destroyOnClose={true} destroyOnHidden
open={modalVisible} open={modalVisible}
maskClosable={false} maskClosable={false}
width={"80%"} width={"80%"}

View File

@@ -51,6 +51,7 @@ import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapper from "../lock-wrapper/lock-wrapper.component"; import LockWrapper from "../lock-wrapper/lock-wrapper.component";
import NotificationCenterContainer from "../notification-center/notification-center.container.jsx"; import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js"; import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
// Redux mappings // Redux mappings
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
@@ -98,6 +99,7 @@ function Header({
const baseTitleRef = useRef(document.title || ""); const baseTitleRef = useRef(document.title || "");
const lastSetTitleRef = useRef(""); const lastSetTitleRef = useRef("");
const userAssociationId = bodyshop?.associations?.[0]?.id; const userAssociationId = bodyshop?.associations?.[0]?.id;
const isEmployee = useIsEmployee(bodyshop, currentUser);
const { const {
data: unreadData, data: unreadData,
@@ -682,7 +684,7 @@ function Header({
icon: unreadLoading ? ( icon: unreadLoading ? (
<Spin size="small" /> <Spin size="small" />
) : ( ) : (
<Badge offset={[8, 0]} size="small" count={unreadCount}> <Badge offset={[8, 0]} size="small" count={isEmployee ? unreadCount : 0}>
<BellFilled /> <BellFilled />
</Badge> </Badge>
), ),

View File

@@ -98,7 +98,7 @@ export function InventoryUpsertModalContainer({ currentUser, bodyshop, inventory
onCancel={() => { onCancel={() => {
toggleModalVisible(); toggleModalVisible();
}} }}
destroyOnClose destroyOnHidden
> >
<Form form={form} onFinish={handleFinish} layout="vertical"> <Form form={form} onFinish={handleFinish} layout="vertical">
<InventoryUpsertModal form={form} /> <InventoryUpsertModal form={form} />

View File

@@ -49,7 +49,7 @@ export function JobCostingModalContainer({ jobCostingModal, toggleModalVisible }
}} }}
cancelButtonProps={{ style: { display: "none" } }} cancelButtonProps={{ style: { display: "none" } }}
width="90%" width="90%"
destroyOnClose destroyOnHidden
> >
{!costingData ? ( {!costingData ? (
<LoadingSpinner loading={true} /> <LoadingSpinner loading={true} />

View File

@@ -32,7 +32,13 @@ const mapStateToProps = createStructuredSelector({
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setPrintCenterContext: (context) => dispatch(setModalContext({ context: context, modal: "printCenter" })), setPrintCenterContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "printCenter"
})
),
insertAuditTrail: ({ jobid, operation, type }) => insertAuditTrail: ({ jobid, operation, type }) =>
dispatch( dispatch(
insertAuditTrail({ insertAuditTrail({
@@ -87,7 +93,7 @@ export function JobDetailCards({ bodyshop, setPrintCenterContext, insertAuditTra
}; };
return ( return (
<Drawer open={!!selected} destroyOnClose width={drawerPercentage} placement="right" onClose={handleDrawerClose}> <Drawer open={!!selected} destroyOnHidden width={drawerPercentage} placement="right" onClose={handleDrawerClose}>
{loading ? <LoadingSpinner /> : null} {loading ? <LoadingSpinner /> : null}
{error ? <AlertComponent message={error.message} type="error" /> : null} {error ? <AlertComponent message={error.message} type="error" /> : null}
{data ? ( {data ? (

View File

@@ -44,7 +44,7 @@ function JobReconciliationModalContainer({ reconciliationModal, toggleModalVisib
onOk={handleCancel} onOk={handleCancel}
onCancel={handleCancel} onCancel={handleCancel}
cancelButtonProps={{ display: "none" }} cancelButtonProps={{ display: "none" }}
destroyOnClose destroyOnHidden
className="imex-reconciliation-modal" className="imex-reconciliation-modal"
> >
{loading && <LoadingSpinner loading={loading} />} {loading && <LoadingSpinner loading={loading} />}

View File

@@ -24,7 +24,8 @@ export default function JobWatcherToggleComponent({
handleToggleSelf, handleToggleSelf,
handleRemoveWatcher, handleRemoveWatcher,
handleWatcherSelect, handleWatcherSelect,
handleTeamSelect handleTeamSelect,
isEmployee
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -66,22 +67,32 @@ export default function JobWatcherToggleComponent({
<List> <List>
<List.Item <List.Item
actions={[ actions={[
<Button <Tooltip title={!isEmployee ? t("notifications.tooltips.not-employee") : ""} placement="top">
type={isWatching ? "primary" : "default"} <span>
danger={!isWatching} <Button
icon={isWatching ? <EyeOutlined /> : <EyeFilled />} type={isWatching ? "primary" : "default"}
size="medium" danger={!isWatching}
onClick={handleToggleSelf} icon={isWatching ? <EyeOutlined /> : <EyeFilled />}
loading={adding || removing} size="medium"
> onClick={handleToggleSelf}
{isWatching ? t("notifications.labels.unwatch") : t("notifications.labels.watch")} loading={adding || removing}
</Button> disabled={!isEmployee || adding || removing}
>
{isWatching ? t("notifications.labels.unwatch") : t("notifications.labels.watch")}
</Button>
</span>
</Tooltip>
]} ]}
> >
<List.Item.Meta> <List.Item.Meta>
<Text type="secondary" style={{ marginBottom: 8, display: "block" }}> <Text type="secondary" style={{ marginBottom: 8, display: "block" }}>
{t("notifications.labels.watching-issue")} {t("notifications.labels.watching-issue")}
</Text> </Text>
{!isEmployee && (
<Text type="danger" style={{ marginBottom: 8, display: "block" }}>
{t("notifications.tooltips.not-employee")}
</Text>
)}
</List.Item.Meta> </List.Item.Meta>
</List.Item> </List.Item>
</List> </List>
@@ -98,8 +109,11 @@ export default function JobWatcherToggleComponent({
<EmployeeSearchSelectComponent <EmployeeSearchSelectComponent
style={{ minWidth: "100%" }} style={{ minWidth: "100%" }}
options={ options={
bodyshop?.employees?.filter((e) => bodyshop?.employees?.filter(
jobWatchers.every((w) => w.user_email !== e.user_email && e.active && e.user_email) (e) =>
e.user_email && // Ensure user_email is not null or undefined
e.active && // Ensure employee is active
jobWatchers.every((w) => w.user_email !== e.user_email) // Ensure not already a watcher
) || [] ) || []
} }
placeholder={t("notifications.labels.employee-search")} placeholder={t("notifications.labels.employee-search")}

View File

@@ -6,6 +6,7 @@ import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import JobWatcherToggleComponent from "./job-watcher-toggle.component.jsx"; import JobWatcherToggleComponent from "./job-watcher-toggle.component.jsx";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -21,13 +22,14 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
splitKey: bodyshop && bodyshop.imexshopid splitKey: bodyshop && bodyshop.imexshopid
}); });
const userEmail = currentUser.email; const isEmployee = useIsEmployee(bodyshop, currentUser);
const jobid = job.id;
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [selectedWatcher, setSelectedWatcher] = useState(null); const [selectedWatcher, setSelectedWatcher] = useState(null);
const [selectedTeam, setSelectedTeam] = useState(null); const [selectedTeam, setSelectedTeam] = useState(null);
const userEmail = currentUser.email;
const jobid = job.id;
// Fetch current watchers with refetch capability // Fetch current watchers with refetch capability
const { const {
data: watcherData, data: watcherData,
@@ -139,13 +141,13 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
}); });
const handleToggleSelf = useCallback(async () => { const handleToggleSelf = useCallback(async () => {
if (adding || removing) return; if (adding || removing || !isEmployee) return;
if (isWatching) { if (isWatching) {
await removeWatcher({ variables: { jobid, userEmail } }); await removeWatcher({ variables: { jobid, userEmail } });
} else { } else {
await addWatcher({ variables: { jobid, userEmail } }); await addWatcher({ variables: { jobid, userEmail } });
} }
}, [isWatching, addWatcher, removeWatcher, jobid, userEmail, adding, removing]); }, [isWatching, addWatcher, removeWatcher, jobid, userEmail, adding, removing, isEmployee]);
const handleRemoveWatcher = useCallback( const handleRemoveWatcher = useCallback(
async (email) => { async (email) => {
@@ -187,7 +189,16 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
setSelectedTeam(null); setSelectedTeam(null);
return; return;
} }
await Promise.all(newWatchers.map((email) => addWatcher({ variables: { jobid, userEmail: email } }))); await Promise.all(
newWatchers.map((email) =>
addWatcher({
variables: {
jobid,
userEmail: email
}
})
)
);
}, },
[jobWatchers, addWatcher, jobid, adding] [jobWatchers, addWatcher, jobid, adding]
); );
@@ -212,6 +223,7 @@ function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
handleWatcherSelect={handleWatcherSelect} handleWatcherSelect={handleWatcherSelect}
handleTeamSelect={handleTeamSelect} handleTeamSelect={handleTeamSelect}
currentUser={currentUser} currentUser={currentUser}
isEmployee={isEmployee} // Pass isEmployee to the component
/> />
); );
} }

View File

@@ -65,7 +65,7 @@ export default connect(
<Modal <Modal
title={t("jobs.labels.existing_jobs")} title={t("jobs.labels.existing_jobs")}
width={"80%"} width={"80%"}
destroyOnClose destroyOnHidden
okButtonProps={{ disabled: selectedJob ? false : true }} okButtonProps={{ disabled: selectedJob ? false : true }}
{...modalProps} {...modalProps}
> >

View File

@@ -20,7 +20,14 @@ const mapStateToProps = createStructuredSelector({
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("noteUpsert")), toggleModalVisible: () => dispatch(toggleModalVisible("noteUpsert")),
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type })) insertAuditTrail: ({ jobid, operation, type }) =>
dispatch(
insertAuditTrail({
jobid,
operation,
type
})
)
}); });
export function NoteUpsertModalContainer({ currentUser, noteUpsertModal, toggleModalVisible, insertAuditTrail }) { export function NoteUpsertModalContainer({ currentUser, noteUpsertModal, toggleModalVisible, insertAuditTrail }) {
@@ -123,7 +130,7 @@ export function NoteUpsertModalContainer({ currentUser, noteUpsertModal, toggleM
onCancel={() => { onCancel={() => {
toggleModalVisible(); toggleModalVisible();
}} }}
destroyOnClose destroyOnHidden
> >
<Form form={form} onFinish={handleFinish} layout="vertical"> <Form form={form} onFinish={handleFinish} layout="vertical">
<NoteUpsertModalComponent form={form} /> <NoteUpsertModalComponent form={form} />

View File

@@ -1,11 +1,11 @@
import { Virtuoso } from "react-virtuoso"; import { Virtuoso } from "react-virtuoso";
import { Badge, Button, Space, Spin, Switch, Tooltip, Typography } from "antd"; import { Alert, Badge, Button, Space, Spin, Switch, Tooltip, Typography } from "antd";
import { CheckCircleFilled, CheckCircleOutlined, EyeFilled, EyeOutlined } from "@ant-design/icons"; import { CheckCircleFilled, CheckCircleOutlined, EyeFilled, EyeOutlined } from "@ant-design/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import "./notification-center.styles.scss"; import "./notification-center.styles.scss";
import day from "../../utils/day.js"; import day from "../../utils/day.js";
import { forwardRef, useRef, useEffect } from "react"; import { forwardRef, useEffect, useRef } from "react";
import { DateTimeFormat } from "../../utils/DateFormatter.jsx"; import { DateTimeFormat } from "../../utils/DateFormatter.jsx";
const { Text, Title } = Typography; const { Text, Title } = Typography;
@@ -26,7 +26,8 @@ const NotificationCenterComponent = forwardRef(
markAllRead, markAllRead,
loadMore, loadMore,
onNotificationClick, onNotificationClick,
unreadCount unreadCount,
isEmployee
}, },
ref ref
) => { ) => {
@@ -93,7 +94,12 @@ const NotificationCenterComponent = forwardRef(
) : ( ) : (
<EyeOutlined className="notification-toggle-icon" /> <EyeOutlined className="notification-toggle-icon" />
)} )}
<Switch checked={showUnreadOnly} onChange={(checked) => toggleUnreadOnly(checked)} size="small" /> <Switch
checked={showUnreadOnly}
onChange={(checked) => toggleUnreadOnly(checked)}
size="small"
disabled={!isEmployee}
/>
</Space> </Space>
</Tooltip> </Tooltip>
<Tooltip title={t("notifications.labels.mark-all-read")}> <Tooltip title={t("notifications.labels.mark-all-read")}>
@@ -106,14 +112,20 @@ const NotificationCenterComponent = forwardRef(
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<Virtuoso {!isEmployee ? (
ref={virtuosoRef} <div style={{ padding: 10 }}>
style={{ height: "400px", width: "100%" }} <Alert message={t("notifications.labels.employee-notification")} type="warning" />
data={notifications} </div>
totalCount={notifications.length} ) : (
endReached={loadMore} <Virtuoso
itemContent={renderNotification} ref={virtuosoRef}
/> style={{ height: "400px", width: "100%" }}
data={notifications}
totalCount={notifications.length}
endReached={loadMore}
itemContent={renderNotification}
/>
)}
</div> </div>
); );
} }

View File

@@ -4,9 +4,10 @@ import { connect } from "react-redux";
import NotificationCenterComponent from "./notification-center.component"; import NotificationCenterComponent from "./notification-center.component";
import { GET_NOTIFICATIONS } from "../../graphql/notifications.queries"; import { GET_NOTIFICATIONS } from "../../graphql/notifications.queries";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
import day from "../../utils/day.js"; import day from "../../utils/day.js";
import { INITIAL_NOTIFICATIONS, useSocket } from "../../contexts/SocketIO/useSocket.js"; import { INITIAL_NOTIFICATIONS, useSocket } from "../../contexts/SocketIO/useSocket.js";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
// This will be used to poll for notifications when the socket is disconnected // This will be used to poll for notifications when the socket is disconnected
const NOTIFICATION_POLL_INTERVAL_SECONDS = 60; const NOTIFICATION_POLL_INTERVAL_SECONDS = 60;
@@ -17,17 +18,18 @@ const NOTIFICATION_POLL_INTERVAL_SECONDS = 60;
* @param onClose * @param onClose
* @param bodyshop * @param bodyshop
* @param unreadCount * @param unreadCount
* @param currentUser
* @returns {JSX.Element} * @returns {JSX.Element}
* @constructor * @constructor
*/ */
const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }) => { const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount, currentUser }) => {
const [showUnreadOnly, setShowUnreadOnly] = useState(false); const [showUnreadOnly, setShowUnreadOnly] = useState(false);
const [notifications, setNotifications] = useState([]); const [notifications, setNotifications] = useState([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { isConnected, markNotificationRead, markAllNotificationsRead } = useSocket(); const { isConnected, markNotificationRead, markAllNotificationsRead } = useSocket();
const notificationRef = useRef(null); const notificationRef = useRef(null);
const userAssociationId = bodyshop?.associations?.[0]?.id; const userAssociationId = bodyshop?.associations?.[0]?.id;
const isEmployee = useIsEmployee(bodyshop, currentUser);
const baseWhereClause = useMemo(() => { const baseWhereClause = useMemo(() => {
return { associationid: { _eq: userAssociationId } }; return { associationid: { _eq: userAssociationId } };
@@ -51,7 +53,7 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }
fetchPolicy: "cache-and-network", fetchPolicy: "cache-and-network",
notifyOnNetworkStatusChange: true, notifyOnNetworkStatusChange: true,
pollInterval: isConnected ? 0 : day.duration(NOTIFICATION_POLL_INTERVAL_SECONDS, "seconds").asMilliseconds(), pollInterval: isConnected ? 0 : day.duration(NOTIFICATION_POLL_INTERVAL_SECONDS, "seconds").asMilliseconds(),
skip: !userAssociationId, skip: !userAssociationId || !isEmployee,
onError: (err) => { onError: (err) => {
console.error(`Error polling Notifications: ${err?.message || ""}`); console.error(`Error polling Notifications: ${err?.message || ""}`);
setTimeout(() => refetch(), day.duration(2, "seconds").asMilliseconds()); setTimeout(() => refetch(), day.duration(2, "seconds").asMilliseconds());
@@ -71,7 +73,7 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }
}, [visible, onClose]); }, [visible, onClose]);
useEffect(() => { useEffect(() => {
if (data?.notifications) { if (data?.notifications && isEmployee) {
const processedNotifications = data.notifications const processedNotifications = data.notifications
.map((notif) => { .map((notif) => {
let scenarioText; let scenarioText;
@@ -101,11 +103,13 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }
}) })
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); .sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
setNotifications(processedNotifications); setNotifications(processedNotifications);
} else if (!isEmployee) {
setNotifications([]); // Clear notifications if not an employee
} }
}, [data]); }, [data, isEmployee]);
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
if (!queryLoading && data?.notifications.length) { if (!queryLoading && data?.notifications.length && isEmployee) {
setIsLoading(true); // Show spinner during fetchMore setIsLoading(true); // Show spinner during fetchMore
fetchMore({ fetchMore({
variables: { offset: data.notifications.length, where: whereClause }, variables: { offset: data.notifications.length, where: whereClause },
@@ -121,13 +125,14 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }
}) })
.finally(() => setIsLoading(false)); // Hide spinner when done .finally(() => setIsLoading(false)); // Hide spinner when done
} }
}, [data?.notifications?.length, fetchMore, queryLoading, whereClause]); }, [data?.notifications?.length, fetchMore, queryLoading, whereClause, isEmployee]);
const handleToggleUnreadOnly = (value) => { const handleToggleUnreadOnly = (value) => {
setShowUnreadOnly(value); setShowUnreadOnly(value);
}; };
const handleMarkAllRead = useCallback(() => { const handleMarkAllRead = useCallback(() => {
if (!isEmployee) return; // Do nothing if not an employee
setIsLoading(true); setIsLoading(true);
markAllNotificationsRead() markAllNotificationsRead()
.then(() => { .then(() => {
@@ -147,7 +152,7 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }
}) })
.catch((e) => console.error(`Error marking all notifications read: ${e?.message || ""}`)) .catch((e) => console.error(`Error marking all notifications read: ${e?.message || ""}`))
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}, [markAllNotificationsRead, userAssociationId, showUnreadOnly]); }, [markAllNotificationsRead, userAssociationId, showUnreadOnly, isEmployee]);
const handleNotificationClick = useCallback( const handleNotificationClick = useCallback(
(notificationId) => { (notificationId) => {
@@ -170,17 +175,18 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }
); );
useEffect(() => { useEffect(() => {
if (visible && !isConnected) { if (visible && !isConnected && isEmployee) {
setIsLoading(true); setIsLoading(true);
refetch() refetch()
.catch((err) => console.error(`Error re-fetching notifications: ${err?.message || ""}`)) .catch((err) => console.error(`Error re-fetching notifications: ${err?.message || ""}`))
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
} }
}, [visible, isConnected, refetch]); }, [visible, isConnected, refetch, isEmployee]);
return ( return (
<NotificationCenterComponent <NotificationCenterComponent
ref={notificationRef} ref={notificationRef}
isEmployee={isEmployee}
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
notifications={notifications} notifications={notifications}
@@ -196,7 +202,8 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }
}; };
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop,
currentUser: selectCurrentUser
}); });
export default connect(mapStateToProps, null)(NotificationCenterContainer); export default connect(mapStateToProps, null)(NotificationCenterContainer);

View File

@@ -1,10 +1,10 @@
import { useMutation, useQuery } from "@apollo/client"; import { useMutation, useQuery } from "@apollo/client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button, Card, Checkbox, Divider, Form, Space, Switch, Table, Typography } from "antd"; import { Alert, Button, Card, Checkbox, Divider, Form, Space, Switch, Table, Typography } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectCurrentUser } from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import { import {
QUERY_NOTIFICATION_SETTINGS, QUERY_NOTIFICATION_SETTINGS,
@@ -16,14 +16,16 @@ import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx"; import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
/** /**
* Notifications Settings Form * Notifications Settings Form
* @param currentUser * @param currentUser
* @param bodyshop
* @returns {JSX.Element} * @returns {JSX.Element}
* @constructor * @constructor
*/ */
const NotificationSettingsForm = ({ currentUser }) => { const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [initialValues, setInitialValues] = useState({}); const [initialValues, setInitialValues] = useState({});
@@ -31,6 +33,7 @@ const NotificationSettingsForm = ({ currentUser }) => {
const [autoAddEnabled, setAutoAddEnabled] = useState(false); const [autoAddEnabled, setAutoAddEnabled] = useState(false);
const [initialAutoAdd, setInitialAutoAdd] = useState(false); const [initialAutoAdd, setInitialAutoAdd] = useState(false);
const notification = useNotification(); const notification = useNotification();
const isEmployee = useIsEmployee(bodyshop, currentUser);
// Fetch notification settings and notifications_autoadd // Fetch notification settings and notifications_autoadd
const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, { const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, {
@@ -199,6 +202,11 @@ const NotificationSettingsForm = ({ currentUser }) => {
</Space> </Space>
} }
> >
{!isEmployee && (
<div style={{ width: "100%", marginBottom: "10px" }}>
<Alert message={t("notifications.labels.employee-notification")} type="warning" />
</div>
)}
<Table dataSource={dataSource} columns={columns} pagination={false} bordered rowKey="key" /> <Table dataSource={dataSource} columns={columns} pagination={false} bordered rowKey="key" />
<Divider /> <Divider />
</Card> </Card>
@@ -209,11 +217,13 @@ const NotificationSettingsForm = ({ currentUser }) => {
NotificationSettingsForm.propTypes = { NotificationSettingsForm.propTypes = {
currentUser: PropTypes.shape({ currentUser: PropTypes.shape({
email: PropTypes.string.isRequired email: PropTypes.string.isRequired
}).isRequired }).isRequired,
bodyshop: PropTypes.object.isRequired
}; };
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser currentUser: selectCurrentUser,
bodyshop: selectBodyshop
}); });
export default connect(mapStateToProps)(NotificationSettingsForm); export default connect(mapStateToProps)(NotificationSettingsForm);

View File

@@ -333,7 +333,7 @@ export function PartsOrderModalContainer({
onOk={() => form.submit()} onOk={() => form.submit()}
okButtonProps={{ loading: saving }} okButtonProps={{ loading: saving }}
cancelButtonProps={{ loading: saving }} cancelButtonProps={{ loading: saving }}
destroyOnClose destroyOnHidden
width="75%" width="75%"
forceRender forceRender
> >

View File

@@ -46,7 +46,7 @@ export default function PartsQueueDetailCard() {
}; };
return ( return (
<Drawer open={!!selected} destroyOnClose width={drawerPercentage} placement="right" onClose={handleDrawerClose}> <Drawer open={!!selected} destroyOnHidden width={drawerPercentage} placement="right" onClose={handleDrawerClose}>
{loading ? <LoadingSpinner /> : null} {loading ? <LoadingSpinner /> : null}
{error ? <AlertComponent message={error.message} type="error" /> : null} {error ? <AlertComponent message={error.message} type="error" /> : null}
{data ? ( {data ? (

View File

@@ -90,7 +90,7 @@ export function PartsReceiveModalContainer({ partsReceiveModal, toggleModalVisib
onCancel={() => toggleModalVisible()} onCancel={() => toggleModalVisible()}
onOk={() => form.submit()} onOk={() => form.submit()}
okButtonProps={{ loading: loading }} okButtonProps={{ loading: loading }}
destroyOnClose destroyOnHidden
forceRender forceRender
width="50%" width="50%"
> >

View File

@@ -134,7 +134,7 @@ function PaymentModalContainer({ paymentModal, toggleModalVisible, bodyshop }) {
<Modal <Modal
title={!context || (context && !context.id) ? t("payments.labels.new") : t("payments.labels.edit")} title={!context || (context && !context.id) ? t("payments.labels.new") : t("payments.labels.edit")}
open={open} open={open}
destroyOnClose destroyOnHidden
okText={t("general.actions.save")} okText={t("general.actions.save")}
onOk={() => form.submit()} onOk={() => form.submit()}
width="50%" width="50%"

View File

@@ -32,7 +32,7 @@ export function PrintCenterModalContainer({ printCenterModal, toggleModalVisible
okText={t("general.actions.close")} okText={t("general.actions.close")}
width="90%" width="90%"
title={t("printcenter.labels.title")} title={t("printcenter.labels.title")}
destroyOnClose destroyOnHidden
> >
<PrintCenterModalComponent context={context} /> <PrintCenterModalComponent context={context} />
</Modal> </Modal>

View File

@@ -28,7 +28,7 @@ export function ReportCenterModalContainer({ reportCenterModal, toggleModalVisib
onOk={() => toggleModalVisible()} onOk={() => toggleModalVisible()}
onCancel={() => toggleModalVisible()} onCancel={() => toggleModalVisible()}
cancelButtonProps={{ style: { display: "none" } }} cancelButtonProps={{ style: { display: "none" } }}
destroyOnClose destroyOnHidden
width="80%" width="80%"
> >
<RbacWrapperComponent action="shop:reportcenter"> <RbacWrapperComponent action="shop:reportcenter">

View File

@@ -209,7 +209,7 @@ export function ScheduleJobModalContainer({
onOk={() => form.submit()} onOk={() => form.submit()}
width={"90%"} width={"90%"}
maskClosable={false} maskClosable={false}
destroyOnClose destroyOnHidden
okButtonProps={{ okButtonProps={{
loading: loading loading: loading
}} }}

View File

@@ -106,7 +106,7 @@ export default function ScoreboardJobsList({ scoreBoardlist }) {
<> <>
<Modal <Modal
open={state.open} open={state.open}
destroyOnClose destroyOnHidden
width="80%" width="80%"
closable={false} closable={false}
cancelButtonProps={{ style: { display: "none" } }} cancelButtonProps={{ style: { display: "none" } }}

View File

@@ -8,7 +8,7 @@ export default function ShopInfoNotificationsAutoadd({ bodyshop }) {
const { t } = useTranslation(); const { t } = useTranslation();
// Filter employee options to ensure active employees with valid IDs // Filter employee options to ensure active employees with valid IDs
const employeeOptions = bodyshop?.employees?.filter((e) => e.active && e.id && typeof e.id === "string") || []; const employeeOptions = bodyshop?.employees?.filter((e) => e.active && e.user_email && e.id) || [];
return ( return (
<div> <div>

View File

@@ -275,7 +275,7 @@ export function TaskUpsertModalContainer({ bodyshop, currentUser, taskUpsert, to
toggleModalVisible(); toggleModalVisible();
}} }}
okButtonProps={{ disabled: !isTouched }} okButtonProps={{ disabled: !isTouched }}
destroyOnClose destroyOnHidden
> >
<Form <Form
form={form} form={form}

View File

@@ -70,7 +70,7 @@ export function TechLookupJobsDrawer({ bodyshop, setPrintCenterContext }) {
}; };
return ( return (
<Drawer open={!!selected} destroyOnClose width={drawerPercentage} placement="right" onClose={handleDrawerClose}> <Drawer open={!!selected} destroyOnHidden width={drawerPercentage} placement="right" onClose={handleDrawerClose}>
{loading ? <LoadingSpinner /> : null} {loading ? <LoadingSpinner /> : null}
{error ? <AlertComponent message={error.message} type="error" /> : null} {error ? <AlertComponent message={error.message} type="error" /> : null}
{data ? ( {data ? (

View File

@@ -39,7 +39,7 @@ export function TimeTicketListTeamPay({ bodyshop, context, actions }) {
return ( return (
<> <>
<Modal width={"80%"} open={visible} destroyOnClose onOk={handleOk} onCancel={() => setVisible(false)}> <Modal width={"80%"} open={visible} destroyOnHidden onOk={handleOk} onCancel={() => setVisible(false)}>
<Form layout="vertical" form={form} initialValues={{ jobid: jobId }}> <Form layout="vertical" form={form} initialValues={{ jobid: jobId }}>
<LayoutFormRow grow noDivider> <LayoutFormRow grow noDivider>
<Form.Item shouldUpdate> <Form.Item shouldUpdate>

View File

@@ -181,7 +181,7 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
)} )}
</Space> </Space>
} }
destroyOnClose destroyOnHidden
id="time-ticket-modal" id="time-ticket-modal"
> >
<Form <Form

View File

@@ -119,7 +119,7 @@ export function TimeTickeTaskModalContainer({
return ( return (
<Modal <Modal
destroyOnClose destroyOnHidden
open={open} open={open}
onCancel={() => { onCancel={() => {
toggleModalVisible(); toggleModalVisible();

View File

@@ -2474,7 +2474,8 @@
"teams-search": "Search for a Team", "teams-search": "Search for a Team",
"unwatch": "Unwatch", "unwatch": "Unwatch",
"watch": "Watch", "watch": "Watch",
"watching-issue": "Watching" "watching-issue": "Watching",
"employee-notification": "Notifications are disabled because you do not have an associated Employee record."
}, },
"scenarios": { "scenarios": {
"alternate-transport-changed": "Alternate Transport Changed", "alternate-transport-changed": "Alternate Transport Changed",
@@ -2494,7 +2495,9 @@
"tasks-updated-created": "Tasks Updated / Created" "tasks-updated-created": "Tasks Updated / Created"
}, },
"tooltips": { "tooltips": {
"job-watchers": "Job Watchers" "job-watchers": "Job Watchers",
"not-employee": "You need to be an employee to watch this job. Reach out to your admin to get set up!",
"not-employee-notifications": "You must be an employee to receive notifications"
} }
}, },
"owner": { "owner": {

View File

@@ -2476,7 +2476,8 @@
"teams-search": "", "teams-search": "",
"unwatch": "", "unwatch": "",
"watch": "", "watch": "",
"watching-issue": "" "watching-issue": "",
"employee-notification": ""
}, },
"scenarios": { "scenarios": {
"alternate-transport-changed": "", "alternate-transport-changed": "",
@@ -2496,7 +2497,8 @@
"tasks-updated-created": "" "tasks-updated-created": ""
}, },
"tooltips": { "tooltips": {
"job-watchers": "" "job-watchers": "",
"not-employee": ""
} }
}, },
"owner": { "owner": {

View File

@@ -2476,7 +2476,8 @@
"teams-search": "", "teams-search": "",
"unwatch": "", "unwatch": "",
"watch": "", "watch": "",
"watching-issue": "" "watching-issue": "",
"employee-notification": ""
}, },
"scenarios": { "scenarios": {
"alternate-transport-changed": "", "alternate-transport-changed": "",
@@ -2496,7 +2497,8 @@
"tasks-updated-created": "" "tasks-updated-created": ""
}, },
"tooltips": { "tooltips": {
"job-watchers": "" "job-watchers": "",
"not-employee": ""
} }
}, },
"owner": { "owner": {

View File

@@ -0,0 +1,19 @@
import { useMemo } from "react";
/**
* Check if the user is an employee of the bodyshop
* @param bodyshop
* @param userOrEmail
* @returns {boolean|*}
*/
export function useIsEmployee(bodyshop, userOrEmail) {
return useMemo(() => {
if (!bodyshop || !bodyshop.employees) return false;
// Handle both user object and email string
const email = typeof userOrEmail === "string" ? userOrEmail : userOrEmail?.email;
if (!email) return false;
return bodyshop.employees.some((employee) => employee.user_email === email);
}, [bodyshop, userOrEmail]);
}

538
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,14 +16,14 @@
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js" "job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.804.0", "@aws-sdk/client-cloudwatch-logs": "^3.806.0",
"@aws-sdk/client-elasticache": "^3.804.0", "@aws-sdk/client-elasticache": "^3.806.0",
"@aws-sdk/client-s3": "^3.804.0", "@aws-sdk/client-s3": "^3.806.0",
"@aws-sdk/client-secrets-manager": "^3.804.0", "@aws-sdk/client-secrets-manager": "^3.806.0",
"@aws-sdk/client-ses": "^3.804.0", "@aws-sdk/client-ses": "^3.806.0",
"@aws-sdk/credential-provider-node": "^3.804.0", "@aws-sdk/credential-provider-node": "^3.806.0",
"@aws-sdk/lib-storage": "^3.804.0", "@aws-sdk/lib-storage": "^3.806.0",
"@aws-sdk/s3-request-presigner": "^3.804.0", "@aws-sdk/s3-request-presigner": "^3.806.0",
"@opensearch-project/opensearch": "^2.13.0", "@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1", "@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",