Merge remote-tracking branch 'origin/master-AIO' into feature/IO-2776-cdk-fortellis

# Conflicts:
#	client/src/components/dms-post-form/dms-post-form.component.jsx
#	package-lock.json
#	package.json
#	server/web-sockets/redisSocketEvents.js
This commit is contained in:
Dave
2025-08-21 11:05:03 -04:00
345 changed files with 23812 additions and 16731 deletions

4
.gitignore vendored
View File

@@ -128,3 +128,7 @@ vitest-coverage/
*.vitest.log
test-output.txt
server/job/test/fixtures
.github
_reference/ragmate/.ragmate.env
docker_data

File diff suppressed because it is too large Load Diff

View File

@@ -11,8 +11,8 @@
"license": "ISC",
"description": "",
"dependencies": {
"express": "^4.21.1",
"mailparser": "^3.7.1",
"express": "^5.1.0",
"mailparser": "^3.7.2",
"node-fetch": "^3.3.2"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -74,50 +74,8 @@
})();
</script>
<% } %>
<script>
!(function () {
"use strict";
var e = [
"debug",
"destroy",
"do",
"help",
"identify",
"is",
"off",
"on",
"ready",
"render",
"reset",
"safe",
"set"
];
if (window.noticeable) console.warn("Noticeable SDK code snippet loaded more than once");
else {
var n = (window.noticeable = window.noticeable || []);
<script>!function(w,d,i,s){function l(){if(!d.getElementById(i)){var f=d.getElementsByTagName(s)[0],e=d.createElement(s);e.type="text/javascript",e.async=!0,e.src="https://canny.io/sdk.js",f.parentNode.insertBefore(e,f)}}if("function"!=typeof w.Canny){var c=function(){c.q.push(arguments)};c.q=[],w.Canny=c,"complete"===d.readyState?l():w.attachEvent?w.attachEvent("onload",l):w.addEventListener("load",l,!1)}}(window,document,"canny-jssdk","script");</script>
function t(e) {
return function () {
var t = Array.prototype.slice.call(arguments);
return t.unshift(e), n.push(t), n;
};
}
!(function () {
for (var o = 0; o < e.length; o++) {
var r = e[o];
n[r] = t(r);
}
})(),
(function () {
var e = document.createElement("script");
(e.async = !0), (e.src = "https://sdk.noticeable.io/l.js");
var n = document.head;
n.insertBefore(e, n.firstChild);
})();
}
})();
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

3778
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,21 +12,21 @@
"@apollo/client": "^3.13.6",
"@emotion/is-prop-valid": "^1.3.1",
"@fingerprintjs/fingerprintjs": "^4.6.1",
"@firebase/analytics": "^0.10.12",
"@firebase/app": "^0.11.4",
"@firebase/auth": "^1.10.0",
"@firebase/firestore": "^4.7.10",
"@firebase/messaging": "^0.12.17",
"@firebase/analytics": "^0.10.16",
"@firebase/app": "^0.13.1",
"@firebase/auth": "^1.10.6",
"@firebase/firestore": "^4.7.17",
"@firebase/messaging": "^0.12.21",
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.6.1",
"@sentry/cli": "^2.43.0",
"@sentry/react": "^9.11.0",
"@sentry/vite-plugin": "^3.3.1",
"@splitsoftware/splitio-react": "^2.1.1",
"@reduxjs/toolkit": "^2.8.2",
"@sentry/cli": "^2.47.1",
"@sentry/react": "^9.38.0",
"@sentry/vite-plugin": "^3.5.0",
"@splitsoftware/splitio-react": "^2.3.1",
"@tanem/react-nprogress": "^5.0.53",
"antd": "^5.24.6",
"antd": "^5.25.4",
"apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^4.2.0",
"apollo-link-sentry": "^4.3.0",
"autosize": "^6.0.1",
"axios": "^1.8.4",
"classnames": "^2.5.1",
@@ -37,28 +37,29 @@
"dotenv": "^16.4.7",
"env-cmd": "^10.1.0",
"exifr": "^7.1.3",
"graphql": "^16.10.0",
"graphql": "^16.11.0",
"i18next": "^24.2.3",
"i18next-browser-languagedetector": "^8.0.4",
"i18next-browser-languagedetector": "^8.1.0",
"immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.12.6",
"libphonenumber-js": "^1.12.10",
"logrocket": "^9.0.2",
"markerjs2": "^2.32.4",
"memoize-one": "^6.0.0",
"normalize-url": "^8.0.1",
"normalize-url": "^8.0.2",
"object-hash": "^3.0.0",
"phone": "^3.1.59",
"prop-types": "^15.8.1",
"query-string": "^9.1.1",
"query-string": "^9.2.0",
"raf-schd": "^4.0.3",
"react": "^18.3.1",
"react-big-calendar": "^1.18.0",
"react-big-calendar": "^1.19.2",
"react-color": "^2.19.3",
"react-cookie": "^8.0.1",
"react-dom": "^18.3.1",
"react-drag-listview": "^2.0.0",
"react-grid-gallery": "^1.0.1",
"react-grid-layout": "^1.3.4",
"react-i18next": "^15.4.1",
"react-grid-layout": "1.3.4",
"react-i18next": "^15.5.2",
"react-icons": "^5.5.0",
"react-image-lightbox": "^5.1.4",
"react-markdown": "^10.1.0",
@@ -69,7 +70,7 @@
"react-resizable": "^3.0.5",
"react-router-dom": "^6.30.0",
"react-sticky": "^6.0.3",
"react-virtuoso": "^4.12.5",
"react-virtuoso": "^4.12.8",
"recharts": "^2.15.2",
"redux": "^5.0.1",
"redux-actions": "^3.0.3",
@@ -77,9 +78,9 @@
"redux-saga": "^1.3.0",
"redux-state-sync": "^3.1.4",
"reselect": "^5.1.1",
"sass": "^1.86.3",
"sass": "^1.89.1",
"socket.io-client": "^4.8.1",
"styled-components": "^6.1.17",
"styled-components": "^6.1.18",
"subscriptions-transport-ws": "^0.11.0",
"use-memo-one": "^1.1.3",
"vite-plugin-ejs": "^1.7.0",
@@ -129,18 +130,18 @@
"devDependencies": {
"@ant-design/icons": "^6.0.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.26.3",
"@dotenvx/dotenvx": "^1.39.1",
"@babel/preset-react": "^7.27.1",
"@dotenvx/dotenvx": "^1.47.5",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.24.0",
"@playwright/test": "^1.51.1",
"@sentry/webpack-plugin": "^3.3.1",
"@eslint/js": "^9.31.0",
"@playwright/test": "^1.54.1",
"@sentry/webpack-plugin": "^3.5.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@vitejs/plugin-react": "^4.3.4",
"browserslist": "^4.24.4",
"@vitejs/plugin-react": "^4.5.1",
"browserslist": "^4.25.0",
"browserslist-to-esbuild": "^2.1.1",
"chalk": "^5.4.1",
"eslint": "^8.57.1",
@@ -148,19 +149,19 @@
"eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0",
"jsdom": "^26.0.0",
"memfs": "^4.17.0",
"memfs": "^4.17.2",
"os-browserify": "^0.3.0",
"playwright": "^1.51.1",
"playwright": "^1.54.1",
"react-error-overlay": "^6.1.0",
"redux-logger": "^3.0.6",
"source-map-explorer": "^2.5.3",
"vite": "^6.2.5",
"vite-plugin-babel": "^1.3.0",
"vite": "^6.3.5",
"vite-plugin-babel": "^1.3.1",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-pwa": "^1.0.0",
"vite-plugin-style-import": "^2.0.0",
"vitest": "^3.1.1",
"vitest": "^3.2.3",
"workbox-window": "^7.3.0"
}
}

View File

