Compare commits

..

11 Commits

Author SHA1 Message Date
Dave Richer
e2297be0af IO-3170-HotfixFoRedis 2025-03-13 11:47:21 -04:00
Dave Richer
4fc3fbdcc0 Merged in release/2025-02-28 (pull request #2140)
IO-2561 Return Items Modal
2025-03-04 16:18:49 +00:00
Patrick Fic
dc60b8d18e Merged in feature/IO-3162-sentry-improvements (pull request #2138)
Feature/IO-3162 sentry improvements
2025-02-28 23:44:58 +00:00
Patrick Fic
ea75ac49aa IO-3162 Resize test CI boxes. 2025-02-28 15:25:03 -08:00
Patrick Fic
f3c6c7f004 IO-3162 Sentry cleanup. 2025-02-28 15:18:42 -08:00
Patrick Fic
65fb73ae82 IO-3162 Add Prod/Test restriction on sentry init. 2025-02-28 15:14:56 -08:00
Patrick Fic
617e39eb17 IO-3162 Add CI paramters to aid in sourcemap generation. 2025-02-28 15:03:16 -08:00
Patrick Fic
c0ffda27cf IO-3162 Additional logging improvements. 2025-02-28 14:21:36 -08:00
Patrick Fic
ba63e8054f IO-3162 Sentry package upgrades and refactors. 2025-02-28 12:15:47 -08:00
Allan Carr
32813032e6 Merged in feature/IO-2561-Return-Items-Modal (pull request #2131)
Feature/IO-2561 Return Items Modal

Approved-by: Dave Richer
2025-02-28 17:36:37 +00:00
Allan Carr
9a71779cfe IO-2561 Return Items Modal
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-02-27 18:42:43 -08:00
107 changed files with 3817 additions and 7493 deletions

View File

@@ -88,7 +88,7 @@ jobs:
name: Install Dependencies
command: npm i
- run: npm run build:production:imex
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:production:imex
- aws-cli/setup:
aws_access_key_id: AWS_ACCESS_KEY_ID
@@ -151,7 +151,7 @@ jobs:
rome-app-build:
docker:
- image: cimg/node:22.13.1
resource_class: large
working_directory: ~/repo/client
steps:
@@ -161,7 +161,7 @@ jobs:
name: Install Dependencies
command: npm i
- run: npm run build:production:rome
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:production:rome
- aws-cli/setup:
aws_access_key_id: AWS_ACCESS_KEY_ID
@@ -209,7 +209,7 @@ jobs:
test-rome-app-build:
docker:
- image: cimg/node:22.13.1
resource_class: large
working_directory: ~/repo/client
steps:
@@ -219,7 +219,7 @@ jobs:
name: Install Dependencies
command: npm i
- run: npm run build:test:rome
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:test:rome
- aws-cli/setup:
aws_access_key_id: AWS_ACCESS_KEY_ID
@@ -277,7 +277,7 @@ jobs:
name: Install Dependencies
command: npm i
- run: npm run build:test:imex
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:test:imex
- aws-s3/sync:
from: build
@@ -298,7 +298,7 @@ jobs:
name: Install Dependencies
command: npm i
- run: npm run build:test:imex
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:test:imex
- aws-cli/setup:
aws_access_key_id: AWS_ACCESS_KEY_ID

998
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,26 +8,27 @@
"private": true,
"proxy": "http://localhost:4000",
"dependencies": {
"@ant-design/pro-layout": "^7.22.3",
"@apollo/client": "^3.13.1",
"@ant-design/pro-layout": "^7.22.0",
"@apollo/client": "^3.12.6",
"@emotion/is-prop-valid": "^1.3.1",
"@fingerprintjs/fingerprintjs": "^4.6.1",
"@fingerprintjs/fingerprintjs": "^4.5.1",
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.6.0",
"@reduxjs/toolkit": "^2.5.0",
"@sentry/cli": "^2.42.2",
"@sentry/react": "^7.114.0",
"@sentry/react": "^9.3.0",
"@sentry/vite-plugin": "^3.2.1",
"@splitsoftware/splitio-react": "^1.13.0",
"@tanem/react-nprogress": "^5.0.53",
"@vitejs/plugin-react": "^4.3.4",
"antd": "^5.24.2",
"antd": "^5.23.1",
"apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^3.3.0",
"apollo-link-sentry": "^4.1.0",
"autosize": "^6.0.1",
"axios": "^1.8.1",
"axios": "^1.7.9",
"classnames": "^2.5.1",
"css-box-model": "^1.2.1",
"dayjs": "^1.11.13",
"dayjs-business-days2": "^1.3.0",
"dayjs-business-days2": "^1.2.3",
"dinero.js": "^1.9.1",
"dotenv": "^16.4.7",
"env-cmd": "^10.1.0",
@@ -35,9 +36,9 @@
"firebase": "^10.13.2",
"graphql": "^16.10.0",
"i18next": "^23.15.1",
"i18next-browser-languagedetector": "^8.0.4",
"i18next-browser-languagedetector": "^8.0.2",
"immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.12.4",
"libphonenumber-js": "^1.11.18",
"logrocket": "^8.1.2",
"markerjs2": "^2.32.3",
"memoize-one": "^6.0.0",
@@ -47,7 +48,7 @@
"query-string": "^9.1.1",
"raf-schd": "^4.0.3",
"react": "^18.3.1",
"react-big-calendar": "^1.18.0",
"react-big-calendar": "^1.17.1",
"react-color": "^2.19.3",
"react-cookie": "^7.2.2",
"react-dom": "^18.3.1",
@@ -55,7 +56,7 @@
"react-grid-gallery": "^1.0.1",
"react-grid-layout": "1.3.4",
"react-i18next": "^14.1.3",
"react-icons": "^5.5.0",
"react-icons": "^5.4.0",
"react-image-lightbox": "^5.1.4",
"react-markdown": "^9.0.3",
"react-number-format": "^5.4.3",
@@ -63,9 +64,9 @@
"react-product-fruits": "^2.2.61",
"react-redux": "^9.2.0",
"react-resizable": "^3.0.5",
"react-router-dom": "^6.30.0",
"react-router-dom": "^6.26.2",
"react-sticky": "^6.0.3",
"react-virtuoso": "^4.12.5",
"react-virtuoso": "^4.10.4",
"recharts": "^2.15.0",
"redux": "^5.0.1",
"redux-actions": "^3.0.3",
@@ -73,12 +74,12 @@
"redux-saga": "^1.3.0",
"redux-state-sync": "^3.1.4",
"reselect": "^5.1.1",
"sass": "^1.85.1",
"sass": "^1.83.4",
"socket.io-client": "^4.8.1",
"styled-components": "^6.1.15",
"styled-components": "^6.1.14",
"subscriptions-transport-ws": "^0.11.0",
"use-memo-one": "^1.1.3",
"userpilot": "^1.3.8",
"userpilot": "^1.3.6",
"vite-plugin-ejs": "^1.7.0",
"web-vitals": "^3.5.2"
},
@@ -98,8 +99,7 @@
"test": "cypress open",
"eject": "react-scripts eject",
"madge": "madge --image ./madge-graph.svg --extensions js,jsx,ts,tsx --circular .",
"eulaize": "node src/utils/eulaize.js",
"sentry:sourcemaps:imex": "sentry-cli sourcemaps inject --org imex --project imexonline ./build && sentry-cli sourcemaps upload --org imex --project imexonline ./build"
"eulaize": "node src/utils/eulaize.js"
},
"browserslist": {
"production": [
@@ -120,14 +120,14 @@
"@rollup/rollup-linux-x64-gnu": "4.6.1"
},
"devDependencies": {
"@ant-design/icons": "^5.6.1",
"@ant-design/icons": "^5.5.2",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.26.3",
"@dotenvx/dotenvx": "^1.38.3",
"@dotenvx/dotenvx": "^1.33.0",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.21.0",
"@sentry/webpack-plugin": "^2.22.4",
"@eslint/js": "^9.18.0",
"@sentry/webpack-plugin": "^3.2.1",
"@testing-library/cypress": "^10.0.2",
"browserslist": "^4.24.4",
"browserslist-to-esbuild": "^2.1.1",
@@ -138,13 +138,13 @@
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-cypress": "^2.15.1",
"eslint-plugin-react": "^7.37.4",
"globals": "^15.15.0",
"globals": "^15.14.0",
"memfs": "^4.17.0",
"os-browserify": "^0.3.0",
"react-error-overlay": "^6.1.0",
"react-error-overlay": "6.0.11",
"redux-logger": "^3.0.6",
"source-map-explorer": "^2.5.3",
"vite": "^6.2.0",
"vite": "^6.0.7",
"vite-plugin-babel": "^1.3.0",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-node-polyfills": "^0.23.0",

View File

@@ -1,10 +1,10 @@
import { useSplitClient } from "@splitsoftware/splitio-react";
import { Button, Result } from "antd";
import LogRocket from "logrocket";
import { lazy, Suspense, useEffect, useState } from "react";
import React, { lazy, Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Route, Routes, useNavigate } from "react-router-dom";
import { Route, Routes } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import DocumentEditorContainer from "../components/document-editor/document-editor.container";
import ErrorBoundary from "../components/error-boundary/error-boundary.component"; // Component Imports
@@ -46,7 +46,6 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
const client = useSplitClient().client;
const [listenersAdded, setListenersAdded] = useState(false);
const { t } = useTranslation();
const navigate = useNavigate();
useEffect(() => {
if (!navigator.onLine) {
@@ -201,7 +200,7 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
path="/manage/*"
element={
<ErrorBoundary>
<SocketProvider bodyshop={bodyshop} navigate={navigate}>
<SocketProvider bodyshop={bodyshop}>
<PrivateRoute isAuthorized={currentUser.authorized} />
</SocketProvider>
</ErrorBoundary>
@@ -213,7 +212,7 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
path="/tech/*"
element={
<ErrorBoundary>
<SocketProvider bodyshop={bodyshop} navigate={navigate}>
<SocketProvider bodyshop={bodyshop}>
<PrivateRoute isAuthorized={currentUser.authorized} />
</SocketProvider>
</ErrorBoundary>

View File

@@ -1,9 +1,9 @@
import { useApolloClient } from "@apollo/client";
import { getToken } from "@firebase/messaging";
import axios from "axios";
import { useEffect } from "react";
import React, { useContext, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useSocket } from "../../contexts/SocketIO/socketContext";
import SocketContext from "../../contexts/SocketIO/socketContext";
import { messaging, requestForToken } from "../../firebase/firebase.utils";
import ChatPopupComponent from "../chat-popup/chat-popup.component";
import "./chat-affix.styles.scss";
@@ -12,7 +12,7 @@ import { registerMessagingHandlers, unregisterMessagingHandlers } from "./regist
export function ChatAffixContainer({ bodyshop, chatVisible }) {
const { t } = useTranslation();
const client = useApolloClient();
const { socket } = useSocket();
const { socket } = useContext(SocketContext);
useEffect(() => {
if (!bodyshop || !bodyshop.messagingservicesid) return;

View File

@@ -1,9 +1,9 @@
import { useMutation } from "@apollo/client";
import { Button } from "antd";
import { useState } from "react";
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { TOGGLE_CONVERSATION_ARCHIVE } from "../../graphql/conversations.queries";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
@@ -18,7 +18,7 @@ export function ChatArchiveButton({ conversation, bodyshop }) {
const [loading, setLoading] = useState(false);
const { t } = useTranslation();
const [updateConversation] = useMutation(TOGGLE_CONVERSATION_ARCHIVE);
const { socket } = useSocket();
const { socket } = useContext(SocketContext);
const handleToggleArchive = async () => {
setLoading(true);

View File

@@ -1,10 +1,11 @@
import { useMutation } from "@apollo/client";
import { Tag } from "antd";
import React, { useContext } from "react";
import { Link } from "react-router-dom";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { REMOVE_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
@@ -17,7 +18,7 @@ const mapDispatchToProps = () => ({});
export function ChatConversationTitleTags({ jobConversations, bodyshop }) {
const [removeJobConversation] = useMutation(REMOVE_CONVERSATION_TAG);
const { socket } = useSocket();
const { socket } = useContext(SocketContext);
const handleRemoveTag = async (jobId) => {
const convId = jobConversations[0].conversationid;

View File

@@ -1,10 +1,10 @@
import { gql, useApolloClient, useQuery, useSubscription } from "@apollo/client";
import axios from "axios";
import { useCallback, useEffect, useState } from "react";
import React, { useCallback, useContext, useEffect, useState } from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/socketContext";
import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
import SocketContext from "../../contexts/SocketIO/socketContext";
import { GET_CONVERSATION_DETAILS, CONVERSATION_SUBSCRIPTION_BY_PK } from "../../graphql/conversations.queries";
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import ChatConversationComponent from "./chat-conversation.component";
@@ -16,7 +16,7 @@ const mapStateToProps = createStructuredSelector({
function ChatConversationContainer({ bodyshop, selectedConversation }) {
const client = useApolloClient();
const { socket } = useSocket();
const { socket } = useContext(SocketContext);
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
// Fetch conversation details

View File

@@ -1,10 +1,10 @@
import { PlusOutlined } from "@ant-design/icons";
import { useMutation } from "@apollo/client";
import { Input, Spin, Tag, Tooltip } from "antd";
import { useState } from "react";
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { UPDATE_CONVERSATION_LABEL } from "../../graphql/conversations.queries";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
@@ -20,7 +20,7 @@ export function ChatLabel({ conversation, bodyshop }) {
const [loading, setLoading] = useState(false);
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(conversation.label);
const { socket } = useSocket();
const { socket } = useContext(SocketContext);
const notification = useNotification();
const { t } = useTranslation();

View File

@@ -1,11 +1,12 @@
import { PlusCircleFilled } from "@ant-design/icons";
import { Button, Form, Popover } from "antd";
import React, { useContext } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
@@ -17,7 +18,7 @@ const mapDispatchToProps = (dispatch) => ({
export function ChatNewConversation({ openChatByPhone }) {
const { t } = useTranslation();
const [form] = Form.useForm();
const { socket } = useSocket();
const { socket } = useContext(SocketContext);
const handleFinish = (values) => {
openChatByPhone({ phone_num: values.phoneNumber, socket });

View File

@@ -1,4 +1,5 @@
import parsePhoneNumber from "libphonenumber-js";
import React, { useContext } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
@@ -7,7 +8,7 @@ import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { searchingForConversation } from "../../redux/messaging/messaging.selectors";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
@@ -21,7 +22,7 @@ const mapDispatchToProps = (dispatch) => ({
export function ChatOpenButton({ bodyshop, searchingForConversation, phone, jobid, openChatByPhone }) {
const { t } = useTranslation();
const { socket } = useSocket();
const { socket } = useContext(SocketContext);
const notification = useNotification();
if (!phone) return <></>;

View File

@@ -1,7 +1,7 @@
import { InfoCircleOutlined, MessageOutlined, ShrinkOutlined, SyncOutlined } from "@ant-design/icons";
import { useApolloClient, useLazyQuery, useQuery } from "@apollo/client";
import { Badge, Card, Col, Row, Space, Tag, Tooltip, Typography } from "antd";
import { useEffect, useState } from "react";
import React, { useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -12,9 +12,8 @@ import ChatConversationListComponent from "../chat-conversation-list/chat-conver
import ChatConversationContainer from "../chat-conversation/chat-conversation.container";
import ChatNewConversation from "../chat-new-conversation/chat-new-conversation.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import "./chat-popup.styles.scss";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,
@@ -28,7 +27,7 @@ const mapDispatchToProps = (dispatch) => ({
export function ChatPopupComponent({ chatVisible, selectedConversation, toggleChatVisible }) {
const { t } = useTranslation();
const [pollInterval, setPollInterval] = useState(0);
const { socket } = useSocket();
const { socket } = useContext(SocketContext);
const client = useApolloClient(); // Apollo Client instance for cache operations
// Lazy query for conversations

View File

@@ -2,13 +2,13 @@ import { PlusOutlined } from "@ant-design/icons";
import { useLazyQuery, useMutation } from "@apollo/client";
import { Tag } from "antd";
import _ from "lodash";
import { useState } from "react";
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import { SEARCH_FOR_JOBS } from "../../graphql/jobs.queries";
import ChatTagRo from "./chat-tag-ro.component";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
@@ -22,7 +22,7 @@ const mapDispatchToProps = () => ({});
export function ChatTagRoContainer({ conversation, bodyshop }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { socket } = useSocket();
const { socket } = useContext(SocketContext);
const [loadRo, { loading, data }] = useLazyQuery(SEARCH_FOR_JOBS);

View File

@@ -75,22 +75,6 @@ class ErrorBoundary extends React.Component {
render() {
const { t } = this.props;
const { error, info } = this.state;
const collapseItems = error
? [
{
key: "errors",
label: t("general.labels.errors"),
children: (
<div>
<strong>{error.message || "Unknown error"}</strong>
<div>{error.stack || "No stack trace available"}</div>
</div>
)
}
]
: [];
if (this.state.hasErrored === true) {
logImEXEvent("error_boundary_rendered", { error, info });
@@ -138,7 +122,14 @@ class ErrorBoundary extends React.Component {
/>
<Row>
<Col offset={6} span={12}>
<Collapse bordered={false} items={collapseItems} />
<Collapse bordered={false}>
<Collapse.Panel header={t("general.labels.errors")}>
<div>
<strong>{this.state.error.message}</strong>
</div>
<div>{this.state.error.stack}</div>
</Collapse.Panel>
</Collapse>
</Col>
</Row>
</div>

View File

@@ -1,7 +1,6 @@
import Icon, {
BankFilled,
BarChartOutlined,
BellFilled,
CarFilled,
CheckCircleOutlined,
ClockCircleFilled,
@@ -27,7 +26,7 @@ import Icon, {
UserOutlined
} from "@ant-design/icons";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Badge, Layout, Menu, Space, Spin } from "antd";
import { Layout, Menu, Space } from "antd";
import { useTranslation } from "react-i18next";
import { BsKanban } from "react-icons/bs";
import { FaCalendarAlt, FaCarCrash, FaCreditCard, FaFileInvoiceDollar, FaTasks } from "react-icons/fa";
@@ -45,15 +44,6 @@ import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selecto
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;
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
@@ -126,64 +116,18 @@ function Header({
const { t } = useTranslation();
const { isConnected } = useSocket();
const [notificationVisible, setNotificationVisible] = useState(false);
// const deleteBetaCookie = () => {
// const cookieExists = document.cookie.split("; ").some((row) => row.startsWith(`betaSwitchImex=`));
// if (cookieExists) {
// const domain = window.location.hostname.split(".").slice(-2).join(".");
// document.cookie = `betaSwitchImex=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=.${domain}`;
// }
// };
//
// deleteBetaCookie();
const userAssociationId = bodyshop?.associations?.[0]?.id;
const {
data: unreadData,
refetch: refetchUnread,
loading: unreadLoading
} = 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
});
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, userAssociationId]);
useEffect(() => {
if (!isConnected && !unreadLoading && userAssociationId) {
refetchUnread().catch((e) => console.error(`Something went wrong fetching unread notifications: ${e?.message}`));
}
}, [isConnected, unreadLoading, refetchUnread, userAssociationId]);
const handleNotificationClick = (e) => {
setNotificationVisible(!notificationVisible);
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(
{
key: "bills",
@@ -406,7 +350,6 @@ function Header({
children: accountingExportChildren
});
// Define all menu items
const menuItems = [
{
key: "home",
@@ -476,6 +419,7 @@ function Header({
icon: <ScheduleOutlined />,
label: <Link to="/manage/production/list">{t("menus.header.productionlist")}</Link>
},
{
key: "productionboard",
id: "header-production-board",
@@ -488,6 +432,7 @@ function Header({
</Link>
)
},
{
type: "divider",
id: "header-jobs-divider3"
@@ -574,6 +519,7 @@ function Header({
}
]
},
...(accountingChildren.length > 0
? [
{
@@ -591,6 +537,7 @@ function Header({
icon: <PhoneOutlined />,
label: <Link to="/manage/phonebook">{t("menus.header.phonebook")}</Link>
},
{
key: "temporarydocs",
id: "header-temporarydocs",
@@ -603,6 +550,7 @@ function Header({
</Link>
)
},
{
key: "tasks",
id: "tasks",
@@ -675,6 +623,7 @@ function Header({
icon: <Icon component={IoBusinessOutline} />,
label: <Link to="/manage/shop/vendors">{t("menus.header.shop_vendors")}</Link>
},
{
key: "shop-csi",
id: "header-shop-csi",
@@ -689,33 +638,9 @@ 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",
children: recentItems.map((i, idx) => ({
key: idx,
id: `header-recent-${idx}`,
label: <Link to={i.url}>{i.label}</Link>
}))
},
{
key: "user",
icon: <UserOutlined />,
// label: currentUser.displayName || currentUser.email || t("general.labels.unknown"),
label: currentUser.displayName || currentUser.email || t("general.labels.unknown"),
children: [
{
key: "signout",
@@ -750,6 +675,7 @@ function Header({
}
]
: []),
{
key: "shiftclock",
id: "header-shiftclock",
@@ -762,68 +688,64 @@ function Header({
</Link>
)
},
{
key: "profile",
id: "header-profile",
icon: <UserOutlined />,
label: <Link to="/manage/profile">{t("menus.currentuser.profile")}</Link>
}
// {
// key: 'langselecter',
// label: t("menus.currentuser.languageselector"),
// children: [
// {
// key: 'en-US',
// label: t("general.languages.english"),
// onClick: () => {
// window.location.href = "/?lang=en-US";
// }
// },
// {
// key: 'fr-CA',
// label: t("general.languages.french"),
// onClick: () => {
// window.location.href = "/?lang=fr-CA";
// }
// },
// {
// key: 'es-MX',
// label: t("general.languages.spanish"),
// onClick: () => {
// window.location.href = "/?lang=es-MX";
// }
// },
// ]
// },
]
},
{
key: "recent",
icon: <ClockCircleFilled />,
id: "header-recent",
children: recentItems.map((i, idx) => ({
key: idx,
id: `header-recent-${idx}`,
label: <Link to={i.url}>{i.label}</Link>
}))
}
];
return (
<Layout.Header style={{ padding: 0 }}>
{isMobile ? (
<Menu
mode="horizontal"
theme="dark"
selectedKeys={[selectedHeader]}
onClick={handleMenuClick}
subMenuCloseDelay={0.3}
items={menuItems}
style={{ width: "100%" }}
/>
) : (
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
width: "100%"
}}
>
<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>
)}
<Layout.Header>
<Menu
mode="horizontal"
theme={"dark"}
selectedKeys={[selectedHeader]}
onClick={handleMenuClick}
subMenuCloseDelay={0.3}
items={menuItems}
/>
</Layout.Header>
);
}

View File

@@ -1,7 +1,30 @@
import { connect } from "react-redux";
import HeaderComponent from "./header.component";
// const mapDispatchToProps = (dispatch) => ({
// setUserLanguage: (language) => dispatch(setUserLanguage(language))
// });
// setUserLanguage was removed from signature because it is not used in the component, and it is throwing a deprecation warning
export function HeaderContainer() {
// Commented out the handleMenuClick function because it is not used in the component, and it is throwing a deprecation warning
/* const handleMenuClick = (e) => {
if (e.item.props.actiontype === "lang-select") {
i18next.changeLanguage(e.key, (err, t) => {
if (err) {
logImEXEvent("language_change_error", { error: err });
return console.log("Error encountered when changing languages.", err);
}
logImEXEvent("language_change", { language: e.key });
setUserLanguage(e.key);
});
}
};*/
// return <HeaderComponent handleMenuClick={handleMenuClick} />;
return <HeaderComponent />;
}

View File

@@ -3,12 +3,12 @@ import { useMutation } from "@apollo/client";
import { Button, Divider, Dropdown, Form, Input, Popover, Select, Space } from "antd";
import parsePhoneNumber from "libphonenumber-js";
import queryString from "query-string";
import { useState } from "react";
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { setModalContext } from "../../redux/modals/modals.actions";
@@ -51,7 +51,7 @@ export function ScheduleEventComponent({
const searchParams = queryString.parse(useLocation().search);
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT);
const [title, setTitle] = useState(event.title);
const { socket } = useSocket();
const { socket } = useContext(SocketContext);
const notification = useNotification();
const blockContent = (

View File

@@ -16,6 +16,7 @@ import InstanceRenderManager from "../../utils/instanceRenderMgr";
import JobCloseRoGuardTtLifecycle from "./job-close-ro-guard.tt-lifecycle";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
bodyshop: selectBodyshop,
jobRO: selectJobReadOnly
});
@@ -39,13 +40,34 @@ export function JobCloseRoGuardContainer({ job, jobRO, bodyshop, form }) {
if (!bodyshop?.md_ro_guard?.enabled) return null;
// Define collapse items for both panels
const collapseItems = [
{
key: "roguard",
label: t("jobs.labels.roguard"),
children: (
<>
return (
<>
{warnings.length > 0 && (
<Card
title={
<Space size="small">
<Badge count={warnings.length} />
{t("jobs.labels.roguardwarnings")}
</Space>
}
>
<ul>
{warnings.map((w, index) => (
<li key={index}>
{bodyshop.md_ro_guard[`enforce_${w.key}`] && (
<Tooltip title={t("jobs.labels.ro_guard.enforced")}>
<LockOutlined style={{ color: "tomato", marginRight: "8px" }} />
</Tooltip>
)}
{w.warning}
</li>
))}
</ul>
</Card>
)}
<Collapse>
<Collapse.Panel forceRender key="roguard" header={t("jobs.labels.roguard")}>
<Row gutter={[32, 32]}>
<Col span={24}>
<JobCloseRoGuardBills job={job} form={form} warningCallback={warningCallback} />
@@ -63,6 +85,7 @@ export function JobCloseRoGuardContainer({ job, jobRO, bodyshop, form }) {
{InstanceRenderManager({
rome: (
<Col md={24} lg={8}>
{/* <JobCloseRoGuardSublet job={job} warningCallback={warningCallback} /> */}
<JobCloseRoGuardPpd job={job} warningCallback={warningCallback} />
</Col>
)
@@ -191,50 +214,16 @@ export function JobCloseRoGuardContainer({ job, jobRO, bodyshop, form }) {
>
<Input prefix={<LockOutlined />} type="password" placeholder="Password" disabled={jobRO} />
</Form.Item>
</>
),
forceRender: true // Preserve the forceRender prop from the original
},
{
key: "performance",
label: t("jobs.labels.performance"),
children: (
<Row gutter={[32, 32]}>
<Col className="ro-guard-col" span={24}>
<JobCloseRoGuardTtLifecycle job={job} />
</Col>
</Row>
)
}
];
</Collapse.Panel>
return (
<>
{warnings.length > 0 && (
<Card
title={
<Space size="small">
<Badge count={warnings.length} />
{t("jobs.labels.roguardwarnings")}
</Space>
}
>
<ul>
{warnings.map((w, index) => (
<li key={index}>
{bodyshop.md_ro_guard[`enforce_${w.key}`] && (
<Tooltip title={t("jobs.labels.ro_guard.enforced")}>
<LockOutlined style={{ color: "tomato", marginRight: "8px" }} />
</Tooltip>
)}
{w.warning}
</li>
))}
</ul>
</Card>
)}
<Collapse items={collapseItems} />
<Collapse.Panel header={t("jobs.labels.performance")}>
<Row gutter={[32, 32]}>
<Col className="ro-guard-col" span={24}>
<JobCloseRoGuardTtLifecycle job={job} />
</Col>
</Row>
</Collapse.Panel>
</Collapse>
</>
);
}

View File

@@ -27,7 +27,7 @@ const colSpan = {
export function JobsTotalsTableComponent({ jobRO, currentUser, job }) {
const { t } = useTranslation();
if (!job.job_totals) {
if (!!!job.job_totals) {
return (
<Card>
<Result title={t("jobs.errors.nofinancial")} extra={<JobCalculateTotals job={job} disabled={jobRO} />} />
@@ -35,29 +35,6 @@ export function JobsTotalsTableComponent({ jobRO, currentUser, job }) {
);
}
// Define collapse items
const collapseItems = [
{
key: "json-tree-totals",
label: "JSON Tree Totals",
children: (
<div>
<pre>
{JSON.stringify(
{
CIECA: job.cieca_ttl && job.cieca_ttl.data,
CIECASTL: job.cieca_stl && job.cieca_stl.data,
ImEXCalc: job.job_totals
},
null,
2
)}
</pre>
</div>
)
}
];
return (
<div>
<Row gutter={[16, 16]}>
@@ -91,7 +68,23 @@ export function JobsTotalsTableComponent({ jobRO, currentUser, job }) {
<Col span={24}>
<Card title="DEVELOPMENT USE ONLY">
<JobCalculateTotals job={job} disabled={jobRO} />
<Collapse items={collapseItems} />
<Collapse>
<Collapse.Panel header="JSON Tree Totals">
<div>
<pre>
{JSON.stringify(
{
CIECA: job.cieca_ttl && job.cieca_ttl.data,
CIECASTL: job.cieca_stl && job.cieca_stl.data,
ImEXCalc: job.job_totals
},
null,
2
)}
</pre>
</div>
</Collapse.Panel>
</Collapse>
</Card>
</Col>
)}

View File

@@ -20,6 +20,7 @@ import JobsMarkPstExempt from "../jobs-mark-pst-exempt/jobs-mark-pst-exempt.comp
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
@@ -38,175 +39,162 @@ export function JobsCreateJobsInfo({ bodyshop, form, selected }) {
}
};
// Define collapse items for all three panels
const collapseItems = [
{
key: "insurance",
label: t("menus.jobsdetail.insurance"),
children: (
<LayoutFormRow>
<Form.Item label={t("jobs.fields.clm_no")} name="clm_no">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.policy_no")} name="policy_no">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.regie_number")} name="regie_number">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.ins_co_nm")} name="ins_co_nm">
<Select onChange={handleInsCoChange}>
{bodyshop.md_ins_cos.map((s) => (
<Select.Option key={s.name} value={s.name}>
{s.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label={t("jobs.fields.ins_addr1")} name="ins_addr1">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.ins_city")} name="ins_city">
<Input />
</Form.Item>
<Form.Item
label={
<Space>
{t("jobs.fields.ins_ct_ln")}
<JobsDetailChangeFilehandler form={form} />
</Space>
}
name="ins_ct_ln"
>
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.ins_ct_fn")} name="ins_ct_fn">
<Input />
</Form.Item>
<Form.Item
label={t("jobs.fields.ins_ph1")}
name="ins_ph1"
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ins_ph1")]}
>
<FormItemPhone />
</Form.Item>
<Form.Item
label={t("jobs.fields.ins_ea")}
name="ins_ea"
rules={[
{
type: "email",
message: "This is not a valid email address."
return (
<div>
<Collapse defaultActiveKey="insurance">
<Collapse.Panel key="insurance" header={t("menus.jobsdetail.insurance")} forceRender>
<LayoutFormRow>
<Form.Item label={t("jobs.fields.clm_no")} name="clm_no">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.policy_no")} name="policy_no">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.regie_number")} name="regie_number">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.ins_co_nm")} name="ins_co_nm">
<Select onChange={handleInsCoChange}>
{bodyshop.md_ins_cos.map((s) => (
<Select.Option key={s.name} value={s.name}>
{s.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label={t("jobs.fields.ins_addr1")} name="ins_addr1">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.ins_city")} name="ins_city">
<Input />
</Form.Item>
<Form.Item
label={
<Space>
{t("jobs.fields.ins_ct_ln")}
<JobsDetailChangeFilehandler form={form} />
</Space>
}
]}
>
<FormItemEmail email={getFieldValue("ins_ea")} />
</Form.Item>
<Form.Item label={t("jobs.fields.loss_date")} name="loss_date">
<DateTimePicker isDateOnly />
</Form.Item>
<Form.Item label={t("jobs.fields.kmin")} name="kmin">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.est_co_nm")} name="est_co_nm">
sausage <Input />
</Form.Item>
<Form.Item
label={
<Space>
{t("jobs.fields.est_ct_fn")}
<JobsDetailChangeEstimator form={form} />
</Space>
}
name="est_ct_fn"
>
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.est_ct_ln")} name="est_ct_ln">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.est_ph1")} name="est_ph1">
<Input />
</Form.Item>
<Form.Item
label={t("jobs.fields.est_ea")}
name="est_ea"
rules={[
{
type: "email",
message: "This is not a valid email address."
name="ins_ct_ln"
>
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.ins_ct_fn")} name="ins_ct_fn">
<Input />
</Form.Item>
<Form.Item
label={t("jobs.fields.ins_ph1")}
name="ins_ph1"
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ins_ph1")]}
>
<FormItemPhone />
</Form.Item>
<Form.Item
label={t("jobs.fields.ins_ea")}
name="ins_ea"
rules={[
{
type: "email",
message: "This is not a valid email address."
}
]}
>
<FormItemEmail email={getFieldValue("ins_ea")} />
</Form.Item>
<Form.Item label={t("jobs.fields.loss_date")} name="loss_date">
<DateTimePicker isDateOnly />
</Form.Item>
<Form.Item label={t("jobs.fields.kmin")} name="kmin">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.est_co_nm")} name="est_co_nm">
<Input />
</Form.Item>
<Form.Item
label={
<Space>
{t("jobs.fields.est_ct_fn")}
<JobsDetailChangeEstimator form={form} />
</Space>
}
]}
>
<FormItemEmail email={getFieldValue("est_ea")} />
</Form.Item>
<Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.servicing_dealer")} name="servicing_dealer">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.selling_dealer_contact")} name="selling_dealer_contact">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.servicing_dealer_contact")} name="servicing_dealer_contact">
<Input />
</Form.Item>
</LayoutFormRow>
),
forceRender: true
},
{
key: "claim",
label: t("menus.jobsdetail.claimdetail"),
children: (
<LayoutFormRow>
<Form.Item label={t("jobs.fields.loss_desc")} name="loss_desc">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.loss_of_use")} name="loss_of_use">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.ponumber")} name="po_number">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.unitnumber")} name="unit_number">
<Input />
</Form.Item>
<Form.Item
label={t("jobs.fields.specialcoveragepolicy")}
valuePropName="checked"
name="special_coverage_policy"
>
<Switch />
</Form.Item>
<Form.Item label={t("jobs.fields.kmin")} name="kmin">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.kmout")} name="kmout">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.referralsource")} name="referral_source">
<Select>
{bodyshop.md_referral_sources.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
<Input />
</Form.Item>
</LayoutFormRow>
),
forceRender: true
},
{
key: "financial",
label: t("menus.jobsdetail.financials"),
children: (
<>
name="est_ct_fn"
>
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.est_ct_ln")} name="est_ct_ln">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.est_ph1")} name="est_ph1">
<Input />
</Form.Item>
<Form.Item
label={t("jobs.fields.est_ea")}
name="est_ea"
rules={[
{
type: "email",
message: "This is not a valid email address."
}
]}
>
<FormItemEmail email={getFieldValue("est_ea")} />
</Form.Item>
<Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.servicing_dealer")} name="servicing_dealer">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.selling_dealer_contact")} name="selling_dealer_contact">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.servicing_dealer_contact")} name="servicing_dealer_contact">
<Input />
</Form.Item>
</LayoutFormRow>
</Collapse.Panel>
<Collapse.Panel forceRender key="claim" header={t("menus.jobsdetail.claimdetail")}>
<LayoutFormRow>
<Form.Item label={t("jobs.fields.loss_desc")} name="loss_desc">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.loss_of_use")} name="loss_of_use">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.ponumber")} name="po_number">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.unitnumber")} name="unit_number">
<Input />
</Form.Item>
<Form.Item
label={t("jobs.fields.specialcoveragepolicy")}
valuePropName="checked"
name="special_coverage_policy"
>
<Switch />
</Form.Item>
<Form.Item label={t("jobs.fields.kmin")} name="kmin">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.kmout")} name="kmout">
<Input />
</Form.Item>
<Form.Item label={t("jobs.fields.referralsource")} name="referral_source">
<Select>
{bodyshop.md_referral_sources.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
<Input />
</Form.Item>
</LayoutFormRow>
</Collapse.Panel>
<Collapse.Panel forceRender key="financial" header={t("menus.jobsdetail.financials")}>
<JobsDetailRatesChangeButton form={form} />
{InstanceRenderManager({
imex: <JobsMarkPstExempt form={form} />
@@ -327,15 +315,8 @@ export function JobsCreateJobsInfo({ bodyshop, form, selected }) {
<CurrencyInput />
</Form.Item>
</LayoutFormRow>
</>
),
forceRender: true
}
];
return (
<div>
<Collapse defaultActiveKey="insurance" items={collapseItems} />
</Collapse.Panel>
</Collapse>
<JobsDetailRatesParts jobRO={false} expanded required={selected && true} form={form} />
{InstanceRenderManager({
rome: (

View File

@@ -4,12 +4,12 @@ import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Card, Dropdown, Form, Input, Modal, Popconfirm, Popover, Select, Space } from "antd";
import axios from "axios";
import parsePhoneNumber from "libphonenumber-js";
import { useMemo, useState } from "react";
import { useContext, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { auth, logImEXEvent } from "../../firebase/firebase.utils";
import { CANCEL_APPOINTMENTS_BY_JOB_ID, INSERT_MANUAL_APPT } from "../../graphql/appointments.queries";
import { GET_CURRENT_QUESTIONSET_ID, INSERT_CSI } from "../../graphql/csi.queries";
@@ -130,7 +130,7 @@ export function JobsDetailHeaderActions({
const [updateJob] = useMutation(UPDATE_JOB);
const [voidJob] = useMutation(VOID_JOB);
const [cancelAllAppointments] = useMutation(CANCEL_APPOINTMENTS_BY_JOB_ID);
const { socket } = useSocket();
const { socket } = useContext(SocketContext);
const notification = useNotification();
const {

View File

@@ -119,7 +119,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
<DataLabel label={t("jobs.labels.contracts")}>
{job.cccontracts.map((c, index) => (
<Space key={c.id} wrap>
<Link to={`/manage/courtesycars/contracts/${c.id}`}>
<Link to={`/manage/courtesycars/contracts/${c.id}`}>
{`${c.agreementnumber} - ${c.courtesycar.fleetnumber} ${c.courtesycar.year} ${c.courtesycar.make} ${c.courtesycar.model}`}
{index !== job.cccontracts.length - 1 ? "," : null}
</Link>

View File

@@ -13,162 +13,154 @@ const mapStateToProps = createStructuredSelector({
export function JobsDetailRatesMaterials({ jobRO, expanded, required = true, form }) {
const { t } = useTranslation();
// Define collapse items
const collapseItems = [
{
key: "materials",
label: t("jobs.fields.materials.materials"),
children: (
<>
<LayoutFormRow header={t("jobs.fields.materials.MAPA")}>
<Form.Item label={t("jobs.fields.materials.cal_maxdlr")} name={["materials", "MAPA", "cal_maxdlr"]}>
<InputNumber min={0} precision={2} disabled={jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.materials.cal_opcode")} name={["materials", "MAPA", "cal_opcode"]}>
<Input disabled={jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.materials.mat_adjp")} name={["materials", "MAPA", "mat_adjp"]}>
<InputNumber min={-100} max={100} precision={4} disabled={jobRO} />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.tax_ind")}
name={["materials", "MAPA", "tax_ind"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item shouldUpdate>
{() => {
return (
<Form.Item
label={t("jobs.fields.materials.mat_taxp")}
name={["materials", "MAPA", "mat_taxp"]}
rules={[
{
required: form.getFieldValue(["materials", "MAPA", "tax_ind"])
//message: t("general.validation.required"),
}
]}
>
<InputNumber min={0} max={100} precision={4} disabled={jobRO} />
</Form.Item>
);
}}
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.mat_tx_in1")}
name={["materials", "MAPA", "mat_tx_in1"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.mat_tx_in2")}
name={["materials", "MAPA", "mat_tx_in2"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.mat_tx_in3")}
name={["materials", "MAPA", "mat_tx_in3"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.mat_tx_in4")}
name={["materials", "MAPA", "mat_tx_in4"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.mat_tx_in5")}
name={["materials", "MAPA", "mat_tx_in5"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
</LayoutFormRow>
<LayoutFormRow header={t("jobs.fields.materials.MASH")}>
<Form.Item label={t("jobs.fields.materials.cal_maxdlr")} name={["materials", "MASH", "cal_maxdlr"]}>
<InputNumber min={0} precision={2} disabled={jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.materials.cal_opcode")} name={["materials", "MASH", "cal_opcode"]}>
<Input disabled={jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.materials.mat_adjp")} name={["materials", "MAPA", "mat_adjp"]}>
<InputNumber min={-100} max={100} precision={4} disabled={jobRO} />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.tax_ind")}
name={["materials", "MASH", "tax_ind"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item shouldUpdate>
{() => {
return (
<Form.Item
label={t("jobs.fields.materials.mat_taxp")}
name={["materials", "MASH", "mat_taxp"]}
rules={[
{
required: form.getFieldValue(["materials", "MASH", "tax_ind"])
//message: t("general.validation.required"),
}
]}
>
<InputNumber min={0} max={100} precision={4} disabled={jobRO} />
</Form.Item>
);
}}
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.mat_tx_in1")}
name={["materials", "MASH", "mat_tx_in1"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.mat_tx_in2")}
name={["materials", "MASH", "mat_tx_in2"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.mat_tx_in3")}
name={["materials", "MASH", "mat_tx_in3"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.mat_tx_in4")}
name={["materials", "MASH", "mat_tx_in4"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.mat_tx_in5")}
name={["materials", "MASH", "mat_tx_in5"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
</LayoutFormRow>
</>
),
forceRender: true
}
];
return <Collapse defaultActiveKey={expanded && "rates"} items={collapseItems} />;
return (
<Collapse defaultActiveKey={expanded && "rates"}>
<Collapse.Panel forceRender header={t("jobs.fields.materials.materials")} key="materials">
<LayoutFormRow header={t("jobs.fields.materials.MAPA")}>
<Form.Item label={t("jobs.fields.materials.cal_maxdlr")} name={["materials", "MAPA", "cal_maxdlr"]}>
<InputNumber min={0} precision={2} disabled={jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.materials.cal_opcode")} name={["materials", "MAPA", "cal_opcode"]}>
<Input disabled={jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.materials.mat_adjp")} name={["materials", "MAPA", "mat_adjp"]}>
<InputNumber min={-100} max={100} precision={4} disabled={jobRO} />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.tax_ind")}
name={["materials", "MAPA", "tax_ind"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item shouldUpdate>
{() => {
return (
<Form.Item
label={t("jobs.fields.materials.mat_taxp")}
name={["materials", "MAPA", "mat_taxp"]}
rules={[
{
required: form.getFieldValue(["materials", "MAPA", "tax_ind"])
//message: t("general.validation.required"),
}
]}
>
<InputNumber min={0} max={100} precision={4} disabled={jobRO} />
</Form.Item>
);
}}
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.mat_tx_in1")}
name={["materials", "MAPA", "mat_tx_in1"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.mat_tx_in2")}
name={["materials", "MAPA", "mat_tx_in2"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.mat_tx_in3")}
name={["materials", "MAPA", "mat_tx_in3"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.mat_tx_in4")}
name={["materials", "MAPA", "mat_tx_in4"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.mat_tx_in5")}
name={["materials", "MAPA", "mat_tx_in5"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
</LayoutFormRow>
<LayoutFormRow header={t("jobs.fields.materials.MASH")}>
<Form.Item label={t("jobs.fields.materials.cal_maxdlr")} name={["materials", "MASH", "cal_maxdlr"]}>
<InputNumber min={0} precision={2} disabled={jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.materials.cal_opcode")} name={["materials", "MASH", "cal_opcode"]}>
<Input disabled={jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.materials.mat_adjp")} name={["materials", "MAPA", "mat_adjp"]}>
<InputNumber min={-100} max={100} precision={4} disabled={jobRO} />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.tax_ind")}
name={["materials", "MASH", "tax_ind"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item shouldUpdate>
{() => {
return (
<Form.Item
label={t("jobs.fields.materials.mat_taxp")}
name={["materials", "MASH", "mat_taxp"]}
rules={[
{
required: form.getFieldValue(["materials", "MASH", "tax_ind"])
//message: t("general.validation.required"),
}
]}
>
<InputNumber min={0} max={100} precision={4} disabled={jobRO} />
</Form.Item>
);
}}
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.mat_tx_in1")}
name={["materials", "MASH", "mat_tx_in1"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.mat_tx_in2")}
name={["materials", "MASH", "mat_tx_in2"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.mat_tx_in3")}
name={["materials", "MASH", "mat_tx_in3"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.mat_tx_in4")}
name={["materials", "MASH", "mat_tx_in4"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t("jobs.fields.materials.mat_tx_in5")}
name={["materials", "MASH", "mat_tx_in5"]}
valuePropName="checked"
>
<Switch />
</Form.Item>
</LayoutFormRow>
</Collapse.Panel>
</Collapse>
);
}
export default connect(mapStateToProps, null)(JobsDetailRatesMaterials);

View File

@@ -13,12 +13,9 @@ const mapStateToProps = createStructuredSelector({
export function JobsDetailRatesOther({ jobRO, expanded, required = true, form }) {
const { t } = useTranslation();
// Define collapse items
const collapseItems = [
{
key: "cieca_pfo",
label: t("jobs.labels.cieca_pfo"),
children: (
return (
<Collapse defaultActiveKey={expanded && "rates"}>
<Collapse.Panel forceRender header={t("jobs.labels.cieca_pfo")} key="cieca_pfo">
<LayoutFormRow noDivider>
<Form.Item
label={t("jobs.fields.cieca_pfo.tow_t_in1")}
@@ -55,6 +52,7 @@ export function JobsDetailRatesOther({ jobRO, expanded, required = true, form })
>
<Switch />
</Form.Item>
<Form.Item
label={t("jobs.fields.cieca_pfo.stor_t_in1")}
name={["cieca_pfo", "stor_t_in1"]}
@@ -91,12 +89,9 @@ export function JobsDetailRatesOther({ jobRO, expanded, required = true, form })
<Switch />
</Form.Item>
</LayoutFormRow>
),
forceRender: true
}
];
return <Collapse defaultActiveKey={expanded && "rates"} items={collapseItems} />;
</Collapse.Panel>
</Collapse>
);
}
export default connect(mapStateToProps, null)(JobsDetailRatesOther);

View File

@@ -11,9 +11,6 @@ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
//If the panel doesnt expand correctly due to the key mismatch, update the Collapse line to:
// <Collapse defaultActiveKey={expanded && "cieca_pft"} items={collapseItems} />
export function JobsDetailRatesTaxes({ jobRO, expanded, bodyshop, required = true, form }) {
const { t } = useTranslation();
const formItems = [];
@@ -50,18 +47,13 @@ export function JobsDetailRatesTaxes({ jobRO, expanded, bodyshop, required = tru
</>
);
}
// Define collapse items
const collapseItems = [
{
key: "cieca_pft",
label: t("jobs.labels.cieca_pft"),
children: <>{formItems}</>,
forceRender: true
}
];
return <Collapse defaultActiveKey={expanded && "rates"} items={collapseItems} />;
return (
<Collapse defaultActiveKey={expanded && "rates"}>
<Collapse.Panel forceRender header={t("jobs.labels.cieca_pft")} key="cieca_pft">
{formItems}
</Collapse.Panel>
</Collapse>
);
}
export default connect(mapStateToProps, null)(JobsDetailRatesTaxes);

View File

@@ -1,94 +0,0 @@
// notification-center.component.jsx
import React from "react";
import { Virtuoso } from "react-virtuoso";
import { Button, Checkbox, List, Badge, Typography, Alert } from "antd";
import { useTranslation } from "react-i18next";
import "./notification-center.styles.scss";
import { Link } from "react-router-dom";
const { Text, Title } = Typography;
const NotificationCenterComponent = ({
visible,
onClose,
notifications,
loading,
error,
showUnreadOnly,
toggleUnreadOnly,
markAllRead,
loadMore,
onNotificationClick
}) => {
const { t } = useTranslation();
const renderNotification = (index, notification) => {
return (
<List.Item
key={`${notification.id}-${index}`}
className={notification.read ? "notification-read" : "notification-unread"}
onClick={() => !notification.read && onNotificationClick(notification.id)}
>
<Badge dot={!notification.read}>
<div>
<Title
level={5}
style={{
margin: "0 0 8px 0",
display: "flex",
justifyContent: "space-between",
alignItems: "center"
}}
>
<Link
to={`/manage/jobs/${notification.jobid}`}
target="_blank"
onClick={(e) => {
e.stopPropagation(); // Prevent List.Item click handler from firing
!notification.read && onNotificationClick(notification.id); // Mark as read when link clicked
}}
>
RO #{notification.roNumber}
</Link>
<Text type="secondary">{new Date(notification.created_at).toLocaleString()}</Text>
</Title>
<Text strong={!notification.read}>
<ul>
{notification.scenarioText.map((text, idx) => (
<li key={`${notification.id}-${idx}`}>{text}</li>
))}
</ul>
</Text>
</div>
</Badge>
</List.Item>
);
};
return (
<div className={`notification-center ${visible ? "visible" : ""}`}>
<div className="notification-header">
<h3>{t("notifications.labels.notification-center")}</h3>
<div className="notification-controls">
<Checkbox checked={showUnreadOnly} onChange={(e) => toggleUnreadOnly(e.target.checked)}>
{t("notifications.labels.show-unread-only")}
</Checkbox>
<Button type="link" onClick={markAllRead} disabled={!notifications.some((n) => !n.read)}>
{t("notifications.labels.mark-all-read")}
</Button>
</div>
</div>
{error && <Alert message="Error" description={error} type="error" closable onClose={() => onClose()} />}
<Virtuoso
style={{ height: "400px", width: "100%" }}
data={notifications}
totalCount={notifications.length}
endReached={loadMore}
itemContent={renderNotification}
/>
{loading && !error && <div>{t("notifications.labels.loading")}</div>}
</div>
);
};
export default NotificationCenterComponent;

View File

@@ -1,176 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQuery } from "@apollo/client";
import { connect } from "react-redux";
import NotificationCenterComponent from "./notification-center.component";
import { GET_NOTIFICATIONS } from "../../graphql/notifications.queries";
import { INITIAL_NOTIFICATIONS, useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
const [notifications, setNotifications] = useState([]);
const [error, setError] = useState(null);
const { isConnected, markNotificationRead, markAllNotificationsRead } = useSocket();
const userAssociationId = bodyshop?.associations?.[0]?.id;
const baseWhereClause = useMemo(() => {
return { associationid: { _eq: userAssociationId } };
}, [userAssociationId]);
const whereClause = useMemo(() => {
return showUnreadOnly ? { ...baseWhereClause, read: { _is_null: true } } : baseWhereClause;
}, [baseWhereClause, showUnreadOnly]);
const {
data,
fetchMore,
loading,
error: queryError,
refetch
} = useQuery(GET_NOTIFICATIONS, {
variables: {
limit: INITIAL_NOTIFICATIONS,
offset: 0,
where: whereClause
},
fetchPolicy: "cache-and-network",
notifyOnNetworkStatusChange: true,
pollInterval: isConnected ? 0 : 30000,
skip: !userAssociationId,
onError: (err) => {
setError(err.message);
console.error("GET_NOTIFICATIONS error:", err);
setTimeout(() => refetch(), 2000);
}
});
useEffect(() => {
if (data?.notifications) {
const processedNotifications = data.notifications
.map((notif) => {
let scenarioText;
let scenarioMeta;
try {
scenarioText = notif.scenario_text ? JSON.parse(notif.scenario_text) : [];
scenarioMeta = notif.scenario_meta ? JSON.parse(notif.scenario_meta) : {};
} catch (e) {
console.error("Error parsing JSON for notification:", notif.id, e);
scenarioText = [notif.fcm_text || "Invalid notification data"];
scenarioMeta = {};
}
if (!Array.isArray(scenarioText)) scenarioText = [scenarioText];
const roNumber = notif.job.ro_number;
if (!Array.isArray(scenarioMeta)) scenarioMeta = [scenarioMeta];
return {
id: notif.id,
jobid: notif.jobid,
associationid: notif.associationid,
scenarioText,
scenarioMeta,
roNumber,
created_at: notif.created_at,
read: notif.read,
__typename: notif.__typename
};
})
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
setNotifications(processedNotifications);
setError(null);
}
}, [data]);
useEffect(() => {
if (queryError) {
setError(queryError.message);
}
}, [queryError]);
const loadMore = useCallback(() => {
if (!loading && data?.notifications.length) {
fetchMore({
variables: { offset: data.notifications.length, where: whereClause },
updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult) return prev;
return {
notifications: [...prev.notifications, ...fetchMoreResult.notifications]
};
}
}).catch((err) => {
setError(err.message);
console.error("Fetch more error:", err);
});
}
}, [data?.notifications?.length, fetchMore, loading, whereClause]);
const handleToggleUnreadOnly = (value) => {
setShowUnreadOnly(value);
};
const handleMarkAllRead = useCallback(() => {
markAllNotificationsRead()
.then(() => {
const timestamp = new Date().toISOString();
setNotifications((prev) => {
const updatedNotifications = prev.map((notif) =>
notif.read === null && notif.associationid === userAssociationId
? {
...notif,
read: timestamp
}
: notif
);
return [...updatedNotifications];
});
})
.catch((e) => console.error(`Error marking all notifications read: ${e?.message || ""}`));
}, [markAllNotificationsRead, userAssociationId]);
const handleNotificationClick = useCallback(
(notificationId) => {
markNotificationRead({
variables: { id: notificationId }
})
.then(() => {
const timestamp = new Date().toISOString();
setNotifications((prev) => {
return prev.map((notif) =>
notif.id === notificationId && !notif.read ? { ...notif, read: timestamp } : notif
);
});
})
.catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`));
},
[markNotificationRead]
);
useEffect(() => {
if (visible && !isConnected) {
refetch().catch(
(err) => `Something went wrong re-fetching notifications in the notification-center: ${err?.message || ""}`
);
}
}, [visible, isConnected, refetch]);
return (
<NotificationCenterComponent
visible={visible}
onClose={onClose}
notifications={notifications}
loading={loading}
error={error}
showUnreadOnly={showUnreadOnly}
toggleUnreadOnly={handleToggleUnreadOnly}
markAllRead={handleMarkAllRead}
loadMore={loadMore}
onNotificationClick={handleNotificationClick}
/>
);
}
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
export default connect(mapStateToProps, null)(NotificationCenterContainer);

View File

@@ -1,113 +0,0 @@
.notification-center {
position: absolute;
top: 64px;
right: 0;
//width: 600px;
background: #fff; /* White background, Ants default */
color: rgba(0, 0, 0, 0.85); /* Primary text color in Ant 5 */
border: 1px solid #d9d9d9; /* Neutral gray border */
border-radius: 6px; /* Slightly larger radius per Ant 5 */
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08), 0 3px 6px rgba(0, 0, 0, 0.06); /* Subtle Ant 5 shadow */
z-index: 1000;
display: none;
&.visible {
display: block;
}
.notification-header {
padding: 16px;
border-bottom: 1px solid #f0f0f0; /* Light gray border from Ant 5 */
display: flex;
justify-content: space-between;
align-items: center;
background: #fafafa; /* Light gray background for header */
h3 {
margin: 0;
font-size: 16px;
color: rgba(0, 0, 0, 0.85); /* Primary text color */
}
.notification-controls {
display: flex;
align-items: center;
gap: 16px;
.ant-checkbox-wrapper {
color: rgba(0, 0, 0, 0.85); /* Match Ants text color */
}
.ant-btn-link {
color: #1677ff; /* Ant 5 primary blue */
&:hover {
color: #69b1ff; /* Lighter blue on hover */
}
&:disabled {
color: rgba(0, 0, 0, 0.25); /* Disabled text color from Ant 5 */
}
}
}
}
.notification-read {
background: #fff; /* White background for read items */
color: rgba(0, 0, 0, 0.65); /* Secondary text color */
}
.notification-unread {
background: #f5f5f5; /* Very light gray for unread items */
color: rgba(0, 0, 0, 0.85); /* Primary text color */
}
.ant-list {
overflow: auto; /* Match Virtuosos default scrolling behavior */
max-height: 100%; /* Allow full height, let Virtuoso handle virtualization */
}
.ant-list-item {
padding: 2px 16px;
border-bottom: 1px solid #f0f0f0; /* Light gray border */
display: block; /* Ensure visibility */
overflow: visible; /* Prevent clipping within items */
min-height: 80px; /* Minimum height for multi-line content */
.ant-typography {
color: inherit; /* Inherit from parent (read/unread) */
}
.ant-typography-secondary {
font-size: 12px;
color: rgba(0, 0, 0, 0.45); /* Ant 5 secondary text color */
}
.ant-badge-dot {
background: #ff4d4f; /* Keep red dot for unread, consistent with Ant */
}
ul {
margin: 0;
padding-left: 20px; /* Standard list padding */
list-style-type: disc; /* Ensure bullet points */
}
li {
margin-bottom: 4px; /* Space between list items */
}
}
.ant-alert {
margin: 8px;
background: #fff1f0; /* Light red background for error per Ant 5 */
color: rgba(0, 0, 0, 0.85);
border: 1px solid #ffa39e; /* Light red border */
.ant-alert-message {
color: #ff4d4f; /* Red text for message */
}
.ant-alert-description {
color: rgba(0, 0, 0, 0.65); /* Slightly muted description */
}
}
}

View File

@@ -1,17 +1,16 @@
import { DeleteFilled, DownOutlined, WarningFilled } from "@ant-design/icons";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
import PartsOrderModalPriceChange from "./parts-order-modal-price-change.component";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -33,7 +32,7 @@ export function PartsOrderModalComponent({ bodyshop, vendorList, sendTypeState,
});
const { t } = useTranslation();
const handleClick = ({ item, key, keyPath }) => {
const handleClick = ({ item }) => {
form.setFieldsValue({ comments: item.props.value });
};
@@ -98,17 +97,18 @@ export function PartsOrderModalComponent({ bodyshop, vendorList, sendTypeState,
<Checkbox />
</Form.Item>
)}
<Form.Item name="order_type" initialValue="parts_order" label={t("parts_orders.labels.order_type")}>
<Radio.Group disabled={sendType === "oec"}>
<Radio value={"parts_order"}>{t("parts_orders.labels.parts_order")}</Radio>
<Radio value={"sublet"}>{t("parts_orders.labels.sublet_order")}</Radio>
</Radio.Group>
</Form.Item>
{!isReturn && (
<Form.Item name="order_type" initialValue="parts_order" label={t("parts_orders.labels.order_type")}>
<Radio.Group disabled={sendType === "oec"}>
<Radio value={"parts_order"}>{t("parts_orders.labels.parts_order")}</Radio>
<Radio value={"sublet"}>{t("parts_orders.labels.sublet_order")}</Radio>
</Radio.Group>
</Form.Item>
)}
</LayoutFormRow>
<Divider orientation="left">{t("parts_orders.labels.inthisorder")}</Divider>
<Form.List name={["parts_order_lines", "data"]}>
{(fields, { add, remove, move }) => {
{(fields, { remove, move }) => {
return (
<div>
{fields.map((field, index) => (

View File

@@ -2,15 +2,15 @@ import { CopyFilled } from "@ant-design/icons";
import { Button, Form, message, Popover, Space } from "antd";
import axios from "axios";
import Dinero from "dinero.js";
import { parsePhoneNumberWithError, ParseError } from "libphonenumber-js";
import React, { useState } from "react";
import { parsePhoneNumber } from "libphonenumber-js";
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -29,34 +29,22 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [paymentLink, setPaymentLink] = useState(null);
const { socket } = useSocket();
const { socket } = useContext(SocketContext);
const handleFinish = async ({ amount }) => {
setLoading(true);
let p;
try {
// Updated to use parsePhoneNumberWithError
p = parsePhoneNumberWithError(job.ownr_ph1 || "", "CA");
p = parsePhoneNumber(job.ownr_ph1 || "", "CA");
} catch (error) {
if (error instanceof ParseError) {
// Handle specific parsing errors
console.log(`Phone number parsing failed: ${error.message}`);
} else {
// Handle other unexpected errors
console.log("Unexpected error while parsing phone number:", error);
}
console.log("Unable to parse phone number");
}
setLoading(true);
const response = await axios.post("/intellipay/generate_payment_url", {
bodyshop,
amount: amount,
account: job.ro_number,
comment: btoa(
JSON.stringify({
payments: [{ jobid: job.id, amount }],
userEmail: currentUser.email
})
)
comment: btoa(JSON.stringify({ payments: [{ jobid: job.id, amount }], userEmail: currentUser.email }))
});
setLoading(false);
setPaymentLink(response.data.shorUrl);
@@ -118,20 +106,7 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope
</Space>
<Button
onClick={() => {
let p;
try {
// Updated second instance of phone parsing
p = parsePhoneNumberWithError(job.ownr_ph1, "CA");
} catch (error) {
if (error instanceof ParseError) {
// Handle specific parsing errors
console.log(`Phone number parsing failed: ${error.message}`);
} else {
// Handle other unexpected errors
console.log("Unexpected error while parsing phone number:", error);
}
return;
}
const p = parsePhoneNumber(job.ownr_ph1, "CA");
openChatByPhone({
phone_num: p.formatInternational(),
jobid: job.id,

View File

@@ -4,7 +4,7 @@ import { useApolloClient } from "@apollo/client";
import { Button, Skeleton, Space } from "antd";
import cloneDeep from "lodash/cloneDeep";
import isEqual from "lodash/isEqual";
import { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from "react";
import React, { useContext, useEffect, useMemo, useRef } from "react";
import { useApolloClient, useQuery, useSubscription } from "@apollo/client";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -12,7 +12,7 @@ import { QUERY_KANBAN_SETTINGS } from "../../graphql/user.queries";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import ProductionBoardKanbanComponent from "./production-board-kanban.component";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -22,7 +22,7 @@ const mapStateToProps = createStructuredSelector({
function ProductionBoardKanbanContainer({ bodyshop, currentUser, subscriptionType = "direct" }) {
const fired = useRef(false);
const client = useApolloClient();
const { socket } = useSocket();
const { socket } = useContext(SocketContext); // Get the socket from context
const reconnectTimeout = useRef(null); // To store the reconnect timeout
const disconnectTime = useRef(null); // To track disconnection time
const acceptableReconnectTime = 2000; // 2 seconds threshold

View File

@@ -1,5 +1,5 @@
import { useApolloClient, useQuery, useSubscription } from "@apollo/client";
import { useEffect, useRef, useState } from "react";
import React, { useContext, useEffect, useState, useRef } from "react";
import {
QUERY_EXACT_JOB_IN_PRODUCTION,
QUERY_EXACT_JOBS_IN_PRODUCTION,
@@ -10,11 +10,11 @@ import {
import ProductionListTable from "./production-list-table.component";
import _ from "lodash";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) {
const client = useApolloClient();
const { socket } = useSocket();
const { socket } = useContext(SocketContext);
const [joblist, setJoblist] = useState([]);
const reconnectTimeout = useRef(null); // To store the reconnect timeout
const disconnectTime = useRef(null); // To store the time of disconnection

View File

@@ -1,194 +0,0 @@
import { useMutation, useQuery } from "@apollo/client";
import { useEffect, useState } from "react";
import { Button, Card, Checkbox, Form, Table } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectCurrentUser } from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component";
import { QUERY_NOTIFICATION_SETTINGS, UPDATE_NOTIFICATION_SETTINGS } from "../../graphql/user.queries.js";
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
import PropTypes from "prop-types";
const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange }) => {
const { t } = useTranslation();
// Subscribe to all form values so that this component re-renders on changes.
const formValues = Form.useWatch([], form) || {};
// Determine if all scenarios for this channel are checked.
const allChecked =
notificationScenarios.length > 0 && notificationScenarios.every((scenario) => formValues[scenario]?.[channel]);
const onChange = (e) => {
const checked = e.target.checked;
// Get current form values.
const currentValues = form.getFieldsValue();
// Update each scenario for this channel.
const newValues = { ...currentValues };
notificationScenarios.forEach((scenario) => {
newValues[scenario] = { ...newValues[scenario], [channel]: checked };
});
// Update form values.
form.setFieldsValue(newValues);
// Manually mark the form as dirty.
if (onHeaderChange) {
onHeaderChange();
}
};
return (
<Checkbox onChange={onChange} checked={allChecked} disabled={disabled}>
{t(`notifications.channels.${channel}`)}
</Checkbox>
);
};
ColumnHeaderCheckbox.propTypes = {
channel: PropTypes.oneOf(["app", "email", "fcm"]).isRequired,
form: PropTypes.object.isRequired,
disabled: PropTypes.bool,
onHeaderChange: PropTypes.func
};
function NotificationSettingsForm({ currentUser }) {
const { t } = useTranslation();
const [form] = Form.useForm();
const [initialValues, setInitialValues] = useState({});
const [isDirty, setIsDirty] = useState(false);
// Fetch notification settings.
const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
variables: { email: currentUser.email },
skip: !currentUser
});
const [updateNotificationSettings, { loading: saving }] = useMutation(UPDATE_NOTIFICATION_SETTINGS);
// Populate form with fetched data.
useEffect(() => {
if (data?.associations?.length > 0) {
const settings = data.associations[0].notification_settings || {};
// Ensure each scenario has an object with { app, email, fcm }.
const formattedValues = notificationScenarios.reduce((acc, scenario) => {
acc[scenario] = settings[scenario] ?? { app: false, email: false, fcm: false };
return acc;
}, {});
setInitialValues(formattedValues);
form.setFieldsValue(formattedValues);
setIsDirty(false); // Reset dirty state when new data loads.
}
}, [data, form]);
const handleSave = async (values) => {
if (data?.associations?.length > 0) {
const userId = data.associations[0].id;
// Save the updated notification settings.
await updateNotificationSettings({ variables: { id: userId, ns: values } });
setInitialValues(values);
setIsDirty(false);
}
};
// Mark the form as dirty on any manual change.
const handleFormChange = () => {
setIsDirty(true);
};
const handleReset = () => {
form.setFieldsValue(initialValues);
setIsDirty(false);
};
if (error) return <AlertComponent type="error" message={error.message} />;
if (loading) return <LoadingSpinner />;
const columns = [
{
title: t("notifications.labels.scenario"),
dataIndex: "scenarioLabel",
key: "scenario",
render: (_, record) => t(`notifications.scenarios.${record.key}`),
width: "90%"
},
{
title: <ColumnHeaderCheckbox channel="app" form={form} onHeaderChange={() => setIsDirty(true)} />,
dataIndex: "app",
key: "app",
align: "center",
render: (_, record) => (
<Form.Item name={[record.key, "app"]} valuePropName="checked" noStyle>
<Checkbox />
</Form.Item>
)
},
{
title: <ColumnHeaderCheckbox channel="email" form={form} onHeaderChange={() => setIsDirty(true)} />,
dataIndex: "email",
key: "email",
align: "center",
render: (_, record) => (
<Form.Item name={[record.key, "email"]} valuePropName="checked" noStyle>
<Checkbox />
</Form.Item>
)
}
// TODO: Disabled for now until FCM is implemented.
// {
// title: <ColumnHeaderCheckbox channel="fcm" form={form} disabled onHeaderChange={() => setIsDirty(true)} />,
// dataIndex: "fcm",
// key: "fcm",
// align: "center",
// render: (_, record) => (
// <Form.Item name={[record.key, "fcm"]} valuePropName="checked" noStyle>
// <Checkbox disabled />
// </Form.Item>
// )
// }
];
const dataSource = notificationScenarios.map((scenario) => ({ key: scenario }));
return (
<Form
form={form}
onFinish={handleSave}
onValuesChange={handleFormChange}
initialValues={initialValues}
autoComplete="off"
layout="vertical"
>
<Card
title={t("notifications.labels.notificationscenarios")}
extra={
<>
<Button type="default" onClick={handleReset} disabled={!isDirty} style={{ marginRight: 8 }}>
{t("general.actions.clear")}
</Button>
<Button type="primary" htmlType="submit" disabled={!isDirty} loading={saving}>
{t("notifications.labels.save")}
</Button>
</>
}
>
<Table dataSource={dataSource} columns={columns} pagination={false} bordered rowKey="key" />
</Card>
</Form>
);
}
NotificationSettingsForm.propTypes = {
currentUser: PropTypes.shape({
email: PropTypes.string.isRequired
}).isRequired
};
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser
});
export default connect(mapStateToProps)(NotificationSettingsForm);

View File

@@ -1,5 +1,6 @@
import { Button, Card, Col, Form, Input } from "antd";
import { LockOutlined } from "@ant-design/icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -8,7 +9,6 @@ import { selectCurrentUser } from "../../redux/user/user.selectors";
import { logImEXEvent, updateCurrentPassword } from "../../firebase/firebase.utils";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import NotificationSettingsForm from "./notification-settings.component.jsx";
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser
@@ -117,9 +117,6 @@ export default connect(
</Card>
</Form>
</Col>
<Col span={24}>
<NotificationSettingsForm />
</Col>
</>
);
});

View File

@@ -36,7 +36,6 @@ export function ScheduleCalendarWrapperComponent({
const search = queryString.parse(useLocation().search);
const history = useNavigate();
const { t } = useTranslation();
const handleEventPropStyles = (event, start, end, isSelected) => {
return {
...(event.color && !((search.view || defaultView) === "agenda")
@@ -52,41 +51,37 @@ export function ScheduleCalendarWrapperComponent({
const selectedDate = new Date(date || dayjs(search.date) || Date.now());
// Convert Collapse to use items prop
const collapseItems = [
{
key: "1",
label: <span style={{ color: "tomato" }}>{t("appointments.labels.severalerrorsfound")}</span>,
children: (
<Space direction="vertical" style={{ width: "100%" }}>
{problemJobs.map((problem) => (
<Alert
key={problem.id}
type="error"
message={
<Trans
i18nKey="appointments.labels.dataconsistency"
components={[<Link to={`/manage/jobs/${problem.id}`} target="_blank" />]}
values={{
ro_number: problem.ro_number,
code: problem.code
}}
/>
}
/>
))}
</Space>
)
}
];
return (
<>
<JobDetailCards />
{HasFeatureAccess({ featureName: "smartscheduling", bodyshop }) &&
problemJobs &&
(problemJobs.length > 2 ? (
<Collapse items={collapseItems} style={{ marginBottom: "5px" }} />
<Collapse style={{ marginBottom: "5px" }}>
<Collapse.Panel
key="1"
header={<span style={{ color: "tomato" }}>{t("appointments.labels.severalerrorsfound")}</span>}
>
<Space direction="vertical" style={{ width: "100%" }}>
{problemJobs.map((problem) => (
<Alert
key={problem.id}
type="error"
message={
<Trans
i18nKey="appointments.labels.dataconsistency"
components={[<Link to={`/manage/jobs/${problem.id}`} target="_blank" />]}
values={{
ro_number: problem.ro_number,
code: problem.code
}}
/>
}
/>
))}
</Space>
</Collapse.Panel>
</Collapse>
) : (
<Space direction="vertical" style={{ width: "100%", marginBottom: "5px" }}>
{problemJobs.map((problem) => (
@@ -124,6 +119,7 @@ export function ScheduleCalendarWrapperComponent({
history({ search: queryString.stringify(search) });
}}
step={15}
// timeslots={1}
showMultiDayTimes
localizer={localizer}
min={bodyshop.schedule_start_time ? new Date(bodyshop.schedule_start_time) : new Date("2020-01-01T06:00:00")}

View File

@@ -1,384 +1,13 @@
import { createContext, useContext, useEffect, useRef, useState } from "react";
import SocketIO from "socket.io-client";
import { auth } from "../../firebase/firebase.utils";
import { store } from "../../redux/store";
import { addAlerts, setWssStatus } from "../../redux/application/application.actions";
import client from "../../utils/GraphQLClient";
import { useNotification } from "../Notifications/notificationContext.jsx";
import {
GET_NOTIFICATIONS,
GET_UNREAD_COUNT,
MARK_ALL_NOTIFICATIONS_READ,
MARK_NOTIFICATION_READ
} from "../../graphql/notifications.queries.js";
import { useMutation } from "@apollo/client";
import React, { createContext } from "react";
import useSocket from "./useSocket"; // Import the custom hook
// Create the SocketContext
const SocketContext = createContext(null);
// This is how many notifications the database will populate on load, and the increment for load more
export const INITIAL_NOTIFICATIONS = 10;
export const SocketProvider = ({ children, bodyshop }) => {
const { socket, clientId } = useSocket(bodyshop);
export const SCENARIO_NOTIFICATION_LOCATION = "bottomRight";
export const SCENARIO_NOTIFICATION_DURATION = 15; // Seconds
export const SocketProvider = ({ children, bodyshop, navigate }) => {
const socketRef = useRef(null);
const [clientId, setClientId] = useState(null);
const [isConnected, setIsConnected] = useState(false);
const notification = useNotification();
const userAssociationId = bodyshop?.associations?.[0]?.id;
const [markNotificationRead] = useMutation(MARK_NOTIFICATION_READ, {
update: (cache, { data: { update_notifications } }) => {
const timestamp = new Date().toISOString();
const updatedNotification = update_notifications.returning[0];
// Update the notifications list
cache.modify({
fields: {
notifications(existing = [], { readField }) {
return existing.map((notif) => {
if (readField("id", notif) === updatedNotification.id) {
return { ...notif, read: timestamp };
}
return notif;
});
}
}
});
// Update the unread count in notifications_aggregate
const unreadCountQuery = cache.readQuery({
query: GET_UNREAD_COUNT,
variables: { associationid: userAssociationId }
});
if (unreadCountQuery?.notifications_aggregate?.aggregate?.count > 0) {
cache.writeQuery({
query: GET_UNREAD_COUNT,
variables: { associationid: userAssociationId },
data: {
notifications_aggregate: {
...unreadCountQuery.notifications_aggregate,
aggregate: {
...unreadCountQuery.notifications_aggregate.aggregate,
count: unreadCountQuery.notifications_aggregate.aggregate.count - 1
}
}
}
});
}
},
onError: (err) => {
console.error("MARK_NOTIFICATION_READ error in SocketProvider:", err);
}
});
const [markAllNotificationsRead] = useMutation(MARK_ALL_NOTIFICATIONS_READ, {
variables: { associationid: userAssociationId },
update: (cache) => {
const timestamp = new Date().toISOString();
cache.modify({
fields: {
notifications(existing = [], { readField }) {
return existing.map((notif) => {
if (readField("read", notif) === null && readField("associationid", notif) === userAssociationId) {
return { ...notif, read: timestamp };
}
return notif;
});
},
notifications_aggregate() {
return { aggregate: { count: 0, __typename: "notifications_aggregate_fields" } };
}
}
});
const baseWhereClause = { associationid: { _eq: userAssociationId } };
const cachedNotifications = cache.readQuery({
query: GET_NOTIFICATIONS,
variables: {
limit: INITIAL_NOTIFICATIONS,
offset: 0,
where: baseWhereClause
}
});
if (cachedNotifications?.notifications) {
cache.writeQuery({
query: GET_NOTIFICATIONS,
variables: {
limit: INITIAL_NOTIFICATIONS,
offset: 0,
where: baseWhereClause
},
data: {
notifications: cachedNotifications.notifications.map((notif) =>
notif.read === null ? { ...notif, read: timestamp } : notif
)
}
});
}
},
onError: (err) => {
console.error("MARK_ALL_NOTIFICATIONS_READ error in SocketProvider:", err);
}
});
useEffect(() => {
const initializeSocket = async (token) => {
if (!bodyshop || !bodyshop.id || socketRef.current) return;
const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "";
const socketInstance = SocketIO(endpoint, {
path: "/wss",
withCredentials: true,
auth: { token, bodyshopId: bodyshop.id },
reconnectionAttempts: Infinity,
reconnectionDelay: 2000,
reconnectionDelayMax: 10000
});
socketRef.current = socketInstance;
const handleBodyshopMessage = (message) => {
if (!message || !message.type) return;
switch (message.type) {
case "alert-update":
store.dispatch(addAlerts(message.payload));
break;
default:
break;
}
};
const handleConnect = () => {
socketInstance.emit("join-bodyshop-room", bodyshop.id);
setClientId(socketInstance.id);
setIsConnected(true);
store.dispatch(setWssStatus("connected"));
};
const handleReconnect = () => {
setIsConnected(true);
store.dispatch(setWssStatus("connected"));
};
const handleConnectionError = (err) => {
console.error("Socket connection error:", err);
setIsConnected(false);
if (err.message.includes("auth/id-token-expired")) {
console.warn("Token expired, refreshing...");
auth.currentUser?.getIdToken(true).then((newToken) => {
socketInstance.auth = { token: newToken };
socketInstance.connect();
});
} else {
store.dispatch(setWssStatus("error"));
}
};
const handleDisconnect = (reason) => {
console.warn("Socket disconnected:", reason);
setIsConnected(false);
store.dispatch(setWssStatus("disconnected"));
if (!socketInstance.connected && reason !== "io server disconnect") {
setTimeout(() => {
if (socketInstance.disconnected) {
console.log("Manually triggering reconnection...");
socketInstance.connect();
}
}, 2000);
}
};
const handleNotification = (data) => {
const { jobId, jobRoNumber, notificationId, associationId, notifications } = data;
if (associationId !== userAssociationId) return;
const newNotification = {
__typename: "notifications",
id: notificationId,
jobid: jobId,
associationid: associationId,
scenario_text: JSON.stringify(notifications.map((notif) => notif.body)),
fcm_text: notifications.map((notif) => notif.body).join(". ") + ".",
scenario_meta: JSON.stringify(notifications.map((notif) => notif.variables || {})),
created_at: new Date(notifications[0].timestamp).toISOString(),
read: null,
job: {
ro_number: jobRoNumber
}
};
const baseVariables = {
limit: INITIAL_NOTIFICATIONS,
offset: 0,
where: { associationid: { _eq: userAssociationId } }
};
try {
const existingNotifications =
client.cache.readQuery({
query: GET_NOTIFICATIONS,
variables: baseVariables
})?.notifications || [];
if (existingNotifications.some((n) => n.id === newNotification.id)) {
return;
}
client.cache.writeQuery({
query: GET_NOTIFICATIONS,
variables: baseVariables,
data: {
notifications: [newNotification, ...existingNotifications].sort(
(a, b) => new Date(b.created_at) - new Date(a.created_at)
)
},
broadcast: true
});
const unreadVariables = {
...baseVariables,
where: { ...baseVariables.where, read: { _is_null: true } }
};
const unreadNotifications =
client.cache.readQuery({
query: GET_NOTIFICATIONS,
variables: unreadVariables
})?.notifications || [];
if (newNotification.read === null && !unreadNotifications.some((n) => n.id === newNotification.id)) {
client.cache.writeQuery({
query: GET_NOTIFICATIONS,
variables: unreadVariables,
data: {
notifications: [newNotification, ...unreadNotifications].sort(
(a, b) => new Date(b.created_at) - new Date(a.created_at)
)
},
broadcast: true
});
}
client.cache.modify({
id: "ROOT_QUERY",
fields: {
notifications_aggregate(existing = { aggregate: { count: 0 } }) {
const isUnread = newNotification.read === null;
const countChange = isUnread ? 1 : 0;
return {
...existing,
aggregate: {
...existing.aggregate,
count: existing.aggregate.count + countChange
}
};
}
}
});
notification.info({
message: `Changes for ${jobRoNumber}:`,
description: (
<ul
className="notification-alert-unorderd-list"
onClick={() => {
markNotificationRead({ variables: { id: notificationId } })
.then(() => navigate(`/manage/jobs/${jobId}`))
.catch((e) => console.error(`Error marking notification read from info: ${e?.message || ""}`));
}}
style={{ cursor: "pointer" }}
>
{notifications.map((notif, index) => (
<li className="notification-alert-unorderd-list-item" key={index}>
{notif.body}
</li>
))}
</ul>
),
placement: SCENARIO_NOTIFICATION_LOCATION,
duration: SCENARIO_NOTIFICATION_DURATION
});
} catch (error) {
console.error(`Something went wrong handling a new notification: ${error?.message || ""}`);
}
};
socketInstance.on("connect", handleConnect);
socketInstance.on("reconnect", handleReconnect);
socketInstance.on("connect_error", handleConnectionError);
socketInstance.on("disconnect", handleDisconnect);
socketInstance.on("bodyshop-message", handleBodyshopMessage);
socketInstance.on("message", (message) => {
try {
if (typeof message === "string" && message.startsWith("42")) {
const parsedMessage = JSON.parse(message.slice(2));
const [event, data] = parsedMessage;
if (event === "notification") handleNotification(data);
} else if (Array.isArray(message)) {
const [event, data] = message;
if (event === "notification") handleNotification(data);
}
} catch (error) {
console.error("Error parsing socket message:", error);
}
});
socketInstance.on("notification", handleNotification);
};
const unsubscribe = auth.onIdTokenChanged(async (user) => {
if (user) {
const token = await user.getIdToken();
if (socketRef.current) {
socketRef.current.emit("update-token", { token, bodyshopId: bodyshop.id });
} else {
initializeSocket(token).catch((err) =>
console.error(`Something went wrong Initializing Sockets: ${err?.message || ""}`)
);
}
} else {
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
setIsConnected(false);
}
}
});
return () => {
unsubscribe();
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
setIsConnected(false);
}
};
}, [bodyshop, notification, userAssociationId, markNotificationRead, markAllNotificationsRead, navigate]);
return (
<SocketContext.Provider
value={{
socket: socketRef.current,
clientId,
isConnected,
markNotificationRead,
markAllNotificationsRead
}}
>
{children}
</SocketContext.Provider>
);
};
export const useSocket = () => {
const context = useContext(SocketContext);
if (!context) {
throw new Error("useSocket must be used within a SocketProvider");
}
return context;
return <SocketContext.Provider value={{ socket, clientId }}> {children}</SocketContext.Provider>;
};
export default SocketContext;

View File

@@ -0,0 +1,125 @@
import { useEffect, useRef, useState } from "react";
import SocketIO from "socket.io-client";
import { auth } from "../../firebase/firebase.utils";
import { store } from "../../redux/store";
import { addAlerts, setWssStatus } from "../../redux/application/application.actions";
const useSocket = (bodyshop) => {
const socketRef = useRef(null);
const [clientId, setClientId] = useState(null);
useEffect(() => {
const initializeSocket = async (token) => {
if (!bodyshop || !bodyshop.id) return;
const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "";
const socketInstance = SocketIO(endpoint, {
path: "/wss",
withCredentials: true,
auth: { token },
reconnectionAttempts: Infinity,
reconnectionDelay: 2000,
reconnectionDelayMax: 10000
});
socketRef.current = socketInstance;
// Handle socket events
const handleBodyshopMessage = (message) => {
if (!message || !message.type) return;
switch (message.type) {
case "alert-update":
store.dispatch(addAlerts(message.payload));
break;
default:
break;
}
if (!import.meta.env.DEV) return;
console.log(`Received message for bodyshop ${bodyshop.id}:`, message);
};
const handleConnect = () => {
socketInstance.emit("join-bodyshop-room", bodyshop.id);
setClientId(socketInstance.id);
store.dispatch(setWssStatus("connected"));
};
const handleReconnect = () => {
store.dispatch(setWssStatus("connected"));
};
const handleConnectionError = (err) => {
console.error("Socket connection error:", err);
// Handle token expiration
if (err.message.includes("auth/id-token-expired")) {
console.warn("Token expired, refreshing...");
auth.currentUser?.getIdToken(true).then((newToken) => {
socketInstance.auth = { token: newToken }; // Update socket auth
socketInstance.connect(); // Retry connection
});
} else {
store.dispatch(setWssStatus("error"));
}
};
const handleDisconnect = (reason) => {
console.warn("Socket disconnected:", reason);
store.dispatch(setWssStatus("disconnected"));
// Manually trigger reconnection if necessary
if (!socketInstance.connected && reason !== "io server disconnect") {
setTimeout(() => {
if (socketInstance.disconnected) {
console.log("Manually triggering reconnection...");
socketInstance.connect();
}
}, 2000); // Retry after 2 seconds
}
};
// Register event handlers
socketInstance.on("connect", handleConnect);
socketInstance.on("reconnect", handleReconnect);
socketInstance.on("connect_error", handleConnectionError);
socketInstance.on("disconnect", handleDisconnect);
socketInstance.on("bodyshop-message", handleBodyshopMessage);
};
const unsubscribe = auth.onIdTokenChanged(async (user) => {
if (user) {
const token = await user.getIdToken();
if (socketRef.current) {
// Update token if socket exists
socketRef.current.emit("update-token", token);
} else {
// Initialize socket if not already connected
initializeSocket(token);
}
} else {
// User is not authenticated
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
}
}
});
// Clean up on unmount
return () => {
unsubscribe();
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
}
};
}, [bodyshop]);
return { socket: socketRef.current, clientId };
};
export default useSocket;

View File

@@ -1,12 +1,10 @@
import { onError } from "@apollo/client/link/error";
//https://stackoverflow.com/questions/57163454/refreshing-a-token-with-apollo-client-firebase-auth
import * as Sentry from "@sentry/react";
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path }) => {
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`);
Sentry.captureException({ message, locations, path });
});
}
if (networkError) console.log(`[Network error]: ${JSON.stringify(networkError)}`);

View File

@@ -349,13 +349,3 @@ export const QUERY_STRIPE_ID = gql`
}
}
`;
export const GET_ACTIVE_EMPLOYEES_IN_SHOP = gql`
query GetActiveEmployeesInShop($shopid: uuid!) {
associations(where: { shopid: { _eq: $shopid } }) {
id
useremail
shopid
}
}
`;

View File

@@ -524,9 +524,6 @@ export const GET_JOB_BY_PK = gql`
invoice_final_note
iouparent
job_totals
job_watchers {
user_email
}
joblines(where: { removed: { _eq: false } }, order_by: { line_no: asc }) {
act_price
act_price_before_ppc
@@ -2570,30 +2567,3 @@ export const GET_JOB_BY_PK_QUICK_INTAKE = gql`
}
}
`;
export const GET_JOB_WATCHERS = gql`
query GET_JOB_WATCHERS($jobid: uuid!) {
job_watchers(where: { jobid: { _eq: $jobid } }) {
id
user_email
}
}
`;
export const ADD_JOB_WATCHER = gql`
mutation ADD_JOB_WATCHER($jobid: uuid!, $userEmail: String!) {
insert_job_watchers_one(object: { jobid: $jobid, user_email: $userEmail }) {
id
jobid
user_email
}
}
`;
export const REMOVE_JOB_WATCHER = gql`
mutation REMOVE_JOB_WATCHER($jobid: uuid!, $userEmail: String!) {
delete_job_watchers(where: { jobid: { _eq: $jobid }, user_email: { _eq: $userEmail } }) {
affected_rows
}
}
`;

View File

@@ -1,51 +0,0 @@
import { gql } from "@apollo/client";
export const GET_NOTIFICATIONS = gql`
query GetNotifications($limit: Int!, $offset: Int!, $where: notifications_bool_exp) {
notifications(limit: $limit, offset: $offset, order_by: { created_at: desc }, where: $where) {
id
jobid
associationid
scenario_text
fcm_text
scenario_meta
created_at
read
job {
ro_number
}
}
}
`;
export const GET_UNREAD_COUNT = gql`
query GetUnreadCount($associationid: uuid!) {
notifications_aggregate(where: { read: { _is_null: true }, associationid: { _eq: $associationid } }) {
aggregate {
count
}
}
}
`;
export const MARK_ALL_NOTIFICATIONS_READ = gql`
mutation MarkAllNotificationsRead($associationid: uuid!) {
update_notifications(
where: { read: { _is_null: true }, associationid: { _eq: $associationid } }
_set: { read: "now()" }
) {
affected_rows
}
}
`;
export const MARK_NOTIFICATION_READ = gql`
mutation MarkNotificationRead($id: uuid!) {
update_notifications(where: { id: { _eq: $id } }, _set: { read: "now()" }) {
returning {
id
read
}
}
}
`;

View File

@@ -85,21 +85,3 @@ export const UPDATE_KANBAN_SETTINGS = gql`
}
}
`;
export const QUERY_NOTIFICATION_SETTINGS = gql`
query QUERY_NOTIFICATION_SETTINGS($email: String!) {
associations(where: { _and: { useremail: { _eq: $email }, active: { _eq: true } } }) {
id
notification_settings
}
}
`;
export const UPDATE_NOTIFICATION_SETTINGS = gql`
mutation UPDATE_NOTIFICATION_SETTINGS($id: uuid!, $ns: jsonb) {
update_associations_by_pk(pk_columns: { id: $id }, _set: { notification_settings: $ns }) {
id
notification_settings
}
}
`;

View File

@@ -1,10 +1,12 @@
import "./utils/sentry"; //Must be first.
import * as Sentry from "@sentry/react";
import { ConfigProvider } from "antd";
import Dinero from "dinero.js";
import React from "react";
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import { createBrowserRouter, createRoutesFromElements, Route, RouterProvider } from "react-router-dom";
import { PersistGate } from "redux-persist/integration/react";
import { registerSW } from "virtual:pwa-register";
import AppContainer from "./App/App.container";
import LoadingSpinner from "./components/loading-spinner/loading-spinner.component";
import "./index.css";
@@ -12,56 +14,18 @@ import { persistor, store } from "./redux/store";
import reportWebVitals from "./reportWebVitals";
import "./translations/i18n";
import "./utils/CleanAxios";
import { ConfigProvider } from "antd";
import InstanceRenderManager from "./utils/instanceRenderMgr";
import { registerSW } from "virtual:pwa-register";
window.global ||= window;
registerSW({ immediate: true });
//import { BrowserTracing } from "@sentry/tracing";
//import "antd/dist/antd.css";
// import "antd/dist/antd.less";
// Dinero.defaultCurrency = "CAD";
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN";
if (import.meta.env.PROD) {
Sentry.init({
dsn: InstanceRenderManager({
imex: "https://fd7e89369b6b4bdc9c6c4c9f22fa4ee4@o492140.ingest.sentry.io/5651027",
rome: "https://a6acc91c073e414196014b8484627a61@o492140.ingest.sentry.io/4504561071161344"
}),
const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV6(createBrowserRouter);
ignoreErrors: [
"ResizeObserver loop",
"ResizeObserver loop limit exceeded",
"Module specifier, 'fs' does not start",
"Module specifier, 'zlib' does not start with"
],
integrations: [
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: true
}),
Sentry.browserTracingIntegration()
],
tracePropagationTargets: [
"api.imex.online",
"api.test.imex.online",
"db.imex.online",
"api.romeonline.io",
"api.test.romeonline.io",
"db.romeonline.io"
],
tracesSampleRate: 1.0,
replaysOnErrorSampleRate: 1.0,
environment: import.meta.env.MODE
});
}
const router = createBrowserRouter(createRoutesFromElements(<Route path="*" element={<AppContainer />} />));
const router = sentryCreateBrowserRouter(createRoutesFromElements(<Route path="*" element={<AppContainer />} />));
if (import.meta.env.DEV) {
let styles =

View File

@@ -1,196 +0,0 @@
import React, { useCallback, useMemo, useState } from "react";
import { useMutation, useQuery } from "@apollo/client";
import { EyeFilled, EyeOutlined, UserOutlined } from "@ant-design/icons";
import { ADD_JOB_WATCHER, GET_JOB_WATCHERS, REMOVE_JOB_WATCHER } from "../../graphql/jobs.queries.js";
import { Avatar, Button, Divider, List, Popover, Select, Tooltip, Typography } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
import EmployeeSearchSelectComponent from "../../components/employee-search-select/employee-search-select.component.jsx";
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component.jsx";
const { Text } = Typography;
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
currentUser: selectCurrentUser
});
const JobWatcherToggle = ({ job, currentUser, bodyshop }) => {
const { t } = useTranslation();
const userEmail = currentUser.email;
const jobid = job.id;
const [open, setOpen] = useState(false);
const [selectedWatcher, setSelectedWatcher] = useState(null); // New state for selected value
const [selectedTeam, setSelectedTeam] = useState(null); // New state to track selected team
// Fetch current watchers
const { data: watcherData, loading: watcherLoading } = useQuery(GET_JOB_WATCHERS, { variables: { jobid } });
// Extract watchers list
const jobWatchers = useMemo(() => watcherData?.job_watchers || [], [watcherData]);
const isWatching = useMemo(() => jobWatchers.some((w) => w.user_email === userEmail), [jobWatchers, userEmail]);
// Add watcher mutation
const [addWatcher, { loading: adding }] = useMutation(ADD_JOB_WATCHER, {
refetchQueries: [{ query: GET_JOB_WATCHERS, variables: { jobid } }]
});
// Remove watcher mutation
const [removeWatcher, { loading: removing }] = useMutation(REMOVE_JOB_WATCHER, {
refetchQueries: [{ query: GET_JOB_WATCHERS, variables: { jobid } }]
});
// Toggle watcher for self
const handleToggleSelf = useCallback(() => {
(isWatching
? removeWatcher({ variables: { jobid, userEmail } })
: addWatcher({ variables: { jobid, userEmail } })
).catch((err) => console.error(`Error updating job watcher: ${err.message}`));
}, [isWatching, addWatcher, removeWatcher, jobid, userEmail]);
// Handle removing a watcher
const handleRemoveWatcher = (userEmail) => {
removeWatcher({ variables: { jobid, userEmail } }).catch((err) =>
console.error(`Error removing job watcher: ${err.message}`)
);
};
const handleWatcherSelect = (selectedUser) => {
const employee = bodyshop.employees.find((e) => e.id === selectedUser);
if (!employee) return;
const isAlreadyWatching = jobWatchers.some((w) => w.user_email === employee.user_email);
if (isAlreadyWatching) {
handleRemoveWatcher(employee.user_email);
} else {
addWatcher({ variables: { jobid, userEmail: employee.user_email } }).catch((err) =>
console.error(`Error adding job watcher: ${err.message}`)
);
}
// Clear selection
setSelectedWatcher(null);
};
const handleTeamSelect = (team) => {
const selectedTeamMembers = JSON.parse(team); // Parse the array of emails
const newWatchers = selectedTeamMembers.filter(
(email) => !jobWatchers.some((watcher) => watcher.user_email === email)
);
// Add each new watcher
newWatchers.forEach((email) => {
addWatcher({ variables: { jobid, userEmail: email } }).catch((err) =>
console.error(`Error adding job watcher: ${err.message}`)
);
});
// Clear selection
setSelectedTeam(null);
};
const handleRenderItem = (watcher) => {
const employee = bodyshop.employees.find((e) => e.user_email === watcher.user_email);
const displayName = employee ? `${employee.first_name} ${employee.last_name}` : watcher.user_email;
return (
<List.Item
actions={[
<Button type="link" danger size="small" onClick={() => handleRemoveWatcher(watcher.user_email)}>
{t("notifications.actions.remove")}
</Button>
]}
>
<List.Item.Meta
avatar={<Avatar icon={<UserOutlined />} />}
title={<Text>{displayName}</Text>}
description={watcher.user_email} // Keep the email for reference
/>
</List.Item>
);
};
// Popover content
const popoverContent = (
<div style={{ width: 600 }}>
{/* Self-toggle Button */}
<Button
block
type="text"
icon={isWatching ? <EyeOutlined /> : <EyeFilled />}
onClick={handleToggleSelf}
loading={adding || removing}
>
{isWatching ? t("notifications.tooltips.unwatch") : t("notifications.tooltips.watch")}
</Button>
{/* List of Watchers */}
<Text type="secondary" style={{ marginBottom: 8, display: "block" }}>
{t("notifications.labels.watching-issue")}
</Text>
{watcherLoading ? <LoadingSpinner /> : <List dataSource={jobWatchers} renderItem={handleRenderItem} />}
{/* Employee Search Select (for adding watchers) */}
<Divider />
<Text type="secondary">{t("notifications.labels.add-watchers")}</Text>
<EmployeeSearchSelectComponent
style={{ minWidth: "100%" }}
options={bodyshop.employees.filter((e) => jobWatchers.every((w) => w.user_email !== e.user_email))}
placeholder={t("notifications.labels.employee-search")}
value={selectedWatcher} // Controlled value
onChange={(value) => {
setSelectedWatcher(value); // Update selected state
handleWatcherSelect(value); // Add watcher logic
}}
/>
{/* Divider for UI separation */}
{/* Only show team selection if there are available teams */}
{bodyshop?.employee_teams?.length > 0 && (
<>
<Divider />
<Text type="secondary">{t("notifications.labels.add-watchers-team")}</Text>
<Select
showSearch
style={{ minWidth: "100%" }}
placeholder={t("notifications.labels.teams-search")}
value={selectedTeam} // Controlled value
onChange={handleTeamSelect}
options={bodyshop.employee_teams.map((team) => {
const teamMembers = team.employee_team_members
.map((member) => {
const employee = bodyshop.employees.find((e) => e.id === member.employeeid);
return employee ? employee.user_email : null;
})
.filter(Boolean); // Remove nulls
return {
value: JSON.stringify(teamMembers), // Store array as string
label: team.name // Use team name as label
};
})}
/>
</>
)}
</div>
);
return (
<Popover content={popoverContent} trigger="click" open={open} onOpenChange={setOpen}>
<Tooltip title={isWatching ? t("notifications.tooltips.unwatch") : t("notifications.tooltips.watch")}>
<Button
shape="circle"
type={isWatching ? "primary" : "default"}
icon={isWatching ? <EyeFilled /> : <EyeOutlined />}
loading={watcherLoading}
/>
</Tooltip>
</Popover>
);
};
export default connect(mapStateToProps)(JobWatcherToggle);

View File

@@ -56,7 +56,6 @@ import { DateTimeFormat } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
import UndefinedToNull from "../../utils/undefinedtonull";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import JobWatcherToggle from "./job-watcher-toggle.component.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -320,13 +319,7 @@ export function JobsDetailPage({
>
<PageHeader
// onBack={() => window.history.back()}
title={
<Space>
<JobWatcherToggle job={job} />
{job.ro_number || t("general.labels.na")}
</Space>
}
title={job.ro_number || t("general.labels.na")}
extra={menuExtra}
/>
<JobsDetailHeader job={job} />

View File

@@ -1,7 +1,7 @@
import { FloatButton, Layout, Spin } from "antd";
// import preval from "preval.macro";
import React, { lazy, Suspense, useEffect, useState } from "react";
import React, { lazy, Suspense, useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, Route, Routes } from "react-router-dom";
@@ -20,7 +20,7 @@ import PartnerPingComponent from "../../components/partner-ping/partner-ping.com
import PrintCenterModalContainer from "../../components/print-center-modal/print-center-modal.container";
import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-status.component";
import { requestForToken } from "../../firebase/firebase.utils";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { selectBodyshop, selectInstanceConflict } from "../../redux/user/user.selectors";
import UpdateAlert from "../../components/update-alert/update-alert.component";
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
@@ -29,7 +29,6 @@ import WssStatusDisplayComponent from "../../components/wss-status-display/wss-s
import { selectAlerts } from "../../redux/application/application.selectors.js";
import { addAlerts } from "../../redux/application/application.actions.js";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const JobsPage = lazy(() => import("../jobs/jobs.page"));
const CardPaymentModalContainer = lazy(
@@ -123,7 +122,7 @@ const mapDispatchToProps = (dispatch) => ({
export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
const { t } = useTranslation();
const [chatVisible] = useState(false);
const { socket, clientId } = useSocket();
const { socket, clientId } = useContext(SocketContext);
const notification = useNotification();
// State to track displayed alerts
@@ -143,11 +142,11 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
const fetchedAlerts = await response.json();
setAlerts(fetchedAlerts);
} catch (error) {
console.error("Error fetching alerts:", error);
console.warn("Error fetching alerts:", error.message);
}
};
fetchAlerts().catch((err) => `Error fetching Bodyshop Alerts: ${err?.message || ""}`);
fetchAlerts();
}, [setAlerts]);
// Use useEffect to watch for new alerts

View File

@@ -351,7 +351,7 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
? window.$crisp.push(["set", "session:segments", [["allAccess"]]])
: window.$crisp.push(["set", "session:segments", [["basic"]]]);
} catch (error) {
console.error("Couldnt find $crisp.");
console.warn("Couldnt find $crisp.", error.message);
}
} catch (error) {
yield put(signInFailure(error.message));

View File

@@ -3766,55 +3766,6 @@
"validation": {
"unique_vendor_name": "You must enter a unique vendor name."
}
},
"notifications": {
"labels": {
"notification-center": "Notification Center",
"scenario": "Scenario",
"notificationscenarios": "Job Notification Scenarios",
"save": "Save Scenarios",
"watching-issue": "Watching",
"add-watchers": "Add Watchers",
"employee-search": "Search for an Employee",
"teams-search": "Search for a Team",
"add-watchers-team": "Add Team Members",
"new-notification-title": "New Notification:",
"show-unread-only": "Show Unread",
"mark-all-read": "Mark Read",
"loading": "Loading Notifications..."
},
"actions": {
"remove": "remove"
},
"aria": {
"toggle": "Toggle Watching Job"
},
"tooltips": {
"watch": "Watch Job",
"unwatch": "Unwatch Job"
},
"scenarios": {
"job-assigned-to-me": "Job Assigned to Me",
"bill-posted": "Bill Posted",
"critical-parts-status-changed": "Critical Parts Status Changed",
"part-marked-back-ordered": "Part Marked Back Ordered",
"new-note-added": "New Note Added",
"supplement-imported": "Supplement Imported",
"schedule-dates-changed": "Schedule Dates Changed",
"tasks-updated-created": "Tasks Updated / Created",
"new-media-added-reassigned": "New Media Added or Reassigned",
"new-time-ticket-posted": "New Time Ticket Posted",
"intake-delivery-checklist-completed": "Intake or Delivery Checklist Completed",
"job-added-to-production": "Job Added to Production",
"job-status-change": "Job Status Changed",
"payment-collected-completed": "Payment Collected / Completed",
"alternate-transport-changed": "Alternate Transport Changed"
},
"channels": {
"app": "App",
"email": "Email",
"fcm": "Push"
}
}
}
}
}
}

View File

@@ -3766,50 +3766,6 @@
"validation": {
"unique_vendor_name": ""
}
},
"notifications": {
"labels": {
"scenario": "",
"notificationscenarios": "",
"save": "",
"watching-issue": "",
"add-watchers": "",
"employee-search": "",
"teams-search": "",
"add-watchers-team": ""
},
"actions": {
"remove": ""
},
"aria": {
"toggle": ""
},
"tooltips": {
"watch": "",
"unwatch": ""
},
"scenarios": {
"job-assigned-to-me": "",
"bill-posted": "",
"critical-parts-status-changed": "",
"part-marked-back-ordered": "",
"new-note-added": "",
"supplement-imported": "",
"schedule-dates-changed": "",
"tasks-updated-created": "",
"new-media-added-reassigned": "",
"new-time-ticket-posted": "",
"intake-delivery-checklist-completed": "",
"job-added-to-production": "",
"job-status-change": "",
"payment-collected-completed": "",
"alternate-transport-changed": ""
},
"channels": {
"app": "",
"email": "",
"fcm": ""
}
}
}
}
}
}

View File

@@ -3766,50 +3766,6 @@
"validation": {
"unique_vendor_name": ""
}
},
"notifications": {
"labels": {
"scenario": "",
"notificationscenarios": "",
"save": "",
"watching-issue": "",
"add-watchers": "",
"employee-search": "",
"teams-search": "",
"add-watchers-team": ""
},
"actions": {
"remove": ""
},
"aria": {
"toggle": ""
},
"tooltips": {
"watch": "",
"unwatch": ""
},
"scenarios": {
"job-assigned-to-me": "",
"bill-posted": "",
"critical-parts-status-changed": "",
"part-marked-back-ordered": "",
"new-note-added": "",
"supplement-imported": "",
"schedule-dates-changed": "",
"tasks-updated-created": "",
"new-media-added-reassigned": "",
"new-time-ticket-posted": "",
"intake-delivery-checklist-completed": "",
"job-added-to-production": "",
"job-status-change": "",
"payment-collected-completed": "",
"alternate-transport-changed": ""
},
"channels": {
"app": "",
"email": "",
"fcm": ""
}
}
}
}
}
}

View File

@@ -7,9 +7,9 @@ import { getMainDefinition } from "@apollo/client/utilities";
//import { split } from "apollo-link";
import apolloLogger from "apollo-link-logger";
//import axios from "axios";
import { SentryLink } from "apollo-link-sentry";
import { auth } from "../firebase/firebase.utils";
import errorLink from "../graphql/apollo-error-handling";
import { SentryLink } from "apollo-link-sentry";
//import { store } from "../redux/store";
const httpLink = new HttpLink({

View File

@@ -1,19 +0,0 @@
const notificationScenarios = [
"job-assigned-to-me",
"bill-posted",
"critical-parts-status-changed",
"part-marked-back-ordered",
"new-note-added",
"supplement-imported",
"schedule-dates-changed",
"tasks-updated-created",
"new-media-added-reassigned",
"new-time-ticket-posted",
"intake-delivery-checklist-completed",
"job-added-to-production",
"job-status-change",
"payment-collected-completed",
"alternate-transport-changed"
];
export { notificationScenarios };

View File

@@ -0,0 +1,63 @@
import * as Sentry from "@sentry/react";
import { excludeGraphQLFetch } from "apollo-link-sentry";
import { useEffect } from "react";
import { createRoutesFromChildren, matchRoutes, useLocation, useNavigationType } from "react-router-dom";
import InstanceRenderManager from "./instanceRenderMgr";
const currentDatePST = new Date()
.toLocaleDateString("en-US", {
timeZone: "America/Los_Angeles",
year: "numeric",
month: "2-digit",
day: "2-digit"
})
.split("/")
.reverse()
.join("-");
const sentryRelease =
`${import.meta.env.VITE_APP_IS_TEST ? "test" : "production"}-${currentDatePST}-${process.env.VITE_GIT_COMMIT_HASH}`.trim();
if (!import.meta.env.DEV) {
Sentry.init({
dsn: InstanceRenderManager({
imex: "https://fd7e89369b6b4bdc9c6c4c9f22fa4ee4@o492140.ingest.sentry.io/5651027",
rome: "https://a6acc91c073e414196014b8484627a61@o492140.ingest.sentry.io/4504561071161344"
}),
release: sentryRelease,
ignoreErrors: [
"ResizeObserver loop",
"ResizeObserver loop limit exceeded",
"Module specifier, 'fs' does not start",
"Module specifier, 'zlib' does not start with",
"Messaging: This browser doesn't support the API's required to use the Firebase SDK.",
"Failed to update a ServiceWorker for scope"
],
integrations: [
// See docs for support of different versions of variation of react router
// https://docs.sentry.io/platforms/javascript/guides/react/configuration/integrations/react-router/
Sentry.reactRouterV6BrowserTracingIntegration({
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes
}),
Sentry.replayIntegration(),
Sentry.browserProfilingIntegration()
],
tracePropagationTargets: [
"api.imex.online",
"api.test.imex.online",
"db.imex.online",
"api.romeonline.io",
"api.test.romeonline.io",
"db.romeonline.io"
],
tracesSampleRate: 1.0,
replaysOnErrorSampleRate: 1.0,
environment: import.meta.env.MODE,
beforeBreadcrumb: excludeGraphQLFetch
});
}

View File

@@ -1,16 +1,31 @@
import { sentryVitePlugin } from "@sentry/vite-plugin";
import react from "@vitejs/plugin-react";
import chalk from "chalk";
import * as child from "child_process";
import { promises as fsPromises } from "fs";
import { createLogger, defineConfig } from "vite";
import { ViteEjsPlugin } from "vite-plugin-ejs";
import eslint from "vite-plugin-eslint";
import { VitePWA } from "vite-plugin-pwa";
import InstanceRenderManager from "./src/utils/instanceRenderMgr";
import chalk from "chalk";
// Ensure your environment variables are set correctly for Vite 6
process.env.VITE_APP_GIT_SHA_DATE = new Date().toLocaleString("en-US", {
timeZone: "America/Los_Angeles"
});
const commitHash = child.execSync("git rev-parse HEAD").toString().trimEnd();
process.env.VITE_GIT_COMMIT_HASH = commitHash;
const currentDatePST = new Date()
.toLocaleDateString("en-US", {
timeZone: "America/Los_Angeles",
year: "numeric",
month: "2-digit",
day: "2-digit"
})
.split("/")
.reverse()
.join("-");
const getFormattedTimestamp = () =>
new Date().toLocaleTimeString("en-US", { hour12: true }).replace("AM", "a.m.").replace("PM", "p.m.");
@@ -78,10 +93,25 @@ export default defineConfig({
}
}),
react(),
eslint()
eslint(),
sentryVitePlugin({
org: "imex",
reactComponentAnnotation: {
enabled: true
},
release: {
name: `${process.env.VITE_APP_IS_TEST ? "test" : "production"}-${currentDatePST}-${commitHash}`.trim()
},
project: InstanceRenderManager({
instance: process.env.VITE_APP_INSTANCE,
imex: "imexonline",
rome: "rome-online"
})
})
],
define: {
APP_VERSION: JSON.stringify(process.env.npm_package_version)
APP_VERSION: JSON.stringify(process.env.npm_package_version),
__COMMIT_HASH__: JSON.stringify(commitHash)
},
server: {
host: true,
@@ -184,7 +214,9 @@ export default defineConfig({
"libphonenumber-js": ["libphonenumber-js"]
}
}
}
},
sourcemap: true
},
optimizeDeps: {
include: [

View File

@@ -31,14 +31,6 @@
headers:
- name: x-imex-auth
value_from_env: DATAPUMP_AUTH
- name: Rome Usage Report
webhook: '{{HASURA_API_URL}}/data/usagereport'
schedule: 0 12 * * 5
include_in_metadata: true
payload: {}
headers:
- name: x-imex-auth
value_from_env: DATAPUMP_AUTH
- name: Task Reminders
webhook: '{{HASURA_API_URL}}/tasks-remind-handler'
schedule: '*/15 * * * *'
@@ -47,3 +39,11 @@
headers:
- name: event-secret
value_from_env: EVENT_SECRET
- name: Rome Usage Report
webhook: '{{HASURA_API_URL}}/data/usagereport'
schedule: 0 12 * * 5
include_in_metadata: true
payload: {}
headers:
- name: x-imex-auth
value_from_env: DATAPUMP_AUTH

View File

@@ -697,6 +697,12 @@
- name: event-secret
value_from_env: EVENT_SECRET
request_transform:
body:
action: transform
template: |-
{
"success": true
}
method: POST
query_params: {}
template_engine: Kriti
@@ -1952,27 +1958,6 @@
_eq: X-Hasura-User-Id
- active:
_eq: true
event_triggers:
- name: notifications_docuemtns
definition:
enable_manual: false
update:
columns:
- jobid
retry_conf:
interval_sec: 10
num_retries: 0
timeout_sec: 60
webhook_from_env: HASURA_API_URL
headers:
- name: event-secret
value_from_env: EVENT_SECRET
request_transform:
method: POST
query_params: {}
template_engine: Kriti
url: '{{$base_url}}/notifications/events/handleDocumentsChange'
version: 2
- table:
name: email_audit_trail
schema: public
@@ -2861,12 +2846,13 @@
- role: user
permission:
check:
job:
bodyshop:
associations:
user:
authid:
_eq: X-Hasura-User-Id
user:
_and:
- associations:
active:
_eq: true
- authid:
_eq: X-Hasura-User-Id
columns:
- user_email
- created_at
@@ -2882,12 +2868,13 @@
- id
- jobid
filter:
job:
bodyshop:
associations:
user:
authid:
_eq: X-Hasura-User-Id
user:
_and:
- associations:
active:
_eq: true
- authid:
_eq: X-Hasura-User-Id
comment: ""
update_permissions:
- role: user
@@ -2898,24 +2885,26 @@
- id
- jobid
filter:
job:
bodyshop:
associations:
user:
authid:
_eq: X-Hasura-User-Id
user:
_and:
- associations:
active:
_eq: true
- authid:
_eq: X-Hasura-User-Id
check: null
comment: ""
delete_permissions:
- role: user
permission:
filter:
job:
bodyshop:
associations:
user:
authid:
_eq: X-Hasura-User-Id
user:
_and:
- associations:
active:
_eq: true
- authid:
_eq: X-Hasura-User-Id
comment: ""
- table:
name: joblines
@@ -3234,29 +3223,6 @@
_eq: X-Hasura-User-Id
- active:
_eq: true
event_triggers:
- name: notifications_joblines
definition:
enable_manual: false
insert:
columns: '*'
update:
columns:
- critical
retry_conf:
interval_sec: 10
num_retries: 0
timeout_sec: 60
webhook_from_env: HASURA_API_URL
headers:
- name: event-secret
value_from_env: EVENT_SECRET
request_transform:
method: POST
query_params: {}
template_engine: Kriti
url: '{{$base_url}}/notifications/events/handleJobLinesChange'
version: 1
- table:
name: joblines_status
schema: public
@@ -3403,13 +3369,6 @@
table:
name: job_conversations
schema: public
- name: job_watchers
using:
foreign_key_constraint_on:
column: jobid
table:
name: job_watchers
schema: public
- name: joblines
using:
foreign_key_constraint_on:
@@ -4514,7 +4473,10 @@
request_transform:
body:
action: transform
template: "{\r\n \"event\": {\r\n \"session_variables\": {\r\n \"x-hasura-user-id\": {{$body.event.session_variables.x-hasura-user-id}}\r\n }, \r\n \"op\": \"UPDATE\",\r\n \"data\": {\r\n \"old\": {\r\n \"id\": {{$body.event.data.old.id}},\r\n \"ro_number\": {{$body.event.data.old.ro_number}},\r\n \"queued_for_parts\": {{$body.event.data.old.queued_for_parts}},\r\n \"employee_prep\": {{$body.event.data.old.employee_prep}},\r\n \"clm_total\": {{$body.event.data.old.clm_total}},\r\n \"towin\": {{$body.event.data.old.towin}},\r\n \"employee_body\": {{$body.event.data.old.employee_body}},\r\n \"converted\": {{$body.event.data.old.converted}},\r\n \"scheduled_in\": {{$body.event.data.old.scheduled_in}},\r\n \"scheduled_completion\": {{$body.event.data.old.scheduled_completion}},\r\n \"scheduled_delivery\": {{$body.event.data.old.scheduled_delivery}},\r\n \"actual_delivery\": {{$body.event.data.old.actual_delivery}},\r\n \"actual_completion\": {{$body.event.data.old.actual_completion}},\r\n \"alt_transport\": {{$body.event.data.old.alt_transport}},\r\n \"date_exported\": {{$body.event.data.old.date_exported}},\r\n \"status\": {{$body.event.data.old.status}},\r\n \"employee_csr\": {{$body.event.data.old.employee_csr}},\r\n \"actual_in\": {{$body.event.data.old.actual_in}},\r\n \"deliverchecklist\": {{$body.event.data.old.deliverchecklist}},\r\n \"comment\": {{$body.event.data.old.comment}},\r\n \"employee_refinish\": {{$body.event.data.old.employee_refinish}},\r\n \"inproduction\": {{$body.event.data.old.inproduction}},\r\n \"production_vars\": {{$body.event.data.old.production_vars}},\r\n \"intakechecklist\": {{$body.event.data.old.intakechecklist}},\r\n \"cieca_ttl\": {{$body.event.data.old.cieca_ttl}},\r\n \"date_invoiced\": {{$body.event.data.old.date_invoiced}}\r\n },\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"ro_number\": {{$body.event.data.old.ro_number}},\r\n \"queued_for_parts\": {{$body.event.data.new.queued_for_parts}},\r\n \"employee_prep\": {{$body.event.data.new.employee_prep}},\r\n \"clm_total\": {{$body.event.data.new.clm_total}},\r\n \"towin\": {{$body.event.data.new.towin}},\r\n \"employee_body\": {{$body.event.data.new.employee_body}},\r\n \"converted\": {{$body.event.data.new.converted}},\r\n \"scheduled_in\": {{$body.event.data.new.scheduled_in}},\r\n \"scheduled_completion\": {{$body.event.data.new.scheduled_completion}},\r\n \"scheduled_delivery\": {{$body.event.data.new.scheduled_delivery}},\r\n \"actual_delivery\": {{$body.event.data.new.actual_delivery}},\r\n \"actual_completion\": {{$body.event.data.new.actual_completion}},\r\n \"alt_transport\": {{$body.event.data.new.alt_transport}},\r\n \"date_exported\": {{$body.event.data.new.date_exported}},\r\n \"status\": {{$body.event.data.new.status}},\r\n \"employee_csr\": {{$body.event.data.new.employee_csr}},\r\n \"actual_in\": {{$body.event.data.new.actual_in}},\r\n \"deliverchecklist\": {{$body.event.data.new.deliverchecklist}},\r\n \"comment\": {{$body.event.data.new.comment}},\r\n \"employee_refinish\": {{$body.event.data.new.employee_refinish}},\r\n \"inproduction\": {{$body.event.data.new.inproduction}},\r\n \"production_vars\": {{$body.event.data.new.production_vars}},\r\n \"intakechecklist\": {{$body.event.data.new.intakechecklist}},\r\n \"cieca_ttl\": {{$body.event.data.new.cieca_ttl}},\r\n \"date_invoiced\": {{$body.event.data.new.date_invoiced}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_jobs\"\r\n },\r\n \"table\": {\r\n \"schema\": \"public\",\r\n \"name\": \"jobs\"\r\n }\r\n}\r\n"
template: |-
{
"success": true
}
method: POST
query_params: {}
template_engine: Kriti
@@ -4863,26 +4825,6 @@
_eq: X-Hasura-User-Id
- active:
_eq: true
event_triggers:
- name: notifications_notes
definition:
enable_manual: false
insert:
columns: '*'
retry_conf:
interval_sec: 10
num_retries: 0
timeout_sec: 60
webhook_from_env: HASURA_API_URL
headers:
- name: event-secret
value_from_env: EVENT_SECRET
request_transform:
method: POST
query_params: {}
template_engine: Kriti
url: '{{$base_url}}/notifications/events/handleNotesChange'
version: 2
- table:
name: notifications
schema: public
@@ -4893,79 +4835,46 @@
- name: job
using:
foreign_key_constraint_on: jobid
insert_permissions:
- role: user
permission:
check:
job:
bodyshop:
associations:
_and:
- user:
authid:
_eq: X-Hasura-User-Id
- active:
_eq: true
columns:
- scenario_meta
- scenario_text
- fcm_text
- created_at
- read
- updated_at
- associationid
- id
- jobid
comment: ""
select_permissions:
- role: user
permission:
columns:
- scenario_meta
- scenario_text
- fcm_text
- created_at
- read
- updated_at
- associationid
- created_at
- fcm_data
- fcm_message
- fcm_title
- id
- jobid
- meta
- read
- ui_translation_meta
- ui_translation_string
- updated_at
filter:
job:
bodyshop:
associations:
_and:
- user:
authid:
_eq: X-Hasura-User-Id
- active:
_eq: true
allow_aggregations: true
association:
_and:
- active:
_eq: true
- user:
authid:
_eq: X-Hasura-User-Id
comment: ""
update_permissions:
- role: user
permission:
columns:
- scenario_meta
- scenario_text
- fcm_text
- created_at
- meta
- read
- updated_at
- associationid
- id
- jobid
filter: {}
check:
job:
bodyshop:
associations:
_and:
- user:
authid:
_eq: X-Hasura-User-Id
- active:
_eq: true
filter:
association:
_and:
- active:
_eq: true
- user:
authid:
_eq: X-Hasura-User-Id
check: null
comment: ""
- table:
name: owners
@@ -5739,25 +5648,6 @@
- active:
_eq: true
event_triggers:
- name: notifications_payments
definition:
enable_manual: false
insert:
columns: '*'
retry_conf:
interval_sec: 10
num_retries: 0
timeout_sec: 60
webhook_from_env: HASURA_API_URL
headers:
- name: event-secret
value_from_env: EVENT_SECRET
request_transform:
method: POST
query_params: {}
template_engine: Kriti
url: '{{$base_url}}/notifications/events/handlePaymentsChange'
version: 2
- name: os_payments
definition:
delete:
@@ -6229,13 +6119,9 @@
columns: '*'
update:
columns:
- joblineid
- assigned_to
- partsorderid
- completed
- description
- billid
- priority
retry_conf:
interval_sec: 10
num_retries: 0
@@ -6245,6 +6131,12 @@
- name: event-secret
value_from_env: EVENT_SECRET
request_transform:
body:
action: transform
template: |-
{
"success": true
}
method: POST
query_params: {}
template_engine: Kriti
@@ -6421,6 +6313,12 @@
- name: event-secret
value_from_env: EVENT_SECRET
request_transform:
body:
action: transform
template: |-
{
"success": true
}
method: POST
query_params: {}
template_engine: Kriti

View File

@@ -1,4 +0,0 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."notifications" add column "html_body" text
-- not null;

View File

@@ -1,2 +0,0 @@
alter table "public"."notifications" add column "html_body" text
not null;

View File

@@ -1 +0,0 @@
alter table "public"."notifications" alter column "fcm_title" set not null;

View File

@@ -1 +0,0 @@
alter table "public"."notifications" alter column "fcm_title" drop not null;

View File

@@ -1 +0,0 @@
alter table "public"."notifications" alter column "fcm_message" set not null;

View File

@@ -1 +0,0 @@
alter table "public"."notifications" alter column "fcm_message" drop not null;

View File

@@ -1,3 +0,0 @@
comment on column "public"."notifications"."html_body" is E'Real Time Notifications System';
alter table "public"."notifications" alter column "html_body" drop not null;
alter table "public"."notifications" add column "html_body" text;

View File

@@ -1 +0,0 @@
alter table "public"."notifications" drop column "html_body" cascade;

View File

@@ -1,4 +0,0 @@
comment on column "public"."notifications"."fcm_data" is E'Real Time Notifications System';
alter table "public"."notifications" alter column "fcm_data" set default jsonb_build_object();
alter table "public"."notifications" alter column "fcm_data" drop not null;
alter table "public"."notifications" add column "fcm_data" jsonb;

View File

@@ -1 +0,0 @@
alter table "public"."notifications" drop column "fcm_data" cascade;

View File

@@ -1,3 +0,0 @@
comment on column "public"."notifications"."fcm_message" is E'Real Time Notifications System';
alter table "public"."notifications" alter column "fcm_message" drop not null;
alter table "public"."notifications" add column "fcm_message" text;

View File

@@ -1 +0,0 @@
alter table "public"."notifications" drop column "fcm_message" cascade;

View File

@@ -1,3 +0,0 @@
comment on column "public"."notifications"."ui_translation_string" is E'Real Time Notifications System';
alter table "public"."notifications" alter column "ui_translation_string" drop not null;
alter table "public"."notifications" add column "ui_translation_string" text;

View File

@@ -1 +0,0 @@
alter table "public"."notifications" drop column "ui_translation_string" cascade;

View File

@@ -1 +0,0 @@
alter table "public"."notifications" rename column "fcm_text" to "fcm_title";

View File

@@ -1 +0,0 @@
alter table "public"."notifications" rename column "fcm_title" to "fcm_text";

View File

@@ -1 +0,0 @@
alter table "public"."notifications" rename column "scenario_text" to "ui_translation_meta";

View File

@@ -1 +0,0 @@
alter table "public"."notifications" rename column "ui_translation_meta" to "scenario_text";

View File

@@ -1 +0,0 @@
alter table "public"."notifications" rename column "scenario_meta" to "meta";

View File

@@ -1 +0,0 @@
alter table "public"."notifications" rename column "meta" to "scenario_meta";

1287
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,39 +19,37 @@
"makeitpretty": "prettier --write \"**/*.{css,js,json,jsx,scss}\""
},
"dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.750.0",
"@aws-sdk/client-elasticache": "^3.755.0",
"@aws-sdk/client-s3": "^3.750.0",
"@aws-sdk/client-secrets-manager": "^3.750.0",
"@aws-sdk/client-ses": "^3.750.0",
"@aws-sdk/credential-provider-node": "^3.750.0",
"@aws-sdk/client-cloudwatch-logs": "^3.738.0",
"@aws-sdk/client-elasticache": "^3.738.0",
"@aws-sdk/client-s3": "^3.738.0",
"@aws-sdk/client-secrets-manager": "^3.738.0",
"@aws-sdk/client-ses": "^3.738.0",
"@aws-sdk/credential-provider-node": "^3.738.0",
"@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0",
"aws4": "^1.13.2",
"axios": "^1.8.1",
"bee-queue": "^1.7.1",
"axios": "^1.7.7",
"better-queue": "^3.8.12",
"bluebird": "^3.7.2",
"body-parser": "^1.20.3",
"bullmq": "^5.41.7",
"chart.js": "^4.4.8",
"chart.js": "^4.4.6",
"cloudinary": "^2.5.1",
"compression": "^1.8.0",
"compression": "^1.7.5",
"cookie-parser": "^1.4.7",
"cors": "2.8.5",
"crisp-status-reporter": "^1.2.2",
"csrf": "^3.1.0",
"dd-trace": "^5.39.0",
"dd-trace": "^5.33.1",
"dinero.js": "^1.9.1",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"firebase-admin": "^13.1.0",
"firebase-admin": "^13.0.2",
"graphql": "^16.10.0",
"graphql-request": "^6.1.0",
"inline-css": "^4.0.3",
"intuit-oauth": "^4.2.0",
"ioredis": "^5.5.0",
"intuit-oauth": "^4.1.3",
"ioredis": "^5.4.2",
"json-2-csv": "^5.5.8",
"juice": "^11.0.0",
"lodash": "^4.17.21",
@@ -66,7 +64,7 @@
"redis": "^4.7.0",
"rimraf": "^6.0.1",
"skia-canvas": "^2.0.2",
"soap": "^1.1.8",
"soap": "^1.1.7",
"socket.io": "^4.8.1",
"socket.io-adapter": "^2.5.5",
"ssh2-sftp-client": "^11.0.0",
@@ -78,14 +76,14 @@
"xmlbuilder2": "^3.1.1"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@eslint/js": "^9.19.0",
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"concurrently": "^8.2.2",
"eslint": "^9.21.0",
"eslint": "^9.19.0",
"eslint-plugin-react": "^7.37.4",
"globals": "^15.15.0",
"globals": "^15.14.0",
"p-limit": "^3.1.0",
"prettier": "^3.5.2",
"prettier": "^3.3.3",
"source-map-explorer": "^2.5.2"
}
}

View File

@@ -4,4 +4,3 @@ cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
appendonly yes
maxmemory-policy noeviction

118
server.js
View File

@@ -5,7 +5,7 @@ require("dotenv").config({
});
if (process.env.NODE_ENV) {
require("dd-trace").init({
const tracer = require("dd-trace").init({
profiling: true,
env: process.env.NODE_ENV,
service: "bodyshop-api"
@@ -28,11 +28,13 @@ const logger = require("./server/utils/logger");
const { applyRedisHelpers } = require("./server/utils/redisHelpers");
const { applyIOHelpers } = require("./server/utils/ioHelpers");
const { redisSocketEvents } = require("./server/web-sockets/redisSocketEvents");
const { ElastiCacheClient, DescribeCacheClustersCommand } = require("@aws-sdk/client-elasticache");
const {
ElastiCacheClient,
DescribeCacheClustersCommand,
DescribeReplicationGroupsCommand
} = require("@aws-sdk/client-elasticache");
const { InstanceRegion } = require("./server/utils/instanceMgr");
const StartStatusReporter = require("./server/utils/statusReporter");
const { loadEmailQueue } = require("./server/notifications/queues/emailQueue");
const { loadAppQueue } = require("./server/notifications/queues/appQueue");
const cleanupTasks = [];
let isShuttingDown = false;
@@ -126,26 +128,48 @@ const applyRoutes = ({ app }) => {
* @returns {Promise<string[]>}
*/
const getRedisNodesFromAWS = async () => {
const client = new ElastiCacheClient({
region: InstanceRegion()
});
const params = {
ReplicationGroupId: process.env.REDIS_CLUSTER_ID,
ShowCacheNodeInfo: true
};
const client = new ElastiCacheClient({ region: InstanceRegion() });
try {
// Fetch the cache clusters associated with the replication group
const command = new DescribeCacheClustersCommand(params);
const response = await client.send(command);
const cacheClusters = response.CacheClusters;
const describeReplicationGroupCommand = new DescribeReplicationGroupsCommand({
ReplicationGroupId: process.env.REDIS_CLUSTER_ID
});
const describeReplicationGroupResponse = await client.send(describeReplicationGroupCommand);
return cacheClusters.flatMap((cluster) =>
cluster.CacheNodes.map((node) => `${node.Endpoint.Address}:${node.Endpoint.Port}`)
);
//TODO: add checking to make sure there's only 1.
const cacheClusterIds = describeReplicationGroupResponse.ReplicationGroups[0].MemberClusters;
// Ensure cacheClusters exists and is an array
if (!cacheClusterIds || !Array.isArray(cacheClusterIds) || cacheClusterIds.length === 0) {
logger.log(`No cache clusters found for cluster id ${process.env.REDIS_CLUSTER_ID}`, "ERROR", "redis", "api");
return [];
}
const nodeEndpointAddresses = [];
for (const cluster of cacheClusterIds) {
const params = { CacheClusterId: cluster, ShowCacheNodeInfo: true };
const command = new DescribeCacheClustersCommand(params);
const response = await client.send(command);
if (response.CacheClusters && Array.isArray(response.CacheClusters)) {
// Map nodes to address strings
//TODO: What happens if we have more shards?
const nodeAddress = `${response.CacheClusters[0].CacheNodes[0].Endpoint.Address}:${response.CacheClusters[0].CacheNodes[0].Endpoint.Port}`;
// Debug log node addresses
logger.log(`Cluster node addresses: ${nodeAddress}`, "DEBUG", "redis", "api");
// Return only those addresses that start with the current cluster id
nodeEndpointAddresses.push(nodeAddress);
}
}
return nodeEndpointAddresses;
// Process each cluster
} catch (err) {
logger.log(`Error fetching Redis nodes from AWS: ${err.message}`, "ERROR", "redis", "api");
logger.log(`Error fetching Redis nodes from AWS:`, "ERROR", "redis", "api", {
message: err?.message,
stack: err?.stack
});
throw err;
}
};
@@ -195,15 +219,7 @@ const connectToRedisCluster = async () => {
return new Promise((resolve, reject) => {
redisCluster.on("ready", () => {
logger.log(`Redis cluster connection established.`, "INFO", "redis", "api");
if (process.env.NODE_ENV === "development" && process.env?.CLEAR_REDIS_ON_START === "true") {
logger.log("[Development] Flushing Redis Cluster on Service start...", "INFO", "redis", "api");
const master = redisCluster.nodes("master");
Promise.all(master.map((node) => node.flushall())).then(() => {
resolve(redisCluster);
});
} else {
resolve(redisCluster);
}
resolve(redisCluster);
});
redisCluster.on("error", (err) => {
@@ -232,11 +248,14 @@ const applySocketIO = async ({ server, app }) => {
pubClient.on("error", (err) => logger.log(`Redis pubClient error: ${err}`, "ERROR", "redis"));
subClient.on("error", (err) => logger.log(`Redis subClient error: ${err}`, "ERROR", "redis"));
// Register Redis cleanup
registerCleanupTask(async () => {
process.on("SIGINT", async () => {
logger.log("Closing Redis connections...", "INFO", "redis", "api");
await Promise.all([pubClient.disconnect(), subClient.disconnect()]);
logger.log("Redis connections closed.", "INFO", "redis", "api");
try {
await Promise.all([pubClient.disconnect(), subClient.disconnect()]);
logger.log("Redis connections closed. Process will exit.", "INFO", "redis", "api");
} catch (error) {
logger.log(`Error closing Redis connections: ${error.message}`, "ERROR", "redis", "api");
}
});
const ioRedis = new Server(server, {
@@ -294,34 +313,6 @@ const applySocketIO = async ({ server, app }) => {
return api;
};
/**
* Load Queues for Email and App
* @param {Object} options - Queue configuration options
* @param {Redis.Cluster} options.pubClient - Redis client for publishing
* @param {Object} options.logger - Logger instance
* @param {Object} options.redisHelpers - Redis helper functions
* @param {Server} options.ioRedis - Socket.IO server instance
* @returns {Promise<void>}
*/
const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
const queueSettings = { pubClient, logger, redisHelpers, ioRedis };
// Assuming loadEmailQueue and loadAppQueue return Promises
const [notificationsEmailsQueue, notificationsAppQueue] = await Promise.all([
loadEmailQueue(queueSettings),
loadAppQueue(queueSettings)
]);
// Add error listeners or other setup for queues if needed
notificationsEmailsQueue.on("error", (error) => {
logger.log(`Error in notificationsEmailsQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message });
});
notificationsAppQueue.on("error", (error) => {
logger.log(`Error in notificationsAppQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message });
});
};
/**
* Main function to start the server
* @returns {Promise<void>}
@@ -339,9 +330,6 @@ const main = async () => {
// Legacy Socket Events
require("./server/web-sockets/web-socket");
// Initialize Queues
await loadQueues({ pubClient: pubClient, logger, redisHelpers, ioRedis });
applyMiddleware({ app });
applyRoutes({ app });
redisSocketEvents({ io: ioRedis, redisHelpers, ioHelpers, logger });
@@ -359,7 +347,7 @@ const main = async () => {
await server.listen(port);
logger.log(`Server started on port ${port}`, "INFO", "api");
} catch (error) {
logger.log(`Server failed to start on port ${port}`, "ERROR", "api", null, { error: error.message });
logger.log(`Server failed to start on port ${port}`, "ERROR", "api", error);
}
};

View File

@@ -7,7 +7,7 @@ const OAuthClient = require("intuit-oauth");
const client = require("../../graphql-client/graphql-client").client;
const queries = require("../../graphql-client/queries");
const { parse, stringify } = require("querystring");
const { InstanceEndpoints } = require("../../utils/instanceMgr");
const InstanceManager = require("../../utils/instanceMgr").default;
const oauthClient = new OAuthClient({
clientId: process.env.QBO_CLIENT_ID,
@@ -17,8 +17,16 @@ const oauthClient = new OAuthClient({
logging: true
});
//TODO:AIO Add in QBO callbacks.
const url = InstanceEndpoints();
let url;
if (process.env.NODE_ENV === "production") {
//TODO:AIO Add in QBO callbacks.
url = InstanceManager({ imex: `https://imex.online`, rome: `https://romeonline.io` });
} else if (process.env.NODE_ENV === "test") {
url = InstanceManager({ imex: `https://test.imex.online`, rome: `https://test.romeonline.io` });
} else {
url = `http://localhost:3000`;
}
exports.default = async (req, res) => {
const queryString = req.url.split("?").reverse()[0];

View File

@@ -69,11 +69,11 @@ const sendServerEmail = async ({ subject, text }) => {
}
},
(err, info) => {
logger.log("server-email-failure", err ? "error" : "debug", null, null, { message: err?.message });
logger.log("server-email-failure", err ? "error" : "debug", null, null, { message: err || info });
}
);
} catch (error) {
logger.log("server-email-failure", "error", null, null, { message: error?.message });
logger.log("server-email-failure", "error", null, null, { error });
}
};
@@ -92,11 +92,11 @@ const sendTaskEmail = async ({ to, subject, type = "text", html, text, attachmen
},
(err, info) => {
// (message, type, user, record, meta
logger.log("server-email", err ? "error" : "debug", null, null, { message: err?.message });
logger.log("server-email", err ? "error" : "debug", null, null, { message: err ? err?.message : info });
}
);
} catch (error) {
logger.log("server-email-failure", "error", null, null, { message: error?.message });
logger.log("server-email-failure", "error", null, null, { error });
}
};
@@ -125,7 +125,7 @@ const sendEmail = async (req, res) => {
cc: req.body.cc,
subject: req.body.subject,
templateStrings: req.body.templateStrings,
errorMessage: error?.message
error
});
}
})
@@ -194,7 +194,7 @@ const sendEmail = async (req, res) => {
cc: req.body.cc,
subject: req.body.subject,
templateStrings: req.body.templateStrings,
errorMessage: err?.message
error: err
});
logEmail(req, {
to: req.body.to,
@@ -202,7 +202,7 @@ const sendEmail = async (req, res) => {
subject: req.body.subject,
bodyshopid: req.body.bodyshopid
});
res.status(500).json({ success: false, errorMessage: err?.message });
res.status(500).json({ success: false, error: err });
}
}
);
@@ -270,14 +270,14 @@ ${body.bounce?.bouncedRecipients.map(
},
(err, info) => {
logger.log("sns-error", err ? "error" : "debug", "api", null, {
errorMessage: err?.message
message: err ? err?.message : info
});
}
);
}
} catch (error) {
logger.log("sns-error", "ERROR", "api", null, {
errorMessage: error?.message
error: JSON.stringify(error)
});
}
res.sendStatus(200);

View File

@@ -10,7 +10,6 @@ const generateEmailTemplate = require("./generateTemplate");
const moment = require("moment-timezone");
const { taskEmailQueue } = require("./tasksEmailsQueue");
const mailer = require("./mailer");
const { InstanceEndpoints } = require("../utils/instanceMgr");
// Initialize the Tasks Email Queue
const tasksEmailQueue = taskEmailQueue();
@@ -84,8 +83,15 @@ const formatPriority = (priority) => {
* @param taskId
* @returns {{header, body: string, subHeader: string}}
*/
const getEndpoints = (bodyshop) =>
InstanceManager({
imex: process.env?.NODE_ENV === "test" ? "https://test.imex.online" : "https://imex.online",
rome: process.env?.NODE_ENV === "test" ? "https//test.romeonline.io" : "https://romeonline.io"
});
const generateTemplateArgs = (title, priority, description, dueDate, bodyshop, job, taskId, dateLine, createdBy) => {
const endPoints = InstanceEndpoints();
const endPoints = getEndpoints(bodyshop);
return {
header: title,
subHeader: `Body Shop: ${bodyshop.shopname} | Priority: ${formatPriority(priority)} ${formatDate(dueDate)} | Created By: ${createdBy || "N/A"}`,
@@ -102,8 +108,9 @@ const generateTemplateArgs = (title, priority, description, dueDate, bodyshop, j
* @param html
* @param taskIds
* @param successCallback
* @param requestInstance
*/
const sendMail = (type, to, subject, html, taskIds, successCallback) => {
const sendMail = (type, to, subject, html, taskIds, successCallback, requestInstance) => {
const fromEmails = InstanceManager({
imex: "ImEX Online <noreply@imex.online>",
rome: "Rome Online <noreply@romeonline.io>"
@@ -129,7 +136,7 @@ const sendMail = (type, to, subject, html, taskIds, successCallback) => {
};
/**
* Email the assigned user.
* Send an email to the assigned user.
* @param req
* @param res
* @returns {Promise<*>}
@@ -179,7 +186,7 @@ const taskAssignedEmail = async (req, res) => {
};
/**
* Email remind the user of their tasks.
* Send an email to remind the user of their tasks.
* @param req
* @param res
* @returns {Promise<*>}
@@ -257,6 +264,11 @@ const tasksRemindEmail = async (req, res) => {
}
// There are multiple emails to send to this author.
else {
const endPoints = InstanceManager({
imex: process.env?.NODE_ENV === "test" ? "https://test.imex.online" : "https://imex.online",
rome: process.env?.NODE_ENV === "test" ? "https//test.romeonline.io" : "https://romeonline.io"
});
const allTasks = groupedTasks[recipient.email];
emailData.subject = `New Tasks Reminder - ${allTasks.length} Tasks require your attention`;
emailData.html = generateEmailTemplate({
@@ -266,7 +278,7 @@ const tasksRemindEmail = async (req, res) => {
body: `<ul>
${allTasks
.map((task) =>
`<li><a href="${InstanceEndpoints()}/manage/tasks/alltasks?taskid=${task.id}">${task.title} - Priority: ${formatPriority(task.priority)} ${task.due_date ? `${formatDate(task.due_date)}` : ""} | Bodyshop: ${task.bodyshop.shopname}</a></li>`.trim()
`<li><a href="${endPoints}/manage/tasks/alltasks?taskid=${task.id}">${task.title} - Priority: ${formatPriority(task.priority)} ${task.due_date ? `${formatDate(task.due_date)}` : ""} | Bodyshop: ${task.bodyshop.shopname}</a></li>`.trim()
)
.join("")}
</ul>`
@@ -326,5 +338,6 @@ const tasksRemindEmail = async (req, res) => {
module.exports = {
taskAssignedEmail,
tasksRemindEmail
tasksRemindEmail,
getEndpoints
};

View File

@@ -2705,57 +2705,3 @@ exports.INSERT_AUDIT_TRAIL = `
}
}
`;
exports.GET_JOB_WATCHERS = `
query GET_JOB_WATCHERS($jobid: uuid!) {
job_watchers_aggregate(where: { jobid: { _eq: $jobid } }) {
nodes {
user_email
user {
authid
employee {
id
first_name
last_name
}
}
}
}
job: jobs_by_pk(id: $jobid) {
id
ro_number
clm_no
bodyshop {
id
shopname
}
}
}
`;
exports.GET_NOTIFICATION_ASSOCIATIONS = `
query GET_NOTIFICATION_ASSOCIATIONS($emails: [String!]!, $shopid: uuid!) {
associations(where: {
useremail: { _in: $emails },
shopid: { _eq: $shopid }
}) {
id
useremail
notification_settings
}
}
`;
exports.INSERT_NOTIFICATIONS_MUTATION = ` mutation INSERT_NOTIFICATIONS($objects: [notifications_insert_input!]!) {
insert_notifications(objects: $objects) {
affected_rows
returning {
id
jobid
associationid
scenario_text
fcm_text
scenario_meta
}
}
}`;

View File

@@ -10,11 +10,12 @@ const moment = require("moment");
const logger = require("../utils/logger");
const { sendTaskEmail } = require("../email/sendemail");
const generateEmailTemplate = require("../email/generateTemplate");
const { getEndpoints } = require("../email/tasksEmails");
const domain = process.env.NODE_ENV ? "secure" : "test";
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
const { InstanceRegion, InstanceEndpoints } = require("../utils/instanceMgr");
const { InstanceRegion } = require("../utils/instanceMgr");
const client = new SecretsManagerClient({
region: InstanceRegion()
@@ -442,28 +443,31 @@ exports.postback = async (req, res) => {
});
if (values.origin === "OneLink" && parsedComment.userEmail) {
sendTaskEmail({
to: parsedComment.userEmail,
subject: `New Payment(s) Received - RO ${jobs.jobs.map((j) => j.ro_number).join(", ")}`,
type: "html",
html: generateEmailTemplate({
header: "New Payment(s) Received",
subHeader: "",
body: jobs.jobs
.map(
(job) =>
`Reference: <a href="${InstanceEndpoints()}/manage/jobs/${job.id}">${job.ro_number || "N/A"}</a> | ${job.ownr_co_nm ? job.ownr_co_nm : `${job.ownr_fn || ""} ${job.ownr_ln || ""}`.trim()} | ${`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim()} | $${partialPayments.find((p) => p.jobid === job.id).amount}`
)
.join("<br/>")
})
}).catch((error) => {
try {
const endPoints = getEndpoints();
sendTaskEmail({
to: parsedComment.userEmail,
subject: `New Payment(s) Received - RO ${jobs.jobs.map((j) => j.ro_number).join(", ")}`,
type: "html",
html: generateEmailTemplate({
header: "New Payment(s) Received",
subHeader: "",
body: jobs.jobs
.map(
(job) =>
`Reference: <a href="${endPoints}/manage/jobs/${job.id}">${job.ro_number || "N/A"}</a> | ${job.ownr_co_nm ? job.ownr_co_nm : `${job.ownr_fn || ""} ${job.ownr_ln || ""}`.trim()} | ${`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim()} | $${partialPayments.find((p) => p.jobid === job.id).amount}`
)
.join("<br/>")
})
});
} catch (error) {
logger.log("intellipay-postback-email-error", "ERROR", req.user?.email, null, {
message: error.message,
jobs,
paymentResult,
...logResponseMeta
});
});
}
}
res.sendStatus(200);
} else if (values.invoice) {

View File

@@ -1,140 +0,0 @@
/**
* @fileoverview Notification event handlers.
* This module exports functions to handle various notification events.
* Each handler optionally calls the scenarioParser and logs errors if they occur,
* then returns a JSON response with a success message.
*/
const scenarioParser = require("./scenarioParser");
/**
* Processes a notification event by invoking the scenario parser.
* The scenarioParser is intentionally not awaited so that the response is sent immediately.
*
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
* @param {string} parserPath - The key path to be passed to scenarioParser.
* @param {string} successMessage - The message to return on success.
* @returns {Promise<Object>} A promise that resolves to an Express JSON response.
*/
async function processNotificationEvent(req, res, parserPath, successMessage) {
const { logger } = req;
// Call scenarioParser but don't await it; log any error that occurs.
scenarioParser(req, parserPath).catch((error) => {
logger.log("notifications-error", "error", "notifications", null, { error: error?.message });
});
return res.status(200).json({ message: successMessage });
}
/**
* Handle job change notifications.
*
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
* @returns {Promise<Object>} JSON response with a success message.
*/
const handleJobsChange = async (req, res) =>
processNotificationEvent(req, res, "req.body.event.new.id", "Job Notifications Event Handled.");
/**
* Handle bills change notifications.
*
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
* @returns {Promise<Object>} JSON response with a success message.
*/
const handleBillsChange = async (req, res) =>
processNotificationEvent(req, res, "req.body.event.new.jobid", "Bills Changed Notification Event Handled.");
/**
* Handle documents change notifications.
*
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
* @returns {Promise<Object>} JSON response with a success message.
*/
const handleDocumentsChange = async (req, res) =>
processNotificationEvent(req, res, "req.body.event.new.jobid", "Documents Change Notifications Event Handled.");
/**
* Handle job lines change notifications.
*
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
* @returns {Promise<Object>} JSON response with a success message.
*/
const handleJobLinesChange = async (req, res) =>
processNotificationEvent(req, res, "req.body.event.new.jobid", "JobLines Change Notifications Event Handled.");
/**
* Handle notes change notifications.
*
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
* @returns {Promise<Object>} JSON response with a success message.
*/
const handleNotesChange = async (req, res) =>
processNotificationEvent(req, res, "req.body.event.new.jobid", "Notes Changed Notification Event Handled.");
/**
* Handle parts dispatch change notifications.
*
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
* @returns {Object} JSON response with a success message.
*/
const handlePartsDispatchChange = (req, res) => res.status(200).json({ message: "Parts Dispatch change handled." });
/**
* Handle parts order change notifications.
*
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
* @returns {Object} JSON response with a success message.
*/
const handlePartsOrderChange = (req, res) => res.status(200).json({ message: "Parts Order change handled." });
/**
* Handle payments change notifications.
*
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
* @returns {Promise<Object>} JSON response with a success message.
*/
const handlePaymentsChange = async (req, res) =>
processNotificationEvent(req, res, "req.body.event.new.jobid", "Payments Changed Notification Event Handled.");
/**
* Handle tasks change notifications.
*
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
* @returns {Promise<Object>} JSON response with a success message.
*/
const handleTasksChange = async (req, res) =>
processNotificationEvent(req, res, "req.body.event.new.jobid", "Tasks Notifications Event Handled.");
/**
* Handle time tickets change notifications.
*
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
* @returns {Promise<Object>} JSON response with a success message.
*/
const handleTimeTicketsChange = async (req, res) =>
processNotificationEvent(req, res, "req.body.event.new.jobid", "Time Tickets Changed Notification Event Handled.");
module.exports = {
handleJobsChange,
handleBillsChange,
handleDocumentsChange,
handleJobLinesChange,
handleNotesChange,
handlePartsDispatchChange,
handlePartsOrderChange,
handlePaymentsChange,
handleTasksChange,
handleTimeTicketsChange
};

View File

@@ -0,0 +1,5 @@
const handleJobsChange = (req, res) => {
return res.status(200).json({ message: "Jobs change handled." });
};
module.exports = handleJobsChange;

View File

@@ -0,0 +1,5 @@
const handleBillsChange = (req, res) => {
return res.status(200).json({ message: "Bills change handled." });
};
module.exports = handleBillsChange;

View File

@@ -0,0 +1,5 @@
const handlePartsDispatchChange = (req, res) => {
return res.status(200).json({ message: "Parts Dispatch change handled." });
};
module.exports = handlePartsDispatchChange;

View File

@@ -0,0 +1,5 @@
const handlePartsOrderChange = (req, res) => {
return res.status(200).json({ message: "Parts Order change handled." });
};
module.exports = handlePartsOrderChange;

View File

@@ -0,0 +1,5 @@
const handleTasksChange = (req, res) => {
return res.status(200).json({ message: "Tasks change handled." });
};
module.exports = handleTasksChange;

View File

@@ -0,0 +1,5 @@
const handleTimeTicketsChange = (req, res) => {
return res.status(200).json({ message: "Time Tickets change handled." });
};
module.exports = handleTimeTicketsChange;

View File

@@ -1,88 +0,0 @@
/**
* Parses an event by comparing old and new data to determine which fields have changed.
*
* This function analyzes the differences between previous (`oldData`) and current (`newData`)
* data states to identify changed fields. It determines if the event is a new entry or an update
* and optionally extracts a `jobId` based on a specified field. The result includes details
* about changed fields, the event type, and associated metadata.
*
* @param {Object} options - Configuration options for parsing the event.
* @param {Object} [options.oldData] - The previous state of the data (undefined for new entries).
* @param {Object} options.newData - The current state of the data.
* @param {string} options.trigger - The type of event trigger (e.g., 'INSERT', 'UPDATE').
* @param {string} options.table - The name of the table associated with the event.
* @param {string} [options.jobIdField] - The field name used to extract the jobId (optional).
* @returns {Object} An object containing the parsed event details:
* - {Array<string>} changedFieldNames - List of field names that have changed.
* - {Object} changedFields - Map of changed fields with their old and new values.
* - {boolean} isNew - True if the event is a new entry (no oldData provided).
* - {Object} data - The current data state (`newData`).
* - {string} trigger - The event trigger type.
* - {string} table - The table name.
* - {string|null} jobId - The extracted jobId or null if not applicable.
*/
const eventParser = async ({ oldData, newData, trigger, table, jobIdField }) => {
const isNew = !oldData; // True if no old data exists, indicating a new entry
let changedFields = {};
let changedFieldNames = [];
if (isNew) {
// For new entries, all fields in newData are considered "changed" (from undefined to their value)
changedFields = Object.fromEntries(
Object.entries(newData).map(([key, value]) => [key, { old: undefined, new: value }])
);
changedFieldNames = Object.keys(newData); // All keys are new
} else {
// Compare oldData and newData to detect updates
for (const key in newData) {
if (Object.prototype.hasOwnProperty.call(newData, key)) {
// Check if the field is new or its value has changed
if (
!Object.prototype.hasOwnProperty.call(oldData, key) || // Field didnt exist before
JSON.stringify(oldData[key]) !== JSON.stringify(newData[key]) // Values differ (deep comparison)
) {
changedFields[key] = {
old: oldData[key], // Undefined if field wasnt in oldData
new: newData[key]
};
changedFieldNames.push(key);
}
}
}
// Identify fields removed in newData (present in oldData but absent in newData)
for (const key in oldData) {
if (Object.prototype.hasOwnProperty.call(oldData, key) && !Object.prototype.hasOwnProperty.call(newData, key)) {
changedFields[key] = {
old: oldData[key],
new: null // Mark as removed
};
changedFieldNames.push(key);
}
}
}
// Extract jobId if jobIdField is provided
let jobId = null;
if (jobIdField) {
let keyName = jobIdField;
const prefix = "req.body.event.new.";
// Strip prefix if present to isolate the actual field name
if (keyName.startsWith(prefix)) {
keyName = keyName.slice(prefix.length);
}
// Look for jobId in newData first, then fallback to oldData if necessary
jobId = newData[keyName] || (oldData && oldData[keyName]) || null;
}
return {
changedFieldNames, // Array of fields that changed
changedFields, // Object with old/new values for changed fields
isNew, // Boolean indicating if this is a new entry
data: newData, // Current data state
trigger, // Event trigger (e.g., 'INSERT', 'UPDATE')
table, // Associated table name
jobId // Extracted jobId or null
};
};
module.exports = eventParser;

View File

@@ -1,280 +0,0 @@
const { Queue, Worker } = require("bullmq");
const { INSERT_NOTIFICATIONS_MUTATION } = require("../../graphql-client/queries");
const graphQLClient = require("../../graphql-client/graphql-client").client;
// Base time-related constant in minutes, sourced from environment variable or defaulting to 1
const APP_CONSOLIDATION_DELAY_IN_MINS = (() => {
const envValue = process.env?.APP_CONSOLIDATION_DELAY_IN_MINS;
const parsedValue = envValue ? parseInt(envValue, 10) : NaN;
return isNaN(parsedValue) ? 1 : Math.max(1, parsedValue); // Default to 1, ensure at least 1
})();
// Base time-related constant (in milliseconds) / DO NOT TOUCH
const APP_CONSOLIDATION_DELAY = APP_CONSOLIDATION_DELAY_IN_MINS * 60000; // 1 minute (base timeout)
// Derived time-related constants based on APP_CONSOLIDATION_DELAY / DO NOT TOUCH
const NOTIFICATION_STORAGE_EXPIRATION = APP_CONSOLIDATION_DELAY * 1.5; // 1.5 minutes (90s)
const CONSOLIDATION_FLAG_EXPIRATION = APP_CONSOLIDATION_DELAY * 1.5; // 1.5 minutes (90s)
const LOCK_EXPIRATION = APP_CONSOLIDATION_DELAY * 0.25; // 15 seconds (quarter of base)
const RATE_LIMITER_DURATION = APP_CONSOLIDATION_DELAY * 0.1; // 6 seconds (tenth of base)
let addQueue;
let consolidateQueue;
/**
* Builds the scenario_text, fcm_text, and scenario_meta for a batch of notifications.
*
* @param {Array<Object>} notifications - Array of notification objects with 'body' and 'variables'.
* @returns {Object} An object with 'scenario_text', 'fcm_text', and 'scenario_meta'.
*/
const buildNotificationContent = (notifications) => {
const scenarioText = notifications.map((n) => n.body); // Array of text entries
const fcmText = scenarioText.join(". "); // Concatenated text with period separator
const scenarioMeta = notifications.map((n) => n.variables || {}); // Array of metadata objects
return {
scenario_text: scenarioText,
fcm_text: fcmText ? `${fcmText}.` : null, // Add trailing period if non-empty, otherwise null
scenario_meta: scenarioMeta
};
};
/**
* Initializes the notification queues and workers for adding and consolidating notifications.
*/
const loadAppQueue = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
if (!addQueue || !consolidateQueue) {
logger.logger.info("Initializing Notifications Queues");
addQueue = new Queue("notificationsAdd", {
connection: pubClient,
prefix: "{BULLMQ}",
defaultJobOptions: { removeOnComplete: true, removeOnFail: true }
});
consolidateQueue = new Queue("notificationsConsolidate", {
connection: pubClient,
prefix: "{BULLMQ}",
defaultJobOptions: { removeOnComplete: true, removeOnFail: true }
});
const addWorker = new Worker(
"notificationsAdd",
async (job) => {
const { jobId, key, variables, recipients, body, jobRoNumber } = job.data;
logger.logger.info(`Adding notifications for jobId ${jobId}`);
const redisKeyPrefix = `app:notifications:${jobId}`;
const notification = { key, variables, body, jobRoNumber, timestamp: Date.now() };
for (const recipient of recipients) {
const { user } = recipient;
const userKey = `${redisKeyPrefix}:${user}`;
const existingNotifications = await pubClient.get(userKey);
const notifications = existingNotifications ? JSON.parse(existingNotifications) : [];
notifications.push(notification);
await pubClient.set(userKey, JSON.stringify(notifications), "EX", NOTIFICATION_STORAGE_EXPIRATION / 1000);
logger.logger.debug(`Stored notification for ${user} under ${userKey}: ${JSON.stringify(notifications)}`);
}
const consolidateKey = `app:consolidate:${jobId}`;
const flagSet = await pubClient.setnx(consolidateKey, "pending");
logger.logger.debug(`Consolidation flag set for jobId ${jobId}: ${flagSet}`);
if (flagSet) {
await consolidateQueue.add(
"consolidate-notifications",
{ jobId, recipients },
{
jobId: `consolidate:${jobId}`,
delay: APP_CONSOLIDATION_DELAY,
attempts: 3,
backoff: LOCK_EXPIRATION
}
);
logger.logger.info(`Scheduled consolidation for jobId ${jobId}`);
await pubClient.expire(consolidateKey, CONSOLIDATION_FLAG_EXPIRATION / 1000);
} else {
logger.logger.debug(`Consolidation already scheduled for jobId ${jobId}`);
}
},
{
connection: pubClient,
prefix: "{BULLMQ}",
concurrency: 5
}
);
const consolidateWorker = new Worker(
"notificationsConsolidate",
async (job) => {
const { jobId, recipients } = job.data;
logger.logger.info(`Consolidating notifications for jobId ${jobId}`);
const redisKeyPrefix = `app:notifications:${jobId}`;
const lockKey = `lock:consolidate:${jobId}`;
const lockAcquired = await pubClient.set(lockKey, "locked", "NX", "EX", LOCK_EXPIRATION / 1000);
logger.logger.debug(`Lock acquisition for jobId ${jobId}: ${lockAcquired}`);
if (lockAcquired) {
try {
const allNotifications = {};
const uniqueUsers = [...new Set(recipients.map((r) => r.user))];
logger.logger.debug(`Unique users for jobId ${jobId}: ${uniqueUsers}`);
for (const user of uniqueUsers) {
const userKey = `${redisKeyPrefix}:${user}`;
const notifications = await pubClient.get(userKey);
logger.logger.debug(`Retrieved notifications for ${user}: ${notifications}`);
if (notifications) {
const parsedNotifications = JSON.parse(notifications);
const userRecipients = recipients.filter((r) => r.user === user);
for (const { bodyShopId } of userRecipients) {
allNotifications[user] = allNotifications[user] || {};
allNotifications[user][bodyShopId] = parsedNotifications;
}
await pubClient.del(userKey);
logger.logger.debug(`Deleted Redis key ${userKey}`);
} else {
logger.logger.warn(`No notifications found for ${user} under ${userKey}`);
}
}
logger.logger.debug(`Consolidated notifications: ${JSON.stringify(allNotifications)}`);
// Insert notifications into the database and collect IDs
const notificationInserts = [];
const notificationIdMap = new Map();
for (const [user, bodyShopData] of Object.entries(allNotifications)) {
const userRecipients = recipients.filter((r) => r.user === user);
const associationId = userRecipients[0]?.associationId;
for (const [bodyShopId, notifications] of Object.entries(bodyShopData)) {
const { scenario_text, fcm_text, scenario_meta } = buildNotificationContent(notifications);
notificationInserts.push({
jobid: jobId,
associationid: associationId,
scenario_text: JSON.stringify(scenario_text),
fcm_text: fcm_text,
scenario_meta: JSON.stringify(scenario_meta)
});
notificationIdMap.set(`${user}:${bodyShopId}`, null);
}
}
if (notificationInserts.length > 0) {
const insertResponse = await graphQLClient.request(INSERT_NOTIFICATIONS_MUTATION, {
objects: notificationInserts
});
logger.logger.info(
`Inserted ${insertResponse.insert_notifications.affected_rows} notifications for jobId ${jobId}`
);
insertResponse.insert_notifications.returning.forEach((row, index) => {
const user = uniqueUsers[Math.floor(index / Object.keys(allNotifications[uniqueUsers[0]]).length)];
const bodyShopId = Object.keys(allNotifications[user])[
index % Object.keys(allNotifications[user]).length
];
notificationIdMap.set(`${user}:${bodyShopId}`, row.id);
});
}
// Emit notifications to users via Socket.io with notification ID
for (const [user, bodyShopData] of Object.entries(allNotifications)) {
const userMapping = await redisHelpers.getUserSocketMapping(user);
const userRecipients = recipients.filter((r) => r.user === user);
const associationId = userRecipients[0]?.associationId;
for (const [bodyShopId, notifications] of Object.entries(bodyShopData)) {
const notificationId = notificationIdMap.get(`${user}:${bodyShopId}`);
const jobRoNumber = notifications[0]?.jobRoNumber;
if (userMapping && userMapping[bodyShopId]?.socketIds) {
userMapping[bodyShopId].socketIds.forEach((socketId) => {
ioRedis.to(socketId).emit("notification", {
jobId,
jobRoNumber,
bodyShopId,
notifications,
notificationId,
associationId
});
});
logger.logger.info(
`Sent ${notifications.length} consolidated notifications to ${user} for jobId ${jobId} with notificationId ${notificationId}`
);
} else {
logger.logger.warn(`No socket IDs found for ${user} in bodyShopId ${bodyShopId}`);
}
}
}
await pubClient.del(`app:consolidate:${jobId}`);
} catch (err) {
logger.logger.error(`Consolidation error for jobId ${jobId}: ${err.message}`, { error: err });
throw err;
} finally {
await pubClient.del(lockKey);
}
} else {
logger.logger.info(`Skipped consolidation for jobId ${jobId} - lock held by another worker`);
}
},
{
connection: pubClient,
prefix: "{BULLMQ}",
concurrency: 1,
limiter: { max: 1, duration: RATE_LIMITER_DURATION }
}
);
addWorker.on("completed", (job) => logger.logger.info(`Add job ${job.id} completed`));
consolidateWorker.on("completed", (job) => logger.logger.info(`Consolidate job ${job.id} completed`));
addWorker.on("failed", (job, err) =>
logger.logger.error(`Add job ${job.id} failed: ${err.message}`, { error: err })
);
consolidateWorker.on("failed", (job, err) =>
logger.logger.error(`Consolidate job ${job.id} failed: ${err.message}`, { error: err })
);
const shutdown = async () => {
logger.logger.info("Closing app queue workers...");
await Promise.all([addWorker.close(), consolidateWorker.close()]);
logger.logger.info("App queue workers closed");
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}
return addQueue;
};
/**
* Retrieves the initialized `addQueue` instance.
*/
const getQueue = () => {
if (!addQueue) throw new Error("Add queue not initialized. Ensure loadAppQueue is called during bootstrap.");
return addQueue;
};
/**
* Dispatches notifications to the `addQueue` for processing.
*/
const dispatchAppsToQueue = async ({ appsToDispatch, logger }) => {
const appQueue = getQueue();
for (const app of appsToDispatch) {
const { jobId, bodyShopId, key, variables, recipients, body, jobRoNumber } = app;
await appQueue.add(
"add-notification",
{ jobId, bodyShopId, key, variables, recipients, body, jobRoNumber },
{ jobId: `${jobId}:${Date.now()}` }
);
logger.logger.info(`Added notification to queue for jobId ${jobId} with ${recipients.length} recipients`);
}
};
module.exports = { loadAppQueue, getQueue, dispatchAppsToQueue };

View File

@@ -1,233 +0,0 @@
const { Queue, Worker } = require("bullmq");
const { sendTaskEmail } = require("../../email/sendemail");
const generateEmailTemplate = require("../../email/generateTemplate");
const { InstanceEndpoints } = require("../../utils/instanceMgr");
const EMAIL_CONSOLIDATION_DELAY_IN_MINS = (() => {
const envValue = process.env?.APP_CONSOLIDATION_DELAY_IN_MINS;
const parsedValue = envValue ? parseInt(envValue, 10) : NaN;
return isNaN(parsedValue) ? 1 : Math.max(1, parsedValue); // Default to 1, ensure at least 1
})();
// Base time-related constant (in milliseconds) / DO NOT TOUCH
const EMAIL_CONSOLIDATION_DELAY = EMAIL_CONSOLIDATION_DELAY_IN_MINS * 60000; // 1 minute (base timeout)
// Derived time-related constants based on EMAIL_CONSOLIDATION_DELAY / DO NOT TOUCH, these are pegged to EMAIL_CONSOLIDATION_DELAY
const CONSOLIDATION_KEY_EXPIRATION = EMAIL_CONSOLIDATION_DELAY * 1.5; // 1.5 minutes (90s, buffer for consolidation)
const LOCK_EXPIRATION = EMAIL_CONSOLIDATION_DELAY * 0.25; // 15 seconds (quarter of base, for lock duration)
const RATE_LIMITER_DURATION = EMAIL_CONSOLIDATION_DELAY * 0.1; // 6 seconds (tenth of base, for rate limiting)
const NOTIFICATION_EXPIRATION = EMAIL_CONSOLIDATION_DELAY * 1.5; // 1.5 minutes (matches consolidation key expiration)
let emailAddQueue;
let emailConsolidateQueue;
let emailAddWorker;
let emailConsolidateWorker;
/**
* Initializes the email notification queues and workers.
*
* @param {Object} options - Configuration options for queue initialization.
* @param {Object} options.pubClient - Redis client instance for queue communication.
* @param {Object} options.logger - Logger instance for logging events and debugging.
* @returns {Queue} The initialized `emailAddQueue` instance for dispatching notifications.
*/
const loadEmailQueue = async ({ pubClient, logger }) => {
if (!emailAddQueue || !emailConsolidateQueue) {
logger.logger.info("Initializing Email Notification Queues");
// Queue for adding email notifications
emailAddQueue = new Queue("emailAdd", {
connection: pubClient,
prefix: "{BULLMQ}",
defaultJobOptions: { removeOnComplete: true, removeOnFail: true }
});
// Queue for consolidating and sending emails
emailConsolidateQueue = new Queue("emailConsolidate", {
connection: pubClient,
prefix: "{BULLMQ}",
defaultJobOptions: { removeOnComplete: true, removeOnFail: true }
});
// Worker to process adding notifications
emailAddWorker = new Worker(
"emailAdd",
async (job) => {
const { jobId, jobRoNumber, bodyShopName, body, recipients } = job.data;
logger.logger.info(`Adding email notifications for jobId ${jobId}`);
const redisKeyPrefix = `email:notifications:${jobId}`;
for (const recipient of recipients) {
const { user, firstName, lastName } = recipient;
const userKey = `${redisKeyPrefix}:${user}`;
await pubClient.rpush(userKey, body);
await pubClient.expire(userKey, NOTIFICATION_EXPIRATION / 1000); // Set expiration
const detailsKey = `email:recipientDetails:${jobId}:${user}`;
await pubClient.hsetnx(detailsKey, "firstName", firstName || "");
await pubClient.hsetnx(detailsKey, "lastName", lastName || "");
await pubClient.expire(detailsKey, NOTIFICATION_EXPIRATION / 1000); // Set expiration
await pubClient.sadd(`email:recipients:${jobId}`, user);
logger.logger.debug(`Stored message for ${user} under ${userKey}: ${body}`);
}
const consolidateKey = `email:consolidate:${jobId}`;
const flagSet = await pubClient.setnx(consolidateKey, "pending");
if (flagSet) {
await emailConsolidateQueue.add(
"consolidate-emails",
{ jobId, jobRoNumber, bodyShopName },
{
jobId: `consolidate:${jobId}`,
delay: EMAIL_CONSOLIDATION_DELAY,
attempts: 3, // Retry up to 3 times
backoff: LOCK_EXPIRATION // Retry delay matches lock expiration (15s)
}
);
logger.logger.info(`Scheduled email consolidation for jobId ${jobId}`);
await pubClient.expire(consolidateKey, CONSOLIDATION_KEY_EXPIRATION / 1000); // Convert to seconds
} else {
logger.logger.debug(`Email consolidation already scheduled for jobId ${jobId}`);
}
},
{
connection: pubClient,
prefix: "{BULLMQ}",
concurrency: 5
}
);
// Worker to consolidate and send emails
emailConsolidateWorker = new Worker(
"emailConsolidate",
async (job) => {
const { jobId, jobRoNumber, bodyShopName } = job.data;
logger.logger.info(`Consolidating emails for jobId ${jobId}`);
const lockKey = `lock:emailConsolidate:${jobId}`;
const lockAcquired = await pubClient.set(lockKey, "locked", "NX", "EX", LOCK_EXPIRATION / 1000); // Convert to seconds
if (lockAcquired) {
try {
const recipientsSet = `email:recipients:${jobId}`;
const recipients = await pubClient.smembers(recipientsSet);
for (const recipient of recipients) {
const userKey = `email:notifications:${jobId}:${recipient}`;
const detailsKey = `email:recipientDetails:${jobId}:${recipient}`;
const messages = await pubClient.lrange(userKey, 0, -1);
if (messages.length > 0) {
const details = await pubClient.hgetall(detailsKey);
const firstName = details.firstName || "User";
const multipleUpdateString = messages.length > 1 ? "Updates" : "Update";
const subject = `${multipleUpdateString} for job ${jobRoNumber} at ${bodyShopName}`;
// Use the template instead of inline HTML
const emailBody = generateEmailTemplate({
header: `${multipleUpdateString} for Job ${jobRoNumber}`,
subHeader: `Dear ${firstName},`,
body: `
<p>There have been updates to job ${jobRoNumber} at ${bodyShopName}:</p><br/>
<ul>
${messages.map((msg) => `<li>${msg}</li>`).join("")}
</ul><br/><br/>
<p><a href="${InstanceEndpoints()}/manage/jobs/${jobId}">Please check the job for more details.</a></p>
`
});
await sendTaskEmail({
to: recipient,
subject,
type: "html",
html: emailBody
});
logger.logger.info(
`Sent consolidated email to ${recipient} for jobId ${jobId} with ${messages.length} updates`
);
await pubClient.del(userKey);
await pubClient.del(detailsKey);
}
}
await pubClient.del(recipientsSet);
await pubClient.del(`email:consolidate:${jobId}`);
} catch (err) {
logger.logger.error(`Email consolidation error for jobId ${jobId}: ${err.message}`, { error: err });
throw err; // Trigger retry if attempts remain
} finally {
await pubClient.del(lockKey);
}
} else {
logger.logger.info(`Skipped email consolidation for jobId ${jobId} - lock held by another worker`);
}
},
{
connection: pubClient,
prefix: "{BULLMQ}",
concurrency: 1,
limiter: { max: 1, duration: RATE_LIMITER_DURATION }
}
);
// Event handlers for workers
emailAddWorker.on("completed", (job) => logger.logger.info(`Email add job ${job.id} completed`));
emailConsolidateWorker.on("completed", (job) => logger.logger.info(`Email consolidate job ${job.id} completed`));
emailAddWorker.on("failed", (job, err) =>
logger.logger.error(`Email add job ${job.id} failed: ${err.message}`, { error: err })
);
emailConsolidateWorker.on("failed", (job, err) =>
logger.logger.error(`Email consolidate job ${job.id} failed: ${err.message}`, { error: err })
);
// Graceful shutdown
const shutdown = async () => {
logger.logger.info("Closing email queue workers...");
await Promise.all([emailAddWorker.close(), emailConsolidateWorker.close()]);
logger.logger.info("Email queue workers closed");
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}
return emailAddQueue;
};
/**
* Retrieves the initialized `emailAddQueue` instance.
*
* @returns {Queue} The `emailAddQueue` instance for adding notifications.
* @throws {Error} If `emailAddQueue` is not initialized.
*/
const getQueue = () => {
if (!emailAddQueue) {
throw new Error("Email add queue not initialized. Ensure loadEmailQueue is called during bootstrap.");
}
return emailAddQueue;
};
/**
* Dispatches email notifications to the `emailAddQueue` for processing.
*
* @param {Object} options - Options for dispatching notifications.
* @param {Array} options.emailsToDispatch - Array of email notification objects.
* @param {Object} options.logger - Logger instance for logging dispatch events.
* @returns {Promise<void>} Resolves when all notifications are added to the queue.
*/
const dispatchEmailsToQueue = async ({ emailsToDispatch, logger }) => {
const emailAddQueue = getQueue();
for (const email of emailsToDispatch) {
const { jobId, jobRoNumber, bodyShopName, body, recipients } = email;
if (!jobId || !jobRoNumber || !bodyShopName || !body || !recipients.length) {
logger.logger.warn(
`Skipping email dispatch for jobId ${jobId} due to missing data: ` +
`jobRoNumber=${jobRoNumber}, bodyShopName=${bodyShopName}, body=${body}, recipients=${recipients.length}`
);
continue;
}
await emailAddQueue.add(
"add-email-notification",
{ jobId, jobRoNumber, bodyShopName, body, recipients },
{ jobId: `${jobId}:${Date.now()}` }
);
logger.logger.info(`Added email notification to queue for jobId ${jobId} with ${recipients.length} recipients`);
}
};
module.exports = { loadEmailQueue, getQueue, dispatchEmailsToQueue };

View File

@@ -1,505 +0,0 @@
const { getJobAssignmentType } = require("./stringHelpers");
/**
* Populates the recipients for app, email, and FCM notifications based on scenario watchers.
*
* @param {Object} data - The data object containing scenarioWatchers and bodyShopId.
* @param {Object} result - The result object to populate with recipients for app, email, and FCM notifications.
*/
const populateWatchers = (data, result) => {
data.scenarioWatchers.forEach((recipients) => {
const { user, app, fcm, email, firstName, lastName, employeeId, associationId } = recipients;
if (app === true) result.app.recipients.push({ user, bodyShopId: data.bodyShopId, employeeId, associationId });
if (fcm === true) result.fcm.recipients.push(user);
if (email === true) result.email.recipients.push({ user, firstName, lastName });
});
};
/**
* Builds notification data for changes to alternate transport.
*/
const alternateTransportChangedBuilder = (data) => {
const body = `The alternate transport status has been updated from ${data?.changedFields?.altTransport?.old}.`;
const result = {
app: {
jobId: data.jobId,
bodyShopId: data.bodyShopId,
jobRoNumber: data.jobRoNumber,
key: "notifications.job.alternateTransportChanged",
body, // Same as email body
variables: {
alternateTransport: data.changedFields.alt_transport?.new,
oldAlternateTransport: data.changedFields.alt_transport?.old
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
populateWatchers(data, result);
return result;
};
/**
* Builds notification data for bill posted events.
*/
const billPostedHandler = (data) => {
const body = `A bill of $${data.data.clm_total} has been posted.`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.billPosted",
body,
variables: {
clmTotal: data.data.clm_total
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
populateWatchers(data, result);
return result;
};
/**
* Builds notification data for changes to critical parts status.
*/
const criticalPartsStatusChangedBuilder = (data) => {
const body = `The critical parts status has changed to ${data.data.queued_for_parts ? "queued" : "not queued"}.`;
const result = {
app: {
jobId: data.jobId,
bodyShopId: data.bodyShopId,
jobRoNumber: data.jobRoNumber,
key: "notifications.job.criticalPartsStatusChanged",
body,
variables: {
queuedForParts: data.data.queued_for_parts,
oldQueuedForParts: data.changedFields.queued_for_parts?.old
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
populateWatchers(data, result);
return result;
};
/**
* Builds notification data for completed intake or delivery checklists.
*/
const intakeDeliveryChecklistCompletedBuilder = (data) => {
const checklistType = data.changedFields.intakechecklist ? "intake" : "delivery";
const body = `The ${checklistType.charAt(0).toUpperCase() + checklistType.slice(1)} checklist has been completed.`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.checklistCompleted",
body,
variables: {
checklistType,
completed: true
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
populateWatchers(data, result);
return result;
};
/**
* Builds notification data for job assignment events.
*/
const jobAssignedToMeBuilder = (data) => {
const body = `You have been assigned to [${getJobAssignmentType(data.scenarioFields?.[0])}]`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.assigned",
body,
variables: {
type: data.scenarioFields?.[0]
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
populateWatchers(data, result);
return result;
};
/**
* Builds notification data for jobs added to production.
*/
const jobsAddedToProductionBuilder = (data) => {
const body = `Job has been added to production.`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.addedToProduction",
body,
variables: {},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
populateWatchers(data, result);
return result;
};
/**
* Builds notification data for job status changes.
*/
const jobStatusChangeBuilder = (data) => {
const body = `The status has changed from ${data.changedFields.status.old} to ${data.changedFields.status.new}`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.statusChanged",
body,
variables: {
status: data.changedFields.status.new,
oldStatus: data.changedFields.status.old
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
populateWatchers(data, result);
return result;
};
/**
* Builds notification data for new media added or reassigned events.
*/
const newMediaAddedReassignedBuilder = (data) => {
const body = `New media has been added.`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.newMediaAdded",
body,
variables: {},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
populateWatchers(data, result);
return result;
};
/**
* Builds notification data for new notes added to a job.
*/
const newNoteAddedBuilder = (data) => {
const body = `A new note has been added: "${data.data.text}"`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.newNoteAdded",
body,
variables: {
text: data.data.text
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
populateWatchers(data, result);
return result;
};
/**
* Builds notification data for new time tickets posted.
*/
const newTimeTicketPostedBuilder = (data) => {
const body = `A new time ticket has been posted.`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.newTimeTicketPosted",
body,
variables: {},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
populateWatchers(data, result);
return result;
};
/**
* Builds notification data for parts marked as back-ordered.
*/
const partMarkedBackOrderedBuilder = (data) => {
const body = `A part has been marked as back-ordered.`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.partBackOrdered",
body,
variables: {
queuedForParts: data.changedFields.queued_for_parts?.new,
oldQueuedForParts: data.changedFields.queued_for_parts?.old
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
populateWatchers(data, result);
return result;
};
/**
* Builds notification data for payment collection events.
*/
const paymentCollectedCompletedBuilder = (data) => {
const body = `Payment of $${data.data.clm_total} has been collected.`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.paymentCollected",
body,
variables: {
clmTotal: data.data.clm_total
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
populateWatchers(data, result);
return result;
};
/**
* Builds notification data for changes to scheduled dates.
*/
const scheduledDatesChangedBuilder = (data) => {
const body = `Scheduled dates have been updated.`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.scheduledDatesChanged",
body,
variables: {
scheduledIn: data.changedFields.scheduled_in?.new,
oldScheduledIn: data.changedFields.scheduled_in?.old,
scheduledCompletion: data.changedFields.scheduled_completion?.new,
oldScheduledCompletion: data.changedFields.scheduled_completion?.old,
scheduledDelivery: data.changedFields.scheduled_delivery?.new,
oldScheduledDelivery: data.changedFields.scheduled_delivery?.old
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
populateWatchers(data, result);
return result;
};
/**
* Builds notification data for supplement imported events.
*/
const supplementImportedBuilder = (data) => {
const body = `A supplement of $${data.data.cieca_ttl?.data?.supp_amt || 0} has been imported.`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: "notifications.job.supplementImported",
body,
variables: {
suppAmt: data.data.cieca_ttl?.data?.supp_amt
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
populateWatchers(data, result);
return result;
};
/**
* Builds notification data for tasks updated or created.
*/
const tasksUpdatedCreatedBuilder = (data) => {
const body = `Tasks have been ${data.isNew ? "created" : "updated"}.`;
const result = {
app: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopId: data.bodyShopId,
key: data.isNew ? "notifications.job.taskCreated" : "notifications.job.taskUpdated",
body,
variables: {
isNew: data.isNew,
roNumber: data.jobRoNumber
},
recipients: []
},
email: {
jobId: data.jobId,
jobRoNumber: data.jobRoNumber,
bodyShopName: data.bodyShopName,
body,
recipients: []
},
fcm: { recipients: [] }
};
populateWatchers(data, result);
return result;
};
module.exports = {
alternateTransportChangedBuilder,
billPostedHandler,
criticalPartsStatusChangedBuilder,
intakeDeliveryChecklistCompletedBuilder,
jobAssignedToMeBuilder,
jobsAddedToProductionBuilder,
jobStatusChangeBuilder,
newMediaAddedReassignedBuilder,
newNoteAddedBuilder,
newTimeTicketPostedBuilder,
partMarkedBackOrderedBuilder,
paymentCollectedCompletedBuilder,
scheduledDatesChangedBuilder,
supplementImportedBuilder,
tasksUpdatedCreatedBuilder
};

View File

@@ -1,192 +0,0 @@
const {
jobAssignedToMeBuilder,
billPostedHandler,
newNoteAddedBuilder,
scheduledDatesChangedBuilder,
tasksUpdatedCreatedBuilder,
jobStatusChangeBuilder,
jobsAddedToProductionBuilder,
alternateTransportChangedBuilder,
newTimeTicketPostedBuilder,
intakeDeliveryChecklistCompletedBuilder,
paymentCollectedCompletedBuilder,
newMediaAddedReassignedBuilder,
criticalPartsStatusChangedBuilder,
supplementImportedBuilder,
partMarkedBackOrderedBuilder
} = require("./scenarioBuilders");
/**
* An array of notification scenario definitions.
*
* Each scenario object can include the following properties:
* - key {string}: The unique scenario name.
* - table {string}: The table name to check for changes.
* - fields {Array<string>}: Fields to check for changes.
* - matchToUserFields {Array<string>}: Fields used to match scenarios to user data.
* - onNew {boolean|Array<boolean>}: Indicates whether the scenario should be triggered on new data.
* - onlyTrue {Array<string>}: Specifies fields that must be true for the scenario to match.
* - builder {Function}: A function to handle the scenario.
*/
const notificationScenarios = [
{
key: "job-assigned-to-me",
table: "jobs",
fields: ["employee_pre", "employee_body", "employee_csr", "employee_refinish"],
matchToUserFields: ["employee_pre", "employee_body", "employee_csr", "employee_refinish"],
builder: jobAssignedToMeBuilder
},
{
key: "bill-posted",
table: "bills",
builder: billPostedHandler,
onNew: true
},
{
key: "new-note-added",
table: "notes",
builder: newNoteAddedBuilder,
onNew: true
},
{
key: "schedule-dates-changed",
table: "jobs",
fields: ["scheduled_in", "scheduled_completion", "scheduled_delivery"],
builder: scheduledDatesChangedBuilder
},
{
key: "tasks-updated-created",
table: "tasks",
fields: ["updated_at"],
// onNew: true,
builder: tasksUpdatedCreatedBuilder
},
{
key: "job-status-change",
table: "jobs",
fields: ["status"],
builder: jobStatusChangeBuilder
},
{
key: "job-added-to-production",
table: "jobs",
fields: ["inproduction"],
builder: jobsAddedToProductionBuilder
},
{
key: "alternate-transport-changed",
table: "jobs",
fields: ["alt_transport"],
builder: alternateTransportChangedBuilder
},
{
key: "new-time-ticket-posted",
table: "timetickets",
builder: newTimeTicketPostedBuilder
},
{
// Good test for batching as this will hit multiple scenarios
key: "intake-delivery-checklist-completed",
table: "jobs",
fields: ["intakechecklist"],
builder: intakeDeliveryChecklistCompletedBuilder
},
{
key: "payment-collected-completed",
table: "payments",
onNew: true,
builder: paymentCollectedCompletedBuilder
},
{
// MAKE SURE YOU ARE NOT ON A LMS ENVIRONMENT
// Potential Callbacks / Save for last
// Not question mark for Non LMS Scenario
key: "new-media-added-reassigned",
table: "documents",
fields: ["jobid"],
builder: newMediaAddedReassignedBuilder
},
{
key: "critical-parts-status-changed",
table: "joblines",
fields: ["critical"],
onlyTrue: ["critical"],
builder: criticalPartsStatusChangedBuilder
},
// -------------- Difficult ---------------
// Holding off on this one for now
{
key: "supplement-imported",
builder: supplementImportedBuilder
// spans multiple tables,
},
// This one may be tricky as the jobid is not directly in the event data (this is probably wrong)
// (should otherwise)
// Status needs to mark meta data 'md_backorderd' for example
// Double check Jobid
{
key: "part-marked-back-ordered",
table: "joblines",
builder: partMarkedBackOrderedBuilder
}
];
/**
* Returns an array of scenarios that match the given event data.
*
* @param {Object} eventData - The parsed event data.
* Expected properties:
* - table: an object with a `name` property (e.g. { name: "tasks", schema: "public" })
* - changedFieldNames: an array of changed field names (e.g. [ "description", "updated_at" ])
* - isNew: boolean indicating whether the record is new or updated
* - data: the new data object (used to check field values)
* - (other properties may be added such as jobWatchers, bodyShopId, etc.)
*
* @returns {Array<Object>} An array of matching scenario objects.
*/
const getMatchingScenarios = (eventData) =>
notificationScenarios.filter((scenario) => {
// If eventData has a table, then only scenarios with a table property that matches should be considered.
if (eventData.table) {
if (!scenario.table || eventData.table.name !== scenario.table) {
return false;
}
}
// Check the onNew flag.
// Allow onNew to be either a boolean or an array of booleans.
if (Object.prototype.hasOwnProperty.call(scenario, "onNew")) {
if (Array.isArray(scenario.onNew)) {
if (!scenario.onNew.includes(eventData.isNew)) return false;
} else {
if (eventData.isNew !== scenario.onNew) return false;
}
}
// If the scenario defines fields, ensure at least one of them is present in changedFieldNames.
if (scenario.fields && scenario.fields.length > 0) {
const hasMatchingField = scenario.fields.some((field) => eventData.changedFieldNames.includes(field));
if (!hasMatchingField) {
return false;
}
}
// OnlyTrue logic:
// If a scenario defines an onlyTrue array, then at least one of those fields must have changed
// and its new value (from eventData.data) must be non-falsey.
if (scenario.onlyTrue && Array.isArray(scenario.onlyTrue) && scenario.onlyTrue.length > 0) {
const hasTruthyChange = scenario.onlyTrue.some(
(field) => eventData.changedFieldNames.includes(field) && Boolean(eventData.data[field])
);
if (!hasTruthyChange) {
return false;
}
}
return true;
});
module.exports = {
notificationScenarios,
getMatchingScenarios
};

Some files were not shown because too many files have changed in this diff Show More