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 *.vitest.log
test-output.txt test-output.txt
server/job/test/fixtures 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", "license": "ISC",
"description": "", "description": "",
"dependencies": { "dependencies": {
"express": "^4.21.1", "express": "^5.1.0",
"mailparser": "^3.7.1", "mailparser": "^3.7.2",
"node-fetch": "^3.3.2" "node-fetch": "^3.3.2"
} }
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -2,15 +2,19 @@ import { ApolloProvider } from "@apollo/client";
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react"; import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
import { ConfigProvider } from "antd"; import { ConfigProvider } from "antd";
import enLocale from "antd/es/locale/en_US"; import enLocale from "antd/es/locale/en_US";
import { useEffect } from "react"; import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; 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 GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
import client from "../utils/GraphQLClient"; import client from "../utils/GraphQLClient";
import App from "./App"; import App from "./App";
import * as Sentry from "@sentry/react"; import * as Sentry from "@sentry/react";
import themeProvider from "./themeProvider"; import getTheme from "./themeProvider";
import { CookiesProvider } from "react-cookie"; 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 // Base Split configuration
const config = { const config = {
@@ -24,19 +28,54 @@ const config = {
function SplitClientProvider({ children }) { function SplitClientProvider({ children }) {
const imexshopid = useSelector((state) => state.user.imexshopid); // Access imexshopid from Redux store const imexshopid = useSelector((state) => state.user.imexshopid); // Access imexshopid from Redux store
const splitClient = useSplitClient({ key: imexshopid || "anon" }); // Use imexshopid or fallback to "anon" const splitClient = useSplitClient({ key: imexshopid || "anon" }); // Use imexshopid or fallback to "anon"
useEffect(() => { useEffect(() => {
if (splitClient && imexshopid) { 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}`); console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
} }
}, [splitClient, imexshopid]); }, [splitClient, imexshopid]);
return children; 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 { 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 ( return (
<CookiesProvider> <CookiesProvider>
@@ -44,11 +83,10 @@ function AppContainer() {
<ConfigProvider <ConfigProvider
input={{ autoComplete: "new-password" }} input={{ autoComplete: "new-password" }}
locale={enLocale} locale={enLocale}
theme={themeProvider} theme={theme}
form={{ form={{
validateMessages: { 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"; @import "react-big-calendar/lib/sass/styles";
.ant-menu-item-divider { .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 // Note: Monitor this in dark mode to ensure text visibility
// 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
.ant-menu-submenu-title { .ant-menu-submenu-title {
color: rgba(255, 255, 255, 0.65) !important; color: var(--menu-submenu-text) !important;
} }
.imex-table-header { .imex-table-header {
@@ -46,7 +257,7 @@
} }
.ellipses { .ellipses {
display: inline-block; /* for em, a, span, etc (inline by default) */ display: inline-block;
text-overflow: ellipsis; text-overflow: ellipsis;
width: calc(95%); width: calc(95%);
overflow: hidden; overflow: hidden;
@@ -60,23 +271,24 @@
} }
} }
// ::-webkit-scrollbar-track { // Scrollbar styles (uncomment if needed, updated for dark mode)
// -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3); ::-webkit-scrollbar-track {
// border-radius: 0.2rem; -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
// background-color: #f5f5f5; border-radius: 0.2rem;
// } background-color: var(--table-stripe-bg);
}
// ::-webkit-scrollbar { ::-webkit-scrollbar {
// width: 0.25rem; width: 0.25rem;
// max-height: 0.25rem; max-height: 0.25rem;
// background-color: #f5f5f5; background-color: var(--table-stripe-bg);
// } }
// ::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
// border-radius: 0.2rem; border-radius: 0.2rem;
// -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3); -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
// background-color: #188fff; background-color: var(--alert-color);
// } }
.ant-input-number-input, .ant-input-number-input,
.ant-input-number, .ant-input-number,
@@ -88,28 +300,27 @@
.production-alert { .production-alert {
animation: alertBlinker 1s linear infinite; animation: alertBlinker 1s linear infinite;
color: blue; color: var(--alert-color);
} }
@keyframes alertBlinker { @keyframes alertBlinker {
50% { 50% {
color: red; color: var(--completion-past-color);
opacity: 100; opacity: 100;
//opacity: 0;
} }
} }
.blue { .blue {
color: blue; color: var(--alert-color);
} }
.production-completion-soon { .production-completion-soon {
color: rgba(255, 140, 0, 0.8); color: var(--completion-soon-color);
font-weight: bold; font-weight: bold;
} }
.production-completion-past { .production-completion-past {
color: rgba(255, 0, 0, 0.8); color: var(--completion-past-color);
font-weight: bold; font-weight: bold;
} }
@@ -139,7 +350,7 @@
} }
.react-kanban-column { .react-kanban-column {
background-color: #ddd !important; background-color: var(--kanban-column-bg) !important;
} }
.production-list-table { .production-list-table {
@@ -151,18 +362,18 @@
.ReactGridGallery_tile-icon-bar { .ReactGridGallery_tile-icon-bar {
div { div {
svg { svg {
fill: #1890ff; fill: var(--alert-color);
} }
} }
} }
.job-line-manual { .job-line-manual {
color: tomato; color: var(--job-line-manual-color);
font-style: italic; font-style: italic;
} }
.ant-table-tbody > tr.ant-table-row:nth-child(2n) > td { .ant-table-tbody > tr.ant-table-row:nth-child(2n) > td {
background-color: #f4f4f4; background-color: var(--table-stripe-bg);
} }
.rowWithColor > td { .rowWithColor > td {
@@ -170,15 +381,15 @@
} }
.muted-button { .muted-button {
color: lightgray; color: var(--muted-button-color);
border: none; border: none;
background: none; background: none;
cursor: pointer; cursor: pointer;
font-size: 16px; /* Adjust as needed */ font-size: 16px;
} }
.muted-button:hover { .muted-button:hover {
color: darkgrey; color: var(--muted-button-hover-color);
} }
.notification-alert-unordered-list { .notification-alert-unordered-list {
@@ -190,3 +401,27 @@
margin-right: 0; 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; const { defaultAlgorithm, darkAlgorithm } = theme;
let isDarkMode = false;
/** /**
* Default theme * Default theme
* @type {{components: {Menu: {itemDividerBorderColor: string}}}} * @type {{components: {Menu: {itemDividerBorderColor: string}}}}
*/ */
const defaultTheme = { const defaultTheme = (isDarkMode) => ({
components: { components: {
Table: { Table: {
rowHoverBg: "#e7f3ff", rowHoverBg: isDarkMode ? "#2a2a2a" : "#e7f3ff",
rowSelectedBg: "#e6f7ff", rowSelectedBg: isDarkMode ? "#333333" : "#e6f7ff",
headerSortHoverBg: "transparent" headerSortHoverBg: "transparent"
}, },
Menu: { Menu: {
darkItemHoverBg: "#1890ff", darkItemHoverBg: isDarkMode ? "#004a77" : "#1890ff",
itemHoverBg: "#1890ff", itemHoverBg: isDarkMode ? "#004a77" : "#1890ff",
horizontalItemHoverBg: "#1890ff" horizontalItemHoverBg: isDarkMode ? "#004a77" : "#1890ff"
} }
}, },
token: { token: {
colorPrimary: InstanceRenderMgr({ colorPrimary: InstanceRenderMgr(
imex: "#1890ff", {
rome: "#326ade" imex: isDarkMode ? "#4da8ff" : "#1890ff",
}), rome: isDarkMode ? "#5b8ce6" : "#326ade"
colorInfo: InstanceRenderMgr({ },
imex: "#1890ff", isDarkMode
rome: "#326ade" ),
}) 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 * Development theme
@@ -60,8 +66,9 @@ const prodTheme = {};
const currentTheme = import.meta.env.DEV ? devTheme : prodTheme; const currentTheme = import.meta.env.DEV ? devTheme : prodTheme;
const finaltheme = { const getTheme = (isDarkMode) => ({
algorithm: isDarkMode ? darkAlgorithm : defaultAlgorithm, algorithm: isDarkMode ? darkAlgorithm : defaultAlgorithm,
...defaultsDeep(currentTheme, defaultTheme) ...defaultsDeep(currentTheme, defaultTheme)
}; });
export default finaltheme;
export default getTheme;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -202,8 +202,6 @@ export const registerMessagingHandlers = ({ socket, client }) => {
text: message.text text: message.text
}; };
// Add cases for other known message types as needed
default: default:
// Log a warning for unhandled message types // Log a warning for unhandled message types
logLocal("handleMessageChanged - Unhandled message type", { type: message.type }); 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 const updatedList = existingList?.conversations
? [ ? [newConversation, ...existingList.conversations.filter((conv) => conv.id !== newConversation.id)]
newConversation, : [newConversation]; // Prevent duplicates
...existingList.conversations.filter((conv) => conv.id !== newConversation.id) // Prevent duplicates
]
: [newConversation];
client.cache.writeQuery({ client.cache.writeQuery({
query: CONVERSATION_LIST_QUERY, query: CONVERSATION_LIST_QUERY,
@@ -403,6 +398,7 @@ export const registerMessagingHandlers = ({ socket, client }) => {
} }
break; break;
default: default:
logLocal("handleConversationChanged - Unhandled type", { type }); logLocal("handleConversationChanged - Unhandled type", { type });
client.cache.modify({ 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-summary", handleNewMessageSummary);
socket.on("new-message-detailed", handleNewMessageDetailed); socket.on("new-message-detailed", handleNewMessageDetailed);
socket.on("message-changed", handleMessageChanged); socket.on("message-changed", handleMessageChanged);
socket.on("conversation-changed", handleConversationChanged); socket.on("conversation-changed", handleConversationChanged);
socket.on("phone-number-opted-out", handlePhoneNumberOptedOut);
socket.on("phone-number-opted-in", handlePhoneNumberOptedIn);
}; };
export const unregisterMessagingHandlers = ({ socket }) => { export const unregisterMessagingHandlers = ({ socket }) => {
@@ -431,4 +512,6 @@ export const unregisterMessagingHandlers = ({ socket }) => {
socket.off("new-message-detailed"); socket.off("new-message-detailed");
socket.off("message-changed"); socket.off("message-changed");
socket.off("conversation-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 { Badge, Card, List, Space, Tag, Tooltip } from "antd";
import React, { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Virtuoso } from "react-virtuoso"; import { Virtuoso } from "react-virtuoso";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
@@ -9,44 +9,65 @@ import { TimeAgoFormatter } from "../../utils/DateFormatter";
import PhoneFormatter from "../../utils/PhoneFormatter"; import PhoneFormatter from "../../utils/PhoneFormatter";
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component"; import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import _ from "lodash"; import _ from "lodash";
import { ExclamationCircleOutlined } from "@ant-design/icons";
import "./chat-conversation-list.styles.scss"; 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({ const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation selectedConversation: selectSelectedConversation,
bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setSelectedConversation: (conversationId) => dispatch(setSelectedConversation(conversationId)) setSelectedConversation: (conversationId) => dispatch(setSelectedConversation(conversationId))
}); });
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation }) { function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation, bodyshop }) {
// That comma is there for a reason, do not remove it const { t } = useTranslation();
const [, forceUpdate] = useState(false); 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(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
forceUpdate((prev) => !prev); // Toggle state to trigger re-render forceUpdate((prev) => !prev);
}, 60000); // 1 minute in milliseconds }, 60000);
return () => clearInterval(interval);
return () => clearInterval(interval); // Cleanup on unmount
}, []); }, []);
// Memoize the sorted conversation list const sortedConversationList = useMemo(() => {
const sortedConversationList = React.useMemo(() => {
return _.orderBy(conversationList, ["updated_at"], ["desc"]); return _.orderBy(conversationList, ["updated_at"], ["desc"]);
}, [conversationList]); }, [conversationList]);
const renderConversation = (index) => { const renderConversation = (index, t) => {
const item = sortedConversationList[index]; 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 cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>;
const cardContentLeft = const cardContentLeft =
item.job_conversations.length > 0 item.job_conversations.length > 0
? item.job_conversations.map((j, idx) => <Tag key={idx}>{j.job.ro_number}</Tag>) ? item.job_conversations.map((j, idx) => <Tag key={idx}>{j.job.ro_number}</Tag>)
: null; : null;
const names = <>{_.uniq(item.job_conversations.map((j, idx) => OwnerNameDisplayFunction(j.job)))}</>; const names = <>{_.uniq(item.job_conversations.map((j, idx) => OwnerNameDisplayFunction(j.job)))}</>;
const cardTitle = ( const cardTitle = (
<> <>
{item.label && <Tag color="blue">{item.label}</Tag>} {item.label && <Tag color="blue">{item.label}</Tag>}
@@ -59,13 +80,22 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
)} )}
</> </>
); );
const cardExtra = (
const cardExtra = <Badge count={item.messages_aggregate.aggregate.count} />; <>
<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 = () => const getCardStyle = () =>
item.id === selectedConversation item.id === selectedConversation
? { backgroundColor: "rgba(128, 128, 128, 0.2)" } ? { backgroundColor: "var(--card-selected-bg)" }
: { backgroundColor: index % 2 === 0 ? "#f0f2f5" : "#ffffff" }; : { backgroundColor: index % 2 === 0 ? "var(--card-stripe-even-bg)" : "var(--card-stripe-odd-bg)" };
return ( return (
<List.Item <List.Item
@@ -73,9 +103,25 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
onClick={() => setSelectedConversation(item.id)} onClick={() => setSelectedConversation(item.id)}
className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`} className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`}
> >
<Card style={getCardStyle()} bordered={false} size="small" extra={cardExtra} title={cardTitle}> <Card style={getCardStyle()} variant={true} size="small" extra={cardExtra} title={cardTitle}>
<div style={{ display: "inline-block", width: "70%", textAlign: "left" }}>{cardContentLeft}</div> <div
<div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div> style={{
display: "inline-block",
width: "70%",
textAlign: "left"
}}
>
{cardContentLeft}
</div>
<div
style={{
display: "inline-block",
width: "30%",
textAlign: "right"
}}
>
{cardContentRight}
</div>
</Card> </Card>
</List.Item> </List.Item>
); );
@@ -85,7 +131,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
<div className="chat-list-container"> <div className="chat-list-container">
<Virtuoso <Virtuoso
data={sortedConversationList} data={sortedConversationList}
itemContent={(index) => renderConversation(index)} itemContent={(index) => renderConversation(index, t)}
style={{ height: "100%", width: "100%" }} style={{ height: "100%", width: "100%" }}
/> />
</div> </div>

View File

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

View File

@@ -58,6 +58,7 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
userid userid
created_at created_at
read read
is_system
} }
`, `,
data: message 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 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 JobDocumentsLocalGalleryExternal from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component"; import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import "./chat-media-selector.styles.scss";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) const mapDispatchToProps = (dispatch) => ({});
});
export default connect(mapStateToProps, mapDispatchToProps)(ChatMediaSelector); export default connect(mapStateToProps, mapDispatchToProps)(ChatMediaSelector);
export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, conversation }) { export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, conversation }) {
@@ -37,9 +38,8 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only", nextFetchPolicy: "network-only",
variables: { 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 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 Imageproxy is on, rely only on the LMS selector
//If not on, use the old methods. //If not on, use the old methods.
const content = ( const content = (
<div> <div className="media-selector-content">
{loading && <LoadingSpinner />} {loading && <LoadingSpinner />}
{error && <AlertComponent message={error.message} type="error" />} {error && <AlertComponent message={error.message} type="error" />}
{selectedMedia.filter((s) => s.isSelected).length >= 10 ? ( {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} ) : null}
{Imgproxy.treatment === "on" ? ( {Imgproxy.treatment === "on" ? (
<> <>
{!bodyshop.uselocalmediaserver && ( {!bodyshop.uselocalmediaserver && (
<JobsDocumentImgproxyGalleryExternal <JobsDocumentImgproxyGalleryExternal
jobId={conversation.job_conversations[0].jobid} jobId={conversation.job_conversations[0]?.jobid}
externalMediaState={[selectedMedia, setSelectedMedia]} externalMediaState={[selectedMedia, setSelectedMedia]}
/> />
)} )}
{bodyshop.uselocalmediaserver && open && ( {bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal <JobDocumentsLocalGalleryExternal
externalMediaState={[selectedMedia, setSelectedMedia]} 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 && ( {bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal <JobDocumentsLocalGalleryExternal
externalMediaState={[selectedMedia, setSelectedMedia]} 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 ( return (
<Popover <Popover
content={ 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")} title={t("messaging.labels.selectmedia")}
trigger="click" trigger="click"
open={open} open={open}
onOpenChange={handleVisibleChange} onOpenChange={handleVisibleChange}
classNames={{ root: "media-selector-popover" }}
> >
<Badge count={selectedMedia.filter((s) => s.isSelected).length}> <Badge count={selectedMedia.filter((s) => s.isSelected).length}>
<PictureFilled style={{ margin: "0 .5rem" }} /> <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%; height: 100%;
width: 100%; width: 100%;
} }
.archive-button { .archive-button {
height: 20px; height: 20px;
border-radius: 4px; border-radius: 4px;
} }
.chat-title { .chat-title {
margin-bottom: 5px; margin-bottom: 5px;
} }
.messages { .messages {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -37,17 +40,18 @@
gap: 8px; gap: 8px;
} }
} }
.chat-send-message-button{
.chat-send-message-button {
margin: 0.3rem; margin: 0.3rem;
padding-left: 0.5rem; padding-left: 0.5rem;
} }
.message-icon { .message-icon {
position: absolute; position: absolute;
bottom: 0.1rem; bottom: 0.1rem;
right: 0.3rem; right: 0.3rem;
margin: 0 0.1rem; margin: 0 0.1rem;
color: whitesmoke; color: var(--message-icon-color);
z-index: 5; z-index: 5;
} }
@@ -75,7 +79,7 @@
&:last-child:after { &:last-child:after {
width: 10px; width: 10px;
background: white; background: var(--message-mine-tail-bg);
z-index: 1; z-index: 1;
} }
} }
@@ -87,11 +91,11 @@
.message { .message {
margin-right: 20%; margin-right: 20%;
background-color: #eee; background-color: var(--message-yours-bg);
&:last-child:before { &:last-child:before {
left: -7px; left: -7px;
background: #eee; background: var(--message-yours-bg);
border-bottom-right-radius: 15px; border-bottom-right-radius: 15px;
} }
@@ -107,14 +111,14 @@
align-items: flex-end; align-items: flex-end;
.message { .message {
color: white; color: var(--message-mine-text);
margin-left: 25%; 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; padding-bottom: 0.6rem;
&:last-child:before { &:last-child:before {
right: -8px; 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; 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 { .virtuoso-container {
flex: 1; flex: 1;
overflow: auto; overflow: auto;

View File

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

View File

@@ -1,6 +1,6 @@
import { LoadingOutlined, SendOutlined } from "@ant-design/icons"; import { ExclamationCircleOutlined, LoadingOutlined, SendOutlined } from "@ant-design/icons";
import { Input, Spin } from "antd"; import { Alert, Input, Space, Spin, Tooltip } from "antd";
import React, { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
@@ -10,6 +10,9 @@ import { selectIsSending, selectMessage } from "../../redux/messaging/messaging.
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import ChatMediaSelector from "../chat-media-selector/chat-media-selector.component"; import ChatMediaSelector from "../chat-media-selector/chat-media-selector.component";
import ChatPresetsComponent from "../chat-presets/chat-presets.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({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -25,16 +28,24 @@ const mapDispatchToProps = (dispatch) => ({
function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSending, message, setMessage }) { function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSending, message, setMessage }) {
const inputArea = useRef(null); const inputArea = useRef(null);
const [selectedMedia, setSelectedMedia] = useState([]); 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(() => { useEffect(() => {
inputArea.current.focus(); inputArea.current.focus();
}, [isSending, setMessage]); }, [isSending, setMessage]);
const { t } = useTranslation();
const handleEnter = () => { const handleEnter = () => {
const selectedImages = selectedMedia.filter((i) => i.isSelected); const selectedImages = selectedMedia.filter((i) => i.isSelected);
if ((message === "" || !message) && selectedImages.length === 0) return; if ((message === "" || !message) && selectedImages.length === 0) return;
if (isOptedOut) return; // Prevent sending if phone number is opted out
logImEXEvent("messaging_send_message"); logImEXEvent("messaging_send_message");
if (selectedImages.length < 11) { if (selectedImages.length < 11) {
@@ -44,7 +55,8 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
messagingServiceSid: bodyshop.messagingservicesid, messagingServiceSid: bodyshop.messagingservicesid,
conversationid: conversation.id, conversationid: conversation.id,
selectedMedia: selectedImages, selectedMedia: selectedImages,
imexshopid: bodyshop.imexshopid imexshopid: bodyshop.imexshopid,
bodyshopid: bodyshop.id
}; };
sendMessage(newMessage); sendMessage(newMessage);
setSelectedMedia( setSelectedMedia(
@@ -56,47 +68,67 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
}; };
return ( return (
<div className="imex-flex-row" style={{ width: "100%" }}> <Space direction="vertical" style={{ width: "100%" }} size="middle">
<ChatPresetsComponent className="imex-flex-row__margin" /> {isOptedOut && (
<ChatMediaSelector <Tooltip title={t("consent.text_body")}>
conversation={conversation} <Alert
selectedMedia={selectedMedia} showIcon={true}
setSelectedMedia={setSelectedMedia} icon={<ExclamationCircleOutlined />}
/> message={t("messaging.errors.no_consent")}
<span style={{ flex: 1 }}> type="error"
<Input.TextArea
className="imex-flex-row__margin imex-flex-row__grow"
allowClear
autoFocus
ref={inputArea}
autoSize={{ minRows: 1, maxRows: 4 }}
value={message}
disabled={isSending}
placeholder={t("messaging.labels.typeamessage")}
onChange={(e) => setMessage(e.target.value)}
onPressEnter={(event) => {
event.preventDefault();
if (!!!event.shiftKey) handleEnter();
}}
/>
</span>
<SendOutlined
className="chat-send-message-button"
// disabled={message === "" || !message}
onClick={handleEnter}
/>
<Spin
style={{ display: `${isSending ? "" : "none"}` }}
indicator={
<LoadingOutlined
style={{
fontSize: 24
}}
spin
/> />
} </Tooltip>
/> )}
</div> <div className="imex-flex-row" style={{ width: "100%" }}>
{!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"
allowClear
autoFocus
ref={inputArea}
autoSize={{ minRows: 1, maxRows: 4 }}
value={message}
disabled={isSending || isOptedOut}
placeholder={t("messaging.labels.typeamessage")}
onChange={(e) => setMessage(e.target.value)}
onPressEnter={(event) => {
event.preventDefault();
if (!event.shiftKey && !isOptedOut) handleEnter();
}}
/>
</span>
{!isOptedOut && (
<SendOutlined
className="chat-send-message-button"
disabled={isSending || message === "" || !message}
onClick={handleEnter}
/>
)}
<Spin
style={{ display: `${isSending ? "" : "none"}` }}
indicator={
<LoadingOutlined
style={{
fontSize: 24
}}
spin
/>
}
/>
</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 { Select } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const { Option } = Select; const { Option } = Select;
const ContractStatusComponent = ({ value, onChange }, ref) => { const ContractStatusComponent = ({ value, onChange }) => {
const [option, setOption] = useState(value); const [option, setOption] = useState(value);
const { t } = useTranslation(); const { t } = useTranslation();

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { Card, Table, Tag } from "antd"; import { Card, Table, Tag } from "antd";
import axios from "axios"; import axios from "axios";
import React, { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import dayjs from "../../../utils/day"; import dayjs from "../../../utils/day";
import LoadingSkeleton from "../../loading-skeleton/loading-skeleton.component"; 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) return null;
if (!data.job_lifecycle || !lifecycleData) return <DashboardRefreshRequired {...cardProps} />; 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()}`; 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", borderRadius: "5px",
borderWidth: "5px", borderWidth: "5px",
borderStyle: "solid", borderStyle: "solid",
borderColor: "#f0f2f5", borderColor: "var(--bar-border-color)",
margin: 0, margin: 0,
padding: 0 padding: 0
}} }}
@@ -107,12 +106,10 @@ export default function JobLifecycleDashboardComponent({ data, bodyshop, ...card
alignItems: "center", alignItems: "center",
margin: 0, margin: 0,
padding: 0, padding: 0,
borderTop: "1px solid var(--bar-border-color)",
borderTop: "1px solid #f0f2f5", borderBottom: "1px solid var(--bar-border-color)",
borderBottom: "1px solid #f0f2f5", borderLeft: isFirst ? "1px solid var(--bar-border-color)" : undefined,
borderLeft: isFirst ? "1px solid #f0f2f5" : undefined, borderRight: isLast ? "1px solid var(--bar-border-color)" : undefined,
borderRight: isLast ? "1px solid #f0f2f5" : undefined,
backgroundColor: key.color, backgroundColor: key.color,
width: `${key.percentage}%` width: `${key.percentage}%`
}} }}
@@ -124,7 +121,7 @@ export default function JobLifecycleDashboardComponent({ data, bodyshop, ...card
<div>{key.roundedPercentage}</div> <div>{key.roundedPercentage}</div>
<div <div
style={{ style={{
backgroundColor: "#f0f2f5", backgroundColor: "var(--tag-wrapper-bg)",
borderRadius: "5px", borderRadius: "5px",
paddingRight: "2px", paddingRight: "2px",
paddingLeft: "2px", paddingLeft: "2px",
@@ -152,8 +149,8 @@ export default function JobLifecycleDashboardComponent({ data, bodyshop, ...card
aria-label={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`} aria-label={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
title={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`} title={`${key.status} | ${key.roundedPercentage} | ${key.humanReadable}`}
style={{ style={{
backgroundColor: "#f0f2f5", backgroundColor: "var(--tag-wrapper-bg)",
color: "#000", color: "var(--tag-wrapper-text)",
padding: "4px", padding: "4px",
textAlign: "center" 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 { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined } from "@ant-design/icons";
import { Card, Space, Switch, Table, Tooltip, Typography } from "antd"; import { Card, Space, Switch, Table, Tooltip, Typography } from "antd";
import dayjs from "../../../utils/day"; import { useState } from "react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { TimeFormatter } from "../../../utils/DateFormatter"; import { TimeFormatter } from "../../../utils/DateFormatter";
import { onlyUnique } from "../../../utils/arrayHelper"; import { onlyUnique } from "../../../utils/arrayHelper";
import dayjs from "../../../utils/day";
import { alphaSort, dateSort } from "../../../utils/sorters"; import { alphaSort, dateSort } from "../../../utils/sorters";
import useLocalStorage from "../../../utils/useLocalStorage"; import useLocalStorage from "../../../utils/useLocalStorage";
import ChatOpenButton from "../../chat-open-button/chat-open-button.component"; import ChatOpenButton from "../../chat-open-button/chat-open-button.component";
@@ -169,7 +169,7 @@ export default function DashboardScheduledInToday({ data, ...cardProps }) {
.filter(onlyUnique) .filter(onlyUnique)
.map((s) => { .map((s) => {
return { return {
text: s || "No Alt. Transport", text: s || t("dashboard.errors.atp"),
value: [s] value: [s]
}; };
}) })
@@ -313,7 +313,7 @@ export default function DashboardScheduledInToday({ data, ...cardProps }) {
.filter(onlyUnique) .filter(onlyUnique)
.map((s) => { .map((s) => {
return { return {
text: s || "No Ins. Co.*", text: s || t("dashboard.errors.insco"),
value: [s] value: [s]
}; };
}) })
@@ -335,7 +335,7 @@ export default function DashboardScheduledInToday({ data, ...cardProps }) {
.filter(onlyUnique) .filter(onlyUnique)
.map((s) => { .map((s) => {
return { return {
text: s || "No Alt. Transport", text: s || t("dashboard.errors.atp"),
value: [s] value: [s]
}; };
}) })

View File

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

View File

@@ -1,30 +1,33 @@
import i18next from "i18next"; import i18next from "i18next";
import DashboardTotalProductionDollars from "../dashboard-components/total-production-dollars/total-production-dollars.component.jsx"; import JobLifecycleDashboardComponent, {
import { JobLifecycleDashboardGQL
DashboardTotalProductionHours, } from "../dashboard-components/job-lifecycle/job-lifecycle-dashboard.component.jsx";
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 DashboardMonthlyEmployeeEfficiency, { import DashboardMonthlyEmployeeEfficiency, {
DashboardMonthlyEmployeeEfficiencyGql DashboardMonthlyEmployeeEfficiencyGql
} from "../dashboard-components/monthly-employee-efficiency/monthly-employee-efficiency.component.jsx"; } 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, { import DashboardScheduledInToday, {
DashboardScheduledInTodayGql DashboardScheduledInTodayGql
} from "../dashboard-components/scheduled-in-today/scheduled-in-today.component.jsx"; } from "../dashboard-components/scheduled-in-today/scheduled-in-today.component.jsx";
import DashboardScheduledOutToday, { import DashboardScheduledOutToday, {
DashboardScheduledOutTodayGql DashboardScheduledOutTodayGql
} from "../dashboard-components/scheduled-out-today/scheduled-out-today.component.jsx"; } from "../dashboard-components/scheduled-out-today/scheduled-out-today.component.jsx";
import JobLifecycleDashboardComponent, { import DashboardTotalProductionDollars from "../dashboard-components/total-production-dollars/total-production-dollars.component.jsx";
JobLifecycleDashboardGQL import {
} from "../dashboard-components/job-lifecycle/job-lifecycle-dashboard.component.jsx"; DashboardTotalProductionHours,
DashboardTotalProductionHoursGql
} from "../dashboard-components/total-production-hours/total-production-hours.component.jsx";
const componentList = { const componentList = {
ProductionDollars: { ProductionDollars: {
@@ -118,6 +121,15 @@ const componentList = {
w: 10, w: 10,
h: 3 h: 3
}, },
ScheduleDeliveryToday: {
label: i18next.t("dashboard.titles.scheduleddeliverytoday"),
component: DashboardScheduledDeliveryToday,
gqlFragment: DashboardScheduledDeliveryTodayGql,
minW: 6,
minH: 2,
w: 10,
h: 3
},
JobLifecycle: { JobLifecycle: {
label: i18next.t("dashboard.titles.joblifecycle"), label: i18next.t("dashboard.titles.joblifecycle"),
component: JobLifecycleDashboardComponent, component: JobLifecycleDashboardComponent,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { DatePicker, Space, TimePicker } from "antd"; import { DatePicker, Space, TimePicker } from "antd";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import React, { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
@@ -94,7 +94,24 @@ const DateTimePicker = ({
showTime={false} showTime={false}
format="MM/DD/YYYY" format="MM/DD/YYYY"
value={value ? dayjs(value) : null} 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")} placeholder={t("general.labels.date")}
onBlur={handleBlur} onBlur={handleBlur}
disabledDate={handleDisabledDate} disabledDate={handleDisabledDate}
@@ -105,13 +122,25 @@ const DateTimePicker = ({
<TimePicker <TimePicker
format="hh:mm a" format="hh:mm a"
minuteStep={15} minuteStep={15}
value={value && dayjs(value).hour() === 0 && dayjs(value).minute() === 0 ? null : dayjs(value)}
defaultOpenValue={dayjs(value) defaultOpenValue={dayjs(value)
.hour(dayjs().hour()) .hour(dayjs().hour())
.minute(Math.floor(dayjs().minute() / 15) * 15) .minute(Math.floor(dayjs().minute() / 15) * 15)
.second(0)} .second(0)}
onChange={(value) => { onChange={(timeValue) => {
handleChange(value); if (timeValue) {
onBlur(); // 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")} placeholder={t("general.labels.time")}
{...restProps} {...restProps}

View File

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

View File

@@ -1,11 +1,13 @@
import React, { forwardRef } from "react"; import { forwardRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const PartTypeFormItem = ({ value, onChange }, ref) => { const PartTypeFormItem = ({ value }) => {
const { t } = useTranslation(); const { t } = useTranslation();
if (!value) return null; 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); export default forwardRef(PartTypeFormItem);

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,5 +1,5 @@
import { AlertFilled } from "@ant-design/icons"; 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 { Button, Divider, Dropdown, Form, Input, Popover, Select, Space } from "antd";
import parsePhoneNumber from "libphonenumber-js"; import parsePhoneNumber from "libphonenumber-js";
import queryString from "query-string"; import queryString from "query-string";
@@ -8,32 +8,40 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js"; import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries"; 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 { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { setModalContext } from "../../redux/modals/modals.actions"; import { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import CurrencyFormatter from "../../utils/CurrencyFormatter"; import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateTimeFormatterFunction } from "../../utils/DateFormatter";
import dayjs from "../../utils/day"; import dayjs from "../../utils/day";
import { GenerateDocument } from "../../utils/RenderTemplate"; import { GenerateDocument } from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants"; import { TemplateList } from "../../utils/TemplateConstants";
import ChatOpenButton from "../chat-open-button/chat-open-button.component"; import ChatOpenButton from "../chat-open-button/chat-open-button.component";
import DataLabel from "../data-label/data-label.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 OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
import ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.component"; import ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.component";
import ScheduleManualEvent from "../schedule-manual-event/schedule-manual-event.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 ScheduleAtChange from "./job-at-change.component";
import ScheduleEventColor from "./schedule-event.color.component"; import ScheduleEventColor from "./schedule-event.color.component";
import ScheduleEventNote from "./schedule-event.note.component"; import ScheduleEventNote from "./schedule-event.note.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setScheduleContext: (context) => dispatch(setModalContext({ context: context, modal: "schedule" })), setScheduleContext: (context) => dispatch(setModalContext({ context: context, modal: "schedule" })),
openChatByPhone: (phone) => dispatch(openChatByPhone(phone)), 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({ export function ScheduleEventComponent({
@@ -43,16 +51,40 @@ export function ScheduleEventComponent({
event, event,
refetch, refetch,
handleCancel, handleCancel,
setScheduleContext setScheduleContext,
insertAuditTrail
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const history = useNavigate(); const history = useNavigate();
const searchParams = queryString.parse(useLocation().search); const searchParams = queryString.parse(useLocation().search);
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT); const [updateAppointment] = useMutation(UPDATE_APPOINTMENT);
const [mutationUpdateJob] = useMutation(JOB_PRODUCTION_TOGGLE);
const [title, setTitle] = useState(event.title); const [title, setTitle] = useState(event.title);
const { socket } = useSocket(); const { socket } = useSocket();
const notification = useNotification(); 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 = ( const blockContent = (
<Space direction="vertical" wrap> <Space direction="vertical" wrap>
@@ -82,13 +114,78 @@ export function ScheduleEventComponent({
}); });
}} }}
/> />
<Button onClick={() => handleCancel({ id: event.id })} disabled={event.arrived}> <Button onClick={() => handleCancel({ id: event.id })} disabled={event.arrived}>
{t("appointments.actions.unblock")} {t("appointments.actions.unblock")}
</Button> </Button>
</Space> </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 = ( const popoverContent = (
<div style={{ maxWidth: "40vw" }}> <div style={{ maxWidth: "40vw" }}>
{!event.isintake ? ( {!event.isintake ? (
@@ -109,7 +206,6 @@ export function ScheduleEventComponent({
<ScheduleEventColor event={event} /> <ScheduleEventColor event={event} />
</Space> </Space>
)} )}
{event.job ? ( {event.job ? (
<div> <div>
<DataLabel label={t("jobs.fields.ro_number")}>{(event.job && event.job.ro_number) || ""}</DataLabel> <DataLabel label={t("jobs.fields.ro_number")}>{(event.job && event.job.ro_number) || ""}</DataLabel>
@@ -270,7 +366,6 @@ export function ScheduleEventComponent({
</Button> </Button>
</Popover> </Popover>
)} )}
{event.isintake ? ( {event.isintake ? (
<Button <Button
disabled={event.arrived} disabled={event.arrived}
@@ -284,7 +379,9 @@ export function ScheduleEventComponent({
previousEvent: event.id, previousEvent: event.id,
color: event.color, color: event.color,
alt_transport: event.job && event.job.alt_transport, 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,41 +391,64 @@ export function ScheduleEventComponent({
) : ( ) : (
<ScheduleManualEvent event={event} /> <ScheduleManualEvent event={event} />
)} )}
{event.isintake ? ( {event.job &&
<Link (HasFeatureAccess({ featureName: "checklist", bodyshop }) ? (
to={{ <Link
pathname: `/manage/jobs/${event.job && event.job.id}/intake`, to={{
search: `?appointmentId=${event.id}` pathname: `/manage/jobs/${event.job && event.job.id}/intake`,
}} search: `?appointmentId=${event.id}`
> }}
<Button disabled={event.arrived}>{t("appointments.actions.intake")}</Button> >
</Link> <Button disabled={event.arrived}>{t("appointments.actions.intake")}</Button>
) : null} </Link>
) : (
<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> </Space>
</div> </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 ? ( const RegularEvent = event.isintake ? (
<Space <Space
wrap wrap
size="small" size="small"
style={{ style={{
backgroundColor: event.color && event.color.hex ? event.color.hex : event.color backgroundColor: getEventBackground()
}} }}
> >
{event.note && <AlertFilled className="production-alert" />} {event.note && <AlertFilled className="production-alert" />}
<strong>{`${event.job.ro_number || t("general.labels.na")}`}</strong> <strong>{`${event.job.ro_number || t("general.labels.na")}`}</strong>
<OwnerNameDisplay ownerObject={event.job} /> <OwnerNameDisplay ownerObject={event.job} />
{`${(event.job && event.job.v_model_yr) || ""} ${ {`${(event.job && event.job.v_model_yr) || ""} ${
(event.job && event.job.v_make_desc) || "" (event.job && event.job.v_make_desc) || ""
} ${(event.job && event.job.v_model_desc) || ""}`} } ${(event.job && event.job.v_model_desc) || ""}`}
{`(${(event.job && event.job.labhrs.aggregate.sum.mod_lb_hrs) || "0"} / ${ {`(${(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.larhrs.aggregate.sum.mod_lb_hrs) || "0"
})`} })`}
{event.job && event.job.alt_transport && <div style={{ margin: ".1rem" }}>{event.job.alt_transport}</div>} {event.job && event.job.alt_transport && <div style={{ margin: ".1rem" }}>{event.job.alt_transport}</div>}
{event?.job?.comment && `C: ${event.job.comment}`} {event?.job?.comment && `C: ${event.job.comment}`}
</Space> </Space>
@@ -337,7 +457,7 @@ export function ScheduleEventComponent({
style={{ style={{
height: "100%", height: "100%",
width: "100%", width: "100%",
backgroundColor: event.color && event.color.hex ? event.color.hex : event.color backgroundColor: getEventBackground()
}} }}
> >
<strong>{`${event.title || ""}`}</strong> <strong>{`${event.title || ""}`}</strong>
@@ -353,8 +473,7 @@ export function ScheduleEventComponent({
style={{ style={{
height: "100%", height: "100%",
width: "100%", width: "100%",
backgroundColor: getEventBackground()
backgroundColor: event.color && event.color.hex ? event.color.hex : event.color
}} }}
> >
{RegularEvent} {RegularEvent}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,11 +4,12 @@ import { Col, Row } from "antd";
import Axios from "axios"; import Axios from "axios";
import _ from "lodash"; import _ from "lodash";
import queryString from "query-string"; import queryString from "query-string";
import React, { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
import { import {
DELETE_AVAILABLE_JOB, 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 { GetSupplementDelta } from "./jobs-available-supplement.estlines.util";
import HeaderFields from "./jobs-available-supplement.headerfields"; import HeaderFields from "./jobs-available-supplement.headerfields";
import JobsAvailableTableComponent from "./jobs-available-table.component"; import JobsAvailableTableComponent from "./jobs-available-table.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -195,7 +195,7 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail
await deleteJob({ await deleteJob({
variables: { id: estData.id } variables: { id: estData.id }
}).then((r) => { }).then(() => {
refetch(); refetch();
setInsertLoading(false); setInsertLoading(false);
}); });
@@ -315,7 +315,7 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail
deleteJob({ deleteJob({
variables: { id: estData.id } variables: { id: estData.id }
}).then((r) => { }).then(() => {
refetch(); refetch();
setInsertLoading(false); setInsertLoading(false);
}); });
@@ -372,7 +372,7 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail
loadEstData({ variables: { id: record.id } }); loadEstData({ variables: { id: record.id } });
modalSearchState[1](record.clm_no); modalSearchState[1](record.clm_no);
setJobModalVisible(true); setJobModalVisible(true);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -456,7 +456,7 @@ function replaceEmpty(someObj, replaceValue = null) {
return JSON.parse(temp); return JSON.parse(temp);
} }
async function CheckTaxRatesUSA(estData, bodyshop) { async function CheckTaxRatesUSA(estData) {
if (!estData.parts_tax_rates?.PAM) { if (!estData.parts_tax_rates?.PAM) {
estData.parts_tax_rates.PAM = estData.parts_tax_rates.PAC; 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. //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. //This needs to be done before cleansing unq_seq since some misc prices could move over.
estData.joblines.data.forEach((line) => { estData.joblines.data.forEach((line) => {
@@ -585,6 +585,9 @@ function ResolveCCCLineIssues(estData, bodyshop) {
// line.notes += ` | ET/UT Update (prev = ${line.mod_lbr_ty})`; // line.notes += ` | ET/UT Update (prev = ${line.mod_lbr_ty})`;
line.mod_lbr_ty = "LAR"; 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 { DownCircleFilled } from "@ant-design/icons";
import { useMutation } from "@apollo/client"; import { useMutation } from "@apollo/client";
import { Button, Dropdown } from "antd"; import { Button, Dropdown } from "antd";
import React, { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { UPDATE_JOB_STATUS } from "../../graphql/jobs.queries"; import { UPDATE_JOB_STATUS } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions"; import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectJobReadOnly } from "../../redux/application/application.selectors"; import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings"; import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -24,7 +24,6 @@ export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [availableStatuses, setAvailableStatuses] = useState([]); const [availableStatuses, setAvailableStatuses] = useState([]);
const [otherStages, setOtherStages] = useState([]);
const [mutationUpdateJobstatus] = useMutation(UPDATE_JOB_STATUS); const [mutationUpdateJobstatus] = useMutation(UPDATE_JOB_STATUS);
const notification = useNotification(); const notification = useNotification();
@@ -32,7 +31,7 @@ export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail }) {
mutationUpdateJobstatus({ mutationUpdateJobstatus({
variables: { jobId: job.id, status: status } variables: { jobId: job.id, status: status }
}) })
.then((r) => { .then(() => {
notification["success"]({ message: t("jobs.successes.save") }); notification["success"]({ message: t("jobs.successes.save") });
insertAuditTrail({ insertAuditTrail({
jobid: job.id, jobid: job.id,
@@ -41,7 +40,7 @@ export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail }) {
}); });
// refetch(); // refetch();
}) })
.catch((error) => { .catch(() => {
notification["error"]({ message: t("jobs.errors.saving") }); notification["error"]({ message: t("jobs.errors.saving") });
}); });
}; };
@@ -51,19 +50,14 @@ export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail }) {
if (job && bodyshop) { if (job && bodyshop) {
if (bodyshop.md_ro_statuses.pre_production_statuses.includes(job.status)) { if (bodyshop.md_ro_statuses.pre_production_statuses.includes(job.status)) {
setAvailableStatuses(bodyshop.md_ro_statuses.pre_production_statuses); 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)) { } else if (bodyshop.md_ro_statuses.production_statuses.includes(job.status)) {
setAvailableStatuses(bodyshop.md_ro_statuses.production_statuses); 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)) { } else if (bodyshop.md_ro_statuses.post_production_statuses.includes(job.status)) {
setAvailableStatuses( setAvailableStatuses(
bodyshop.md_ro_statuses.post_production_statuses.filter( bodyshop.md_ro_statuses.post_production_statuses.filter(
(s) => s !== bodyshop.md_ro_statuses.default_invoiced && s !== bodyshop.md_ro_statuses.default_exported (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 { } else {
console.log("Status didn't match any restrictions. Allowing all status changes."); console.log("Status didn't match any restrictions. Allowing all status changes.");
setAvailableStatuses(bodyshop.md_ro_statuses.statuses); setAvailableStatuses(bodyshop.md_ro_statuses.statuses);
@@ -76,16 +70,7 @@ export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail }) {
...availableStatuses.map((item) => ({ ...availableStatuses.map((item) => ({
key: item, key: item,
label: item label: item
})), }))
...(job.converted
? [
{ type: "divider" },
...otherStages.map((item) => ({
key: item,
label: item
}))
]
: [])
], ],
onClick: (e) => updateJobStatus(e.key) 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 { Form, Select, Space, Tooltip } from "antd";
import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
@@ -8,14 +8,13 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import LaborTypeFormItem from "../form-items-formatted/labor-type-form-item.component"; import LaborTypeFormItem from "../form-items-formatted/labor-type-form-item.component";
import PartTypeFormItem from "../form-items-formatted/part-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 ReadOnlyFormItem from "../form-items-formatted/read-only-form-item.component";
import { WarningOutlined } from "@ant-design/icons";
import "./jobs-close-lines.styles.scss"; import "./jobs-close-lines.styles.scss";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
jobRO: selectJobReadOnly jobRO: selectJobReadOnly
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
@@ -24,7 +23,7 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
return ( return (
<div> <div>
<Form.List name={["joblines"]}> <Form.List name={["joblines"]}>
{(fields, { add, remove, move }) => { {(fields) => {
return ( return (
<table className="jobs-close-table"> <table className="jobs-close-table">
<thead> <thead>

View File

@@ -6,7 +6,7 @@
td { td {
padding: 8px; padding: 8px;
text-align: left; text-align: left;
border-bottom: 1px solid #ddd; border-bottom: 1px solid var(--table-border-color);
.ant-form-item { .ant-form-item {
margin-bottom: 0px !important; margin-bottom: 0px !important;
@@ -14,6 +14,6 @@
} }
tr:hover { 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 React, { useContext } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import JobCreateContext from "../../pages/jobs-create/jobs-create.context"; 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"]}> <Form.Item label={t("owners.fields.preferred_contact")} name={["owner", "data", "preferred_contact"]}>
<Input disabled={!state.owner.new} /> <Input disabled={!state.owner.new} />
</Form.Item> </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> </LayoutFormRow>
</div> </div>
); );

View File

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

View File

@@ -72,7 +72,7 @@ export default function JobsCreateVehicleInfoPredefined({ disabled, form }) {
open={open} open={open}
placement="left" placement="left"
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
destroyTooltipOnHide destroyOnHidden
> >
<SearchOutlined style={{ cursor: "pointer" }} /> <SearchOutlined style={{ cursor: "pointer" }} />
</Popover> </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 { Card, Input, Space, Table } from "antd";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { alphaSort } from "../../utils/sorters";
import JobCreateContext from "../../pages/jobs-create/jobs-create.context"; import JobCreateContext from "../../pages/jobs-create/jobs-create.context";
import { alphaSort } from "../../utils/sorters";
import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component"; import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component";
export default function JobsCreateVehicleInfoSearchComponent({ loading, vehicles }) { export default function JobsCreateVehicleInfoSearchComponent({ loading, vehicles }) {
@@ -63,6 +63,7 @@ export default function JobsCreateVehicleInfoSearchComponent({ loading, vehicles
}); });
}} }}
enterButton enterButton
id="search-vehicle"
/> />
</Space> </Space>
} }
@@ -91,9 +92,9 @@ export default function JobsCreateVehicleInfoSearchComponent({ loading, vehicles
type: "radio", type: "radio",
selectedRowKeys: [state.vehicle.selectedid] selectedRowKeys: [state.vehicle.selectedid]
}} }}
onRow={(record, rowIndex) => { onRow={(record) => {
return { return {
onClick: (event) => { onClick: () => {
if (record) { if (record) {
if (record.id) { if (record.id) {
setState({ setState({

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { GET_JOB_BY_PK_QUICK_INTAKE, JOB_PRODUCTION_TOGGLE } from "../../graphql/jobs.queries"; import { GET_JOB_BY_PK_QUICK_INTAKE, JOB_PRODUCTION_TOGGLE } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions"; import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectJobReadOnly } from "../../redux/application/application.selectors"; import { selectJobReadOnly } from "../../redux/application/application.selectors";
@@ -12,7 +13,6 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings"; import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { DateTimeFormatterFunction } from "../../utils/DateFormatter"; import { DateTimeFormatterFunction } from "../../utils/DateFormatter";
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component"; import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx"; import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
@@ -44,9 +44,16 @@ export function JobsDetailHeaderActionsToggleProduction({
variables: { id: job.id }, variables: { id: job.id },
onCompleted: (data) => { onCompleted: (data) => {
if (data?.jobs_by_pk) { 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({ form.setFieldsValue({
actual_in: data.jobs_by_pk.actual_in ? data.jobs_by_pk.actual_in : dayjs(), 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, actual_completion: data.jobs_by_pk.actual_completion,
scheduled_delivery: data.jobs_by_pk.scheduled_delivery, scheduled_delivery: data.jobs_by_pk.scheduled_delivery,
actual_delivery: data.jobs_by_pk.actual_delivery actual_delivery: data.jobs_by_pk.actual_delivery
@@ -160,7 +167,18 @@ export function JobsDetailHeaderActionsToggleProduction({
<FormDateTimePickerComponent disabled={jobRO} /> <FormDateTimePickerComponent disabled={jobRO} />
</Form.Item> </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} /> <FormDateTimePickerComponent disabled={jobRO} />
</Form.Item> </Form.Item>
</> </>

View File

@@ -1,15 +1,21 @@
import { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined, WarningFilled } from "@ant-design/icons"; import { BranchesOutlined, ExclamationCircleFilled, PauseCircleOutlined, WarningFilled } from "@ant-design/icons";
import { Card, Col, Divider, Row, Space, Tag, Tooltip } from "antd"; import { useMutation } from "@apollo/client";
import React, { useState } from "react"; import { Card, Checkbox, Col, Divider, Row, Space, Tag, Tooltip } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect"; 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 { selectJobReadOnly } from "../../redux/application/application.selectors";
import { setModalContext } from "../../redux/modals/modals.actions"; import { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
import CurrencyFormatter from "../../utils/CurrencyFormatter"; 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 PhoneNumberFormatter from "../../utils/PhoneFormatter";
import ChatOpenButton from "../chat-open-button/chat-open-button.component"; import ChatOpenButton from "../chat-open-button/chat-open-button.component";
import DataLabel from "../data-label/data-label.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 JobEmployeeAssignments from "../job-employee-assignments/job-employee-assignments.container";
import JobsRelatedRos from "../jobs-related-ros/jobs-related-ros.component"; import JobsRelatedRos from "../jobs-related-ros/jobs-related-ros.component";
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.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 ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.component";
import ProductionListColumnProductionNote from "../production-list-columns/production-list-columns.productionnote.component"; import ProductionListColumnProductionNote from "../production-list-columns/production-list-columns.productionnote.component";
import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component"; import VehicleVinDisplay from "../vehicle-vin-display/vehicle-vin-display.component";
import "./jobs-detail-header.styles.scss"; import "./jobs-detail-header.styles.scss";
import dayjs from "../../utils/day";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly, jobRO: selectJobReadOnly,
@@ -29,255 +35,325 @@ const mapStateToProps = createStructuredSelector({
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setPrintCenterContext: (context) => dispatch(setModalContext({ context: context, modal: "printCenter" })) setPrintCenterContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "printCenter"
})
),
insertAuditTrail: ({ jobid, operation, type }) =>
dispatch(
insertAuditTrail({
jobid,
operation,
type
})
)
}); });
const colSpan = { const colSpan = {
xs: { xs: { span: 24 },
span: 24 sm: { span: 24 },
}, md: { span: 12 },
sm: { lg: { span: 6 },
span: 24 xl: { span: 6 }
},
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 { t } = useTranslation();
const { notification } = useNotification();
const [notesClamped, setNotesClamped] = useState(true); const [notesClamped, setNotesClamped] = useState(true);
const vehicleTitle = `${job.v_model_yr || ""} ${job.v_color || ""} const [updateJob] = useMutation(UPDATE_JOB);
${job.v_make_desc || ""} const vehicleTitle =
${job.v_model_desc || ""}`.trim(); `${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 bodyHrs = job.joblines.filter((j) => j.mod_lbr_ty !== "LAR").reduce((acc, val) => acc + val.mod_lb_hrs, 0);
const refinishHrs = job.joblines const refinishHrs = job.joblines
.filter((line) => line.mod_lbr_ty === "LAR") .filter((line) => line.mod_lbr_ty === "LAR")
.reduce((acc, val) => acc + val.mod_lb_hrs, 0); .reduce((acc, val) => acc + val.mod_lb_hrs, 0);
const ownerTitle = OwnerNameDisplayFunction(job).trim(); 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 ( return (
<Row gutter={[16, 16]} style={{ alignItems: "stretch" }}> <>
<Col {...colSpan}> <Row gutter={[16, 16]} style={{ alignItems: "stretch" }}>
<Card title={"Job Status"} style={{ height: "100%" }}> <Col {...colSpan}>
<div> <Card title={"Job Status"} style={{ height: "100%" }}>
<DataLabel label={t("jobs.fields.status")}> <div>
<DataLabel label={t("jobs.fields.status")}>
<Space wrap>
{job.status}
{job.inproduction && <Tag color="#f50">{t("jobs.labels.inproduction")}</Tag>}
{job.suspended && <PauseCircleOutlined style={{ color: "orangered" }} />}
{job.iouparent && (
<Link to={`/manage/jobs/${job.iouparent}`}>
<Tooltip title={t("jobs.labels.iou")}>
<BranchesOutlined style={{ color: "orangered" }} />
</Tooltip>
</Link>
)}
{job.production_vars && job.production_vars.alert ? (
<ExclamationCircleFilled className="production-alert" />
) : null}
{job.status === bodyshop.md_ro_statuses.default_scheduled && job.scheduled_in ? (
<Tag>
<Link to={`/manage/schedule?date=${dayjs(job.scheduled_in).format("YYYY-MM-DD")}`}>
<DateTimeFormatter>{job.scheduled_in}</DateTimeFormatter>
</Link>
</Tag>
) : null}
</Space>
</DataLabel>
<DataLabel label={t("jobs.fields.comment")} valueStyle={{ overflow: "hidden", textOverflow: "ellipsis" }}>
<ProductionListColumnComment record={job} />
</DataLabel>
<DataLabel label={t("jobs.fields.ins_co_nm_short")}>{job.ins_co_nm}</DataLabel>
<DataLabel label={t("jobs.fields.clm_no")}>{job.clm_no}</DataLabel>
<DataLabel label={t("jobs.fields.ponumber")} hideIfNull>
{job.po_number}
</DataLabel>
<DataLabel label={t("jobs.fields.repairtotal")}>
<CurrencyFormatter>{job.clm_total}</CurrencyFormatter>
<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} />
</DataLabel>
{job?.cccontracts?.length > 0 && (
<DataLabel label={t("jobs.labels.contracts")}>
{job.cccontracts.map((c, index) => (
<Space key={c.id} wrap>
<Link to={`/manage/courtesycars/contracts/${c.id}`}>
{`${c.agreementnumber} - ${c.courtesycar.fleetnumber} ${c.courtesycar.year} ${c.courtesycar.make} ${c.courtesycar.model}`}
{index !== job.cccontracts.length - 1 ? "," : null}
</Link>
</Space>
))}
</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> <Space wrap>
{job.status} {job.special_coverage_policy && (
{job.inproduction && ( <Tag color="tomato">
<Tag color="#f50" key="production"> <Space>
{t("jobs.labels.inproduction")} <WarningFilled />
<span>{t("jobs.labels.specialcoveragepolicy")}</span>
</Space>
</Tag> </Tag>
)} )}
{job.suspended && <PauseCircleOutlined style={{ color: "orangered" }} />} {job.ca_gst_registrant && (
{job.iouparent && ( <Tag color="geekblue">
<Link to={`/manage/jobs/${job.iouparent}`}> <Space>
<Tooltip title={t("jobs.labels.iou")}> <WarningFilled />
<BranchesOutlined style={{ color: "orangered" }} /> <span>{t("jobs.fields.ca_gst_registrant")}</span>
</Tooltip> </Space>
</Link>
)}
{job.production_vars && job.production_vars.alert ? (
<ExclamationCircleFilled className="production-alert" />
) : null}
{job.status === bodyshop.md_ro_statuses.default_scheduled && job.scheduled_in ? (
<Tag>
<Link to={`/manage/schedule?date=${dayjs(job.scheduled_in).format("YYYY-MM-DD")}`}>
<DateTimeFormatter>{job.scheduled_in}</DateTimeFormatter>
</Link>
</Tag> </Tag>
) : null} )}
{job.hit_and_run && (
<Tag color="green">
<Space>
<WarningFilled />
<span>{t("jobs.fields.hit_and_run")}</span>
</Space>
</Tag>
)}
</Space> </Space>
</DataLabel> </div>
<DataLabel label={t("jobs.fields.comment")} valueStyle={{ overflow: "hidden", textOverflow: "ellipsis" }}> </Card>
<ProductionListColumnComment record={job} /> </Col>
</DataLabel> <Col {...colSpan}>
<DataLabel label={t("jobs.fields.ins_co_nm_short")}>{job.ins_co_nm}</DataLabel> <Card
<DataLabel label={t("jobs.fields.clm_no")}>{job.clm_no}</DataLabel> style={{ height: "100%" }}
<DataLabel label={t("jobs.fields.ponumber")} hideIfNull> title={
{job.po_number}
</DataLabel>
<DataLabel label={t("jobs.fields.repairtotal")}>
<CurrencyFormatter>{job.clm_total}</CurrencyFormatter>
<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} />
</DataLabel>
{job?.cccontracts?.length > 0 && (
<DataLabel label={t("jobs.labels.contracts")}>
{job.cccontracts.map((c, index) => (
<Space key={c.id} wrap>
<Link to={`/manage/courtesycars/contracts/${c.id}`}>
{`${c.agreementnumber} - ${c.courtesycar.fleetnumber} ${c.courtesycar.year} ${c.courtesycar.make} ${c.courtesycar.model}`}
{index !== job.cccontracts.length - 1 ? "," : null}
</Link>
</Space>
))}
</DataLabel>
)}
<DataLabel label={t("jobs.fields.production_vars.note")}>
<ProductionListColumnProductionNote record={job} />
</DataLabel>
<Space wrap>
{job.special_coverage_policy && (
<Tag color="tomato">
<Space>
<WarningFilled />
<span>{t("jobs.labels.specialcoveragepolicy")}</span>
</Space>
</Tag>
)}
{job.ca_gst_registrant && (
<Tag color="geekblue">
<Space>
<WarningFilled />
<span>{t("jobs.fields.ca_gst_registrant")}</span>
</Space>
</Tag>
)}
</Space>
</div>
</Card>
</Col>
<Col {...colSpan}>
<Card
style={{ height: "100%" }}
title={
disabled ? (
<>{ownerTitle.length > 0 ? ownerTitle : t("owner.labels.noownerinfo")}</>
) : (
<Link to={`/manage/owners/${job.owner.id}`}>
{ownerTitle.length > 0 ? ownerTitle : t("owner.labels.noownerinfo")}
</Link>
)
}
>
<div>
<DataLabel key="2" label={t("jobs.fields.ownr_ph1")}>
{disabled ? (
<PhoneNumberFormatter>{job.ownr_ph1}</PhoneNumberFormatter>
) : (
<ChatOpenButton phone={job.ownr_ph1} jobid={job.id} />
)}
</DataLabel>
<DataLabel key="22" label={t("jobs.fields.ownr_ph2")}>
{disabled ? (
<PhoneNumberFormatter>{job.ownr_ph2}</PhoneNumberFormatter>
) : (
<ChatOpenButton phone={job.ownr_ph2} jobid={job.id} />
)}
</DataLabel>
<DataLabel key="3" label={t("owners.fields.address")}>
{`${job.ownr_addr1 || ""} ${job.ownr_addr2 || ""} ${
job.ownr_city || ""
} ${job.ownr_st || ""} ${job.ownr_zip || ""}`}
</DataLabel>
<DataLabel key="4" label={t("owners.fields.ownr_ea")}>
{disabled ? (
<>{job.ownr_ea || ""}</>
) : job.ownr_ea ? (
<a href={`mailto:${job.ownr_ea}`}>{job.ownr_ea}</a>
) : null}
</DataLabel>
{job.owner?.tax_number && (
<DataLabel key="5" label={t("owners.fields.tax_number")}>
{job.owner?.tax_number || ""}
</DataLabel>
)}
<DataLabel label={t("owners.fields.note")} valueStyle={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{job.owner?.note || ""}
</DataLabel>
</div>
</Card>
</Col>
<Col {...colSpan}>
<Card
style={{ height: "100%" }}
title={
job.vehicle ? (
disabled ? ( disabled ? (
<>{vehicleTitle.length > 0 ? vehicleTitle : t("vehicles.labels.novehinfo")} </> <>{ownerTitle.length > 0 ? ownerTitle : t("owner.labels.noownerinfo")}</>
) : ( ) : (
<Link to={job.vehicle && `/manage/vehicles/${job.vehicle.id}`}> <Link to={`/manage/owners/${job.owner.id}`}>
{vehicleTitle.length > 0 ? vehicleTitle : t("vehicles.labels.novehinfo")} {ownerTitle.length > 0 ? ownerTitle : t("owner.labels.noownerinfo")}
</Link> </Link>
) )
) : ( }
<span></span> >
) <div>
} <DataLabel key="2" label={t("jobs.fields.ownr_ph1")}>
> {disabled ? (
<div> <PhoneNumberFormatter>{job.ownr_ph1}</PhoneNumberFormatter>
<DataLabel key="2" label={t("vehicles.fields.plate_no")}> ) : (
{`${job.plate_no || t("general.labels.na")} (${`${job.plate_st || t("general.labels.na")}`})`} <ChatOpenButton phone={job.ownr_ph1} jobid={job.id} />
</DataLabel> )}
<DataLabel key="4" label={t("vehicles.fields.v_vin")}>
<VehicleVinDisplay>{`${job.v_vin || t("general.labels.na")}`}</VehicleVinDisplay>
{bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid ? (
job.v_vin?.length !== 17 ? (
<WarningFilled style={{ color: "tomato", marginLeft: ".3rem" }} />
) : null
) : null}
</DataLabel>
<DataLabel label={t("jobs.fields.regie_number")}>{job.regie_number || t("general.labels.na")}</DataLabel>
<DataLabel label={t("jobs.labels.relatedros")}>
<JobsRelatedRos jobid={job.id} job={job} disabled={disabled} />
</DataLabel>
{job.vehicle && job.vehicle.notes && (
<DataLabel
label={t("vehicles.fields.notes")}
valueStyle={{ whiteSpace: "pre-wrap" }}
valueClassName={notesClamped ? "clamp" : ""}
onValueClick={() => setNotesClamped(!notesClamped)}
>
{job.vehicle.notes}
</DataLabel> </DataLabel>
)} <DataLabel key="22" label={t("jobs.fields.ownr_ph2")}>
{job.vehicle && job.vehicle.v_paint_codes && ( {disabled ? (
<DataLabel label={t("vehicles.fields.v_paint_codes", { number: "" })}> <PhoneNumberFormatter>{job.ownr_ph2}</PhoneNumberFormatter>
<span style={{ whiteSpace: "pre" }}> ) : (
{Object.keys(job.vehicle.v_paint_codes) <ChatOpenButton phone={job.ownr_ph2} jobid={job.id} />
.filter( )}
(key) =>
job.vehicle.v_paint_codes[key] !== "" &&
job.vehicle.v_paint_codes[key] !== null &&
job.vehicle.v_paint_codes[key] !== undefined
)
.map((key, idx) => (
<Tag key={idx}>{job.vehicle.v_paint_codes[key]}</Tag>
))}
</span>
</DataLabel> </DataLabel>
)} <DataLabel key="3" label={t("owners.fields.address")}>
</div> {`${job.ownr_addr1 || ""} ${job.ownr_addr2 || ""} ${
</Card> job.ownr_city || ""
</Col> } ${job.ownr_st || ""} ${job.ownr_zip || ""}`}
<Col {...colSpan}> </DataLabel>
<Card style={{ height: "100%" }} title={t("jobs.labels.employeeassignments")}> <DataLabel key="4" label={t("owners.fields.ownr_ea")}>
<div> {disabled ? (
<JobEmployeeAssignments job={job} /> <>{job.ownr_ea || ""}</>
<Divider style={{ margin: ".5rem" }} /> ) : job.ownr_ea ? (
<DataLabel label={t("jobs.labels.labor_hrs")}> <a href={`mailto:${job.ownr_ea}`}>{job.ownr_ea}</a>
{bodyHrs.toFixed(1)} / {refinishHrs.toFixed(1)} / {(bodyHrs + refinishHrs).toFixed(1)} ) : null}
</DataLabel> </DataLabel>
</div> {job.owner?.tax_number && (
</Card> <DataLabel key="5" label={t("owners.fields.tax_number")}>
</Col> {job.owner?.tax_number || ""}
</Row> </DataLabel>
)}
<DataLabel label={t("owners.fields.note")} valueStyle={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{job.owner?.note || ""}
</DataLabel>
</div>
</Card>
</Col>
<Col {...colSpan}>
<Card
style={{ height: "100%" }}
title={
job.vehicle ? (
disabled ? (
<>{vehicleTitle.length > 0 ? vehicleTitle : t("vehicles.labels.novehinfo")} </>
) : (
<Link to={job.vehicle && `/manage/vehicles/${job.vehicle.id}`}>
{vehicleTitle.length > 0 ? vehicleTitle : t("vehicles.labels.novehinfo")}
</Link>
)
) : (
<span></span>
)
}
>
<div>
<DataLabel key="2" label={t("vehicles.fields.plate_no")}>
{`${job.plate_no || t("general.labels.na")} (${`${job.plate_st || t("general.labels.na")}`})`}
</DataLabel>
<DataLabel key="4" label={t("vehicles.fields.v_vin")}>
<VehicleVinDisplay>{`${job.v_vin || t("general.labels.na")}`}</VehicleVinDisplay>
{bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid ? (
job.v_vin?.length !== 17 ? (
<WarningFilled style={{ color: "tomato", marginLeft: ".3rem" }} />
) : null
) : null}
</DataLabel>
<DataLabel label={t("jobs.fields.regie_number")}>{job.regie_number || t("general.labels.na")}</DataLabel>
<DataLabel label={t("jobs.labels.relatedros")}>
<JobsRelatedRos jobid={job.id} job={job} disabled={disabled} />
</DataLabel>
{job.vehicle && job.vehicle.notes && (
<DataLabel
label={t("vehicles.fields.notes")}
valueStyle={{ whiteSpace: "pre-wrap" }}
valueClassName={notesClamped ? "clamp" : ""}
onValueClick={() => setNotesClamped(!notesClamped)}
>
{job.vehicle.notes}
</DataLabel>
)}
{job.vehicle && job.vehicle.v_paint_codes && (
<DataLabel label={t("vehicles.fields.v_paint_codes", { number: "" })}>
<span style={{ whiteSpace: "pre" }}>
{Object.keys(job.vehicle.v_paint_codes)
.filter(
(key) =>
job.vehicle.v_paint_codes[key] !== "" &&
job.vehicle.v_paint_codes[key] !== null &&
job.vehicle.v_paint_codes[key] !== undefined
)
.map((key, idx) => (
<Tag key={idx}>{job.vehicle.v_paint_codes[key]}</Tag>
))}
</span>
</DataLabel>
)}
</div>
</Card>
</Col>
<Col {...colSpan}>
<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" }} />
<DataLabel label={t("jobs.labels.labor_hrs")}>
{bodyHrs.toFixed(1)} / {refinishHrs.toFixed(1)} / {(bodyHrs + refinishHrs).toFixed(1)}
</DataLabel>
</div>
</Card>
</Col>
</Row>
<PinnedJobNotes job={job} />
</>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,12 @@
import { useMutation } from "@apollo/client"; import { useMutation } from "@apollo/client";
import { Form, Modal } from "antd"; import { Form, Modal } from "antd";
import React, { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { logImEXEvent } from "../../firebase/firebase.utils"; 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 { INSERT_NEW_NOTE, UPDATE_NOTE } from "../../graphql/notes.queries";
import { insertAuditTrail } from "../../redux/application/application.actions"; import { insertAuditTrail } from "../../redux/application/application.actions";
import { toggleModalVisible } from "../../redux/modals/modals.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 { selectCurrentUser } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings"; import AuditTrailMapping from "../../utils/AuditTrailMappings";
import NoteUpsertModalComponent from "./note-upsert-modal.component"; import NoteUpsertModalComponent from "./note-upsert-modal.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser, currentUser: selectCurrentUser,
@@ -20,7 +21,14 @@ const mapStateToProps = createStructuredSelector({
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("noteUpsert")), toggleModalVisible: () => dispatch(toggleModalVisible("noteUpsert")),
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type })) insertAuditTrail: ({ jobid, operation, type }) =>
dispatch(
insertAuditTrail({
jobid,
operation,
type
})
)
}); });
export function NoteUpsertModalContainer({ currentUser, noteUpsertModal, toggleModalVisible, insertAuditTrail }) { export function NoteUpsertModalContainer({ currentUser, noteUpsertModal, toggleModalVisible, insertAuditTrail }) {
@@ -58,8 +66,9 @@ export function NoteUpsertModalContainer({ currentUser, noteUpsertModal, toggleM
variables: { variables: {
noteId: existingNote.id, noteId: existingNote.id,
note: values note: values
} },
}).then((r) => { refetchQueries: ["GET_JOB_BY_PK", "QUERY_JOB_CARD_DETAILS", "QUERY_PARTS_QUEUE_CARD_DETAILS"]
}).then(() => {
notification["success"]({ notification["success"]({
message: t("notes.successes.updated") message: t("notes.successes.updated")
}); });
@@ -79,6 +88,33 @@ export function NoteUpsertModalContainer({ currentUser, noteUpsertModal, toggleM
variables: { variables: {
noteInput: [{ ...values, jobid: jobId, created_by: currentUser.email }] 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"] refetchQueries: ["QUERY_NOTES_BY_JOB_PK"]
}); });
@@ -123,7 +159,7 @@ export function NoteUpsertModalContainer({ currentUser, noteUpsertModal, toggleM
onCancel={() => { onCancel={() => {
toggleModalVisible(); toggleModalVisible();
}} }}
destroyOnClose destroyOnHidden
> >
<Form form={form} onFinish={handleFinish} layout="vertical"> <Form form={form} onFinish={handleFinish} layout="vertical">
<NoteUpsertModalComponent form={form} /> <NoteUpsertModalComponent form={form} />

View File

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

View File

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

View File

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

View File

@@ -1,32 +1,41 @@
import { useMutation, useQuery } from "@apollo/client"; import { useMutation, useQuery } from "@apollo/client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button, Card, Checkbox, Form, Space, Table } from "antd"; import { Alert, Button, Card, Checkbox, Divider, Form, Space, Switch, Table, Typography } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectCurrentUser } from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import { 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 { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx"; import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx"; import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
/** /**
* Notifications Settings Form * Notifications Settings Form
* @param currentUser * @param currentUser
* @param bodyshop
* @returns {JSX.Element} * @returns {JSX.Element}
* @constructor * @constructor
*/ */
const NotificationSettingsForm = ({ currentUser }) => { const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [initialValues, setInitialValues] = useState({}); const [initialValues, setInitialValues] = useState({});
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const [autoAddEnabled, setAutoAddEnabled] = useState(false);
const [initialAutoAdd, setInitialAutoAdd] = useState(false);
const notification = useNotification(); 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, { const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, {
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only", nextFetchPolicy: "network-only",
@@ -34,13 +43,16 @@ const NotificationSettingsForm = ({ currentUser }) => {
skip: !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(() => { useEffect(() => {
if (data?.associations?.length > 0) { if (data?.associations?.length > 0) {
const settings = data.associations[0].notification_settings || {}; 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) => { const formattedValues = notificationScenarios.reduce((acc, scenario) => {
acc[scenario] = settings[scenario] ?? { app: false, email: false, fcm: false }; acc[scenario] = settings[scenario] ?? { app: false, email: false, fcm: false };
return acc; return acc;
@@ -48,32 +60,66 @@ const NotificationSettingsForm = ({ currentUser }) => {
setInitialValues(formattedValues); setInitialValues(formattedValues);
form.setFieldsValue(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]); }, [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) => { const handleSave = async (values) => {
if (data?.associations?.length > 0) { if (data?.associations?.length > 0) {
const userId = data.associations[0].id; const userId = data.associations[0].id;
// Save the updated notification settings. try {
const result = await updateNotificationSettings({ variables: { id: userId, ns: values } }); const result = await updateNotificationSettings({ variables: { id: userId, ns: values } });
if (!result?.errors) { if (!result?.errors) {
notification.success({ message: t("notifications.labels.notification-settings-success") }); notification.success({ message: t("notifications.labels.notification-settings-success") });
setInitialValues(values); setInitialValues(values);
setIsDirty(false); setIsDirty(false);
} else { } else {
throw new Error("Failed to update notification settings");
}
} catch (err) {
notification.error({ message: t("notifications.labels.notification-settings-failure") }); 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 = () => { const handleFormChange = () => {
setIsDirty(true); setIsDirty(true);
}; };
// Check if auto-add has changed
const isAutoAddDirty = autoAddEnabled !== initialAutoAdd;
// Handle reset of form and auto-add
const handleReset = () => { const handleReset = () => {
form.setFieldsValue(initialValues); form.setFieldsValue(initialValues);
setAutoAddEnabled(initialAutoAdd);
setIsDirty(false); setIsDirty(false);
}; };
@@ -139,17 +185,30 @@ const NotificationSettingsForm = ({ currentUser }) => {
title={t("notifications.labels.notificationscenarios")} title={t("notifications.labels.notificationscenarios")}
extra={ extra={
<Space> <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")} {t("general.actions.clear")}
</Button> </Button>
<Button type="primary" htmlType="submit" disabled={!isDirty} loading={savingSettings}>
<Button type="primary" htmlType="submit" disabled={!isDirty} loading={saving}>
{t("notifications.labels.save")} {t("notifications.labels.save")}
</Button> </Button>
</Space> </Space>
} }
> >
{!isEmployee && (
<div style={{ width: "100%", marginBottom: "10px" }}>
<Alert message={t("notifications.labels.employee-notification")} type="warning" />
</div>
)}
<Table dataSource={dataSource} columns={columns} pagination={false} bordered rowKey="key" /> <Table dataSource={dataSource} columns={columns} pagination={false} bordered rowKey="key" />
<Divider />
</Card> </Card>
</Form> </Form>
); );
@@ -158,11 +217,13 @@ const NotificationSettingsForm = ({ currentUser }) => {
NotificationSettingsForm.propTypes = { NotificationSettingsForm.propTypes = {
currentUser: PropTypes.shape({ currentUser: PropTypes.shape({
email: PropTypes.string.isRequired email: PropTypes.string.isRequired
}).isRequired }).isRequired,
bodyshop: PropTypes.object.isRequired
}; };
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser currentUser: selectCurrentUser,
bodyshop: selectBodyshop
}); });
export default connect(mapStateToProps)(NotificationSettingsForm); export default connect(mapStateToProps)(NotificationSettingsForm);

View File

@@ -1,14 +1,15 @@
import { Form, Input, Switch } from "antd"; import { Form, Input, Tooltip } from "antd";
import React from "react"; import { CloseCircleFilled } from "@ant-design/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.component"; import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
import FormItemEmail from "../form-items-formatted/email-form-item.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 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 { t } = useTranslation();
const { getFieldValue } = form; const { getFieldValue } = form;
return ( return (
<div> <div>
<FormFieldsChanged form={form} /> <FormFieldsChanged form={form} />
@@ -26,7 +27,7 @@ export default function OwnerDetailFormComponent({ form, loading }) {
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item label={t("owners.fields.accountingid")} name="accountingid"> <Form.Item label={t("owners.fields.accountingid")} name="accountingid">
<Input disabled/> <Input disabled />
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow header={t("owners.forms.address")}> <LayoutFormRow header={t("owners.forms.address")}>
@@ -50,9 +51,6 @@ export default function OwnerDetailFormComponent({ form, loading }) {
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow header={t("owners.forms.contact")}> <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 <Form.Item
label={t("owners.fields.ownr_ea")} label={t("owners.fields.ownr_ea")}
name="ownr_ea" name="ownr_ea"
@@ -65,19 +63,55 @@ export default function OwnerDetailFormComponent({ form, loading }) {
> >
<FormItemEmail email={getFieldValue("ownr_ea")} /> <FormItemEmail email={getFieldValue("ownr_ea")} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item label={t("owners.fields.ownr_ph1")} style={{ marginBottom: 0 }}>
label={t("owners.fields.ownr_ph1")} <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
name="ownr_ph1" <Form.Item
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ownr_ph1")]} name="ownr_ph1"
> noStyle
<FormItemPhone /> rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ownr_ph1")]}
>
<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>
<Form.Item <Form.Item label={t("owners.fields.ownr_ph2")} style={{ marginBottom: 0 }}>
label={t("owners.fields.ownr_ph2")} <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
name="ownr_ph2" <Form.Item
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ownr_ph2")]} name="ownr_ph2"
> noStyle
<FormItemPhone /> rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ownr_ph2")]}
>
<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>
<Form.Item label={t("owners.fields.preferred_contact")} name="preferred_contact"> <Form.Item label={t("owners.fields.preferred_contact")} name="preferred_contact">
<Input /> <Input />

View File

@@ -1,69 +1,115 @@
import { Button, Form, Popconfirm } from "antd"; import { Button, Form, Popconfirm } from "antd";
import { PageHeader } from "@ant-design/pro-layout"; import { PageHeader } from "@ant-design/pro-layout";
import { useEffect, useState } from "react";
import React, { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useMutation } from "@apollo/client"; import { useApolloClient, useMutation } from "@apollo/client";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { DELETE_OWNER, UPDATE_OWNER } from "../../graphql/owners.queries"; 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 OwnerDetailFormComponent from "./owner-detail-form.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; 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 { t } = useTranslation();
const [form] = Form.useForm(); const [form] = Form.useForm();
const history = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [optedOutPhones, setOptedOutPhones] = useState(new Set());
const [updateOwner] = useMutation(UPDATE_OWNER); const [updateOwner] = useMutation(UPDATE_OWNER);
const [deleteOwner] = useMutation(DELETE_OWNER); const [deleteOwner] = useMutation(DELETE_OWNER);
const notification = useNotification(); 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 () => { const handleDelete = async () => {
setLoading(true); setLoading(true);
const result = await deleteOwner({ try {
variables: { id: owner.id } const result = await deleteOwner({
}); variables: { id: owner.id }
console.log(result); });
if (result.errors) { if (result.errors) {
notification["error"]({ notification.error({
message: t("owners.errors.deleting", {
error: JSON.stringify(result.errors)
})
});
} else {
notification.success({
message: t("owners.successes.delete")
});
navigate(`/manage/owners`);
}
} catch (error) {
notification.error({
message: t("owners.errors.deleting", { message: t("owners.errors.deleting", {
error: JSON.stringify(result.errors) error: error.message
}) })
}); });
} finally {
setLoading(false); setLoading(false);
} else {
notification["success"]({
message: t("owners.successes.delete")
});
setLoading(false);
history(`/manage/owners`);
} }
}; };
const handleFinish = async (values) => { const handleFinish = async (values) => {
setLoading(true); setLoading(true);
const result = await updateOwner({ try {
variables: { ownerId: owner.id, owner: values } const result = await updateOwner({
}); variables: { ownerId: owner.id, owner: values }
});
if (!!result.errors) { if (result.errors) {
notification["error"]({ notification.error({
message: t("owners.errors.saving", {
error: JSON.stringify(result.errors)
})
});
} else {
notification.success({
message: t("owners.successes.save")
});
if (refetch) await refetch();
form.resetFields();
}
} catch (error) {
notification.error({
message: t("owners.errors.saving", { message: t("owners.errors.saving", {
error: JSON.stringify(result.errors) error: error.message
}) })
}); });
} finally {
setLoading(false); setLoading(false);
return;
} }
notification["success"]({
message: t("owners.successes.save")
});
if (refetch) await refetch();
form.resetFields();
form.resetFields();
setLoading(false);
}; };
return ( return (
@@ -72,6 +118,7 @@ function OwnerDetailFormContainer({ owner, refetch }) {
title={t("menus.header.owners")} title={t("menus.header.owners")}
extra={[ extra={[
<Popconfirm <Popconfirm
key="delete"
trigger="click" trigger="click"
onConfirm={handleDelete} onConfirm={handleDelete}
disabled={owner.jobs.length !== 0} disabled={owner.jobs.length !== 0}
@@ -81,16 +128,29 @@ function OwnerDetailFormContainer({ owner, refetch }) {
{t("general.actions.delete")} {t("general.actions.delete")}
</Button> </Button>
</Popconfirm>, </Popconfirm>,
<Button type="primary" loading={loading} onClick={() => form.submit()}> <Button key="save" type="primary" loading={loading} onClick={() => form.submit()}>
{t("general.actions.save")} {t("general.actions.save")}
</Button> </Button>
]} ]}
/> />
<Form form={form} onFinish={handleFinish} autoComplete="off" layout="vertical" initialValues={owner}> <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> </Form>
</> </>
); );
} }
export default OwnerDetailFormContainer; export default connect(mapStateToProps)(OwnerDetailFormContainer);

View File

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

View File

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

View File

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

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