@@ -2,15 +2,19 @@ import { ApolloProvider } from "@apollo/client";
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
import { ConfigProvider } from "antd";
import enLocale from "antd/es/locale/en_US";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { connect, useSelector } from "react-redux";
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
import client from "../utils/GraphQLClient";
import App from "./App";
import * as Sentry from "@sentry/react";
import themeProvider from "./themeProvider";
import getTheme from "./themeProvider";
import { CookiesProvider } from "react-cookie";
import { createStructuredSelector } from "reselect";
import { selectCurrentUser } from "../redux/user/user.selectors.js";
import { selectDarkMode } from "../redux/application/application.selectors";
import { setDarkMode } from "../redux/application/application.actions";
// Base Split configuration
const config = {
@@ -24,19 +28,54 @@ const config = {
function SplitClientProvider({ children }) {
const imexshopid = useSelector((state) => state.user.imexshopid); // Access imexshopid from Redux store
const splitClient = useSplitClient({ key: imexshopid || "anon" }); // Use imexshopid or fallback to "anon"
useEffect(() => {
if (splitClient && imexshopid) {
// Log readiness for debugging; no need for ready() since isReady is available
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
}
}, [splitClient, imexshopid]);
return children;
}
function AppContainer() {
const mapDispatchToProps = (dispatch) => ({
setDarkMode: (isDarkMode) => dispatch(setDarkMode(isDarkMode))
});
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser
});
function AppContainer({ currentUser, setDarkMode }) {
const { t } = useTranslation();
const isDarkMode = useSelector(selectDarkMode);
const theme = useMemo(() => getTheme(isDarkMode), [isDarkMode]);
// Update data-theme attribute when dark mode changes
useEffect(() => {
document.documentElement.setAttribute("data-theme", isDarkMode ? "dark" : "light");
return () => document.documentElement.removeAttribute("data-theme");
}, [isDarkMode]);
// Sync Redux darkMode with localStorage on user change
useEffect(() => {
if (currentUser?.uid) {
const savedMode = localStorage.getItem(`dark-mode-${currentUser.uid}`);
if (savedMode !== null) {
setDarkMode(JSON.parse(savedMode));
} else {
setDarkMode(false); // default to light mode
}
} else {
setDarkMode(false);
}
// eslint-disable-next-line
}, [currentUser?.uid]);
// Persist darkMode to localStorage when it or user changes
useEffect(() => {
if (currentUser?.uid) {
localStorage.setItem(`dark-mode-${currentUser.uid}`, JSON.stringify(isDarkMode));
}
}, [isDarkMode, currentUser?.uid]);
return (
<CookiesProvider>
@@ -44,11 +83,10 @@ function AppContainer() {
<ConfigProvider
input={{ autoComplete: "new-password" }}
locale={enLocale}
theme={themeProvider}
theme={theme}
form={{
validateMessages: {
// eslint-disable-next-line no-template-curly-in-string
required: t("general.validation.required", { label: "${label}" })
required: t("general.validation.required", { label: "{{label}}" })
}
}}
>
@@ -64,4 +102,4 @@ function AppContainer() {
);
}
export default Sentry.withProfiler(AppContainer);
export default Sentry.withProfiler(connect(mapStateToProps, mapDispatchToProps)(AppContainer));

View File

@@ -1,15 +1,226 @@
//Global Styles.
:root {
--table-stripe-bg: #f4f4f4; /* Light mode table stripe */
--menu-divider-color: #74695c; /* Light mode menu divider */
--menu-submenu-text: rgba(255, 255, 255, 0.65); /* Light mode submenu text */
--kanban-column-bg: #ddd; /* Light mode kanban column */
--alert-color: blue; /* Light mode alert */
--completion-soon-color: rgba(255, 140, 0, 0.8); /* Light mode completion soon */
--completion-past-color: rgba(255, 0, 0, 0.8); /* Light mode completion past */
--job-line-manual-color: tomato; /* Light mode job line manual */
--muted-button-color: lightgray; /* Light mode muted button */
--muted-button-hover-color: darkgrey; /* Light mode muted button hover */
--table-border-color: #ddd; /* Light mode table border */
--table-hover-bg: #f5f5f5; /* Light mode table hover */
--popover-bg: #fff; /* Light mode popover background */
--error-text: red; /* Light mode error message */
--no-jobs-text: #888; /* Light mode no jobs message */
--message-yours-bg: #eee; /* Light mode yours message background */
--message-mine-bg-start: #00d0ea; /* Light mode mine message gradient start */
--message-mine-bg-end: #0085d1; /* Light mode mine message gradient end */
--message-mine-text: white; /* Light mode mine message text */
--message-mine-tail-bg: white; /* Light mode mine/yours message tail */
--system-message-bg: #f5f5f5; /* Light mode system message background */
--system-message-text: #555; /* Light mode system message text */
--system-label-text: #888; /* Light mode system label/date text */
--message-icon-color: whitesmoke; /* Light mode message icon */
--eula-card-bg: lightgray; /* Light mode eula card background */
--notification-bg: #fff; /* Light mode notification background */
--notification-text: rgba(0, 0, 0, 0.85); /* Light mode notification text */
--notification-border: #d9d9d9; /* Light mode notification border */
--notification-header-bg: #fafafa; /* Light mode notification header background */
--notification-header-border: #f0f0f0; /* Light mode notification header border */
--notification-header-text: rgba(0, 0, 0, 0.85); /* Light mode notification header text */
--notification-toggle-icon: #1677ff; /* Light mode notification toggle icon */
--notification-switch-bg: #1677ff; /* Light mode notification switch background */
--notification-btn-link: #1677ff; /* Light mode notification link button */
--notification-btn-link-hover: #69b1ff; /* Light mode notification link button hover */
--notification-btn-link-disabled: rgba(0, 0, 0, 0.25); /* Light mode notification link button disabled */
--notification-btn-link-active: #0958d9; /* Light mode notification link button active */
--notification-read-bg: #fff; /* Light mode notification read background */
--notification-read-text: rgba(0, 0, 0, 0.65); /* Light mode notification read text */
--notification-unread-bg: #f5f5f5; /* Light mode notification unread background */
--notification-unread-text: rgba(0, 0, 0, 0.85); /* Light mode notification unread text */
--notification-item-hover-bg: #fafafa; /* Light mode notification item hover background */
--notification-ro-number: #1677ff; /* Light mode notification RO number */
--notification-relative-time: rgba(0, 0, 0, 0.45); /* Light mode notification relative time */
--alert-bg: #fff1f0; /* Light mode alert background */
--alert-text: rgba(0, 0, 0, 0.85); /* Light mode alert text */
--alert-border: #ffa39e; /* Light mode alert border */
--alert-message: #ff4d4f; /* Light mode alert message */
--share-badge-bg: #cccccc; /* Light mode share badge background */
--column-header-bg: #d0d0d0; /* Light mode column header background */
--footer-bg: #d0d0d0; /* Light mode footer background */
--tech-icon-color: orangered; /* Light mode tech icon color */
--clone-border-color: #1890ff; /* Light mode clone border color */
--event-arrived-bg: rgba(4, 141, 4, 0.4); /* Light mode arrived event background */
--event-block-bg: tomato; /* Light mode block event background */
--event-selected-bg: slategrey; /* Light mode selected event background */
--task-bg: #fff; /* Light mode task center background */
--task-text: rgba(0, 0, 0, 0.85); /* Light mode task text */
--task-border: #d9d9d9; /* Light mode task border */
--task-header-bg: #fafafa; /* Light mode task header background */
--task-header-border: #f0f0f0; /* Light mode task header border */
--task-section-bg: #f5f5f5; /* Light mode task section background */
--task-section-border: #e8e8e8; /* Light mode task section border */
--task-row-hover-bg: #f5f5f5; /* Light mode task row hover background */
--task-row-border: #f0f0f0; /* Light mode task row border */
--task-ro-number: #1677ff; /* Light mode task RO number */
--task-due-text: rgba(0, 0, 0, 0.45); /* Light mode task due text */
--task-button-bg: #1677ff; /* Light mode task button background */
--task-button-hover-bg: #4096ff; /* Light mode task button hover background */
--task-button-disabled-bg: #d9d9d9; /* Light mode task button disabled background */
--task-button-text: white; /* Light mode task button text */
--task-message-text: rgba(0, 0, 0, 0.45); /* Light mode task message text */
--mask-bg: rgba(0, 0, 0, 0.05); /* Light mode mask background */
--board-text-color: #393939; /* Light mode board text color */
--section-bg: #e3e3e3; /* Light mode section background */
--detail-text-color: #4d4d4d; /* Light mode detail text color */
--card-selected-bg: rgba(128, 128, 128, 0.2); /* Light mode selected card background */
--card-stripe-even-bg: #f0f2f5; /* Light mode even card background */
--card-stripe-odd-bg: #ffffff; /* Light mode odd card background */
--bar-border-color: #f0f2f5; /* Light mode bar border and background */
--tag-wrapper-bg: #f0f2f5; /* Light mode tag wrapper background */
--tag-wrapper-text: #000; /* Light mode tag wrapper text */
--preview-bg: lightgray; /* Light mode preview background */
--preview-border-color: #2196F3; /* Light mode preview border color */
--event-bg-fallback: #c4c4c4; /* Light mode event background fallback */
--card-bg-fallback: #ffffff; /* Light mode card background fallback */
--card-text-fallback: black; /* Light mode card text fallback */
--table-row-even-bg: rgb(236, 236, 236); /* Light mode table row even background */
--status-row-bg-fallback: #ffffff; /* Light mode status row fallback background */
--reset-link-color: #0000ff; /* Light mode reset link color */
--error-header-text: tomato; /* Light mode error header text */
--tooltip-bg: white; /* Light mode tooltip background */
--tooltip-border: gray; /* Light mode tooltip border */
--tooltip-text-fallback: black; /* Light mode tooltip text fallback */
--teams-button-bg: #6264A7; /* Light mode Teams button background */
--teams-button-border: #6264A7; /* Light mode Teams button border */
--teams-button-text: #FFFFFF; /* Light mode Teams button text and icon */
--content-bg: #fff; /* Light mode content background */
--legend-bg-fallback: #ffffff; /* Light mode legend background fallback */
--tech-content-bg: #fff; /* Light mode tech content background */
--today-bg: #ffffff; /* Light mode today background */
--today-text: #000000; /* Light mode today text */
--off-range-bg: #f8f8f8; /* Light mode off-range background */
}
[data-theme="dark"] {
--table-stripe-bg: #2a2a2a; /* Dark mode table stripe */
--menu-divider-color: #5c5c5c; /* Dark mode menu divider */
--menu-submenu-text: rgba(255, 255, 255, 0.85); /* Dark mode submenu text */
--kanban-column-bg: #333333; /* Dark mode kanban column */
--alert-color: #4da8ff; /* Dark mode alert */
--completion-soon-color: #ff8c1a; /* Dark mode completion soon */
--completion-past-color: #ff4d4f; /* Dark mode completion past */
--job-line-manual-color: #ff6347; /* Dark mode job line manual */
--muted-button-color: #666666; /* Dark mode muted button */
--muted-button-hover-color: #999999; /* Dark mode muted button hover */
--table-border-color: #5c5c5c; /* Dark mode table border */
--table-hover-bg: #2a2a2a; /* Dark mode table hover */
--popover-bg: #2a2a2a; /* Dark mode popover background */
--error-text: #ff4d4f; /* Dark mode error message */
--no-jobs-text: #999999; /* Dark mode no jobs message */
--message-yours-bg: #2a2a2a; /* Dark mode yours message background */
--message-mine-bg-start: #4da8ff; /* Dark mode mine message gradient start */
--message-mine-bg-end: #326ade; /* Dark mode mine message gradient end */
--message-mine-text: #ffffff; /* Dark mode mine message text */
--message-mine-tail-bg: #1f1f1f; /* Dark mode mine/yours message tail */
--system-message-bg: #333333; /* Dark mode system message background */
--system-message-text: #cccccc; /* Dark mode system message text */
--system-label-text: #999999; /* Dark mode system label/date text */
--message-icon-color: #cccccc; /* Dark mode message icon */
--eula-card-bg: #2a2a2a; /* Dark mode eula card background */
--notification-bg: #2a2a2a; /* Dark mode notification background */
--notification-text: rgba(255, 255, 255, 0.85); /* Dark mode notification text */
--notification-border: #5c5c5c; /* Dark mode notification border */
--notification-header-bg: #333333; /* Dark mode notification header background */
--notification-header-border: #444444; /* Dark mode notification header border */
--notification-header-text: rgba(255, 255, 255, 0.85); /* Dark mode notification header text */
--notification-toggle-icon: #4da8ff; /* Dark mode notification toggle icon */
--notification-switch-bg: #4da8ff; /* Dark mode notification switch background */
--notification-btn-link: #4da8ff; /* Dark mode notification link button */
--notification-btn-link-hover: #80c1ff; /* Dark mode notification link button hover */
--notification-btn-link-disabled: rgba(255, 255, 255, 0.25); /* Dark mode notification link button disabled */
--notification-btn-link-active: #2681ff; /* Dark mode notification link button active */
--notification-read-bg: #2a2a2a; /* Dark mode notification read background */
--notification-read-text: rgba(255, 255, 255, 0.65); /* Dark mode notification read text */
--notification-unread-bg: #333333; /* Dark mode notification unread background */
--notification-unread-text: rgba(255, 255, 255, 0.85); /* Dark mode notification unread text */
--notification-item-hover-bg: #3a3a3a; /* Dark mode notification item hover background */
--notification-ro-number: #4da8ff; /* Dark mode notification RO number */
--notification-relative-time: rgba(255, 255, 255, 0.45); /* Dark mode notification relative time */
--alert-bg: #3a1a1a; /* Dark mode alert background */
--alert-text: rgba(255, 255, 255, 0.85); /* Dark mode alert text */
--alert-border: #ff6666; /* Dark mode alert border */
--alert-message: #ff6666; /* Dark mode alert message */
--share-badge-bg: #666666; /* Dark mode share badge background */
--column-header-bg: #333333; /* Dark mode column header background */
--footer-bg: #333333; /* Dark mode footer background */
--tech-icon-color: #ff4500; /* Dark mode tech icon color */
--clone-border-color: #4da8ff; /* Dark mode clone border color */
--event-arrived-bg: rgba(4, 141, 4, 0.6); /* Dark mode arrived event background */
--event-block-bg: tomato; /* Dark mode block event background */
--event-selected-bg: #4a5e6e; /* Dark mode selected event background */
--task-bg: #2a2a2a; /* Dark mode task center background */
--task-text: rgba(255, 255, 255, 0.85); /* Dark mode task text */
--task-border: #5c5c5c; /* Dark mode task border */
--task-header-bg: #333333; /* Dark mode task header background */
--task-header-border: #444444; /* Dark mode task header border */
--task-section-bg: #333333; /* Dark mode task section background */
--task-section-border: #444444; /* Dark mode task section border */
--task-row-hover-bg: #3a3a3a; /* Dark mode task row hover background */
--task-row-border: #444444; /* Dark mode task row border */
--task-ro-number: #4da8ff; /* Dark mode task RO number */
--task-due-text: rgba(255, 255, 255, 0.45); /* Dark mode task due text */
--task-button-bg: #4da8ff; /* Dark mode task button background */
--task-button-hover-bg: #80c1ff; /* Dark mode task button hover background */
--task-button-disabled-bg: #666666; /* Dark mode task button disabled background */
--task-button-text: #ffffff; /* Dark mode task button text */
--task-message-text: rgba(255, 255, 255, 0.45); /* Dark mode task message text */
--mask-bg: rgba(255, 255, 255, 0.05); /* Dark mode mask background */
--board-text-color: #cccccc; /* Dark mode board text color */
--section-bg: #333333; /* Dark mode section background */
--detail-text-color: #bbbbbb; /* Dark mode detail text color */
--card-selected-bg: rgba(255, 255, 255, 0.1); /* Dark mode selected card background */
--card-stripe-even-bg: #2a2a2a; /* Dark mode even card background */
--card-stripe-odd-bg: #1f1f1f; /* Dark mode odd card background */
--bar-border-color: #2a2a2a; /* Dark mode bar border and background */
--tag-wrapper-bg: #2a2a2a; /* Dark mode tag wrapper background */
--tag-wrapper-text: #cccccc; /* Dark mode tag wrapper text */
--preview-bg: #2a2a2a; /* Dark mode preview background */
--preview-border-color: #4da8ff; /* Dark mode preview border color */
--event-bg-fallback: #262626; /* Dark mode event background fallback */
--card-bg-fallback: #2a2a2a; /* Dark mode card background fallback */
--card-text-fallback: #cccccc; /* Dark mode card text fallback */
--table-row-even-bg: #2a2a2a; /* Dark mode table row even background */
--status-row-bg-fallback: #1f1f1f; /* Dark mode status row fallback background */
--reset-link-color: #4da8ff; /* Dark mode reset link color */
--error-header-text: #ff6347; /* Dark mode error header text */
--tooltip-bg: #2a2a2a; /* Dark mode tooltip background */
--tooltip-border: #5c5c5c; /* Dark mode tooltip border */
--tooltip-text-fallback: #cccccc; /* Dark mode tooltip text fallback */
--teams-button-bg: #7b7dc4; /* Dark mode Teams button background */
--teams-button-border: #7b7dc4; /* Dark mode Teams button border */
--teams-button-text: #ffffff; /* Dark mode Teams button text and icon */
--content-bg: #2a2a2a; /* Dark mode content background */
--legend-bg-fallback: #2a2a2a; /* Dark mode legend background fallback */
--tech-content-bg: #2a2a2a; /* Dark mode tech content background */
--today-bg: #4a5e6e; /* Dark mode today background */
--today-text: #ffffff; /* Dark mode today text */
--off-range-bg: #333333; /* Dark mode off-range background */
--svg-background: #FFF; /* Dark mode SVG background */
}
// Global Styles
@import "react-big-calendar/lib/sass/styles";
.ant-menu-item-divider {
border-bottom: 1px solid #74695c !important;
border-bottom: 1px solid var(--menu-divider-color) !important;
}
// TODO: This was added because the newest release of ant was making the text color and the background color the same on a selected header
// Tried all available tokens (https://ant.design/components/menu?locale=en-US) and even reverted all our custom styles, to no avail
// This should be kept an eye on, especially if implementing DARK MODE
// Note: Monitor this in dark mode to ensure text visibility
.ant-menu-submenu-title {
color: rgba(255, 255, 255, 0.65) !important;
color: var(--menu-submenu-text) !important;
}
.imex-table-header {
@@ -46,7 +257,7 @@
}
.ellipses {
display: inline-block; /* for em, a, span, etc (inline by default) */
display: inline-block;
text-overflow: ellipsis;
width: calc(95%);
overflow: hidden;
@@ -60,23 +271,24 @@
}
}
// ::-webkit-scrollbar-track {
// -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
// border-radius: 0.2rem;
// background-color: #f5f5f5;
// }
// Scrollbar styles (uncomment if needed, updated for dark mode)
::-webkit-scrollbar-track {
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
border-radius: 0.2rem;
background-color: var(--table-stripe-bg);
}
// ::-webkit-scrollbar {
// width: 0.25rem;
// max-height: 0.25rem;
// background-color: #f5f5f5;
// }
::-webkit-scrollbar {
width: 0.25rem;
max-height: 0.25rem;
background-color: var(--table-stripe-bg);
}
// ::-webkit-scrollbar-thumb {
// border-radius: 0.2rem;
// -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
// background-color: #188fff;
// }
::-webkit-scrollbar-thumb {
border-radius: 0.2rem;
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
background-color: var(--alert-color);
}
.ant-input-number-input,
.ant-input-number,
@@ -88,28 +300,27 @@
.production-alert {
animation: alertBlinker 1s linear infinite;
color: blue;
color: var(--alert-color);
}
@keyframes alertBlinker {
50% {
color: red;
color: var(--completion-past-color);
opacity: 100;
//opacity: 0;
}
}
.blue {
color: blue;
color: var(--alert-color);
}
.production-completion-soon {
color: rgba(255, 140, 0, 0.8);
color: var(--completion-soon-color);
font-weight: bold;
}
.production-completion-past {
color: rgba(255, 0, 0, 0.8);
color: var(--completion-past-color);
font-weight: bold;
}
@@ -139,7 +350,7 @@
}
.react-kanban-column {
background-color: #ddd !important;
background-color: var(--kanban-column-bg) !important;
}
.production-list-table {
@@ -151,18 +362,18 @@
.ReactGridGallery_tile-icon-bar {
div {
svg {
fill: #1890ff;
fill: var(--alert-color);
}
}
}
.job-line-manual {
color: tomato;
color: var(--job-line-manual-color);
font-style: italic;
}
.ant-table-tbody > tr.ant-table-row:nth-child(2n) > td {
background-color: #f4f4f4;
background-color: var(--table-stripe-bg);
}
.rowWithColor > td {
@@ -170,15 +381,15 @@
}
.muted-button {
color: lightgray;
color: var(--muted-button-color);
border: none;
background: none;
cursor: pointer;
font-size: 16px; /* Adjust as needed */
font-size: 16px;
}
.muted-button:hover {
color: darkgrey;
color: var(--muted-button-hover-color);
}
.notification-alert-unordered-list {
@@ -190,3 +401,27 @@
margin-right: 0;
}
}
// Override react-big-calendar styles for dark mode only
[data-theme="dark"] {
.car-svg {
background-color: var(--svg-background);
}
.rbc-today {
background-color: var(--today-bg);
color: var(--today-text);
}
.rbc-off-range {
background-color: var(--off-range-bg);
}
.rbc-day-bg.rbc-today {
background-color: var(--today-bg);
}
}
//.rbc-time-header-gutter {
// padding: 0;
//}

View File

@@ -4,36 +4,42 @@ import InstanceRenderMgr from "../utils/instanceRenderMgr";
const { defaultAlgorithm, darkAlgorithm } = theme;
let isDarkMode = false;
/**
* Default theme
* @type {{components: {Menu: {itemDividerBorderColor: string}}}}
*/
const defaultTheme = {
const defaultTheme = (isDarkMode) => ({
components: {
Table: {
rowHoverBg: "#e7f3ff",
rowSelectedBg: "#e6f7ff",
rowHoverBg: isDarkMode ? "#2a2a2a" : "#e7f3ff",
rowSelectedBg: isDarkMode ? "#333333" : "#e6f7ff",
headerSortHoverBg: "transparent"
},
Menu: {
darkItemHoverBg: "#1890ff",
itemHoverBg: "#1890ff",
horizontalItemHoverBg: "#1890ff"
darkItemHoverBg: isDarkMode ? "#004a77" : "#1890ff",
itemHoverBg: isDarkMode ? "#004a77" : "#1890ff",
horizontalItemHoverBg: isDarkMode ? "#004a77" : "#1890ff"
}
},
token: {
colorPrimary: InstanceRenderMgr({
imex: "#1890ff",
rome: "#326ade"
}),
colorInfo: InstanceRenderMgr({
imex: "#1890ff",
rome: "#326ade"
})
colorPrimary: InstanceRenderMgr(
{
imex: isDarkMode ? "#4da8ff" : "#1890ff",
rome: isDarkMode ? "#5b8ce6" : "#326ade"
},
isDarkMode
),
colorInfo: InstanceRenderMgr(
{
imex: isDarkMode ? "#4da8ff" : "#1890ff",
rome: isDarkMode ? "#5b8ce6" : "#326ade"
},
isDarkMode
),
colorError: isDarkMode ? "#ff4d4f" : "#f5222d",
colorBgBase: isDarkMode ? "#1f1f1f" : "#ffffff" // Align with Ant Design dark mode
}
};
});
/**
* Development theme
@@ -60,8 +66,9 @@ const prodTheme = {};
const currentTheme = import.meta.env.DEV ? devTheme : prodTheme;
const finaltheme = {
const getTheme = (isDarkMode) => ({
algorithm: isDarkMode ? darkAlgorithm : defaultAlgorithm,
...defaultsDeep(currentTheme, defaultTheme)
};
export default finaltheme;
});
export default getTheme;

View File

@@ -1,6 +1,6 @@
import { Card, Checkbox, Input, Space, Table } from "antd";
import queryString from "query-string";
import React, { useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
@@ -16,12 +16,13 @@ import PayableExportAll from "../payable-export-all-button/payable-export-all-bu
import PayableExportButton from "../payable-export-button/payable-export-button.component";
import BillMarkSelectedExported from "../payable-mark-selected-exported/payable-mark-selected-exported.component";
import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.component";
import useLocalStorage from "./../../utils/useLocalStorage";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
@@ -31,7 +32,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, bills, ref
const { t } = useTranslation();
const [selectedBills, setSelectedBills] = useState([]);
const [transInProgress, setTransInProgress] = useState(false);
const [state, setState] = useState({
const [state, setState] = useLocalStorage("accounting-payables-table-state", {
sortedInfo: {},
search: ""
});
@@ -181,7 +182,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, bills, ref
onChange={handleTableChange}
rowSelection={{
onSelectAll: (selected, selectedRows) => setSelectedBills(selectedRows.map((i) => i.id)),
onSelect: (record, selected, selectedRows, nativeEvent) => {
onSelect: (record, selected, selectedRows) => {
setSelectedBills(selectedRows.map((i) => i.id));
},
getCheckboxProps: (record) => ({

View File

@@ -1,5 +1,5 @@
import { Card, Input, Space, Table } from "antd";
import React, { useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
@@ -10,6 +10,7 @@ import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateFormatter, DateTimeFormatter } from "../../utils/DateFormatter";
import { exportPageLimit } from "../../utils/config";
import { alphaSort, dateSort } from "../../utils/sorters";
import useLocalStorage from "../../utils/useLocalStorage";
import ExportLogsCountDisplay from "../export-logs-count-display/export-logs-count-display.component";
import OwnerNameDisplay, { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import PaymentExportButton from "../payment-export-button/payment-export-button.component";
@@ -21,7 +22,7 @@ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
@@ -31,7 +32,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, payments,
const { t } = useTranslation();
const [selectedPayments, setSelectedPayments] = useState([]);
const [transInProgress, setTransInProgress] = useState(false);
const [state, setState] = useState({
const [state, setState] = useLocalStorage("accounting-payments-table-state", {
sortedInfo: {},
search: ""
});
@@ -194,7 +195,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, payments,
onChange={handleTableChange}
rowSelection={{
onSelectAll: (selected, selectedRows) => setSelectedPayments(selectedRows.map((i) => i.id)),
onSelect: (record, selected, selectedRows, nativeEvent) => {
onSelect: (record, selected, selectedRows) => {
setSelectedPayments(selectedRows.map((i) => i.id));
},
getCheckboxProps: (record) => ({

View File

@@ -1,5 +1,5 @@
import { Button, Card, Input, Space, Table } from "antd";
import React, { useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
@@ -10,6 +10,7 @@ import { exportPageLimit } from "../../utils/config";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateFormatter } from "../../utils/DateFormatter";
import { alphaSort, dateSort, statusSort } from "../../utils/sorters";
import useLocalStorage from "../../utils/useLocalStorage";
import ExportLogsCountDisplay from "../export-logs-count-display/export-logs-count-display.component";
import JobExportButton from "../jobs-close-export-button/jobs-close-export-button.component";
import JobsExportAllButton from "../jobs-export-all-button/jobs-export-all-button.component";
@@ -20,7 +21,7 @@ import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(AccountingReceivablesTableComponent);
@@ -30,7 +31,7 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r
const [selectedJobs, setSelectedJobs] = useState([]);
const [transInProgress, setTransInProgress] = useState(false);
const [state, setState] = useState({
const [state, setState] = useLocalStorage("accounting-receivables-table-state", {
sortedInfo: {},
search: ""
});
@@ -207,7 +208,7 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r
onChange={handleTableChange}
rowSelection={{
onSelectAll: (selected, selectedRows) => setSelectedJobs(selectedRows.map((i) => i.id)),
onSelect: (record, selected, selectedRows, nativeEvent) => {
onSelect: (record, selected, selectedRows) => {
setSelectedJobs(selectedRows.map((i) => i.id));
},
getCheckboxProps: (record) => ({

View File

@@ -6,7 +6,7 @@
td {
padding: 8px;
text-align: left;
border-bottom: 1px solid #ddd;
border-bottom: 1px solid var(--table-border-color);
.ant-form-item {
margin-bottom: 0px !important;
@@ -14,6 +14,6 @@
}
tr:hover {
background-color: #f5f5f5;
background-color: var(--table-hover-bg);
}
}

View File

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

View File

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

View File

@@ -2,10 +2,11 @@ import { useApolloClient, useMutation } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Checkbox, Form, Modal, Space } from "antd";
import _ from "lodash";
import React, { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { INSERT_NEW_BILL } from "../../graphql/bills.queries";
import { UPDATE_INVENTORY_LINES } from "../../graphql/inventory.queries";
import { UPDATE_JOB_LINE } from "../../graphql/jobs-lines.queries";
@@ -24,7 +25,7 @@ import BillFormContainer from "../bill-form/bill-form.container";
import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility";
import { handleUpload as handleLocalUpload } from "../documents-local-upload/documents-local-upload.utility";
import { handleUpload } from "../documents-upload/documents-upload.utility";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { handleUpload as handleUploadToImageProxy } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility";
const mapStateToProps = createStructuredSelector({
billEnterModal: selectBillEnterModal,
@@ -53,10 +54,10 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
const notification = useNotification();
const {
treatments: { Enhanced_Payroll }
treatments: { Enhanced_Payroll, Imgproxy }
} = useSplitTreatments({
attributes: {},
names: ["Enhanced_Payroll"],
names: ["Enhanced_Payroll", "Imgproxy"],
splitKey: bodyshop.imexshopid
});
@@ -196,7 +197,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
job: { lbr_adjustments: newAdjustments }
}
});
if (!!jobUpdate.errors) {
if (jobUpdate.errors) {
notification["error"]({
message: t("jobs.errors.saving", {
message: JSON.stringify(jobUpdate.errors)
@@ -213,7 +214,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
variables: { partsLineIds: markPolReceived.map((p) => p.id) },
refetchQueries: ["QUERY_PARTS_BILLS_BY_JOBID"]
});
if (!!r2.errors) {
if (r2.errors) {
setLoading(false);
setEnterAgain(false);
notification["error"]({
@@ -224,7 +225,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
}
}
if (!!r1.errors) {
if (r1.errors) {
setLoading(false);
setEnterAgain(false);
notification["error"]({
@@ -244,7 +245,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
consumedbybillid: billId
}
});
if (!!r2.errors) {
if (r2.errors) {
setLoading(false);
setEnterAgain(false);
notification["error"]({
@@ -297,6 +298,24 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
notification
});
});
} else {
//Check if using Imgproxy or cloudinary
if (Imgproxy.treatment === "on") {
upload.forEach((u) => {
handleUploadToImageProxy(
{ file: u.originFileObj },
{
bodyshop: bodyshop,
uploaded_by: currentUser.email,
jobId: values.jobid,
billId: billId,
tagsArray: null,
callback: null
},
notification
);
});
} else {
upload.forEach((u) => {
handleUpload(
@@ -314,6 +333,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
});
}
}
}
///////////////////////////
setLoading(false);
notification["success"]({
@@ -396,7 +416,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
{t("bills.labels.generatepartslabel")}
</Checkbox>
<Button onClick={handleCancel}>{t("general.actions.cancel")}</Button>
<Button loading={loading} onClick={() => form.submit()}>
<Button loading={loading} onClick={() => form.submit()} id="save-bill-enter-modal">
{t("general.actions.save")}
</Button>
{billEnterModal.context && billEnterModal.context.id ? null : (
@@ -406,13 +426,14 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
onClick={() => {
setEnterAgain(true);
}}
id="save-and-new-bill-enter-modal"
>
{t("general.actions.saveandnew")}
</Button>
)}
</Space>
}
destroyOnClose
destroyOnHidden
>
<Form
onFinish={handleFinish}

View File

@@ -6,7 +6,7 @@
td {
padding: 8px;
text-align: left;
border-bottom: 1px solid #ddd;
border-bottom: 1px solid var(--table-border-color);
.ant-form-item {
margin-bottom: 0px !important;
@@ -14,6 +14,6 @@
}
tr:hover {
background-color: #f5f5f5;
background-color: var(--table-hover-bg);
}
}

View File

@@ -1,5 +1,5 @@
import { Select } from "antd";
import React, { forwardRef } from "react";
import { forwardRef } from "react";
import { useTranslation } from "react-i18next";
import InstanceRenderMgr from "../../utils/instanceRenderMgr";
@@ -43,7 +43,7 @@ const BillLineSearchSelect = ({ options, disabled, allowRemoved, ...restProps },
item.oem_partno ? ` - ${item.oem_partno}` : ""
}${item.alt_partno ? ` (${item.alt_partno})` : ""}`.trim(),
label: (
<div style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
<div style={{ whiteSpace: "normal", wordBreak: "break-word" }}>
<span>
{`${item.removed ? `(REMOVED) ` : ""}${item.line_desc}${
item.oem_partno ? ` - ${item.oem_partno}` : ""

View File

@@ -1,6 +1,6 @@
import { EditFilled, SyncOutlined } from "@ant-design/icons";
import { Button, Card, Checkbox, Input, Space, Table } from "antd";
import React, { useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FaTasks } from "react-icons/fa";
import { connect } from "react-redux";
@@ -209,6 +209,7 @@ export function BillsListTableComponent({
}
});
}}
id="reconcile-bills-button"
>
<LockerWrapperComponent featureName="bills"> {t("jobs.actions.reconcile")}</LockerWrapperComponent>
</Button>

View File

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

View File

@@ -3,6 +3,7 @@ import { Button, Form, InputNumber, Popover, Space } from "antd";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { logImEXEvent } from "../../firebase/firebase.utils";
export default function CABCpvrtCalculator({ disabled, form }) {
const [visibility, setVisibility] = useState(false);
@@ -39,7 +40,7 @@ export default function CABCpvrtCalculator({ disabled, form }) {
);
return (
<Popover destroyTooltipOnHide content={popContent} open={visibility} disabled={disabled}>
<Popover destroyOnHidden content={popContent} open={visibility} disabled={disabled}>
<Button disabled={disabled} onClick={() => setVisibility(true)}>
<CalculatorFilled />
</Button>

View File

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

View File

@@ -34,16 +34,14 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) {
SubscribeToTopicForFCMNotification();
//Register WS handlers
// Register WebSocket handlers
if (socket && socket.connected) {
registerMessagingHandlers({ socket, client });
}
return () => {
if (socket && socket.connected) {
unregisterMessagingHandlers({ socket });
}
};
}
}, [bodyshop, socket, t, client]);
if (!bodyshop || !bodyshop.messagingservicesid) return <></>;

View File

@@ -202,8 +202,6 @@ export const registerMessagingHandlers = ({ socket, client }) => {
text: message.text
};
// Add cases for other known message types as needed
default:
// Log a warning for unhandled message types
logLocal("handleMessageChanged - Unhandled message type", { type: message.type });
@@ -211,7 +209,7 @@ export const registerMessagingHandlers = ({ socket, client }) => {
}
}
return messageRef; // Keep other messages unchanged
return messageRef;
});
}
}
@@ -245,11 +243,8 @@ export const registerMessagingHandlers = ({ socket, client }) => {
});
const updatedList = existingList?.conversations
? [
newConversation,
...existingList.conversations.filter((conv) => conv.id !== newConversation.id) // Prevent duplicates
]
: [newConversation];
? [newConversation, ...existingList.conversations.filter((conv) => conv.id !== newConversation.id)]
: [newConversation]; // Prevent duplicates
client.cache.writeQuery({
query: CONVERSATION_LIST_QUERY,
@@ -403,6 +398,7 @@ export const registerMessagingHandlers = ({ socket, client }) => {
}
break;
default:
logLocal("handleConversationChanged - Unhandled type", { type });
client.cache.modify({
@@ -419,10 +415,95 @@ export const registerMessagingHandlers = ({ socket, client }) => {
}
};
// Existing handler for phone number opt-out
const handlePhoneNumberOptedOut = async (data) => {
const { bodyshopid, phone_number } = data;
logLocal("handlePhoneNumberOptedOut - Start", data);
try {
client.cache.modify({
id: "ROOT_QUERY",
fields: {
phone_number_opt_out(existing = [], { readField }) {
const phoneNumberExists = existing.some(
(ref) => readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid
);
if (phoneNumberExists) {
logLocal("handlePhoneNumberOptedOut - Phone number already in cache", { phone_number, bodyshopid });
return existing;
}
const newOptOut = {
__typename: "phone_number_opt_out",
id: `temporary-${phone_number}-${Date.now()}`,
bodyshopid,
phone_number,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
return [...existing, newOptOut];
}
},
broadcast: true
});
client.cache.evict({
id: "ROOT_QUERY",
fieldName: "phone_number_opt_out",
args: { bodyshopid, search: phone_number }
});
client.cache.gc();
logLocal("handlePhoneNumberOptedOut - Cache updated successfully", data);
} catch (error) {
console.error("Error updating cache for phone number opt-out:", error);
logLocal("handlePhoneNumberOptedOut - Error", { error: error.message });
}
};
// New handler for phone number opt-in
const handlePhoneNumberOptedIn = async (data) => {
const { bodyshopid, phone_number } = data;
logLocal("handlePhoneNumberOptedIn - Start", data);
try {
// Update the Apollo cache for GET_PHONE_NUMBER_OPT_OUTS by removing the phone number
client.cache.modify({
id: "ROOT_QUERY",
fields: {
phone_number_opt_out(existing = [], { readField }) {
// Filter out the phone number from the opt-out list
return existing.filter(
(ref) => !(readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid)
);
}
},
broadcast: true // Trigger UI updates
});
// Evict the cache entry to force a refetch on next query
client.cache.evict({
id: "ROOT_QUERY",
fieldName: "phone_number_opt_out",
args: { bodyshopid, search: phone_number }
});
client.cache.gc();
logLocal("handlePhoneNumberOptedIn - Cache updated successfully", data);
} catch (error) {
console.error("Error updating cache for phone number opt-in:", error);
logLocal("handlePhoneNumberOptedIn - Error", { error: error.message });
}
};
socket.on("new-message-summary", handleNewMessageSummary);
socket.on("new-message-detailed", handleNewMessageDetailed);
socket.on("message-changed", handleMessageChanged);
socket.on("conversation-changed", handleConversationChanged);
socket.on("phone-number-opted-out", handlePhoneNumberOptedOut);
socket.on("phone-number-opted-in", handlePhoneNumberOptedIn);
};
export const unregisterMessagingHandlers = ({ socket }) => {
@@ -431,4 +512,6 @@ export const unregisterMessagingHandlers = ({ socket }) => {
socket.off("new-message-detailed");
socket.off("message-changed");
socket.off("conversation-changed");
socket.off("phone-number-opted-out");
socket.off("phone-number-opted-in");
};

View File

@@ -1,5 +1,5 @@
import { Badge, Card, List, Space, Tag } from "antd";
import React, { useEffect, useState } from "react";
import { Badge, Card, List, Space, Tag, Tooltip } from "antd";
import { useEffect, useMemo, useState } from "react";
import { connect } from "react-redux";
import { Virtuoso } from "react-virtuoso";
import { createStructuredSelector } from "reselect";
@@ -9,44 +9,65 @@ import { TimeAgoFormatter } from "../../utils/DateFormatter";
import PhoneFormatter from "../../utils/PhoneFormatter";
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import _ from "lodash";
import { ExclamationCircleOutlined } from "@ant-design/icons";
import "./chat-conversation-list.styles.scss";
import { useQuery } from "@apollo/client";
import { GET_PHONE_NUMBER_OPT_OUTS } from "../../graphql/phone-number-opt-out.queries.js";
import { phone } from "phone";
import { useTranslation } from "react-i18next";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation
selectedConversation: selectSelectedConversation,
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
setSelectedConversation: (conversationId) => dispatch(setSelectedConversation(conversationId))
});
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation }) {
// That comma is there for a reason, do not remove it
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation, bodyshop }) {
const { t } = useTranslation();
const [, forceUpdate] = useState(false);
const phoneNumbers = conversationList.map((item) => phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, ""));
const { data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUTS, {
variables: {
bodyshopid: bodyshop.id,
phone_numbers: phoneNumbers
},
skip: !conversationList.length,
fetchPolicy: "cache-and-network"
});
const optOutMap = useMemo(() => {
const map = new Map();
optOutData?.phone_number_opt_out?.forEach((optOut) => {
map.set(optOut.phone_number, true);
});
return map;
}, [optOutData?.phone_number_opt_out]);
// Re-render every minute
useEffect(() => {
const interval = setInterval(() => {
forceUpdate((prev) => !prev); // Toggle state to trigger re-render
}, 60000); // 1 minute in milliseconds
return () => clearInterval(interval); // Cleanup on unmount
forceUpdate((prev) => !prev);
}, 60000);
return () => clearInterval(interval);
}, []);
// Memoize the sorted conversation list
const sortedConversationList = React.useMemo(() => {
const sortedConversationList = useMemo(() => {
return _.orderBy(conversationList, ["updated_at"], ["desc"]);
}, [conversationList]);
const renderConversation = (index) => {
const renderConversation = (index, t) => {
const item = sortedConversationList[index];
const normalizedPhone = phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, "");
const hasOptOutEntry = optOutMap.has(normalizedPhone);
const cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>;
const cardContentLeft =
item.job_conversations.length > 0
? item.job_conversations.map((j, idx) => <Tag key={idx}>{j.job.ro_number}</Tag>)
: null;
const names = <>{_.uniq(item.job_conversations.map((j, idx) => OwnerNameDisplayFunction(j.job)))}</>;
const cardTitle = (
<>
{item.label && <Tag color="blue">{item.label}</Tag>}
@@ -59,13 +80,22 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
)}
</>
);
const cardExtra = <Badge count={item.messages_aggregate.aggregate.count} />;
const cardExtra = (
<>
<Badge count={item.messages_aggregate.aggregate.count} />
{hasOptOutEntry && (
<Tooltip title={t("consent.text_body")}>
<Tag color="red" icon={<ExclamationCircleOutlined />}>
{t("messaging.labels.no_consent")}
</Tag>
</Tooltip>
)}
</>
);
const getCardStyle = () =>
item.id === selectedConversation
? { backgroundColor: "rgba(128, 128, 128, 0.2)" }
: { backgroundColor: index % 2 === 0 ? "#f0f2f5" : "#ffffff" };
? { backgroundColor: "var(--card-selected-bg)" }
: { backgroundColor: index % 2 === 0 ? "var(--card-stripe-even-bg)" : "var(--card-stripe-odd-bg)" };
return (
<List.Item
@@ -73,9 +103,25 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
onClick={() => setSelectedConversation(item.id)}
className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`}
>
<Card style={getCardStyle()} bordered={false} size="small" extra={cardExtra} title={cardTitle}>
<div style={{ display: "inline-block", width: "70%", textAlign: "left" }}>{cardContentLeft}</div>
<div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div>
<Card style={getCardStyle()} variant={true} size="small" extra={cardExtra} title={cardTitle}>
<div
style={{
display: "inline-block",
width: "70%",
textAlign: "left"
}}
>
{cardContentLeft}
</div>
<div
style={{
display: "inline-block",
width: "30%",
textAlign: "right"
}}
>
{cardContentRight}
</div>
</Card>
</List.Item>
);
@@ -85,7 +131,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
<div className="chat-list-container">
<Virtuoso
data={sortedConversationList}
itemContent={(index) => renderConversation(index)}
itemContent={(index) => renderConversation(index, t)}
style={{ height: "100%", width: "100%" }}
/>
</div>

View File

@@ -24,7 +24,7 @@
/* Add spacing and better alignment for items */
.chat-list-item {
padding: 0.5rem 0; /* Add spacing between list items */
padding: 0.2rem 0; /* Add spacing between list items */
.ant-card {
border-radius: 8px; /* Slight rounding for card edges */

View File

@@ -58,6 +58,7 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
userid
created_at
read
is_system
}
`,
data: message

View File

@@ -13,13 +13,14 @@ import JobDocumentsGalleryExternal from "../jobs-documents-gallery/jobs-document
import JobsDocumentImgproxyGalleryExternal from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component";
import JobDocumentsLocalGalleryExternal from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import "./chat-media-selector.styles.scss";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
const mapDispatchToProps = (dispatch) => ({});
export default connect(mapStateToProps, mapDispatchToProps)(ChatMediaSelector);
export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, conversation }) {
@@ -37,9 +38,8 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
variables: {
jobId: conversation.job_conversations[0] && conversation.job_conversations[0].jobid
jobId: conversation.job_conversations[0]?.jobid
},
skip: !open || !conversation.job_conversations || conversation.job_conversations.length === 0
});
@@ -56,25 +56,25 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
//If Imageproxy is on, rely only on the LMS selector
//If not on, use the old methods.
const content = (
<div>
<div className="media-selector-content">
{loading && <LoadingSpinner />}
{error && <AlertComponent message={error.message} type="error" />}
{selectedMedia.filter((s) => s.isSelected).length >= 10 ? (
<div style={{ color: "red" }}>{t("messaging.labels.maxtenimages")}</div>
<div className="error-message">{t("messaging.labels.maxtenimages")}</div>
) : null}
{Imgproxy.treatment === "on" ? (
<>
{!bodyshop.uselocalmediaserver && (
<JobsDocumentImgproxyGalleryExternal
jobId={conversation.job_conversations[0].jobid}
jobId={conversation.job_conversations[0]?.jobid}
externalMediaState={[selectedMedia, setSelectedMedia]}
/>
)}
{bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal
externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid}
jobId={conversation.job_conversations[0]?.jobid}
/>
)}
</>
@@ -89,7 +89,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
{bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal
externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid}
jobId={conversation.job_conversations[0]?.jobid}
/>
)}
</>
@@ -100,12 +100,17 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
return (
<Popover
content={
conversation.job_conversations.length === 0 ? <div>{t("messaging.errors.noattachedjobs")}</div> : content
conversation.job_conversations.length === 0 ? (
<div className="no-jobs-message">{t("messaging.errors.noattachedjobs")}</div>
) : (
content
)
}
title={t("messaging.labels.selectmedia")}
trigger="click"
open={open}
onOpenChange={handleVisibleChange}
classNames={{ root: "media-selector-popover" }}
>
<Badge count={selectedMedia.filter((s) => s.isSelected).length}>
<PictureFilled style={{ margin: "0 .5rem" }} />

View File

@@ -0,0 +1,47 @@
.media-selector-popover {
.ant-popover-inner-content {
position: relative;
max-width: 640px;
max-height: 480px;
overflow-y: auto;
padding: 8px;
background-color: var(--popover-bg);
border-radius: 8px;
}
}
.media-selector-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.error-message {
color: var(--error-text);
font-size: 12px;
text-align: center;
margin-bottom: 8px;
}
.no-jobs-message {
font-size: 14px;
color: var(--no-jobs-text);
text-align: center;
padding: 8px;
}
/* Style images within gallery components */
.media-selector-content img {
object-fit: cover;
border-radius: 4px;
margin: 4px;
cursor: pointer;
}
/* Grid layout for gallery components */
.media-selector-content .ant-image,
.media-selector-content .gallery-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 4px;
}

View File

@@ -4,13 +4,16 @@
height: 100%;
width: 100%;
}
.archive-button {
height: 20px;
border-radius: 4px;
}
.chat-title {
margin-bottom: 5px;
}
.messages {
display: flex;
flex-direction: column;
@@ -37,17 +40,18 @@
gap: 8px;
}
}
.chat-send-message-button {
margin: 0.3rem;
padding-left: 0.5rem;
}
.message-icon {
position: absolute;
bottom: 0.1rem;
right: 0.3rem;
margin: 0 0.1rem;
color: whitesmoke;
color: var(--message-icon-color);
z-index: 5;
}
@@ -75,7 +79,7 @@
&:last-child:after {
width: 10px;
background: white;
background: var(--message-mine-tail-bg);
z-index: 1;
}
}
@@ -87,11 +91,11 @@
.message {
margin-right: 20%;
background-color: #eee;
background-color: var(--message-yours-bg);
&:last-child:before {
left: -7px;
background: #eee;
background: var(--message-yours-bg);
border-bottom-right-radius: 15px;
}
@@ -107,14 +111,14 @@
align-items: flex-end;
.message {
color: white;
color: var(--message-mine-text);
margin-left: 25%;
background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%);
background: linear-gradient(to bottom, var(--message-mine-bg-start) 0%, var(--message-mine-bg-end) 100%);
padding-bottom: 0.6rem;
&:last-child:before {
right: -8px;
background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%);
background: linear-gradient(to bottom, var(--message-mine-bg-start) 0%, var(--message-mine-bg-end) 100%);
border-bottom-left-radius: 15px;
}
@@ -125,6 +129,36 @@
}
}
.system {
align-items: center;
margin: 0.5rem 10%;
.message {
background-color: var(--system-message-bg);
border-radius: 10px;
padding: 0.5rem 1rem;
text-align: center;
font-style: italic;
color: var(--system-message-text);
width: fit-content;
max-width: 80%;
}
.system-label {
font-size: 0.75rem;
color: var(--system-label-text);
margin-bottom: 0.2rem;
display: block;
}
.system-date {
font-size: 0.75rem;
color: var(--system-label-text);
margin-top: 0.2rem;
text-align: center;
}
}
.virtuoso-container {
flex: 1;
overflow: auto;

View File

@@ -2,17 +2,29 @@ import Icon from "@ant-design/icons";
import { Tooltip } from "antd";
import i18n from "i18next";
import dayjs from "../../utils/day";
import { MdDone, MdDoneAll } from "react-icons/md";
import { MdClose, MdDone, MdDoneAll } from "react-icons/md";
import { DateTimeFormatter } from "../../utils/DateFormatter";
export const renderMessage = (messages, index) => {
const message = messages[index];
const isSystem = message.is_system;
// Determine message class
const messageClass = isSystem ? "system messages" : message.isoutbound ? "mine messages" : "yours messages";
// Tooltip content based on message type
const tooltipTitle = isSystem ? (
i18n.t("consent.text_body")
) : (
<DateTimeFormatter>{message.created_at}</DateTimeFormatter>
);
return (
<div key={index} className={`${message.isoutbound ? "mine messages" : "yours messages"}`}>
<div key={index} className={messageClass}>
<div className="message msgmargin">
<Tooltip title={DateTimeFormatter({ children: message.created_at })}>
<Tooltip title={tooltipTitle}>
<div>
{isSystem && <span className="system-label">System</span>}
{/* Render images if available */}
{message.image && message.image_path?.length > 0 && (
<div className="message-images">
@@ -26,20 +38,31 @@ export const renderMessage = (messages, index) => {
</div>
)}
{/* Render text if available */}
{message.text && <div>{message.text}</div>}
</div>
</Tooltip>
{/* Message status icons */}
{message.status && (message.status === "sent" || message.status === "delivered") && (
<div className="message-status">
<Icon component={message.status === "sent" ? MdDone : MdDoneAll} className="message-icon" />
{message.text && <div className="message-text">{message.text}</div>}
{/* Render date for system messages */}
{isSystem && (
<div className="system-date">
<DateTimeFormatter>{message.created_at}</DateTimeFormatter>
</div>
)}
</div>
</Tooltip>
{/* Outbound message metadata */}
{message.isoutbound && (
{/* Message status icons for non-system messages */}
{!isSystem &&
message.status &&
(message.status === "sent" || message.status === "delivered" || message.status === "failed") && (
<div className="message-status">
<Icon
component={message.status === "sent" ? MdDone : message.status === "delivered" ? MdDoneAll : MdClose}
className="message-icon"
style={message.status === "failed" ? { color: "#ff0000" } : undefined}
/>
</div>
)}
</div>
{/* Outbound message metadata for non-system messages */}
{!isSystem && message.isoutbound && (
<div style={{ fontSize: 10 }}>
{i18n.t("messaging.labels.sentby", {
by: message.userid,

View File

@@ -1,6 +1,6 @@
import { LoadingOutlined, SendOutlined } from "@ant-design/icons";
import { Input, Spin } from "antd";
import React, { useEffect, useRef, useState } from "react";
import { ExclamationCircleOutlined, LoadingOutlined, SendOutlined } from "@ant-design/icons";
import { Alert, Input, Space, Spin, Tooltip } from "antd";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -10,6 +10,9 @@ import { selectIsSending, selectMessage } from "../../redux/messaging/messaging.
import { selectBodyshop } from "../../redux/user/user.selectors";
import ChatMediaSelector from "../chat-media-selector/chat-media-selector.component";
import ChatPresetsComponent from "../chat-presets/chat-presets.component";
import { useQuery } from "@apollo/client";
import { phone } from "phone";
import { GET_PHONE_NUMBER_OPT_OUT } from "../../graphql/phone-number-opt-out.queries";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -25,16 +28,24 @@ const mapDispatchToProps = (dispatch) => ({
function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSending, message, setMessage }) {
const inputArea = useRef(null);
const [selectedMedia, setSelectedMedia] = useState([]);
const { t } = useTranslation();
const normalizedPhone = phone(conversation.phone_num, "CA").phoneNumber.replace(/^\+1/, "");
const { data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUT, {
variables: { bodyshopid: bodyshop.id, phone_number: normalizedPhone },
fetchPolicy: "cache-and-network"
});
const isOptedOut = !!optOutData?.phone_number_opt_out?.[0];
useEffect(() => {
inputArea.current.focus();
}, [isSending, setMessage]);
const { t } = useTranslation();
const handleEnter = () => {
const selectedImages = selectedMedia.filter((i) => i.isSelected);
if ((message === "" || !message) && selectedImages.length === 0) return;
if (isOptedOut) return; // Prevent sending if phone number is opted out
logImEXEvent("messaging_send_message");
if (selectedImages.length < 11) {
@@ -44,7 +55,8 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
messagingServiceSid: bodyshop.messagingservicesid,
conversationid: conversation.id,
selectedMedia: selectedImages,
imexshopid: bodyshop.imexshopid
imexshopid: bodyshop.imexshopid,
bodyshopid: bodyshop.id
};
sendMessage(newMessage);
setSelectedMedia(
@@ -56,13 +68,29 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
};
return (
<Space direction="vertical" style={{ width: "100%" }} size="middle">
{isOptedOut && (
<Tooltip title={t("consent.text_body")}>
<Alert
showIcon={true}
icon={<ExclamationCircleOutlined />}
message={t("messaging.errors.no_consent")}
type="error"
/>
</Tooltip>
)}
<div className="imex-flex-row" style={{ width: "100%" }}>
<ChatPresetsComponent className="imex-flex-row__margin" />
{!isOptedOut && (
<>
<ChatPresetsComponent disabled={isSending} className="imex-flex-row__margin" />
<ChatMediaSelector
disabled={isSending}
conversation={conversation}
selectedMedia={selectedMedia}
setSelectedMedia={setSelectedMedia}
/>
</>
)}
<span style={{ flex: 1 }}>
<Input.TextArea
className="imex-flex-row__margin imex-flex-row__grow"
@@ -71,20 +99,23 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
ref={inputArea}
autoSize={{ minRows: 1, maxRows: 4 }}
value={message}
disabled={isSending}
disabled={isSending || isOptedOut}
placeholder={t("messaging.labels.typeamessage")}
onChange={(e) => setMessage(e.target.value)}
onPressEnter={(event) => {
event.preventDefault();
if (!!!event.shiftKey) handleEnter();
if (!event.shiftKey && !isOptedOut) handleEnter();
}}
/>
</span>
{!isOptedOut && (
<SendOutlined
className="chat-send-message-button"
// disabled={message === "" || !message}
disabled={isSending || message === "" || !message}
onClick={handleEnter}
/>
)}
<Spin
style={{ display: `${isSending ? "" : "none"}` }}
indicator={
@@ -97,6 +128,7 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
}
/>
</div>
</Space>
);
}

View File

@@ -1,10 +1,10 @@
import React, { forwardRef, useEffect, useState } from "react";
import { forwardRef, useEffect, useState } from "react";
import { Select } from "antd";
import { useTranslation } from "react-i18next";
const { Option } = Select;
const ContractStatusComponent = ({ value, onChange }, ref) => {
const ContractStatusComponent = ({ value, onChange }) => {
const [option, setOption] = useState(value);
const { t } = useTranslation();

View File

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

View File

@@ -1,5 +1,5 @@
import { Slider } from "antd";
import React, { forwardRef } from "react";
import { forwardRef } from "react";
import { useTranslation } from "react-i18next";
const CourtesyCarFuelComponent = (props, ref) => {

View File

@@ -1,6 +1,6 @@
import { Card, Table, Tag } from "antd";
import axios from "axios";
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import dayjs from "../../../utils/day";
import LoadingSkeleton from "../../loading-skeleton/loading-skeleton.component";
@@ -69,7 +69,6 @@ export default function JobLifecycleDashboardComponent({ data, bodyshop, ...card
];
if (!data) return null;
if (!data.job_lifecycle || !lifecycleData) return <DashboardRefreshRequired {...cardProps} />;
const extra = `${t("job_lifecycle.content.calculated_based_on")} ${lifecycleData.jobs} ${t("job_lifecycle.content.jobs_in_since")} ${fortyFiveDaysAgo()}`;
@@ -88,7 +87,7 @@ export default function JobLifecycleDashboardComponent({ data, bodyshop, ...card
borderRadius: "5px",
borderWidth: "5px",
borderStyle: "solid",
borderColor: "#f0f2f5",
borderColor: "var(--bar-border-color)",
margin: 0,
padding: 0
}}
@@ -107,12 +106,10 @@ export default function JobLifecycleDashboardComponent({ data, bodyshop, ...card
alignItems: "center",
margin: 0,
padding: 0,
borderTop: "1px solid #f0f2f5",
borderBottom: "1px solid #f0f2f5",
borderLeft: isFirst ? "1px solid #f0f2f5" : undefined,
borderRight: isLast ? "1px solid #f0f2f5" : undefined,
borderTop: "1px solid var(--bar-border-color)",
borderBottom: "1px solid var(--bar-border-color)",
borderLeft: isFirst ? "1px solid var(--bar-border-color)" : undefined,
borderRight: isLast ? "1px solid var(--bar-border-color)" : undefined,
backgroundColor: key.color,
width: `${key.percentage}%`
}}
@@ -124,7 +121,7 @@ export default function JobLifecycleDashboardComponent({ data, bodyshop, ...card
<div>{key.roundedPercentage}</div>
<div
style={{
backgroundColor: "#f0f2f5",
backgroundColor: "var(--tag-wrapper-bg)",
borderRadius: "5px",
paddingRight: "2px",
paddingLeft: "2px",
@@ -152,8 +149,8 @@ export default function JobLifecycleDashboardComponent({ data, bodyshop, ...card
aria-label={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
title={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
style={{
backgroundColor: "#f0f2f5",
color: "#000",
backgroundColor: "var(--tag-wrapper-bg)",
color: "var(--tag-wrapper-text)",
padding: "4px",
textAlign: "center"
}}

View File

@@ -0,0 +1,411 @@
import { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined } from "@ant-design/icons";
import { Card, Space, Switch, Table, Tooltip, Typography } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { TimeFormatter } from "../../../utils/DateFormatter";
import { onlyUnique } from "../../../utils/arrayHelper";
import dayjs from "../../../utils/day";
import { alphaSort, dateSort } from "../../../utils/sorters";
import useLocalStorage from "../../../utils/useLocalStorage";
import ChatOpenButton from "../../chat-open-button/chat-open-button.component";
import OwnerNameDisplay, { OwnerNameDisplayFunction } from "../../owner-name-display/owner-name-display.component";
import DashboardRefreshRequired from "../refresh-required.component";
export default function DashboardScheduledDeliveryToday({ data, ...cardProps }) {
const { t } = useTranslation();
const [state, setState] = useState({
sortedInfo: {},
filteredInfo: {}
});
const [isTvModeScheduledDelivery, setIsTvModeScheduledDelivery] = useLocalStorage("isTvModeScheduledDelivery", false);
if (!data) return null;
if (!data.scheduled_delivery_today) return <DashboardRefreshRequired {...cardProps} />;
const scheduledDeliveryToday = data.scheduled_delivery_today.map((item) => {
const joblines_body = item.joblines
? item.joblines.filter((l) => l.mod_lbr_ty !== "LAR").reduce((acc, val) => acc + val.mod_lb_hrs, 0)
: 0;
const joblines_ref = item.joblines
? item.joblines.filter((l) => l.mod_lbr_ty === "LAR").reduce((acc, val) => acc + val.mod_lb_hrs, 0)
: 0;
return {
...item,
joblines_body,
joblines_ref
};
});
const tvFontSize = 18;
const tvFontWeight = "bold";
const tvColumns = [
{
title: t("jobs.fields.scheduled_delivery"),
dataIndex: "scheduled_delivery",
key: "scheduled_delivery",
ellipsis: true,
sorter: (a, b) => dateSort(a.scheduled_delivery, b.scheduled_delivery),
sortOrder: state.sortedInfo.columnKey === "scheduled_delivery" && state.sortedInfo.order,
render: (text, record) => (
<span style={{ fontSize: tvFontSize, fontWeight: tvFontWeight }}>
<TimeFormatter>{record.scheduled_delivery}</TimeFormatter>
</span>
)
},
{
title: t("jobs.fields.ro_number"),
dataIndex: "ro_number",
key: "ro_number",
sorter: (a, b) => alphaSort(a.ro_number, b.ro_number),
sortOrder: state.sortedInfo.columnKey === "ro_number" && state.sortedInfo.order,
render: (text, record) => (
<Link to={"/manage/jobs/" + record.jobid} onClick={(e) => e.stopPropagation()}>
<Space>
<span style={{ fontSize: tvFontSize, fontWeight: tvFontWeight }}>
{record.ro_number || t("general.labels.na")}
{record.production_vars && record.production_vars.alert ? (
<ExclamationCircleFilled className="production-alert" />
) : null}
{record.suspended && <PauseCircleOutlined style={{ color: "orangered" }} />}
{record.iouparent && (
<Tooltip title={t("jobs.labels.iou")}>
<BranchesOutlined style={{ color: "orangered" }} />
</Tooltip>
)}
</span>
</Space>
</Link>
)
},
{
title: t("jobs.fields.owner"),
dataIndex: "owner",
key: "owner",
ellipsis: true,
sorter: (a, b) => alphaSort(OwnerNameDisplayFunction(a), OwnerNameDisplayFunction(b)),
sortOrder: state.sortedInfo.columnKey === "owner" && state.sortedInfo.order,
render: (text, record) => {
return record.ownerid ? (
<Link to={"/manage/owners/" + record.ownerid} onClick={(e) => e.stopPropagation()}>
<span style={{ fontSize: tvFontSize, fontWeight: tvFontWeight }}>
<OwnerNameDisplay ownerObject={record} />
</span>
</Link>
) : (
<span style={{ fontSize: tvFontSize, fontWeight: tvFontWeight }}>
<OwnerNameDisplay ownerObject={record} />
</span>
);
}
},
{
title: t("jobs.fields.vehicle"),
dataIndex: "vehicle",
key: "vehicle",
ellipsis: true,
sorter: (a, b) =>
alphaSort(
`${a.v_model_yr || ""} ${a.v_make_desc || ""} ${a.v_model_desc || ""}`,
`${b.v_model_yr || ""} ${b.v_make_desc || ""} ${b.v_model_desc || ""}`
),
sortOrder: state.sortedInfo.columnKey === "vehicle" && state.sortedInfo.order,
render: (text, record) => {
return record.vehicleid ? (
<Link to={"/manage/vehicles/" + record.vehicleid} onClick={(e) => e.stopPropagation()}>
<span style={{ fontSize: tvFontSize, fontWeight: tvFontWeight }}>
{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${record.v_model_desc || ""}`}
</span>
</Link>
) : (
<span style={{ fontSize: tvFontSize, fontWeight: tvFontWeight }}>{`${
record.v_model_yr || ""
} ${record.v_make_desc || ""} ${record.v_model_desc || ""}`}</span>
);
}
},
{
title: t("appointments.fields.alt_transport"),
dataIndex: "alt_transport",
key: "alt_transport",
ellipsis: true,
sorter: (a, b) => alphaSort(a.alt_transport, b.alt_transport),
sortOrder: state.sortedInfo.columnKey === "alt_transport" && state.sortedInfo.order,
filters:
(scheduledDeliveryToday &&
scheduledDeliveryToday
.map((j) => j.alt_transport)
.filter(onlyUnique)
.map((s) => {
return {
text: s || t("dashboard.errors.atp"),
value: [s]
};
})
.sort((a, b) => alphaSort(a.text, b.text))) ||
[],
onFilter: (value, record) => value.includes(record.alt_transport),
render: (text, record) => (
<span style={{ fontSize: tvFontSize, fontWeight: tvFontWeight }}>{record.alt_transport}</span>
)
},
{
title: t("jobs.fields.status"),
dataIndex: "status",
key: "status",
ellipsis: true,
sorter: (a, b) => alphaSort(a.status, b.status),
sortOrder: state.sortedInfo.columnKey === "status" && state.sortedInfo.order,
filters:
(scheduledDeliveryToday &&
scheduledDeliveryToday
.map((j) => j.status)
.filter(onlyUnique)
.map((s) => {
return {
text: s || t("dashboard.errors.status"),
value: [s]
};
})
.sort((a, b) => alphaSort(a.text, b.text))) ||
[],
onFilter: (value, record) => value.includes(record.status),
render: (text, record) => <span style={{ fontSize: tvFontSize, fontWeight: tvFontWeight }}>{record.status}</span>
},
{
title: t("jobs.fields.lab"),
dataIndex: "joblines_body",
key: "joblines_body",
sorter: (a, b) => a.joblines_body - b.joblines_body,
sortOrder: state.sortedInfo.columnKey === "joblines_body" && state.sortedInfo.order,
align: "right",
render: (text, record) => (
<span style={{ fontSize: tvFontSize, fontWeight: tvFontWeight }}>{record.joblines_body.toFixed(1)}</span>
)
},
{
title: t("jobs.fields.lar"),
dataIndex: "joblines_ref",
key: "joblines_ref",
sorter: (a, b) => a.joblines_ref - b.joblines_ref,
sortOrder: state.sortedInfo.columnKey === "joblines_ref" && state.sortedInfo.order,
align: "right",
render: (text, record) => (
<span style={{ fontSize: tvFontSize, fontWeight: tvFontWeight }}>{record.joblines_ref.toFixed(1)}</span>
)
}
];
const columns = [
{
title: t("jobs.fields.scheduled_delivery"),
dataIndex: "scheduled_delivery",
key: "scheduled_delivery",
ellipsis: true,
sorter: (a, b) => dateSort(a.scheduled_delivery, b.scheduled_delivery),
sortOrder: state.sortedInfo.columnKey === "scheduled_delivery" && state.sortedInfo.order,
render: (text, record) => <TimeFormatter>{record.scheduled_delivery}</TimeFormatter>
},
{
title: t("jobs.fields.ro_number"),
dataIndex: "ro_number",
key: "ro_number",
sorter: (a, b) => alphaSort(a.ro_number, b.ro_number),
sortOrder: state.sortedInfo.columnKey === "ro_number" && state.sortedInfo.order,
render: (text, record) => (
<Link to={"/manage/jobs/" + record.jobid} onClick={(e) => e.stopPropagation()}>
<Space>
{record.ro_number || t("general.labels.na")}
{record.production_vars && record.production_vars.alert ? (
<ExclamationCircleFilled className="production-alert" />
) : null}
{record.suspended && <PauseCircleOutlined style={{ color: "orangered" }} />}
{record.iouparent && (
<Tooltip title={t("jobs.labels.iou")}>
<BranchesOutlined style={{ color: "orangered" }} />
</Tooltip>
)}
</Space>
</Link>
)
},
{
title: t("jobs.fields.owner"),
dataIndex: "owner",
key: "owner",
ellipsis: true,
sorter: (a, b) => alphaSort(OwnerNameDisplayFunction(a), OwnerNameDisplayFunction(b)),
sortOrder: state.sortedInfo.columnKey === "owner" && state.sortedInfo.order,
render: (text, record) => {
return record.ownerid ? (
<Link to={"/manage/owners/" + record.ownerid} onClick={(e) => e.stopPropagation()}>
<OwnerNameDisplay ownerObject={record} />
</Link>
) : (
<span>
<OwnerNameDisplay ownerObject={record} />
</span>
);
}
},
{
title: t("dashboard.labels.phone"),
dataIndex: "ownr_ph",
key: "ownr_ph",
ellipsis: true,
responsive: ["md"],
render: (text, record) => (
<Space size="small" wrap>
<ChatOpenButton phone={record.ownr_ph1} jobid={record.jobid} />
<ChatOpenButton phone={record.ownr_ph2} jobid={record.jobid} />
</Space>
)
},
{
title: t("jobs.fields.ownr_ea"),
dataIndex: "ownr_ea",
key: "ownr_ea",
ellipsis: true,
responsive: ["md"],
render: (text, record) => <a href={`mailto:${record.ownr_ea}`}>{record.ownr_ea}</a>
},
{
title: t("jobs.fields.vehicle"),
dataIndex: "vehicle",
key: "vehicle",
ellipsis: true,
sorter: (a, b) =>
alphaSort(
`${a.v_model_yr || ""} ${a.v_make_desc || ""} ${a.v_model_desc || ""}`,
`${b.v_model_yr || ""} ${b.v_make_desc || ""} ${b.v_model_desc || ""}`
),
sortOrder: state.sortedInfo.columnKey === "vehicle" && state.sortedInfo.order,
render: (text, record) => {
return record.vehicleid ? (
<Link to={"/manage/vehicles/" + record.vehicleid} onClick={(e) => e.stopPropagation()}>
{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${record.v_model_desc || ""}`}
</Link>
) : (
<span>{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${record.v_model_desc || ""}`}</span>
);
}
},
{
title: t("jobs.fields.ins_co_nm"),
dataIndex: "ins_co_nm",
key: "ins_co_nm",
ellipsis: true,
responsive: ["md"],
sorter: (a, b) => alphaSort(a.ins_co_nm, b.ins_co_nm),
sortOrder: state.sortedInfo.columnKey === "ins_co_nm" && state.sortedInfo.order,
filters:
(scheduledDeliveryToday &&
scheduledDeliveryToday
.map((j) => j.ins_co_nm)
.filter(onlyUnique)
.map((s) => {
return {
text: s || t("dashboard.errors.insco"),
value: [s]
};
})
.sort((a, b) => alphaSort(a.text, b.text))) ||
[],
onFilter: (value, record) => value.includes(record.ins_co_nm)
},
{
title: t("appointments.fields.alt_transport"),
dataIndex: "alt_transport",
key: "alt_transport",
ellipsis: true,
sorter: (a, b) => alphaSort(a.alt_transport, b.alt_transport),
sortOrder: state.sortedInfo.columnKey === "alt_transport" && state.sortedInfo.order,
filters:
(scheduledDeliveryToday &&
scheduledDeliveryToday
.map((j) => j.alt_transport)
.filter(onlyUnique)
.map((s) => {
return {
text: s || t("dashboard.errors.atp"),
value: [s]
};
})
.sort((a, b) => alphaSort(a.text, b.text))) ||
[],
onFilter: (value, record) => value.includes(record.alt_transport)
}
];
const handleTableChange = (pagination, filters, sorter) => {
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
};
return (
<Card
title={t("dashboard.titles.scheduleddeliverydate", {
date: dayjs().startOf("day").format("MM/DD/YYYY")
})}
extra={
<Space>
<Typography.Text>{t("general.labels.tvmode")}</Typography.Text>
<Switch
onClick={() => setIsTvModeScheduledDelivery(!isTvModeScheduledDelivery)}
defaultChecked={isTvModeScheduledDelivery}
/>
</Space>
}
{...cardProps}
>
<div style={{ height: "100%" }}>
<Table
onChange={handleTableChange}
pagination={false}
columns={isTvModeScheduledDelivery ? tvColumns : columns}
scroll={{ x: true, y: "calc(100% - 2em)" }}
rowKey="id"
style={{ height: "85%" }}
dataSource={scheduledDeliveryToday}
size={isTvModeScheduledDelivery ? "small" : "middle"}
/>
</div>
</Card>
);
}
export const DashboardScheduledDeliveryTodayGql = `
scheduled_delivery_today: jobs(where: {
date_invoiced: {_is_null: true},
ro_number: {_is_null: false},
voided: {_eq: false},
scheduled_delivery: {_gte: "${dayjs().startOf("day").toISOString()}",
_lte: "${dayjs().endOf("day").toISOString()}"}}) {
alt_transport
clm_no
jobid: id
joblines(where: {removed: {_eq: false}}) {
mod_lb_hrs
mod_lbr_ty
}
ins_co_nm
iouparent
ownerid
ownr_co_nm
ownr_ea
ownr_fn
ownr_ln
ownr_ph1
ownr_ph2
production_vars
ro_number
scheduled_delivery
status
suspended
v_make_desc
v_model_desc
v_model_yr
v_vin
vehicleid
}
`;

