Merged in release/2025-03-14 (pull request #2204)

Release/2025-03-14 into master-AIO -IO-3096, IO-3166, IO-3169, IO-3170, IO-3172

Approved-by: Patrick Fic
This commit is contained in:
Dave Richer
2025-03-14 22:01:48 +00:00
committed by Patrick Fic
124 changed files with 6613 additions and 2078 deletions

590
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,27 +8,27 @@
"private": true, "private": true,
"proxy": "http://localhost:4000", "proxy": "http://localhost:4000",
"dependencies": { "dependencies": {
"@ant-design/pro-layout": "^7.22.0", "@ant-design/pro-layout": "^7.22.3",
"@apollo/client": "^3.12.6", "@apollo/client": "^3.13.1",
"@emotion/is-prop-valid": "^1.3.1", "@emotion/is-prop-valid": "^1.3.1",
"@fingerprintjs/fingerprintjs": "^4.5.1", "@fingerprintjs/fingerprintjs": "^4.6.1",
"@jsreport/browser-client": "^3.1.0", "@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.5.0", "@reduxjs/toolkit": "^2.6.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.1", "@sentry/vite-plugin": "^3.2.2",
"@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.23.1", "antd": "^5.24.2",
"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.7.9", "axios": "^1.8.1",
"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.2.3", "dayjs-business-days2": "^1.3.0",
"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.2", "i18next-browser-languagedetector": "^8.0.4",
"immutability-helper": "^3.1.1", "immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.11.18", "libphonenumber-js": "^1.12.4",
"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.17.1", "react-big-calendar": "^1.18.0",
"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.4.0", "react-icons": "^5.5.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.26.2", "react-router-dom": "^6.30.0",
"react-sticky": "^6.0.3", "react-sticky": "^6.0.3",
"react-virtuoso": "^4.10.4", "react-virtuoso": "^4.12.5",
"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.83.4", "sass": "^1.85.1",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"styled-components": "^6.1.14", "styled-components": "^6.1.15",
"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.6", "userpilot": "^1.3.8",
"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.5.2", "@ant-design/icons": "^5.6.1",
"@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.33.0", "@dotenvx/dotenvx": "^1.38.3",
"@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.18.0", "@eslint/js": "^9.21.0",
"@sentry/webpack-plugin": "^3.2.1", "@sentry/webpack-plugin": "^3.2.2",
"@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.14.0", "globals": "^15.15.0",
"memfs": "^4.17.0", "memfs": "^4.17.0",
"os-browserify": "^0.3.0", "os-browserify": "^0.3.0",
"react-error-overlay": "6.0.11", "react-error-overlay": "^6.1.0",
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",
"source-map-explorer": "^2.5.3", "source-map-explorer": "^2.5.3",
"vite": "^6.0.7", "vite": "^6.2.0",
"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",

View File

@@ -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 React, { lazy, Suspense, useEffect, useState } from "react"; import { 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 } from "react-router-dom"; import { Route, Routes, useNavigate } 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/socketContext.jsx"; import { SocketProvider } from "../contexts/SocketIO/useSocket.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,6 +46,7 @@ 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();
useEffect(() => { useEffect(() => {
if (!navigator.onLine) { if (!navigator.onLine) {
@@ -200,7 +201,7 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
path="/manage/*" path="/manage/*"
element={ element={
<ErrorBoundary> <ErrorBoundary>
<SocketProvider bodyshop={bodyshop}> <SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
<PrivateRoute isAuthorized={currentUser.authorized} /> <PrivateRoute isAuthorized={currentUser.authorized} />
</SocketProvider> </SocketProvider>
</ErrorBoundary> </ErrorBoundary>
@@ -212,7 +213,7 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
path="/tech/*" path="/tech/*"
element={ element={
<ErrorBoundary> <ErrorBoundary>
<SocketProvider bodyshop={bodyshop}> <SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
<PrivateRoute isAuthorized={currentUser.authorized} /> <PrivateRoute isAuthorized={currentUser.authorized} />
</SocketProvider> </SocketProvider>
</ErrorBoundary> </ErrorBoundary>

View File

@@ -180,3 +180,13 @@
.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;
}
}

View File

@@ -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 React, { useContext, useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import SocketContext from "../../contexts/SocketIO/socketContext"; import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
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 } = useContext(SocketContext); const { socket } = useSocket();
useEffect(() => { useEffect(() => {
if (!bodyshop || !bodyshop.messagingservicesid) return; if (!bodyshop || !bodyshop.messagingservicesid) return;

View File

@@ -1,9 +1,9 @@
import { useMutation } from "@apollo/client"; import { useMutation } from "@apollo/client";
import { Button } from "antd"; import { Button } from "antd";
import React, { useContext, useState } from "react"; import { 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 SocketContext from "../../contexts/SocketIO/socketContext.jsx"; import { useSocket } from "../../contexts/SocketIO/useSocket.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 } = useContext(SocketContext); const { socket } = useSocket();
const handleToggleArchive = async () => { const handleToggleArchive = async () => {
setLoading(true); setLoading(true);

View File

@@ -1,11 +1,10 @@
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 SocketContext from "../../contexts/SocketIO/socketContext.jsx"; import { useSocket } from "../../contexts/SocketIO/useSocket.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 +17,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 } = useContext(SocketContext); const { socket } = useSocket();
const handleRemoveTag = async (jobId) => { const handleRemoveTag = async (jobId) => {
const convId = jobConversations[0].conversationid; const convId = jobConversations[0].conversationid;

View File

@@ -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 React, { useCallback, useContext, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import SocketContext from "../../contexts/SocketIO/socketContext"; import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { GET_CONVERSATION_DETAILS, CONVERSATION_SUBSCRIPTION_BY_PK } from "../../graphql/conversations.queries"; import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } 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 } = useContext(SocketContext); const { socket } = useSocket();
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false); const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
// Fetch conversation details // Fetch conversation details

View File

@@ -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 React, { useContext, useState } from "react"; import { 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 SocketContext from "../../contexts/SocketIO/socketContext.jsx"; import { useSocket } from "../../contexts/SocketIO/useSocket.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 } = useContext(SocketContext); const { socket } = useSocket();
const notification = useNotification(); const notification = useNotification();
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -1,12 +1,11 @@
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 SocketContext from "../../contexts/SocketIO/socketContext.jsx"; import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser //currentUser: selectCurrentUser
@@ -18,7 +17,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 } = useContext(SocketContext); const { socket } = useSocket();
const handleFinish = (values) => { const handleFinish = (values) => {
openChatByPhone({ phone_num: values.phoneNumber, socket }); openChatByPhone({ phone_num: values.phoneNumber, socket });

View File

@@ -1,5 +1,4 @@
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";
@@ -8,7 +7,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 SocketContext from "../../contexts/SocketIO/socketContext.jsx"; import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
@@ -22,7 +21,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 } = useContext(SocketContext); const { socket } = useSocket();
const notification = useNotification(); const notification = useNotification();
if (!phone) return <></>; if (!phone) return <></>;

View File

@@ -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 React, { useContext, useEffect, useState } from "react"; import { 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,8 +12,9 @@ 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,
@@ -27,7 +28,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 } = useContext(SocketContext); const { socket } = useSocket();
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

View File

@@ -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 React, { useContext, useState } from "react"; import { 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 SocketContext from "../../contexts/SocketIO/socketContext.jsx"; import { useSocket } from "../../contexts/SocketIO/useSocket.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 } = useContext(SocketContext); const { socket } = useSocket();
const [loadRo, { loading, data }] = useLazyQuery(SEARCH_FOR_JOBS); const [loadRo, { loading, data }] = useLazyQuery(SEARCH_FOR_JOBS);

View File

@@ -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 header={t("general.labels.errors")}> <Collapse.Panel key="errors-panel" header={t("general.labels.errors")}>
<div> <div>
<strong>{this.state.error.message}</strong> <strong>{this.state.error.message}</strong>
</div> </div>

View File

@@ -78,9 +78,7 @@ 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({

View File

@@ -1,6 +1,7 @@
import Icon, { import {
BankFilled, BankFilled,
BarChartOutlined, BarChartOutlined,
BellFilled,
CarFilled, CarFilled,
CheckCircleOutlined, CheckCircleOutlined,
ClockCircleFilled, ClockCircleFilled,
@@ -25,8 +26,10 @@ import Icon, {
UnorderedListOutlined, UnorderedListOutlined,
UserOutlined UserOutlined
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useQuery } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Layout, Menu, Space } from "antd"; import { Badge, Layout, Menu, Spin } from "antd";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; 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";
@@ -37,14 +40,19 @@ import { RiSurveyLine } from "react-icons/ri";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
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 day from "../../utils/day.js";
import InstanceRenderManager from "../../utils/instanceRenderMgr"; import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component"; import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapper from "../lock-wrapper/lock-wrapper.component"; import LockWrapper from "../lock-wrapper/lock-wrapper.component";
import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
// Redux mappings
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser, currentUser: selectCurrentUser,
recentItems: selectRecentItems, recentItems: selectRecentItems,
@@ -53,43 +61,13 @@ const mapStateToProps = createStructuredSelector({
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setBillEnterContext: (context) => setBillEnterContext: (context) => dispatch(setModalContext({ context, modal: "billEnter" })),
dispatch( setTimeTicketContext: (context) => dispatch(setModalContext({ context, modal: "timeTicket" })),
setModalContext({ setPaymentContext: (context) => dispatch(setModalContext({ context, modal: "payment" })),
context: context, setReportCenterContext: (context) => dispatch(setModalContext({ context, modal: "reportCenter" })),
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) => setCardPaymentContext: (context) => dispatch(setModalContext({ context, modal: "cardPayment" })),
dispatch( setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" }))
setModalContext({
context: context,
modal: "cardPayment"
})
),
setTaskUpsertContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "taskUpsert"
})
)
}); });
function Header({ function Header({
@@ -115,24 +93,81 @@ 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 deleteBetaCookie = () => { const {
// const cookieExists = document.cookie.split("; ").some((row) => row.startsWith(`betaSwitchImex=`)); data: unreadData,
// if (cookieExists) { refetch: refetchUnread,
// const domain = window.location.hostname.split(".").slice(-2).join("."); loading: unreadLoading
// document.cookie = `betaSwitchImex=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=.${domain}`; } = useQuery(GET_UNREAD_COUNT, {
// } variables: { associationid: userAssociationId },
// }; fetchPolicy: "network-only",
// pollInterval: isConnected ? 0 : day.duration(60, "seconds").asMilliseconds(),
// deleteBetaCookie(); skip: !userAssociationId || !scenarioNotificationsOn
});
const accountingChildren = []; const unreadCount = unreadData?.notifications_aggregate?.aggregate?.count ?? 0;
accountingChildren.push( useEffect(() => {
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: <Icon component={FaFileInvoiceDollar} />, icon: <FaFileInvoiceDollar />,
label: ( label: (
<Link to="/manage/bills"> <Link to="/manage/bills">
<LockWrapper featureName="bills" bodyshop={bodyshop}> <LockWrapper featureName="bills" bodyshop={bodyshop}>
@@ -144,92 +179,60 @@ function Header({
{ {
key: "enterbills", key: "enterbills",
id: "header-accounting-enterbills", id: "header-accounting-enterbills",
icon: <Icon component={GiPayMoney} />, icon: <GiPayMoney />,
label: ( label: (
<Space> <LockWrapper featureName="bills" bodyshop={bodyshop}>
<LockWrapper featureName="bills" bodyshop={bodyshop}> {t("menus.header.enterbills")}
{t("menus.header.enterbills")} </LockWrapper>
</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: ( label: <Link to="/manage/payments">{t("menus.header.allpayments")}</Link>
<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: <FaCreditCard />,
label: ( label: t("menus.header.enterpayment"),
<LockWrapper featureName="payments" bodyshop={bodyshop}> onClick: () =>
{t("menus.header.enterpayment")} setPaymentContext({
</LockWrapper>
),
onClick: () => {
HasFeatureAccess({ featureName: "payments", bodyshop }) &&
setPaymentContext({
actions: {},
context: null
});
}
}
);
if (ImEXPay.treatment === "on") {
accountingChildren.push({
key: "entercardpayments",
id: "header-accounting-entercardpayments",
icon: <Icon component={FaCreditCard} />,
label: t("menus.header.entercardpayment"),
onClick: () => {
setCardPaymentContext({
actions: {}, actions: {},
context: {} context: null
}); })
}
});
}
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",
@@ -241,132 +244,124 @@ function Header({
</LockWrapper> </LockWrapper>
</Link> </Link>
) )
} },
); ...(bodyshop?.md_tasks_presets?.use_approvals
? [
if (bodyshop?.md_tasks_presets?.use_approvals) { {
accountingChildren.push({ key: "ttapprovals",
key: "ttapprovals", id: "header-accounting-ttapprovals",
id: "header-accounting-ttapprovals", icon: <FieldTimeOutlined />,
icon: <FieldTimeOutlined />, label: <Link to="/manage/ttapprovals">{t("menus.header.ttapprovals")}</Link>
label: <Link to="/manage/ttapprovals">{t("menus.header.ttapprovals")}</Link> }
}); ]
} : []),
accountingChildren.push(
{ {
key: "entertimetickets", key: "entertimetickets",
icon: <Icon component={GiPlayerTime} />, id: "header-accounting-entertimetickets",
icon: <GiPlayerTime />,
label: ( label: (
<LockWrapper featureName="timetickets" bodyshop={bodyshop}> <LockWrapper featureName="timetickets" bodyshop={bodyshop}>
{t("menus.header.entertimeticket")} {t("menus.header.entertimeticket")}
</LockWrapper> </LockWrapper>
), ),
id: "header-accounting-entertimetickets", onClick: () =>
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.concat(" | ", currentUser.displayName) ? `${currentUser.email} | ${currentUser.displayName}`
: currentUser.email : currentUser.email
} }
}); })
}
}, },
{ type: "divider" },
{ {
type: "divider" key: "accountingexport",
} id: "header-accounting-export",
); icon: <ExportOutlined />,
const accountingExportChildren = [
{
key: "receivables",
id: "header-accounting-receivables",
label: ( label: (
<Link to="/manage/accounting/receivables"> <LockWrapper featureName="export" bodyshop={bodyshop}>
<LockWrapper featureName="export" bodyshop={bodyshop}> {t("menus.header.export")}
{t("menus.header.accounting-receivables")} </LockWrapper>
</LockWrapper> ),
</Link> children: [
) {
key: "receivables",
id: "header-accounting-receivables",
label: (
<Link to="/manage/accounting/receivables">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-receivables")}
</LockWrapper>
</Link>
)
},
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) ||
DmsAp.treatment === "on"
? [
{
key: "payables",
id: "header-accounting-payables",
label: (
<Link to="/manage/accounting/payables">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-payables")}
</LockWrapper>
</Link>
)
}
]
: []),
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber))
? [
{
key: "payments",
id: "header-accounting-payments",
label: (
<Link to="/manage/accounting/payments">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-payments")}
</LockWrapper>
</Link>
)
}
]
: []),
{ type: "divider" },
{
key: "exportlogs",
id: "header-accounting-exportlogs",
label: (
<Link to="/manage/accounting/exportlogs">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.export-logs")}
</LockWrapper>
</Link>
)
}
]
} }
]; ];
if (!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) || DmsAp.treatment === "on") { // Left menu items (includes original navigation items)
accountingExportChildren.push({ const leftMenuItems = [
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",
icon: <HomeFilled />,
id: "header-home", id: "header-home",
icon: <HomeFilled />,
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: <Icon component={FaCalendarAlt} />, icon: <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: <Icon component={FaCarCrash} />, icon: <FaCarCrash />,
label: t("menus.header.jobs"), label: t("menus.header.jobs"),
children: [ children: [
{ {
@@ -399,31 +394,24 @@ 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: <Icon component={BsKanban} />, icon: <BsKanban />,
label: ( label: (
<Link to="/manage/production/board"> <Link to="/manage/production/board">
<LockWrapper featureName="visualboard" bodyshop={bodyshop}> <LockWrapper featureName="visualboard" bodyshop={bodyshop}>
@@ -432,11 +420,7 @@ function Header({
</Link> </Link>
) )
}, },
{ type: "divider" },
{
type: "divider",
id: "header-jobs-divider3"
},
{ {
key: "scoreboard", key: "scoreboard",
id: "header-scoreboard", id: "header-scoreboard",
@@ -453,8 +437,8 @@ function Header({
}, },
{ {
key: "customers", key: "customers",
icon: <UserOutlined />,
id: "header-customers", id: "header-customers",
icon: <UserOutlined />,
label: t("menus.header.customers"), label: t("menus.header.customers"),
children: [ children: [
{ {
@@ -519,7 +503,6 @@ function Header({
} }
] ]
}, },
...(accountingChildren.length > 0 ...(accountingChildren.length > 0
? [ ? [
{ {
@@ -537,7 +520,6 @@ 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",
@@ -550,7 +532,6 @@ function Header({
</Link> </Link>
) )
}, },
{ {
key: "tasks", key: "tasks",
id: "tasks", id: "tasks",
@@ -562,12 +543,7 @@ 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: () => { onClick: () => setTaskUpsertContext({ actions: {}, context: {} })
setTaskUpsertContext({
actions: {},
context: {}
});
}
}, },
{ {
key: "mytasks", key: "mytasks",
@@ -592,7 +568,7 @@ function Header({
{ {
key: "shop", key: "shop",
id: "header-shop", id: "header-shop",
icon: <Icon component={GiSettingsKnobs} />, icon: <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>
}, },
{ {
@@ -610,24 +586,18 @@ function Header({
id: "header-reportcenter", id: "header-reportcenter",
icon: <BarChartOutlined />, icon: <BarChartOutlined />,
label: t("menus.header.reportcenter"), label: t("menus.header.reportcenter"),
onClick: () => { onClick: () => setReportCenterContext({ actions: {}, context: {} })
setReportCenterContext({
actions: {},
context: {}
});
}
}, },
{ {
key: "shop-vendors", key: "shop-vendors",
id: "header-shop-vendors", id: "header-shop-vendors",
icon: <Icon component={IoBusinessOutline} />, icon: <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: <Icon component={RiSurveyLine} />, icon: <RiSurveyLine />,
label: ( label: (
<Link to="/manage/shop/csi"> <Link to="/manage/shop/csi">
<LockWrapper featureName="export" bodyshop={bodyshop}> <LockWrapper featureName="export" bodyshop={bodyshop}>
@@ -638,14 +608,27 @@ 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",
label: currentUser.displayName || currentUser.email || t("general.labels.unknown"), id: "header-user",
icon: <UserOutlined />,
label: t("menus.currentuser.profile"),
children: [ children: [
{ {
key: "signout", key: "signout",
id: "header-signout", id: "header-signout",
icon: <Icon component={FiLogOut} />, icon: <FiLogOut />,
danger: true, danger: true,
label: t("user.actions.signout"), label: t("user.actions.signout"),
onClick: () => signOutStart() onClick: () => signOutStart()
@@ -653,33 +636,25 @@ function Header({
{ {
key: "help", key: "help",
id: "header-help", id: "header-help",
icon: <Icon component={QuestionCircleFilled} />, icon: <QuestionCircleFilled />,
label: t("menus.header.help"), label: t("menus.header.help"),
onClick: () => { onClick: () => window.open("https://help.imex.online/", "_blank")
window.open("https://help.imex.online/", "_blank");
}
}, },
...(InstanceRenderManager({ ...(InstanceRenderManager({ imex: true, rome: false })
imex: true,
rome: false
})
? [ ? [
{ {
key: "rescue", key: "rescue",
id: "header-rescue", id: "header-rescue",
icon: <Icon component={CarFilled} />, icon: <CarFilled />,
label: t("menus.header.rescueme"), label: t("menus.header.rescueme"),
onClick: () => { onClick: () => window.open("https://imexrescue.com/", "_blank")
window.open("https://imexrescue.com/", "_blank");
}
} }
] ]
: []), : []),
{ {
key: "shiftclock", key: "shiftclock",
id: "header-shiftclock", id: "header-shiftclock",
icon: <Icon component={GiPlayerTime} />, icon: <GiPlayerTime />,
label: ( label: (
<Link to="/manage/shiftclock"> <Link to="/manage/shiftclock">
<LockWrapper featureName="export" bodyshop={bodyshop}> <LockWrapper featureName="export" bodyshop={bodyshop}>
@@ -688,64 +663,79 @@ 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> <Layout.Header style={{ padding: 0, background: "#001529" }}>
<Menu <div
mode="horizontal" style={{
theme={"dark"} display: "flex",
selectedKeys={[selectedHeader]} justifyContent: "space-between",
onClick={handleMenuClick} alignItems: "center",
subMenuCloseDelay={0.3} height: "100%",
items={menuItems} overflow: "hidden"
/> }}
>
<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>
); );
} }

View File

@@ -1,30 +1,7 @@
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 />;
} }

View File

@@ -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 React, { useContext, useState } from "react"; import { 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 SocketContext from "../../contexts/SocketIO/socketContext.jsx"; import { useSocket } from "../../contexts/SocketIO/useSocket.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 } = useContext(SocketContext); const { socket } = useSocket();
const notification = useNotification(); const notification = useNotification();
const blockContent = ( const blockContent = (

View File

@@ -216,7 +216,7 @@ export function JobCloseRoGuardContainer({ job, jobRO, bodyshop, form }) {
</Form.Item> </Form.Item>
</Collapse.Panel> </Collapse.Panel>
<Collapse.Panel header={t("jobs.labels.performance")}> <Collapse.Panel key="job-performance" 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} />

View File

@@ -21,6 +21,8 @@ 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
@@ -37,6 +39,7 @@ 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];
@@ -78,7 +81,12 @@ export function JobDetailCards({ bodyshop, setPrintCenterContext }) {
{data ? ( {data ? (
<Card <Card
title={ title={
<Link to={`/manage/jobs/${data.jobs_by_pk.id}`}>{data.jobs_by_pk.ro_number || t("general.labels.na")}</Link> <Space>
{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>
@@ -122,7 +130,11 @@ export function JobDetailCards({ bodyshop, setPrintCenterContext }) {
</Col> </Col>
{!bodyshop.uselocalmediaserver && ( {!bodyshop.uselocalmediaserver && (
<Col {...span}> <Col {...span}>
<JobDetailCardsDocumentsComponent loading={loading} data={data ? data.jobs_by_pk : null} bodyshop={bodyshop} /> <JobDetailCardsDocumentsComponent
loading={loading}
data={data ? data.jobs_by_pk : null}
bodyshop={bodyshop}
/>
</Col> </Col>
)} )}
<Col {...span}> <Col {...span}>

View File

@@ -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 header="JSON Tree Totals"> <Collapse.Panel key="json-totals" header="JSON Tree Totals">
<div> <div>
<pre> <pre>
{JSON.stringify( {JSON.stringify(

View File

@@ -0,0 +1,154 @@
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>
);
}

View File

@@ -0,0 +1,219 @@
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);

View File

@@ -4,12 +4,12 @@ import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Card, Dropdown, Form, Input, Modal, Popconfirm, Popover, Select, Space } from "antd"; import { 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 { useContext, useMemo, useState } from "react"; import { 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 SocketContext from "../../contexts/SocketIO/socketContext.jsx"; import { useSocket } from "../../contexts/SocketIO/useSocket.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,11 +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 { 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,
@@ -130,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 } = useContext(SocketContext); const { socket } = useSocket();
const notification = useNotification(); const notification = useNotification();
const { const {
@@ -775,15 +775,14 @@ export function JobsDetailHeaderActions({
key: "enterpayments", key: "enterpayments",
id: "job-actions-enterpayments", id: "job-actions-enterpayments",
disabled: !job.converted, disabled: !job.converted,
label: <LockerWrapperComponent featureName="payments">{t("menus.header.enterpayment")}</LockerWrapperComponent>, label: t("menus.header.enterpayment"),
onClick: () => { onClick: () => {
logImEXEvent("job_header_enter_payment"); logImEXEvent("job_header_enter_payment");
HasFeatureAccess({ featureName: "payments", bodyshop }) && setPaymentContext({
setPaymentContext({ actions: {},
actions: {}, context: { jobid: job.id }
context: { jobid: job.id } });
});
} }
}); });

View File

@@ -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>

View File

@@ -0,0 +1,122 @@
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;

View File

@@ -0,0 +1,202 @@
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);

View File

@@ -0,0 +1,175 @@
.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;
}
}
}

View File

@@ -0,0 +1,56 @@
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;

View File

@@ -0,0 +1,168 @@
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);

View File

@@ -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 { parsePhoneNumber } from "libphonenumber-js"; import { parsePhoneNumberWithError, ParseError } from "libphonenumber-js";
import React, { useContext, useState } from "react"; import React, { 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 SocketContext from "../../contexts/SocketIO/socketContext.jsx"; import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -29,22 +29,34 @@ 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 } = useContext(SocketContext); const { socket } = useSocket();
const handleFinish = async ({ amount }) => { const handleFinish = async ({ amount }) => {
setLoading(true); setLoading(true);
let p; let p;
try { try {
p = parsePhoneNumber(job.ownr_ph1 || "", "CA"); // Updated to use parsePhoneNumberWithError
p = parsePhoneNumberWithError(job.ownr_ph1 || "", "CA");
} catch (error) { } catch (error) {
console.log("Unable to parse phone number"); 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);
}
} }
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(JSON.stringify({ payments: [{ jobid: job.id, amount }], userEmail: currentUser.email })) comment: btoa(
JSON.stringify({
payments: [{ jobid: job.id, amount }],
userEmail: currentUser.email
})
)
}); });
setLoading(false); setLoading(false);
setPaymentLink(response.data.shorUrl); setPaymentLink(response.data.shorUrl);
@@ -106,7 +118,20 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope
</Space> </Space>
<Button <Button
onClick={() => { onClick={() => {
const p = parsePhoneNumber(job.ownr_ph1, "CA"); let p;
try {
// Updated second instance of phone parsing
p = parsePhoneNumberWithError(job.ownr_ph1, "CA");
} catch (error) {
if (error instanceof ParseError) {
// Handle specific parsing errors
console.log(`Phone number parsing failed: ${error.message}`);
} else {
// Handle other unexpected errors
console.log("Unexpected error while parsing phone number:", error);
}
return;
}
openChatByPhone({ openChatByPhone({
phone_num: p.formatInternational(), phone_num: p.formatInternational(),
jobid: job.id, jobid: job.id,

View File

@@ -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 React, { useCallback, useEffect, useMemo, useState } from "react"; import { 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";

View File

@@ -1,4 +1,4 @@
import React, { useContext, useEffect, useMemo, useRef } from "react"; import { 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 SocketContext from "../../contexts/SocketIO/socketContext.jsx"; import { useSocket } from "../../contexts/SocketIO/useSocket.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 } = useContext(SocketContext); // Get the socket from context const { socket } = useSocket();
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

View File

@@ -27,6 +27,8 @@ 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,
@@ -41,6 +43,7 @@ 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) || {};
@@ -60,7 +63,12 @@ export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, te
<Drawer <Drawer
title={ title={
<PageHeader <PageHeader
title={theJob.ro_number} title={
<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}

View File

@@ -1,5 +1,5 @@
import { useApolloClient, useQuery, useSubscription } from "@apollo/client"; import { useApolloClient, useQuery, useSubscription } from "@apollo/client";
import React, { useContext, useEffect, useState, useRef } from "react"; import { useEffect, useRef, useState } 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 SocketContext from "../../contexts/SocketIO/socketContext.jsx"; import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) { export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) {
const client = useApolloClient(); const client = useApolloClient();
const { socket } = useContext(SocketContext); const { socket } = useSocket();
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

View File

@@ -39,7 +39,7 @@ export default function ProductionRemoveButton({ jobId }) {
}; };
return ( return (
<Button loading={loading} onClick={handleRemoveFromProd} type={"danger"}> <Button loading={loading} onClick={handleRemoveFromProd} type="default" danger>
{t("production.actions.remove")} {t("production.actions.remove")}
</Button> </Button>
); );

View File

@@ -1,6 +1,5 @@
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";
@@ -9,6 +8,8 @@ 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
@@ -22,6 +23,7 @@ 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");
@@ -117,6 +119,11 @@ export default connect(
</Card> </Card>
</Form> </Form>
</Col> </Col>
{scenarioNotificationsOn && (
<Col span={24}>
<NotificationSettingsForm />
</Col>
)}
</> </>
); );
}); });

View File

@@ -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 React, { useCallback, useEffect, useState } from "react"; import { 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,8 +81,7 @@ 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) {

View File

@@ -1,5 +1,4 @@
// NotificationProvider.jsx import { createContext, useContext } from "react";
import React, { createContext, useContext } from "react";
import { notification } from "antd"; import { notification } from "antd";
/** /**
@@ -22,7 +21,11 @@ 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}>

View File

@@ -1,13 +0,0 @@
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;

View File

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

View File

@@ -0,0 +1,500 @@
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";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
const SocketContext = createContext(null);
const INITIAL_NOTIFICATIONS = 10;
/**
* Socket Provider - Scenario Notifications / Web Socket related items
* @param children
* @param bodyshop
* @param navigate
* @param currentUser
* @returns {JSX.Element}
* @constructor
*/
const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
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 {
treatments: { Realtime_Notifications_UI }
} = useSplitTreatments({
attributes: {},
names: ["Realtime_Notifications_UI"],
splitKey: bodyshop?.imexshopid
});
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 (Realtime_Notifications_UI?.treatment !== "on") {
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 (Realtime_Notifications_UI?.treatment !== "on") {
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 (Realtime_Notifications_UI?.treatment !== "on") {
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,
Realtime_Notifications_UI,
t
]);
return (
<SocketContext.Provider
value={{
socket: socketRef.current,
clientId,
isConnected,
markNotificationRead,
markAllNotificationsRead,
scenarioNotificationsOn: Realtime_Notifications_UI?.treatment === "on"
}}
>
{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 };

View File

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

View File

@@ -524,6 +524,10 @@ 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
@@ -2567,3 +2571,34 @@ 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
}
}
}
`;

View File

@@ -0,0 +1,58 @@
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
}
`;

View File

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

View File

@@ -56,6 +56,8 @@ 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,
@@ -102,6 +104,7 @@ 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));
@@ -319,7 +322,13 @@ 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} />

View File

@@ -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, useContext, 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 { 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 SocketContext from "../../contexts/SocketIO/socketContext.jsx"; import { useSocket } from "../../contexts/SocketIO/useSocket.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,6 +29,7 @@ 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(
@@ -122,7 +123,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 } = useContext(SocketContext); const { socket, clientId } = useSocket();
const notification = useNotification(); const notification = useNotification();
// State to track displayed alerts // State to track displayed alerts
@@ -146,7 +147,7 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
} }
}; };
fetchAlerts(); fetchAlerts().catch((err) => `Error fetching Bodyshop Alerts: ${err?.message || ""}`);
}, [setAlerts]); }, [setAlerts]);
// Use useEffect to watch for new alerts // Use useEffect to watch for new alerts
@@ -166,7 +167,6 @@ 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

View File

@@ -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 React, { useEffect } from "react"; import { 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,23 +10,17 @@ 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({ bodyshop, setBreadcrumbs, setSelectedHeader }) { export function AllJobs({ 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;
@@ -60,25 +54,15 @@ export function AllJobs({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
if (error) return <AlertComponent message={error.message} type="error" />; if (error) return <AlertComponent message={error.message} type="error" />;
return ( return (
<FeatureWrapperComponent <RbacWrapper action="payments:list">
featureName="payments" <PaymentsListPaginated
noauth={ refetch={refetch}
<Card> loading={loading}
<UpsellComponent upsell={upsellEnum().payments.general} /> searchParams={searchParams}
</Card> total={data ? data.payments_aggregate.aggregate.count : 0}
} 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>
); );
} }

View File

@@ -3766,6 +3766,60 @@
"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"
}
}
}
} }

View File

@@ -3766,6 +3766,60 @@
"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": ""
}
}
}
} }

View File

@@ -3766,6 +3766,60 @@
"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": ""
}
}
}
} }

View File

@@ -143,7 +143,41 @@ 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 querys 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,
@@ -163,4 +197,5 @@ const client = new ApolloClient({
} }
} }
}); });
export default client; export default client;

View File

@@ -0,0 +1,23 @@
/** Notification Scenarios
* @description This file contains the scenarios for job notifications.
* @type {string[]}
*/
const notificationScenarios = [
"job-assigned-to-me",
"bill-posted",
"critical-parts-status-changed",
"part-marked-back-ordered",
"new-note-added",
"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"
// "supplement-imported", // Disabled for now
];
export { notificationScenarios };

221
docker-compose-cluster.yml Normal file
View File

@@ -0,0 +1,221 @@
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:

View File

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

View File

@@ -198,6 +198,14 @@
- 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:
@@ -697,12 +705,6 @@
- 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
@@ -1133,6 +1135,46 @@
- 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
@@ -1958,6 +2000,29 @@
_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
@@ -2846,13 +2911,12 @@
- role: user - role: user
permission: permission:
check: check:
user: job:
_and: bodyshop:
- associations: associations:
active: user:
_eq: true authid:
- authid: _eq: X-Hasura-User-Id
_eq: X-Hasura-User-Id
columns: columns:
- user_email - user_email
- created_at - created_at
@@ -2868,13 +2932,12 @@
- id - id
- jobid - jobid
filter: filter:
user: job:
_and: bodyshop:
- associations: associations:
active: user:
_eq: true authid:
- authid: _eq: X-Hasura-User-Id
_eq: X-Hasura-User-Id
comment: "" comment: ""
update_permissions: update_permissions:
- role: user - role: user
@@ -2885,26 +2948,24 @@
- id - id
- jobid - jobid
filter: filter:
user: job:
_and: bodyshop:
- associations: associations:
active: user:
_eq: true authid:
- authid: _eq: X-Hasura-User-Id
_eq: X-Hasura-User-Id
check: null check: null
comment: "" comment: ""
delete_permissions: delete_permissions:
- role: user - role: user
permission: permission:
filter: filter:
user: job:
_and: bodyshop:
- associations: associations:
active: user:
_eq: true authid:
- authid: _eq: X-Hasura-User-Id
_eq: X-Hasura-User-Id
comment: "" comment: ""
- table: - table:
name: joblines name: joblines
@@ -3223,6 +3284,31 @@
_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
@@ -3369,6 +3455,13 @@
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:
@@ -3399,6 +3492,13 @@
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:
@@ -4473,10 +4573,7 @@
request_transform: request_transform:
body: body:
action: transform action: transform
template: |- 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"
{
"success": true
}
method: POST method: POST
query_params: {} query_params: {}
template_engine: Kriti template_engine: Kriti
@@ -4825,6 +4922,26 @@
_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
@@ -4835,46 +4952,79 @@
- 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:
- associationid - scenario_meta
- scenario_text
- fcm_text
- created_at - created_at
- fcm_data - read
- fcm_message - updated_at
- fcm_title - associationid
- id - id
- jobid - jobid
- meta
- read
- ui_translation_meta
- ui_translation_string
- updated_at
filter: filter:
association: job:
_and: bodyshop:
- active: associations:
_eq: true _and:
- 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:
- meta - scenario_meta
- scenario_text
- fcm_text
- created_at
- read - read
filter: - updated_at
association: - associationid
_and: - id
- active: - jobid
_eq: true filter: {}
- user: check:
authid: job:
_eq: X-Hasura-User-Id bodyshop:
check: null associations:
_and:
- user:
authid:
_eq: X-Hasura-User-Id
- active:
_eq: true
comment: "" comment: ""
- table: - table:
name: owners name: owners
@@ -5116,32 +5266,6 @@
- active: - active:
_eq: true _eq: true
check: null check: null
event_triggers:
- name: notifications_parts_dispatch
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:
body:
action: transform
template: |-
{
"success": true
}
method: POST
query_params: {}
template_engine: Kriti
url: '{{$base_url}}/notifications/events/handlePartsDispatchChange'
version: 2
- table: - table:
name: parts_dispatch_lines name: parts_dispatch_lines
schema: public schema: public
@@ -5648,6 +5772,25 @@
- 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:
@@ -6119,9 +6262,15 @@
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
@@ -6131,12 +6280,6 @@
- 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
@@ -6313,12 +6456,6 @@
- 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
@@ -6586,6 +6723,13 @@
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:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
DROP INDEX IF EXISTS "public"."idx_job_watchers_jobid_user_email_unique";

View File

@@ -0,0 +1,2 @@
CREATE UNIQUE INDEX "idx_job_watchers_jobid_user_email_unique" on
"public"."job_watchers" using btree ("jobid", "user_email");

View File

@@ -0,0 +1 @@
DROP INDEX IF EXISTS "public"."notificiations_idx_jobs";

View File

@@ -0,0 +1,2 @@
CREATE INDEX "notificiations_idx_jobs" on
"public"."notifications" using btree ("jobid");

View File

@@ -0,0 +1 @@
DROP INDEX IF EXISTS "public"."notifications_idx_associations";

View File

@@ -0,0 +1,2 @@
CREATE INDEX "notifications_idx_associations" on
"public"."notifications" using btree ("associationid");

View File

@@ -0,0 +1,3 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- CREATE INDEX idx_notifications_created_at_not_read ON notifications(created_at desc, read) where read is null;

View File

@@ -0,0 +1 @@
CREATE INDEX idx_notifications_created_at_not_read ON notifications(created_at desc, read) where read is null;

View File

@@ -0,0 +1,3 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- CREATE INDEX idx_notifications_associations_not_read ON notifications(associationid, read) where read is null;

View File

@@ -0,0 +1 @@
CREATE INDEX idx_notifications_associations_not_read ON notifications(associationid, read) where read is null;

45
nginx-websocket.conf Normal file
View File

@@ -0,0 +1,45 @@
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

File diff suppressed because it is too large Load Diff

View File

@@ -19,39 +19,41 @@
"makeitpretty": "prettier --write \"**/*.{css,js,json,jsx,scss}\"" "makeitpretty": "prettier --write \"**/*.{css,js,json,jsx,scss}\""
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.738.0", "@aws-sdk/client-cloudwatch-logs": "^3.758.0",
"@aws-sdk/client-elasticache": "^3.738.0", "@aws-sdk/client-elasticache": "^3.758.0",
"@aws-sdk/client-s3": "^3.738.0", "@aws-sdk/client-s3": "^3.758.0",
"@aws-sdk/client-secrets-manager": "^3.738.0", "@aws-sdk/client-secrets-manager": "^3.758.0",
"@aws-sdk/client-ses": "^3.738.0", "@aws-sdk/client-ses": "^3.758.0",
"@aws-sdk/credential-provider-node": "^3.738.0", "@aws-sdk/credential-provider-node": "^3.758.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.7.7", "axios": "^1.8.1",
"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",
"chart.js": "^4.4.6", "bullmq": "^5.41.7",
"chart.js": "^4.4.8",
"cloudinary": "^2.5.1", "cloudinary": "^2.5.1",
"compression": "^1.7.5", "compression": "^1.8.0",
"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.33.1", "dd-trace": "^5.40.0",
"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.0.2", "firebase-admin": "^13.1.0",
"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.1.3", "intuit-oauth": "^4.2.0",
"ioredis": "^5.4.2", "ioredis": "^5.5.0",
"json-2-csv": "^5.5.8", "json-2-csv": "^5.5.8",
"juice": "^11.0.0", "juice": "^11.0.1",
"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",
@@ -64,7 +66,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.7", "soap": "^1.1.9",
"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",
@@ -76,14 +78,14 @@
"xmlbuilder2": "^3.1.1" "xmlbuilder2": "^3.1.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.19.0", "@eslint/js": "^9.21.0",
"@trivago/prettier-plugin-sort-imports": "^4.3.0", "@trivago/prettier-plugin-sort-imports": "^5.2.2",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"eslint": "^9.19.0", "eslint": "^9.21.0",
"eslint-plugin-react": "^7.37.4", "eslint-plugin-react": "^7.37.4",
"globals": "^15.14.0", "globals": "^15.15.0",
"p-limit": "^3.1.0", "p-limit": "^3.1.0",
"prettier": "^3.3.3", "prettier": "^3.5.3",
"source-map-explorer": "^2.5.2" "source-map-explorer": "^2.5.2"
} }
} }

View File

@@ -4,3 +4,4 @@ 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

129
server.js
View File

@@ -5,7 +5,7 @@ require("dotenv").config({
}); });
if (process.env.NODE_ENV) { if (process.env.NODE_ENV) {
const tracer = require("dd-trace").init({ require("dd-trace").init({
profiling: true, profiling: true,
env: process.env.NODE_ENV, env: process.env.NODE_ENV,
service: "bodyshop-api" service: "bodyshop-api"
@@ -22,7 +22,7 @@ const cookieParser = require("cookie-parser");
const { Server } = require("socket.io"); const { Server } = require("socket.io");
const { createAdapter } = require("@socket.io/redis-adapter"); const { createAdapter } = require("@socket.io/redis-adapter");
const { instrument } = require("@socket.io/admin-ui"); const { instrument } = require("@socket.io/admin-ui");
const { isString, isEmpty } = require("lodash"); const { isString, isEmpty, isFunction } = require("lodash");
const logger = require("./server/utils/logger"); const logger = require("./server/utils/logger");
const { applyRedisHelpers } = require("./server/utils/redisHelpers"); const { applyRedisHelpers } = require("./server/utils/redisHelpers");
@@ -35,9 +35,11 @@ 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;
@@ -193,7 +195,10 @@ 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);
} }
} }
@@ -219,11 +224,22 @@ 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");
resolve(redisCluster); if (process.env.NODE_ENV === "development" && process.env?.CLEAR_REDIS_ON_START === "true") {
logger.log("[Development] Flushing Redis Cluster on Service start...", "INFO", "redis", "api");
const master = redisCluster.nodes("master");
Promise.all(master.map((node) => node.flushall())).then(() => {
resolve(redisCluster);
});
} else {
resolve(redisCluster);
}
}); });
redisCluster.on("error", (err) => { redisCluster.on("error", (err) => {
logger.log(`Redis cluster connection failed: ${err.message}`, "ERROR", "redis", "api"); logger.log(`Redis cluster connection failed:`, "ERROR", "redis", "api", {
message: err?.message,
stack: err?.stack
});
reject(err); reject(err);
}); });
}); });
@@ -245,17 +261,24 @@ const applySocketIO = async ({ server, app }) => {
const pubClient = redisCluster; const pubClient = redisCluster;
const subClient = pubClient.duplicate(); const subClient = pubClient.duplicate();
pubClient.on("error", (err) => logger.log(`Redis pubClient error: ${err}`, "ERROR", "redis")); pubClient.on("error", (err) =>
subClient.on("error", (err) => logger.log(`Redis subClient error: ${err}`, "ERROR", "redis")); logger.log(`Redis pubClient error: ${err}`, "ERROR", "redis", "api", {
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
})
);
process.on("SIGINT", async () => { // Register Redis cleanup
registerCleanupTask(async () => {
logger.log("Closing Redis connections...", "INFO", "redis", "api"); logger.log("Closing Redis connections...", "INFO", "redis", "api");
try { await Promise.all([pubClient.disconnect(), subClient.disconnect()]);
await Promise.all([pubClient.disconnect(), subClient.disconnect()]); logger.log("Redis connections closed.", "INFO", "redis", "api");
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, {
@@ -313,6 +336,34 @@ 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>}
@@ -323,6 +374,9 @@ 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 });
@@ -330,24 +384,25 @@ 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 });
const StatusReporter = StartStatusReporter(); const StatusReporter = StartStatusReporter();
registerCleanupTask(async () => { registerCleanupTask(async () => {
StatusReporter.end(); if (isFunction(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", error); logger.log(`Server failed to start on port ${port}`, "ERROR", "api", null, { error: error.message });
} }
}; };
@@ -361,33 +416,3 @@ 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);
}

View File

@@ -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 InstanceManager = require("../../utils/instanceMgr").default; const { InstanceEndpoints } = require("../../utils/instanceMgr");
const oauthClient = new OAuthClient({ const oauthClient = new OAuthClient({
clientId: process.env.QBO_CLIENT_ID, clientId: process.env.QBO_CLIENT_ID,
@@ -17,16 +17,8 @@ const oauthClient = new OAuthClient({
logging: true logging: true
}); });
let url; //TODO:AIO Add in QBO callbacks.
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];

View File

@@ -20,6 +20,11 @@ const defaultFooter = () => {
const now = () => moment().format("MM/DD/YYYY @ hh:mm a"); const now = () => moment().format("MM/DD/YYYY @ hh:mm a");
/**
* Generate the email template
* @param strings
* @returns {string}
*/
const generateEmailTemplate = (strings) => { const generateEmailTemplate = (strings) => {
return ( return (
` `

View File

@@ -69,11 +69,14 @@ const sendServerEmail = async ({ subject, text }) => {
} }
}, },
(err, info) => { (err, info) => {
logger.log("server-email-failure", err ? "error" : "debug", null, null, { message: err || info }); logger.log("server-email-failure", err ? "error" : "debug", null, null, {
message: err?.message,
stack: err?.stack
});
} }
); );
} catch (error) { } catch (error) {
logger.log("server-email-failure", "error", null, null, { error }); logger.log("server-email-failure", "error", null, null, { message: error?.message, stack: error?.stack });
} }
}; };
@@ -92,11 +95,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 ? err?.message : info }); logger.log("server-email", err ? "error" : "debug", null, null, { message: err?.message, stack: err?.stack });
} }
); );
} catch (error) { } catch (error) {
logger.log("server-email-failure", "error", null, null, { error }); logger.log("server-email-failure", "error", null, null, { message: error?.message, stack: error?.stack });
} }
}; };
@@ -125,7 +128,8 @@ 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,
error errorMessage: error?.message,
errorStack: error?.stack
}); });
} }
}) })
@@ -194,7 +198,8 @@ 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,
error: err errorMessage: err?.message,
errorStack: err?.stack
}); });
logEmail(req, { logEmail(req, {
to: req.body.to, to: req.body.to,
@@ -202,7 +207,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, error: err }); res.status(500).json({ success: false, errorMessage: err?.message, stack: err?.stack });
} }
} }
); );
@@ -270,14 +275,16 @@ ${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, {
message: err ? err?.message : info errorMessage: err?.message,
errorStack: err?.stack
}); });
} }
); );
} }
} catch (error) { } catch (error) {
logger.log("sns-error", "ERROR", "api", null, { logger.log("sns-error", "ERROR", "api", null, {
error: JSON.stringify(error) errorMessage: error?.message,
errorStack: error?.stack
}); });
} }
res.sendStatus(200); res.sendStatus(200);

