Compare commits
1 Commits
feature/IO
...
feature/IO
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2297be0af |
590
client/package-lock.json
generated
590
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,27 +8,27 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"proxy": "http://localhost:4000",
|
"proxy": "http://localhost:4000",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/pro-layout": "^7.22.3",
|
"@ant-design/pro-layout": "^7.22.0",
|
||||||
"@apollo/client": "^3.13.1",
|
"@apollo/client": "^3.12.6",
|
||||||
"@emotion/is-prop-valid": "^1.3.1",
|
"@emotion/is-prop-valid": "^1.3.1",
|
||||||
"@fingerprintjs/fingerprintjs": "^4.6.1",
|
"@fingerprintjs/fingerprintjs": "^4.5.1",
|
||||||
"@jsreport/browser-client": "^3.1.0",
|
"@jsreport/browser-client": "^3.1.0",
|
||||||
"@reduxjs/toolkit": "^2.6.0",
|
"@reduxjs/toolkit": "^2.5.0",
|
||||||
"@sentry/cli": "^2.42.2",
|
"@sentry/cli": "^2.42.2",
|
||||||
"@sentry/react": "^9.3.0",
|
"@sentry/react": "^9.3.0",
|
||||||
"@sentry/vite-plugin": "^3.2.2",
|
"@sentry/vite-plugin": "^3.2.1",
|
||||||
"@splitsoftware/splitio-react": "^1.13.0",
|
"@splitsoftware/splitio-react": "^1.13.0",
|
||||||
"@tanem/react-nprogress": "^5.0.53",
|
"@tanem/react-nprogress": "^5.0.53",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"antd": "^5.24.2",
|
"antd": "^5.23.1",
|
||||||
"apollo-link-logger": "^2.0.1",
|
"apollo-link-logger": "^2.0.1",
|
||||||
"apollo-link-sentry": "^4.1.0",
|
"apollo-link-sentry": "^4.1.0",
|
||||||
"autosize": "^6.0.1",
|
"autosize": "^6.0.1",
|
||||||
"axios": "^1.8.1",
|
"axios": "^1.7.9",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"css-box-model": "^1.2.1",
|
"css-box-model": "^1.2.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"dayjs-business-days2": "^1.3.0",
|
"dayjs-business-days2": "^1.2.3",
|
||||||
"dinero.js": "^1.9.1",
|
"dinero.js": "^1.9.1",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"env-cmd": "^10.1.0",
|
"env-cmd": "^10.1.0",
|
||||||
@@ -36,9 +36,9 @@
|
|||||||
"firebase": "^10.13.2",
|
"firebase": "^10.13.2",
|
||||||
"graphql": "^16.10.0",
|
"graphql": "^16.10.0",
|
||||||
"i18next": "^23.15.1",
|
"i18next": "^23.15.1",
|
||||||
"i18next-browser-languagedetector": "^8.0.4",
|
"i18next-browser-languagedetector": "^8.0.2",
|
||||||
"immutability-helper": "^3.1.1",
|
"immutability-helper": "^3.1.1",
|
||||||
"libphonenumber-js": "^1.12.4",
|
"libphonenumber-js": "^1.11.18",
|
||||||
"logrocket": "^8.1.2",
|
"logrocket": "^8.1.2",
|
||||||
"markerjs2": "^2.32.3",
|
"markerjs2": "^2.32.3",
|
||||||
"memoize-one": "^6.0.0",
|
"memoize-one": "^6.0.0",
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
"query-string": "^9.1.1",
|
"query-string": "^9.1.1",
|
||||||
"raf-schd": "^4.0.3",
|
"raf-schd": "^4.0.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-big-calendar": "^1.18.0",
|
"react-big-calendar": "^1.17.1",
|
||||||
"react-color": "^2.19.3",
|
"react-color": "^2.19.3",
|
||||||
"react-cookie": "^7.2.2",
|
"react-cookie": "^7.2.2",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
"react-grid-gallery": "^1.0.1",
|
"react-grid-gallery": "^1.0.1",
|
||||||
"react-grid-layout": "1.3.4",
|
"react-grid-layout": "1.3.4",
|
||||||
"react-i18next": "^14.1.3",
|
"react-i18next": "^14.1.3",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.4.0",
|
||||||
"react-image-lightbox": "^5.1.4",
|
"react-image-lightbox": "^5.1.4",
|
||||||
"react-markdown": "^9.0.3",
|
"react-markdown": "^9.0.3",
|
||||||
"react-number-format": "^5.4.3",
|
"react-number-format": "^5.4.3",
|
||||||
@@ -64,9 +64,9 @@
|
|||||||
"react-product-fruits": "^2.2.61",
|
"react-product-fruits": "^2.2.61",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-resizable": "^3.0.5",
|
"react-resizable": "^3.0.5",
|
||||||
"react-router-dom": "^6.30.0",
|
"react-router-dom": "^6.26.2",
|
||||||
"react-sticky": "^6.0.3",
|
"react-sticky": "^6.0.3",
|
||||||
"react-virtuoso": "^4.12.5",
|
"react-virtuoso": "^4.10.4",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
"redux": "^5.0.1",
|
"redux": "^5.0.1",
|
||||||
"redux-actions": "^3.0.3",
|
"redux-actions": "^3.0.3",
|
||||||
@@ -74,12 +74,12 @@
|
|||||||
"redux-saga": "^1.3.0",
|
"redux-saga": "^1.3.0",
|
||||||
"redux-state-sync": "^3.1.4",
|
"redux-state-sync": "^3.1.4",
|
||||||
"reselect": "^5.1.1",
|
"reselect": "^5.1.1",
|
||||||
"sass": "^1.85.1",
|
"sass": "^1.83.4",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"styled-components": "^6.1.15",
|
"styled-components": "^6.1.14",
|
||||||
"subscriptions-transport-ws": "^0.11.0",
|
"subscriptions-transport-ws": "^0.11.0",
|
||||||
"use-memo-one": "^1.1.3",
|
"use-memo-one": "^1.1.3",
|
||||||
"userpilot": "^1.3.8",
|
"userpilot": "^1.3.6",
|
||||||
"vite-plugin-ejs": "^1.7.0",
|
"vite-plugin-ejs": "^1.7.0",
|
||||||
"web-vitals": "^3.5.2"
|
"web-vitals": "^3.5.2"
|
||||||
},
|
},
|
||||||
@@ -120,14 +120,14 @@
|
|||||||
"@rollup/rollup-linux-x64-gnu": "4.6.1"
|
"@rollup/rollup-linux-x64-gnu": "4.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ant-design/icons": "^5.6.1",
|
"@ant-design/icons": "^5.5.2",
|
||||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||||
"@babel/preset-react": "^7.26.3",
|
"@babel/preset-react": "^7.26.3",
|
||||||
"@dotenvx/dotenvx": "^1.38.3",
|
"@dotenvx/dotenvx": "^1.33.0",
|
||||||
"@emotion/babel-plugin": "^11.13.5",
|
"@emotion/babel-plugin": "^11.13.5",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@eslint/js": "^9.21.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@sentry/webpack-plugin": "^3.2.2",
|
"@sentry/webpack-plugin": "^3.2.1",
|
||||||
"@testing-library/cypress": "^10.0.2",
|
"@testing-library/cypress": "^10.0.2",
|
||||||
"browserslist": "^4.24.4",
|
"browserslist": "^4.24.4",
|
||||||
"browserslist-to-esbuild": "^2.1.1",
|
"browserslist-to-esbuild": "^2.1.1",
|
||||||
@@ -138,13 +138,13 @@
|
|||||||
"eslint-config-react-app": "^7.0.1",
|
"eslint-config-react-app": "^7.0.1",
|
||||||
"eslint-plugin-cypress": "^2.15.1",
|
"eslint-plugin-cypress": "^2.15.1",
|
||||||
"eslint-plugin-react": "^7.37.4",
|
"eslint-plugin-react": "^7.37.4",
|
||||||
"globals": "^15.15.0",
|
"globals": "^15.14.0",
|
||||||
"memfs": "^4.17.0",
|
"memfs": "^4.17.0",
|
||||||
"os-browserify": "^0.3.0",
|
"os-browserify": "^0.3.0",
|
||||||
"react-error-overlay": "^6.1.0",
|
"react-error-overlay": "6.0.11",
|
||||||
"redux-logger": "^3.0.6",
|
"redux-logger": "^3.0.6",
|
||||||
"source-map-explorer": "^2.5.3",
|
"source-map-explorer": "^2.5.3",
|
||||||
"vite": "^6.2.0",
|
"vite": "^6.0.7",
|
||||||
"vite-plugin-babel": "^1.3.0",
|
"vite-plugin-babel": "^1.3.0",
|
||||||
"vite-plugin-eslint": "^1.8.1",
|
"vite-plugin-eslint": "^1.8.1",
|
||||||
"vite-plugin-node-polyfills": "^0.23.0",
|
"vite-plugin-node-polyfills": "^0.23.0",
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useSplitClient } from "@splitsoftware/splitio-react";
|
import { useSplitClient } from "@splitsoftware/splitio-react";
|
||||||
import { Button, Result } from "antd";
|
import { Button, Result } from "antd";
|
||||||
import LogRocket from "logrocket";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
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 { createStructuredSelector } from "reselect";
|
||||||
import DocumentEditorContainer from "../components/document-editor/document-editor.container";
|
import DocumentEditorContainer from "../components/document-editor/document-editor.container";
|
||||||
import ErrorBoundary from "../components/error-boundary/error-boundary.component"; // Component Imports
|
import ErrorBoundary from "../components/error-boundary/error-boundary.component"; // Component Imports
|
||||||
@@ -21,7 +21,7 @@ import "./App.styles.scss";
|
|||||||
import Eula from "../components/eula/eula.component";
|
import Eula from "../components/eula/eula.component";
|
||||||
import InstanceRenderMgr from "../utils/instanceRenderMgr";
|
import InstanceRenderMgr from "../utils/instanceRenderMgr";
|
||||||
import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx";
|
import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx";
|
||||||
import { SocketProvider } from "../contexts/SocketIO/useSocket.jsx";
|
import { SocketProvider } from "../contexts/SocketIO/socketContext.jsx";
|
||||||
import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx";
|
import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx";
|
||||||
|
|
||||||
const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component"));
|
const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component"));
|
||||||
@@ -46,9 +46,6 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
|
|||||||
const client = useSplitClient().client;
|
const client = useSplitClient().client;
|
||||||
const [listenersAdded, setListenersAdded] = useState(false);
|
const [listenersAdded, setListenersAdded] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const scenarioNotificationsOn = client?.getTreatment("Realtime_Notifications_UI") === "on";
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
@@ -203,12 +200,7 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
|
|||||||
path="/manage/*"
|
path="/manage/*"
|
||||||
element={
|
element={
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<SocketProvider
|
<SocketProvider bodyshop={bodyshop}>
|
||||||
bodyshop={bodyshop}
|
|
||||||
navigate={navigate}
|
|
||||||
currentUser={currentUser}
|
|
||||||
scenarioNotificationsOn={scenarioNotificationsOn}
|
|
||||||
>
|
|
||||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||||
</SocketProvider>
|
</SocketProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
@@ -220,12 +212,7 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
|
|||||||
path="/tech/*"
|
path="/tech/*"
|
||||||
element={
|
element={
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<SocketProvider
|
<SocketProvider bodyshop={bodyshop}>
|
||||||
bodyshop={bodyshop}
|
|
||||||
navigate={navigate}
|
|
||||||
currentUser={currentUser}
|
|
||||||
scenarioNotificationsOn={scenarioNotificationsOn}
|
|
||||||
>
|
|
||||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||||
</SocketProvider>
|
</SocketProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -180,13 +180,3 @@
|
|||||||
.muted-button:hover {
|
.muted-button:hover {
|
||||||
color: darkgrey;
|
color: darkgrey;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-alert-unordered-list {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
.notification-alert-unordered-list-item {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useApolloClient } from "@apollo/client";
|
import { useApolloClient } from "@apollo/client";
|
||||||
import { getToken } from "@firebase/messaging";
|
import { getToken } from "@firebase/messaging";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useEffect } from "react";
|
import React, { useContext, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
import SocketContext from "../../contexts/SocketIO/socketContext";
|
||||||
import { messaging, requestForToken } from "../../firebase/firebase.utils";
|
import { messaging, requestForToken } from "../../firebase/firebase.utils";
|
||||||
import ChatPopupComponent from "../chat-popup/chat-popup.component";
|
import ChatPopupComponent from "../chat-popup/chat-popup.component";
|
||||||
import "./chat-affix.styles.scss";
|
import "./chat-affix.styles.scss";
|
||||||
@@ -12,7 +12,7 @@ import { registerMessagingHandlers, unregisterMessagingHandlers } from "./regist
|
|||||||
export function ChatAffixContainer({ bodyshop, chatVisible }) {
|
export function ChatAffixContainer({ bodyshop, chatVisible }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const client = useApolloClient();
|
const client = useApolloClient();
|
||||||
const { socket } = useSocket();
|
const { socket } = useContext(SocketContext);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bodyshop || !bodyshop.messagingservicesid) return;
|
if (!bodyshop || !bodyshop.messagingservicesid) return;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useMutation } from "@apollo/client";
|
import { useMutation } from "@apollo/client";
|
||||||
import { Button } from "antd";
|
import { Button } from "antd";
|
||||||
import { useState } from "react";
|
import React, { useContext, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TOGGLE_CONVERSATION_ARCHIVE } from "../../graphql/conversations.queries";
|
import { TOGGLE_CONVERSATION_ARCHIVE } from "../../graphql/conversations.queries";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
@@ -18,7 +18,7 @@ export function ChatArchiveButton({ conversation, bodyshop }) {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [updateConversation] = useMutation(TOGGLE_CONVERSATION_ARCHIVE);
|
const [updateConversation] = useMutation(TOGGLE_CONVERSATION_ARCHIVE);
|
||||||
const { socket } = useSocket();
|
const { socket } = useContext(SocketContext);
|
||||||
|
|
||||||
const handleToggleArchive = async () => {
|
const handleToggleArchive = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useMutation } from "@apollo/client";
|
import { useMutation } from "@apollo/client";
|
||||||
import { Tag } from "antd";
|
import { Tag } from "antd";
|
||||||
|
import React, { useContext } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||||
import { REMOVE_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
|
import { REMOVE_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
|
||||||
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
|
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
@@ -17,7 +18,7 @@ const mapDispatchToProps = () => ({});
|
|||||||
|
|
||||||
export function ChatConversationTitleTags({ jobConversations, bodyshop }) {
|
export function ChatConversationTitleTags({ jobConversations, bodyshop }) {
|
||||||
const [removeJobConversation] = useMutation(REMOVE_CONVERSATION_TAG);
|
const [removeJobConversation] = useMutation(REMOVE_CONVERSATION_TAG);
|
||||||
const { socket } = useSocket();
|
const { socket } = useContext(SocketContext);
|
||||||
|
|
||||||
const handleRemoveTag = async (jobId) => {
|
const handleRemoveTag = async (jobId) => {
|
||||||
const convId = jobConversations[0].conversationid;
|
const convId = jobConversations[0].conversationid;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { gql, useApolloClient, useQuery, useSubscription } from "@apollo/client";
|
import { gql, useApolloClient, useQuery, useSubscription } from "@apollo/client";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useContext, useEffect, useState } from "react";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
import SocketContext from "../../contexts/SocketIO/socketContext";
|
||||||
import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
|
import { GET_CONVERSATION_DETAILS, CONVERSATION_SUBSCRIPTION_BY_PK } from "../../graphql/conversations.queries";
|
||||||
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
|
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import ChatConversationComponent from "./chat-conversation.component";
|
import ChatConversationComponent from "./chat-conversation.component";
|
||||||
@@ -16,7 +16,7 @@ const mapStateToProps = createStructuredSelector({
|
|||||||
|
|
||||||
function ChatConversationContainer({ bodyshop, selectedConversation }) {
|
function ChatConversationContainer({ bodyshop, selectedConversation }) {
|
||||||
const client = useApolloClient();
|
const client = useApolloClient();
|
||||||
const { socket } = useSocket();
|
const { socket } = useContext(SocketContext);
|
||||||
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
|
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
|
||||||
|
|
||||||
// Fetch conversation details
|
// Fetch conversation details
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { PlusOutlined } from "@ant-design/icons";
|
import { PlusOutlined } from "@ant-design/icons";
|
||||||
import { useMutation } from "@apollo/client";
|
import { useMutation } from "@apollo/client";
|
||||||
import { Input, Spin, Tag, Tooltip } from "antd";
|
import { Input, Spin, Tag, Tooltip } from "antd";
|
||||||
import { useState } from "react";
|
import React, { useContext, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { UPDATE_CONVERSATION_LABEL } from "../../graphql/conversations.queries";
|
import { UPDATE_CONVERSATION_LABEL } from "../../graphql/conversations.queries";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
@@ -20,7 +20,7 @@ export function ChatLabel({ conversation, bodyshop }) {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [value, setValue] = useState(conversation.label);
|
const [value, setValue] = useState(conversation.label);
|
||||||
const { socket } = useSocket();
|
const { socket } = useContext(SocketContext);
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { PlusCircleFilled } from "@ant-design/icons";
|
import { PlusCircleFilled } from "@ant-design/icons";
|
||||||
import { Button, Form, Popover } from "antd";
|
import { Button, Form, Popover } from "antd";
|
||||||
|
import React, { useContext } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
|
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
|
||||||
import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
|
import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
//currentUser: selectCurrentUser
|
//currentUser: selectCurrentUser
|
||||||
@@ -17,7 +18,7 @@ const mapDispatchToProps = (dispatch) => ({
|
|||||||
export function ChatNewConversation({ openChatByPhone }) {
|
export function ChatNewConversation({ openChatByPhone }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const { socket } = useSocket();
|
const { socket } = useContext(SocketContext);
|
||||||
|
|
||||||
const handleFinish = (values) => {
|
const handleFinish = (values) => {
|
||||||
openChatByPhone({ phone_num: values.phoneNumber, socket });
|
openChatByPhone({ phone_num: values.phoneNumber, socket });
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import parsePhoneNumber from "libphonenumber-js";
|
import parsePhoneNumber from "libphonenumber-js";
|
||||||
|
import React, { useContext } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
|
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
|
||||||
@@ -7,7 +8,7 @@ import PhoneNumberFormatter from "../../utils/PhoneFormatter";
|
|||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import { searchingForConversation } from "../../redux/messaging/messaging.selectors";
|
import { searchingForConversation } from "../../redux/messaging/messaging.selectors";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
@@ -21,7 +22,7 @@ const mapDispatchToProps = (dispatch) => ({
|
|||||||
|
|
||||||
export function ChatOpenButton({ bodyshop, searchingForConversation, phone, jobid, openChatByPhone }) {
|
export function ChatOpenButton({ bodyshop, searchingForConversation, phone, jobid, openChatByPhone }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { socket } = useSocket();
|
const { socket } = useContext(SocketContext);
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|
||||||
if (!phone) return <></>;
|
if (!phone) return <></>;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { InfoCircleOutlined, MessageOutlined, ShrinkOutlined, SyncOutlined } from "@ant-design/icons";
|
import { InfoCircleOutlined, MessageOutlined, ShrinkOutlined, SyncOutlined } from "@ant-design/icons";
|
||||||
import { useApolloClient, useLazyQuery, useQuery } from "@apollo/client";
|
import { useApolloClient, useLazyQuery, useQuery } from "@apollo/client";
|
||||||
import { Badge, Card, Col, Row, Space, Tag, Tooltip, Typography } from "antd";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
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 ChatConversationContainer from "../chat-conversation/chat-conversation.container";
|
||||||
import ChatNewConversation from "../chat-new-conversation/chat-new-conversation.component";
|
import ChatNewConversation from "../chat-new-conversation/chat-new-conversation.component";
|
||||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
|
||||||
|
|
||||||
import "./chat-popup.styles.scss";
|
import "./chat-popup.styles.scss";
|
||||||
|
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
selectedConversation: selectSelectedConversation,
|
selectedConversation: selectSelectedConversation,
|
||||||
@@ -28,7 +27,7 @@ const mapDispatchToProps = (dispatch) => ({
|
|||||||
export function ChatPopupComponent({ chatVisible, selectedConversation, toggleChatVisible }) {
|
export function ChatPopupComponent({ chatVisible, selectedConversation, toggleChatVisible }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [pollInterval, setPollInterval] = useState(0);
|
const [pollInterval, setPollInterval] = useState(0);
|
||||||
const { socket } = useSocket();
|
const { socket } = useContext(SocketContext);
|
||||||
const client = useApolloClient(); // Apollo Client instance for cache operations
|
const client = useApolloClient(); // Apollo Client instance for cache operations
|
||||||
|
|
||||||
// Lazy query for conversations
|
// Lazy query for conversations
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { PlusOutlined } from "@ant-design/icons";
|
|||||||
import { useLazyQuery, useMutation } from "@apollo/client";
|
import { useLazyQuery, useMutation } from "@apollo/client";
|
||||||
import { Tag } from "antd";
|
import { Tag } from "antd";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { useState } from "react";
|
import React, { useContext, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||||
import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
|
import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
|
||||||
import { SEARCH_FOR_JOBS } from "../../graphql/jobs.queries";
|
import { SEARCH_FOR_JOBS } from "../../graphql/jobs.queries";
|
||||||
import ChatTagRo from "./chat-tag-ro.component";
|
import ChatTagRo from "./chat-tag-ro.component";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
@@ -22,7 +22,7 @@ const mapDispatchToProps = () => ({});
|
|||||||
export function ChatTagRoContainer({ conversation, bodyshop }) {
|
export function ChatTagRoContainer({ conversation, bodyshop }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { socket } = useSocket();
|
const { socket } = useContext(SocketContext);
|
||||||
|
|
||||||
const [loadRo, { loading, data }] = useLazyQuery(SEARCH_FOR_JOBS);
|
const [loadRo, { loading, data }] = useLazyQuery(SEARCH_FOR_JOBS);
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ class ErrorBoundary extends React.Component {
|
|||||||
<Row>
|
<Row>
|
||||||
<Col offset={6} span={12}>
|
<Col offset={6} span={12}>
|
||||||
<Collapse bordered={false}>
|
<Collapse bordered={false}>
|
||||||
<Collapse.Panel key="errors-panel" header={t("general.labels.errors")}>
|
<Collapse.Panel header={t("general.labels.errors")}>
|
||||||
<div>
|
<div>
|
||||||
<strong>{this.state.error.message}</strong>
|
<strong>{this.state.error.message}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ const Eula = ({ currentEula, currentUser, acceptEula }) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
notification.error({
|
notification.error({
|
||||||
message: t("eula.errors.acceptance.message"),
|
message: t("eula.errors.acceptance.message"),
|
||||||
description: t("eula.errors.acceptance.description")
|
description: t("eula.errors.acceptance.description"),
|
||||||
|
placement: "bottomRight",
|
||||||
|
duration: 5000
|
||||||
});
|
});
|
||||||
console.log(`${t("eula.errors.acceptance.message")}`);
|
console.log(`${t("eula.errors.acceptance.message")}`);
|
||||||
console.dir({
|
console.dir({
|
||||||
|
|||||||
@@ -1,19 +1,6 @@
|
|||||||
import { Badge, Layout, Menu, Spin } from "antd";
|
import Icon, {
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { connect } from "react-redux";
|
|
||||||
import { createStructuredSelector } from "reselect";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { useQuery } from "@apollo/client";
|
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
|
||||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
|
||||||
import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
|
|
||||||
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
|
|
||||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
|
||||||
import {
|
|
||||||
BankFilled,
|
BankFilled,
|
||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
BellFilled,
|
|
||||||
CarFilled,
|
CarFilled,
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
ClockCircleFilled,
|
ClockCircleFilled,
|
||||||
@@ -38,21 +25,26 @@ import {
|
|||||||
UnorderedListOutlined,
|
UnorderedListOutlined,
|
||||||
UserOutlined
|
UserOutlined
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
|
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||||
|
import { Layout, Menu, Space } from "antd";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { BsKanban } from "react-icons/bs";
|
import { BsKanban } from "react-icons/bs";
|
||||||
import { FaCalendarAlt, FaCarCrash, FaCreditCard, FaFileInvoiceDollar, FaTasks } from "react-icons/fa";
|
import { FaCalendarAlt, FaCarCrash, FaCreditCard, FaFileInvoiceDollar, FaTasks } from "react-icons/fa";
|
||||||
import { FiLogOut } from "react-icons/fi";
|
import { FiLogOut } from "react-icons/fi";
|
||||||
import { GiPayMoney, GiPlayerTime, GiSettingsKnobs } from "react-icons/gi";
|
import { GiPayMoney, GiPlayerTime, GiSettingsKnobs } from "react-icons/gi";
|
||||||
import { IoBusinessOutline } from "react-icons/io5";
|
import { IoBusinessOutline } from "react-icons/io5";
|
||||||
import { RiSurveyLine } from "react-icons/ri";
|
import { RiSurveyLine } from "react-icons/ri";
|
||||||
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
|
import { connect } from "react-redux";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors";
|
import { selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors";
|
||||||
import { setModalContext } from "../../redux/modals/modals.actions";
|
import { setModalContext } from "../../redux/modals/modals.actions";
|
||||||
import { signOutStart } from "../../redux/user/user.actions";
|
import { signOutStart } from "../../redux/user/user.actions";
|
||||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||||
import day from "../../utils/day.js";
|
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
||||||
|
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
|
||||||
|
|
||||||
// Redux mappings
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
currentUser: selectCurrentUser,
|
currentUser: selectCurrentUser,
|
||||||
recentItems: selectRecentItems,
|
recentItems: selectRecentItems,
|
||||||
@@ -61,13 +53,43 @@ const mapStateToProps = createStructuredSelector({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
setBillEnterContext: (context) => dispatch(setModalContext({ context, modal: "billEnter" })),
|
setBillEnterContext: (context) =>
|
||||||
setTimeTicketContext: (context) => dispatch(setModalContext({ context, modal: "timeTicket" })),
|
dispatch(
|
||||||
setPaymentContext: (context) => dispatch(setModalContext({ context, modal: "payment" })),
|
setModalContext({
|
||||||
setReportCenterContext: (context) => dispatch(setModalContext({ context, modal: "reportCenter" })),
|
context: context,
|
||||||
|
modal: "billEnter"
|
||||||
|
})
|
||||||
|
),
|
||||||
|
setTimeTicketContext: (context) =>
|
||||||
|
dispatch(
|
||||||
|
setModalContext({
|
||||||
|
context: context,
|
||||||
|
modal: "timeTicket"
|
||||||
|
})
|
||||||
|
),
|
||||||
|
setPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "payment" })),
|
||||||
|
setReportCenterContext: (context) =>
|
||||||
|
dispatch(
|
||||||
|
setModalContext({
|
||||||
|
context: context,
|
||||||
|
modal: "reportCenter"
|
||||||
|
})
|
||||||
|
),
|
||||||
signOutStart: () => dispatch(signOutStart()),
|
signOutStart: () => dispatch(signOutStart()),
|
||||||
setCardPaymentContext: (context) => dispatch(setModalContext({ context, modal: "cardPayment" })),
|
setCardPaymentContext: (context) =>
|
||||||
setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" }))
|
dispatch(
|
||||||
|
setModalContext({
|
||||||
|
context: context,
|
||||||
|
modal: "cardPayment"
|
||||||
|
})
|
||||||
|
),
|
||||||
|
setTaskUpsertContext: (context) =>
|
||||||
|
dispatch(
|
||||||
|
setModalContext({
|
||||||
|
context: context,
|
||||||
|
modal: "taskUpsert"
|
||||||
|
})
|
||||||
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
function Header({
|
function Header({
|
||||||
@@ -93,81 +115,24 @@ function Header({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isConnected, scenarioNotificationsOn } = useSocket();
|
|
||||||
const [notificationVisible, setNotificationVisible] = useState(false);
|
|
||||||
const baseTitleRef = useRef(document.title || "");
|
|
||||||
const lastSetTitleRef = useRef("");
|
|
||||||
const userAssociationId = bodyshop?.associations?.[0]?.id;
|
|
||||||
|
|
||||||
const {
|
// const deleteBetaCookie = () => {
|
||||||
data: unreadData,
|
// const cookieExists = document.cookie.split("; ").some((row) => row.startsWith(`betaSwitchImex=`));
|
||||||
refetch: refetchUnread,
|
// if (cookieExists) {
|
||||||
loading: unreadLoading
|
// const domain = window.location.hostname.split(".").slice(-2).join(".");
|
||||||
} = useQuery(GET_UNREAD_COUNT, {
|
// document.cookie = `betaSwitchImex=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=.${domain}`;
|
||||||
variables: { associationid: userAssociationId },
|
// }
|
||||||
fetchPolicy: "network-only",
|
// };
|
||||||
pollInterval: isConnected ? 0 : day.duration(60, "seconds").asMilliseconds(),
|
//
|
||||||
skip: !userAssociationId || !scenarioNotificationsOn
|
// deleteBetaCookie();
|
||||||
});
|
|
||||||
|
|
||||||
const unreadCount = unreadData?.notifications_aggregate?.aggregate?.count ?? 0;
|
const accountingChildren = [];
|
||||||
|
|
||||||
useEffect(() => {
|
accountingChildren.push(
|
||||||
if (userAssociationId) {
|
|
||||||
refetchUnread().catch((e) => console.error(`Error fetching unread notifications: ${e?.message}`));
|
|
||||||
}
|
|
||||||
}, [refetchUnread, userAssociationId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isConnected && !unreadLoading && userAssociationId) {
|
|
||||||
refetchUnread().catch((e) => console.error(`Error fetching unread notifications: ${e?.message}`));
|
|
||||||
}
|
|
||||||
}, [isConnected, unreadLoading, refetchUnread, userAssociationId]);
|
|
||||||
|
|
||||||
// Keep The unread count in the title.
|
|
||||||
useEffect(() => {
|
|
||||||
const updateTitle = () => {
|
|
||||||
const currentTitle = document.title;
|
|
||||||
// Check if the current title differs from what we last set
|
|
||||||
if (currentTitle !== lastSetTitleRef.current) {
|
|
||||||
// Extract base title by removing any unread count prefix
|
|
||||||
const baseTitleMatch = currentTitle.match(/^\(\d+\)\s*(.*)$/);
|
|
||||||
baseTitleRef.current = baseTitleMatch ? baseTitleMatch[1] : currentTitle;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply unread count to the base title
|
|
||||||
const newTitle = unreadCount > 0 ? `(${unreadCount}) ${baseTitleRef.current}` : baseTitleRef.current;
|
|
||||||
|
|
||||||
// Only update if the title has changed to avoid unnecessary DOM writes
|
|
||||||
if (document.title !== newTitle) {
|
|
||||||
document.title = newTitle;
|
|
||||||
lastSetTitleRef.current = newTitle; // Store what we set
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initial update
|
|
||||||
updateTitle();
|
|
||||||
|
|
||||||
// Poll every 100ms to catch child component changes
|
|
||||||
const interval = setInterval(updateTitle, 100);
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
return () => {
|
|
||||||
clearInterval(interval);
|
|
||||||
document.title = baseTitleRef.current; // Reset to base title on unmount
|
|
||||||
};
|
|
||||||
}, [unreadCount]); // Re-run when unreadCount changes
|
|
||||||
|
|
||||||
const handleNotificationClick = (e) => {
|
|
||||||
setNotificationVisible(!notificationVisible);
|
|
||||||
if (handleMenuClick) handleMenuClick(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
const accountingChildren = [
|
|
||||||
{
|
{
|
||||||
key: "bills",
|
key: "bills",
|
||||||
id: "header-accounting-bills",
|
id: "header-accounting-bills",
|
||||||
icon: <FaFileInvoiceDollar />,
|
icon: <Icon component={FaFileInvoiceDollar} />,
|
||||||
label: (
|
label: (
|
||||||
<Link to="/manage/bills">
|
<Link to="/manage/bills">
|
||||||
<LockWrapper featureName="bills" bodyshop={bodyshop}>
|
<LockWrapper featureName="bills" bodyshop={bodyshop}>
|
||||||
@@ -179,47 +144,69 @@ function Header({
|
|||||||
{
|
{
|
||||||
key: "enterbills",
|
key: "enterbills",
|
||||||
id: "header-accounting-enterbills",
|
id: "header-accounting-enterbills",
|
||||||
icon: <GiPayMoney />,
|
icon: <Icon component={GiPayMoney} />,
|
||||||
label: (
|
label: (
|
||||||
<LockWrapper featureName="bills" bodyshop={bodyshop}>
|
<Space>
|
||||||
{t("menus.header.enterbills")}
|
<LockWrapper featureName="bills" bodyshop={bodyshop}>
|
||||||
</LockWrapper>
|
{t("menus.header.enterbills")}
|
||||||
|
</LockWrapper>
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
onClick: () =>
|
onClick: () => {
|
||||||
HasFeatureAccess({ featureName: "bills", bodyshop }) &&
|
HasFeatureAccess({ featureName: "bills", bodyshop }) &&
|
||||||
setBillEnterContext({
|
setBillEnterContext({
|
||||||
actions: {},
|
actions: {},
|
||||||
context: {}
|
context: {}
|
||||||
})
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Simple_Inventory.treatment === "on") {
|
||||||
|
accountingChildren.push(
|
||||||
|
{
|
||||||
|
type: "divider"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "inventory",
|
||||||
|
id: "header-accounting-inventory",
|
||||||
|
icon: <Icon component={FaFileInvoiceDollar} />,
|
||||||
|
label: <Link to="/manage/inventory">{t("menus.header.inventory")}</Link>
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
accountingChildren.push(
|
||||||
|
{
|
||||||
|
type: "divider"
|
||||||
},
|
},
|
||||||
...(Simple_Inventory.treatment === "on"
|
|
||||||
? [
|
|
||||||
{ type: "divider" },
|
|
||||||
{
|
|
||||||
key: "inventory",
|
|
||||||
id: "header-accounting-inventory",
|
|
||||||
icon: <FaFileInvoiceDollar />,
|
|
||||||
label: <Link to="/manage/inventory">{t("menus.header.inventory")}</Link>
|
|
||||||
}
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{ type: "divider" },
|
|
||||||
{
|
{
|
||||||
key: "allpayments",
|
key: "allpayments",
|
||||||
id: "header-accounting-allpayments",
|
id: "header-accounting-allpayments",
|
||||||
icon: <BankFilled />,
|
icon: <BankFilled />,
|
||||||
label: <Link to="/manage/payments">{t("menus.header.allpayments")}</Link>
|
label: (
|
||||||
|
<Link to="/manage/payments">
|
||||||
|
<LockWrapper featureName="payments" bodyshop={bodyshop}>
|
||||||
|
{t("menus.header.allpayments")}
|
||||||
|
</LockWrapper>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "enterpayments",
|
key: "enterpayments",
|
||||||
id: "header-accounting-enterpayments",
|
id: "header-accounting-enterpayments",
|
||||||
icon: <Icon component={FaCreditCard} />,
|
icon: <Icon component={FaCreditCard} />,
|
||||||
label: t("menus.header.enterpayment"),
|
label: (
|
||||||
|
<LockWrapper featureName="payments" bodyshop={bodyshop}>
|
||||||
|
{t("menus.header.enterpayment")}
|
||||||
|
</LockWrapper>
|
||||||
|
),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setPaymentContext({
|
HasFeatureAccess({ featureName: "payments", bodyshop }) &&
|
||||||
actions: {},
|
setPaymentContext({
|
||||||
context: null
|
actions: {},
|
||||||
});
|
context: null
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -233,21 +220,16 @@ function Header({
|
|||||||
onClick: () => {
|
onClick: () => {
|
||||||
setCardPaymentContext({
|
setCardPaymentContext({
|
||||||
actions: {},
|
actions: {},
|
||||||
context: null
|
context: {}
|
||||||
})
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
accountingChildren.push(
|
||||||
|
{
|
||||||
|
type: "divider"
|
||||||
},
|
},
|
||||||
...(ImEXPay.treatment === "on"
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
key: "entercardpayments",
|
|
||||||
id: "header-accounting-entercardpayments",
|
|
||||||
icon: <FaCreditCard />,
|
|
||||||
label: t("menus.header.entercardpayment"),
|
|
||||||
onClick: () => setCardPaymentContext({ actions: {}, context: {} })
|
|
||||||
}
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{ type: "divider" },
|
|
||||||
{
|
{
|
||||||
key: "timetickets",
|
key: "timetickets",
|
||||||
id: "header-accounting-timetickets",
|
id: "header-accounting-timetickets",
|
||||||
@@ -259,124 +241,132 @@ function Header({
|
|||||||
</LockWrapper>
|
</LockWrapper>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
...(bodyshop?.md_tasks_presets?.use_approvals
|
);
|
||||||
? [
|
|
||||||
{
|
if (bodyshop?.md_tasks_presets?.use_approvals) {
|
||||||
key: "ttapprovals",
|
accountingChildren.push({
|
||||||
id: "header-accounting-ttapprovals",
|
key: "ttapprovals",
|
||||||
icon: <FieldTimeOutlined />,
|
id: "header-accounting-ttapprovals",
|
||||||
label: <Link to="/manage/ttapprovals">{t("menus.header.ttapprovals")}</Link>
|
icon: <FieldTimeOutlined />,
|
||||||
}
|
label: <Link to="/manage/ttapprovals">{t("menus.header.ttapprovals")}</Link>
|
||||||
]
|
});
|
||||||
: []),
|
}
|
||||||
|
accountingChildren.push(
|
||||||
{
|
{
|
||||||
key: "entertimetickets",
|
key: "entertimetickets",
|
||||||
id: "header-accounting-entertimetickets",
|
icon: <Icon component={GiPlayerTime} />,
|
||||||
icon: <GiPlayerTime />,
|
|
||||||
label: (
|
label: (
|
||||||
<LockWrapper featureName="timetickets" bodyshop={bodyshop}>
|
<LockWrapper featureName="timetickets" bodyshop={bodyshop}>
|
||||||
{t("menus.header.entertimeticket")}
|
{t("menus.header.entertimeticket")}
|
||||||
</LockWrapper>
|
</LockWrapper>
|
||||||
),
|
),
|
||||||
onClick: () =>
|
id: "header-accounting-entertimetickets",
|
||||||
|
onClick: () => {
|
||||||
HasFeatureAccess({ featureName: "timetickets", bodyshop }) &&
|
HasFeatureAccess({ featureName: "timetickets", bodyshop }) &&
|
||||||
setTimeTicketContext({
|
setTimeTicketContext({
|
||||||
actions: {},
|
actions: {},
|
||||||
context: {
|
context: {
|
||||||
created_by: currentUser.displayName
|
created_by: currentUser.displayName
|
||||||
? `${currentUser.email} | ${currentUser.displayName}`
|
? currentUser.email.concat(" | ", currentUser.displayName)
|
||||||
: currentUser.email
|
: currentUser.email
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ type: "divider" },
|
|
||||||
{
|
{
|
||||||
key: "accountingexport",
|
type: "divider"
|
||||||
id: "header-accounting-export",
|
}
|
||||||
icon: <ExportOutlined />,
|
);
|
||||||
|
|
||||||
|
const accountingExportChildren = [
|
||||||
|
{
|
||||||
|
key: "receivables",
|
||||||
|
id: "header-accounting-receivables",
|
||||||
label: (
|
label: (
|
||||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
<Link to="/manage/accounting/receivables">
|
||||||
{t("menus.header.export")}
|
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||||
</LockWrapper>
|
{t("menus.header.accounting-receivables")}
|
||||||
),
|
</LockWrapper>
|
||||||
children: [
|
</Link>
|
||||||
{
|
)
|
||||||
key: "receivables",
|
|
||||||
id: "header-accounting-receivables",
|
|
||||||
label: (
|
|
||||||
<Link to="/manage/accounting/receivables">
|
|
||||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
|
||||||
{t("menus.header.accounting-receivables")}
|
|
||||||
</LockWrapper>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) ||
|
|
||||||
DmsAp.treatment === "on"
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
key: "payables",
|
|
||||||
id: "header-accounting-payables",
|
|
||||||
label: (
|
|
||||||
<Link to="/manage/accounting/payables">
|
|
||||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
|
||||||
{t("menus.header.accounting-payables")}
|
|
||||||
</LockWrapper>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber))
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
key: "payments",
|
|
||||||
id: "header-accounting-payments",
|
|
||||||
label: (
|
|
||||||
<Link to="/manage/accounting/payments">
|
|
||||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
|
||||||
{t("menus.header.accounting-payments")}
|
|
||||||
</LockWrapper>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{ type: "divider" },
|
|
||||||
{
|
|
||||||
key: "exportlogs",
|
|
||||||
id: "header-accounting-exportlogs",
|
|
||||||
label: (
|
|
||||||
<Link to="/manage/accounting/exportlogs">
|
|
||||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
|
||||||
{t("menus.header.export-logs")}
|
|
||||||
</LockWrapper>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Left menu items (includes original navigation items)
|
if (!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) || DmsAp.treatment === "on") {
|
||||||
const leftMenuItems = [
|
accountingExportChildren.push({
|
||||||
|
key: "payables",
|
||||||
|
id: "header-accounting-payables",
|
||||||
|
label: (
|
||||||
|
<Link to="/manage/accounting/payables">
|
||||||
|
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||||
|
{t("menus.header.accounting-payables")}
|
||||||
|
</LockWrapper>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber))) {
|
||||||
|
accountingExportChildren.push({
|
||||||
|
key: "payments",
|
||||||
|
id: "header-accounting-payments",
|
||||||
|
label: (
|
||||||
|
<Link to="/manage/accounting/payments">
|
||||||
|
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||||
|
{t("menus.header.accounting-payments")}
|
||||||
|
</LockWrapper>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
accountingExportChildren.push(
|
||||||
|
{
|
||||||
|
type: "divider"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "exportlogs",
|
||||||
|
id: "header-accounting-exportlogs",
|
||||||
|
label: (
|
||||||
|
<Link to="/manage/accounting/exportlogs">
|
||||||
|
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||||
|
{t("menus.header.export-logs")}
|
||||||
|
</LockWrapper>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
accountingChildren.push({
|
||||||
|
key: "accountingexport",
|
||||||
|
id: "header-accounting-export",
|
||||||
|
icon: <ExportOutlined />,
|
||||||
|
label: (
|
||||||
|
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||||
|
{t("menus.header.export")}
|
||||||
|
</LockWrapper>
|
||||||
|
),
|
||||||
|
children: accountingExportChildren
|
||||||
|
});
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
{
|
{
|
||||||
key: "home",
|
key: "home",
|
||||||
id: "header-home",
|
|
||||||
icon: <HomeFilled />,
|
icon: <HomeFilled />,
|
||||||
|
id: "header-home",
|
||||||
label: <Link to="/manage/">{t("menus.header.home")}</Link>
|
label: <Link to="/manage/">{t("menus.header.home")}</Link>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "schedule",
|
key: "schedule",
|
||||||
id: "header-schedule",
|
id: "header-schedule",
|
||||||
icon: <FaCalendarAlt />,
|
icon: <Icon component={FaCalendarAlt} />,
|
||||||
label: <Link to="/manage/schedule">{t("menus.header.schedule")}</Link>
|
label: <Link to="/manage/schedule">{t("menus.header.schedule")}</Link>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "jobssubmenu",
|
key: "jobssubmenu",
|
||||||
id: "header-jobs",
|
id: "header-jobs",
|
||||||
icon: <FaCarCrash />,
|
icon: <Icon component={FaCarCrash} />,
|
||||||
label: t("menus.header.jobs"),
|
label: t("menus.header.jobs"),
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
@@ -409,24 +399,31 @@ function Header({
|
|||||||
icon: <FileAddOutlined />,
|
icon: <FileAddOutlined />,
|
||||||
label: <Link to="/manage/jobs/new">{t("menus.header.newjob")}</Link>
|
label: <Link to="/manage/jobs/new">{t("menus.header.newjob")}</Link>
|
||||||
},
|
},
|
||||||
{ type: "divider" },
|
{
|
||||||
|
type: "divider",
|
||||||
|
id: "header-jobs-divider"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "alljobs",
|
key: "alljobs",
|
||||||
id: "header-all-jobs",
|
id: "header-all-jobs",
|
||||||
icon: <UnorderedListOutlined />,
|
icon: <UnorderedListOutlined />,
|
||||||
label: <Link to="/manage/jobs/all">{t("menus.header.alljobs")}</Link>
|
label: <Link to="/manage/jobs/all">{t("menus.header.alljobs")}</Link>
|
||||||
},
|
},
|
||||||
{ type: "divider" },
|
{
|
||||||
|
type: "divider",
|
||||||
|
id: "header-jobs-divider2"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "productionlist",
|
key: "productionlist",
|
||||||
id: "header-production-list",
|
id: "header-production-list",
|
||||||
icon: <ScheduleOutlined />,
|
icon: <ScheduleOutlined />,
|
||||||
label: <Link to="/manage/production/list">{t("menus.header.productionlist")}</Link>
|
label: <Link to="/manage/production/list">{t("menus.header.productionlist")}</Link>
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
key: "productionboard",
|
key: "productionboard",
|
||||||
id: "header-production-board",
|
id: "header-production-board",
|
||||||
icon: <BsKanban />,
|
icon: <Icon component={BsKanban} />,
|
||||||
label: (
|
label: (
|
||||||
<Link to="/manage/production/board">
|
<Link to="/manage/production/board">
|
||||||
<LockWrapper featureName="visualboard" bodyshop={bodyshop}>
|
<LockWrapper featureName="visualboard" bodyshop={bodyshop}>
|
||||||
@@ -435,7 +432,11 @@ function Header({
|
|||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{ type: "divider" },
|
|
||||||
|
{
|
||||||
|
type: "divider",
|
||||||
|
id: "header-jobs-divider3"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "scoreboard",
|
key: "scoreboard",
|
||||||
id: "header-scoreboard",
|
id: "header-scoreboard",
|
||||||
@@ -452,8 +453,8 @@ function Header({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "customers",
|
key: "customers",
|
||||||
id: "header-customers",
|
|
||||||
icon: <UserOutlined />,
|
icon: <UserOutlined />,
|
||||||
|
id: "header-customers",
|
||||||
label: t("menus.header.customers"),
|
label: t("menus.header.customers"),
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
@@ -518,6 +519,7 @@ function Header({
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
...(accountingChildren.length > 0
|
...(accountingChildren.length > 0
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -535,6 +537,7 @@ function Header({
|
|||||||
icon: <PhoneOutlined />,
|
icon: <PhoneOutlined />,
|
||||||
label: <Link to="/manage/phonebook">{t("menus.header.phonebook")}</Link>
|
label: <Link to="/manage/phonebook">{t("menus.header.phonebook")}</Link>
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
key: "temporarydocs",
|
key: "temporarydocs",
|
||||||
id: "header-temporarydocs",
|
id: "header-temporarydocs",
|
||||||
@@ -547,6 +550,7 @@ function Header({
|
|||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
key: "tasks",
|
key: "tasks",
|
||||||
id: "tasks",
|
id: "tasks",
|
||||||
@@ -558,7 +562,12 @@ function Header({
|
|||||||
id: "header-create-task",
|
id: "header-create-task",
|
||||||
icon: <PlusCircleOutlined />,
|
icon: <PlusCircleOutlined />,
|
||||||
label: t("menus.header.create_task"),
|
label: t("menus.header.create_task"),
|
||||||
onClick: () => setTaskUpsertContext({ actions: {}, context: {} })
|
onClick: () => {
|
||||||
|
setTaskUpsertContext({
|
||||||
|
actions: {},
|
||||||
|
context: {}
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "mytasks",
|
key: "mytasks",
|
||||||
@@ -583,7 +592,7 @@ function Header({
|
|||||||
{
|
{
|
||||||
key: "shop",
|
key: "shop",
|
||||||
id: "header-shop",
|
id: "header-shop",
|
||||||
icon: <GiSettingsKnobs />,
|
icon: <Icon component={GiSettingsKnobs} />,
|
||||||
label: <Link to="/manage/shop?tab=info">{t("menus.header.shop_config")}</Link>
|
label: <Link to="/manage/shop?tab=info">{t("menus.header.shop_config")}</Link>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -601,18 +610,24 @@ function Header({
|
|||||||
id: "header-reportcenter",
|
id: "header-reportcenter",
|
||||||
icon: <BarChartOutlined />,
|
icon: <BarChartOutlined />,
|
||||||
label: t("menus.header.reportcenter"),
|
label: t("menus.header.reportcenter"),
|
||||||
onClick: () => setReportCenterContext({ actions: {}, context: {} })
|
onClick: () => {
|
||||||
|
setReportCenterContext({
|
||||||
|
actions: {},
|
||||||
|
context: {}
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "shop-vendors",
|
key: "shop-vendors",
|
||||||
id: "header-shop-vendors",
|
id: "header-shop-vendors",
|
||||||
icon: <IoBusinessOutline />,
|
icon: <Icon component={IoBusinessOutline} />,
|
||||||
label: <Link to="/manage/shop/vendors">{t("menus.header.shop_vendors")}</Link>
|
label: <Link to="/manage/shop/vendors">{t("menus.header.shop_vendors")}</Link>
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
key: "shop-csi",
|
key: "shop-csi",
|
||||||
id: "header-shop-csi",
|
id: "header-shop-csi",
|
||||||
icon: <RiSurveyLine />,
|
icon: <Icon component={RiSurveyLine} />,
|
||||||
label: (
|
label: (
|
||||||
<Link to="/manage/shop/csi">
|
<Link to="/manage/shop/csi">
|
||||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||||
@@ -623,27 +638,14 @@ function Header({
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "recent",
|
|
||||||
id: "header-recent",
|
|
||||||
icon: <ClockCircleFilled />,
|
|
||||||
label: t("menus.header.recent"),
|
|
||||||
children: recentItems.map((i, idx) => ({
|
|
||||||
key: idx,
|
|
||||||
id: `header-recent-${idx}`,
|
|
||||||
label: <Link to={i.url}>{i.label}</Link>
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "user",
|
key: "user",
|
||||||
id: "header-user",
|
label: currentUser.displayName || currentUser.email || t("general.labels.unknown"),
|
||||||
icon: <UserOutlined />,
|
|
||||||
label: t("menus.currentuser.profile"),
|
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
key: "signout",
|
key: "signout",
|
||||||
id: "header-signout",
|
id: "header-signout",
|
||||||
icon: <FiLogOut />,
|
icon: <Icon component={FiLogOut} />,
|
||||||
danger: true,
|
danger: true,
|
||||||
label: t("user.actions.signout"),
|
label: t("user.actions.signout"),
|
||||||
onClick: () => signOutStart()
|
onClick: () => signOutStart()
|
||||||
@@ -651,25 +653,33 @@ function Header({
|
|||||||
{
|
{
|
||||||
key: "help",
|
key: "help",
|
||||||
id: "header-help",
|
id: "header-help",
|
||||||
icon: <QuestionCircleFilled />,
|
icon: <Icon component={QuestionCircleFilled} />,
|
||||||
label: t("menus.header.help"),
|
label: t("menus.header.help"),
|
||||||
onClick: () => window.open("https://help.imex.online/", "_blank")
|
onClick: () => {
|
||||||
|
window.open("https://help.imex.online/", "_blank");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
...(InstanceRenderManager({ imex: true, rome: false })
|
...(InstanceRenderManager({
|
||||||
|
imex: true,
|
||||||
|
rome: false
|
||||||
|
})
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
key: "rescue",
|
key: "rescue",
|
||||||
id: "header-rescue",
|
id: "header-rescue",
|
||||||
icon: <CarFilled />,
|
icon: <Icon component={CarFilled} />,
|
||||||
label: t("menus.header.rescueme"),
|
label: t("menus.header.rescueme"),
|
||||||
onClick: () => window.open("https://imexrescue.com/", "_blank")
|
onClick: () => {
|
||||||
|
window.open("https://imexrescue.com/", "_blank");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
|
||||||
{
|
{
|
||||||
key: "shiftclock",
|
key: "shiftclock",
|
||||||
id: "header-shiftclock",
|
id: "header-shiftclock",
|
||||||
icon: <GiPlayerTime />,
|
icon: <Icon component={GiPlayerTime} />,
|
||||||
label: (
|
label: (
|
||||||
<Link to="/manage/shiftclock">
|
<Link to="/manage/shiftclock">
|
||||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||||
@@ -678,79 +688,64 @@ function Header({
|
|||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
key: "profile",
|
key: "profile",
|
||||||
id: "header-profile",
|
id: "header-profile",
|
||||||
icon: <UserOutlined />,
|
icon: <UserOutlined />,
|
||||||
label: <Link to="/manage/profile">{t("menus.currentuser.profile")}</Link>
|
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>
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Notifications item (always on the right)
|
|
||||||
const notificationItem = scenarioNotificationsOn
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
key: "notifications",
|
|
||||||
id: "header-notifications",
|
|
||||||
icon: unreadLoading ? (
|
|
||||||
<Spin size="small" />
|
|
||||||
) : (
|
|
||||||
<Badge offset={[8, 0]} size="small" count={unreadCount}>
|
|
||||||
<BellFilled />
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
onClick: handleNotificationClick
|
|
||||||
}
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout.Header style={{ padding: 0, background: "#001529" }}>
|
<Layout.Header>
|
||||||
<div
|
<Menu
|
||||||
style={{
|
mode="horizontal"
|
||||||
display: "flex",
|
theme={"dark"}
|
||||||
justifyContent: "space-between",
|
selectedKeys={[selectedHeader]}
|
||||||
alignItems: "center",
|
onClick={handleMenuClick}
|
||||||
height: "100%",
|
subMenuCloseDelay={0.3}
|
||||||
overflow: "hidden"
|
items={menuItems}
|
||||||
}}
|
/>
|
||||||
>
|
|
||||||
<Menu
|
|
||||||
mode="horizontal"
|
|
||||||
theme="dark"
|
|
||||||
selectedKeys={[selectedHeader]}
|
|
||||||
onClick={handleMenuClick}
|
|
||||||
subMenuCloseDelay={0.3}
|
|
||||||
items={leftMenuItems}
|
|
||||||
style={{
|
|
||||||
flex: "1 1 auto",
|
|
||||||
minWidth: 0,
|
|
||||||
overflowX: "auto",
|
|
||||||
borderBottom: "none",
|
|
||||||
background: "transparent"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{scenarioNotificationsOn && (
|
|
||||||
<Menu
|
|
||||||
mode="horizontal"
|
|
||||||
theme="dark"
|
|
||||||
selectedKeys={[selectedHeader]}
|
|
||||||
onClick={handleMenuClick}
|
|
||||||
subMenuCloseDelay={0.3}
|
|
||||||
items={notificationItem}
|
|
||||||
style={{ flex: "0 0 auto", minWidth: 0, borderBottom: "none", background: "transparent" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{scenarioNotificationsOn && (
|
|
||||||
<NotificationCenterContainer
|
|
||||||
visible={notificationVisible}
|
|
||||||
onClose={() => setNotificationVisible(false)}
|
|
||||||
unreadCount={unreadCount}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Layout.Header>
|
</Layout.Header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,30 @@
|
|||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import HeaderComponent from "./header.component";
|
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() {
|
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 />;
|
return <HeaderComponent />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { useMutation } from "@apollo/client";
|
|||||||
import { Button, Divider, Dropdown, Form, Input, Popover, Select, Space } from "antd";
|
import { Button, Divider, Dropdown, Form, Input, Popover, Select, Space } from "antd";
|
||||||
import parsePhoneNumber from "libphonenumber-js";
|
import parsePhoneNumber from "libphonenumber-js";
|
||||||
import queryString from "query-string";
|
import queryString from "query-string";
|
||||||
import { useState } from "react";
|
import React, { useContext, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||||
import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries";
|
import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries";
|
||||||
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
|
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
|
||||||
import { setModalContext } from "../../redux/modals/modals.actions";
|
import { setModalContext } from "../../redux/modals/modals.actions";
|
||||||
@@ -51,7 +51,7 @@ export function ScheduleEventComponent({
|
|||||||
const searchParams = queryString.parse(useLocation().search);
|
const searchParams = queryString.parse(useLocation().search);
|
||||||
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT);
|
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT);
|
||||||
const [title, setTitle] = useState(event.title);
|
const [title, setTitle] = useState(event.title);
|
||||||
const { socket } = useSocket();
|
const { socket } = useContext(SocketContext);
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|
||||||
const blockContent = (
|
const blockContent = (
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ export function JobCloseRoGuardContainer({ job, jobRO, bodyshop, form }) {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
|
|
||||||
<Collapse.Panel key="job-performance" header={t("jobs.labels.performance")}>
|
<Collapse.Panel header={t("jobs.labels.performance")}>
|
||||||
<Row gutter={[32, 32]}>
|
<Row gutter={[32, 32]}>
|
||||||
<Col className="ro-guard-col" span={24}>
|
<Col className="ro-guard-col" span={24}>
|
||||||
<JobCloseRoGuardTtLifecycle job={job} />
|
<JobCloseRoGuardTtLifecycle job={job} />
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ import JobDetailCardsInsuranceComponent from "./job-detail-cards.insurance.compo
|
|||||||
import JobDetailCardsNotesComponent from "./job-detail-cards.notes.component";
|
import JobDetailCardsNotesComponent from "./job-detail-cards.notes.component";
|
||||||
import JobDetailCardsPartsComponent from "./job-detail-cards.parts.component";
|
import JobDetailCardsPartsComponent from "./job-detail-cards.parts.component";
|
||||||
import JobDetailCardsTotalsComponent from "./job-detail-cards.totals.component";
|
import JobDetailCardsTotalsComponent from "./job-detail-cards.totals.component";
|
||||||
import JobWatcherToggleContainer from "../job-watcher-toggle/job-watcher-toggle.container.jsx";
|
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop
|
||||||
@@ -39,7 +37,6 @@ const span = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function JobDetailCards({ bodyshop, setPrintCenterContext }) {
|
export function JobDetailCards({ bodyshop, setPrintCenterContext }) {
|
||||||
const { scenarioNotificationsOn } = useSocket();
|
|
||||||
const selectedBreakpoint = Object.entries(Grid.useBreakpoint())
|
const selectedBreakpoint = Object.entries(Grid.useBreakpoint())
|
||||||
.filter((screen) => !!screen[1])
|
.filter((screen) => !!screen[1])
|
||||||
.slice(-1)[0];
|
.slice(-1)[0];
|
||||||
@@ -81,12 +78,7 @@ export function JobDetailCards({ bodyshop, setPrintCenterContext }) {
|
|||||||
{data ? (
|
{data ? (
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
<Space>
|
<Link to={`/manage/jobs/${data.jobs_by_pk.id}`}>{data.jobs_by_pk.ro_number || t("general.labels.na")}</Link>
|
||||||
{scenarioNotificationsOn && <JobWatcherToggleContainer job={data.jobs_by_pk} />}
|
|
||||||
<Link to={`/manage/jobs/${data.jobs_by_pk.id}`}>
|
|
||||||
{data.jobs_by_pk.ro_number || t("general.labels.na")}
|
|
||||||
</Link>
|
|
||||||
</Space>
|
|
||||||
}
|
}
|
||||||
extra={
|
extra={
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
@@ -130,11 +122,7 @@ export function JobDetailCards({ bodyshop, setPrintCenterContext }) {
|
|||||||
</Col>
|
</Col>
|
||||||
{!bodyshop.uselocalmediaserver && (
|
{!bodyshop.uselocalmediaserver && (
|
||||||
<Col {...span}>
|
<Col {...span}>
|
||||||
<JobDetailCardsDocumentsComponent
|
<JobDetailCardsDocumentsComponent loading={loading} data={data ? data.jobs_by_pk : null} bodyshop={bodyshop} />
|
||||||
loading={loading}
|
|
||||||
data={data ? data.jobs_by_pk : null}
|
|
||||||
bodyshop={bodyshop}
|
|
||||||
/>
|
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
<Col {...span}>
|
<Col {...span}>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function JobsTotalsTableComponent({ jobRO, currentUser, job }) {
|
|||||||
<Card title="DEVELOPMENT USE ONLY">
|
<Card title="DEVELOPMENT USE ONLY">
|
||||||
<JobCalculateTotals job={job} disabled={jobRO} />
|
<JobCalculateTotals job={job} disabled={jobRO} />
|
||||||
<Collapse>
|
<Collapse>
|
||||||
<Collapse.Panel key="json-totals" header="JSON Tree Totals">
|
<Collapse.Panel header="JSON Tree Totals">
|
||||||
<div>
|
<div>
|
||||||
<pre>
|
<pre>
|
||||||
{JSON.stringify(
|
{JSON.stringify(
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { EyeFilled, EyeOutlined, UserOutlined } from "@ant-design/icons";
|
|
||||||
import { Avatar, Button, Divider, List, Popover, Select, Tooltip, Typography } from "antd";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import EmployeeSearchSelectComponent from "../../components/employee-search-select/employee-search-select.component.jsx";
|
|
||||||
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component.jsx";
|
|
||||||
import { BiSolidTrash } from "react-icons/bi";
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
export default function JobWatcherToggleComponent({
|
|
||||||
jobWatchers,
|
|
||||||
isWatching,
|
|
||||||
watcherLoading,
|
|
||||||
adding,
|
|
||||||
removing,
|
|
||||||
open,
|
|
||||||
setOpen,
|
|
||||||
selectedWatcher,
|
|
||||||
setSelectedWatcher,
|
|
||||||
selectedTeam,
|
|
||||||
bodyshop,
|
|
||||||
Enhanced_Payroll,
|
|
||||||
handleToggleSelf,
|
|
||||||
handleRemoveWatcher,
|
|
||||||
handleWatcherSelect,
|
|
||||||
handleTeamSelect
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const handleRenderItem = (watcher) => {
|
|
||||||
// Check if watcher is defined and has user_email
|
|
||||||
if (!watcher || !watcher.user_email) {
|
|
||||||
return null; // Skip rendering invalid watchers
|
|
||||||
}
|
|
||||||
|
|
||||||
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="default"
|
|
||||||
danger
|
|
||||||
size="medium"
|
|
||||||
icon={<BiSolidTrash />}
|
|
||||||
onClick={() => handleRemoveWatcher(watcher.user_email)}
|
|
||||||
disabled={adding || removing} // Optional: Disable button during mutations
|
|
||||||
>
|
|
||||||
{t("notifications.actions.remove")}
|
|
||||||
</Button>
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<List.Item.Meta
|
|
||||||
avatar={<Avatar icon={<UserOutlined />} />}
|
|
||||||
title={<Text>{displayName}</Text>}
|
|
||||||
description={watcher.user_email}
|
|
||||||
/>
|
|
||||||
</List.Item>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const popoverContent = (
|
|
||||||
<div style={{ width: "30em" }}>
|
|
||||||
<List>
|
|
||||||
<List.Item
|
|
||||||
actions={[
|
|
||||||
<Button
|
|
||||||
type={isWatching ? "primary" : "default"}
|
|
||||||
danger={!isWatching}
|
|
||||||
icon={isWatching ? <EyeOutlined /> : <EyeFilled />}
|
|
||||||
size="medium"
|
|
||||||
onClick={handleToggleSelf}
|
|
||||||
loading={adding || removing}
|
|
||||||
>
|
|
||||||
{isWatching ? t("notifications.labels.unwatch") : t("notifications.labels.watch")}
|
|
||||||
</Button>
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<List.Item.Meta>
|
|
||||||
<Text type="secondary" style={{ marginBottom: 8, display: "block" }}>
|
|
||||||
{t("notifications.labels.watching-issue")}
|
|
||||||
</Text>
|
|
||||||
</List.Item.Meta>
|
|
||||||
</List.Item>
|
|
||||||
</List>
|
|
||||||
{watcherLoading ? (
|
|
||||||
<LoadingSpinner />
|
|
||||||
) : jobWatchers && jobWatchers.length > 0 ? (
|
|
||||||
<List dataSource={jobWatchers} renderItem={handleRenderItem} />
|
|
||||||
) : (
|
|
||||||
<Text type="secondary">{t("notifications.labels.no-watchers")}</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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 && e.active && e.user_email)
|
|
||||||
) || []
|
|
||||||
}
|
|
||||||
placeholder={t("notifications.labels.employee-search")}
|
|
||||||
value={selectedWatcher}
|
|
||||||
onChange={(value) => {
|
|
||||||
setSelectedWatcher(value);
|
|
||||||
handleWatcherSelect(value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{Enhanced_Payroll && 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}
|
|
||||||
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?.user_email && employee?.active ? employee.user_email : null;
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
return {
|
|
||||||
value: JSON.stringify(teamMembers),
|
|
||||||
label: team.name
|
|
||||||
};
|
|
||||||
}) || []
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover placement="rightTop" content={popoverContent} trigger="click" open={open} onOpenChange={setOpen}>
|
|
||||||
<Tooltip title={t("notifications.tooltips.job-watchers")}>
|
|
||||||
<Button
|
|
||||||
shape="circle"
|
|
||||||
type={isWatching ? "primary" : "default"}
|
|
||||||
icon={isWatching ? <EyeFilled /> : <EyeOutlined />}
|
|
||||||
loading={watcherLoading}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useMutation, useQuery } from "@apollo/client";
|
|
||||||
import { ADD_JOB_WATCHER, GET_JOB_WATCHERS, REMOVE_JOB_WATCHER } from "../../graphql/jobs.queries.js";
|
|
||||||
import { connect } from "react-redux";
|
|
||||||
import { createStructuredSelector } from "reselect";
|
|
||||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
|
|
||||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
|
||||||
import JobWatcherToggleComponent from "./job-watcher-toggle.component.jsx";
|
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
|
||||||
bodyshop: selectBodyshop,
|
|
||||||
currentUser: selectCurrentUser
|
|
||||||
});
|
|
||||||
|
|
||||||
function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
|
|
||||||
const {
|
|
||||||
treatments: { Enhanced_Payroll }
|
|
||||||
} = useSplitTreatments({
|
|
||||||
attributes: {},
|
|
||||||
names: ["Enhanced_Payroll"],
|
|
||||||
splitKey: bodyshop && bodyshop.imexshopid
|
|
||||||
});
|
|
||||||
|
|
||||||
const userEmail = currentUser.email;
|
|
||||||
const jobid = job.id;
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [selectedWatcher, setSelectedWatcher] = useState(null);
|
|
||||||
const [selectedTeam, setSelectedTeam] = useState(null);
|
|
||||||
|
|
||||||
// Fetch current watchers with refetch capability
|
|
||||||
const {
|
|
||||||
data: watcherData,
|
|
||||||
loading: watcherLoading,
|
|
||||||
refetch
|
|
||||||
} = useQuery(GET_JOB_WATCHERS, {
|
|
||||||
variables: { jobid },
|
|
||||||
fetchPolicy: "cache-and-network" // Ensure fresh data from server
|
|
||||||
});
|
|
||||||
|
|
||||||
// Refetch jobWatchers when the popover opens (open changes to true)
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
refetch().catch((err) =>
|
|
||||||
console.error(`Something went wrong fetching Notification Watchers on popover open: ${err?.message}`, {
|
|
||||||
stack: err?.stack
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [open, refetch]);
|
|
||||||
|
|
||||||
const jobWatchers = useMemo(() => (watcherData?.job_watchers ? [...watcherData.job_watchers] : []), [watcherData]);
|
|
||||||
const isWatching = jobWatchers.some((w) => w.user_email === userEmail);
|
|
||||||
|
|
||||||
const [addWatcher, { loading: adding }] = useMutation(ADD_JOB_WATCHER, {
|
|
||||||
onCompleted: () =>
|
|
||||||
refetch().catch((err) =>
|
|
||||||
console.error(`Something went wrong fetching Notification Watchers after add: ${err?.message}`, {
|
|
||||||
stack: err?.stack
|
|
||||||
})
|
|
||||||
),
|
|
||||||
onError: (err) => {
|
|
||||||
if (err.graphQLErrors && err.graphQLErrors.length > 0) {
|
|
||||||
const errorMessage = err.graphQLErrors[0].message;
|
|
||||||
if (
|
|
||||||
errorMessage.includes("Uniqueness violation") ||
|
|
||||||
errorMessage.includes("idx_job_watchers_jobid_user_email_unique")
|
|
||||||
) {
|
|
||||||
console.warn("Watcher already exists for this job and user.");
|
|
||||||
refetch().catch((err) =>
|
|
||||||
console.error(
|
|
||||||
`Something went wrong fetching Notification Watchers after uniqueness violation: ${err?.message}`,
|
|
||||||
{ stack: err?.stack }
|
|
||||||
)
|
|
||||||
); // Sync with server to ensure UI reflects actual state
|
|
||||||
} else {
|
|
||||||
console.error(`Error adding job watcher: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error(`Unexpected error adding job watcher: ${err.message || JSON.stringify(err)}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
update(cache, { data }) {
|
|
||||||
if (!data || !data.insert_job_watchers_one) {
|
|
||||||
console.warn("No data or insert_job_watchers_one returned from mutation, skipping cache update.");
|
|
||||||
refetch().catch((err) =>
|
|
||||||
console.error(`Something went wrong updating Notification Watchers after add: ${err?.message}`, {
|
|
||||||
stack: err?.stack
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const insert_job_watchers_one = data.insert_job_watchers_one;
|
|
||||||
const existingData = cache.readQuery({
|
|
||||||
query: GET_JOB_WATCHERS,
|
|
||||||
variables: { jobid }
|
|
||||||
});
|
|
||||||
|
|
||||||
cache.writeQuery({
|
|
||||||
query: GET_JOB_WATCHERS,
|
|
||||||
variables: { jobid },
|
|
||||||
data: {
|
|
||||||
...existingData,
|
|
||||||
job_watchers: [...(existingData?.job_watchers || []), insert_job_watchers_one]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const [removeWatcher, { loading: removing }] = useMutation(REMOVE_JOB_WATCHER, {
|
|
||||||
onCompleted: () =>
|
|
||||||
refetch().catch((err) =>
|
|
||||||
console.error(`Something went wrong fetching Notification Watchers after remove: ${err?.message}`, {
|
|
||||||
stack: err?.stack
|
|
||||||
})
|
|
||||||
), // Refetch to sync with server after success
|
|
||||||
onError: (err) => console.error(`Error removing job watcher: ${err.message}`),
|
|
||||||
update(cache, { data: { delete_job_watchers } }) {
|
|
||||||
const existingData = cache.readQuery({
|
|
||||||
query: GET_JOB_WATCHERS,
|
|
||||||
variables: { jobid }
|
|
||||||
});
|
|
||||||
|
|
||||||
const deletedWatcher = delete_job_watchers.returning[0];
|
|
||||||
const updatedWatchers = deletedWatcher
|
|
||||||
? (existingData?.job_watchers || []).filter((watcher) => watcher.user_email !== deletedWatcher.user_email)
|
|
||||||
: existingData?.job_watchers || [];
|
|
||||||
|
|
||||||
cache.writeQuery({
|
|
||||||
query: GET_JOB_WATCHERS,
|
|
||||||
variables: { jobid },
|
|
||||||
data: {
|
|
||||||
...existingData,
|
|
||||||
job_watchers: updatedWatchers
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleToggleSelf = useCallback(async () => {
|
|
||||||
if (adding || removing) return;
|
|
||||||
if (isWatching) {
|
|
||||||
await removeWatcher({ variables: { jobid, userEmail } });
|
|
||||||
} else {
|
|
||||||
await addWatcher({ variables: { jobid, userEmail } });
|
|
||||||
}
|
|
||||||
}, [isWatching, addWatcher, removeWatcher, jobid, userEmail, adding, removing]);
|
|
||||||
|
|
||||||
const handleRemoveWatcher = useCallback(
|
|
||||||
async (email) => {
|
|
||||||
if (removing) return;
|
|
||||||
await removeWatcher({ variables: { jobid, userEmail: email } });
|
|
||||||
},
|
|
||||||
[removeWatcher, jobid, removing]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleWatcherSelect = useCallback(
|
|
||||||
async (selectedUser) => {
|
|
||||||
if (adding || removing) return;
|
|
||||||
const employee = bodyshop.employees.find((e) => e.id === selectedUser);
|
|
||||||
if (!employee) return;
|
|
||||||
|
|
||||||
const email = employee.user_email;
|
|
||||||
const isAlreadyWatching = jobWatchers.some((w) => w.user_email === email);
|
|
||||||
|
|
||||||
if (isAlreadyWatching) {
|
|
||||||
await handleRemoveWatcher(email);
|
|
||||||
} else {
|
|
||||||
await addWatcher({ variables: { jobid, userEmail: email } });
|
|
||||||
}
|
|
||||||
setSelectedWatcher(null);
|
|
||||||
},
|
|
||||||
[jobWatchers, addWatcher, handleRemoveWatcher, jobid, bodyshop, adding, removing]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTeamSelect = useCallback(
|
|
||||||
async (team) => {
|
|
||||||
if (adding) return;
|
|
||||||
const selectedTeamMembers = JSON.parse(team);
|
|
||||||
const newWatchers = selectedTeamMembers.filter(
|
|
||||||
(email) => !jobWatchers.some((watcher) => watcher.user_email === email)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (newWatchers.length === 0) {
|
|
||||||
console.warn("All selected team members are already watchers.");
|
|
||||||
setSelectedTeam(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await Promise.all(newWatchers.map((email) => addWatcher({ variables: { jobid, userEmail: email } })));
|
|
||||||
},
|
|
||||||
[jobWatchers, addWatcher, jobid, adding]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<JobWatcherToggleComponent
|
|
||||||
jobWatchers={jobWatchers}
|
|
||||||
isWatching={isWatching}
|
|
||||||
watcherLoading={watcherLoading}
|
|
||||||
adding={adding}
|
|
||||||
removing={removing}
|
|
||||||
open={open}
|
|
||||||
setOpen={setOpen}
|
|
||||||
selectedWatcher={selectedWatcher}
|
|
||||||
setSelectedWatcher={setSelectedWatcher}
|
|
||||||
selectedTeam={selectedTeam}
|
|
||||||
setSelectedTeam={setSelectedTeam}
|
|
||||||
bodyshop={bodyshop}
|
|
||||||
Enhanced_Payroll={Enhanced_Payroll}
|
|
||||||
handleToggleSelf={handleToggleSelf}
|
|
||||||
handleRemoveWatcher={handleRemoveWatcher}
|
|
||||||
handleWatcherSelect={handleWatcherSelect}
|
|
||||||
handleTeamSelect={handleTeamSelect}
|
|
||||||
currentUser={currentUser}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(JobWatcherToggleContainer);
|
|
||||||
@@ -4,12 +4,12 @@ import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
|||||||
import { Button, Card, Dropdown, Form, Input, Modal, Popconfirm, Popover, Select, Space } from "antd";
|
import { Button, Card, Dropdown, Form, Input, Modal, Popconfirm, Popover, Select, Space } from "antd";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import parsePhoneNumber from "libphonenumber-js";
|
import parsePhoneNumber from "libphonenumber-js";
|
||||||
import { useMemo, useState } from "react";
|
import { useContext, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||||
import { auth, logImEXEvent } from "../../firebase/firebase.utils";
|
import { auth, logImEXEvent } from "../../firebase/firebase.utils";
|
||||||
import { CANCEL_APPOINTMENTS_BY_JOB_ID, INSERT_MANUAL_APPT } from "../../graphql/appointments.queries";
|
import { CANCEL_APPOINTMENTS_BY_JOB_ID, INSERT_MANUAL_APPT } from "../../graphql/appointments.queries";
|
||||||
import { GET_CURRENT_QUESTIONSET_ID, INSERT_CSI } from "../../graphql/csi.queries";
|
import { GET_CURRENT_QUESTIONSET_ID, INSERT_CSI } from "../../graphql/csi.queries";
|
||||||
@@ -28,10 +28,11 @@ import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
|||||||
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
|
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
|
||||||
import LockerWrapperComponent from "../lock-wrapper/lock-wrapper.component";
|
import LockerWrapperComponent from "../lock-wrapper/lock-wrapper.component";
|
||||||
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
|
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
|
||||||
import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
|
|
||||||
import AddToProduction from "./jobs-detail-header-actions.addtoproduction.util";
|
import AddToProduction from "./jobs-detail-header-actions.addtoproduction.util";
|
||||||
import DuplicateJob from "./jobs-detail-header-actions.duplicate.util";
|
import DuplicateJob from "./jobs-detail-header-actions.duplicate.util";
|
||||||
import JobsDetailHeaderActionsToggleProduction from "./jobs-detail-header-actions.toggle-production";
|
import JobsDetailHeaderActionsToggleProduction from "./jobs-detail-header-actions.toggle-production";
|
||||||
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop,
|
bodyshop: selectBodyshop,
|
||||||
@@ -129,7 +130,7 @@ export function JobsDetailHeaderActions({
|
|||||||
const [updateJob] = useMutation(UPDATE_JOB);
|
const [updateJob] = useMutation(UPDATE_JOB);
|
||||||
const [voidJob] = useMutation(VOID_JOB);
|
const [voidJob] = useMutation(VOID_JOB);
|
||||||
const [cancelAllAppointments] = useMutation(CANCEL_APPOINTMENTS_BY_JOB_ID);
|
const [cancelAllAppointments] = useMutation(CANCEL_APPOINTMENTS_BY_JOB_ID);
|
||||||
const { socket } = useSocket();
|
const { socket } = useContext(SocketContext);
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -774,14 +775,15 @@ export function JobsDetailHeaderActions({
|
|||||||
key: "enterpayments",
|
key: "enterpayments",
|
||||||
id: "job-actions-enterpayments",
|
id: "job-actions-enterpayments",
|
||||||
disabled: !job.converted,
|
disabled: !job.converted,
|
||||||
label: t("menus.header.enterpayment"),
|
label: <LockerWrapperComponent featureName="payments">{t("menus.header.enterpayment")}</LockerWrapperComponent>,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
logImEXEvent("job_header_enter_payment");
|
logImEXEvent("job_header_enter_payment");
|
||||||
|
|
||||||
setPaymentContext({
|
HasFeatureAccess({ featureName: "payments", bodyshop }) &&
|
||||||
actions: {},
|
setPaymentContext({
|
||||||
context: { jobid: job.id }
|
actions: {},
|
||||||
});
|
context: { jobid: job.id }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
|
|||||||
<DataLabel label={t("jobs.labels.contracts")}>
|
<DataLabel label={t("jobs.labels.contracts")}>
|
||||||
{job.cccontracts.map((c, index) => (
|
{job.cccontracts.map((c, index) => (
|
||||||
<Space key={c.id} wrap>
|
<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}`}
|
{`${c.agreementnumber} - ${c.courtesycar.fleetnumber} ${c.courtesycar.year} ${c.courtesycar.make} ${c.courtesycar.model}`}
|
||||||
{index !== job.cccontracts.length - 1 ? "," : null}
|
{index !== job.cccontracts.length - 1 ? "," : null}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
import { Virtuoso } from "react-virtuoso";
|
|
||||||
import { Badge, Button, Space, Spin, Switch, Tooltip, Typography } from "antd";
|
|
||||||
import { CheckCircleFilled, CheckCircleOutlined, EyeFilled, EyeOutlined } from "@ant-design/icons";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import "./notification-center.styles.scss";
|
|
||||||
import day from "../../utils/day.js";
|
|
||||||
import { forwardRef, useRef, useEffect } from "react";
|
|
||||||
import { DateTimeFormat } from "../../utils/DateFormatter.jsx";
|
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Notification Center Component
|
|
||||||
* @type {React.ForwardRefExoticComponent<React.PropsWithoutRef<{readonly visible?: *, readonly onClose?: *, readonly notifications?: *, readonly loading?: *, readonly showUnreadOnly?: *, readonly toggleUnreadOnly?: *, readonly markAllRead?: *, readonly loadMore?: *, readonly onNotificationClick?: *, readonly unreadCount?: *}> & React.RefAttributes<unknown>>}
|
|
||||||
*/
|
|
||||||
const NotificationCenterComponent = forwardRef(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
notifications,
|
|
||||||
loading,
|
|
||||||
showUnreadOnly,
|
|
||||||
toggleUnreadOnly,
|
|
||||||
markAllRead,
|
|
||||||
loadMore,
|
|
||||||
onNotificationClick,
|
|
||||||
unreadCount
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const virtuosoRef = useRef(null);
|
|
||||||
|
|
||||||
// Scroll to top when showUnreadOnly changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (virtuosoRef.current) {
|
|
||||||
virtuosoRef.current.scrollToIndex({ index: 0, behavior: "smooth" });
|
|
||||||
}
|
|
||||||
}, [showUnreadOnly]);
|
|
||||||
|
|
||||||
const renderNotification = (index, notification) => {
|
|
||||||
const handleClick = () => {
|
|
||||||
if (!notification.read) {
|
|
||||||
onNotificationClick(notification.id);
|
|
||||||
}
|
|
||||||
navigate(`/manage/jobs/${notification.jobid}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${notification.id}-${index}`}
|
|
||||||
className={`notification-item ${notification.read ? "notification-read" : "notification-unread"}`}
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
<Badge dot={!notification.read}>
|
|
||||||
<div className="notification-content">
|
|
||||||
<Title level={5} className="notification-title">
|
|
||||||
<span className="ro-number">
|
|
||||||
{t("notifications.labels.ro-number", { ro_number: notification.roNumber || t("general.labels.na") })}
|
|
||||||
</span>
|
|
||||||
<Text type="secondary" className="relative-time" title={DateTimeFormat(notification.created_at)}>
|
|
||||||
{day(notification.created_at).fromNow()}
|
|
||||||
</Text>
|
|
||||||
</Title>
|
|
||||||
<Text strong={!notification.read} className="notification-body">
|
|
||||||
<ul>
|
|
||||||
{notification.scenarioText.map((text, idx) => (
|
|
||||||
<li key={`${notification.id}-${idx}`}>{text}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`notification-center ${visible ? "visible" : ""}`} ref={ref}>
|
|
||||||
<div className="notification-header">
|
|
||||||
<Space direction="horizontal">
|
|
||||||
<h3>{t("notifications.labels.notification-center")}</h3>
|
|
||||||
{loading && <Spin spinning={loading} size="small"></Spin>}
|
|
||||||
</Space>
|
|
||||||
<div className="notification-controls">
|
|
||||||
<Tooltip title={t("notifications.labels.show-unread-only")}>
|
|
||||||
<Space size={4} align="center" className="notification-toggle">
|
|
||||||
{showUnreadOnly ? (
|
|
||||||
<EyeFilled className="notification-toggle-icon" />
|
|
||||||
) : (
|
|
||||||
<EyeOutlined className="notification-toggle-icon" />
|
|
||||||
)}
|
|
||||||
<Switch checked={showUnreadOnly} onChange={(checked) => toggleUnreadOnly(checked)} size="small" />
|
|
||||||
</Space>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={t("notifications.labels.mark-all-read")}>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
icon={!unreadCount ? <CheckCircleFilled /> : <CheckCircleOutlined />}
|
|
||||||
onClick={markAllRead}
|
|
||||||
disabled={!unreadCount}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Virtuoso
|
|
||||||
ref={virtuosoRef}
|
|
||||||
style={{ height: "400px", width: "100%" }}
|
|
||||||
data={notifications}
|
|
||||||
totalCount={notifications.length}
|
|
||||||
endReached={loadMore}
|
|
||||||
itemContent={renderNotification}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default NotificationCenterComponent;
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, 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/useSocket.jsx";
|
|
||||||
import { createStructuredSelector } from "reselect";
|
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
|
||||||
import day from "../../utils/day.js";
|
|
||||||
|
|
||||||
// This will be used to poll for notifications when the socket is disconnected
|
|
||||||
const NOTIFICATION_POLL_INTERVAL_SECONDS = 60;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Notification Center Container
|
|
||||||
* @param visible
|
|
||||||
* @param onClose
|
|
||||||
* @param bodyshop
|
|
||||||
* @param unreadCount
|
|
||||||
* @returns {JSX.Element}
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }) => {
|
|
||||||
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
|
|
||||||
const [notifications, setNotifications] = useState([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const { isConnected, markNotificationRead, markAllNotificationsRead } = useSocket();
|
|
||||||
const notificationRef = useRef(null);
|
|
||||||
|
|
||||||
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: queryLoading,
|
|
||||||
refetch
|
|
||||||
} = useQuery(GET_NOTIFICATIONS, {
|
|
||||||
variables: {
|
|
||||||
limit: INITIAL_NOTIFICATIONS,
|
|
||||||
offset: 0,
|
|
||||||
where: whereClause
|
|
||||||
},
|
|
||||||
fetchPolicy: "cache-and-network",
|
|
||||||
notifyOnNetworkStatusChange: true,
|
|
||||||
pollInterval: isConnected ? 0 : day.duration(NOTIFICATION_POLL_INTERVAL_SECONDS, "seconds").asMilliseconds(),
|
|
||||||
skip: !userAssociationId,
|
|
||||||
onError: (err) => {
|
|
||||||
console.error(`Error polling Notifications: ${err?.message || ""}`);
|
|
||||||
setTimeout(() => refetch(), day.duration(2, "seconds").asMilliseconds());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event) => {
|
|
||||||
// Prevent open + close behavior from the header
|
|
||||||
if (event.target.closest("#header-notifications")) return;
|
|
||||||
if (visible && notificationRef.current && !notificationRef.current.contains(event.target)) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}, [visible, onClose]);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
|
||||||
if (!queryLoading && data?.notifications.length) {
|
|
||||||
setIsLoading(true); // Show spinner during fetchMore
|
|
||||||
fetchMore({
|
|
||||||
variables: { offset: data.notifications.length, where: whereClause },
|
|
||||||
updateQuery: (prev, { fetchMoreResult }) => {
|
|
||||||
if (!fetchMoreResult) return prev;
|
|
||||||
return {
|
|
||||||
notifications: [...prev.notifications, ...fetchMoreResult.notifications]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error("Fetch more error:", err);
|
|
||||||
})
|
|
||||||
.finally(() => setIsLoading(false)); // Hide spinner when done
|
|
||||||
}
|
|
||||||
}, [data?.notifications?.length, fetchMore, queryLoading, whereClause]);
|
|
||||||
|
|
||||||
const handleToggleUnreadOnly = (value) => {
|
|
||||||
setShowUnreadOnly(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMarkAllRead = useCallback(() => {
|
|
||||||
setIsLoading(true);
|
|
||||||
markAllNotificationsRead()
|
|
||||||
.then(() => {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
setNotifications((prev) => {
|
|
||||||
const updatedNotifications = prev.map((notif) =>
|
|
||||||
notif.read === null && notif.associationid === userAssociationId
|
|
||||||
? {
|
|
||||||
...notif,
|
|
||||||
read: timestamp
|
|
||||||
}
|
|
||||||
: notif
|
|
||||||
);
|
|
||||||
// Filter out read notifications if in unread only mode
|
|
||||||
return showUnreadOnly ? updatedNotifications.filter((notif) => !notif.read) : updatedNotifications;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((e) => console.error(`Error marking all notifications read: ${e?.message || ""}`))
|
|
||||||
.finally(() => setIsLoading(false));
|
|
||||||
}, [markAllNotificationsRead, userAssociationId, showUnreadOnly]);
|
|
||||||
|
|
||||||
const handleNotificationClick = useCallback(
|
|
||||||
(notificationId) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
markNotificationRead({ variables: { id: notificationId } })
|
|
||||||
.then(() => {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
setNotifications((prev) => {
|
|
||||||
const updatedNotifications = prev.map((notif) =>
|
|
||||||
notif.id === notificationId && !notif.read ? { ...notif, read: timestamp } : notif
|
|
||||||
);
|
|
||||||
// Filter out the read notification if in unread only mode
|
|
||||||
return showUnreadOnly ? updatedNotifications.filter((notif) => !notif.read) : updatedNotifications;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`))
|
|
||||||
.finally(() => setIsLoading(false));
|
|
||||||
},
|
|
||||||
[markNotificationRead, showUnreadOnly]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible && !isConnected) {
|
|
||||||
setIsLoading(true);
|
|
||||||
refetch()
|
|
||||||
.catch((err) => console.error(`Error re-fetching notifications: ${err?.message || ""}`))
|
|
||||||
.finally(() => setIsLoading(false));
|
|
||||||
}
|
|
||||||
}, [visible, isConnected, refetch]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NotificationCenterComponent
|
|
||||||
ref={notificationRef}
|
|
||||||
visible={visible}
|
|
||||||
onClose={onClose}
|
|
||||||
notifications={notifications}
|
|
||||||
loading={isLoading}
|
|
||||||
showUnreadOnly={showUnreadOnly}
|
|
||||||
toggleUnreadOnly={handleToggleUnreadOnly}
|
|
||||||
markAllRead={handleMarkAllRead}
|
|
||||||
loadMore={loadMore}
|
|
||||||
onNotificationClick={handleNotificationClick}
|
|
||||||
unreadCount={unreadCount}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
|
||||||
bodyshop: selectBodyshop
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, null)(NotificationCenterContainer);
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
.notification-center {
|
|
||||||
position: absolute;
|
|
||||||
top: 64px;
|
|
||||||
right: 0;
|
|
||||||
width: 400px;
|
|
||||||
max-width: 400px;
|
|
||||||
background: #fff;
|
|
||||||
color: rgba(0, 0, 0, 0.85);
|
|
||||||
border: 1px solid #d9d9d9;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08), 0 3px 6px rgba(0, 0, 0, 0.06);
|
|
||||||
z-index: 1000;
|
|
||||||
display: none;
|
|
||||||
overflow-x: hidden;
|
|
||||||
|
|
||||||
&.visible {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-header {
|
|
||||||
padding: 4px 16px;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
background: #fafafa;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
color: rgba(0, 0, 0, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
// Styles for the eye icon and switch (custom classes)
|
|
||||||
.notification-toggle {
|
|
||||||
align-items: center; // Ensure vertical alignment
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-toggle-icon {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #1677ff;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-switch {
|
|
||||||
&.ant-switch-small {
|
|
||||||
min-width: 28px;
|
|
||||||
height: 16px;
|
|
||||||
line-height: 16px;
|
|
||||||
|
|
||||||
.ant-switch-handle {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.ant-switch-checked {
|
|
||||||
background-color: #1677ff;
|
|
||||||
.ant-switch-handle {
|
|
||||||
left: calc(100% - 14px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Styles for the "Mark All Read" button (restore original link button style)
|
|
||||||
.ant-btn-link {
|
|
||||||
padding: 0;
|
|
||||||
color: #1677ff;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #69b1ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
color: rgba(0, 0, 0, 0.25);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
color: #0958d9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-read {
|
|
||||||
background: #fff;
|
|
||||||
color: rgba(0, 0, 0, 0.65);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-unread {
|
|
||||||
background: #f5f5f5;
|
|
||||||
color: rgba(0, 0, 0, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-item {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
display: block;
|
|
||||||
overflow: visible;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #fafafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-content {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-title {
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
.ro-number {
|
|
||||||
margin: 0;
|
|
||||||
color: #1677ff;
|
|
||||||
flex-shrink: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relative-time {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: rgba(0, 0, 0, 0.45);
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-body {
|
|
||||||
margin-top: 4px;
|
|
||||||
|
|
||||||
.ant-typography {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-badge {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-alert {
|
|
||||||
margin: 8px;
|
|
||||||
background: #fff1f0;
|
|
||||||
color: rgba(0, 0, 0, 0.85);
|
|
||||||
border: 1px solid #ffa39e;
|
|
||||||
|
|
||||||
.ant-alert-message {
|
|
||||||
color: #ff4d4f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
|
|
||||||
import { Checkbox, Form } from "antd";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import PropTypes from "prop-types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ColumnHeaderCheckbox
|
|
||||||
* @param channel
|
|
||||||
* @param form
|
|
||||||
* @param disabled
|
|
||||||
* @param onHeaderChange
|
|
||||||
* @returns {JSX.Element}
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ColumnHeaderCheckbox;
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import { useMutation, useQuery } from "@apollo/client";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Button, Card, Checkbox, Form, Space, 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";
|
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
|
||||||
import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Notifications Settings Form
|
|
||||||
* @param currentUser
|
|
||||||
* @returns {JSX.Element}
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
const NotificationSettingsForm = ({ currentUser }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
const [initialValues, setInitialValues] = useState({});
|
|
||||||
const [isDirty, setIsDirty] = useState(false);
|
|
||||||
const notification = useNotification();
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
const result = await updateNotificationSettings({ variables: { id: userId, ns: values } });
|
|
||||||
if (!result?.errors) {
|
|
||||||
notification.success({ message: t("notifications.labels.notification-settings-success") });
|
|
||||||
setInitialValues(values);
|
|
||||||
setIsDirty(false);
|
|
||||||
} else {
|
|
||||||
notification.error({ message: t("notifications.labels.notification-settings-failure") });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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={
|
|
||||||
<Space>
|
|
||||||
<Button type="default" onClick={handleReset} disabled={!isDirty}>
|
|
||||||
{t("general.actions.clear")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="primary" htmlType="submit" disabled={!isDirty} loading={saving}>
|
|
||||||
{t("notifications.labels.save")}
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<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);
|
|
||||||
@@ -2,15 +2,15 @@ import { CopyFilled } from "@ant-design/icons";
|
|||||||
import { Button, Form, message, Popover, Space } from "antd";
|
import { Button, Form, message, Popover, Space } from "antd";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import Dinero from "dinero.js";
|
import Dinero from "dinero.js";
|
||||||
import { parsePhoneNumberWithError, ParseError } from "libphonenumber-js";
|
import { parsePhoneNumber } from "libphonenumber-js";
|
||||||
import React, { useState } from "react";
|
import React, { useContext, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
|
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
|
||||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||||
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
|
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop,
|
bodyshop: selectBodyshop,
|
||||||
@@ -29,34 +29,22 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [paymentLink, setPaymentLink] = useState(null);
|
const [paymentLink, setPaymentLink] = useState(null);
|
||||||
const { socket } = useSocket();
|
const { socket } = useContext(SocketContext);
|
||||||
|
|
||||||
const handleFinish = async ({ amount }) => {
|
const handleFinish = async ({ amount }) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
let p;
|
let p;
|
||||||
try {
|
try {
|
||||||
// Updated to use parsePhoneNumberWithError
|
p = parsePhoneNumber(job.ownr_ph1 || "", "CA");
|
||||||
p = parsePhoneNumberWithError(job.ownr_ph1 || "", "CA");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ParseError) {
|
console.log("Unable to parse phone number");
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await axios.post("/intellipay/generate_payment_url", {
|
const response = await axios.post("/intellipay/generate_payment_url", {
|
||||||
bodyshop,
|
bodyshop,
|
||||||
amount: amount,
|
amount: amount,
|
||||||
account: job.ro_number,
|
account: job.ro_number,
|
||||||
comment: btoa(
|
comment: btoa(JSON.stringify({ payments: [{ jobid: job.id, amount }], userEmail: currentUser.email }))
|
||||||
JSON.stringify({
|
|
||||||
payments: [{ jobid: job.id, amount }],
|
|
||||||
userEmail: currentUser.email
|
|
||||||
})
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setPaymentLink(response.data.shorUrl);
|
setPaymentLink(response.data.shorUrl);
|
||||||
@@ -118,20 +106,7 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope
|
|||||||
</Space>
|
</Space>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
let p;
|
const p = parsePhoneNumber(job.ownr_ph1, "CA");
|
||||||
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;
|
|
||||||
}
|
|
||||||
openChatByPhone({
|
openChatByPhone({
|
||||||
phone_num: p.formatInternational(),
|
phone_num: p.formatInternational(),
|
||||||
jobid: job.id,
|
jobid: job.id,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useApolloClient } from "@apollo/client";
|
|||||||
import { Button, Skeleton, Space } from "antd";
|
import { Button, Skeleton, Space } from "antd";
|
||||||
import cloneDeep from "lodash/cloneDeep";
|
import cloneDeep from "lodash/cloneDeep";
|
||||||
import isEqual from "lodash/isEqual";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
|
|||||||
@@ -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 { useApolloClient, useQuery, useSubscription } from "@apollo/client";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
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 { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||||
import ProductionBoardKanbanComponent from "./production-board-kanban.component";
|
import ProductionBoardKanbanComponent from "./production-board-kanban.component";
|
||||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop,
|
bodyshop: selectBodyshop,
|
||||||
@@ -22,7 +22,7 @@ const mapStateToProps = createStructuredSelector({
|
|||||||
function ProductionBoardKanbanContainer({ bodyshop, currentUser, subscriptionType = "direct" }) {
|
function ProductionBoardKanbanContainer({ bodyshop, currentUser, subscriptionType = "direct" }) {
|
||||||
const fired = useRef(false);
|
const fired = useRef(false);
|
||||||
const client = useApolloClient();
|
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 reconnectTimeout = useRef(null); // To store the reconnect timeout
|
||||||
const disconnectTime = useRef(null); // To track disconnection time
|
const disconnectTime = useRef(null); // To track disconnection time
|
||||||
const acceptableReconnectTime = 2000; // 2 seconds threshold
|
const acceptableReconnectTime = 2000; // 2 seconds threshold
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ import ScoreboardAddButton from "../job-scoreboard-add-button/job-scoreboard-add
|
|||||||
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
|
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
|
||||||
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
|
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
|
||||||
import ProductionRemoveButton from "../production-remove-button/production-remove-button.component";
|
import ProductionRemoveButton from "../production-remove-button/production-remove-button.component";
|
||||||
import JobWatcherToggleContainer from "../job-watcher-toggle/job-watcher-toggle.container.jsx";
|
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop,
|
bodyshop: selectBodyshop,
|
||||||
@@ -43,7 +41,6 @@ export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, te
|
|||||||
const search = queryString.parse(useLocation().search);
|
const search = queryString.parse(useLocation().search);
|
||||||
const history = useNavigate();
|
const history = useNavigate();
|
||||||
const { selected } = search;
|
const { selected } = search;
|
||||||
const { scenarioNotificationsOn } = useSocket();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const theJob = jobs.find((j) => j.id === selected) || {};
|
const theJob = jobs.find((j) => j.id === selected) || {};
|
||||||
@@ -63,12 +60,7 @@ export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, te
|
|||||||
<Drawer
|
<Drawer
|
||||||
title={
|
title={
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={
|
title={theJob.ro_number}
|
||||||
<Space>
|
|
||||||
{!technician && scenarioNotificationsOn && <JobWatcherToggleContainer job={theJob} />}
|
|
||||||
{theJob.ro_number}
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
extra={
|
extra={
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
{!technician ? <ProductionRemoveButton jobId={theJob.id} /> : null}
|
{!technician ? <ProductionRemoveButton jobId={theJob.id} /> : null}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useApolloClient, useQuery, useSubscription } from "@apollo/client";
|
import { useApolloClient, useQuery, useSubscription } from "@apollo/client";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import React, { useContext, useEffect, useState, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
QUERY_EXACT_JOB_IN_PRODUCTION,
|
QUERY_EXACT_JOB_IN_PRODUCTION,
|
||||||
QUERY_EXACT_JOBS_IN_PRODUCTION,
|
QUERY_EXACT_JOBS_IN_PRODUCTION,
|
||||||
@@ -10,11 +10,11 @@ import {
|
|||||||
import ProductionListTable from "./production-list-table.component";
|
import ProductionListTable from "./production-list-table.component";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||||
|
|
||||||
export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) {
|
export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) {
|
||||||
const client = useApolloClient();
|
const client = useApolloClient();
|
||||||
const { socket } = useSocket();
|
const { socket } = useContext(SocketContext);
|
||||||
const [joblist, setJoblist] = useState([]);
|
const [joblist, setJoblist] = useState([]);
|
||||||
const reconnectTimeout = useRef(null); // To store the reconnect timeout
|
const reconnectTimeout = useRef(null); // To store the reconnect timeout
|
||||||
const disconnectTime = useRef(null); // To store the time of disconnection
|
const disconnectTime = useRef(null); // To store the time of disconnection
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function ProductionRemoveButton({ jobId }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button loading={loading} onClick={handleRemoveFromProd} type="default" danger>
|
<Button loading={loading} onClick={handleRemoveFromProd} type={"danger"}>
|
||||||
{t("production.actions.remove")}
|
{t("production.actions.remove")}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Button, Card, Col, Form, Input } from "antd";
|
import { Button, Card, Col, Form, Input } from "antd";
|
||||||
import { LockOutlined } from "@ant-design/icons";
|
import { LockOutlined } from "@ant-design/icons";
|
||||||
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
@@ -8,8 +9,6 @@ import { selectCurrentUser } from "../../redux/user/user.selectors";
|
|||||||
import { logImEXEvent, updateCurrentPassword } from "../../firebase/firebase.utils";
|
import { logImEXEvent, updateCurrentPassword } from "../../firebase/firebase.utils";
|
||||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
|
||||||
import NotificationSettingsForm from "../notification-settings/notification-settings-form.component.jsx";
|
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
currentUser: selectCurrentUser
|
currentUser: selectCurrentUser
|
||||||
@@ -23,7 +22,6 @@ export default connect(
|
|||||||
)(function ProfileMyComponent({ currentUser, updateUserDetails }) {
|
)(function ProfileMyComponent({ currentUser, updateUserDetails }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
const { scenarioNotificationsOn } = useSocket();
|
|
||||||
|
|
||||||
const handleFinish = (values) => {
|
const handleFinish = (values) => {
|
||||||
logImEXEvent("profile_update");
|
logImEXEvent("profile_update");
|
||||||
@@ -119,11 +117,6 @@ export default connect(
|
|||||||
</Card>
|
</Card>
|
||||||
</Form>
|
</Form>
|
||||||
</Col>
|
</Col>
|
||||||
{scenarioNotificationsOn && (
|
|
||||||
<Col span={24}>
|
|
||||||
<NotificationSettingsForm />
|
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { AlertOutlined } from "@ant-design/icons";
|
import { AlertOutlined } from "@ant-design/icons";
|
||||||
import { Alert, Button, Col, Row, Space } from "antd";
|
import { Alert, Button, Col, Row, Space } from "antd";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
@@ -81,7 +81,8 @@ export function UpdateAlert({ updateAvailable }) {
|
|||||||
imex: "$t(titles.imexonline)",
|
imex: "$t(titles.imexonline)",
|
||||||
rome: "$t(titles.romeonline)"
|
rome: "$t(titles.romeonline)"
|
||||||
})
|
})
|
||||||
})
|
}),
|
||||||
|
placement: "bottomRight"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (needRefresh && timerStarted && timeLeft <= 0) {
|
if (needRefresh && timerStarted && timeLeft <= 0) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createContext, useContext } from "react";
|
// NotificationProvider.jsx
|
||||||
|
import React, { createContext, useContext } from "react";
|
||||||
import { notification } from "antd";
|
import { notification } from "antd";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,11 +22,7 @@ export const useNotification = () => {
|
|||||||
* - Provide `api` via the NotificationContext.
|
* - Provide `api` via the NotificationContext.
|
||||||
*/
|
*/
|
||||||
export const NotificationProvider = ({ children }) => {
|
export const NotificationProvider = ({ children }) => {
|
||||||
const [api, contextHolder] = notification.useNotification({
|
const [api, contextHolder] = notification.useNotification();
|
||||||
placement: "bottomRight",
|
|
||||||
bottom: 70,
|
|
||||||
showProgress: true
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NotificationContext.Provider value={api}>
|
<NotificationContext.Provider value={api}>
|
||||||
|
|||||||
13
client/src/contexts/SocketIO/socketContext.jsx
Normal file
13
client/src/contexts/SocketIO/socketContext.jsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React, { createContext } from "react";
|
||||||
|
import useSocket from "./useSocket"; // Import the custom hook
|
||||||
|
|
||||||
|
// Create the SocketContext
|
||||||
|
const SocketContext = createContext(null);
|
||||||
|
|
||||||
|
export const SocketProvider = ({ children, bodyshop }) => {
|
||||||
|
const { socket, clientId } = useSocket(bodyshop);
|
||||||
|
|
||||||
|
return <SocketContext.Provider value={{ socket, clientId }}> {children}</SocketContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SocketContext;
|
||||||
125
client/src/contexts/SocketIO/useSocket.js
Normal file
125
client/src/contexts/SocketIO/useSocket.js
Normal 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;
|
||||||
@@ -1,492 +0,0 @@
|
|||||||
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,
|
|
||||||
UPDATE_NOTIFICATIONS_READ_FRAGMENT
|
|
||||||
} from "../../graphql/notifications.queries.js";
|
|
||||||
import { useMutation } from "@apollo/client";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
const SocketContext = createContext(null);
|
|
||||||
|
|
||||||
const INITIAL_NOTIFICATIONS = 10;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Socket Provider - Scenario Notifications / Web Socket related items
|
|
||||||
* @param children
|
|
||||||
* @param bodyshop
|
|
||||||
* @param navigate
|
|
||||||
* @param currentUser
|
|
||||||
* @param scenarioNotificationsOn
|
|
||||||
* @returns {JSX.Element}
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
const SocketProvider = ({ children, bodyshop, navigate, currentUser, scenarioNotificationsOn }) => {
|
|
||||||
const socketRef = useRef(null);
|
|
||||||
const [clientId, setClientId] = useState(null);
|
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
|
||||||
const notification = useNotification();
|
|
||||||
const userAssociationId = bodyshop?.associations?.[0]?.id;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [markNotificationRead] = useMutation(MARK_NOTIFICATION_READ, {
|
|
||||||
update: (cache, { data: { update_notifications } }) => {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
const updatedNotification = update_notifications.returning[0];
|
|
||||||
|
|
||||||
cache.modify({
|
|
||||||
fields: {
|
|
||||||
notifications(existing = [], { readField }) {
|
|
||||||
return existing.map((notif) =>
|
|
||||||
readField("id", notif) === updatedNotification.id
|
|
||||||
? {
|
|
||||||
...notif,
|
|
||||||
read: timestamp
|
|
||||||
}
|
|
||||||
: notif
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (socketRef.current && isConnected) {
|
|
||||||
socketRef.current.emit("sync-notification-read", {
|
|
||||||
email: currentUser?.email,
|
|
||||||
bodyshopId: bodyshop.id,
|
|
||||||
notificationId: updatedNotification.id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (err) =>
|
|
||||||
console.error("MARK_NOTIFICATION_READ error:", {
|
|
||||||
message: err?.message,
|
|
||||||
stack: err?.stack
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
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) =>
|
|
||||||
readField("read", notif) === null && readField("associationid", notif) === userAssociationId
|
|
||||||
? { ...notif, read: timestamp }
|
|
||||||
: 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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (socketRef.current && isConnected) {
|
|
||||||
socketRef.current.emit("sync-all-notifications-read", {
|
|
||||||
email: currentUser?.email,
|
|
||||||
bodyshopId: bodyshop.id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (err) => console.error("MARK_ALL_NOTIFICATIONS_READ error:", 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) => {
|
|
||||||
// Scenario Notifications have been disabled, bail.
|
|
||||||
if (!scenarioNotificationsOn) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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)) {
|
|
||||||
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 } }) {
|
|
||||||
return {
|
|
||||||
...existing,
|
|
||||||
aggregate: {
|
|
||||||
...existing.aggregate,
|
|
||||||
count: existing.aggregate.count + (newNotification.read === null ? 1 : 0)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
notification.info({
|
|
||||||
message: (
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
markNotificationRead({ variables: { id: notificationId } })
|
|
||||||
.then(() => navigate(`/manage/jobs/${jobId}`))
|
|
||||||
.catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("notifications.labels.notification-popup-title", {
|
|
||||||
ro_number: jobRoNumber || t("general.labels.na")
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
description: (
|
|
||||||
<ul
|
|
||||||
className="notification-alert-unordered-list"
|
|
||||||
onClick={() => {
|
|
||||||
markNotificationRead({ variables: { id: notificationId } })
|
|
||||||
.then(() => navigate(`/manage/jobs/${jobId}`))
|
|
||||||
.catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{notifications.map((notif, index) => (
|
|
||||||
<li className="notification-alert-unordered-list-item" key={index}>
|
|
||||||
{notif.body}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error handling new notification: ${error?.message || ""}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSyncNotificationRead = ({ notificationId, timestamp }) => {
|
|
||||||
// Scenario Notifications have been disabled, bail.
|
|
||||||
if (!scenarioNotificationsOn) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const notificationRef = client.cache.identify({
|
|
||||||
__typename: "notifications",
|
|
||||||
id: notificationId
|
|
||||||
});
|
|
||||||
client.cache.writeFragment({
|
|
||||||
id: notificationRef,
|
|
||||||
fragment: UPDATE_NOTIFICATIONS_READ_FRAGMENT,
|
|
||||||
data: { read: timestamp }
|
|
||||||
});
|
|
||||||
|
|
||||||
const unreadCountData = client.cache.readQuery({
|
|
||||||
query: GET_UNREAD_COUNT,
|
|
||||||
variables: { associationid: userAssociationId }
|
|
||||||
});
|
|
||||||
if (unreadCountData?.notifications_aggregate?.aggregate?.count > 0) {
|
|
||||||
const newCount = Math.max(unreadCountData.notifications_aggregate.aggregate.count - 1, 0);
|
|
||||||
client.cache.writeQuery({
|
|
||||||
query: GET_UNREAD_COUNT,
|
|
||||||
variables: { associationid: userAssociationId },
|
|
||||||
data: {
|
|
||||||
notifications_aggregate: {
|
|
||||||
__typename: "notifications_aggregate",
|
|
||||||
aggregate: {
|
|
||||||
__typename: "notifications_aggregate_fields",
|
|
||||||
count: newCount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error in handleSyncNotificationRead:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSyncAllNotificationsRead = ({ timestamp }) => {
|
|
||||||
// Scenario Notifications have been disabled, bail.
|
|
||||||
if (!scenarioNotificationsOn) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const queryVars = {
|
|
||||||
limit: INITIAL_NOTIFICATIONS,
|
|
||||||
offset: 0,
|
|
||||||
where: { associationid: { _eq: userAssociationId } }
|
|
||||||
};
|
|
||||||
const cachedData = client.cache.readQuery({
|
|
||||||
query: GET_NOTIFICATIONS,
|
|
||||||
variables: queryVars
|
|
||||||
});
|
|
||||||
|
|
||||||
if (cachedData?.notifications) {
|
|
||||||
cachedData.notifications.forEach((notif) => {
|
|
||||||
if (!notif.read) {
|
|
||||||
const notifRef = client.cache.identify({ __typename: "notifications", id: notif.id });
|
|
||||||
client.cache.writeFragment({
|
|
||||||
id: notifRef,
|
|
||||||
fragment: UPDATE_NOTIFICATIONS_READ_FRAGMENT,
|
|
||||||
data: { read: timestamp }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
client.cache.writeQuery({
|
|
||||||
query: GET_UNREAD_COUNT,
|
|
||||||
variables: { associationid: userAssociationId },
|
|
||||||
data: {
|
|
||||||
notifications_aggregate: {
|
|
||||||
__typename: "notifications_aggregate",
|
|
||||||
aggregate: {
|
|
||||||
__typename: "notifications_aggregate_fields",
|
|
||||||
count: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error In HandleSyncAllNotificationsRead: ${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("notification", handleNotification);
|
|
||||||
socketInstance.on("sync-notification-read", handleSyncNotificationRead);
|
|
||||||
socketInstance.on("sync-all-notifications-read", handleSyncAllNotificationsRead);
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
currentUser,
|
|
||||||
scenarioNotificationsOn,
|
|
||||||
t
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SocketContext.Provider
|
|
||||||
value={{
|
|
||||||
socket: socketRef.current,
|
|
||||||
clientId,
|
|
||||||
isConnected,
|
|
||||||
markNotificationRead,
|
|
||||||
markAllNotificationsRead,
|
|
||||||
scenarioNotificationsOn
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SocketContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const useSocket = () => {
|
|
||||||
const context = useContext(SocketContext);
|
|
||||||
// NOTE: Not sure if we absolutely require this, does cause slipups on dev env
|
|
||||||
if (!context) throw new Error("useSocket must be used within a SocketProvider");
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export { SocketContext, SocketProvider, INITIAL_NOTIFICATIONS, useSocket };
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|||||||
@@ -524,10 +524,6 @@ export const GET_JOB_BY_PK = gql`
|
|||||||
invoice_final_note
|
invoice_final_note
|
||||||
iouparent
|
iouparent
|
||||||
job_totals
|
job_totals
|
||||||
job_watchers {
|
|
||||||
id
|
|
||||||
user_email
|
|
||||||
}
|
|
||||||
joblines(where: { removed: { _eq: false } }, order_by: { line_no: asc }) {
|
joblines(where: { removed: { _eq: false } }, order_by: { line_no: asc }) {
|
||||||
act_price
|
act_price
|
||||||
act_price_before_ppc
|
act_price_before_ppc
|
||||||
@@ -2571,34 +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
|
|
||||||
returning {
|
|
||||||
id
|
|
||||||
user_email
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|||||||
@@ -1,58 +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 {
|
|
||||||
id
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const UPDATE_NOTIFICATIONS_READ_FRAGMENT = gql`
|
|
||||||
fragment UpdateNotificationRead on notifications {
|
|
||||||
read
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|||||||
@@ -56,8 +56,6 @@ import { DateTimeFormat } from "../../utils/DateFormatter";
|
|||||||
import dayjs from "../../utils/day";
|
import dayjs from "../../utils/day";
|
||||||
import UndefinedToNull from "../../utils/undefinedtonull";
|
import UndefinedToNull from "../../utils/undefinedtonull";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
|
||||||
import JobWatcherToggleContainer from "../../components/job-watcher-toggle/job-watcher-toggle.container.jsx";
|
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop,
|
bodyshop: selectBodyshop,
|
||||||
@@ -104,7 +102,6 @@ export function JobsDetailPage({
|
|||||||
nextFetchPolicy: "network-only"
|
nextFetchPolicy: "network-only"
|
||||||
});
|
});
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
const { scenarioNotificationsOn } = useSocket();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
//form.setFieldsValue(transormJobToForm(job));
|
//form.setFieldsValue(transormJobToForm(job));
|
||||||
@@ -322,13 +319,7 @@ export function JobsDetailPage({
|
|||||||
>
|
>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
// onBack={() => window.history.back()}
|
// onBack={() => window.history.back()}
|
||||||
|
title={job.ro_number || t("general.labels.na")}
|
||||||
title={
|
|
||||||
<Space>
|
|
||||||
{scenarioNotificationsOn && <JobWatcherToggleContainer job={job} />}
|
|
||||||
{job.ro_number || t("general.labels.na")}
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
extra={menuExtra}
|
extra={menuExtra}
|
||||||
/>
|
/>
|
||||||
<JobsDetailHeader job={job} />
|
<JobsDetailHeader job={job} />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FloatButton, Layout, Spin } from "antd";
|
import { FloatButton, Layout, Spin } from "antd";
|
||||||
|
|
||||||
// import preval from "preval.macro";
|
// 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 { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { Link, Route, Routes } from "react-router-dom";
|
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 PrintCenterModalContainer from "../../components/print-center-modal/print-center-modal.container";
|
||||||
import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-status.component";
|
import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-status.component";
|
||||||
import { requestForToken } from "../../firebase/firebase.utils";
|
import { requestForToken } from "../../firebase/firebase.utils";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||||
import { selectBodyshop, selectInstanceConflict } from "../../redux/user/user.selectors";
|
import { selectBodyshop, selectInstanceConflict } from "../../redux/user/user.selectors";
|
||||||
import UpdateAlert from "../../components/update-alert/update-alert.component";
|
import UpdateAlert from "../../components/update-alert/update-alert.component";
|
||||||
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
|
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 { selectAlerts } from "../../redux/application/application.selectors.js";
|
||||||
import { addAlerts } from "../../redux/application/application.actions.js";
|
import { addAlerts } from "../../redux/application/application.actions.js";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
|
||||||
const JobsPage = lazy(() => import("../jobs/jobs.page"));
|
const JobsPage = lazy(() => import("../jobs/jobs.page"));
|
||||||
|
|
||||||
const CardPaymentModalContainer = lazy(
|
const CardPaymentModalContainer = lazy(
|
||||||
@@ -123,7 +122,7 @@ const mapDispatchToProps = (dispatch) => ({
|
|||||||
export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
|
export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [chatVisible] = useState(false);
|
const [chatVisible] = useState(false);
|
||||||
const { socket, clientId } = useSocket();
|
const { socket, clientId } = useContext(SocketContext);
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|
||||||
// State to track displayed alerts
|
// State to track displayed alerts
|
||||||
@@ -147,7 +146,7 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchAlerts().catch((err) => `Error fetching Bodyshop Alerts: ${err?.message || ""}`);
|
fetchAlerts();
|
||||||
}, [setAlerts]);
|
}, [setAlerts]);
|
||||||
|
|
||||||
// Use useEffect to watch for new alerts
|
// Use useEffect to watch for new alerts
|
||||||
@@ -167,6 +166,7 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
|
|||||||
description: alert.description,
|
description: alert.description,
|
||||||
type: alert.type || "info",
|
type: alert.type || "info",
|
||||||
duration: 0,
|
duration: 0,
|
||||||
|
placement: "bottomRight",
|
||||||
closable: true,
|
closable: true,
|
||||||
onClose: () => {
|
onClose: () => {
|
||||||
// When the notification is closed, update displayed alerts state and localStorage
|
// When the notification is closed, update displayed alerts state and localStorage
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from "@apollo/client";
|
import { useQuery } from "@apollo/client";
|
||||||
import queryString from "query-string";
|
import queryString from "query-string";
|
||||||
import { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
@@ -10,17 +10,23 @@ import PaymentsListPaginated from "../../components/payments-list-paginated/paym
|
|||||||
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
|
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
|
||||||
import { QUERY_ALL_PAYMENTS_PAGINATED } from "../../graphql/payments.queries";
|
import { QUERY_ALL_PAYMENTS_PAGINATED } from "../../graphql/payments.queries";
|
||||||
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
|
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
|
||||||
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import { pageLimit } from "../../utils/config";
|
import { pageLimit } from "../../utils/config";
|
||||||
|
import FeatureWrapperComponent from "../../components/feature-wrapper/feature-wrapper.component";
|
||||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||||
|
import UpsellComponent, { upsellEnum } from "../../components/upsell/upsell.component";
|
||||||
|
import { Card } from "antd";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({});
|
const mapStateToProps = createStructuredSelector({
|
||||||
|
bodyshop: selectBodyshop
|
||||||
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
|
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
|
||||||
setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
|
setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
|
||||||
});
|
});
|
||||||
|
|
||||||
export function AllJobs({ setBreadcrumbs, setSelectedHeader }) {
|
export function AllJobs({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
|
||||||
const searchParams = queryString.parse(useLocation().search);
|
const searchParams = queryString.parse(useLocation().search);
|
||||||
const { page, sortcolumn, sortorder, searchObj } = searchParams;
|
const { page, sortcolumn, sortorder, searchObj } = searchParams;
|
||||||
|
|
||||||
@@ -54,15 +60,25 @@ export function AllJobs({ setBreadcrumbs, setSelectedHeader }) {
|
|||||||
|
|
||||||
if (error) return <AlertComponent message={error.message} type="error" />;
|
if (error) return <AlertComponent message={error.message} type="error" />;
|
||||||
return (
|
return (
|
||||||
<RbacWrapper action="payments:list">
|
<FeatureWrapperComponent
|
||||||
<PaymentsListPaginated
|
featureName="payments"
|
||||||
refetch={refetch}
|
noauth={
|
||||||
loading={loading}
|
<Card>
|
||||||
searchParams={searchParams}
|
<UpsellComponent upsell={upsellEnum().payments.general} />
|
||||||
total={data ? data.payments_aggregate.aggregate.count : 0}
|
</Card>
|
||||||
payments={data ? data.payments : []}
|
}
|
||||||
/>
|
z
|
||||||
</RbacWrapper>
|
>
|
||||||
|
<RbacWrapper action="payments:list">
|
||||||
|
<PaymentsListPaginated
|
||||||
|
refetch={refetch}
|
||||||
|
loading={loading}
|
||||||
|
searchParams={searchParams}
|
||||||
|
total={data ? data.payments_aggregate.aggregate.count : 0}
|
||||||
|
payments={data ? data.payments : []}
|
||||||
|
/>
|
||||||
|
</RbacWrapper>
|
||||||
|
</FeatureWrapperComponent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3766,60 +3766,6 @@
|
|||||||
"validation": {
|
"validation": {
|
||||||
"unique_vendor_name": "You must enter a unique vendor name."
|
"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 Only",
|
|
||||||
"mark-all-read": "Mark All Read",
|
|
||||||
"notification-popup-title": "Changes for Job #{{ro_number}}",
|
|
||||||
"ro-number": "RO #{{ro_number}}",
|
|
||||||
"no-watchers": "No Watchers",
|
|
||||||
"notification-settings-success": "Notification Settings saved successfully.",
|
|
||||||
"notification-settings-failure": "Error saving Notification Settings. {{error}}",
|
|
||||||
"watch": "Watch",
|
|
||||||
"unwatch": "Unwatch"
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"remove": "Remove"
|
|
||||||
},
|
|
||||||
"aria": {
|
|
||||||
"toggle": "Toggle Watching Job"
|
|
||||||
},
|
|
||||||
"tooltips": {
|
|
||||||
"job-watchers": "Job Watchers"
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3766,60 +3766,6 @@
|
|||||||
"validation": {
|
"validation": {
|
||||||
"unique_vendor_name": ""
|
"unique_vendor_name": ""
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"notifications": {
|
}
|
||||||
"labels": {
|
|
||||||
"notification-center": "",
|
|
||||||
"scenario": "",
|
|
||||||
"notificationscenarios": "",
|
|
||||||
"save": "",
|
|
||||||
"watching-issue": "",
|
|
||||||
"add-watchers": "",
|
|
||||||
"employee-search": "",
|
|
||||||
"teams-search": "",
|
|
||||||
"add-watchers-team": "",
|
|
||||||
"new-notification-title": "",
|
|
||||||
"show-unread-only": "",
|
|
||||||
"mark-all-read": "",
|
|
||||||
"notification-popup-title": "",
|
|
||||||
"ro-number": "",
|
|
||||||
"no-watchers": "",
|
|
||||||
"notification-settings-success": "",
|
|
||||||
"notification-settings-failure": "",
|
|
||||||
"watch": "",
|
|
||||||
"unwatch": ""
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"remove": ""
|
|
||||||
},
|
|
||||||
"aria": {
|
|
||||||
"toggle": ""
|
|
||||||
},
|
|
||||||
"tooltips": {
|
|
||||||
"job-watchers": ""
|
|
||||||
},
|
|
||||||
"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": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3766,60 +3766,6 @@
|
|||||||
"validation": {
|
"validation": {
|
||||||
"unique_vendor_name": ""
|
"unique_vendor_name": ""
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"notifications": {
|
}
|
||||||
"labels": {
|
|
||||||
"notification-center": "",
|
|
||||||
"scenario": "",
|
|
||||||
"notificationscenarios": "",
|
|
||||||
"save": "",
|
|
||||||
"watching-issue": "",
|
|
||||||
"add-watchers": "",
|
|
||||||
"employee-search": "",
|
|
||||||
"teams-search": "",
|
|
||||||
"add-watchers-team": "",
|
|
||||||
"new-notification-title": "",
|
|
||||||
"show-unread-only": "",
|
|
||||||
"mark-all-read": "",
|
|
||||||
"notification-popup-title": "",
|
|
||||||
"ro-number": "",
|
|
||||||
"no-watchers": "",
|
|
||||||
"notification-settings-success": "",
|
|
||||||
"notification-settings-failure": "",
|
|
||||||
"watch": "",
|
|
||||||
"unwatch": ""
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"remove": ""
|
|
||||||
},
|
|
||||||
"aria": {
|
|
||||||
"toggle": ""
|
|
||||||
},
|
|
||||||
"tooltips": {
|
|
||||||
"job-watchers": ""
|
|
||||||
},
|
|
||||||
"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": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,41 +143,7 @@ middlewares.push(
|
|||||||
new SentryLink().concat(roundTripLink.concat(retryLink.concat(errorLink.concat(authLink.concat(link)))))
|
new SentryLink().concat(roundTripLink.concat(retryLink.concat(errorLink.concat(authLink.concat(link)))))
|
||||||
);
|
);
|
||||||
|
|
||||||
const cache = new InMemoryCache({
|
const cache = new InMemoryCache({});
|
||||||
typePolicies: {
|
|
||||||
Query: {
|
|
||||||
fields: {
|
|
||||||
// Note: This is required because we switch from a read to an unread state with a toggle,
|
|
||||||
notifications: {
|
|
||||||
merge(existing = [], incoming = [], { readField }) {
|
|
||||||
// Create a map to deduplicate by __ref
|
|
||||||
const merged = new Map();
|
|
||||||
|
|
||||||
// Add existing items to retain cached data
|
|
||||||
existing.forEach((item) => {
|
|
||||||
const ref = readField("__ref", item);
|
|
||||||
if (ref) {
|
|
||||||
merged.set(ref, item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add incoming items, overwriting duplicates
|
|
||||||
incoming.forEach((item) => {
|
|
||||||
const ref = readField("__ref", item);
|
|
||||||
if (ref) {
|
|
||||||
merged.set(ref, item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return incoming to respect the current query’s filter (e.g., unread-only or all)
|
|
||||||
return incoming;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new ApolloClient({
|
const client = new ApolloClient({
|
||||||
link: ApolloLink.from(middlewares),
|
link: ApolloLink.from(middlewares),
|
||||||
cache,
|
cache,
|
||||||
@@ -197,5 +163,4 @@ const client = new ApolloClient({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default client;
|
export default client;
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
services:
|
|
||||||
# Load Balancer (NGINX) with WebSocket support and session persistence
|
|
||||||
load-balancer:
|
|
||||||
image: nginx:latest
|
|
||||||
container_name: load-balancer
|
|
||||||
ports:
|
|
||||||
- "4000:80" # External port 4000 maps to NGINX's port 80
|
|
||||||
volumes:
|
|
||||||
- ./nginx-websocket.conf:/etc/nginx/nginx.conf:ro # Mount NGINX configuration
|
|
||||||
networks:
|
|
||||||
- redis-cluster-net
|
|
||||||
depends_on:
|
|
||||||
- node-app-1
|
|
||||||
- node-app-2
|
|
||||||
- node-app-3
|
|
||||||
healthcheck:
|
|
||||||
test: [ "CMD", "curl", "-f", "http://localhost/health" ]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
# Node App Instance 1
|
|
||||||
node-app-1:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
container_name: node-app-1
|
|
||||||
hostname: node-app-1
|
|
||||||
networks:
|
|
||||||
- redis-cluster-net
|
|
||||||
env_file:
|
|
||||||
- .env.development
|
|
||||||
depends_on:
|
|
||||||
redis-node-1:
|
|
||||||
condition: service_healthy
|
|
||||||
redis-node-2:
|
|
||||||
condition: service_healthy
|
|
||||||
redis-node-3:
|
|
||||||
condition: service_healthy
|
|
||||||
localstack:
|
|
||||||
condition: service_healthy
|
|
||||||
aws-cli:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
ports:
|
|
||||||
- "4001:4000" # Different external port for local access
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
- node-app-npm-cache:/app/node_modules
|
|
||||||
|
|
||||||
# Node App Instance 2
|
|
||||||
node-app-2:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
container_name: node-app-2
|
|
||||||
hostname: node-app-2
|
|
||||||
networks:
|
|
||||||
- redis-cluster-net
|
|
||||||
env_file:
|
|
||||||
- .env.development
|
|
||||||
depends_on:
|
|
||||||
redis-node-1:
|
|
||||||
condition: service_healthy
|
|
||||||
redis-node-2:
|
|
||||||
condition: service_healthy
|
|
||||||
redis-node-3:
|
|
||||||
condition: service_healthy
|
|
||||||
localstack:
|
|
||||||
condition: service_healthy
|
|
||||||
aws-cli:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
ports:
|
|
||||||
- "4002:4000" # Different external port for local access
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
- node-app-npm-cache:/app/node_modules
|
|
||||||
|
|
||||||
# Node App Instance 3
|
|
||||||
node-app-3:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
container_name: node-app-3
|
|
||||||
hostname: node-app-3
|
|
||||||
networks:
|
|
||||||
- redis-cluster-net
|
|
||||||
env_file:
|
|
||||||
- .env.development
|
|
||||||
depends_on:
|
|
||||||
redis-node-1:
|
|
||||||
condition: service_healthy
|
|
||||||
redis-node-2:
|
|
||||||
condition: service_healthy
|
|
||||||
redis-node-3:
|
|
||||||
condition: service_healthy
|
|
||||||
localstack:
|
|
||||||
condition: service_healthy
|
|
||||||
aws-cli:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
ports:
|
|
||||||
- "4003:4000" # Different external port for local access
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
- node-app-npm-cache:/app/node_modules
|
|
||||||
|
|
||||||
# Redis Node 1
|
|
||||||
redis-node-1:
|
|
||||||
build:
|
|
||||||
context: ./redis
|
|
||||||
container_name: redis-node-1
|
|
||||||
hostname: redis-node-1
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- redis-cluster-net
|
|
||||||
volumes:
|
|
||||||
- redis-node-1-data:/data
|
|
||||||
- redis-lock:/redis-lock
|
|
||||||
healthcheck:
|
|
||||||
test: [ "CMD", "redis-cli", "ping" ]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
# Redis Node 2
|
|
||||||
redis-node-2:
|
|
||||||
build:
|
|
||||||
context: ./redis
|
|
||||||
container_name: redis-node-2
|
|
||||||
hostname: redis-node-2
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- redis-cluster-net
|
|
||||||
volumes:
|
|
||||||
- redis-node-2-data:/data
|
|
||||||
- redis-lock:/redis-lock
|
|
||||||
healthcheck:
|
|
||||||
test: [ "CMD", "redis-cli", "ping" ]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
# Redis Node 3
|
|
||||||
redis-node-3:
|
|
||||||
build:
|
|
||||||
context: ./redis
|
|
||||||
container_name: redis-node-3
|
|
||||||
hostname: redis-node-3
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- redis-cluster-net
|
|
||||||
volumes:
|
|
||||||
- redis-node-3-data:/data
|
|
||||||
- redis-lock:/redis-lock
|
|
||||||
healthcheck:
|
|
||||||
test: [ "CMD", "redis-cli", "ping" ]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
# LocalStack
|
|
||||||
localstack:
|
|
||||||
image: localstack/localstack
|
|
||||||
container_name: localstack
|
|
||||||
hostname: localstack
|
|
||||||
networks:
|
|
||||||
- redis-cluster-net
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
environment:
|
|
||||||
- SERVICES=s3,ses,secretsmanager,cloudwatch,logs
|
|
||||||
- DEBUG=0
|
|
||||||
- AWS_ACCESS_KEY_ID=test
|
|
||||||
- AWS_SECRET_ACCESS_KEY=test
|
|
||||||
- AWS_DEFAULT_REGION=ca-central-1
|
|
||||||
- EXTRA_CORS_ALLOWED_HEADERS=Authorization,Content-Type
|
|
||||||
- EXTRA_CORS_ALLOWED_ORIGINS=*
|
|
||||||
- EXTRA_CORS_EXPOSE_HEADERS=Authorization,Content-Type
|
|
||||||
ports:
|
|
||||||
- "4566:4566"
|
|
||||||
healthcheck:
|
|
||||||
test: [ "CMD", "curl", "-f", "http://localhost:4566/_localstack/health" ]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 20s
|
|
||||||
|
|
||||||
# AWS-CLI
|
|
||||||
aws-cli:
|
|
||||||
image: amazon/aws-cli
|
|
||||||
container_name: aws-cli
|
|
||||||
hostname: aws-cli
|
|
||||||
networks:
|
|
||||||
- redis-cluster-net
|
|
||||||
depends_on:
|
|
||||||
localstack:
|
|
||||||
condition: service_healthy
|
|
||||||
volumes:
|
|
||||||
- './localstack:/tmp/localstack'
|
|
||||||
- './certs:/tmp/certs'
|
|
||||||
environment:
|
|
||||||
- AWS_ACCESS_KEY_ID=test
|
|
||||||
- AWS_SECRET_ACCESS_KEY=test
|
|
||||||
- AWS_DEFAULT_REGION=ca-central-1
|
|
||||||
entrypoint: /bin/sh -c
|
|
||||||
command: >
|
|
||||||
"
|
|
||||||
aws --endpoint-url=http://localstack:4566 ses verify-domain-identity --domain imex.online --region ca-central-1
|
|
||||||
aws --endpoint-url=http://localstack:4566 ses verify-email-identity --email-address noreply@imex.online --region ca-central-1
|
|
||||||
aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/io-ftp-test.key
|
|
||||||
aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1
|
|
||||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1
|
|
||||||
"
|
|
||||||
|
|
||||||
networks:
|
|
||||||
redis-cluster-net:
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
node-app-npm-cache:
|
|
||||||
redis-node-1-data:
|
|
||||||
redis-node-2-data:
|
|
||||||
redis-node-3-data:
|
|
||||||
redis-lock:
|
|
||||||
@@ -31,14 +31,6 @@
|
|||||||
headers:
|
headers:
|
||||||
- name: x-imex-auth
|
- name: x-imex-auth
|
||||||
value_from_env: DATAPUMP_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
|
- name: Task Reminders
|
||||||
webhook: '{{HASURA_API_URL}}/tasks-remind-handler'
|
webhook: '{{HASURA_API_URL}}/tasks-remind-handler'
|
||||||
schedule: '*/15 * * * *'
|
schedule: '*/15 * * * *'
|
||||||
@@ -47,3 +39,11 @@
|
|||||||
headers:
|
headers:
|
||||||
- name: event-secret
|
- name: event-secret
|
||||||
value_from_env: 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
|
||||||
|
|||||||
@@ -198,14 +198,6 @@
|
|||||||
- name: user
|
- name: user
|
||||||
using:
|
using:
|
||||||
foreign_key_constraint_on: useremail
|
foreign_key_constraint_on: useremail
|
||||||
array_relationships:
|
|
||||||
- name: notifications
|
|
||||||
using:
|
|
||||||
foreign_key_constraint_on:
|
|
||||||
column: associationid
|
|
||||||
table:
|
|
||||||
name: notifications
|
|
||||||
schema: public
|
|
||||||
select_permissions:
|
select_permissions:
|
||||||
- role: user
|
- role: user
|
||||||
permission:
|
permission:
|
||||||
@@ -705,6 +697,12 @@
|
|||||||
- name: event-secret
|
- name: event-secret
|
||||||
value_from_env: EVENT_SECRET
|
value_from_env: EVENT_SECRET
|
||||||
request_transform:
|
request_transform:
|
||||||
|
body:
|
||||||
|
action: transform
|
||||||
|
template: |-
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
method: POST
|
method: POST
|
||||||
query_params: {}
|
query_params: {}
|
||||||
template_engine: Kriti
|
template_engine: Kriti
|
||||||
@@ -1135,46 +1133,6 @@
|
|||||||
- active:
|
- active:
|
||||||
_eq: true
|
_eq: true
|
||||||
check: null
|
check: null
|
||||||
event_triggers:
|
|
||||||
- name: cache_bodyshop
|
|
||||||
definition:
|
|
||||||
enable_manual: false
|
|
||||||
update:
|
|
||||||
columns:
|
|
||||||
- shopname
|
|
||||||
- md_order_statuses
|
|
||||||
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:
|
|
||||||
body:
|
|
||||||
action: transform
|
|
||||||
template: |-
|
|
||||||
{
|
|
||||||
"created_at": {{$body.created_at}},
|
|
||||||
"delivery_info": {{$body.delivery_info}},
|
|
||||||
"event": {
|
|
||||||
"data": {
|
|
||||||
"new": {
|
|
||||||
"id": {{$body.event.data.new.id}},
|
|
||||||
"shopname": {{$body.event.data.new.shopname}},
|
|
||||||
"md_order_statuses": {{$body.event.data.new.md_order_statuses}}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"op": {{$body.event.op}},
|
|
||||||
"session_variables": {{$body.event.session_variables}}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
method: POST
|
|
||||||
query_params: {}
|
|
||||||
template_engine: Kriti
|
|
||||||
url: '{{$base_url}}/bodyshop-cache'
|
|
||||||
version: 2
|
|
||||||
- table:
|
- table:
|
||||||
name: cccontracts
|
name: cccontracts
|
||||||
schema: public
|
schema: public
|
||||||
@@ -2000,29 +1958,6 @@
|
|||||||
_eq: X-Hasura-User-Id
|
_eq: X-Hasura-User-Id
|
||||||
- active:
|
- active:
|
||||||
_eq: true
|
_eq: true
|
||||||
event_triggers:
|
|
||||||
- name: notifications_documents
|
|
||||||
definition:
|
|
||||||
enable_manual: false
|
|
||||||
insert:
|
|
||||||
columns: '*'
|
|
||||||
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:
|
- table:
|
||||||
name: email_audit_trail
|
name: email_audit_trail
|
||||||
schema: public
|
schema: public
|
||||||
@@ -2911,12 +2846,13 @@
|
|||||||
- role: user
|
- role: user
|
||||||
permission:
|
permission:
|
||||||
check:
|
check:
|
||||||
job:
|
user:
|
||||||
bodyshop:
|
_and:
|
||||||
associations:
|
- associations:
|
||||||
user:
|
active:
|
||||||
authid:
|
_eq: true
|
||||||
_eq: X-Hasura-User-Id
|
- authid:
|
||||||
|
_eq: X-Hasura-User-Id
|
||||||
columns:
|
columns:
|
||||||
- user_email
|
- user_email
|
||||||
- created_at
|
- created_at
|
||||||
@@ -2932,12 +2868,13 @@
|
|||||||
- id
|
- id
|
||||||
- jobid
|
- jobid
|
||||||
filter:
|
filter:
|
||||||
job:
|
user:
|
||||||
bodyshop:
|
_and:
|
||||||
associations:
|
- associations:
|
||||||
user:
|
active:
|
||||||
authid:
|
_eq: true
|
||||||
_eq: X-Hasura-User-Id
|
- authid:
|
||||||
|
_eq: X-Hasura-User-Id
|
||||||
comment: ""
|
comment: ""
|
||||||
update_permissions:
|
update_permissions:
|
||||||
- role: user
|
- role: user
|
||||||
@@ -2948,24 +2885,26 @@
|
|||||||
- id
|
- id
|
||||||
- jobid
|
- jobid
|
||||||
filter:
|
filter:
|
||||||
job:
|
user:
|
||||||
bodyshop:
|
_and:
|
||||||
associations:
|
- associations:
|
||||||
user:
|
active:
|
||||||
authid:
|
_eq: true
|
||||||
_eq: X-Hasura-User-Id
|
- authid:
|
||||||
|
_eq: X-Hasura-User-Id
|
||||||
check: null
|
check: null
|
||||||
comment: ""
|
comment: ""
|
||||||
delete_permissions:
|
delete_permissions:
|
||||||
- role: user
|
- role: user
|
||||||
permission:
|
permission:
|
||||||
filter:
|
filter:
|
||||||
job:
|
user:
|
||||||
bodyshop:
|
_and:
|
||||||
associations:
|
- associations:
|
||||||
user:
|
active:
|
||||||
authid:
|
_eq: true
|
||||||
_eq: X-Hasura-User-Id
|
- authid:
|
||||||
|
_eq: X-Hasura-User-Id
|
||||||
comment: ""
|
comment: ""
|
||||||
- table:
|
- table:
|
||||||
name: joblines
|
name: joblines
|
||||||
@@ -3284,31 +3223,6 @@
|
|||||||
_eq: X-Hasura-User-Id
|
_eq: X-Hasura-User-Id
|
||||||
- active:
|
- active:
|
||||||
_eq: true
|
_eq: true
|
||||||
event_triggers:
|
|
||||||
- name: notifications_joblines
|
|
||||||
definition:
|
|
||||||
enable_manual: false
|
|
||||||
update:
|
|
||||||
columns:
|
|
||||||
- critical
|
|
||||||
- status
|
|
||||||
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:
|
|
||||||
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 ?? \"Internal\"}},\r\n \"x-hasura-role\": {{$body?.event?.session_variables?.x-hasura-role ?? \"Internal\"}}\r\n }, \r\n \"op\": \"UPDATE\",\r\n \"data\": {\r\n \"old\": {\r\n \"id\": {{$body.event.data.old.id}},\r\n \"jobid\": {{$body.event.data.old.jobid}},\r\n \"critical\": {{$body.event.data.old.critical}},\r\n \"status\": {{$body.event.data.old.status}},\r\n \"line_desc\": {{$body.event.data.old.line_desc}}\r\n },\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"jobid\": {{$body.event.data.new.jobid}},\r\n \"critical\": {{$body.event.data.new.critical}},\r\n \"status\": {{$body.event.data.new.status}},\r\n \"line_desc\": {{$body.event.data.new.line_desc}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_joblines\"\r\n },\r\n \"table\": {\r\n \"schema\": \"public\",\r\n \"name\": \"joblines\"\r\n }\r\n}\r\n"
|
|
||||||
method: POST
|
|
||||||
query_params: {}
|
|
||||||
template_engine: Kriti
|
|
||||||
url: '{{$base_url}}/notifications/events/handleJobLinesChange'
|
|
||||||
version: 2
|
|
||||||
- table:
|
- table:
|
||||||
name: joblines_status
|
name: joblines_status
|
||||||
schema: public
|
schema: public
|
||||||
@@ -3455,13 +3369,6 @@
|
|||||||
table:
|
table:
|
||||||
name: job_conversations
|
name: job_conversations
|
||||||
schema: public
|
schema: public
|
||||||
- name: job_watchers
|
|
||||||
using:
|
|
||||||
foreign_key_constraint_on:
|
|
||||||
column: jobid
|
|
||||||
table:
|
|
||||||
name: job_watchers
|
|
||||||
schema: public
|
|
||||||
- name: joblines
|
- name: joblines
|
||||||
using:
|
using:
|
||||||
foreign_key_constraint_on:
|
foreign_key_constraint_on:
|
||||||
@@ -3492,13 +3399,6 @@
|
|||||||
table:
|
table:
|
||||||
name: notes
|
name: notes
|
||||||
schema: public
|
schema: public
|
||||||
- name: notifications
|
|
||||||
using:
|
|
||||||
foreign_key_constraint_on:
|
|
||||||
column: jobid
|
|
||||||
table:
|
|
||||||
name: notifications
|
|
||||||
schema: public
|
|
||||||
- name: parts_dispatches
|
- name: parts_dispatches
|
||||||
using:
|
using:
|
||||||
foreign_key_constraint_on:
|
foreign_key_constraint_on:
|
||||||
@@ -4573,7 +4473,10 @@
|
|||||||
request_transform:
|
request_transform:
|
||||||
body:
|
body:
|
||||||
action: transform
|
action: transform
|
||||||
template: "{\r\n \"event\": {\r\n \"session_variables\": {\r\n \"x-hasura-user-id\": {{$body?.event?.session_variables?.x-hasura-user-id ?? \"Internal\"}},\r\n \"x-hasura-role\": {{$body?.event?.session_variables?.x-hasura-role ?? \"Internal\"}}\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
|
method: POST
|
||||||
query_params: {}
|
query_params: {}
|
||||||
template_engine: Kriti
|
template_engine: Kriti
|
||||||
@@ -4922,26 +4825,6 @@
|
|||||||
_eq: X-Hasura-User-Id
|
_eq: X-Hasura-User-Id
|
||||||
- active:
|
- active:
|
||||||
_eq: true
|
_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:
|
- table:
|
||||||
name: notifications
|
name: notifications
|
||||||
schema: public
|
schema: public
|
||||||
@@ -4952,79 +4835,46 @@
|
|||||||
- name: job
|
- name: job
|
||||||
using:
|
using:
|
||||||
foreign_key_constraint_on: jobid
|
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:
|
select_permissions:
|
||||||
- role: user
|
- role: user
|
||||||
permission:
|
permission:
|
||||||
columns:
|
columns:
|
||||||
- scenario_meta
|
|
||||||
- scenario_text
|
|
||||||
- fcm_text
|
|
||||||
- created_at
|
|
||||||
- read
|
|
||||||
- updated_at
|
|
||||||
- associationid
|
- associationid
|
||||||
|
- created_at
|
||||||
|
- fcm_data
|
||||||
|
- fcm_message
|
||||||
|
- fcm_title
|
||||||
- id
|
- id
|
||||||
- jobid
|
- jobid
|
||||||
|
- meta
|
||||||
|
- read
|
||||||
|
- ui_translation_meta
|
||||||
|
- ui_translation_string
|
||||||
|
- updated_at
|
||||||
filter:
|
filter:
|
||||||
job:
|
association:
|
||||||
bodyshop:
|
_and:
|
||||||
associations:
|
- active:
|
||||||
_and:
|
_eq: true
|
||||||
- user:
|
- user:
|
||||||
authid:
|
authid:
|
||||||
_eq: X-Hasura-User-Id
|
_eq: X-Hasura-User-Id
|
||||||
- active:
|
|
||||||
_eq: true
|
|
||||||
allow_aggregations: true
|
|
||||||
comment: ""
|
comment: ""
|
||||||
update_permissions:
|
update_permissions:
|
||||||
- role: user
|
- role: user
|
||||||
permission:
|
permission:
|
||||||
columns:
|
columns:
|
||||||
- scenario_meta
|
- meta
|
||||||
- scenario_text
|
|
||||||
- fcm_text
|
|
||||||
- created_at
|
|
||||||
- read
|
- read
|
||||||
- updated_at
|
filter:
|
||||||
- associationid
|
association:
|
||||||
- id
|
_and:
|
||||||
- jobid
|
- active:
|
||||||
filter: {}
|
_eq: true
|
||||||
check:
|
- user:
|
||||||
job:
|
authid:
|
||||||
bodyshop:
|
_eq: X-Hasura-User-Id
|
||||||
associations:
|
check: null
|
||||||
_and:
|
|
||||||
- user:
|
|
||||||
authid:
|
|
||||||
_eq: X-Hasura-User-Id
|
|
||||||
- active:
|
|
||||||
_eq: true
|
|
||||||
comment: ""
|
comment: ""
|
||||||
- table:
|
- table:
|
||||||
name: owners
|
name: owners
|
||||||
@@ -5798,25 +5648,6 @@
|
|||||||
- active:
|
- active:
|
||||||
_eq: true
|
_eq: true
|
||||||
event_triggers:
|
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
|
- name: os_payments
|
||||||
definition:
|
definition:
|
||||||
delete:
|
delete:
|
||||||
@@ -6288,15 +6119,9 @@
|
|||||||
columns: '*'
|
columns: '*'
|
||||||
update:
|
update:
|
||||||
columns:
|
columns:
|
||||||
- joblineid
|
|
||||||
- assigned_to
|
- assigned_to
|
||||||
- due_date
|
|
||||||
- partsorderid
|
|
||||||
- completed
|
- completed
|
||||||
- description
|
- description
|
||||||
- billid
|
|
||||||
- title
|
|
||||||
- priority
|
|
||||||
retry_conf:
|
retry_conf:
|
||||||
interval_sec: 10
|
interval_sec: 10
|
||||||
num_retries: 0
|
num_retries: 0
|
||||||
@@ -6306,6 +6131,12 @@
|
|||||||
- name: event-secret
|
- name: event-secret
|
||||||
value_from_env: EVENT_SECRET
|
value_from_env: EVENT_SECRET
|
||||||
request_transform:
|
request_transform:
|
||||||
|
body:
|
||||||
|
action: transform
|
||||||
|
template: |-
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
method: POST
|
method: POST
|
||||||
query_params: {}
|
query_params: {}
|
||||||
template_engine: Kriti
|
template_engine: Kriti
|
||||||
@@ -6482,6 +6313,12 @@
|
|||||||
- name: event-secret
|
- name: event-secret
|
||||||
value_from_env: EVENT_SECRET
|
value_from_env: EVENT_SECRET
|
||||||
request_transform:
|
request_transform:
|
||||||
|
body:
|
||||||
|
action: transform
|
||||||
|
template: |-
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
method: POST
|
method: POST
|
||||||
query_params: {}
|
query_params: {}
|
||||||
template_engine: Kriti
|
template_engine: Kriti
|
||||||
@@ -6749,13 +6586,6 @@
|
|||||||
table:
|
table:
|
||||||
name: ioevents
|
name: ioevents
|
||||||
schema: public
|
schema: public
|
||||||
- name: job_watchers
|
|
||||||
using:
|
|
||||||
foreign_key_constraint_on:
|
|
||||||
column: user_email
|
|
||||||
table:
|
|
||||||
name: job_watchers
|
|
||||||
schema: public
|
|
||||||
- name: messages
|
- name: messages
|
||||||
using:
|
using:
|
||||||
foreign_key_constraint_on:
|
foreign_key_constraint_on:
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
alter table "public"."notifications" add column "html_body" text
|
|
||||||
not null;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
alter table "public"."notifications" alter column "fcm_title" set not null;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
alter table "public"."notifications" alter column "fcm_title" drop not null;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
alter table "public"."notifications" alter column "fcm_message" set not null;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
alter table "public"."notifications" alter column "fcm_message" drop not null;
|
|
||||||
@@ -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;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
alter table "public"."notifications" drop column "html_body" cascade;
|
|
||||||
@@ -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;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
alter table "public"."notifications" drop column "fcm_data" cascade;
|
|
||||||
@@ -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;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
alter table "public"."notifications" drop column "fcm_message" cascade;
|
|
||||||
@@ -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;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
alter table "public"."notifications" drop column "ui_translation_string" cascade;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
alter table "public"."notifications" rename column "fcm_text" to "fcm_title";
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
alter table "public"."notifications" rename column "fcm_title" to "fcm_text";
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
alter table "public"."notifications" rename column "scenario_text" to "ui_translation_meta";
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
alter table "public"."notifications" rename column "ui_translation_meta" to "scenario_text";
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
alter table "public"."notifications" rename column "scenario_meta" to "meta";
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
alter table "public"."notifications" rename column "meta" to "scenario_meta";
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
DROP INDEX IF EXISTS "public"."idx_job_watchers_jobid_user_email_unique";
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
CREATE UNIQUE INDEX "idx_job_watchers_jobid_user_email_unique" on
|
|
||||||
"public"."job_watchers" using btree ("jobid", "user_email");
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
events {
|
|
||||||
worker_connections 1024;
|
|
||||||
}
|
|
||||||
|
|
||||||
http {
|
|
||||||
upstream node_app {
|
|
||||||
ip_hash; # Enables session persistence based on client IP
|
|
||||||
server node-app-1:4000;
|
|
||||||
server node-app-2:4000;
|
|
||||||
server node-app-3:4000;
|
|
||||||
}
|
|
||||||
|
|
||||||
# WebSocket upgrade configuration
|
|
||||||
map $http_upgrade $connection_upgrade {
|
|
||||||
default upgrade;
|
|
||||||
'' close;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://node_app;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# WebSocket headers
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
proxy_read_timeout 86400; # Keep WebSocket connections alive (24 hours)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Health check endpoint
|
|
||||||
location /health {
|
|
||||||
proxy_pass http://node_app;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1320
package-lock.json
generated
1320
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
42
package.json
42
package.json
@@ -19,41 +19,39 @@
|
|||||||
"makeitpretty": "prettier --write \"**/*.{css,js,json,jsx,scss}\""
|
"makeitpretty": "prettier --write \"**/*.{css,js,json,jsx,scss}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-cloudwatch-logs": "^3.758.0",
|
"@aws-sdk/client-cloudwatch-logs": "^3.738.0",
|
||||||
"@aws-sdk/client-elasticache": "^3.758.0",
|
"@aws-sdk/client-elasticache": "^3.738.0",
|
||||||
"@aws-sdk/client-s3": "^3.758.0",
|
"@aws-sdk/client-s3": "^3.738.0",
|
||||||
"@aws-sdk/client-secrets-manager": "^3.758.0",
|
"@aws-sdk/client-secrets-manager": "^3.738.0",
|
||||||
"@aws-sdk/client-ses": "^3.758.0",
|
"@aws-sdk/client-ses": "^3.738.0",
|
||||||
"@aws-sdk/credential-provider-node": "^3.758.0",
|
"@aws-sdk/credential-provider-node": "^3.738.0",
|
||||||
"@opensearch-project/opensearch": "^2.13.0",
|
"@opensearch-project/opensearch": "^2.13.0",
|
||||||
"@socket.io/admin-ui": "^0.5.1",
|
"@socket.io/admin-ui": "^0.5.1",
|
||||||
"@socket.io/redis-adapter": "^8.3.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"aws4": "^1.13.2",
|
"aws4": "^1.13.2",
|
||||||
"axios": "^1.8.1",
|
"axios": "^1.7.7",
|
||||||
"bee-queue": "^1.7.1",
|
|
||||||
"better-queue": "^3.8.12",
|
"better-queue": "^3.8.12",
|
||||||
"bluebird": "^3.7.2",
|
"bluebird": "^3.7.2",
|
||||||
"body-parser": "^1.20.3",
|
"body-parser": "^1.20.3",
|
||||||
"bullmq": "^5.41.7",
|
"chart.js": "^4.4.6",
|
||||||
"chart.js": "^4.4.8",
|
|
||||||
"cloudinary": "^2.5.1",
|
"cloudinary": "^2.5.1",
|
||||||
"compression": "^1.8.0",
|
"compression": "^1.7.5",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"crisp-status-reporter": "^1.2.2",
|
"crisp-status-reporter": "^1.2.2",
|
||||||
"csrf": "^3.1.0",
|
"csrf": "^3.1.0",
|
||||||
"dd-trace": "^5.40.0",
|
"dd-trace": "^5.33.1",
|
||||||
"dinero.js": "^1.9.1",
|
"dinero.js": "^1.9.1",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"firebase-admin": "^13.1.0",
|
"firebase-admin": "^13.0.2",
|
||||||
"graphql": "^16.10.0",
|
"graphql": "^16.10.0",
|
||||||
"graphql-request": "^6.1.0",
|
"graphql-request": "^6.1.0",
|
||||||
"inline-css": "^4.0.3",
|
"inline-css": "^4.0.3",
|
||||||
"intuit-oauth": "^4.2.0",
|
"intuit-oauth": "^4.1.3",
|
||||||
"ioredis": "^5.5.0",
|
"ioredis": "^5.4.2",
|
||||||
"json-2-csv": "^5.5.8",
|
"json-2-csv": "^5.5.8",
|
||||||
"juice": "^11.0.1",
|
"juice": "^11.0.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"moment-timezone": "^0.5.47",
|
"moment-timezone": "^0.5.47",
|
||||||
@@ -66,7 +64,7 @@
|
|||||||
"redis": "^4.7.0",
|
"redis": "^4.7.0",
|
||||||
"rimraf": "^6.0.1",
|
"rimraf": "^6.0.1",
|
||||||
"skia-canvas": "^2.0.2",
|
"skia-canvas": "^2.0.2",
|
||||||
"soap": "^1.1.9",
|
"soap": "^1.1.7",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"socket.io-adapter": "^2.5.5",
|
"socket.io-adapter": "^2.5.5",
|
||||||
"ssh2-sftp-client": "^11.0.0",
|
"ssh2-sftp-client": "^11.0.0",
|
||||||
@@ -78,14 +76,14 @@
|
|||||||
"xmlbuilder2": "^3.1.1"
|
"xmlbuilder2": "^3.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.21.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"eslint": "^9.21.0",
|
"eslint": "^9.19.0",
|
||||||
"eslint-plugin-react": "^7.37.4",
|
"eslint-plugin-react": "^7.37.4",
|
||||||
"globals": "^15.15.0",
|
"globals": "^15.14.0",
|
||||||
"p-limit": "^3.1.0",
|
"p-limit": "^3.1.0",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.3.3",
|
||||||
"source-map-explorer": "^2.5.2"
|
"source-map-explorer": "^2.5.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,3 @@ cluster-enabled yes
|
|||||||
cluster-config-file nodes.conf
|
cluster-config-file nodes.conf
|
||||||
cluster-node-timeout 5000
|
cluster-node-timeout 5000
|
||||||
appendonly yes
|
appendonly yes
|
||||||
maxmemory-policy noeviction
|
|
||||||
|
|||||||
123
server.js
123
server.js
@@ -5,7 +5,7 @@ require("dotenv").config({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (process.env.NODE_ENV) {
|
if (process.env.NODE_ENV) {
|
||||||
require("dd-trace").init({
|
const tracer = require("dd-trace").init({
|
||||||
profiling: true,
|
profiling: true,
|
||||||
env: process.env.NODE_ENV,
|
env: process.env.NODE_ENV,
|
||||||
service: "bodyshop-api"
|
service: "bodyshop-api"
|
||||||
@@ -35,11 +35,9 @@ const {
|
|||||||
} = require("@aws-sdk/client-elasticache");
|
} = require("@aws-sdk/client-elasticache");
|
||||||
const { InstanceRegion } = require("./server/utils/instanceMgr");
|
const { InstanceRegion } = require("./server/utils/instanceMgr");
|
||||||
const StartStatusReporter = require("./server/utils/statusReporter");
|
const StartStatusReporter = require("./server/utils/statusReporter");
|
||||||
const { registerCleanupTask, initializeCleanupManager } = require("./server/utils/cleanupManager");
|
|
||||||
|
|
||||||
const { loadEmailQueue } = require("./server/notifications/queues/emailQueue");
|
|
||||||
const { loadAppQueue } = require("./server/notifications/queues/appQueue");
|
|
||||||
|
|
||||||
|
const cleanupTasks = [];
|
||||||
|
let isShuttingDown = false;
|
||||||
const CLUSTER_RETRY_BASE_DELAY = 100;
|
const CLUSTER_RETRY_BASE_DELAY = 100;
|
||||||
const CLUSTER_RETRY_MAX_DELAY = 5000;
|
const CLUSTER_RETRY_MAX_DELAY = 5000;
|
||||||
const CLUSTER_RETRY_JITTER = 100;
|
const CLUSTER_RETRY_JITTER = 100;
|
||||||
@@ -195,10 +193,7 @@ const connectToRedisCluster = async () => {
|
|||||||
try {
|
try {
|
||||||
redisServers = JSON.parse(process.env.REDIS_URL);
|
redisServers = JSON.parse(process.env.REDIS_URL);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.log(`Failed to parse REDIS_URL: ${error.message}. Exiting...`, "ERROR", "redis", "api", {
|
logger.log(`Failed to parse REDIS_URL: ${error.message}. Exiting...`, "ERROR", "redis", "api");
|
||||||
message: error?.message,
|
|
||||||
stack: error?.stack
|
|
||||||
});
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,22 +219,11 @@ const connectToRedisCluster = async () => {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
redisCluster.on("ready", () => {
|
redisCluster.on("ready", () => {
|
||||||
logger.log(`Redis cluster connection established.`, "INFO", "redis", "api");
|
logger.log(`Redis cluster connection established.`, "INFO", "redis", "api");
|
||||||
if (process.env.NODE_ENV === "development" && process.env?.CLEAR_REDIS_ON_START === "true") {
|
resolve(redisCluster);
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
redisCluster.on("error", (err) => {
|
redisCluster.on("error", (err) => {
|
||||||
logger.log(`Redis cluster connection failed:`, "ERROR", "redis", "api", {
|
logger.log(`Redis cluster connection failed: ${err.message}`, "ERROR", "redis", "api");
|
||||||
message: err?.message,
|
|
||||||
stack: err?.stack
|
|
||||||
});
|
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -261,24 +245,17 @@ const applySocketIO = async ({ server, app }) => {
|
|||||||
const pubClient = redisCluster;
|
const pubClient = redisCluster;
|
||||||
const subClient = pubClient.duplicate();
|
const subClient = pubClient.duplicate();
|
||||||
|
|
||||||
pubClient.on("error", (err) =>
|
pubClient.on("error", (err) => logger.log(`Redis pubClient error: ${err}`, "ERROR", "redis"));
|
||||||
logger.log(`Redis pubClient error: ${err}`, "ERROR", "redis", "api", {
|
subClient.on("error", (err) => logger.log(`Redis subClient error: ${err}`, "ERROR", "redis"));
|
||||||
message: err?.message,
|
|
||||||
stack: err?.stack
|
|
||||||
})
|
|
||||||
);
|
|
||||||
subClient.on("error", (err) =>
|
|
||||||
logger.log(`Redis subClient error: ${err}`, "ERROR", "redis", "api", {
|
|
||||||
message: err?.message,
|
|
||||||
stack: err?.stack
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Register Redis cleanup
|
process.on("SIGINT", async () => {
|
||||||
registerCleanupTask(async () => {
|
|
||||||
logger.log("Closing Redis connections...", "INFO", "redis", "api");
|
logger.log("Closing Redis connections...", "INFO", "redis", "api");
|
||||||
await Promise.all([pubClient.disconnect(), subClient.disconnect()]);
|
try {
|
||||||
logger.log("Redis connections closed.", "INFO", "redis", "api");
|
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, {
|
const ioRedis = new Server(server, {
|
||||||
@@ -336,34 +313,6 @@ const applySocketIO = async ({ server, app }) => {
|
|||||||
return api;
|
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
|
* Main function to start the server
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
@@ -374,9 +323,6 @@ const main = async () => {
|
|||||||
|
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
|
|
||||||
// Initialize cleanup manager with signal handlers
|
|
||||||
initializeCleanupManager();
|
|
||||||
|
|
||||||
const { pubClient, ioRedis } = await applySocketIO({ server, app });
|
const { pubClient, ioRedis } = await applySocketIO({ server, app });
|
||||||
const redisHelpers = applyRedisHelpers({ pubClient, app, logger });
|
const redisHelpers = applyRedisHelpers({ pubClient, app, logger });
|
||||||
const ioHelpers = applyIOHelpers({ app, redisHelpers, ioRedis, logger });
|
const ioHelpers = applyIOHelpers({ app, redisHelpers, ioRedis, logger });
|
||||||
@@ -384,9 +330,6 @@ const main = async () => {
|
|||||||
// Legacy Socket Events
|
// Legacy Socket Events
|
||||||
require("./server/web-sockets/web-socket");
|
require("./server/web-sockets/web-socket");
|
||||||
|
|
||||||
// Initialize Queues
|
|
||||||
await loadQueues({ pubClient: pubClient, logger, redisHelpers, ioRedis });
|
|
||||||
|
|
||||||
applyMiddleware({ app });
|
applyMiddleware({ app });
|
||||||
applyRoutes({ app });
|
applyRoutes({ app });
|
||||||
redisSocketEvents({ io: ioRedis, redisHelpers, ioHelpers, logger });
|
redisSocketEvents({ io: ioRedis, redisHelpers, ioHelpers, logger });
|
||||||
@@ -396,11 +339,15 @@ const main = async () => {
|
|||||||
StatusReporter.end();
|
StatusReporter.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add SIGTERM signal handler
|
||||||
|
process.on("SIGTERM", handleSigterm);
|
||||||
|
process.on("SIGINT", handleSigterm); // Optional: Handle Ctrl+C
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await server.listen(port);
|
await server.listen(port);
|
||||||
logger.log(`Server started on port ${port}`, "INFO", "api");
|
logger.log(`Server started on port ${port}`, "INFO", "api");
|
||||||
} catch (error) {
|
} 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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -414,3 +361,33 @@ main().catch((error) => {
|
|||||||
// Note: If we want the app to crash on all uncaught async operations, we would
|
// Note: If we want the app to crash on all uncaught async operations, we would
|
||||||
// need to put a `process.exit(1);` here
|
// need to put a `process.exit(1);` here
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Register a cleanup task
|
||||||
|
function registerCleanupTask(task) {
|
||||||
|
cleanupTasks.push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIGTERM handler
|
||||||
|
async function handleSigterm() {
|
||||||
|
if (isShuttingDown) {
|
||||||
|
logger.log("sigterm-api", "WARN", null, null, { message: "Shutdown already in progress, ignoring signal." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isShuttingDown = true;
|
||||||
|
|
||||||
|
logger.log("sigterm-api", "WARN", null, null, { message: "SIGTERM Received. Starting graceful shutdown." });
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const task of cleanupTasks) {
|
||||||
|
logger.log("sigterm-api", "WARN", null, null, { message: `Running cleanup task: ${task.name}` });
|
||||||
|
|
||||||
|
await task();
|
||||||
|
}
|
||||||
|
logger.log("sigterm-api", "WARN", null, null, { message: `All cleanup tasks completed.` });
|
||||||
|
} catch (error) {
|
||||||
|
logger.log("sigterm-api-error", "ERROR", null, null, { message: error.message, stack: error.stack });
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const OAuthClient = require("intuit-oauth");
|
|||||||
const client = require("../../graphql-client/graphql-client").client;
|
const client = require("../../graphql-client/graphql-client").client;
|
||||||
const queries = require("../../graphql-client/queries");
|
const queries = require("../../graphql-client/queries");
|
||||||
const { parse, stringify } = require("querystring");
|
const { parse, stringify } = require("querystring");
|
||||||
const { InstanceEndpoints } = require("../../utils/instanceMgr");
|
const InstanceManager = require("../../utils/instanceMgr").default;
|
||||||
|
|
||||||
const oauthClient = new OAuthClient({
|
const oauthClient = new OAuthClient({
|
||||||
clientId: process.env.QBO_CLIENT_ID,
|
clientId: process.env.QBO_CLIENT_ID,
|
||||||
@@ -17,8 +17,16 @@ const oauthClient = new OAuthClient({
|
|||||||
logging: true
|
logging: true
|
||||||
});
|
});
|
||||||
|
|
||||||
//TODO:AIO Add in QBO callbacks.
|
let url;
|
||||||
const url = InstanceEndpoints();
|
|
||||||
|
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) => {
|
exports.default = async (req, res) => {
|
||||||
const queryString = req.url.split("?").reverse()[0];
|
const queryString = req.url.split("?").reverse()[0];
|
||||||
|
|||||||
@@ -69,14 +69,11 @@ const sendServerEmail = async ({ subject, text }) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
(err, info) => {
|
(err, info) => {
|
||||||
logger.log("server-email-failure", err ? "error" : "debug", null, null, {
|
logger.log("server-email-failure", err ? "error" : "debug", null, null, { message: err || info });
|
||||||
message: err?.message,
|
|
||||||
stack: err?.stack
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.log("server-email-failure", "error", null, null, { message: error?.message, stack: error?.stack });
|
logger.log("server-email-failure", "error", null, null, { error });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,11 +92,11 @@ const sendTaskEmail = async ({ to, subject, type = "text", html, text, attachmen
|
|||||||
},
|
},
|
||||||
(err, info) => {
|
(err, info) => {
|
||||||
// (message, type, user, record, meta
|
// (message, type, user, record, meta
|
||||||
logger.log("server-email", err ? "error" : "debug", null, null, { message: err?.message, stack: err?.stack });
|
logger.log("server-email", err ? "error" : "debug", null, null, { message: err ? err?.message : info });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.log("server-email-failure", "error", null, null, { message: error?.message, stack: error?.stack });
|
logger.log("server-email-failure", "error", null, null, { error });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,8 +125,7 @@ const sendEmail = async (req, res) => {
|
|||||||
cc: req.body.cc,
|
cc: req.body.cc,
|
||||||
subject: req.body.subject,
|
subject: req.body.subject,
|
||||||
templateStrings: req.body.templateStrings,
|
templateStrings: req.body.templateStrings,
|
||||||
errorMessage: error?.message,
|
error
|
||||||
errorStack: error?.stack
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -198,8 +194,7 @@ const sendEmail = async (req, res) => {
|
|||||||
cc: req.body.cc,
|
cc: req.body.cc,
|
||||||
subject: req.body.subject,
|
subject: req.body.subject,
|
||||||
templateStrings: req.body.templateStrings,
|
templateStrings: req.body.templateStrings,
|
||||||
errorMessage: err?.message,
|
error: err
|
||||||
errorStack: err?.stack
|
|
||||||
});
|
});
|
||||||
logEmail(req, {
|
logEmail(req, {
|
||||||
to: req.body.to,
|
to: req.body.to,
|
||||||
@@ -207,7 +202,7 @@ const sendEmail = async (req, res) => {
|
|||||||
subject: req.body.subject,
|
subject: req.body.subject,
|
||||||
bodyshopid: req.body.bodyshopid
|
bodyshopid: req.body.bodyshopid
|
||||||
});
|
});
|
||||||
res.status(500).json({ success: false, errorMessage: err?.message, stack: err?.stack });
|
res.status(500).json({ success: false, error: err });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -275,16 +270,14 @@ ${body.bounce?.bouncedRecipients.map(
|
|||||||
},
|
},
|
||||||
(err, info) => {
|
(err, info) => {
|
||||||
logger.log("sns-error", err ? "error" : "debug", "api", null, {
|
logger.log("sns-error", err ? "error" : "debug", "api", null, {
|
||||||
errorMessage: err?.message,
|
message: err ? err?.message : info
|
||||||
errorStack: err?.stack
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.log("sns-error", "ERROR", "api", null, {
|
logger.log("sns-error", "ERROR", "api", null, {
|
||||||
errorMessage: error?.message,
|
error: JSON.stringify(error)
|
||||||
errorStack: error?.stack
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.sendStatus(200);
|
res.sendStatus(200);
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ const generateEmailTemplate = require("./generateTemplate");
|
|||||||
const moment = require("moment-timezone");
|
const moment = require("moment-timezone");
|
||||||
const { taskEmailQueue } = require("./tasksEmailsQueue");
|
const { taskEmailQueue } = require("./tasksEmailsQueue");
|
||||||
const mailer = require("./mailer");
|
const mailer = require("./mailer");
|
||||||
const { InstanceEndpoints } = require("../utils/instanceMgr");
|
|
||||||
const { formatTaskPriority } = require("../notifications/stringHelpers");
|
|
||||||
|
|
||||||
// Initialize the Tasks Email Queue
|
// Initialize the Tasks Email Queue
|
||||||
const tasksEmailQueue = taskEmailQueue();
|
const tasksEmailQueue = taskEmailQueue();
|
||||||
@@ -63,6 +61,16 @@ const formatDate = (date) => {
|
|||||||
return date ? `| Due on: ${moment(date).format("MM/DD/YYYY")}` : "";
|
return date ? `| Due on: ${moment(date).format("MM/DD/YYYY")}` : "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatPriority = (priority) => {
|
||||||
|
if (priority === 1) {
|
||||||
|
return "High";
|
||||||
|
} else if (priority === 3) {
|
||||||
|
return "Low";
|
||||||
|
} else {
|
||||||
|
return "Medium";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate the email template arguments.
|
* Generate the email template arguments.
|
||||||
* @param title
|
* @param title
|
||||||
@@ -75,11 +83,18 @@ const formatDate = (date) => {
|
|||||||
* @param taskId
|
* @param taskId
|
||||||
* @returns {{header, body: string, subHeader: string}}
|
* @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 generateTemplateArgs = (title, priority, description, dueDate, bodyshop, job, taskId, dateLine, createdBy) => {
|
||||||
const endPoints = InstanceEndpoints();
|
const endPoints = getEndpoints(bodyshop);
|
||||||
return {
|
return {
|
||||||
header: title,
|
header: title,
|
||||||
subHeader: `Body Shop: ${bodyshop.shopname} | Priority: ${formatTaskPriority(priority)} ${formatDate(dueDate)} | Created By: ${createdBy || "N/A"}`,
|
subHeader: `Body Shop: ${bodyshop.shopname} | Priority: ${formatPriority(priority)} ${formatDate(dueDate)} | Created By: ${createdBy || "N/A"}`,
|
||||||
body: `Reference: ${job.ro_number || "N/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()}<br>${description ? description.concat("<br>") : ""}<a href="${endPoints}/manage/tasks/alltasks?taskid=${taskId}">View this task.</a>`,
|
body: `Reference: ${job.ro_number || "N/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()}<br>${description ? description.concat("<br>") : ""}<a href="${endPoints}/manage/tasks/alltasks?taskid=${taskId}">View this task.</a>`,
|
||||||
dateLine
|
dateLine
|
||||||
};
|
};
|
||||||
@@ -93,8 +108,9 @@ const generateTemplateArgs = (title, priority, description, dueDate, bodyshop, j
|
|||||||
* @param html
|
* @param html
|
||||||
* @param taskIds
|
* @param taskIds
|
||||||
* @param successCallback
|
* @param successCallback
|
||||||
|
* @param requestInstance
|
||||||
*/
|
*/
|
||||||
const sendMail = (type, to, subject, html, taskIds, successCallback) => {
|
const sendMail = (type, to, subject, html, taskIds, successCallback, requestInstance) => {
|
||||||
const fromEmails = InstanceManager({
|
const fromEmails = InstanceManager({
|
||||||
imex: "ImEX Online <noreply@imex.online>",
|
imex: "ImEX Online <noreply@imex.online>",
|
||||||
rome: "Rome Online <noreply@romeonline.io>"
|
rome: "Rome Online <noreply@romeonline.io>"
|
||||||
@@ -120,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 req
|
||||||
* @param res
|
* @param res
|
||||||
* @returns {Promise<*>}
|
* @returns {Promise<*>}
|
||||||
@@ -146,7 +162,7 @@ const taskAssignedEmail = async (req, res) => {
|
|||||||
sendMail(
|
sendMail(
|
||||||
"assigned",
|
"assigned",
|
||||||
tasks_by_pk.assigned_to_employee.user_email,
|
tasks_by_pk.assigned_to_employee.user_email,
|
||||||
`A ${formatTaskPriority(newTask.priority)} priority task has been ${dirty ? "reassigned to" : "created for"} you - ${newTask.title}`,
|
`A ${formatPriority(newTask.priority)} priority task has been ${dirty ? "reassigned to" : "created for"} you - ${newTask.title}`,
|
||||||
generateEmailTemplate(
|
generateEmailTemplate(
|
||||||
generateTemplateArgs(
|
generateTemplateArgs(
|
||||||
newTask.title,
|
newTask.title,
|
||||||
@@ -170,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 req
|
||||||
* @param res
|
* @param res
|
||||||
* @returns {Promise<*>}
|
* @returns {Promise<*>}
|
||||||
@@ -230,7 +246,7 @@ const tasksRemindEmail = async (req, res) => {
|
|||||||
const onlyTask = groupedTasks[recipient.email][0];
|
const onlyTask = groupedTasks[recipient.email][0];
|
||||||
|
|
||||||
emailData.subject =
|
emailData.subject =
|
||||||
`New ${formatTaskPriority(onlyTask.priority)} Priority Task Reminder - ${onlyTask.title} ${onlyTask.due_date ? `- ${formatDate(onlyTask.due_date)}` : ""}`.trim();
|
`New ${formatPriority(onlyTask.priority)} Priority Task Reminder - ${onlyTask.title} ${onlyTask.due_date ? `- ${formatDate(onlyTask.due_date)}` : ""}`.trim();
|
||||||
|
|
||||||
emailData.html = generateEmailTemplate(
|
emailData.html = generateEmailTemplate(
|
||||||
generateTemplateArgs(
|
generateTemplateArgs(
|
||||||
@@ -248,6 +264,11 @@ const tasksRemindEmail = async (req, res) => {
|
|||||||
}
|
}
|
||||||
// There are multiple emails to send to this author.
|
// There are multiple emails to send to this author.
|
||||||
else {
|
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];
|
const allTasks = groupedTasks[recipient.email];
|
||||||
emailData.subject = `New Tasks Reminder - ${allTasks.length} Tasks require your attention`;
|
emailData.subject = `New Tasks Reminder - ${allTasks.length} Tasks require your attention`;
|
||||||
emailData.html = generateEmailTemplate({
|
emailData.html = generateEmailTemplate({
|
||||||
@@ -257,7 +278,7 @@ const tasksRemindEmail = async (req, res) => {
|
|||||||
body: `<ul>
|
body: `<ul>
|
||||||
${allTasks
|
${allTasks
|
||||||
.map((task) =>
|
.map((task) =>
|
||||||
`<li><a href="${InstanceEndpoints()}/manage/tasks/alltasks?taskid=${task.id}">${task.title} - Priority: ${formatTaskPriority(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("")}
|
.join("")}
|
||||||
</ul>`
|
</ul>`
|
||||||
@@ -317,5 +338,6 @@ const tasksRemindEmail = async (req, res) => {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
taskAssignedEmail,
|
taskAssignedEmail,
|
||||||
tasksRemindEmail
|
tasksRemindEmail,
|
||||||
|
getEndpoints
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2705,67 +2705,3 @@ exports.INSERT_AUDIT_TRAIL = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports.GET_JOB_WATCHERS = `
|
|
||||||
query GET_JOB_WATCHERS($jobid: uuid!) {
|
|
||||||
job_watchers(where: { jobid: { _eq: $jobid } }) {
|
|
||||||
user_email
|
|
||||||
user {
|
|
||||||
authid
|
|
||||||
employee {
|
|
||||||
id
|
|
||||||
first_name
|
|
||||||
last_name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
job: jobs_by_pk(id: $jobid) {
|
|
||||||
id
|
|
||||||
ro_number
|
|
||||||
clm_no
|
|
||||||
bodyshop {
|
|
||||||
id
|
|
||||||
shopname
|
|
||||||
timezone
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
|
|
||||||
// REMEMBER: Update the cache_bodyshop event in hasura to include any added fields
|
|
||||||
exports.GET_BODYSHOP_BY_ID = `
|
|
||||||
query GET_BODYSHOP_BY_ID($id: uuid!) {
|
|
||||||
bodyshops_by_pk(id: $id) {
|
|
||||||
id
|
|
||||||
md_order_statuses
|
|
||||||
shopname
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ const moment = require("moment");
|
|||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
const { sendTaskEmail } = require("../email/sendemail");
|
const { sendTaskEmail } = require("../email/sendemail");
|
||||||
const generateEmailTemplate = require("../email/generateTemplate");
|
const generateEmailTemplate = require("../email/generateTemplate");
|
||||||
|
const { getEndpoints } = require("../email/tasksEmails");
|
||||||
|
|
||||||
const domain = process.env.NODE_ENV ? "secure" : "test";
|
const domain = process.env.NODE_ENV ? "secure" : "test";
|
||||||
|
|
||||||
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
|
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
|
||||||
const { InstanceRegion, InstanceEndpoints } = require("../utils/instanceMgr");
|
const { InstanceRegion } = require("../utils/instanceMgr");
|
||||||
|
|
||||||
const client = new SecretsManagerClient({
|
const client = new SecretsManagerClient({
|
||||||
region: InstanceRegion()
|
region: InstanceRegion()
|
||||||
@@ -442,28 +443,31 @@ exports.postback = async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (values.origin === "OneLink" && parsedComment.userEmail) {
|
if (values.origin === "OneLink" && parsedComment.userEmail) {
|
||||||
sendTaskEmail({
|
try {
|
||||||
to: parsedComment.userEmail,
|
const endPoints = getEndpoints();
|
||||||
subject: `New Payment(s) Received - RO ${jobs.jobs.map((j) => j.ro_number).join(", ")}`,
|
sendTaskEmail({
|
||||||
type: "html",
|
to: parsedComment.userEmail,
|
||||||
html: generateEmailTemplate({
|
subject: `New Payment(s) Received - RO ${jobs.jobs.map((j) => j.ro_number).join(", ")}`,
|
||||||
header: "New Payment(s) Received",
|
type: "html",
|
||||||
subHeader: "",
|
html: generateEmailTemplate({
|
||||||
body: jobs.jobs
|
header: "New Payment(s) Received",
|
||||||
.map(
|
subHeader: "",
|
||||||
(job) =>
|
body: jobs.jobs
|
||||||
`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}`
|
.map(
|
||||||
)
|
(job) =>
|
||||||
.join("<br/>")
|
`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}`
|
||||||
})
|
)
|
||||||
}).catch((error) => {
|
.join("<br/>")
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
logger.log("intellipay-postback-email-error", "ERROR", req.user?.email, null, {
|
logger.log("intellipay-postback-email-error", "ERROR", req.user?.email, null, {
|
||||||
message: error.message,
|
message: error.message,
|
||||||
jobs,
|
jobs,
|
||||||
paymentResult,
|
paymentResult,
|
||||||
...logResponseMeta
|
...logResponseMeta
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
res.sendStatus(200);
|
res.sendStatus(200);
|
||||||
} else if (values.invoice) {
|
} else if (values.invoice) {
|
||||||
|
|||||||
@@ -1,196 +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, { message: error?.message, stack: error?.stack });
|
|
||||||
});
|
|
||||||
|
|
||||||
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.
|
|
||||||
* Processes both old and new job IDs if the document was moved between jobs.
|
|
||||||
*
|
|
||||||
* @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) => {
|
|
||||||
const { logger } = req;
|
|
||||||
const newJobId = req.body?.event?.data?.new?.jobid;
|
|
||||||
const oldJobId = req.body?.event?.data?.old?.jobid;
|
|
||||||
|
|
||||||
// If jobid changed (document moved between jobs), we need to notify both jobs
|
|
||||||
if (oldJobId && newJobId && oldJobId !== newJobId) {
|
|
||||||
// Process notification for new job ID
|
|
||||||
scenarioParser(req, "req.body.event.new.jobid").catch((error) => {
|
|
||||||
logger.log("notifications-error", "error", "notifications", null, {
|
|
||||||
message: error?.message,
|
|
||||||
stack: error?.stack
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a modified request for old job ID
|
|
||||||
const oldJobReq = {
|
|
||||||
body: {
|
|
||||||
...req.body,
|
|
||||||
event: {
|
|
||||||
...req.body.event,
|
|
||||||
data: {
|
|
||||||
new: {
|
|
||||||
...req.body.event.data.old,
|
|
||||||
// Add a flag to indicate this document was moved away
|
|
||||||
_documentMoved: true,
|
|
||||||
_movedToJob: newJobId
|
|
||||||
},
|
|
||||||
old: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
logger,
|
|
||||||
sessionUtils: req.sessionUtils
|
|
||||||
};
|
|
||||||
|
|
||||||
// Process notification for old job ID using the modified request
|
|
||||||
scenarioParser(oldJobReq, "req.body.event.new.jobid").catch((error) => {
|
|
||||||
logger.log("notifications-error", "error", "notifications", null, {
|
|
||||||
message: error?.message,
|
|
||||||
stack: error?.stack
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(200).json({ message: "Documents Change Notifications Event Handled for both jobs." });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise just process the new job ID
|
|
||||||
scenarioParser(req, "req.body.event.new.jobid").catch((error) => {
|
|
||||||
logger.log("notifications-error", "error", "notifications", null, {
|
|
||||||
message: error?.message,
|
|
||||||
stack: error?.stack
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(200).json({ message: "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
|
|
||||||
};
|
|
||||||
5
server/notifications/eventHandlers/handeJobsChange.js
Normal file
5
server/notifications/eventHandlers/handeJobsChange.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
const handleJobsChange = (req, res) => {
|
||||||
|
return res.status(200).json({ message: "Jobs change handled." });
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = handleJobsChange;
|
||||||
5
server/notifications/eventHandlers/handleBillsChange.js
Normal file
5
server/notifications/eventHandlers/handleBillsChange.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
const handleBillsChange = (req, res) => {
|
||||||
|
return res.status(200).json({ message: "Bills change handled." });
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = handleBillsChange;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
const handlePartsDispatchChange = (req, res) => {
|
||||||
|
return res.status(200).json({ message: "Parts Dispatch change handled." });
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = handlePartsDispatchChange;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
const handlePartsOrderChange = (req, res) => {
|
||||||
|
return res.status(200).json({ message: "Parts Order change handled." });
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = handlePartsOrderChange;
|
||||||
5
server/notifications/eventHandlers/handleTasksChange.js
Normal file
5
server/notifications/eventHandlers/handleTasksChange.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
const handleTasksChange = (req, res) => {
|
||||||
|
return res.status(200).json({ message: "Tasks change handled." });
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = handleTasksChange;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
const handleTimeTicketsChange = (req, res) => {
|
||||||
|
return res.status(200).json({ message: "Time Tickets change handled." });
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = handleTimeTicketsChange;
|
||||||
@@ -1,74 +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 returns 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.jobId] - The job ID, if already extracted by the caller (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 provided jobId or null if not provided.
|
|
||||||
*/
|
|
||||||
const eventParser = async ({ oldData, newData, trigger, table, jobId = null }) => {
|
|
||||||
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 didn’t exist before
|
|
||||||
JSON.stringify(oldData[key]) !== JSON.stringify(newData[key]) // Values differ (deep comparison)
|
|
||||||
) {
|
|
||||||
changedFields[key] = {
|
|
||||||
old: oldData[key], // Undefined if field wasn’t 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 // Provided jobId or null
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = eventParser;
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
const { Queue, Worker } = require("bullmq");
|
|
||||||
const { INSERT_NOTIFICATIONS_MUTATION } = require("../../graphql-client/queries");
|
|
||||||
const { registerCleanupTask } = require("../../utils/cleanupManager");
|
|
||||||
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) ? 3 : Math.max(1, parsedValue); // Default to 3, 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.debug("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.debug(`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.debug(`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.debug(`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.debug(`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.debug(
|
|
||||||
`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.debug(
|
|
||||||
`Sent ${notifications.length} consolidated notifications to ${user} for jobId ${jobId} with notificationId ${notificationId}`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logger.logger.debug(`No socket IDs found for ${user} in bodyShopId ${bodyShopId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await pubClient.del(`app:consolidate:${jobId}`);
|
|
||||||
} catch (err) {
|
|
||||||
logger.log(`app-queue-consolidation-error`, "ERROR", "notifications", "api", {
|
|
||||||
message: err?.message,
|
|
||||||
stack: err?.stack
|
|
||||||
});
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
await pubClient.del(lockKey);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.logger.debug(`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.debug(`Add job ${job.id} completed`));
|
|
||||||
consolidateWorker.on("completed", (job) => logger.logger.debug(`Consolidate job ${job.id} completed`));
|
|
||||||
addWorker.on("failed", (job, err) =>
|
|
||||||
logger.log(`app-queue-notification-error`, "ERROR", "notifications", "api", {
|
|
||||||
message: err?.message,
|
|
||||||
stack: err?.stack
|
|
||||||
})
|
|
||||||
);
|
|
||||||
consolidateWorker.on("failed", (job, err) =>
|
|
||||||
logger.log(`app-queue-consolidation-failed:`, "ERROR", "notifications", "api", {
|
|
||||||
message: err?.message,
|
|
||||||
stack: err?.stack
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Register cleanup task instead of direct process listeners
|
|
||||||
const shutdown = async () => {
|
|
||||||
logger.logger.debug("Closing app queue workers...");
|
|
||||||
await Promise.all([addWorker.close(), consolidateWorker.close()]);
|
|
||||||
logger.logger.debug("App queue workers closed");
|
|
||||||
};
|
|
||||||
|
|
||||||
registerCleanupTask(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.debug(`Added notification to queue for jobId ${jobId} with ${recipients.length} recipients`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = { loadAppQueue, getQueue, dispatchAppsToQueue };
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
const { Queue, Worker } = require("bullmq");
|
|
||||||
const { sendTaskEmail } = require("../../email/sendemail");
|
|
||||||
const generateEmailTemplate = require("../../email/generateTemplate");
|
|
||||||
const { InstanceEndpoints } = require("../../utils/instanceMgr");
|
|
||||||
const { registerCleanupTask } = require("../../utils/cleanupManager");
|
|
||||||
|
|
||||||
const EMAIL_CONSOLIDATION_DELAY_IN_MINS = (() => {
|
|
||||||
const envValue = process.env?.EMAIL_CONSOLIDATION_DELAY_IN_MINS;
|
|
||||||
const parsedValue = envValue ? parseInt(envValue, 10) : NaN;
|
|
||||||
return isNaN(parsedValue) ? 3 : Math.max(1, parsedValue); // Default to 3, 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.debug("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.debug(`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);
|
|
||||||
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);
|
|
||||||
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,
|
|
||||||
backoff: LOCK_EXPIRATION
|
|
||||||
}
|
|
||||||
);
|
|
||||||
logger.logger.debug(`Scheduled email consolidation for jobId ${jobId}`);
|
|
||||||
await pubClient.expire(consolidateKey, CONSOLIDATION_KEY_EXPIRATION / 1000);
|
|
||||||
} 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.debug(`Consolidating emails for jobId ${jobId}`);
|
|
||||||
|
|
||||||
const lockKey = `lock:emailConsolidate:${jobId}`;
|
|
||||||
const lockAcquired = await pubClient.set(lockKey, "locked", "NX", "EX", LOCK_EXPIRATION / 1000);
|
|
||||||
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 || "N/A"} at ${bodyShopName}`;
|
|
||||||
const emailBody = generateEmailTemplate({
|
|
||||||
header: `${multipleUpdateString} for Job ${jobRoNumber || "N/A"}`,
|
|
||||||
subHeader: `Dear ${firstName},`,
|
|
||||||
body: `
|
|
||||||
<p>There have been updates to job ${jobRoNumber || "N/A"} 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.debug(
|
|
||||||
`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.log(`email-queue-consolidation-error`, "ERROR", "notifications", "api", {
|
|
||||||
message: err?.message,
|
|
||||||
stack: err?.stack
|
|
||||||
});
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
await pubClient.del(lockKey);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.logger.debug(`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.debug(`Email add job ${job.id} completed`));
|
|
||||||
emailConsolidateWorker.on("completed", (job) => logger.logger.debug(`Email consolidate job ${job.id} completed`));
|
|
||||||
emailAddWorker.on("failed", (job, err) =>
|
|
||||||
logger.log(`add-email-queue-failed`, "ERROR", "notifications", "api", {
|
|
||||||
message: err?.message,
|
|
||||||
stack: err?.stack
|
|
||||||
})
|
|
||||||
);
|
|
||||||
emailConsolidateWorker.on("failed", (job, err) =>
|
|
||||||
logger.log(`email-consolidation-job-failed`, "ERROR", "notifications", "api", {
|
|
||||||
message: err?.message,
|
|
||||||
stack: err?.stack
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Register cleanup task instead of direct process listeners
|
|
||||||
const shutdown = async () => {
|
|
||||||
logger.logger.debug("Closing email queue workers...");
|
|
||||||
await Promise.all([emailAddWorker.close(), emailConsolidateWorker.close()]);
|
|
||||||
logger.logger.debug("Email queue workers closed");
|
|
||||||
};
|
|
||||||
registerCleanupTask(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 || "N/A"}, 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.debug(`Added email notification to queue for jobId ${jobId} with ${recipients.length} recipients`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = { loadEmailQueue, getQueue, dispatchEmailsToQueue };
|
|
||||||
@@ -1,691 +0,0 @@
|
|||||||
const { getJobAssignmentType, formatTaskPriority } = require("./stringHelpers");
|
|
||||||
const moment = require("moment-timezone");
|
|
||||||
const { startCase } = require("lodash");
|
|
||||||
const Dinero = require("dinero.js");
|
|
||||||
|
|
||||||
Dinero.globalRoundingMode = "HALF_EVEN";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 transportation has been changed from ${data.changedFields.alt_transport?.old || "unset"} to ${data?.changedFields?.alt_transport?.new || "unset"}.`;
|
|
||||||
const result = {
|
|
||||||
app: {
|
|
||||||
jobId: data.jobId,
|
|
||||||
bodyShopId: data.bodyShopId,
|
|
||||||
jobRoNumber: data.jobRoNumber,
|
|
||||||
key: "notifications.job.alternateTransportChanged",
|
|
||||||
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 facing = data?.data?.isinhouse ? "in-house" : "vendor";
|
|
||||||
const body = `An ${facing} ${data?.data?.is_credit_memo ? "credit memo" : "bill"} has been posted.`.trim();
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
app: {
|
|
||||||
jobId: data.jobId,
|
|
||||||
jobRoNumber: data.jobRoNumber,
|
|
||||||
bodyShopId: data.bodyShopId,
|
|
||||||
key: "notifications.job.billPosted",
|
|
||||||
body,
|
|
||||||
variables: {
|
|
||||||
isInHouse: data?.data?.isinhouse,
|
|
||||||
isCreditMemo: data?.data?.is_credit_memo
|
|
||||||
},
|
|
||||||
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 status on a critical part line (${data?.data?.line_desc}) has changed to ${data?.data?.status || "unset"}.`;
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
app: {
|
|
||||||
jobId: data.jobId,
|
|
||||||
bodyShopId: data.bodyShopId,
|
|
||||||
jobRoNumber: data.jobRoNumber,
|
|
||||||
key: "notifications.job.criticalPartsStatusChanged",
|
|
||||||
body,
|
|
||||||
variables: {
|
|
||||||
joblineId: data?.data?.id, // If we want to deeplink to the jobline
|
|
||||||
status: data?.data?.status,
|
|
||||||
line_desc: data?.data?.line_desc
|
|
||||||
},
|
|
||||||
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 is now in 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 || "unset"} to ${data?.changedFields?.status?.new || "unset"}`;
|
|
||||||
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) => {
|
|
||||||
// Determine if it's an image or document
|
|
||||||
const mediaType = data?.data?.type?.startsWith("image") ? "Image" : "Document";
|
|
||||||
|
|
||||||
// Determine the action
|
|
||||||
let action;
|
|
||||||
|
|
||||||
if (data?.data?._documentMoved) {
|
|
||||||
action = "moved to another job"; // Special case for document moved from this job
|
|
||||||
} else if (data.isNew) {
|
|
||||||
action = "added"; // New media
|
|
||||||
} else if (data.changedFields?.jobid && data.changedFields.jobid.old !== data.changedFields.jobid.new) {
|
|
||||||
action = "moved to this job";
|
|
||||||
} else {
|
|
||||||
action = "updated";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct the body string
|
|
||||||
const body = `An ${mediaType} has been ${action}.`;
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
app: {
|
|
||||||
jobId: data.jobId,
|
|
||||||
jobRoNumber: data.jobRoNumber,
|
|
||||||
bodyShopId: data.bodyShopId,
|
|
||||||
key: "notifications.job.newMediaAdded",
|
|
||||||
body,
|
|
||||||
variables: {
|
|
||||||
mediaType,
|
|
||||||
action,
|
|
||||||
movedToJob: data?.data?._movedToJob
|
|
||||||
},
|
|
||||||
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",
|
|
||||||
data?.data?.critical && "critical",
|
|
||||||
data?.data?.private && "private",
|
|
||||||
data?.data?.type,
|
|
||||||
"note has been added by",
|
|
||||||
`${data.data.created_by}`
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
app: {
|
|
||||||
jobId: data.jobId,
|
|
||||||
jobRoNumber: data.jobRoNumber,
|
|
||||||
bodyShopId: data.bodyShopId,
|
|
||||||
key: "notifications.job.newNoteAdded",
|
|
||||||
body,
|
|
||||||
variables: {
|
|
||||||
createdBy: data?.data?.created_by,
|
|
||||||
critical: data?.data?.critical,
|
|
||||||
type: data?.data?.type,
|
|
||||||
private: data?.data?.private
|
|
||||||
},
|
|
||||||
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 type = data?.data?.cost_center;
|
|
||||||
const body = `A ${startCase(type.toLowerCase())} time ticket for ${data?.data?.date} has been posted.`.trim();
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
app: {
|
|
||||||
jobId: data.jobId,
|
|
||||||
jobRoNumber: data.jobRoNumber,
|
|
||||||
bodyShopId: data.bodyShopId,
|
|
||||||
key: "notifications.job.newTimeTicketPosted",
|
|
||||||
body,
|
|
||||||
variables: {
|
|
||||||
type,
|
|
||||||
date: data?.data?.date
|
|
||||||
},
|
|
||||||
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 ${data?.data?.line_desc} has been marked as back-ordered.`;
|
|
||||||
const result = {
|
|
||||||
app: {
|
|
||||||
jobId: data.jobId,
|
|
||||||
jobRoNumber: data.jobRoNumber,
|
|
||||||
bodyShopId: data.bodyShopId,
|
|
||||||
key: "notifications.job.partBackOrdered",
|
|
||||||
body,
|
|
||||||
variables: {
|
|
||||||
line_desc: data?.data?.line_desc
|
|
||||||
},
|
|
||||||
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 momentFormat = "MM/DD/YYYY";
|
|
||||||
|
|
||||||
// Format amount using Dinero.js
|
|
||||||
const amountDinero = Dinero({
|
|
||||||
amount: Math.round((data.data.amount || 0) * 100) // Convert to cents, default to 0 if missing
|
|
||||||
});
|
|
||||||
|
|
||||||
const amountFormatted = amountDinero.toFormat();
|
|
||||||
|
|
||||||
const payer = data.data.payer;
|
|
||||||
const paymentType = data.data.type;
|
|
||||||
const paymentDate = moment(data.data.date).format(momentFormat);
|
|
||||||
|
|
||||||
const body = `Payment of ${amountFormatted} has been collected from ${payer} via ${paymentType} on ${paymentDate}`;
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
app: {
|
|
||||||
jobId: data.jobId,
|
|
||||||
jobRoNumber: data.jobRoNumber,
|
|
||||||
bodyShopId: data.bodyShopId,
|
|
||||||
key: "notifications.job.paymentCollected",
|
|
||||||
body,
|
|
||||||
variables: {
|
|
||||||
amount: data.data.amount,
|
|
||||||
payer: data.data.payer,
|
|
||||||
type: data.data.type,
|
|
||||||
date: data.data.date
|
|
||||||
},
|
|
||||||
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 changedFields = data.changedFields;
|
|
||||||
|
|
||||||
// Define field configurations
|
|
||||||
const fieldConfigs = {
|
|
||||||
scheduled_in: "Scheduled In",
|
|
||||||
scheduled_completion: "Scheduled Completion",
|
|
||||||
scheduled_delivery: "Scheduled Delivery"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to format date and time with "at"
|
|
||||||
const formatDateTime = (date) => {
|
|
||||||
if (!date) return "unset";
|
|
||||||
const formatted = moment(date).tz(data.bodyShopTimezone);
|
|
||||||
const datePart = formatted.format("MM/DD/YYYY");
|
|
||||||
const timePart = formatted.format("hh:mm a");
|
|
||||||
return `${datePart} at ${timePart}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build field messages dynamically
|
|
||||||
const fieldMessages = Object.entries(fieldConfigs)
|
|
||||||
.filter(([field]) => changedFields[field]) // Only include changed fields
|
|
||||||
.map(([field, label]) => {
|
|
||||||
const { old, new: newValue } = changedFields[field];
|
|
||||||
|
|
||||||
// Case 1: Scheduled date cancelled (from value to null)
|
|
||||||
if (old && !newValue) {
|
|
||||||
return `${label} was cancelled (previously ${formatDateTime(old)}).`;
|
|
||||||
}
|
|
||||||
// Case 2: Scheduled date set (from null to value)
|
|
||||||
else if (!old && newValue) {
|
|
||||||
return `${label} was set to ${formatDateTime(newValue)}.`;
|
|
||||||
}
|
|
||||||
// Case 3: Scheduled date changed (from value to value)
|
|
||||||
else if (old && newValue) {
|
|
||||||
return `${label} changed from ${formatDateTime(old)} to ${formatDateTime(newValue)}.`;
|
|
||||||
}
|
|
||||||
return ""; // Fallback, though this shouldn't happen with the filter
|
|
||||||
})
|
|
||||||
.filter(Boolean); // Remove any empty strings
|
|
||||||
|
|
||||||
const body = fieldMessages.length > 0 ? fieldMessages.join(" ") : "Scheduled dates have been updated.";
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
app: {
|
|
||||||
jobId: data.jobId,
|
|
||||||
jobRoNumber: data.jobRoNumber,
|
|
||||||
bodyShopId: data.bodyShopId,
|
|
||||||
key: "notifications.job.scheduledDatesChanged",
|
|
||||||
body,
|
|
||||||
variables: {
|
|
||||||
scheduledIn: changedFields.scheduled_in?.new,
|
|
||||||
oldScheduledIn: changedFields.scheduled_in?.old,
|
|
||||||
scheduledCompletion: changedFields.scheduled_completion?.new,
|
|
||||||
oldScheduledCompletion: changedFields.scheduled_completion?.old,
|
|
||||||
scheduledDelivery: changedFields.scheduled_delivery?.new,
|
|
||||||
oldScheduledDelivery: 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 tasks updated or created.
|
|
||||||
*/
|
|
||||||
const tasksUpdatedCreatedBuilder = (data) => {
|
|
||||||
const momentFormat = "MM/DD/YYYY hh:mm a";
|
|
||||||
const timezone = data.bodyShopTimezone;
|
|
||||||
const taskTitle = data?.data?.title ? `"${data.data.title}"` : "Unnamed Task";
|
|
||||||
|
|
||||||
let body;
|
|
||||||
let variables;
|
|
||||||
|
|
||||||
if (data.isNew) {
|
|
||||||
// Created case
|
|
||||||
const priority = formatTaskPriority(data?.data?.priority);
|
|
||||||
const createdBy = data?.data?.created_by || "Unknown"; // Fallback for undefined created_by
|
|
||||||
const dueDate = data.data.due_date ? ` due on ${moment(data.data.due_date).tz(timezone).format(momentFormat)}` : "";
|
|
||||||
const completedOnCreation = data.data.completed === true;
|
|
||||||
body = `A ${priority} task ${taskTitle} has been created${completedOnCreation ? " and marked completed" : ""} by ${createdBy}${dueDate}.`;
|
|
||||||
variables = {
|
|
||||||
isNew: data.isNew,
|
|
||||||
roNumber: data.jobRoNumber,
|
|
||||||
title: data?.data?.title,
|
|
||||||
priority: data?.data?.priority,
|
|
||||||
createdBy: data?.data?.created_by,
|
|
||||||
dueDate: data?.data?.due_date,
|
|
||||||
completed: completedOnCreation ? data?.data?.completed : undefined // Only include if true
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Updated case
|
|
||||||
const changedFields = data.changedFields;
|
|
||||||
const fieldNames = Object.keys(changedFields);
|
|
||||||
const oldTitle = changedFields.title ? `"${changedFields.title.old || "Unnamed Task"}"` : taskTitle;
|
|
||||||
|
|
||||||
// Special case: Only 'completed' changed
|
|
||||||
if (fieldNames.length === 1 && changedFields.completed) {
|
|
||||||
body = `Task ${oldTitle} was marked ${changedFields.completed.new ? "complete" : "incomplete"}`;
|
|
||||||
variables = {
|
|
||||||
isNew: data.isNew,
|
|
||||||
roNumber: data.jobRoNumber,
|
|
||||||
title: data?.data?.title,
|
|
||||||
changedCompleted: data?.changedFields?.completed?.new
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// General update case
|
|
||||||
const fieldMessages = [];
|
|
||||||
|
|
||||||
if (changedFields.title) {
|
|
||||||
fieldMessages.push(`Task ${oldTitle} changed title to "${changedFields.title.new || "unnamed task"}".`);
|
|
||||||
}
|
|
||||||
if (changedFields.description) {
|
|
||||||
fieldMessages.push("Description updated.");
|
|
||||||
}
|
|
||||||
if (changedFields.priority) {
|
|
||||||
fieldMessages.push(`Priority changed to ${formatTaskPriority(changedFields.priority.new)}.`);
|
|
||||||
}
|
|
||||||
if (changedFields.due_date) {
|
|
||||||
fieldMessages.push(`Due date set to ${moment(changedFields.due_date.new).tz(timezone).format(momentFormat)}.`);
|
|
||||||
}
|
|
||||||
if (changedFields.completed) {
|
|
||||||
fieldMessages.push(`Status changed to ${changedFields.completed.new ? "complete" : "incomplete"}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
body =
|
|
||||||
fieldMessages.length > 0
|
|
||||||
? fieldMessages.length === 1 && changedFields.title
|
|
||||||
? fieldMessages[0] // If only title changed, use it standalone
|
|
||||||
: `Task ${oldTitle} updated: ${fieldMessages.join(", ")}`
|
|
||||||
: `Task ${oldTitle} has been updated.`;
|
|
||||||
variables = {
|
|
||||||
isNew: data.isNew,
|
|
||||||
roNumber: data.jobRoNumber,
|
|
||||||
title: data?.data?.title,
|
|
||||||
changedTitleOld: data?.changedFields?.title?.old,
|
|
||||||
changedTitleNew: data?.changedFields?.title?.new,
|
|
||||||
changedPriority: data?.changedFields?.priority?.new,
|
|
||||||
changedDueDate: data?.changedFields?.due_date?.new,
|
|
||||||
changedCompleted: data?.changedFields?.completed?.new
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
app: {
|
|
||||||
jobId: data.jobId,
|
|
||||||
jobRoNumber: data.jobRoNumber,
|
|
||||||
bodyShopId: data.bodyShopId,
|
|
||||||
key: data.isNew ? "notifications.job.taskCreated" : "notifications.job.taskUpdated",
|
|
||||||
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 supplement imported events.
|
|
||||||
* TODO: This is an advanced case and will be done later
|
|
||||||
*/
|
|
||||||
const supplementImportedBuilder = (data) => {
|
|
||||||
const body = `A supplement has been imported.`;
|
|
||||||
const result = {
|
|
||||||
app: {
|
|
||||||
jobId: data.jobId,
|
|
||||||
jobRoNumber: data.jobRoNumber,
|
|
||||||
bodyShopId: data.bodyShopId,
|
|
||||||
key: "notifications.job.supplementImported",
|
|
||||||
body,
|
|
||||||
variables: {},
|
|
||||||
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
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user