View File

@@ -1,11 +1,11 @@
import { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined } from "@ant-design/icons";
import { Card, Space, Switch, Table, Tooltip, Typography } from "antd";
import dayjs from "../../../utils/day";
import React, { useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { TimeFormatter } from "../../../utils/DateFormatter";
import { onlyUnique } from "../../../utils/arrayHelper";
import dayjs from "../../../utils/day";
import { alphaSort, dateSort } from "../../../utils/sorters";
import useLocalStorage from "../../../utils/useLocalStorage";
import ChatOpenButton from "../../chat-open-button/chat-open-button.component";
@@ -169,7 +169,7 @@ export default function DashboardScheduledInToday({ data, ...cardProps }) {
.filter(onlyUnique)
.map((s) => {
return {
text: s || "No Alt. Transport",
text: s || t("dashboard.errors.atp"),
value: [s]
};
})
@@ -313,7 +313,7 @@ export default function DashboardScheduledInToday({ data, ...cardProps }) {
.filter(onlyUnique)
.map((s) => {
return {
text: s || "No Ins. Co.*",
text: s || t("dashboard.errors.insco"),
value: [s]
};
})
@@ -335,7 +335,7 @@ export default function DashboardScheduledInToday({ data, ...cardProps }) {
.filter(onlyUnique)
.map((s) => {
return {
text: s || "No Alt. Transport",
text: s || t("dashboard.errors.atp"),
value: [s]
};
})

View File

@@ -1,11 +1,11 @@
import { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined } from "@ant-design/icons";
import { Card, Space, Switch, Table, Tooltip, Typography } from "antd";
import dayjs from "../../../utils/day";
import React, { useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { TimeFormatter } from "../../../utils/DateFormatter";
import { onlyUnique } from "../../../utils/arrayHelper";
import dayjs from "../../../utils/day";
import { alphaSort, dateSort } from "../../../utils/sorters";
import useLocalStorage from "../../../utils/useLocalStorage";
import ChatOpenButton from "../../chat-open-button/chat-open-button.component";
@@ -138,7 +138,7 @@ export default function DashboardScheduledOutToday({ data, ...cardProps }) {
.filter(onlyUnique)
.map((s) => {
return {
text: s || "No Alt. Transport*",
text: s || t("dashboard.errors.atp"),
value: [s]
};
})
@@ -154,7 +154,7 @@ export default function DashboardScheduledOutToday({ data, ...cardProps }) {
dataIndex: "status",
key: "status",
ellipsis: true,
sorter: (a, b) => alphaSort(a.alt_transport, b.alt_transport),
sorter: (a, b) => alphaSort(a.status, b.status),
sortOrder: state.sortedInfo.columnKey === "status" && state.sortedInfo.order,
filters:
(scheduledOutToday &&
@@ -163,7 +163,7 @@ export default function DashboardScheduledOutToday({ data, ...cardProps }) {
.filter(onlyUnique)
.map((s) => {
return {
text: s || "No Status*",
text: s || t("dashboard.errors.status"),
value: [s]
};
})
@@ -306,7 +306,7 @@ export default function DashboardScheduledOutToday({ data, ...cardProps }) {
.filter(onlyUnique)
.map((s) => {
return {
text: s || "No Ins. Co.*",
text: s || t("dashboard.errors.insco"),
value: [s]
};
})
@@ -328,7 +328,7 @@ export default function DashboardScheduledOutToday({ data, ...cardProps }) {
.filter(onlyUnique)
.map((s) => {
return {
text: s || "No Alt. Transport*",
text: s || t("dashboard.errors.atp"),
value: [s]
};
})

View File

@@ -1,30 +1,33 @@
import i18next from "i18next";
import DashboardTotalProductionDollars from "../dashboard-components/total-production-dollars/total-production-dollars.component.jsx";
import {
DashboardTotalProductionHours,
DashboardTotalProductionHoursGql
} from "../dashboard-components/total-production-hours/total-production-hours.component.jsx";
import DashboardProjectedMonthlySales, {
DashboardProjectedMonthlySalesGql
} from "../dashboard-components/pojected-monthly-sales/projected-monthly-sales.component.jsx";
import DashboardMonthlyRevenueGraph, {
DashboardMonthlyRevenueGraphGql
} from "../dashboard-components/monthly-revenue-graph/monthly-revenue-graph.component.jsx";
import DashboardMonthlyJobCosting from "../dashboard-components/monthly-job-costing/monthly-job-costing.component.jsx";
import DashboardMonthlyPartsSales from "../dashboard-components/monthly-parts-sales/monthly-parts-sales.component.jsx";
import DashboardMonthlyLaborSales from "../dashboard-components/monthly-labor-sales/monthly-labor-sales.component.jsx";
import JobLifecycleDashboardComponent, {
JobLifecycleDashboardGQL
} from "../dashboard-components/job-lifecycle/job-lifecycle-dashboard.component.jsx";
import DashboardMonthlyEmployeeEfficiency, {
DashboardMonthlyEmployeeEfficiencyGql
} from "../dashboard-components/monthly-employee-efficiency/monthly-employee-efficiency.component.jsx";
import DashboardMonthlyJobCosting from "../dashboard-components/monthly-job-costing/monthly-job-costing.component.jsx";
import DashboardMonthlyLaborSales from "../dashboard-components/monthly-labor-sales/monthly-labor-sales.component.jsx";
import DashboardMonthlyPartsSales from "../dashboard-components/monthly-parts-sales/monthly-parts-sales.component.jsx";
import DashboardMonthlyRevenueGraph, {
DashboardMonthlyRevenueGraphGql
} from "../dashboard-components/monthly-revenue-graph/monthly-revenue-graph.component.jsx";
import DashboardProjectedMonthlySales, {
DashboardProjectedMonthlySalesGql
} from "../dashboard-components/pojected-monthly-sales/projected-monthly-sales.component.jsx";
import DashboardScheduledDeliveryToday, {
DashboardScheduledDeliveryTodayGql
} from "../dashboard-components/scheduled-delivery-today/scheduled-delivery-today.component.jsx";
import DashboardScheduledInToday, {
DashboardScheduledInTodayGql
} from "../dashboard-components/scheduled-in-today/scheduled-in-today.component.jsx";
import DashboardScheduledOutToday, {
DashboardScheduledOutTodayGql
} from "../dashboard-components/scheduled-out-today/scheduled-out-today.component.jsx";
import JobLifecycleDashboardComponent, {
JobLifecycleDashboardGQL
} from "../dashboard-components/job-lifecycle/job-lifecycle-dashboard.component.jsx";
import DashboardTotalProductionDollars from "../dashboard-components/total-production-dollars/total-production-dollars.component.jsx";
import {
DashboardTotalProductionHours,
DashboardTotalProductionHoursGql
} from "../dashboard-components/total-production-hours/total-production-hours.component.jsx";
const componentList = {
ProductionDollars: {
@@ -118,6 +121,15 @@ const componentList = {
w: 10,
h: 3
},
ScheduleDeliveryToday: {
label: i18next.t("dashboard.titles.scheduleddeliverytoday"),
component: DashboardScheduledDeliveryToday,
gqlFragment: DashboardScheduledDeliveryTodayGql,
minW: 6,
minH: 2,
w: 10,
h: 3
},
JobLifecycle: {
label: i18next.t("dashboard.titles.joblifecycle"),
component: JobLifecycleDashboardComponent,

View File

@@ -14,7 +14,6 @@ import {
Typography
} from "antd";
import Dinero from "dinero.js";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -24,16 +23,16 @@ import i18n from "../../translations/i18n";
import dayjs from "../../utils/day";
import DmsCdkMakes from "../dms-cdk-makes/dms-cdk-makes.component";
import DmsCdkMakesRefetch from "../dms-cdk-makes/dms-cdk-makes.refetch.component";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(DmsPostForm);
@@ -125,7 +124,9 @@ export function DmsPostForm({ bodyshop, socket, job, logsRef }) {
})
: ""
}`.slice(0, 239),
inservicedate: dayjs("2019-01-01")
inservicedate: dayjs(
`${(job.v_model_yr && (job.v_model_yr < 100 ? (job.v_model_yr >= (dayjs().year() + 1) % 100 ? 1900 + parseInt(job.v_model_yr) : 2000 + parseInt(job.v_model_yr)) : job.v_model_yr)) || 2019}-01-01`
)
}}
>
<LayoutFormRow grow>

View File

@@ -1,7 +1,6 @@
import { UploadOutlined, UserAddOutlined } from "@ant-design/icons";
import { Button, Divider, Dropdown, Form, Input, Select, Space, Tabs, Upload } from "antd";
import _ from "lodash";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -15,20 +14,24 @@ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
emailConfig: selectEmailConfig
});
const mapDispatchToProps = (dispatch) => ({
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(EmailOverlayComponent);
export function EmailOverlayComponent({ emailConfig, form, selectedMediaState, bodyshop, currentUser }) {
const { t } = useTranslation();
const handleClick = ({ item, key, keyPath }) => {
const handleClick = ({ item }) => {
const email = item.props.value;
form.setFieldsValue({
to: _.uniq([...form.getFieldValue("to"), ...(typeof email === "string" ? [email] : email)])
});
};
const handle_CC_Click = ({ item, key, keyPath }) => {
const handle_CC_Click = ({ item }) => {
const email = item.props.value;
form.setFieldsValue({
cc: _.uniq([...(form.getFieldValue("cc") || ""), ...(typeof email === "string" ? [email] : email)])
@@ -52,6 +55,7 @@ export function EmailOverlayComponent({ emailConfig, form, selectedMediaState, b
],
onClick: handleClick
};
const menuCC = {
items: [
...bodyshop.employees
@@ -136,26 +140,22 @@ export function EmailOverlayComponent({ emailConfig, form, selectedMediaState, b
>
<Input />
</Form.Item>
<Divider>{t("emails.labels.preview")}</Divider>
{bodyshop.attach_pdf_to_email && <strong>{t("emails.labels.pdfcopywillbeattached")}</strong>}
<Form.Item shouldUpdate>
{() => {
return (
<div
style={{
padding: "1rem",
backgroundColor: "lightgray",
borderLeft: "6px solid #2196F3"
backgroundColor: "var(--preview-bg)",
borderLeft: "6px solid var(--preview-border-color)"
}}
dangerouslySetInnerHTML={{ __html: form.getFieldValue("html") }}
/>
);
}}
</Form.Item>
<Tabs
defaultActiveKey="documents"
items={[
@@ -184,12 +184,10 @@ export function EmailOverlayComponent({ emailConfig, form, selectedMediaState, b
return e && e.fileList;
}}
rules={[
({ getFieldValue }) => ({
() => ({
validator(rule, value) {
const totalSize = value.reduce((acc, val) => (acc = acc + val.size), 0);
const limit = 10485760 - new Blob([form.getFieldValue("html")]).size;
if (totalSize > limit) {
return Promise.reject(t("general.errors.sizelimit"));
}

View File

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

View File

@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
const { Option } = Select;
//To be used as a form element only.
const EmployeeSearchSelect = ({ options, ...props }) => {
const EmployeeSearchSelect = ({ options, showEmail, ...props }) => {
const { t } = useTranslation();
return (
@@ -21,12 +21,16 @@ const EmployeeSearchSelect = ({ options, ...props }) => {
{options
? options.map((o) => (
<Option key={o.id} value={o.id} search={`${o.employee_number} ${o.first_name} ${o.last_name}`}>
<Space>
{`${o.employee_number} ${o.first_name} ${o.last_name}`}
<Tag color="green">
<Space size="small">
{`${o.employee_number ?? ""} ${o.first_name} ${o.last_name}`}
<Tag color="green" style={{ padding: "0.1 0.1rem", marginRight: "1px", marginLeft: "1px" }}>
{o.flat_rate ? t("timetickets.labels.flat_rate") : t("timetickets.labels.straight_time")}
</Tag>
{showEmail && o.user_email ? (
<Tag color="blue" style={{ padding: "0.1 0.1rem", marginRight: "1px", marginLeft: "1px" }}>
{o.user_email}
</Tag>
) : null}
</Space>
</Option>
))

View File

@@ -5,7 +5,7 @@
.eula-markdown-card {
max-height: 50vh;
overflow-y: auto;
background-color: lightgray;
background-color: var(--eula-card-bg);
}
.eula-markdown-div {

View File

@@ -81,8 +81,9 @@ export function HasFeatureAccess({ featureName, bodyshop, bypass, debug = false
}
return (
bodyshop?.features?.allAccess ||
bodyshop?.features?.[featureName] ||
dayjs(bodyshop?.features[featureName]).isAfter(dayjs())
(typeof bodyshop?.features?.[featureName] === "boolean"
? bodyshop?.features?.[featureName]
: dayjs(bodyshop?.features?.[featureName]).isAfter(dayjs()))
);
}

View File

@@ -1,6 +1,6 @@
import { DatePicker, Space, TimePicker } from "antd";
import PropTypes from "prop-types";
import React, { useCallback, useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -94,7 +94,24 @@ const DateTimePicker = ({
showTime={false}
format="MM/DD/YYYY"
value={value ? dayjs(value) : null}
onChange={handleChange}
onChange={(dateValue) => {
if (dateValue) {
// When date changes, preserve the existing time if it exists
if (value && dayjs(value).isValid()) {
const existingTime = dayjs(value);
const newDateTime = dayjs(dateValue)
.hour(existingTime.hour())
.minute(existingTime.minute())
.second(existingTime.second());
handleChange(newDateTime);
} else {
// If no existing time, just set the date without time
handleChange(dateValue);
}
} else {
handleChange(dateValue);
}
}}
placeholder={t("general.labels.date")}
onBlur={handleBlur}
disabledDate={handleDisabledDate}
@@ -105,13 +122,25 @@ const DateTimePicker = ({
<TimePicker
format="hh:mm a"
minuteStep={15}
value={value && dayjs(value).hour() === 0 && dayjs(value).minute() === 0 ? null : dayjs(value)}
defaultOpenValue={dayjs(value)
.hour(dayjs().hour())
.minute(Math.floor(dayjs().minute() / 15) * 15)
.second(0)}
onChange={(value) => {
handleChange(value);
onBlur();
onChange={(timeValue) => {
if (timeValue) {
// When time changes, combine it with the existing date
const existingDate = dayjs(value);
const newDateTime = existingDate
.hour(timeValue.hour())
.minute(timeValue.minute())
.second(0);
handleChange(newDateTime);
} else {
// If time is cleared, just update with null time but keep date
handleChange(timeValue);
}
if (onBlur) onBlur();
}}
placeholder={t("general.labels.time")}
{...restProps}

View File

@@ -1,7 +1,7 @@
import React, { forwardRef } from "react";
import { forwardRef } from "react";
import { useTranslation } from "react-i18next";
const LaborTypeFormItem = ({ value, onChange }, ref) => {
const LaborTypeFormItem = ({ value }) => {
const { t } = useTranslation();
if (!value) return null;

View File

@@ -1,11 +1,13 @@
import React, { forwardRef } from "react";
import { forwardRef } from "react";
import { useTranslation } from "react-i18next";
const PartTypeFormItem = ({ value, onChange }, ref) => {
const PartTypeFormItem = ({ value }) => {
const { t } = useTranslation();
if (!value) return null;
return <div>{t(`joblines.fields.part_types.${value}`)}</div>;
return (
<div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{t(`joblines.fields.part_types.${value}`)}</div>
);
};
export default forwardRef(PartTypeFormItem);

View File

@@ -1,6 +1,4 @@
import Dinero from "dinero.js";
import React, { forwardRef } from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
@@ -8,24 +6,25 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
const ReadOnlyFormItem = ({ bodyshop, value, type = "text", onChange }, ref) => {
const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => {
if (!value) return null;
switch (type) {
case "employee":
case "employee": {
const emp = bodyshop.employees.find((e) => e.id === value);
return `${emp?.first_name} ${emp?.last_name}`;
}
case "text":
return <div>{value}</div>;
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
case "currency":
return <div>{Dinero({ amount: Math.round(value * 100) }).toFormat()}</div>;
default:
return <div>{value}</div>;
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
}
};
export default connect(mapStateToProps, mapDispatchToProps)(forwardRef(ReadOnlyFormItem));
export default connect(mapStateToProps, mapDispatchToProps)(ReadOnlyFormItem);

View File

@@ -0,0 +1,190 @@
import { Link } from "react-router-dom";
import { FaCreditCard, FaFileInvoiceDollar } from "react-icons/fa";
import { GiPayMoney, GiPlayerTime } from "react-icons/gi";
import { BankFilled, ExportOutlined, FieldTimeOutlined } from "@ant-design/icons";
import LockWrapper from "../../components/lock-wrapper/lock-wrapper.component.jsx";
import { HasFeatureAccess } from "../../components/feature-wrapper/feature-wrapper.component";
// --- Menu Item Builders ---
const buildAccountingChildren = ({
t,
bodyshop,
currentUser,
setBillEnterContext,
setPaymentContext,
setCardPaymentContext,
setTimeTicketContext,
ImEXPay,
DmsAp,
Simple_Inventory
}) => [
{
key: "bills",
id: "header-accounting-bills",
icon: <FaFileInvoiceDollar />,
label: (
<Link to="/manage/bills">
<LockWrapper featureName="bills" bodyshop={bodyshop}>
{t("menus.header.bills")}
</LockWrapper>
</Link>
)
},
{
key: "enterbills",
id: "header-accounting-enterbills",
icon: <GiPayMoney />,
label: (
<LockWrapper featureName="bills" bodyshop={bodyshop}>
{t("menus.header.enterbills")}
</LockWrapper>
),
onClick: () =>
HasFeatureAccess({ featureName: "bills", bodyshop }) && setBillEnterContext({ actions: {}, context: {} })
},
...(Simple_Inventory.treatment === "on"
? [
{ type: "divider" },
{
key: "inventory",
id: "header-accounting-inventory",
icon: <FaFileInvoiceDollar />,
label: <Link to="/manage/inventory">{t("menus.header.inventory")}</Link>
}
]
: []),
{ type: "divider" },
{
key: "allpayments",
id: "header-accounting-allpayments",
icon: <BankFilled />,
label: <Link to="/manage/payments">{t("menus.header.allpayments")}</Link>
},
{
key: "enterpayments",
id: "header-accounting-enterpayments",
icon: <FaCreditCard />,
label: t("menus.header.enterpayment"),
onClick: () => setPaymentContext({ actions: {}, context: null })
},
...(ImEXPay.treatment === "on"
? [
{
key: "entercardpayments",
id: "header-accounting-entercardpayments",
icon: <FaCreditCard />,
label: t("menus.header.entercardpayment"),
onClick: () => setCardPaymentContext({ actions: {}, context: {} })
}
]
: []),
{ type: "divider" },
{
key: "timetickets",
id: "header-accounting-timetickets",
icon: <FieldTimeOutlined />,
label: (
<Link to="/manage/timetickets">
<LockWrapper featureName="timetickets" bodyshop={bodyshop}>
{t("menus.header.timetickets")}
</LockWrapper>
</Link>
)
},
...(bodyshop?.md_tasks_presets?.use_approvals
? [
{
key: "ttapprovals",
id: "header-accounting-ttapprovals",
icon: <FieldTimeOutlined />,
label: <Link to="/manage/ttapprovals">{t("menus.header.ttapprovals")}</Link>
}
]
: []),
{
key: "entertimetickets",
id: "header-accounting-entertimetickets",
icon: <GiPlayerTime />,
label: (
<LockWrapper featureName="timetickets" bodyshop={bodyshop}>
{t("menus.header.entertimeticket")}
</LockWrapper>
),
onClick: () =>
HasFeatureAccess({ featureName: "timetickets", bodyshop }) &&
setTimeTicketContext({
actions: {},
context: {
created_by: currentUser.displayName ? `${currentUser.email} | ${currentUser.displayName}` : currentUser.email
}
})
},
{ type: "divider" },
{
key: "accountingexport",
id: "header-accounting-export",
icon: <ExportOutlined />,
label: (
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.export")}
</LockWrapper>
),
children: [
{
key: "receivables",
id: "header-accounting-receivables",
label: (
<Link to="/manage/accounting/receivables">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-receivables")}
</LockWrapper>
</Link>
)
},
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) || DmsAp.treatment === "on"
? [
{
key: "payables",
id: "header-accounting-payables",
label: (
<Link to="/manage/accounting/payables">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-payables")}
</LockWrapper>
</Link>
)
}
]
: []),
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber))
? [
{
key: "payments",
id: "header-accounting-payments",
label: (
<Link to="/manage/accounting/payments">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-payments")}
</LockWrapper>
</Link>
)
}
]
: []),
{ type: "divider" },
{
key: "exportlogs",
id: "header-accounting-exportlogs",
label: (
<Link to="/manage/accounting/exportlogs">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.export-logs")}
</LockWrapper>
</Link>
)
}
]
}
];
export default buildAccountingChildren;

View File

@@ -0,0 +1,399 @@
import { Link } from "react-router-dom";
import {
BarChartOutlined,
CarFilled,
CheckCircleOutlined,
ClockCircleFilled,
DashboardFilled,
DollarCircleFilled,
FileAddFilled,
FileAddOutlined,
FileFilled,
HomeFilled,
ImportOutlined,
LineChartOutlined,
OneToOneOutlined,
PaperClipOutlined,
PhoneOutlined,
PlusCircleOutlined,
QuestionCircleFilled,
ScheduleOutlined,
SettingOutlined,
TeamOutlined,
ToolFilled,
UnorderedListOutlined,
UsergroupAddOutlined,
UserOutlined
} from "@ant-design/icons";
import { FaCalendarAlt, FaCarCrash, FaMoon, FaSun, FaTasks } from "react-icons/fa";
import { BsKanban } from "react-icons/bs";
import { FiLogOut } from "react-icons/fi";
import { GiPlayerTime, GiSettingsKnobs } from "react-icons/gi";
import { RiSurveyLine } from "react-icons/ri";
import { IoBusinessOutline } from "react-icons/io5";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import LockWrapper from "../../components/lock-wrapper/lock-wrapper.component.jsx";
const buildLeftMenuItems = ({
t,
bodyshop,
recentItems,
setTaskUpsertContext,
setReportCenterContext,
signOutStart,
accountingChildren,
handleDarkModeToggle,
darkMode
}) => {
return [
{
key: "home",
id: "header-home",
icon: <HomeFilled />,
label: <Link to="/manage/">{t("menus.header.home")}</Link>
},
{
key: "schedule",
id: "header-schedule",
icon: <FaCalendarAlt />,
label: <Link to="/manage/schedule">{t("menus.header.schedule")}</Link>
},
{
key: "jobssubmenu",
id: "header-jobs",
icon: <FaCarCrash />,
label: t("menus.header.jobs"),
children: [
{
key: "activejobs",
id: "header-active-jobs",
icon: <FileFilled />,
label: <Link to="/manage/jobs">{t("menus.header.activejobs")}</Link>
},
{
key: "readyjobs",
id: "header-ready-jobs",
icon: <CheckCircleOutlined />,
label: <Link to="/manage/jobs/ready">{t("menus.header.readyjobs")}</Link>
},
{
key: "parts-queue",
id: "header-parts-queue",
icon: <ToolFilled />,
label: <Link to="/manage/partsqueue">{t("menus.header.parts-queue")}</Link>
},
{
key: "availablejobs",
id: "header-jobs-available",
icon: <ImportOutlined />,
label: <Link to="/manage/available">{t("menus.header.availablejobs")}</Link>
},
{
key: "newjob",
id: "header-new-job",
icon: <FileAddOutlined />,
label: <Link to="/manage/jobs/new">{t("menus.header.newjob")}</Link>
},
{ type: "divider" },
{
key: "alljobs",
id: "header-all-jobs",
icon: <UnorderedListOutlined />,
label: <Link to="/manage/jobs/all">{t("menus.header.alljobs")}</Link>
},
{ type: "divider" },
{
key: "productionlist",
id: "header-production-list",
icon: <ScheduleOutlined />,
label: <Link to="/manage/production/list">{t("menus.header.productionlist")}</Link>
},
{
key: "productionboard",
id: "header-production-board",
icon: <BsKanban />,
label: (
<Link to="/manage/production/board">
<LockWrapper featureName="visualboard" bodyshop={bodyshop}>
{t("menus.header.productionboard")}
</LockWrapper>
</Link>
)
},
{ type: "divider" },
{
key: "scoreboard",
id: "header-scoreboard",
icon: <LineChartOutlined />,
label: (
<Link to="/manage/scoreboard">
<LockWrapper featureName="scoreboard" bodyshop={bodyshop}>
{t("menus.header.scoreboard")}
</LockWrapper>
</Link>
)
}
]
},
{
key: "customers",
id: "header-customers",
icon: <UserOutlined />,
label: t("menus.header.customers"),
children: [
{
key: "owners",
id: "header-owners",
icon: <TeamOutlined />,
label: <Link to="/manage/owners">{t("menus.header.owners")}</Link>
},
{
key: "vehicles",
id: "header-vehicles",
icon: <CarFilled />,
label: <Link to="/manage/vehicles">{t("menus.header.vehicles")}</Link>
}
]
},
{
key: "ccs",
id: "header-css",
icon: <CarFilled />,
label: (
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars")}
</LockWrapper>
),
children: [
{
key: "courtesycarsall",
id: "header-courtesycars-all",
icon: <CarFilled />,
label: (
<Link to="/manage/courtesycars">
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars-all")}
</LockWrapper>
</Link>
)
},
{
key: "contracts",
id: "header-contracts",
icon: <FileFilled />,
label: (
<Link to="/manage/courtesycars/contracts">
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars-contracts")}
</LockWrapper>
</Link>
)
},
{
key: "newcontract",
id: "header-newcontract",
icon: <FileAddFilled />,
label: (
<Link to="/manage/courtesycars/contracts/new">
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars-newcontract")}
</LockWrapper>
</Link>
)
}
]
},
...(accountingChildren.length > 0
? [
{
key: "accounting",
id: "header-accounting",
icon: <DollarCircleFilled />,
label: t("menus.header.accounting"),
children: accountingChildren
}
]
: []),
{
key: "phonebook",
id: "header-phonebook",
icon: <PhoneOutlined />,
label: <Link to="/manage/phonebook">{t("menus.header.phonebook")}</Link>
},
{
key: "temporarydocs",
id: "header-temporarydocs",
icon: <PaperClipOutlined />,
label: (
<Link to="/manage/temporarydocs">
<LockWrapper featureName="media" bodyshop={bodyshop}>
{t("menus.header.temporarydocs")}
</LockWrapper>
</Link>
)
},
{
key: "tasks",
id: "tasks",
icon: <FaTasks />,
label: t("menus.header.tasks"),
children: [
{
key: "createTask",
id: "header-create-task",
icon: <PlusCircleOutlined />,
label: t("menus.header.create_task"),
onClick: () => setTaskUpsertContext({ actions: {}, context: {} })
},
{
key: "mytasks",
id: "header-my-tasks",
icon: <FaTasks />,
label: <Link to="/manage/tasks/mytasks">{t("menus.header.my_tasks")}</Link>
},
{
key: "all_tasks",
id: "header-all-tasks",
icon: <FaTasks />,
label: <Link to="/manage/tasks/alltasks">{t("menus.header.all_tasks")}</Link>
}
]
},
{
key: "shopsubmenu",
id: "header-shopsubmenu",
icon: <SettingOutlined />,
label: t("menus.header.shop"),
children: [
{
key: "shop",
id: "header-shop",
icon: <GiSettingsKnobs />,
label: <Link to="/manage/shop?tab=info">{t("menus.header.shop_config")}</Link>
},
{
key: "dashboard",
id: "header-dashboard",
icon: <DashboardFilled />,
label: (
<Link to="/manage/dashboard">
<LockWrapper featureName="bills">{t("menus.header.dashboard")}</LockWrapper>
</Link>
)
},
{
key: "reportcenter",
id: "header-reportcenter",
icon: <BarChartOutlined />,
label: t("menus.header.reportcenter"),
onClick: () => setReportCenterContext({ actions: {}, context: {} })
},
{
key: "shop-vendors",
id: "header-shop-vendors",
icon: <IoBusinessOutline />,
label: <Link to="/manage/shop/vendors">{t("menus.header.shop_vendors")}</Link>
},
{
key: "shop-csi",
id: "header-shop-csi",
icon: <RiSurveyLine />,
label: (
<Link to="/manage/shop/csi">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.shop_csi")}
</LockWrapper>
</Link>
)
}
]
},
{
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",
id: "header-user",
icon: <UserOutlined />,
label: t("menus.currentuser.profile"),
children: [
{
key: "signout",
id: "header-signout",
icon: <FiLogOut />,
danger: true,
label: t("user.actions.signout"),
onClick: () => signOutStart()
},
{
key: "darkmode-toggle",
id: "header-darkmode-toggle",
label: darkMode ? t("user.actions.light_theme") : t("user.actions.dark_theme"),
icon: darkMode ? <FaSun /> : <FaMoon />,
onClick: handleDarkModeToggle
},
{
key: "help",
id: "header-help",
icon: <QuestionCircleFilled />,
label: t("menus.header.help"),
onClick: () => window.open("https://help.imex.online/", "_blank")
},
{
key: "remoteassist",
id: "header-remote-assist",
icon: <OneToOneOutlined />,
label: t("menus.header.remoteassist"),
children: [
...(InstanceRenderManager({ imex: true, rome: false })
? [
{
key: "rescue",
id: "header-rescue",
icon: <PlusCircleOutlined />,
label: t("menus.header.rescueme"),
onClick: () => window.open("https://imexrescue.com/", "_blank")
}
]
: []),
{
key: "rescue-zoho",
id: "header-rescue-zoho",
icon: <UsergroupAddOutlined />,
label: t("menus.header.rescuemezoho"),
onClick: () => window.open("https://join.zoho.com/", "_blank")
}
]
},
{
key: "shiftclock",
id: "header-shiftclock",
icon: <GiPlayerTime />,
label: (
<Link to="/manage/shiftclock">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.shiftclock")}
</LockWrapper>
</Link>
)
},
{
key: "profile",
id: "header-profile",
icon: <UserOutlined />,
label: <Link to="/manage/profile">{t("menus.currentuser.profile")}</Link>
}
]
}
];
};
export default buildLeftMenuItems;

View File

@@ -1,63 +1,36 @@
import {
BankFilled,
BarChartOutlined,
BellFilled,
CarFilled,
CheckCircleOutlined,
ClockCircleFilled,
DashboardFilled,
DollarCircleFilled,
ExportOutlined,
FieldTimeOutlined,
FileAddFilled,
FileAddOutlined,
FileFilled,
HomeFilled,
ImportOutlined,
LineChartOutlined,
PaperClipOutlined,
PhoneOutlined,
PlusCircleOutlined,
QuestionCircleFilled,
ScheduleOutlined,
SettingOutlined,
TeamOutlined,
ToolFilled,
UnorderedListOutlined,
UserOutlined
} from "@ant-design/icons";
// noinspection RegExpAnonymousGroup
import { BellFilled } from "@ant-design/icons";
import { useQuery } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Badge, Layout, Menu, Spin } from "antd";
import { useEffect, useRef, useState } from "react";
import { Badge, Layout, Menu, Spin, Tooltip } from "antd";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BsKanban } from "react-icons/bs";
import { FaCalendarAlt, FaCarCrash, FaCreditCard, FaFileInvoiceDollar, FaTasks } from "react-icons/fa";
import { FiLogOut } from "react-icons/fi";
import { GiPayMoney, GiPlayerTime, GiSettingsKnobs } from "react-icons/gi";
import { IoBusinessOutline } from "react-icons/io5";
import { RiSurveyLine } from "react-icons/ri";
import { FaTasks } from "react-icons/fa";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { TASKS_CENTER_POLL_INTERVAL, useSocket } from "../../contexts/SocketIO/useSocket.js";
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
import { selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors";
import { QUERY_MY_TASKS_COUNT } from "../../graphql/tasks.queries.js";
import { selectDarkMode, selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors";
import { setModalContext } from "../../redux/modals/modals.actions";
import { signOutStart } from "../../redux/user/user.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import day from "../../utils/day.js";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import TaskCenterContainer from "../task-center/task-center.container.jsx";
import buildAccountingChildren from "./buildAccountingChildren.jsx";
import buildLeftMenuItems from "./buildLeftMenuItems.jsx";
import { toggleDarkMode } from "../../redux/application/application.actions";
// Redux mappings
// --- Redux mappings ---
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
recentItems: selectRecentItems,
selectedHeader: selectSelectedHeader,
bodyshop: selectBodyshop
bodyshop: selectBodyshop,
darkMode: selectDarkMode
});
const mapDispatchToProps = (dispatch) => ({
@@ -67,38 +40,12 @@ const mapDispatchToProps = (dispatch) => ({
setReportCenterContext: (context) => dispatch(setModalContext({ context, modal: "reportCenter" })),
signOutStart: () => dispatch(signOutStart()),
setCardPaymentContext: (context) => dispatch(setModalContext({ context, modal: "cardPayment" })),
setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" }))
setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" })),
toggleDarkMode: () => dispatch(toggleDarkMode())
});
function Header({
handleMenuClick,
currentUser,
bodyshop,
selectedHeader,
signOutStart,
setBillEnterContext,
setTimeTicketContext,
setPaymentContext,
setReportCenterContext,
recentItems,
setCardPaymentContext,
setTaskUpsertContext
}) {
const {
treatments: { ImEXPay, DmsAp, Simple_Inventory }
} = useSplitTreatments({
attributes: {},
names: ["ImEXPay", "DmsAp", "Simple_Inventory"],
splitKey: bodyshop && bodyshop.imexshopid
});
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;
// --- Utility Hooks ---
function useUnreadNotifications(userAssociationId, isConnected, scenarioNotificationsOn) {
const {
data: unreadData,
refetch: refetchUnread,
@@ -124,584 +71,265 @@ function Header({
}
}, [isConnected, unreadLoading, refetchUnread, userAssociationId]);
// Keep The unread count in the title.
return { unreadCount, unreadLoading };
}
function useIncompleteTaskCount(assignedToId, bodyshopId, isEmployee, isConnected) {
const { data: taskCountData, loading: taskCountLoading } = useQuery(QUERY_MY_TASKS_COUNT, {
variables: { assigned_to: assignedToId, bodyshopid: bodyshopId },
skip: !assignedToId || !bodyshopId || !isEmployee,
fetchPolicy: "network-only",
pollInterval: isConnected ? 0 : TASKS_CENTER_POLL_INTERVAL
});
const incompleteTaskCount = taskCountData?.tasks_aggregate?.aggregate?.count ?? 0;
return { incompleteTaskCount, taskCountLoading };
}
// --- Main Component ---
function Header({
handleMenuClick,
currentUser,
bodyshop,
selectedHeader,
signOutStart,
setBillEnterContext,
setTimeTicketContext,
setPaymentContext,
setReportCenterContext,
recentItems,
setCardPaymentContext,
setTaskUpsertContext,
toggleDarkMode,
darkMode
}) {
// Feature flags
const {
treatments: { ImEXPay, DmsAp, Simple_Inventory }
} = useSplitTreatments({
attributes: {},
names: ["ImEXPay", "DmsAp", "Simple_Inventory"],
splitKey: bodyshop && bodyshop.imexshopid
});
// Contexts and hooks
const { t } = useTranslation();
const { isConnected, scenarioNotificationsOn } = useSocket();
const [notificationVisible, setNotificationVisible] = useState(false);
const [taskCenterVisible, setTaskCenterVisible] = useState(false);
const baseTitleRef = useRef(document.title || "");
const lastSetTitleRef = useRef("");
const taskCenterRef = useRef(null);
const notificationRef = useRef(null);
const userAssociationId = bodyshop?.associations?.[0]?.id;
const isEmployee = useIsEmployee(bodyshop, currentUser);
// Data hooks
const { unreadCount, unreadLoading } = useUnreadNotifications(
userAssociationId,
isConnected,
scenarioNotificationsOn
);
const assignedToId = bodyshop?.employees?.find((e) => e.user_email === currentUser.email)?.id;
const { incompleteTaskCount, taskCountLoading } = useIncompleteTaskCount(
assignedToId,
bodyshop?.id,
isEmployee,
isConnected
);
// --- Effects ---
// Update document title with unread count
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
lastSetTitleRef.current = newTitle;
}
};
// 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
document.title = baseTitleRef.current;
};
}, [unreadCount]); // Re-run when unreadCount changes
}, [unreadCount]);
const handleNotificationClick = (e) => {
setNotificationVisible(!notificationVisible);
// Handle outside clicks for popovers
useEffect(() => {
const handleClickOutside = (event) => {
const isNotificationClick = event.target.closest("#header-notifications");
const isTaskCenterClick = event.target.closest("#header-taskcenter");
if (isNotificationClick && scenarioNotificationsOn) {
setTaskCenterVisible(false); // Close task center
return;
}
if (isTaskCenterClick) {
setNotificationVisible(scenarioNotificationsOn ? false : notificationVisible); // Close notification center if enabled
return;
}
if (taskCenterVisible && taskCenterRef.current && !taskCenterRef.current.contains(event.target)) {
setTaskCenterVisible(false);
}
if (
scenarioNotificationsOn &&
notificationVisible &&
notificationRef.current &&
!notificationRef.current.contains(event.target)
) {
setNotificationVisible(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [taskCenterVisible, notificationVisible, scenarioNotificationsOn]);
// --- Event Handlers ---
const handleTaskCenterClick = useCallback(
(e) => {
setTaskCenterVisible((prev) => {
if (prev) return false;
return true;
});
if (handleMenuClick) handleMenuClick(e);
};
},
[handleMenuClick]
);
const accountingChildren = [
{
key: "bills",
id: "header-accounting-bills",
icon: <FaFileInvoiceDollar />,
label: (
<Link to="/manage/bills">
<LockWrapper featureName="bills" bodyshop={bodyshop}>
{t("menus.header.bills")}
</LockWrapper>
</Link>
)
const handleNotificationClick = useCallback(
(e) => {
setNotificationVisible((prev) => {
if (prev) return false;
return true;
});
if (handleMenuClick) handleMenuClick(e);
},
{
key: "enterbills",
id: "header-accounting-enterbills",
icon: <GiPayMoney />,
label: (
<LockWrapper featureName="bills" bodyshop={bodyshop}>
{t("menus.header.enterbills")}
</LockWrapper>
),
onClick: () =>
HasFeatureAccess({ featureName: "bills", bodyshop }) &&
setBillEnterContext({
actions: {},
context: {}
})
},
...(Simple_Inventory.treatment === "on"
? [
{ type: "divider" },
{
key: "inventory",
id: "header-accounting-inventory",
icon: <FaFileInvoiceDollar />,
label: <Link to="/manage/inventory">{t("menus.header.inventory")}</Link>
}
]
: []),
{ type: "divider" },
{
key: "allpayments",
id: "header-accounting-allpayments",
icon: <BankFilled />,
label: <Link to="/manage/payments">{t("menus.header.allpayments")}</Link>
},
{
key: "enterpayments",
id: "header-accounting-enterpayments",
icon: <FaCreditCard />,
label: t("menus.header.enterpayment"),
onClick: () =>
setPaymentContext({
actions: {},
context: null
})
},
...(ImEXPay.treatment === "on"
? [
{
key: "entercardpayments",
id: "header-accounting-entercardpayments",
icon: <FaCreditCard />,
label: t("menus.header.entercardpayment"),
onClick: () => setCardPaymentContext({ actions: {}, context: {} })
}
]
: []),
{ type: "divider" },
{
key: "timetickets",
id: "header-accounting-timetickets",
icon: <FieldTimeOutlined />,
label: (
<Link to="/manage/timetickets">
<LockWrapper featureName="timetickets" bodyshop={bodyshop}>
{t("menus.header.timetickets")}
</LockWrapper>
</Link>
)
},
...(bodyshop?.md_tasks_presets?.use_approvals
? [
{
key: "ttapprovals",
id: "header-accounting-ttapprovals",
icon: <FieldTimeOutlined />,
label: <Link to="/manage/ttapprovals">{t("menus.header.ttapprovals")}</Link>
}
]
: []),
{
key: "entertimetickets",
id: "header-accounting-entertimetickets",
icon: <GiPlayerTime />,
label: (
<LockWrapper featureName="timetickets" bodyshop={bodyshop}>
{t("menus.header.entertimeticket")}
</LockWrapper>
),
onClick: () =>
HasFeatureAccess({ featureName: "timetickets", bodyshop }) &&
setTimeTicketContext({
actions: {},
context: {
created_by: currentUser.displayName
? `${currentUser.email} | ${currentUser.displayName}`
: currentUser.email
}
})
},
{ type: "divider" },
{
key: "accountingexport",
id: "header-accounting-export",
icon: <ExportOutlined />,
label: (
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.export")}
</LockWrapper>
),
children: [
{
key: "receivables",
id: "header-accounting-receivables",
label: (
<Link to="/manage/accounting/receivables">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-receivables")}
</LockWrapper>
</Link>
)
},
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) ||
DmsAp.treatment === "on"
? [
{
key: "payables",
id: "header-accounting-payables",
label: (
<Link to="/manage/accounting/payables">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-payables")}
</LockWrapper>
</Link>
)
}
]
: []),
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber))
? [
{
key: "payments",
id: "header-accounting-payments",
label: (
<Link to="/manage/accounting/payments">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.accounting-payments")}
</LockWrapper>
</Link>
)
}
]
: []),
{ type: "divider" },
{
key: "exportlogs",
id: "header-accounting-exportlogs",
label: (
<Link to="/manage/accounting/exportlogs">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.export-logs")}
</LockWrapper>
</Link>
)
}
]
}
];
[handleMenuClick]
);
// Left menu items (includes original navigation items)
const leftMenuItems = [
{
key: "home",
id: "header-home",
icon: <HomeFilled />,
label: <Link to="/manage/">{t("menus.header.home")}</Link>
},
{
key: "schedule",
id: "header-schedule",
icon: <FaCalendarAlt />,
label: <Link to="/manage/schedule">{t("menus.header.schedule")}</Link>
},
{
key: "jobssubmenu",
id: "header-jobs",
icon: <FaCarCrash />,
label: t("menus.header.jobs"),
children: [
{
key: "activejobs",
id: "header-active-jobs",
icon: <FileFilled />,
label: <Link to="/manage/jobs">{t("menus.header.activejobs")}</Link>
},
{
key: "readyjobs",
id: "header-ready-jobs",
icon: <CheckCircleOutlined />,
label: <Link to="/manage/jobs/ready">{t("menus.header.readyjobs")}</Link>
},
{
key: "parts-queue",
id: "header-parts-queue",
icon: <ToolFilled />,
label: <Link to="/manage/partsqueue">{t("menus.header.parts-queue")}</Link>
},
{
key: "availablejobs",
id: "header-jobs-available",
icon: <ImportOutlined />,
label: <Link to="/manage/available">{t("menus.header.availablejobs")}</Link>
},
{
key: "newjob",
id: "header-new-job",
icon: <FileAddOutlined />,
label: <Link to="/manage/jobs/new">{t("menus.header.newjob")}</Link>
},
{ type: "divider" },
{
key: "alljobs",
id: "header-all-jobs",
icon: <UnorderedListOutlined />,
label: <Link to="/manage/jobs/all">{t("menus.header.alljobs")}</Link>
},
{ type: "divider" },
{
key: "productionlist",
id: "header-production-list",
icon: <ScheduleOutlined />,
label: <Link to="/manage/production/list">{t("menus.header.productionlist")}</Link>
},
{
key: "productionboard",
id: "header-production-board",
icon: <BsKanban />,
label: (
<Link to="/manage/production/board">
<LockWrapper featureName="visualboard" bodyshop={bodyshop}>
{t("menus.header.productionboard")}
</LockWrapper>
</Link>
)
},
{ type: "divider" },
{
key: "scoreboard",
id: "header-scoreboard",
icon: <LineChartOutlined />,
label: (
<Link to="/manage/scoreboard">
<LockWrapper featureName="scoreboard" bodyshop={bodyshop}>
{t("menus.header.scoreboard")}
</LockWrapper>
</Link>
)
}
]
},
{
key: "customers",
id: "header-customers",
icon: <UserOutlined />,
label: t("menus.header.customers"),
children: [
{
key: "owners",
id: "header-owners",
icon: <TeamOutlined />,
label: <Link to="/manage/owners">{t("menus.header.owners")}</Link>
},
{
key: "vehicles",
id: "header-vehicles",
icon: <CarFilled />,
label: <Link to="/manage/vehicles">{t("menus.header.vehicles")}</Link>
}
]
},
{
key: "ccs",
id: "header-css",
icon: <CarFilled />,
label: (
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars")}
</LockWrapper>
),
children: [
{
key: "courtesycarsall",
id: "header-courtesycars-all",
icon: <CarFilled />,
label: (
<Link to="/manage/courtesycars">
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars-all")}
</LockWrapper>
</Link>
)
},
{
key: "contracts",
id: "header-contracts",
icon: <FileFilled />,
label: (
<Link to="/manage/courtesycars/contracts">
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars-contracts")}
</LockWrapper>
</Link>
)
},
{
key: "newcontract",
id: "header-newcontract",
icon: <FileAddFilled />,
label: (
<Link to="/manage/courtesycars/contracts/new">
<LockWrapper featureName="courtesycars" bodyshop={bodyshop}>
{t("menus.header.courtesycars-newcontract")}
</LockWrapper>
</Link>
)
}
]
},
...(accountingChildren.length > 0
? [
{
key: "accounting",
id: "header-accounting",
icon: <DollarCircleFilled />,
label: t("menus.header.accounting"),
children: accountingChildren
}
]
: []),
{
key: "phonebook",
id: "header-phonebook",
icon: <PhoneOutlined />,
label: <Link to="/manage/phonebook">{t("menus.header.phonebook")}</Link>
},
{
key: "temporarydocs",
id: "header-temporarydocs",
icon: <PaperClipOutlined />,
label: (
<Link to="/manage/temporarydocs">
<LockWrapper featureName="media" bodyshop={bodyshop}>
{t("menus.header.temporarydocs")}
</LockWrapper>
</Link>
)
},
{
key: "tasks",
id: "tasks",
icon: <FaTasks />,
label: t("menus.header.tasks"),
children: [
{
key: "createTask",
id: "header-create-task",
icon: <PlusCircleOutlined />,
label: t("menus.header.create_task"),
onClick: () => setTaskUpsertContext({ actions: {}, context: {} })
},
{
key: "mytasks",
id: "header-my-tasks",
icon: <FaTasks />,
label: <Link to="/manage/tasks/mytasks">{t("menus.header.my_tasks")}</Link>
},
{
key: "all_tasks",
id: "header-all-tasks",
icon: <FaTasks />,
label: <Link to="/manage/tasks/alltasks">{t("menus.header.all_tasks")}</Link>
}
]
},
{
key: "shopsubmenu",
id: "header-shopsubmenu",
icon: <SettingOutlined />,
label: t("menus.header.shop"),
children: [
{
key: "shop",
id: "header-shop",
icon: <GiSettingsKnobs />,
label: <Link to="/manage/shop?tab=info">{t("menus.header.shop_config")}</Link>
},
{
key: "dashboard",
id: "header-dashboard",
icon: <DashboardFilled />,
label: (
<Link to="/manage/dashboard">
<LockWrapper featureName="bills">{t("menus.header.dashboard")}</LockWrapper>
</Link>
)
},
{
key: "reportcenter",
id: "header-reportcenter",
icon: <BarChartOutlined />,
label: t("menus.header.reportcenter"),
onClick: () => setReportCenterContext({ actions: {}, context: {} })
},
{
key: "shop-vendors",
id: "header-shop-vendors",
icon: <IoBusinessOutline />,
label: <Link to="/manage/shop/vendors">{t("menus.header.shop_vendors")}</Link>
},
{
key: "shop-csi",
id: "header-shop-csi",
icon: <RiSurveyLine />,
label: (
<Link to="/manage/shop/csi">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.shop_csi")}
</LockWrapper>
</Link>
)
}
]
},
{
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",
id: "header-user",
icon: <UserOutlined />,
label: t("menus.currentuser.profile"),
children: [
{
key: "signout",
id: "header-signout",
icon: <FiLogOut />,
danger: true,
label: t("user.actions.signout"),
onClick: () => signOutStart()
},
{
key: "help",
id: "header-help",
icon: <QuestionCircleFilled />,
label: t("menus.header.help"),
onClick: () => window.open("https://help.imex.online/", "_blank")
},
...(InstanceRenderManager({ imex: true, rome: false })
? [
{
key: "rescue",
id: "header-rescue",
icon: <CarFilled />,
label: t("menus.header.rescueme"),
onClick: () => window.open("https://imexrescue.com/", "_blank")
}
]
: []),
{
key: "shiftclock",
id: "header-shiftclock",
icon: <GiPlayerTime />,
label: (
<Link to="/manage/shiftclock">
<LockWrapper featureName="export" bodyshop={bodyshop}>
{t("menus.header.shiftclock")}
</LockWrapper>
</Link>
)
},
{
key: "profile",
id: "header-profile",
icon: <UserOutlined />,
label: <Link to="/manage/profile">{t("menus.currentuser.profile")}</Link>
}
]
}
];
const handleDarkModeToggle = useCallback(() => {
toggleDarkMode();
}, [toggleDarkMode]);
// Notifications item (always on the right)
const notificationItem = scenarioNotificationsOn
? [
{
// --- Menu Items ---
// built externally to keep the component clean, but on this level to prevent unnecessary re-renders
const accountingChildren = useMemo(
() =>
buildAccountingChildren({
t,
bodyshop,
currentUser,
setBillEnterContext,
setPaymentContext,
setCardPaymentContext,
setTimeTicketContext,
ImEXPay,
DmsAp,
Simple_Inventory
}),
[
t,
bodyshop,
currentUser,
setBillEnterContext,
setPaymentContext,
setCardPaymentContext,
setTimeTicketContext,
ImEXPay,
DmsAp,
Simple_Inventory
]
);
// Built externally to keep the component clean
const leftMenuItems = useMemo(
() =>
buildLeftMenuItems({
t,
bodyshop,
recentItems,
setTaskUpsertContext,
setReportCenterContext,
signOutStart,
accountingChildren,
darkMode,
handleDarkModeToggle
}),
[
t,
bodyshop,
recentItems,
setTaskUpsertContext,
setReportCenterContext,
signOutStart,
accountingChildren,
darkMode,
handleDarkModeToggle
]
);
const rightMenuItems = useMemo(() => {
const items = [];
if (scenarioNotificationsOn) {
items.push({
key: "notifications",
id: "header-notifications",
icon: unreadLoading ? (
<Spin size="small" />
) : (
<Badge offset={[8, 0]} size="small" count={unreadCount}>
<Badge offset={[8, 0]} size="small" count={isEmployee ? unreadCount : 0}>
<BellFilled />
</Badge>
),
onClick: handleNotificationClick
});
}
]
: [];
items.push({
key: "taskcenter",
id: "header-taskcenter",
icon: taskCountLoading ? (
<Spin size="small" />
) : (
<Badge offset={[8, 0]} size="small" count={incompleteTaskCount > 0 ? incompleteTaskCount : 0} showZero={false}>
<Tooltip title={t("menus.header.tasks")}>
<FaTasks />
</Tooltip>
</Badge>
),
onClick: handleTaskCenterClick
});
return items;
}, [
scenarioNotificationsOn,
unreadLoading,
unreadCount,
taskCountLoading,
incompleteTaskCount,
isEmployee,
handleNotificationClick,
handleTaskCenterClick,
t
]);
// --- Render ---
return (
<Layout.Header style={{ padding: 0, background: "#001529" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
height: "100%",
overflow: "hidden"
}}
>
<div style={{ display: "flex", alignItems: "center", height: "100%", overflow: "hidden" }}>
<div style={{ flexGrow: 1, overflowX: "auto", whiteSpace: "nowrap" }}>
<Menu
mode="horizontal"
theme="dark"
@@ -709,33 +337,37 @@ function Header({
onClick={handleMenuClick}
subMenuCloseDelay={0.3}
items={leftMenuItems}
style={{
flex: "1 1 auto",
minWidth: 0,
overflowX: "auto",
borderBottom: "none",
background: "transparent"
}}
style={{ borderBottom: "none", background: "transparent", minWidth: "100%" }}
/>
{scenarioNotificationsOn && (
</div>
<div style={{ width: 120, flexShrink: 0 }}>
<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" }}
items={rightMenuItems}
style={{ borderBottom: "none", background: "transparent", justifyContent: "flex-end" }}
/>
)}
</div>
</div>
{scenarioNotificationsOn && (
<div ref={notificationRef}>
<NotificationCenterContainer
visible={notificationVisible}
onClose={() => setNotificationVisible(false)}
unreadCount={unreadCount}
/>
</div>
)}
<div ref={taskCenterRef}>
<TaskCenterContainer
incompleteTaskCount={incompleteTaskCount}
visible={taskCenterVisible}
onClose={() => setTaskCenterVisible(false)}
/>
</div>
</Layout.Header>
);
}

View File

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

View File

@@ -1,5 +1,5 @@
import { AlertFilled } from "@ant-design/icons";
import { useMutation } from "@apollo/client";
import { useLazyQuery, useMutation } from "@apollo/client";
import { Button, Divider, Dropdown, Form, Input, Popover, Select, Space } from "antd";
import parsePhoneNumber from "libphonenumber-js";
import queryString from "query-string";
@@ -8,32 +8,40 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries";
import { GET_JOB_BY_PK_QUICK_INTAKE, JOB_PRODUCTION_TOGGLE } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateTimeFormatterFunction } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
import { GenerateDocument } from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants";
import ChatOpenButton from "../chat-open-button/chat-open-button.component";
import DataLabel from "../data-label/data-label.component";
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
import ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.component";
import ScheduleManualEvent from "../schedule-manual-event/schedule-manual-event.component";
import { HasFeatureAccess } from "./../feature-wrapper/feature-wrapper.component";
import ScheduleAtChange from "./job-at-change.component";
import ScheduleEventColor from "./schedule-event.color.component";
import ScheduleEventNote from "./schedule-event.note.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
setScheduleContext: (context) => dispatch(setModalContext({ context: context, modal: "schedule" })),
openChatByPhone: (phone) => dispatch(openChatByPhone(phone)),
setMessage: (text) => dispatch(setMessage(text))
setMessage: (text) => dispatch(setMessage(text)),
insertAuditTrail: ({ jobid, operation }) => dispatch(insertAuditTrail({ jobid, operation }))
});
export function ScheduleEventComponent({
@@ -43,16 +51,40 @@ export function ScheduleEventComponent({
event,
refetch,
handleCancel,
setScheduleContext
setScheduleContext,
insertAuditTrail
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const history = useNavigate();
const searchParams = queryString.parse(useLocation().search);
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT);
const [mutationUpdateJob] = useMutation(JOB_PRODUCTION_TOGGLE);
const [title, setTitle] = useState(event.title);
const { socket } = useSocket();
const notification = useNotification();
const [form] = Form.useForm();
const [popOverVisible, setPopOverVisible] = useState(false);
const [getJobDetails] = useLazyQuery(GET_JOB_BY_PK_QUICK_INTAKE, {
variables: { id: event.job?.id },
onCompleted: (data) => {
if (data?.jobs_by_pk) {
const totalHours =
(data.jobs_by_pk.labhrs?.aggregate?.sum?.mod_lb_hrs || 0) +
(data.jobs_by_pk.larhrs?.aggregate?.sum?.mod_lb_hrs || 0);
form.setFieldsValue({
actual_in: data.jobs_by_pk.actual_in ? data.jobs_by_pk.actual_in : dayjs(),
scheduled_completion: data.jobs_by_pk.scheduled_completion
? data.jobs_by_pk.scheduled_completion
: totalHours && bodyshop.ss_configuration.nobusinessdays
? dayjs().businessDaysAdd(totalHours / (bodyshop.target_touchtime || 1), "day")
: dayjs().add(totalHours / (bodyshop.target_touchtime || 1), "day"),
scheduled_delivery: data.jobs_by_pk.scheduled_delivery
});
}
},
fetchPolicy: "network-only"
});
const blockContent = (
<Space direction="vertical" wrap>
@@ -82,13 +114,78 @@ export function ScheduleEventComponent({
});
}}
/>
<Button onClick={() => handleCancel({ id: event.id })} disabled={event.arrived}>
{t("appointments.actions.unblock")}
</Button>
</Space>
);
const handleConvert = async (values) => {
const res = await mutationUpdateJob({
variables: {
jobId: event.job.id,
job: {
...values,
status: bodyshop.md_ro_statuses.default_arrived,
inproduction: true
}
}
});
if (!res.errors) {
notification["success"]({
message: t("jobs.successes.converted")
});
insertAuditTrail({
jobid: event.job.id,
operation: AuditTrailMapping.jobintake(
res.data.update_jobs.returning[0].status,
DateTimeFormatterFunction(values.scheduled_completion)
)
});
setPopOverVisible(false);
refetch();
}
};
const popMenu = (
<div onClick={(e) => e.stopPropagation()}>
<Form layout="vertical" form={form} onFinish={handleConvert}>
<Form.Item
name={["actual_in"]}
label={t("jobs.fields.actual_in")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<FormDateTimePickerComponent disabled={event.ro_number} />
</Form.Item>
<Form.Item
name={["scheduled_completion"]}
label={t("jobs.fields.scheduled_completion")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<FormDateTimePickerComponent disabled={event.ro_number} />
</Form.Item>
<Form.Item name={["scheduled_delivery"]} label={t("jobs.fields.scheduled_delivery")}>
<FormDateTimePickerComponent disabled={event.ro_number} />
</Form.Item>
<Space wrap>
<Button type="primary" onClick={() => form.submit()}>
{t("general.actions.save")}
</Button>
</Space>
</Form>
</div>
);
const popoverContent = (
<div style={{ maxWidth: "40vw" }}>
{!event.isintake ? (
@@ -109,7 +206,6 @@ export function ScheduleEventComponent({
<ScheduleEventColor event={event} />
</Space>
)}
{event.job ? (
<div>
<DataLabel label={t("jobs.fields.ro_number")}>{(event.job && event.job.ro_number) || ""}</DataLabel>
@@ -270,7 +366,6 @@ export function ScheduleEventComponent({
</Button>
</Popover>
)}
{event.isintake ? (
<Button
disabled={event.arrived}
@@ -284,7 +379,9 @@ export function ScheduleEventComponent({
previousEvent: event.id,
color: event.color,
alt_transport: event.job && event.job.alt_transport,
note: event.note
note: event.note,
scheduled_in: event.job && event.job.scheduled_in,
scheduled_completion: event.job && event.job.scheduled_completion
}
});
}}
@@ -294,7 +391,8 @@ export function ScheduleEventComponent({
) : (
<ScheduleManualEvent event={event} />
)}
{event.isintake ? (
{event.job &&
(HasFeatureAccess({ featureName: "checklist", bodyshop }) ? (
<Link
to={{
pathname: `/manage/jobs/${event.job && event.job.id}/intake`,
@@ -303,32 +401,54 @@ export function ScheduleEventComponent({
>
<Button disabled={event.arrived}>{t("appointments.actions.intake")}</Button>
</Link>
) : null}
) : (
<Popover //open={open}
content={popMenu}
open={popOverVisible}
onOpenChange={setPopOverVisible}
onClick={(e) => {
if (event.job?.id) {
e.stopPropagation();
getJobDetails();
}
}}
getPopupContainer={(trigger) => trigger.parentNode}
trigger="click"
>
<Button disabled={event.arrived}>{t("jobs.actions.intake_quick")}</Button>
</Popover>
))}
</Space>
</div>
);
// Adjust event color for dark mode if needed
const getEventBackground = () => {
if (event?.block) {
return "var(--event-block-bg)"; // Use a specific color for dark mode
}
const baseColor = event.color && event.color.hex ? event.color.hex : event.color || "var(--event-bg-fallback)";
// Optionally adjust color for dark mode (e.g., lighten if too dark)
return baseColor;
};
const RegularEvent = event.isintake ? (
<Space
wrap
size="small"
style={{
backgroundColor: event.color && event.color.hex ? event.color.hex : event.color
backgroundColor: getEventBackground()
}}
>
{event.note && <AlertFilled className="production-alert" />}
<strong>{`${event.job.ro_number || t("general.labels.na")}`}</strong>
<OwnerNameDisplay ownerObject={event.job} />
{`${(event.job && event.job.v_model_yr) || ""} ${
(event.job && event.job.v_make_desc) || ""
} ${(event.job && event.job.v_model_desc) || ""}`}
{`(${(event.job && event.job.labhrs.aggregate.sum.mod_lb_hrs) || "0"} / ${
(event.job && event.job.larhrs.aggregate.sum.mod_lb_hrs) || "0"
})`}
{event.job && event.job.alt_transport && <div style={{ margin: ".1rem" }}>{event.job.alt_transport}</div>}
{event?.job?.comment && `C: ${event.job.comment}`}
</Space>
@@ -337,7 +457,7 @@ export function ScheduleEventComponent({
style={{
height: "100%",
width: "100%",
backgroundColor: event.color && event.color.hex ? event.color.hex : event.color
backgroundColor: getEventBackground()
}}
>
<strong>{`${event.title || ""}`}</strong>
@@ -353,8 +473,7 @@ export function ScheduleEventComponent({
style={{
height: "100%",
width: "100%",
backgroundColor: event.color && event.color.hex ? event.color.hex : event.color
backgroundColor: getEventBackground()
}}
>
{RegularEvent}

View File

@@ -1,7 +1,7 @@
import { useMutation } from "@apollo/client";
import { Button, Card, Form, Input, Switch } from "antd";
import queryString from "query-string";
import React, { useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useLocation, useNavigate, useParams } from "react-router-dom";
@@ -9,7 +9,6 @@ import { createStructuredSelector } from "reselect";
import { logImEXEvent } from "../../../../firebase/firebase.utils";
import { MARK_APPOINTMENT_ARRIVED, MARK_LATEST_APPOINTMENT_ARRIVED } from "../../../../graphql/appointments.queries";
import { UPDATE_JOB } from "../../../../graphql/jobs.queries";
import { UPDATE_OWNER } from "../../../../graphql/owners.queries";
import { insertAuditTrail } from "../../../../redux/application/application.actions";
import { selectBodyshop, selectCurrentUser } from "../../../../redux/user/user.selectors";
import AuditTrailMapping from "../../../../utils/AuditTrailMappings";
@@ -32,7 +31,6 @@ export function JobChecklistForm({ insertAuditTrail, formItems, bodyshop, curren
const [loading, setLoading] = useState(false);
const [markAptArrived] = useMutation(MARK_APPOINTMENT_ARRIVED);
const [markLatestAptArrived] = useMutation(MARK_LATEST_APPOINTMENT_ARRIVED);
const [updateOwner] = useMutation(UPDATE_OWNER);
const notification = useNotification();
const { jobId } = useParams();
@@ -129,24 +127,6 @@ export function JobChecklistForm({ insertAuditTrail, formItems, bodyshop, curren
}
}
if (type === "intake" && job.owner && job.owner.id) {
//Updae Owner Allow to Text
const updateOwnerResult = await updateOwner({
variables: {
ownerId: job.owner.id,
owner: { allow_text_message: values.allow_text_message }
}
});
if (!!updateOwnerResult.errors) {
notification["error"]({
message: t("checklist.errors.complete", {
error: JSON.stringify(result.errors)
})
});
}
}
setLoading(false);
if (!!!result.errors) {
@@ -189,7 +169,6 @@ export function JobChecklistForm({ insertAuditTrail, formItems, bodyshop, curren
initialValues={{
...(type === "intake" && {
addToProduction: true,
allow_text_message: job.owner && job.owner.allow_text_message,
scheduled_completion:
(job && job.scheduled_completion && dayjs(job.scheduled_completion)) ||
(job &&
@@ -228,14 +207,6 @@ export function JobChecklistForm({ insertAuditTrail, formItems, bodyshop, curren
>
<Switch disabled={readOnly} />
</Form.Item>
<Form.Item
name="allow_text_message"
valuePropName="checked"
label={t("checklist.labels.allow_text_message")}
disabled={readOnly}
>
<Switch disabled={readOnly} />
</Form.Item>
<Form.Item
name="scheduled_completion"
label={t("jobs.fields.scheduled_completion")}

View File

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

View File

@@ -1,4 +1,3 @@
import React from "react";
import { useTranslation } from "react-i18next";
const Car = ({ dmg1, dmg2 }) => {
@@ -8,6 +7,7 @@ const Car = ({ dmg1, dmg2 }) => {
<div style={{ position: "relative", textAlign: "center" }}>
{t("jobs.labels.cards.damage")}
<svg
className="car-svg"
style={{ left: 0, top: 0, width: "100%", height: "100%" }}
id="svg166"
version="1.1"

View File

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

View File

@@ -80,7 +80,7 @@ export function JobEmployeeAssignments({
);
return (
<Popover destroyTooltipOnHide content={popContent} open={visibility}>
<Popover destroyOnHidden content={popContent} open={visibility}>
<Spin spinning={loading}>
<DataLabel label={t("jobs.fields.employee_body")}>
{body ? (

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import dayjs from "../../utils/day";
import axios from "axios";
import { Badge, Card, Space, Table, Tag } from "antd";
@@ -6,24 +6,24 @@ import { gql, useQuery } from "@apollo/client";
import { DateTimeFormatterFunction } from "../../utils/DateFormatter";
import { isEmpty } from "lodash";
import { useTranslation } from "react-i18next";
import "./job-lifecycle.styles.scss";
import BlurWrapperComponent from "../feature-wrapper/blur-wrapper.component";
import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
// show text on bar if text can fit
export function JobLifecycleComponent({ bodyshop, job, statuses, ...rest }) {
export function JobLifecycleComponent({ bodyshop, job, statuses }) {
const [loading, setLoading] = useState(true);
const [lifecycleData, setLifecycleData] = useState(null);
const { t } = useTranslation(); // Used for tracking external state changes.
@@ -79,7 +79,7 @@ export function JobLifecycleComponent({ bodyshop, job, statuses, ...rest }) {
title: t("job_lifecycle.columns.value"),
dataIndex: "value",
key: "value",
render: (text, record) => (
render: (text) => (
<BlurWrapperComponent
featureName="lifecycle"
bypass
@@ -95,7 +95,7 @@ export function JobLifecycleComponent({ bodyshop, job, statuses, ...rest }) {
dataIndex: "start",
key: "start",
sorter: (a, b) => dayjs(a.start).unix() - dayjs(b.start).unix(),
render: (text, record) => (
render: (text) => (
<BlurWrapperComponent featureName="lifecycle" bypass valueProp="children" overrideValueFunction="RandomDate">
<span>{DateTimeFormatterFunction(text)}</span>
</BlurWrapperComponent>
@@ -119,8 +119,7 @@ export function JobLifecycleComponent({ bodyshop, job, statuses, ...rest }) {
}
return dayjs(a.end).unix() - dayjs(b.end).unix();
},
render: (text, record) => (
render: (text) => (
<BlurWrapperComponent featureName="lifecycle" bypass valueProp="children" overrideValueFunction="RandomDate">
<span>{isEmpty(text) ? t("job_lifecycle.content.not_available") : DateTimeFormatterFunction(text)}</span>
</BlurWrapperComponent>
@@ -170,7 +169,7 @@ export function JobLifecycleComponent({ bodyshop, job, statuses, ...rest }) {
borderRadius: "5px",
borderWidth: "5px",
borderStyle: "solid",
borderColor: "#f0f2f5",
borderColor: "var(--bar-border-color)",
margin: 0,
padding: 0
}}
@@ -189,12 +188,10 @@ export function JobLifecycleComponent({ bodyshop, job, statuses, ...rest }) {
alignItems: "center",
margin: 0,
padding: 0,
borderTop: "1px solid #f0f2f5",
borderBottom: "1px solid #f0f2f5",
borderLeft: isFirst ? "1px solid #f0f2f5" : undefined,
borderRight: isLast ? "1px solid #f0f2f5" : undefined,
borderTop: "1px solid var(--bar-border-color)",
borderBottom: "1px solid var(--bar-border-color)",
borderLeft: isFirst ? "1px solid var(--bar-border-color)" : undefined,
borderRight: isLast ? "1px solid var(--bar-border-color)" : undefined,
backgroundColor: key.color,
width: `${key.percentage}%`
}}
@@ -206,7 +203,7 @@ export function JobLifecycleComponent({ bodyshop, job, statuses, ...rest }) {
<div>{key.roundedPercentage}</div>
<div
style={{
backgroundColor: "#f0f2f5",
backgroundColor: "var(--tag-wrapper-bg)",
borderRadius: "5px",
paddingRight: "2px",
paddingLeft: "2px",
@@ -230,8 +227,8 @@ export function JobLifecycleComponent({ bodyshop, job, statuses, ...rest }) {
aria-label={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
title={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
style={{
backgroundColor: "#f0f2f5",
color: "#000",
backgroundColor: "var(--tag-wrapper-bg)",
color: "var(--tag-wrapper-text)",
padding: "4px",
textAlign: "center"
}}
@@ -315,4 +312,5 @@ export function JobLifecycleComponent({ bodyshop, job, statuses, ...rest }) {
</Card>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(JobLifecycleComponent);

View File

@@ -0,0 +1,25 @@
import { PushpinFilled, PushpinOutlined } from "@ant-design/icons";
import { useMutation } from "@apollo/client";
import { UPDATE_NOTE } from "../../graphql/notes.queries";
function JobNotesPinToggle({ note }) {
const [updateNote] = useMutation(UPDATE_NOTE);
const handlePinToggle = () => {
updateNote({
variables: {
noteId: note.id,
note: { pinned: !note.pinned }
},
refetchQueries: ["GET_JOB_BY_PK", "QUERY_JOB_CARD_DETAILS", "QUERY_PARTS_QUEUE_CARD_DETAILS"]
});
};
return note.pinned ? (
<PushpinFilled size="large" onClick={handlePinToggle} style={{ color: "gold" }} />
) : (
<PushpinOutlined size="large" onClick={handlePinToggle} />
);
}
export default JobNotesPinToggle;

View File

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

View File

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

View File

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

View File

@@ -106,7 +106,12 @@ export function JobsAdminDatesChange({ insertAuditTrail, job }) {
<Form.Item label={t("jobs.fields.date_open")} name="date_open">
<DateTimePicker />
</Form.Item>
<Form.Item label={t("jobs.fields.estimate_sent_approval")} name="estimate_sent_approval">
<DateTimePicker />
</Form.Item>
<Form.Item label={t("jobs.fields.estimate_approved")} name="estimate_approved">
<DateTimePicker />
</Form.Item>
<Form.Item label={t("jobs.fields.date_scheduled")} name="date_scheduled">
<DateTimePicker />
</Form.Item>

View File

@@ -1,19 +1,19 @@
import { DownloadOutlined, SyncOutlined } from "@ant-design/icons";
import { Button, Card, Input, Space, Table } from "antd";
import axios from "axios";
import React, { useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { selectPartnerVersion } from "../../redux/application/application.selectors";
import { alphaSort } from "../../utils/sorters";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
partnerVersion: selectPartnerVersion
});
const mapDispatchToProps = (dispatch) => ({
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(JobsAvailableScan);
@@ -126,6 +126,7 @@ export function JobsAvailableScan({ partnerVersion, refetch }) {
onClick={() => {
scanEstimates();
}}
id="scan-estimates-button"
>
<SyncOutlined />
</Button>

View File

@@ -4,11 +4,12 @@ import { Col, Row } from "antd";
import Axios from "axios";
import _ from "lodash";
import queryString from "query-string";
import React, { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { logImEXEvent } from "../../firebase/firebase.utils";
import {
DELETE_AVAILABLE_JOB,
@@ -33,7 +34,6 @@ import OwnerFindModalContainer from "../owner-find-modal/owner-find-modal.contai
import { GetSupplementDelta } from "./jobs-available-supplement.estlines.util";
import HeaderFields from "./jobs-available-supplement.headerfields";
import JobsAvailableTableComponent from "./jobs-available-table.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -195,7 +195,7 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail
await deleteJob({
variables: { id: estData.id }
}).then((r) => {
}).then(() => {
refetch();
setInsertLoading(false);
});
@@ -315,7 +315,7 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail
deleteJob({
variables: { id: estData.id }
}).then((r) => {
}).then(() => {
refetch();
setInsertLoading(false);
});
@@ -372,7 +372,7 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail
loadEstData({ variables: { id: record.id } });
modalSearchState[1](record.clm_no);
setJobModalVisible(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line
}, []);
useEffect(() => {
@@ -456,7 +456,7 @@ function replaceEmpty(someObj, replaceValue = null) {
return JSON.parse(temp);
}
async function CheckTaxRatesUSA(estData, bodyshop) {
async function CheckTaxRatesUSA(estData) {
if (!estData.parts_tax_rates?.PAM) {
estData.parts_tax_rates.PAM = estData.parts_tax_rates.PAC;
}
@@ -568,7 +568,7 @@ async function CheckTaxRates(estData, bodyshop) {
});
//}
}
function ResolveCCCLineIssues(estData, bodyshop) {
function ResolveCCCLineIssues(estData) {
//Find all misc amounts, populate them to the act price.
//This needs to be done before cleansing unq_seq since some misc prices could move over.
estData.joblines.data.forEach((line) => {
@@ -585,6 +585,9 @@ function ResolveCCCLineIssues(estData, bodyshop) {
// line.notes += ` | ET/UT Update (prev = ${line.mod_lbr_ty})`;
line.mod_lbr_ty = "LAR";
}
if (line.mod_lbr_ty === "OTSL") {
line.mod_lbr_ty = line.mod_lbr_hrs === 0 ? null : "LAB";
}
}
});
});

View File

@@ -1,16 +1,16 @@
import { DownCircleFilled } from "@ant-design/icons";
import { useMutation } from "@apollo/client";
import { Button, Dropdown } from "antd";
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { UPDATE_JOB_STATUS } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -24,7 +24,6 @@ export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail }) {
const { t } = useTranslation();
const [availableStatuses, setAvailableStatuses] = useState([]);
const [otherStages, setOtherStages] = useState([]);
const [mutationUpdateJobstatus] = useMutation(UPDATE_JOB_STATUS);
const notification = useNotification();
@@ -32,7 +31,7 @@ export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail }) {
mutationUpdateJobstatus({
variables: { jobId: job.id, status: status }
})
.then((r) => {
.then(() => {
notification["success"]({ message: t("jobs.successes.save") });
insertAuditTrail({
jobid: job.id,
@@ -41,7 +40,7 @@ export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail }) {
});
// refetch();
})
.catch((error) => {
.catch(() => {
notification["error"]({ message: t("jobs.errors.saving") });
});
};
@@ -51,19 +50,14 @@ export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail }) {
if (job && bodyshop) {
if (bodyshop.md_ro_statuses.pre_production_statuses.includes(job.status)) {
setAvailableStatuses(bodyshop.md_ro_statuses.pre_production_statuses);
if (bodyshop.md_ro_statuses.production_statuses[0])
setOtherStages([bodyshop.md_ro_statuses.production_statuses[0]]);
} else if (bodyshop.md_ro_statuses.production_statuses.includes(job.status)) {
setAvailableStatuses(bodyshop.md_ro_statuses.production_statuses);
setOtherStages([bodyshop.md_ro_statuses.default_imported, bodyshop.md_ro_statuses.default_delivered]);
} else if (bodyshop.md_ro_statuses.post_production_statuses.includes(job.status)) {
setAvailableStatuses(
bodyshop.md_ro_statuses.post_production_statuses.filter(
(s) => s !== bodyshop.md_ro_statuses.default_invoiced && s !== bodyshop.md_ro_statuses.default_exported
)
);
if (bodyshop.md_ro_statuses.production_statuses[0])
setOtherStages([bodyshop.md_ro_statuses.production_statuses[0]]);
} else {
console.log("Status didn't match any restrictions. Allowing all status changes.");
setAvailableStatuses(bodyshop.md_ro_statuses.statuses);
@@ -76,16 +70,7 @@ export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail }) {
...availableStatuses.map((item) => ({
key: item,
label: item
})),
...(job.converted
? [
{ type: "divider" },
...otherStages.map((item) => ({
key: item,
label: item
}))
]
: [])
],
onClick: (e) => updateJobStatus(e.key)
};

View File

@@ -1,5 +1,5 @@
import { WarningOutlined } from "@ant-design/icons";
import { Form, Select, Space, Tooltip } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -8,14 +8,13 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import LaborTypeFormItem from "../form-items-formatted/labor-type-form-item.component";
import PartTypeFormItem from "../form-items-formatted/part-type-form-item.component";
import ReadOnlyFormItem from "../form-items-formatted/read-only-form-item.component";
import { WarningOutlined } from "@ant-design/icons";
import "./jobs-close-lines.styles.scss";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
jobRO: selectJobReadOnly
});
const mapDispatchToProps = (dispatch) => ({
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
@@ -24,7 +23,7 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
return (
<div>
<Form.List name={["joblines"]}>
{(fields, { add, remove, move }) => {
{(fields) => {
return (
<table className="jobs-close-table">
<thead>

View File

@@ -6,7 +6,7 @@
td {
padding: 8px;
text-align: left;
border-bottom: 1px solid #ddd;
border-bottom: 1px solid var(--table-border-color);
.ant-form-item {
margin-bottom: 0px !important;
@@ -14,6 +14,6 @@
}
tr:hover {
background-color: #f5f5f5;
background-color: var(--table-hover-bg);
}
}

View File

@@ -1,4 +1,4 @@
import { Form, Input, Switch } from "antd";
import { Form, Input } from "antd";
import React, { useContext } from "react";
import { useTranslation } from "react-i18next";
import JobCreateContext from "../../pages/jobs-create/jobs-create.context";
@@ -129,13 +129,6 @@ export default function JobsCreateOwnerInfoNewComponent() {
<Form.Item label={t("owners.fields.preferred_contact")} name={["owner", "data", "preferred_contact"]}>
<Input disabled={!state.owner.new} />
</Form.Item>
<Form.Item
label={t("owners.fields.allow_text_message")}
valuePropName="checked"
name={["owner", "data", "allow_text_message"]}
>
<Switch disabled={!state.owner.new} />
</Form.Item>
</LayoutFormRow>
</div>
);

View File

@@ -1,5 +1,5 @@
import { Card, Input, Table } from "antd";
import React, { useContext, useState } from "react";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import JobCreateContext from "../../pages/jobs-create/jobs-create.context";
import PhoneFormatter from "../../utils/PhoneFormatter";
@@ -91,6 +91,7 @@ export default function JobsCreateOwnerInfoSearchComponent({ loading, owners })
});
}}
enterButton
id="search-owner"
/>
}
>
@@ -112,9 +113,9 @@ export default function JobsCreateOwnerInfoSearchComponent({ loading, owners })
type: "radio",
selectedRowKeys: [state.owner.selectedid]
}}
onRow={(record, rowIndex) => {
onRow={(record) => {
return {
onClick: (event) => {
onClick: () => {
if (record) {
if (record.id) {
setState({

View File

@@ -72,7 +72,7 @@ export default function JobsCreateVehicleInfoPredefined({ disabled, form }) {
open={open}
placement="left"
onOpenChange={handleOpenChange}
destroyTooltipOnHide
destroyOnHidden
>
<SearchOutlined style={{ cursor: "pointer" }} />
</Popover>

View File

@@ -1,9 +1,9 @@
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { Card, Input, Space, Table } from "antd";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { alphaSort } from "../../utils/sorters";
import JobCreateContext from "../../pages/jobs-create/jobs-create.context";
import { alphaSort } from "../../utils/sorters";
import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component";
export default function JobsCreateVehicleInfoSearchComponent({ loading, vehicles }) {
@@ -63,6 +63,7 @@ export default function JobsCreateVehicleInfoSearchComponent({ loading, vehicles
});
}}
enterButton
id="search-vehicle"
/>
</Space>
}
@@ -91,9 +92,9 @@ export default function JobsCreateVehicleInfoSearchComponent({ loading, vehicles
type: "radio",
selectedRowKeys: [state.vehicle.selectedid]
}}
onRow={(record, rowIndex) => {
onRow={(record) => {
return {
onClick: (event) => {
onClick: () => {
if (record) {
if (record.id) {
setState({

View File

@@ -1,10 +1,11 @@
import { Form, Statistic, Tooltip } from "antd";
import React, { useMemo } from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import dayjs from "../../utils/day";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component";
import FormRow from "../layout-form-row/layout-form-row.component";
@@ -40,6 +41,20 @@ export function JobsDetailDatesComponent({ jobRO, job, bodyshop }) {
<Form.Item label={t("jobs.fields.date_rentalresp")} name="date_rentalresp">
<DateTimePicker disabled={jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.estimate_sent_approval")} name="estimate_sent_approval">
<DateTimePicker
disabled={jobRO}
value={job.estimate_sent_approval ? dayjs(job.estimate_sent_approval) : null}
placeholder={t("general.labels.na")}
/>
</Form.Item>
<Form.Item label={t("jobs.fields.estimate_approved")} name="estimate_approved">
<DateTimePicker
disabled={jobRO}
value={job.estimate_approved ? dayjs(job.estimate_approved) : null}
placeholder={t("general.labels.na")}
/>
</Form.Item>
</FormRow>
<FormRow header={t("jobs.forms.scheddates")}>
@@ -48,7 +63,7 @@ export function JobsDetailDatesComponent({ jobRO, job, bodyshop }) {
</Form.Item>
<Tooltip title={t("jobs.labels.scheduledinchange")}>
<Form.Item label={t("jobs.fields.scheduled_in")} name="scheduled_in">
<DateTimePicker disabled={true || jobRO} />
<DateTimePicker disabled={true} />
</Form.Item>
</Tooltip>
<Form.Item label={t("jobs.fields.actual_in")} name="actual_in">
@@ -76,21 +91,15 @@ export function JobsDetailDatesComponent({ jobRO, job, bodyshop }) {
<DateTimePicker disabled={jobRO} />
</Form.Item>
<Form.Item shouldUpdate>
{() => {
return (
{() => (
<Form.Item
label={t("jobs.fields.actual_completion")}
name="actual_completion"
rules={[
{
required: jobInPostProduction
}
]}
rules={[{ required: jobInPostProduction }]}
>
<DateTimePicker disabled={jobRO} />
</Form.Item>
);
}}
)}
</Form.Item>
<Form.Item label={t("jobs.fields.scheduled_delivery")} name="scheduled_delivery">
<DateTimePicker disabled={jobRO} />
@@ -101,19 +110,16 @@ export function JobsDetailDatesComponent({ jobRO, job, bodyshop }) {
</FormRow>
<FormRow header={t("jobs.forms.admindates")}>
<Form.Item label={t("jobs.fields.date_invoiced")} name="date_invoiced">
<DateTimePicker disabled={true || jobRO} />
<DateTimePicker disabled={true} />
</Form.Item>
<Form.Item label={t("jobs.fields.date_exported")} name="date_exported">
<DateTimePicker disabled={true || jobRO} />
<DateTimePicker disabled={true} />
</Form.Item>
<Form.Item label={t("jobs.fields.date_void")} name="date_void">
<DateTimePicker disabled={true || jobRO} />
<DateTimePicker disabled={true} />
</Form.Item>
<Form.Item label={t("jobs.fields.date_lost_sale")} name="date_lost_sale">
<DateTimePicker disabled={true || jobRO} />
<DateTimePicker disabled={true} />
</Form.Item>
</FormRow>
</div>

View File

@@ -1,5 +1,4 @@
import { Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -188,6 +187,12 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
<Form.Item label={t("jobs.fields.tlos_ind")} name="tlos_ind" valuePropName="checked">
<Switch disabled={jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.hit_and_run")} name="hit_and_run" valuePropName="checked">
<Switch disabled={jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.acv_amount")} name="acv_amount">
<CurrencyInput disabled={jobRO} min={0} />
</Form.Item>
</FormRow>
</Col>
<Col {...lossColDamage}>

View File

@@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { auth, logImEXEvent } from "../../firebase/firebase.utils";
import { CANCEL_APPOINTMENTS_BY_JOB_ID, INSERT_MANUAL_APPT } from "../../graphql/appointments.queries";
@@ -32,7 +33,6 @@ import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
import AddToProduction from "./jobs-detail-header-actions.addtoproduction.util";
import DuplicateJob from "./jobs-detail-header-actions.duplicate.util";
import JobsDetailHeaderActionsToggleProduction from "./jobs-detail-header-actions.toggle-production";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -673,7 +673,9 @@ export function JobsDetailHeaderActions({
context: {
jobId: job.id,
job: job,
alt_transport: job.alt_transport
alt_transport: job.alt_transport,
scheduled_in: job.scheduled_in,
scheduled_completion: job.scheduled_completion
}
});
}
@@ -1078,7 +1080,8 @@ export function JobsDetailHeaderActions({
menuItems.push({
key: "deletejob",
id: "job-actions-deletejob",
label: (
label:
job.job_watchers.length === 0 ? (
<Popconfirm
title={t("jobs.labels.deleteconfirm")}
okText={t("general.labels.yes")}
@@ -1088,6 +1091,10 @@ export function JobsDetailHeaderActions({
>
{t("menus.jobsactions.deletejob")}
</Popconfirm>
) : (
<Popconfirm title={t("jobs.labels.deletewatchers")} onClick={(e) => e.stopPropagation()} showCancel={false}>
{t("menus.jobsactions.deletejob")}
</Popconfirm>
)
});
}
@@ -1109,8 +1116,8 @@ export function JobsDetailHeaderActions({
<RbacWrapper action="jobs:void" noauth>
<Popconfirm
title={t("jobs.labels.voidjob")}
okText="Yes"
cancelText="No"
okText={t("general.labels.yes")}
cancelText={t("general.labels.no")}
onClick={(e) => e.stopPropagation()}
onConfirm={handleVoidJob}
>

View File

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { GET_JOB_BY_PK_QUICK_INTAKE, JOB_PRODUCTION_TOGGLE } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
@@ -12,7 +13,6 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { DateTimeFormatterFunction } from "../../utils/DateFormatter";
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
const mapStateToProps = createStructuredSelector({
@@ -44,9 +44,16 @@ export function JobsDetailHeaderActionsToggleProduction({
variables: { id: job.id },
onCompleted: (data) => {
if (data?.jobs_by_pk) {
const totalHours =
(data.jobs_by_pk.labhrs?.aggregate?.sum?.mod_lb_hrs || 0) +
(data.jobs_by_pk.larhrs?.aggregate?.sum?.mod_lb_hrs || 0);
form.setFieldsValue({
actual_in: data.jobs_by_pk.actual_in ? data.jobs_by_pk.actual_in : dayjs(),
scheduled_completion: data.jobs_by_pk.scheduled_completion,
scheduled_completion: data.jobs_by_pk.scheduled_completion
? data.jobs_by_pk.scheduled_completion
: totalHours && bodyshop.ss_configuration.nobusinessdays
? dayjs().businessDaysAdd(totalHours / (bodyshop.target_touchtime || 1), "day")
: dayjs().add(totalHours / (bodyshop.target_touchtime || 1), "day"),
actual_completion: data.jobs_by_pk.actual_completion,
scheduled_delivery: data.jobs_by_pk.scheduled_delivery,
actual_delivery: data.jobs_by_pk.actual_delivery
@@ -160,7 +167,18 @@ export function JobsDetailHeaderActionsToggleProduction({
<FormDateTimePickerComponent disabled={jobRO} />
</Form.Item>
<Form.Item name={["actual_delivery"]} label={t("jobs.fields.actual_delivery")}>
<Form.Item
name={["actual_delivery"]}
label={t("jobs.fields.actual_delivery")}
rules={[
{
required: bodyshop.deliverchecklist.actual_delivery
? bodyshop.deliverchecklist.actual_delivery
: false
//message: t("general.validation.required"),
}
]}
>
<FormDateTimePickerComponent disabled={jobRO} />
</Form.Item>
</>

View File

@@ -1,15 +1,21 @@
import { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined, WarningFilled } from "@ant-design/icons";
import { Card, Col, Divider, Row, Space, Tag, Tooltip } from "antd";
import React, { useState } from "react";
import { useMutation } from "@apollo/client";
import { Card, Checkbox, Col, Divider, Row, Space, Tag, Tooltip } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { UPDATE_JOB } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions.js";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateTimeFormatter } from "../../utils/DateFormatter";
import { DateTimeFormatter, DateTimeFormatterFunction } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import ChatOpenButton from "../chat-open-button/chat-open-button.component";
import DataLabel from "../data-label/data-label.component";
@@ -17,11 +23,11 @@ import JobAltTransportChange from "../job-at-change/job-at-change.component";
import JobEmployeeAssignments from "../job-employee-assignments/job-employee-assignments.container";
import JobsRelatedRos from "../jobs-related-ros/jobs-related-ros.component";
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import PinnedJobNotes from "../pinned-job-notes/pinned-job-notes.component.jsx";
import ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.component";
import ProductionListColumnProductionNote from "../production-list-columns/production-list-columns.productionnote.component";
import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component";
import "./jobs-detail-header.styles.scss";
import dayjs from "../../utils/day";
const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly,
@@ -29,42 +35,75 @@ const mapStateToProps = createStructuredSelector({
});
const mapDispatchToProps = (dispatch) => ({
setPrintCenterContext: (context) => dispatch(setModalContext({ context: context, modal: "printCenter" }))
setPrintCenterContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "printCenter"
})
),
insertAuditTrail: ({ jobid, operation, type }) =>
dispatch(
insertAuditTrail({
jobid,
operation,
type
})
)
});
const colSpan = {
xs: {
span: 24
},
sm: {
span: 24
},
md: {
span: 12
},
lg: {
span: 6
},
xl: {
span: 6
}
xs: { span: 24 },
sm: { span: 24 },
md: { span: 12 },
lg: { span: 6 },
xl: { span: 6 }
};
export function JobsDetailHeader({ job, bodyshop, disabled }) {
export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail }) {
const { t } = useTranslation();
const { notification } = useNotification();
const [notesClamped, setNotesClamped] = useState(true);
const vehicleTitle = `${job.v_model_yr || ""} ${job.v_color || ""}
${job.v_make_desc || ""}
${job.v_model_desc || ""}`.trim();
const [updateJob] = useMutation(UPDATE_JOB);
const vehicleTitle =
`${job.v_model_yr || ""} ${job.v_color || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim();
const bodyHrs = job.joblines.filter((j) => j.mod_lbr_ty !== "LAR").reduce((acc, val) => acc + val.mod_lb_hrs, 0);
const refinishHrs = job.joblines
.filter((line) => line.mod_lbr_ty === "LAR")
.reduce((acc, val) => acc + val.mod_lb_hrs, 0);
const ownerTitle = OwnerNameDisplayFunction(job).trim();
// Handle checkbox changes
const handleCheckboxChange = async (field, checked) => {
const value = checked ? dayjs().toISOString() : null;
try {
const ret = await updateJob({
variables: {
jobId: job.id,
job: { [field]: value }
},
refetchQueries: ["GET_JOB_BY_PK"],
awaitRefetchQueries: true
});
insertAuditTrail({
jobid: job.id,
operation: AuditTrailMapping.jobfieldchange(
field,
ret.data.update_jobs.returning[0][field]
? DateTimeFormatterFunction(ret.data.update_jobs.returning[0][field])
: checked
),
type: "jobfieldchange"
});
} catch (error) {
notification.error({
message: t("jobs.errors.saving", { error: error.message })
});
}
};
return (
<>
<Row gutter={[16, 16]} style={{ alignItems: "stretch" }}>
<Col {...colSpan}>
<Card title={"Job Status"} style={{ height: "100%" }}>
@@ -72,11 +111,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
<DataLabel label={t("jobs.fields.status")}>
<Space wrap>
{job.status}
{job.inproduction && (
<Tag color="#f50" key="production">
{t("jobs.labels.inproduction")}
</Tag>
)}
{job.inproduction && <Tag color="#f50">{t("jobs.labels.inproduction")}</Tag>}
{job.suspended && <PauseCircleOutlined style={{ color: "orangered" }} />}
{job.iouparent && (
<Link to={`/manage/jobs/${job.iouparent}`}>
@@ -110,7 +145,6 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
<span style={{ margin: "0rem .5rem" }}>/</span>
<CurrencyFormatter>{job.owner_owing}</CurrencyFormatter>
</DataLabel>
<DataLabel label={t("jobs.fields.alt_transport")}>
{job.alt_transport}
<JobAltTransportChange job={job} />
@@ -127,11 +161,39 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
))}
</DataLabel>
)}
<DataLabel label={t("jobs.fields.production_vars.note")}>
<ProductionListColumnProductionNote record={job} />
</DataLabel>
<DataLabel label={t("jobs.fields.estimate_sent_approval")}>
<Space>
<Checkbox
checked={!!job.estimate_sent_approval}
onChange={(e) => handleCheckboxChange("estimate_sent_approval", e.target.checked)}
disabled={disabled}
>
{job.estimate_sent_approval && (
<span style={{ color: "#888" }}>
<DateTimeFormatter>{job.estimate_sent_approval}</DateTimeFormatter>
</span>
)}
</Checkbox>
</Space>
</DataLabel>
<DataLabel label={t("jobs.fields.estimate_approved")}>
<Space>
<Checkbox
checked={!!job.estimate_approved}
onChange={(e) => handleCheckboxChange("estimate_approved", e.target.checked)}
disabled={disabled}
>
{job.estimate_approved && (
<span style={{ color: "#888" }}>
<DateTimeFormatter>{job.estimate_approved}</DateTimeFormatter>
</span>
)}
</Checkbox>
</Space>
</DataLabel>
<Space wrap>
{job.special_coverage_policy && (
<Tag color="tomato">
@@ -149,6 +211,14 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
</Space>
</Tag>
)}
{job.hit_and_run && (
<Tag color="green">
<Space>
<WarningFilled />
<span>{t("jobs.fields.hit_and_run")}</span>
</Space>
</Tag>
)}
</Space>
</div>
</Card>
@@ -267,7 +337,11 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
</Card>
</Col>
<Col {...colSpan}>
<Card style={{ height: "100%" }} title={t("jobs.labels.employeeassignments")}>
<Card
style={{ height: "100%" }}
title={<span id="job-employee-assignments-title">{t("jobs.labels.employeeassignments")}</span>}
id={"job-employee-assignments"}
>
<div>
<JobEmployeeAssignments job={job} />
<Divider style={{ margin: ".5rem" }} />
@@ -278,6 +352,8 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
</Card>
</Col>
</Row>
<PinnedJobNotes job={job} />
</>
);
}

View File

@@ -42,7 +42,7 @@ export function JobsDocumentsContainer({
variables: { jobId: jobId },
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
skip: Imgproxy.treatment === "on" || !!billId
skip: !!billId
});
if (loading) return <LoadingSpinner />;

View File

@@ -1,12 +1,9 @@
import { Button, Space } from "antd";
import axios from "axios";
import React, { useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { logImEXEvent } from "../../firebase/firebase.utils";
import cleanAxios from "../../utils/CleanAxios";
import formatBytes from "../../utils/formatbytes";
//import yauzl from "yauzl";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
@@ -14,7 +11,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
@@ -28,7 +25,7 @@ const mapDispatchToProps = (dispatch) => ({
export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsImgproxyDownloadButton);
export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, identifier }) {
export function JobsDocumentsImgproxyDownloadButton({ galleryImages, identifier, jobId }) {
const { t } = useTranslation();
const [download, setDownload] = useState(null);
const [loading, setLoading] = useState(false);
@@ -46,32 +43,42 @@ export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, i
};
});
}
function standardMediaDownload(bufferData) {
try {
const a = document.createElement("a");
const url = window.URL.createObjectURL(new Blob([bufferData]));
a.href = url;
a.download = `${identifier || "documents"}.zip`;
a.click();
} catch (error) {
setLoading(false);
setDownload(null);
}
}
const handleDownload = async () => {
logImEXEvent("jobs_documents_download");
setLoading(true);
const zipUrl = await axios({
try {
const response = await axios({
url: "/media/imgproxy/download",
method: "POST",
data: { documentids: imagesToDownload.map((_) => _.id) }
});
const theDownloadedZip = await cleanAxios({
url: zipUrl.data.url,
method: "GET",
responseType: "arraybuffer",
responseType: "blob",
data: { jobId, documentids: imagesToDownload.map((_) => _.id) },
onDownloadProgress: downloadProgress
});
setLoading(false);
setDownload(null);
standardMediaDownload(theDownloadedZip.data);
// Use the response data (Blob) to trigger download
standardMediaDownload(response.data);
} catch (error) {
setLoading(false);
setDownload(null);
// handle error (optional)
}
};
return (

View File

@@ -46,8 +46,8 @@ function JobsDocumentsImgproxyComponent({
const [modalState, setModalState] = useState({ open: false, index: 0 });
const fetchThumbnails = useCallback(() => {
fetchImgproxyThumbnails({ setStateCallback: setGalleryImages, jobId });
}, [jobId, setGalleryImages]);
fetchImgproxyThumbnails({ setStateCallback: setGalleryImages, jobId, billId });
}, [jobId, billId, setGalleryImages]);
useEffect(() => {
if (data) {
@@ -75,7 +75,7 @@ function JobsDocumentsImgproxyComponent({
<SyncOutlined />
</Button>
<JobsDocumentsGallerySelectAllComponent galleryImages={galleryImages} setGalleryImages={setGalleryImages} />
<JobsDocumentsDownloadButton galleryImages={galleryImages} identifier={downloadIdentifier} />
<JobsDocumentsDownloadButton galleryImages={galleryImages} identifier={downloadIdentifier} jobId={jobId} />
<JobsDocumentsDeleteButton
galleryImages={galleryImages}
deletionCallback={billsCallback || fetchThumbnails || refetch}
@@ -98,7 +98,13 @@ function JobsDocumentsImgproxyComponent({
jobId={jobId}
totalSize={totalSize}
billId={billId}
callbackAfterUpload={billsCallback || fetchThumbnails || refetch}
callbackAfterUpload={
billsCallback ||
function () {
isFunction(refetch) && refetch();
isFunction(fetchThumbnails) && fetchThumbnails();
}
}
ignoreSizeLimit={ignoreSizeLimit}
/>
</Card>
@@ -202,8 +208,8 @@ function JobsDocumentsImgproxyComponent({
export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsImgproxyComponent);
export const fetchImgproxyThumbnails = async ({ setStateCallback, jobId, imagesOnly }) => {
const result = await axios.post("/media/imgproxy/thumbnails", { jobid: jobId });
export const fetchImgproxyThumbnails = async ({ setStateCallback, jobId, billId, imagesOnly }) => {
const result = await axios.post("/media/imgproxy/thumbnails", { jobid: jobId, billid: billId });
const documents = result.data.reduce(
(acc, value) => {
if (value.type.startsWith("image")) {

View File

@@ -1,9 +1,9 @@
import { SyncOutlined } from "@ant-design/icons";
import { Button, Checkbox, Divider, Input, Space, Table } from "antd";
import dayjs from "../../utils/day";
import React, { useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import dayjs from "../../utils/day";
import PhoneFormatter from "../../utils/PhoneFormatter";
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
@@ -223,9 +223,9 @@ export default function JobsFindModalComponent({
type: "radio",
selectedRowKeys: [selectedJob]
}}
onRow={(record, rowIndex) => {
onRow={(record) => {
return {
onClick: (event) => {
onClick: () => {
handleOnRowClick(record);
}
};
@@ -241,15 +241,17 @@ export default function JobsFindModalComponent({
overrideHeaders: e.target.checked
})
}
id="override_header"
>
{t("jobs.labels.override_header")}
</Checkbox>
<Checkbox checked={partsQueueToggle} onChange={(e) => setPartsQueueToggle(e.target.checked)}>
<Checkbox checked={partsQueueToggle} onChange={(e) => setPartsQueueToggle(e.target.checked)} id="parts_queue_toggle">
{t("bodyshop.fields.md_functionality_toggles.parts_queue_toggle")}
</Checkbox>
<Checkbox
checked={updateSchComp.checked}
onChange={(e) => setSchComp({ ...updateSchComp, checked: e.target.checked })}
id="update_scheduled_completion"
>
{t("jobs.labels.update_scheduled_completion")}
</Checkbox>
@@ -261,6 +263,7 @@ export default function JobsFindModalComponent({
onChange={(e) => {
setSchComp({ ...updateSchComp, scheduled_completion: e });
}}
id="scheduled_completion_date_time_picker"
/>
) : null}
<Checkbox
@@ -273,6 +276,7 @@ export default function JobsFindModalComponent({
automatic: true
});
}}
id="calculate_scheduled_completion"
>
{t("jobs.labels.calc_scheuled_completion")}
</Checkbox>

View File

@@ -1,6 +1,5 @@
import { useQuery } from "@apollo/client";
import { Modal } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -65,8 +64,8 @@ export default connect(
<Modal
title={t("jobs.labels.existing_jobs")}
width={"80%"}
destroyOnClose
okButtonProps={{ disabled: selectedJob ? false : true }}
destroyOnHidden
okButtonProps={{ disabled: selectedJob ? false : true, id: "jobs-find-modal-container-ok" }}
{...modalProps}
>
{loading ? <LoadingSpinner /> : null}

View File

@@ -3,7 +3,7 @@ import { Button, Card, Input, Space, Table, Typography } from "antd";
import axios from "axios";
import _ from "lodash";
import queryString from "query-string";
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom";
@@ -20,7 +20,7 @@ const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
@@ -203,6 +203,8 @@ export function JobsList({ bodyshop, refetch, loading, jobs, total }) {
return (
<Card
id="all-jobs-list"
title={t("titles.bc.jobs-all")}
extra={
<Space wrap>
{search.search && (
@@ -256,6 +258,7 @@ export function JobsList({ bodyshop, refetch, loading, jobs, total }) {
rowKey="id"
dataSource={search?.search ? openSearchResults : jobs}
onChange={handleTableChange}
id="all-jobs-list-table"
/>
</Card>
);

View File

@@ -2,7 +2,7 @@ import { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined, SyncOut
import { useQuery } from "@apollo/client";
import { Button, Card, Grid, Input, Space, Table, Tooltip } from "antd";
import queryString from "query-string";
import React, { useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom";
@@ -22,7 +22,7 @@ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({});
const mapDispatchToProps = () => ({});
export function JobsList({ bodyshop }) {
const searchParams = queryString.parse(useLocation().search);
@@ -342,13 +342,14 @@ export function JobsList({ bodyshop }) {
type: "radio"
}}
onChange={handleTableChange}
onRow={(record, rowIndex) => {
onRow={(record) => {
return {
onClick: (event) => {
onClick: () => {
handleOnRowClick(record);
}
};
}}
id="active-jobs-list-table"
/>
</Card>
);

View File

@@ -12,6 +12,7 @@ import useLocalStorage from "../../utils/useLocalStorage";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import NoteUpsertModal from "../note-upsert-modal/note-upsert-modal.container";
import PrintWrapperComponent from "../print-wrapper/print-wrapper.component";
import JobNotesPinToggle from "../job-notes-pin-toggle/job-notes-pin-toggle.component";
const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly
@@ -47,6 +48,9 @@ export function JobNotesComponent({
key: "icons",
width: 80,
filteredValue: filter?.icons || null,
defaultSortOrder: "desc",
multiple: 1,
sorter: (a, b) => a.pinned - b.pinned,
filters: [
{
text: t("notes.labels.usernotes"),
@@ -63,6 +67,7 @@ export function JobNotesComponent({
{record.critical ? <WarningFilled style={{ margin: 4, color: "red" }} /> : null}
{record.private ? <EyeInvisibleFilled style={{ margin: 4 }} /> : null}
{record.audit ? <AuditOutlined style={{ margin: 4 }} /> : null}
<JobNotesPinToggle note={record} />
</span>
)
},
@@ -100,6 +105,7 @@ export function JobNotesComponent({
dataIndex: "updated_at",
key: "updated_at",
defaultSortOrder: "descend",
multiple: 2,
width: 200,
sorter: (a, b) => new Date(a.updated_at) - new Date(b.updated_at),
render: (text, record) => <DateTimeFormatter>{record.updated_at}</DateTimeFormatter>

View File

@@ -23,17 +23,22 @@ export function NoteUpsertModalComponent({ form, noteUpsertModal }) {
return (
<>
<Row gutter={[16, 16]}>
<Col span={8}>
<Col span={6}>
<Form.Item label={t("notes.fields.critical")} name="critical" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col span={8}>
<Col span={6}>
<Form.Item label={t("notes.fields.private")} name="private" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col span={8}>
<Col span={6}>
<Form.Item label={t("notes.fields.pinned")} name="pinned" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item label={t("notes.fields.type")} name="type" initialValue="general">
<Select
options={[

View File

@@ -1,10 +1,12 @@
import { useMutation } from "@apollo/client";
import { Form, Modal } from "antd";
import React, { useEffect } from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { GET_JOB_BY_PK } from "../../graphql/jobs.queries.js";
import { INSERT_NEW_NOTE, UPDATE_NOTE } from "../../graphql/notes.queries";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { toggleModalVisible } from "../../redux/modals/modals.actions";
@@ -12,7 +14,6 @@ import { selectNoteUpsert } from "../../redux/modals/modals.selectors";
import { selectCurrentUser } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import NoteUpsertModalComponent from "./note-upsert-modal.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
@@ -20,7 +21,14 @@ const mapStateToProps = createStructuredSelector({
});
const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("noteUpsert")),
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
insertAuditTrail: ({ jobid, operation, type }) =>
dispatch(
insertAuditTrail({
jobid,
operation,
type
})
)
});
export function NoteUpsertModalContainer({ currentUser, noteUpsertModal, toggleModalVisible, insertAuditTrail }) {
@@ -58,8 +66,9 @@ export function NoteUpsertModalContainer({ currentUser, noteUpsertModal, toggleM
variables: {
noteId: existingNote.id,
note: values
}
}).then((r) => {
},
refetchQueries: ["GET_JOB_BY_PK", "QUERY_JOB_CARD_DETAILS", "QUERY_PARTS_QUEUE_CARD_DETAILS"]
}).then(() => {
notification["success"]({
message: t("notes.successes.updated")
});
@@ -79,6 +88,33 @@ export function NoteUpsertModalContainer({ currentUser, noteUpsertModal, toggleM
variables: {
noteInput: [{ ...values, jobid: jobId, created_by: currentUser.email }]
},
update(cache, { data: { updateNote: updatedNote } }) {
try {
const existingJob = cache.readQuery({
query: GET_JOB_BY_PK,
variables: { id: jobId }
});
if (existingJob) {
cache.writeQuery({
query: GET_JOB_BY_PK,
variables: { id: jobId },
data: {
...existingJob,
job: {
...existingJob.job,
notes: updatedNote.pinned
? [updatedNote, ...existingJob.job.notes]
: existingJob.job.notes.filter((n) => n.id !== updatedNote.id)
}
}
});
}
} catch (error) {
// Cache miss is okay, query hasn't been executed yet
console.log("Cache miss for GET_JOB_BY_PK");
}
},
refetchQueries: ["QUERY_NOTES_BY_JOB_PK"]
});
@@ -123,7 +159,7 @@ export function NoteUpsertModalContainer({ currentUser, noteUpsertModal, toggleM
onCancel={() => {
toggleModalVisible();
}}
destroyOnClose
destroyOnHidden
>
<Form form={form} onFinish={handleFinish} layout="vertical">
<NoteUpsertModalComponent form={form} />

View File

@@ -1,11 +1,11 @@
import { Virtuoso } from "react-virtuoso";
import { Badge, Button, Space, Spin, Switch, Tooltip, Typography } from "antd";
import { Alert, Badge, Button, Space, Spin, Switch, Tooltip, Typography } from "antd";
import { CheckCircleFilled, CheckCircleOutlined, EyeFilled, EyeOutlined } from "@ant-design/icons";
import { 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 { forwardRef, useEffect, useRef } from "react";
import { DateTimeFormat } from "../../utils/DateFormatter.jsx";
const { Text, Title } = Typography;
@@ -26,7 +26,8 @@ const NotificationCenterComponent = forwardRef(
markAllRead,
loadMore,
onNotificationClick,
unreadCount
unreadCount,
isEmployee
},
ref
) => {
@@ -93,7 +94,12 @@ const NotificationCenterComponent = forwardRef(
) : (
<EyeOutlined className="notification-toggle-icon" />
)}
<Switch checked={showUnreadOnly} onChange={(checked) => toggleUnreadOnly(checked)} size="small" />
<Switch
checked={showUnreadOnly}
onChange={(checked) => toggleUnreadOnly(checked)}
size="small"
disabled={!isEmployee}
/>
</Space>
</Tooltip>
<Tooltip title={t("notifications.labels.mark-all-read")}>
@@ -106,6 +112,11 @@ const NotificationCenterComponent = forwardRef(
</Tooltip>
</div>
</div>
{!isEmployee ? (
<div style={{ padding: 10 }}>
<Alert message={t("notifications.labels.employee-notification")} type="warning" />
</div>
) : (
<Virtuoso
ref={virtuosoRef}
style={{ height: "400px", width: "100%" }}
@@ -114,9 +125,12 @@ const NotificationCenterComponent = forwardRef(
endReached={loadMore}
itemContent={renderNotification}
/>
)}
</div>
);
}
);
NotificationCenterComponent.displayName = "NotificationCenterComponent";
export default NotificationCenterComponent;

View File

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

View File

@@ -4,9 +4,9 @@
right: 0;
width: 400px;
max-width: 400px;
background: #fff;
color: rgba(0, 0, 0, 0.85);
border: 1px solid #d9d9d9;
background: var(--notification-bg);
color: var(--notification-text);
border: 1px solid var(--notification-border);
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;
@@ -19,23 +19,22 @@
.notification-header {
padding: 4px 16px;
border-bottom: 1px solid #f0f0f0;
border-bottom: 1px solid var(--notification-header-border);
display: flex;
justify-content: space-between;
align-items: center;
background: #fafafa;
background: var(--notification-header-bg);
h3 {
margin: 0;
font-size: 14px;
color: rgba(0, 0, 0, 0.85);
color: var(--notification-header-text);
}
.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
@@ -43,7 +42,7 @@
.notification-toggle-icon {
font-size: 14px;
color: #1677ff;
color: var(--notification-toggle-icon);
vertical-align: middle;
}
@@ -59,7 +58,8 @@
}
&.ant-switch-checked {
background-color: #1677ff;
background-color: var(--notification-switch-bg);
.ant-switch-handle {
left: calc(100% - 14px);
}
@@ -70,37 +70,37 @@
// Styles for the "Mark All Read" button (restore original link button style)
.ant-btn-link {
padding: 0;
color: #1677ff;
color: var(--notification-btn-link);
&:hover {
color: #69b1ff;
color: var(--notification-btn-link-hover);
}
&:disabled {
color: rgba(0, 0, 0, 0.25);
color: var(--notification-btn-link-disabled);
cursor: not-allowed;
}
&.active {
color: #0958d9;
color: var(--notification-btn-link-active);
}
}
}
}
.notification-read {
background: #fff;
color: rgba(0, 0, 0, 0.65);
background: var(--notification-read-bg);
color: var(--notification-read-text);
}
.notification-unread {
background: #f5f5f5;
color: rgba(0, 0, 0, 0.85);
background: var(--notification-unread-bg);
color: var(--notification-unread-text);
}
.notification-item {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
border-bottom: 1px solid var(--notification-header-border);
display: block;
overflow: visible;
width: 100%;
@@ -108,7 +108,7 @@
cursor: pointer;
&:hover {
background: #fafafa;
background: var(--notification-item-hover-bg);
}
.notification-content {
@@ -125,7 +125,7 @@
.ro-number {
margin: 0;
color: #1677ff;
color: var(--notification-ro-number);
flex-shrink: 0;
white-space: nowrap;
}
@@ -133,7 +133,7 @@
.relative-time {
margin: 0;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
color: var(--notification-relative-time);
white-space: nowrap;
flex-shrink: 0;
margin-left: auto;
@@ -164,12 +164,12 @@
.ant-alert {
margin: 8px;
background: #fff1f0;
color: rgba(0, 0, 0, 0.85);
border: 1px solid #ffa39e;
background: var(--alert-bg);
color: var(--alert-text);
border: 1px solid var(--alert-border);
.ant-alert-message {
color: #ff4d4f;
color: var(--alert-message);
}
}
}

View File

@@ -1,32 +1,41 @@
import { useMutation, useQuery } from "@apollo/client";
import { useEffect, useState } from "react";
import { Button, Card, Checkbox, Form, Space, Table } from "antd";
import { Alert, Button, Card, Checkbox, Divider, Form, Space, Switch, Table, Typography } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectCurrentUser } from "../../redux/user/user.selectors";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component";
import { QUERY_NOTIFICATION_SETTINGS, UPDATE_NOTIFICATION_SETTINGS } from "../../graphql/user.queries.js";
import {
QUERY_NOTIFICATION_SETTINGS,
UPDATE_NOTIFICATION_SETTINGS,
UPDATE_NOTIFICATIONS_AUTOADD
} 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";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
/**
* Notifications Settings Form
* @param currentUser
* @param bodyshop
* @returns {JSX.Element}
* @constructor
*/
const NotificationSettingsForm = ({ currentUser }) => {
const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
const { t } = useTranslation();
const [form] = Form.useForm();
const [initialValues, setInitialValues] = useState({});
const [isDirty, setIsDirty] = useState(false);
const [autoAddEnabled, setAutoAddEnabled] = useState(false);
const [initialAutoAdd, setInitialAutoAdd] = useState(false);
const notification = useNotification();
const isEmployee = useIsEmployee(bodyshop, currentUser);
// Fetch notification settings.
// Fetch notification settings and notifications_autoadd
const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
@@ -34,13 +43,16 @@ const NotificationSettingsForm = ({ currentUser }) => {
skip: !currentUser
});
const [updateNotificationSettings, { loading: saving }] = useMutation(UPDATE_NOTIFICATION_SETTINGS);
const [updateNotificationSettings, { loading: savingSettings }] = useMutation(UPDATE_NOTIFICATION_SETTINGS);
const [updateNotificationsAutoAdd, { loading: savingAutoAdd }] = useMutation(UPDATE_NOTIFICATIONS_AUTOADD);
// Populate form with fetched data.
// 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 autoAdd = data.associations[0].notifications_autoadd ?? false;
// 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;
@@ -48,32 +60,66 @@ const NotificationSettingsForm = ({ currentUser }) => {
setInitialValues(formattedValues);
form.setFieldsValue(formattedValues);
setIsDirty(false); // Reset dirty state when new data loads.
setAutoAddEnabled(autoAdd);
setInitialAutoAdd(autoAdd);
setIsDirty(false); // Reset dirty state when new data loads
}
}, [data, form]);
// Handle toggle of notifications_autoadd
const handleAutoAddToggle = async (checked) => {
if (data?.associations?.length > 0) {
const userId = data.associations[0].id;
try {
const result = await updateNotificationsAutoAdd({
variables: { id: userId, autoadd: checked }
});
if (!result?.errors) {
setAutoAddEnabled(checked);
setInitialAutoAdd(checked);
notification.success({ message: t("notifications.labels.auto-add-success") });
setIsDirty(false); // Reset dirty state if only auto-add was changed
} else {
throw new Error("Failed to update auto-add setting");
}
} catch (err) {
setAutoAddEnabled(!checked); // Revert on error
notification.error({ message: t("notifications.labels.auto-add-failure") });
}
}
};
// Handle save of notification settings
const handleSave = async (values) => {
if (data?.associations?.length > 0) {
const userId = data.associations[0].id;
// Save the updated notification settings.
try {
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 {
throw new Error("Failed to update notification settings");
}
} catch (err) {
notification.error({ message: t("notifications.labels.notification-settings-failure") });
}
}
};
// Mark the form as dirty on any manual change.
// Mark the form as dirty on any manual change
const handleFormChange = () => {
setIsDirty(true);
};
// Check if auto-add has changed
const isAutoAddDirty = autoAddEnabled !== initialAutoAdd;
// Handle reset of form and auto-add
const handleReset = () => {
form.setFieldsValue(initialValues);
setAutoAddEnabled(initialAutoAdd);
setIsDirty(false);
};
@@ -139,17 +185,30 @@ const NotificationSettingsForm = ({ currentUser }) => {
title={t("notifications.labels.notificationscenarios")}
extra={
<Space>
<Button type="default" onClick={handleReset} disabled={!isDirty}>
<Typography.Text type="secondary">{t("notifications.labels.auto-add")}</Typography.Text>
<Switch
checked={autoAddEnabled}
onChange={handleAutoAddToggle}
loading={savingAutoAdd}
// checkedChildren={t("notifications.labels.auto-add-on")}
// unCheckedChildren={t("notifications.labels.auto-add-off")}
/>
<Button type="default" onClick={handleReset} disabled={!isDirty && !isAutoAddDirty}>
{t("general.actions.clear")}
</Button>
<Button type="primary" htmlType="submit" disabled={!isDirty} loading={saving}>
<Button type="primary" htmlType="submit" disabled={!isDirty} loading={savingSettings}>
{t("notifications.labels.save")}
</Button>
</Space>
}
>
{!isEmployee && (
<div style={{ width: "100%", marginBottom: "10px" }}>
<Alert message={t("notifications.labels.employee-notification")} type="warning" />
</div>
)}
<Table dataSource={dataSource} columns={columns} pagination={false} bordered rowKey="key" />
<Divider />
</Card>
</Form>
);
@@ -158,11 +217,13 @@ const NotificationSettingsForm = ({ currentUser }) => {
NotificationSettingsForm.propTypes = {
currentUser: PropTypes.shape({
email: PropTypes.string.isRequired
}).isRequired
}).isRequired,
bodyshop: PropTypes.object.isRequired
};
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser
currentUser: selectCurrentUser,
bodyshop: selectBodyshop
});
export default connect(mapStateToProps)(NotificationSettingsForm);

View File

@@ -1,14 +1,15 @@
import { Form, Input, Switch } from "antd";
import React from "react";
import { Form, Input, Tooltip } from "antd";
import { CloseCircleFilled } from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
import FormItemEmail from "../form-items-formatted/email-form-item.component";
import FormItemPhone, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
export default function OwnerDetailFormComponent({ form, loading }) {
export default function OwnerDetailFormComponent({ form, loading, isPhone1OptedOut, isPhone2OptedOut }) {
const { t } = useTranslation();
const { getFieldValue } = form;
return (
<div>
<FormFieldsChanged form={form} />
@@ -50,9 +51,6 @@ export default function OwnerDetailFormComponent({ form, loading }) {
</Form.Item>
</LayoutFormRow>
<LayoutFormRow header={t("owners.forms.contact")}>
<Form.Item label={t("owners.fields.allow_text_message")} name="allow_text_message" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
label={t("owners.fields.ownr_ea")}
name="ownr_ea"
@@ -65,19 +63,55 @@ export default function OwnerDetailFormComponent({ form, loading }) {
>
<FormItemEmail email={getFieldValue("ownr_ea")} />
</Form.Item>
<Form.Item label={t("owners.fields.ownr_ph1")} style={{ marginBottom: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Form.Item
label={t("owners.fields.ownr_ph1")}
name="ownr_ph1"
noStyle
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ownr_ph1")]}
>
<FormItemPhone />
<Input style={{ flex: 1, minWidth: "150px" }} />
</Form.Item>
{isPhone1OptedOut && (
<Tooltip title={t("consent.text_body")}>
<CloseCircleFilled
style={{
color: "#ff4d4f",
fontSize: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%"
}}
/>
</Tooltip>
)}
</div>
</Form.Item>
<Form.Item label={t("owners.fields.ownr_ph2")} style={{ marginBottom: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Form.Item
label={t("owners.fields.ownr_ph2")}
name="ownr_ph2"
noStyle
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ownr_ph2")]}
>
<FormItemPhone />
<Input style={{ flex: 1, minWidth: "150px" }} />
</Form.Item>
{isPhone2OptedOut && (
<Tooltip title={t("consent.text_body")}>
<CloseCircleFilled
style={{
color: "#ff4d4f",
fontSize: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%"
}}
/>
</Tooltip>
)}
</div>
</Form.Item>
<Form.Item label={t("owners.fields.preferred_contact")} name="preferred_contact">
<Input />

View File

@@ -1,69 +1,115 @@
import { Button, Form, Popconfirm } from "antd";
import { PageHeader } from "@ant-design/pro-layout";
import React, { useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation } from "@apollo/client";
import { useApolloClient, useMutation } from "@apollo/client";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { DELETE_OWNER, UPDATE_OWNER } from "../../graphql/owners.queries";
import { selectBodyshop } from "../../redux/user/user.selectors"; // Adjust path
import { phoneNumberOptOutService } from "../../utils/phoneOptOutService.js"; // Adjust path
import OwnerDetailFormComponent from "./owner-detail-form.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { phone } from "phone"; // Import phone utility for formatting
function OwnerDetailFormContainer({ owner, refetch }) {
// Connect to Redux to access bodyshop
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
function OwnerDetailFormContainer({ owner, refetch, bodyshop }) {
const { t } = useTranslation();
const [form] = Form.useForm();
const history = useNavigate();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [optedOutPhones, setOptedOutPhones] = useState(new Set());
const [updateOwner] = useMutation(UPDATE_OWNER);
const [deleteOwner] = useMutation(DELETE_OWNER);
const notification = useNotification();
const apolloClient = useApolloClient();
// Fetch opt-out status on mount
useEffect(() => {
const fetchOptOutStatus = async () => {
if (bodyshop?.id && bodyshop?.messagingservicesid && (owner?.ownr_ph1 || owner?.ownr_ph2)) {
const phoneNumbers = [owner.ownr_ph1, owner.ownr_ph2].filter(Boolean);
const optOutSet = await phoneNumberOptOutService(apolloClient, bodyshop.id, phoneNumbers);
setOptedOutPhones(optOutSet);
} else {
setOptedOutPhones(new Set());
}
};
fetchOptOutStatus();
}, [apolloClient, bodyshop?.id, bodyshop?.messagingservicesid, owner?.ownr_ph1, owner?.ownr_ph2]);
// Reset form fields when owner changes
useEffect(() => {
form.setFieldsValue({
ownr_ph1: owner?.ownr_ph1,
ownr_ph2: owner?.ownr_ph2,
...owner
});
}, [owner, form]);
const handleDelete = async () => {
setLoading(true);
try {
const result = await deleteOwner({
variables: { id: owner.id }
});
console.log(result);
if (result.errors) {
notification["error"]({
notification.error({
message: t("owners.errors.deleting", {
error: JSON.stringify(result.errors)
})
});
setLoading(false);
} else {
notification["success"]({
notification.success({
message: t("owners.successes.delete")
});
navigate(`/manage/owners`);
}
} catch (error) {
notification.error({
message: t("owners.errors.deleting", {
error: error.message
})
});
} finally {
setLoading(false);
history(`/manage/owners`);
}
};
const handleFinish = async (values) => {
setLoading(true);
try {
const result = await updateOwner({
variables: { ownerId: owner.id, owner: values }
});
if (!!result.errors) {
notification["error"]({
if (result.errors) {
notification.error({
message: t("owners.errors.saving", {
error: JSON.stringify(result.errors)
})
});
setLoading(false);
return;
}
notification["success"]({
} else {
notification.success({
message: t("owners.successes.save")
});
if (refetch) await refetch();
form.resetFields();
form.resetFields();
}
} catch (error) {
notification.error({
message: t("owners.errors.saving", {
error: error.message
})
});
} finally {
setLoading(false);
}
};
return (
@@ -72,6 +118,7 @@ function OwnerDetailFormContainer({ owner, refetch }) {
title={t("menus.header.owners")}
extra={[
<Popconfirm
key="delete"
trigger="click"
onConfirm={handleDelete}
disabled={owner.jobs.length !== 0}
@@ -81,16 +128,29 @@ function OwnerDetailFormContainer({ owner, refetch }) {
{t("general.actions.delete")}
</Button>
</Popconfirm>,
<Button type="primary" loading={loading} onClick={() => form.submit()}>
<Button key="save" type="primary" loading={loading} onClick={() => form.submit()}>
{t("general.actions.save")}
</Button>
]}
/>
<Form form={form} onFinish={handleFinish} autoComplete="off" layout="vertical" initialValues={owner}>
<OwnerDetailFormComponent loading={loading} form={form} />
<OwnerDetailFormComponent
loading={loading}
form={form}
isPhone1OptedOut={
bodyshop?.messagingservicesid &&
owner?.ownr_ph1 &&
optedOutPhones.has(phone(owner.ownr_ph1, "CA").phoneNumber?.replace(/^\+1/, ""))
}
isPhone2OptedOut={
bodyshop?.messagingservicesid &&
owner?.ownr_ph2 &&
optedOutPhones.has(phone(owner.ownr_ph2, "CA").phoneNumber?.replace(/^\+1/, ""))
}
/>
</Form>
</>
);
}
export default OwnerDetailFormContainer;
export default connect(mapStateToProps)(OwnerDetailFormContainer);

View File

@@ -1,12 +1,12 @@
import { useLazyQuery } from "@apollo/client";
import { Input, Modal } from "antd";
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { QUERY_SEARCH_OWNER_BY_IDX } from "../../graphql/owners.queries";
import AlertComponent from "../alert/alert.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import OwnerFindModalComponent from "./owner-find-modal.component";
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import OwnerFindModalComponent from "./owner-find-modal.component";
export default function OwnerFindModalContainer({
loading,
@@ -41,6 +41,7 @@ export default function OwnerFindModalContainer({
<Modal
title={<span id="owner-find-modal-title">{t("owners.labels.existing_owners")}</span>}
width={"80%"}
okButtonProps={{ id: "owner-find-modal-ok-button" }}
{...modalProps}
>
{loading ? <LoadingSpinner /> : null}

View File

@@ -75,7 +75,7 @@ export function PartsOrderBackorderEta({
);
return (
<Popover destroyTooltipOnHide content={popContent} open={visibility} disabled={disabled}>
<Popover destroyOnHidden content={popContent} open={visibility} disabled={disabled}>
<DateFormatter>{backordered_eta}</DateFormatter>
{isAlreadyBackordered && <CalendarFilled style={{ cursor: "pointer" }} onClick={handlePopover} />}
{loading && <Spin />}

View File

@@ -84,7 +84,7 @@ export function PartsOrderLineBackorderButton({ partsOrderStatus, partsLineId, j
);
return (
<Popover destroyTooltipOnHide content={popContent} open={visibility} disabled={disabled}>
<Popover destroyOnHidden content={popContent} open={visibility} disabled={disabled}>
<Button loading={loading} onClick={handlePopover}>
{isAlreadyBackordered ? t("parts_orders.actions.receive") : t("parts_orders.actions.backordered")}
</Button>

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