View File

@@ -10,6 +10,8 @@ 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();
@@ -61,16 +63,6 @@ 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
@@ -83,18 +75,11 @@ const formatPriority = (priority) => {
* @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 = getEndpoints(bodyshop); const endPoints = InstanceEndpoints();
return { return {
header: title, header: title,
subHeader: `Body Shop: ${bodyshop.shopname} | Priority: ${formatPriority(priority)} ${formatDate(dueDate)} | Created By: ${createdBy || "N/A"}`, subHeader: `Body Shop: ${bodyshop.shopname} | Priority: ${formatTaskPriority(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
}; };
@@ -108,9 +93,8 @@ 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, requestInstance) => { const sendMail = (type, to, subject, html, taskIds, successCallback) => {
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>"
@@ -136,7 +120,7 @@ const sendMail = (type, to, subject, html, taskIds, successCallback, requestInst
}; };
/** /**
* Send an email to the assigned user. * Email the assigned user.
* @param req * @param req
* @param res * @param res
* @returns {Promise<*>} * @returns {Promise<*>}
@@ -162,7 +146,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 ${formatPriority(newTask.priority)} priority task has been ${dirty ? "reassigned to" : "created for"} you - ${newTask.title}`, `A ${formatTaskPriority(newTask.priority)} priority task has been ${dirty ? "reassigned to" : "created for"} you - ${newTask.title}`,
generateEmailTemplate( generateEmailTemplate(
generateTemplateArgs( generateTemplateArgs(
newTask.title, newTask.title,
@@ -186,7 +170,7 @@ const taskAssignedEmail = async (req, res) => {
}; };
/** /**
* Send an email to remind the user of their tasks. * Email remind the user of their tasks.
* @param req * @param req
* @param res * @param res
* @returns {Promise<*>} * @returns {Promise<*>}
@@ -246,7 +230,7 @@ const tasksRemindEmail = async (req, res) => {
const onlyTask = groupedTasks[recipient.email][0]; const onlyTask = groupedTasks[recipient.email][0];
emailData.subject = emailData.subject =
`New ${formatPriority(onlyTask.priority)} Priority Task Reminder - ${onlyTask.title} ${onlyTask.due_date ? `- ${formatDate(onlyTask.due_date)}` : ""}`.trim(); `New ${formatTaskPriority(onlyTask.priority)} Priority Task Reminder - ${onlyTask.title} ${onlyTask.due_date ? `- ${formatDate(onlyTask.due_date)}` : ""}`.trim();
emailData.html = generateEmailTemplate( emailData.html = generateEmailTemplate(
generateTemplateArgs( generateTemplateArgs(
@@ -264,11 +248,6 @@ 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({
@@ -278,7 +257,7 @@ const tasksRemindEmail = async (req, res) => {
body: `<ul> body: `<ul>
${allTasks ${allTasks
.map((task) => .map((task) =>
`<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() `<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()
) )
.join("")} .join("")}
</ul>` </ul>`
@@ -338,6 +317,5 @@ const tasksRemindEmail = async (req, res) => {
module.exports = { module.exports = {
taskAssignedEmail, taskAssignedEmail,
tasksRemindEmail, tasksRemindEmail
getEndpoints
}; };

View File

@@ -2705,3 +2705,67 @@ 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
}
}
`;

View File

@@ -10,12 +10,11 @@ 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 } = require("../utils/instanceMgr"); const { InstanceRegion, InstanceEndpoints } = require("../utils/instanceMgr");
const client = new SecretsManagerClient({ const client = new SecretsManagerClient({
region: InstanceRegion() region: InstanceRegion()
@@ -443,31 +442,28 @@ exports.postback = async (req, res) => {
}); });
if (values.origin === "OneLink" && parsedComment.userEmail) { if (values.origin === "OneLink" && parsedComment.userEmail) {
try { sendTaskEmail({
const endPoints = getEndpoints(); to: parsedComment.userEmail,
sendTaskEmail({ subject: `New Payment(s) Received - RO ${jobs.jobs.map((j) => j.ro_number).join(", ")}`,
to: parsedComment.userEmail, type: "html",
subject: `New Payment(s) Received - RO ${jobs.jobs.map((j) => j.ro_number).join(", ")}`, html: generateEmailTemplate({
type: "html", header: "New Payment(s) Received",
html: generateEmailTemplate({ subHeader: "",
header: "New Payment(s) Received", body: jobs.jobs
subHeader: "", .map(
body: jobs.jobs (job) =>
.map( `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}`
(job) => )
`Reference: <a href="${endPoints}/manage/jobs/${job.id}">${job.ro_number || "N/A"}</a> | ${job.ownr_co_nm ? job.ownr_co_nm : `${job.ownr_fn || ""} ${job.ownr_ln || ""}`.trim()} | ${`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim()} | $${partialPayments.find((p) => p.jobid === job.id).amount}` .join("<br/>")
) })
.join("<br/>") }).catch((error) => {
})
});
} 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) {

View File

@@ -0,0 +1,199 @@
/**
* @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 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.");
/**
* Handle parts dispatch change notifications.
* Note: Placeholder
*
* @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.
* Note: Placeholder
*
* @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." });
module.exports = {
handleJobsChange,
handleBillsChange,
handleDocumentsChange,
handleJobLinesChange,
handleNotesChange,
handlePartsDispatchChange,
handlePartsOrderChange,
handlePaymentsChange,
handleTasksChange,
handleTimeTicketsChange
};

View File

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

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