Compare commits
246 Commits
feature/IO
...
test-AIO
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
045f36e294 | ||
|
|
c7c6dfcd7d | ||
|
|
c1c0b35c8f | ||
|
|
c024fdd57b | ||
|
|
a4ccacf83a | ||
|
|
aa3b303fe9 | ||
|
|
fdaf50d778 | ||
|
|
468ed23f73 | ||
|
|
322ebd3bc7 | ||
|
|
b887cfed01 | ||
|
|
0f800c5a4c | ||
|
|
6cce92b0fd | ||
|
|
60ab04cb38 | ||
|
|
345a470731 | ||
|
|
0025e113c6 | ||
|
|
dc435b2bb0 | ||
|
|
fd72d244e7 | ||
|
|
87bb472271 | ||
|
|
825959880e | ||
|
|
c40fea0ec9 | ||
|
|
ebdf427b58 | ||
|
|
b3fdd68276 | ||
|
|
30e5027c8c | ||
|
|
3e63c58b9b | ||
|
|
938cef1f6b | ||
|
|
7e2df3e341 | ||
|
|
45d095a7a3 | ||
|
|
709b6ef1d6 | ||
|
|
4e98df6694 | ||
|
|
b920bb4437 | ||
|
|
e36a110e81 | ||
|
|
719d1b6479 | ||
|
|
29ded5efbf | ||
|
|
551e0f0592 | ||
|
|
4d299bb226 | ||
|
|
ae9b68a0bc | ||
|
|
cf8df89e30 | ||
|
|
bfd6cc83af | ||
|
|
99b65e8186 | ||
|
|
f8fd2ee64c | ||
|
|
8240ea9a64 | ||
|
|
ebde2f1581 | ||
|
|
85b3b88538 | ||
|
|
426283ffee | ||
|
|
a45808eb94 | ||
|
|
a2389b1f26 | ||
|
|
4fc86ccaa3 | ||
|
|
519997a8be | ||
|
|
ab606a4266 | ||
|
|
c4c36b7fd0 | ||
|
|
deb2fc28ce | ||
|
|
a67946c5a3 | ||
|
|
da317704c4 | ||
|
|
771573409f | ||
|
|
e43923b7a0 | ||
|
|
cb9ccb7e77 | ||
|
|
a5d00d562c | ||
|
|
bdeeea0406 | ||
|
|
297d8afa8a | ||
|
|
3a12597c45 | ||
|
|
72c96f14eb | ||
|
|
de9d47272c | ||
|
|
3fd51f0140 | ||
|
|
e9ef429729 | ||
|
|
84ec68f142 | ||
|
|
22af37e8f1 | ||
|
|
86affddc24 | ||
|
|
db01ad9155 | ||
|
|
57fdffff09 | ||
|
|
e74be56681 | ||
|
|
f5d33a2386 | ||
|
|
edc9ba33c5 | ||
|
|
4586f32f38 | ||
|
|
8bf7fbd1f1 | ||
|
|
281e50a43e | ||
|
|
7a50f2a2fe | ||
|
|
0c83f796db | ||
|
|
c37037ef21 | ||
|
|
237c575bab | ||
|
|
a54e74a27d | ||
|
|
87797c7743 | ||
|
|
d227cacd68 | ||
|
|
6050aebcd5 | ||
|
|
ef4565d738 | ||
|
|
74eeceacca | ||
|
|
77d0f5ab38 | ||
|
|
6e566e2f8a | ||
|
|
80697a5259 | ||
|
|
a0692f8c69 | ||
|
|
fe8d1f7e95 | ||
|
|
32f3143dca | ||
|
|
4f76aeb06f | ||
|
|
0ba207a499 | ||
|
|
f849ea9d0a | ||
|
|
302a42089f | ||
|
|
e0b113e5d0 | ||
|
|
906265c4b2 | ||
|
|
fc199279d1 | ||
|
|
fcba77fe20 | ||
|
|
f294eafde7 | ||
|
|
388b042037 | ||
|
|
e0f55b8e7a | ||
|
|
ef6aee0518 | ||
|
|
d5e643b429 | ||
|
|
88ae1fb1cc | ||
|
|
c6af2b34b2 | ||
|
|
73eb76a230 | ||
|
|
d51dcc0ef2 | ||
|
|
e6178a613d | ||
|
|
d5e9b79f75 | ||
|
|
2a69115903 | ||
|
|
c8262da440 | ||
|
|
56d0c009e2 | ||
|
|
1f41a532e2 | ||
|
|
32e67b14b6 | ||
|
|
79030f6b36 | ||
|
|
d901004751 | ||
|
|
661e019a4d | ||
|
|
82021c1edc | ||
|
|
a6156a70c1 | ||
|
|
0014a5335d | ||
|
|
5e78cdd8ae | ||
|
|
cd054fcf33 | ||
|
|
5ab54433ff | ||
|
|
62c053ed87 | ||
|
|
6242e0f309 | ||
|
|
614420d7d2 | ||
|
|
3113818a91 | ||
|
|
92a3e57205 | ||
|
|
de6038038a | ||
|
|
8f4ac866f1 | ||
|
|
8a043767cd | ||
|
|
1f8836d9d8 | ||
|
|
6ca0ebff5f | ||
|
|
a96a1139fa | ||
|
|
483da283dc | ||
|
|
a267d65425 | ||
|
|
9ad2a53bec | ||
|
|
9267e584ff | ||
|
|
cacda3805a | ||
|
|
6590f8961b | ||
|
|
69861af88c | ||
|
|
d7294ebba6 | ||
|
|
7df71b8f44 | ||
|
|
d9270102b1 | ||
|
|
af757ee71e | ||
|
|
d416780e63 | ||
|
|
b6cbfb8e45 | ||
|
|
9c97b30e8e | ||
|
|
cc48448a07 | ||
|
|
eb666f2ca1 | ||
|
|
4776b03a21 | ||
|
|
d991e32501 | ||
|
|
2b8990950b | ||
|
|
3f2e05befc | ||
|
|
06bfdeb449 | ||
|
|
20943f74e9 | ||
|
|
66df286ddb | ||
|
|
1b2f9fc027 | ||
|
|
4af312854e | ||
|
|
1287c7ec36 | ||
|
|
fb29fa2caa | ||
|
|
6bda497d8c | ||
|
|
ff084f6fb8 | ||
|
|
a018b6dc5a | ||
|
|
8a4679f86c | ||
|
|
5c9e4517a6 | ||
|
|
4d558da46a | ||
|
|
190217ffce | ||
|
|
90789e743f | ||
|
|
a4dbc5250e | ||
|
|
704543d823 | ||
|
|
28dc1d4533 | ||
|
|
d8924d6cf3 | ||
|
|
a1d0e2df93 | ||
|
|
a97e03e0b1 | ||
|
|
9a86a337bb | ||
|
|
fe848b5de4 | ||
|
|
a287601f27 | ||
|
|
7688f22161 | ||
|
|
2cc6774334 | ||
|
|
e30353cab6 | ||
|
|
efdcd06921 | ||
|
|
d2dd276ce7 | ||
|
|
c0a37d7c1a | ||
|
|
6947ad54a7 | ||
|
|
6759bc5865 | ||
|
|
db52bf0e94 | ||
|
|
c9b9f67170 | ||
|
|
04732fc6cd | ||
|
|
5d95275c0b | ||
|
|
a65a34ef1f | ||
|
|
1ea7798eeb | ||
|
|
7739d48741 | ||
|
|
969dd8be8d | ||
|
|
794f64dfba | ||
|
|
ed0693fc5b | ||
|
|
4a47f543b2 | ||
|
|
074be66b8c | ||
|
|
8db8744782 | ||
|
|
c2d8d78e0a | ||
|
|
e9e189d032 | ||
|
|
3b60aa89f1 | ||
|
|
71aec6d0c5 | ||
|
|
f89d7865fa | ||
|
|
8fd368ebb4 | ||
|
|
132fc0a20f | ||
|
|
0b470e3c31 | ||
|
|
220b1c7968 | ||
|
|
7dab60e3bc | ||
|
|
ab8b44bee4 | ||
|
|
20d2572087 | ||
|
|
9ea2d83043 | ||
|
|
abad7d5f00 | ||
|
|
d4c7298334 | ||
|
|
d497ec9f7d | ||
|
|
e49500887d | ||
|
|
b8246e03c1 | ||
|
|
3aa19ec09f | ||
|
|
c97213bc96 | ||
|
|
866e9581c2 | ||
|
|
1102670e66 | ||
|
|
591439b79c | ||
|
|
2de605e520 | ||
|
|
2690e09626 | ||
|
|
dd306e1a7b | ||
|
|
ac4c09af60 | ||
|
|
fd712da4a3 | ||
|
|
bcb693f03c | ||
|
|
c33a3118bc | ||
|
|
d23a182650 | ||
|
|
e17b57c705 | ||
|
|
6a60af9dfe | ||
|
|
dfb6f02864 | ||
|
|
48bb494e0f | ||
|
|
9b74cba56b | ||
|
|
6fc8124268 | ||
|
|
4abc1a7d0f | ||
|
|
255d761210 | ||
|
|
2a5e5d2462 | ||
|
|
6ef56f97c0 | ||
|
|
97d8047a3d | ||
|
|
16220d0a27 | ||
|
|
51fba24a3d | ||
|
|
52f43a600c | ||
|
|
e25174ff97 |
@@ -7,6 +7,7 @@ _reference
|
||||
client
|
||||
redis/dockerdata
|
||||
hasura
|
||||
harness-feature-flags-export
|
||||
node_modules
|
||||
# Files to exclude
|
||||
.ebignore
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
/client
|
||||
/firebase
|
||||
/hasura
|
||||
/harness-feature-flags-export
|
||||
/jsreport
|
||||
/node_modules
|
||||
.env.local
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -17,6 +17,9 @@ jsreport/auth-server/node_modules
|
||||
client/coverage
|
||||
admin/coverage
|
||||
|
||||
# Generated Harness/Split feature flag export artifacts
|
||||
/harness-feature-flags-export/
|
||||
|
||||
# production
|
||||
/build
|
||||
client/build
|
||||
@@ -149,3 +152,9 @@ docker_data
|
||||
/COPILOT.md
|
||||
/.github/copilot-instructions.md
|
||||
/GEMINI.md
|
||||
/_reference/select-component-test-plan.md
|
||||
|
||||
.terraform
|
||||
|
||||
terraform.tfvars
|
||||
terraform.exe
|
||||
|
||||
1297
_reference/feature-flags.md
Normal file
1297
_reference/feature-flags.md
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3362
client/package-lock.json
generated
3362
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,88 +8,88 @@
|
||||
"private": true,
|
||||
"proxy": "http://localhost:4000",
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-browser": "^2.37.0",
|
||||
"@amplitude/analytics-browser": "^2.42.4",
|
||||
"@ant-design/pro-layout": "^7.22.6",
|
||||
"@apollo/client": "^4.1.6",
|
||||
"@apollo/client": "^4.2.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@documenso/embed-react": "^0.6.1",
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@fingerprintjs/fingerprintjs": "^5.1.0",
|
||||
"@firebase/analytics": "^0.10.21",
|
||||
"@firebase/app": "^0.14.10",
|
||||
"@firebase/auth": "^1.12.2",
|
||||
"@firebase/firestore": "^4.13.0",
|
||||
"@firebase/messaging": "^0.12.25",
|
||||
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
||||
"@firebase/analytics": "^0.10.22",
|
||||
"@firebase/app": "^0.14.12",
|
||||
"@firebase/auth": "^1.13.1",
|
||||
"@firebase/firestore": "^4.14.1",
|
||||
"@firebase/messaging": "^0.12.26",
|
||||
"@jsreport/browser-client": "^3.1.0",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@sentry/cli": "^3.3.3",
|
||||
"@sentry/react": "^10.45.0",
|
||||
"@reduxjs/toolkit": "^2.12.0",
|
||||
"@sentry/cli": "^3.4.3",
|
||||
"@sentry/react": "^10.53.1",
|
||||
"@sentry/vite-plugin": "^4.9.1",
|
||||
"@splitsoftware/splitio-react": "^2.6.1",
|
||||
"@tanem/react-nprogress": "^5.0.63",
|
||||
"antd": "^6.3.3",
|
||||
"antd": "^6.4.3",
|
||||
"apollo-link-logger": "^3.0.0",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.13.6",
|
||||
"axios": "^1.16.1",
|
||||
"classnames": "^2.5.1",
|
||||
"css-box-model": "^1.2.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"dayjs-business-days2": "^1.3.2",
|
||||
"dayjs-business-days2": "^1.3.3",
|
||||
"dinero.js": "^1.9.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"env-cmd": "^11.0.0",
|
||||
"exifr": "^7.1.3",
|
||||
"graphql": "^16.13.1",
|
||||
"graphql-ws": "^6.0.7",
|
||||
"i18next": "^25.10.5",
|
||||
"graphql": "^16.14.0",
|
||||
"graphql-ws": "^6.0.8",
|
||||
"i18next": "^25.10.10",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"libphonenumber-js": "^1.12.40",
|
||||
"libphonenumber-js": "^1.13.3",
|
||||
"lightningcss": "^1.32.0",
|
||||
"logrocket": "^12.1.0",
|
||||
"logrocket": "^12.1.1",
|
||||
"markerjs2": "^2.32.7",
|
||||
"memoize-one": "^6.0.0",
|
||||
"normalize-url": "^8.1.1",
|
||||
"object-hash": "^3.0.0",
|
||||
"phone": "^3.1.71",
|
||||
"posthog-js": "^1.363.2",
|
||||
"posthog-js": "^1.376.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"query-string": "^9.3.1",
|
||||
"raf-schd": "^4.0.3",
|
||||
"react": "^19.2.4",
|
||||
"react": "^19.2.6",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
"react-color": "^2.19.3",
|
||||
"react-cookie": "^8.0.1",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-cookie": "^8.1.2",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-grid-gallery": "^1.0.1",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-i18next": "^16.6.2",
|
||||
"react-grid-layout": "^2.2.3",
|
||||
"react-i18next": "^16.6.6",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-image-lightbox": "^5.1.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-number-format": "^5.4.5",
|
||||
"react-popopo": "^2.1.9",
|
||||
"react-product-fruits": "^2.2.62",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-redux": "^9.3.0",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"react-router-dom": "^7.15.1",
|
||||
"react-sticky": "^6.0.3",
|
||||
"react-virtuoso": "^4.18.3",
|
||||
"recharts": "^3.8.0",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"recharts": "^3.8.1",
|
||||
"redux": "^5.0.1",
|
||||
"redux-actions": "^3.0.3",
|
||||
"redux-persist": "^6.0.0",
|
||||
"redux-saga": "^1.4.2",
|
||||
"redux-saga": "^1.5.0",
|
||||
"redux-state-sync": "^3.1.4",
|
||||
"reselect": "^5.1.1",
|
||||
"reselect": "^5.2.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"sass": "^1.98.0",
|
||||
"sass": "^1.100.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"styled-components": "^6.3.12",
|
||||
"styled-components": "^6.4.2",
|
||||
"vite-plugin-ejs": "^1.7.0",
|
||||
"web-vitals": "^5.1.0"
|
||||
"web-vitals": "^5.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "echo 'when updating react-big-calendar, remember to check to localizer in the calendar wrapper'",
|
||||
@@ -137,40 +137,40 @@
|
||||
"@rollup/rollup-linux-x64-gnu": "4.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@ant-design/icons": "^6.2.3",
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@dotenvx/dotenvx": "^1.57.2",
|
||||
"@babel/preset-react": "^7.29.7",
|
||||
"@dotenvx/dotenvx": "^1.68.1",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"browserslist": "^4.28.1",
|
||||
"browserslist": "^4.28.2",
|
||||
"browserslist-to-esbuild": "^2.1.1",
|
||||
"chalk": "^5.6.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
||||
"globals": "^17.4.0",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"memfs": "^4.57.1",
|
||||
"memfs": "^4.57.2",
|
||||
"os-browserify": "^0.3.0",
|
||||
"playwright": "^1.58.2",
|
||||
"playwright": "^1.60.0",
|
||||
"react-error-overlay": "^6.1.0",
|
||||
"redux-logger": "^3.0.6",
|
||||
"source-map-explorer": "^2.5.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-babel": "^1.6.0",
|
||||
"vite-plugin-babel": "^1.7.3",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-plugin-node-polyfills": "^0.25.0",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-node-polyfills": "^0.28.0",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vite-plugin-style-import": "^2.0.0",
|
||||
"vitest": "^4.1.0",
|
||||
"workbox-window": "^7.4.0"
|
||||
"vitest": "^4.1.7",
|
||||
"workbox-window": "^7.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5593,29 +5593,6 @@ Demo: https://rawgit.com/Sphinxxxx/color-conversion/master/demo/index.html
|
||||
|
||||
-----------
|
||||
|
||||
The following NPM packages may be included in this product:
|
||||
|
||||
- @splitsoftware/splitio-commons@1.6.1
|
||||
- @splitsoftware/splitio-react@1.7.1
|
||||
|
||||
These packages each contain the following license and notice below:
|
||||
|
||||
Copyright © 2022 Split Software, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
-----------
|
||||
|
||||
The following NPM packages may be included in this product:
|
||||
|
||||
- @stripe/react-stripe-js@1.9.0
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
import { ApolloProvider } from "@apollo/client/react";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
|
||||
import { ConfigProvider, Grid } from "antd";
|
||||
import enLocale from "antd/es/locale/en_US";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { CookiesProvider } from "react-cookie";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
|
||||
import { setDarkMode } from "../redux/application/application.actions";
|
||||
import { selectDarkMode } from "../redux/application/application.selectors";
|
||||
import { selectCurrentUser } from "../redux/user/user.selectors.js";
|
||||
import { signOutStart } from "../redux/user/user.actions";
|
||||
import client from "../utils/GraphQLClient";
|
||||
import App from "./App";
|
||||
import getTheme from "./themeProvider";
|
||||
|
||||
// Base Split configuration
|
||||
const config = {
|
||||
core: {
|
||||
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
|
||||
key: "anon"
|
||||
}
|
||||
};
|
||||
|
||||
function SplitClientProvider({ children }) {
|
||||
const imexshopid = useSelector((state) => state.user.imexshopid);
|
||||
const splitClient = useSplitClient({ key: imexshopid || "anon" });
|
||||
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV && splitClient && imexshopid) {
|
||||
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
|
||||
}
|
||||
}, [splitClient, imexshopid]);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function AppContainer() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const currentUser = useSelector(selectCurrentUser);
|
||||
const isDarkMode = useSelector(selectDarkMode);
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isPhone = !screens.md;
|
||||
const isUltraWide = Boolean(screens.xxxl);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
const baseTheme = getTheme(isDarkMode);
|
||||
|
||||
return {
|
||||
...baseTheme,
|
||||
token: {
|
||||
...(baseTheme.token || {}),
|
||||
screenXXXL: 2160
|
||||
},
|
||||
components: {
|
||||
...(baseTheme.components || {}),
|
||||
Table: {
|
||||
...(baseTheme.components?.Table || {}),
|
||||
cellFontSizeSM: isPhone ? 12 : 13,
|
||||
cellFontSizeMD: isPhone ? 13 : isUltraWide ? 15 : 14,
|
||||
cellFontSize: isUltraWide ? 15 : 14,
|
||||
cellPaddingInlineSM: isPhone ? 8 : 10,
|
||||
cellPaddingInlineMD: isPhone ? 10 : 14,
|
||||
cellPaddingInline: isUltraWide ? 20 : 16,
|
||||
cellPaddingBlockSM: isPhone ? 8 : 10,
|
||||
cellPaddingBlockMD: isPhone ? 10 : 12,
|
||||
cellPaddingBlock: isUltraWide ? 14 : 12,
|
||||
selectionColumnWidth: isPhone ? 44 : 52
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [isDarkMode, isPhone, isUltraWide]);
|
||||
|
||||
const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []);
|
||||
const antdTable = useMemo(() => ({ scroll: { x: "max-content" } }), []);
|
||||
const antdPagination = useMemo(
|
||||
() => ({
|
||||
showSizeChanger: !isPhone,
|
||||
totalBoundaryShowSizeChanger: 100
|
||||
}),
|
||||
[isPhone]
|
||||
);
|
||||
|
||||
const antdForm = useMemo(
|
||||
() => ({
|
||||
validateMessages: {
|
||||
required: t("general.validation.required", { label: "${label}" })
|
||||
}
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
// Global seamless logout listener with redirect to /signin
|
||||
useEffect(() => {
|
||||
const handleSeamlessLogout = (event) => {
|
||||
if (event.data?.type !== "seamlessLogoutRequest") return;
|
||||
|
||||
// Only accept messages from the parent window
|
||||
if (event.source !== window.parent) return;
|
||||
|
||||
const targetOrigin = event.origin || "*";
|
||||
|
||||
if (currentUser?.authorized !== true) {
|
||||
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "already_logged_out" }, targetOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(signOutStart());
|
||||
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, targetOrigin);
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleSeamlessLogout);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleSeamlessLogout);
|
||||
};
|
||||
}, [dispatch, currentUser?.authorized]);
|
||||
|
||||
// Update data-theme attribute (no cleanup to avoid transient style churn)
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = isDarkMode ? "dark" : "light";
|
||||
}, [isDarkMode]);
|
||||
|
||||
// Sync darkMode with localStorage
|
||||
useEffect(() => {
|
||||
const uid = currentUser?.uid;
|
||||
|
||||
if (!uid) {
|
||||
dispatch(setDarkMode(false));
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `dark-mode-${uid}`;
|
||||
const raw = localStorage.getItem(key);
|
||||
|
||||
if (raw == null) {
|
||||
dispatch(setDarkMode(false));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
dispatch(setDarkMode(Boolean(JSON.parse(raw))));
|
||||
} catch {
|
||||
dispatch(setDarkMode(false));
|
||||
}
|
||||
}, [currentUser?.uid, dispatch]);
|
||||
|
||||
// Persist darkMode
|
||||
useEffect(() => {
|
||||
const uid = currentUser?.uid;
|
||||
if (!uid) return;
|
||||
|
||||
localStorage.setItem(`dark-mode-${uid}`, JSON.stringify(isDarkMode));
|
||||
}, [isDarkMode, currentUser?.uid]);
|
||||
|
||||
return (
|
||||
<CookiesProvider>
|
||||
<ApolloProvider client={client}>
|
||||
<ConfigProvider
|
||||
input={antdInput}
|
||||
locale={enLocale}
|
||||
theme={theme}
|
||||
form={antdForm}
|
||||
table={antdTable}
|
||||
pagination={antdPagination}
|
||||
componentSize={isPhone ? "small" : isUltraWide ? "large" : "middle"}
|
||||
popupOverflow="viewport"
|
||||
>
|
||||
<GlobalLoadingBar />
|
||||
<SplitFactoryProvider config={config}>
|
||||
<SplitClientProvider>
|
||||
<App />
|
||||
</SplitClientProvider>
|
||||
</SplitFactoryProvider>
|
||||
</ConfigProvider>
|
||||
</ApolloProvider>
|
||||
</CookiesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sentry.withProfiler(AppContainer);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApolloProvider } from "@apollo/client/react";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
|
||||
import { FeatureFlagProvider, useFeatureFlagClient } from "../feature-flags/splitio-react-replacement";
|
||||
import { ConfigProvider } from "antd";
|
||||
import enLocale from "antd/es/locale/en_US";
|
||||
import { useEffect, useMemo } from "react";
|
||||
@@ -16,23 +16,21 @@ import client from "../utils/GraphQLClient";
|
||||
import App from "./App";
|
||||
import getTheme from "./themeProvider";
|
||||
|
||||
// Base Split configuration
|
||||
const config = {
|
||||
core: {
|
||||
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
|
||||
key: "anon"
|
||||
}
|
||||
};
|
||||
|
||||
function SplitClientProvider({ children }) {
|
||||
function FeatureFlagClientProvider({ children }) {
|
||||
const imexshopid = useSelector((state) => state.user.imexshopid);
|
||||
const splitClient = useSplitClient({ key: imexshopid || "anon" });
|
||||
const featureFlagClient = useFeatureFlagClient({ key: imexshopid || "anon" });
|
||||
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV && splitClient && imexshopid) {
|
||||
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
|
||||
if (import.meta.env.DEV && featureFlagClient && imexshopid) {
|
||||
console.log(`Feature flag client initialized with key: ${imexshopid}, isReady: ${featureFlagClient.isReady}`);
|
||||
}
|
||||
}, [splitClient, imexshopid]);
|
||||
}, [featureFlagClient, imexshopid]);
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -124,11 +122,11 @@ function AppContainer() {
|
||||
<ApolloProvider client={client}>
|
||||
<ConfigProvider input={antdInput} locale={enLocale} theme={theme} form={antdForm}>
|
||||
<GlobalLoadingBar />
|
||||
<SplitFactoryProvider config={config}>
|
||||
<SplitClientProvider>
|
||||
<FeatureFlagProvider config={config}>
|
||||
<FeatureFlagClientProvider>
|
||||
<App />
|
||||
</SplitClientProvider>
|
||||
</SplitFactoryProvider>
|
||||
</FeatureFlagClientProvider>
|
||||
</FeatureFlagProvider>
|
||||
</ConfigProvider>
|
||||
</ApolloProvider>
|
||||
</CookiesProvider>
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
import { ApolloProvider } from "@apollo/client/react";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
|
||||
import { ConfigProvider, Grid } from "antd";
|
||||
import enLocale from "antd/es/locale/en_US";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { CookiesProvider } from "react-cookie";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
|
||||
import { setDarkMode } from "../redux/application/application.actions";
|
||||
import { selectDarkMode } from "../redux/application/application.selectors";
|
||||
import { selectCurrentUser } from "../redux/user/user.selectors.js";
|
||||
import { signOutStart } from "../redux/user/user.actions";
|
||||
import client from "../utils/GraphQLClient";
|
||||
import App from "./App";
|
||||
import getTheme from "./themeProvider";
|
||||
|
||||
// Base Split configuration
|
||||
const config = {
|
||||
core: {
|
||||
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
|
||||
key: "anon"
|
||||
}
|
||||
};
|
||||
|
||||
function SplitClientProvider({ children }) {
|
||||
const imexshopid = useSelector((state) => state.user.imexshopid);
|
||||
const splitClient = useSplitClient({ key: imexshopid || "anon" });
|
||||
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV && splitClient && imexshopid) {
|
||||
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
|
||||
}
|
||||
}, [splitClient, imexshopid]);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function AppContainer() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const currentUser = useSelector(selectCurrentUser);
|
||||
const isDarkMode = useSelector(selectDarkMode);
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isPhone = !screens.md;
|
||||
const isUltraWide = Boolean(screens.xxxl);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
const baseTheme = getTheme(isDarkMode);
|
||||
|
||||
return {
|
||||
...baseTheme,
|
||||
token: {
|
||||
...(baseTheme.token || {}),
|
||||
screenXXXL: 2160
|
||||
},
|
||||
components: {
|
||||
...(baseTheme.components || {}),
|
||||
Table: {
|
||||
...(baseTheme.components?.Table || {}),
|
||||
cellFontSizeSM: isPhone ? 12 : 13,
|
||||
cellFontSizeMD: isPhone ? 13 : isUltraWide ? 15 : 14,
|
||||
cellFontSize: isUltraWide ? 15 : 14,
|
||||
cellPaddingInlineSM: isPhone ? 8 : 10,
|
||||
cellPaddingInlineMD: isPhone ? 10 : 14,
|
||||
cellPaddingInline: isUltraWide ? 20 : 16,
|
||||
cellPaddingBlockSM: isPhone ? 8 : 10,
|
||||
cellPaddingBlockMD: isPhone ? 10 : 12,
|
||||
cellPaddingBlock: isUltraWide ? 14 : 12,
|
||||
selectionColumnWidth: isPhone ? 44 : 52
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [isDarkMode, isPhone, isUltraWide]);
|
||||
|
||||
const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []);
|
||||
const antdTable = useMemo(() => ({ scroll: { x: "max-content" } }), []);
|
||||
const antdPagination = useMemo(
|
||||
() => ({
|
||||
showSizeChanger: !isPhone,
|
||||
totalBoundaryShowSizeChanger: 100
|
||||
}),
|
||||
[isPhone]
|
||||
);
|
||||
|
||||
const antdForm = useMemo(
|
||||
() => ({
|
||||
validateMessages: {
|
||||
required: t("general.validation.required", { label: "${label}" })
|
||||
}
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
// Global seamless logout listener with redirect to /signin
|
||||
useEffect(() => {
|
||||
const handleSeamlessLogout = (event) => {
|
||||
if (event.data?.type !== "seamlessLogoutRequest") return;
|
||||
|
||||
// Only accept messages from the parent window
|
||||
if (event.source !== window.parent) return;
|
||||
|
||||
const targetOrigin = event.origin || "*";
|
||||
|
||||
if (currentUser?.authorized !== true) {
|
||||
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "already_logged_out" }, targetOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(signOutStart());
|
||||
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, targetOrigin);
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleSeamlessLogout);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleSeamlessLogout);
|
||||
};
|
||||
}, [dispatch, currentUser?.authorized]);
|
||||
|
||||
// Update data-theme attribute (no cleanup to avoid transient style churn)
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = isDarkMode ? "dark" : "light";
|
||||
}, [isDarkMode]);
|
||||
|
||||
// Sync darkMode with localStorage
|
||||
useEffect(() => {
|
||||
const uid = currentUser?.uid;
|
||||
|
||||
if (!uid) {
|
||||
dispatch(setDarkMode(false));
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `dark-mode-${uid}`;
|
||||
const raw = localStorage.getItem(key);
|
||||
|
||||
if (raw == null) {
|
||||
dispatch(setDarkMode(false));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
dispatch(setDarkMode(Boolean(JSON.parse(raw))));
|
||||
} catch {
|
||||
dispatch(setDarkMode(false));
|
||||
}
|
||||
}, [currentUser?.uid, dispatch]);
|
||||
|
||||
// Persist darkMode
|
||||
useEffect(() => {
|
||||
const uid = currentUser?.uid;
|
||||
if (!uid) return;
|
||||
|
||||
localStorage.setItem(`dark-mode-${uid}`, JSON.stringify(isDarkMode));
|
||||
}, [isDarkMode, currentUser?.uid]);
|
||||
|
||||
return (
|
||||
<CookiesProvider>
|
||||
<ApolloProvider client={client}>
|
||||
<ConfigProvider
|
||||
input={antdInput}
|
||||
locale={enLocale}
|
||||
theme={theme}
|
||||
form={antdForm}
|
||||
table={antdTable}
|
||||
pagination={antdPagination}
|
||||
componentSize={isPhone ? "small" : isUltraWide ? "large" : "middle"}
|
||||
popupOverflow="viewport"
|
||||
>
|
||||
<GlobalLoadingBar />
|
||||
<SplitFactoryProvider config={config}>
|
||||
<SplitClientProvider>
|
||||
<App />
|
||||
</SplitClientProvider>
|
||||
</SplitFactoryProvider>
|
||||
</ConfigProvider>
|
||||
</ApolloProvider>
|
||||
</CookiesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sentry.withProfiler(AppContainer);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSplitClient } from "@splitsoftware/splitio-react";
|
||||
import { useSplitClient } from "../feature-flags/splitio-react-replacement";
|
||||
import { Button, Result } from "antd";
|
||||
import LogRocket from "logrocket";
|
||||
//import LogRocket from "logrocket";
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
@@ -101,13 +101,13 @@ export function App({
|
||||
client.setAttribute("imexshopid", bodyshop.imexshopid);
|
||||
|
||||
if (client.getTreatment("LogRocket_Tracking") === "on") {
|
||||
console.log("LR Start");
|
||||
LogRocket.init(
|
||||
InstanceRenderMgr({
|
||||
imex: "gvfvfw/bodyshopapp",
|
||||
rome: "rome-online/rome-online"
|
||||
})
|
||||
);
|
||||
// console.log("LR Start");
|
||||
// LogRocket.init(
|
||||
// InstanceRenderMgr({
|
||||
// imex: "gvfvfw/bodyshopapp",
|
||||
// rome: "rome-online/rome-online"
|
||||
// })
|
||||
// );
|
||||
}
|
||||
}
|
||||
}, [bodyshop, client, currentUser.authorized]);
|
||||
@@ -225,13 +225,22 @@ export function App({
|
||||
path="/parts/*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
</SocketProvider>
|
||||
</ErrorBoundary>
|
||||
}
|
||||
>
|
||||
<Route path="*" element={<SimplifiedPartsPageContainer />} />
|
||||
</Route>
|
||||
<Route path="/edit/*" element={<PrivateRoute isAuthorized={currentUser.authorized} />}>
|
||||
<Route
|
||||
path="/edit/*"
|
||||
element={
|
||||
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
</SocketProvider>
|
||||
}
|
||||
>
|
||||
<Route path="*" element={<DocumentEditorContainer />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -509,3 +509,55 @@
|
||||
pointer-events: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.esignature-embed {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.esignature-modal {
|
||||
.ant-modal {
|
||||
top: 16px;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 32px);
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.esignature-modal-frame {
|
||||
width: 100%;
|
||||
height: calc(100vh - 150px);
|
||||
min-height: 320px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px), (max-height: 560px) {
|
||||
.esignature-modal {
|
||||
.ant-modal {
|
||||
top: 8px;
|
||||
max-width: calc(100vw - 16px);
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
max-height: calc(100vh - 16px);
|
||||
}
|
||||
}
|
||||
|
||||
.esignature-modal-frame {
|
||||
height: calc(100vh - 132px);
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ const defaultTheme = (isDarkMode) => ({
|
||||
isDarkMode
|
||||
),
|
||||
colorError: isDarkMode ? "#ff4d4f" : "#f5222d",
|
||||
colorShadow: isDarkMode ? "rgba(0, 0, 0, 0.7)" : "#000000",
|
||||
colorBgBase: isDarkMode ? "#1f1f1f" : "#ffffff" // Align with Ant Design dark mode
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Alert } from "antd";
|
||||
|
||||
export default function AlertComponent(props) {
|
||||
return <Alert {...props} />;
|
||||
export default function AlertComponent({ title, message, ...props }) {
|
||||
return <Alert {...props} title={title ?? message} />;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { insertAuditTrail } from "../../redux/application/application.actions";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import AuditTrailMapping from "../../utils/AuditTrailMappings";
|
||||
import dayjs from "../../utils/day";
|
||||
import { buildBillUpdateAuditDetails } from "../../utils/auditTrailDetails";
|
||||
import AlertComponent from "../alert/alert.component";
|
||||
import BillFormContainer from "../bill-form/bill-form.container";
|
||||
import BillMarkExportedButton from "../bill-mark-exported-button/bill-mark-exported-button.component";
|
||||
@@ -134,10 +135,16 @@ export function BillDetailEditcontainer({ insertAuditTrail, bodyshop }) {
|
||||
|
||||
await Promise.all(updates);
|
||||
|
||||
const details = buildBillUpdateAuditDetails({
|
||||
originalBill: data?.bills_by_pk,
|
||||
bill,
|
||||
billlines
|
||||
});
|
||||
|
||||
insertAuditTrail({
|
||||
jobid: bill.jobid,
|
||||
jobid: bill.jobid ?? data?.bills_by_pk?.jobid,
|
||||
billid: search.billid,
|
||||
operation: AuditTrailMapping.billupdated(bill.invoice_number),
|
||||
operation: AuditTrailMapping.billupdated(bill.invoice_number, details),
|
||||
type: "billupdated"
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useApolloClient, useMutation } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Checkbox, Divider, Form, Modal, Space } from "antd";
|
||||
import _ from "lodash";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Icon, { UploadOutlined } from "@ant-design/icons";
|
||||
import { useApolloClient } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Alert, Divider, Form, Input, Select, Space, Statistic, Switch, Upload } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -52,6 +52,7 @@ export function BillFormComponent({
|
||||
const [discount, setDiscount] = useState(0);
|
||||
const notification = useNotification();
|
||||
const jobIdFormWatch = Form.useWatch("jobid", form);
|
||||
const vendorIdFormWatch = Form.useWatch("vendorid", form);
|
||||
|
||||
const {
|
||||
treatments: { Extended_Bill_Posting, ClosingPeriod }
|
||||
@@ -118,6 +119,7 @@ export function BillFormComponent({
|
||||
}
|
||||
}, [
|
||||
form,
|
||||
vendorIdFormWatch,
|
||||
billEdit,
|
||||
loadOutstandingReturns,
|
||||
loadInventory,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLazyQuery, useQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { QUERY_OUTSTANDING_INVENTORY } from "../../graphql/inventory.queries";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DeleteFilled, DollarCircleFilled } from "@ant-design/icons";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Checkbox, Form, Input, InputNumber, Select, Space, Switch, Table, Tooltip } from "antd";
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -36,6 +36,7 @@ export function BillEnterModalLinesComponent({
|
||||
const { t } = useTranslation();
|
||||
const { setFieldsValue, getFieldsValue, getFieldValue } = form;
|
||||
const firstFieldRefs = useRef({});
|
||||
const lineDescriptionRefs = useRef({});
|
||||
|
||||
const CONTROL_HEIGHT = 32;
|
||||
|
||||
@@ -94,6 +95,23 @@ export function BillEnterModalLinesComponent({
|
||||
});
|
||||
};
|
||||
|
||||
const focusLineDescription = (index) => {
|
||||
const lineDescription = lineDescriptionRefs.current[index];
|
||||
|
||||
if (typeof lineDescription?.focus === "function") {
|
||||
lineDescription.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
|
||||
lineDescription?.resizableTextArea?.textArea?.focus?.({ preventScroll: true });
|
||||
};
|
||||
|
||||
const focusJobLineSelect = (index) => {
|
||||
window.setTimeout(() => {
|
||||
firstFieldRefs.current[index]?.focus?.({ preventScroll: true });
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// Only fill actual_cost when the user forward-tabs out of Retail (actual_price)
|
||||
const autofillActualCost = (index) => {
|
||||
if (bodyshop.accountingconfig?.disableBillCostCalculation) return;
|
||||
@@ -195,6 +213,12 @@ export function BillEnterModalLinesComponent({
|
||||
minHeight: `${CONTROL_HEIGHT}px`
|
||||
}}
|
||||
allowRemoved={form.getFieldValue("is_credit_memo") || false}
|
||||
onInputKeyDown={(event) => {
|
||||
if (event.key !== "Tab" || event.shiftKey || event.defaultPrevented) return;
|
||||
|
||||
event.preventDefault();
|
||||
focusLineDescription(index);
|
||||
}}
|
||||
onSelect={(value, opt) => {
|
||||
// IMPORTANT:
|
||||
// Do NOT autofill actual_cost here. It should only fill when the user forward-tabs
|
||||
@@ -221,6 +245,7 @@ export function BillEnterModalLinesComponent({
|
||||
};
|
||||
})
|
||||
});
|
||||
focusJobLineSelect(index);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@@ -236,7 +261,16 @@ export function BillEnterModalLinesComponent({
|
||||
label: t("billlines.fields.line_desc"),
|
||||
rules: [{ required: true }]
|
||||
}),
|
||||
formInput: () => <Input.TextArea disabled={disabled} autoSize tabIndex={0} />
|
||||
formInput: (record, index) => (
|
||||
<Input.TextArea
|
||||
ref={(el) => {
|
||||
lineDescriptionRefs.current[index] = el;
|
||||
}}
|
||||
disabled={disabled}
|
||||
autoSize
|
||||
tabIndex={0}
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
||||
{
|
||||
@@ -435,9 +469,9 @@ export function BillEnterModalLinesComponent({
|
||||
rules: [{ required: true }]
|
||||
}),
|
||||
formInput: () => (
|
||||
<Select
|
||||
showSearch
|
||||
style={{ minWidth: "3rem" }}
|
||||
<Select
|
||||
showSearch
|
||||
style={{ minWidth: "3rem" }}
|
||||
disabled={disabled}
|
||||
tabIndex={0}
|
||||
options={
|
||||
@@ -461,7 +495,7 @@ export function BillEnterModalLinesComponent({
|
||||
name: [field.name, "location"]
|
||||
}),
|
||||
formInput: () => (
|
||||
<Select
|
||||
<Select
|
||||
disabled={disabled}
|
||||
tabIndex={0}
|
||||
options={bodyshop.md_parts_locations.map((loc) => ({ value: loc, label: loc }))}
|
||||
@@ -495,7 +529,9 @@ export function BillEnterModalLinesComponent({
|
||||
{Enhanced_Payroll.treatment === "on" ? (
|
||||
<Space>
|
||||
{t("joblines.fields.assigned_team", { name: employeeTeamName?.name })}
|
||||
{`${jobline.mod_lb_hrs} units/${t(`joblines.fields.lbr_types.${jobline.mod_lbr_ty}`)}`}
|
||||
{jobline
|
||||
? `${jobline.mod_lb_hrs} units/${t(`joblines.fields.lbr_types.${jobline.mod_lbr_ty}`)}`
|
||||
: null}
|
||||
</Space>
|
||||
) : null}
|
||||
|
||||
@@ -506,10 +542,7 @@ export function BillEnterModalLinesComponent({
|
||||
rules={[{ required: true }]}
|
||||
name={[record.name, "lbr_adjustment", "mod_lbr_ty"]}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
options={CiecaSelect(false, true)}
|
||||
/>
|
||||
<Select allowClear options={CiecaSelect(false, true)} />
|
||||
</Form.Item>
|
||||
|
||||
{Enhanced_Payroll.treatment === "on" ? (
|
||||
|
||||
@@ -9,18 +9,20 @@ import { createStructuredSelector } from "reselect";
|
||||
import { selectAuthLevel, selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { insertAuditTrail } from "../../redux/application/application.actions.js";
|
||||
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
authLevel: selectAuthLevel
|
||||
});
|
||||
const mapDispatchToProps = () => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(BillMarkForReexportButton);
|
||||
|
||||
export function BillMarkForReexportButton({ bodyshop, authLevel, bill }) {
|
||||
export function BillMarkForReexportButton({ bodyshop, authLevel, bill, insertAuditTrail }) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const notification = useNotification();
|
||||
@@ -47,6 +49,12 @@ export function BillMarkForReexportButton({ bodyshop, authLevel, bill }) {
|
||||
notification.success({
|
||||
title: t("bills.successes.reexport")
|
||||
});
|
||||
insertAuditTrail({
|
||||
jobid: bill.jobid,
|
||||
billid: bill.id,
|
||||
operation: AuditTrailMapping.billmarkforreexport(bill.invoice_number),
|
||||
type: "billmarkforreexport"
|
||||
});
|
||||
} else {
|
||||
notification.error({
|
||||
title: t("bills.errors.saving", {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import GlobalSearch from "../global-search/global-search.component";
|
||||
import GlobalSearchOs from "../global-search/global-search-os.component";
|
||||
import "./breadcrumbs.styles.scss";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
breadcrumbs: selectBreadcrumbs,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PictureFilled } from "@ant-design/icons";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Badge, Popover } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -64,7 +64,7 @@ function normalizeJobAllocations(ack) {
|
||||
* RR-specific DMS Allocations Summary
|
||||
* Focused on what we actually send to RR:
|
||||
* - ROGOG (split by taxable / non-taxable segments)
|
||||
* - ROLABOR shell
|
||||
* - ROLABOR labor rows with bill hours / rates
|
||||
*
|
||||
* The heavy lifting (ROGOG/ROLABOR split, cost allocation, tax flags)
|
||||
* is now done on the backend via buildRogogFromAllocations/buildRolaborFromRogog.
|
||||
@@ -181,21 +181,30 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
|
||||
const rolaborRows = useMemo(() => {
|
||||
if (!rolaborPreview || !Array.isArray(rolaborPreview.ops)) return [];
|
||||
|
||||
return rolaborPreview.ops.map((op, idx) => {
|
||||
const rowOpCode = opCode || op.opCode;
|
||||
return rolaborPreview.ops
|
||||
.filter((op) =>
|
||||
[op.bill?.jobTotalHrs, op.bill?.billTime, op.bill?.billRate, op.amount?.custPrice, op.amount?.totalAmt]
|
||||
.map((value) => Number.parseFloat(value ?? "0"))
|
||||
.some((value) => !Number.isNaN(value) && value !== 0)
|
||||
)
|
||||
.map((op, idx) => {
|
||||
const rowOpCode = opCode || op.opCode;
|
||||
|
||||
return {
|
||||
key: `${op.jobNo}-${idx}`,
|
||||
opCode: rowOpCode,
|
||||
jobNo: op.jobNo,
|
||||
custPayTypeFlag: op.custPayTypeFlag,
|
||||
custTxblNtxblFlag: op.custTxblNtxblFlag,
|
||||
payType: op.bill?.payType,
|
||||
amtType: op.amount?.amtType,
|
||||
custPrice: op.amount?.custPrice,
|
||||
totalAmt: op.amount?.totalAmt
|
||||
};
|
||||
});
|
||||
return {
|
||||
key: `${op.jobNo}-${idx}`,
|
||||
opCode: rowOpCode,
|
||||
jobNo: op.jobNo,
|
||||
custPayTypeFlag: op.custPayTypeFlag,
|
||||
custTxblNtxblFlag: op.custTxblNtxblFlag,
|
||||
payType: op.bill?.payType,
|
||||
jobTotalHrs: op.bill?.jobTotalHrs,
|
||||
billTime: op.bill?.billTime,
|
||||
billRate: op.bill?.billRate,
|
||||
amtType: op.amount?.amtType,
|
||||
custPrice: op.amount?.custPrice,
|
||||
totalAmt: op.amount?.totalAmt
|
||||
};
|
||||
});
|
||||
}, [rolaborPreview, opCode]);
|
||||
|
||||
// Totals for ROGOG (sum custPrice + dlrCost over all lines)
|
||||
@@ -245,6 +254,9 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
|
||||
{ title: "CustPayType", dataIndex: "custPayTypeFlag", key: "custPayTypeFlag" },
|
||||
{ title: "CustTxblFlag", dataIndex: "custTxblNtxblFlag", key: "custTxblNtxblFlag" },
|
||||
{ title: "PayType", dataIndex: "payType", key: "payType" },
|
||||
{ title: "JobTotalHrs", dataIndex: "jobTotalHrs", key: "jobTotalHrs" },
|
||||
{ title: "BillTime", dataIndex: "billTime", key: "billTime" },
|
||||
{ title: "BillRate", dataIndex: "billRate", key: "billRate" },
|
||||
{ title: "AmtType", dataIndex: "amtType", key: "amtType" },
|
||||
{ title: "CustPrice", dataIndex: "custPrice", key: "custPrice" },
|
||||
{ title: "TotalAmt", dataIndex: "totalAmt", key: "totalAmt" }
|
||||
@@ -317,12 +329,13 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
|
||||
children: (
|
||||
<>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
|
||||
This mirrors the shell that would be sent for ROLABOR when all financials are carried in GOG.
|
||||
This mirrors the labor rows RR will receive, including weighted bill hours and rates derived from the
|
||||
job's labor lines.
|
||||
</Typography.Paragraph>
|
||||
<ResponsiveTable
|
||||
pagination={false}
|
||||
columns={rolaborColumns}
|
||||
mobileColumnKeys={["jobNo", "opCode", "breakOut", "itemType"]}
|
||||
mobileColumnKeys={["jobNo", "opCode", "billRate", "custPrice"]}
|
||||
rowKey="key"
|
||||
dataSource={rolaborRows}
|
||||
locale={{ emptyText: "No ROLABOR lines would be generated." }}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Col } from "antd";
|
||||
import { Button, Checkbox, Col } from "antd";
|
||||
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -49,7 +49,13 @@ export default function PBSCustomerSelector({ bodyshop, socket }) {
|
||||
if (!open) return null;
|
||||
|
||||
const columns = [
|
||||
{ title: t("jobs.fields.dms.id"), dataIndex: "ContactId", key: "ContactId" },
|
||||
{ title: t("jobs.fields.dms.id"), dataIndex: "Code", key: "ContactId" },
|
||||
{
|
||||
title: t("jobs.fields.dms.IsARCustomer"),
|
||||
dataIndex: "IsARCustomer",
|
||||
key: "IsARCustomer",
|
||||
render: (text, record) => <Checkbox checked={record.IsARCustomer} disabled />
|
||||
},
|
||||
{
|
||||
title: t("jobs.fields.dms.name1"),
|
||||
key: "name1",
|
||||
|
||||
@@ -26,7 +26,7 @@ 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 { DMS_MAP } from "../../utils/dmsUtils";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
/**
|
||||
* CDK-like DMS post form:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { Result } from "antd";
|
||||
import { Result, theme } from "antd";
|
||||
import * as markerjs2 from "markerjs2";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -9,6 +9,12 @@ import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selecto
|
||||
import { handleUpload } from "../documents-local-upload/documents-local-upload.utility";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import {
|
||||
addGreyscaleButtonToMarkerArea,
|
||||
addImageHistoryUndoToMarkerArea,
|
||||
applyGreyscaleToMarkerAreaImage,
|
||||
setMarkerAreaImageSource
|
||||
} from "./document-editor.utility";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser,
|
||||
@@ -24,7 +30,9 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
const markerArea = useRef(null);
|
||||
const imageHistory = useRef([]);
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const notification = useNotification();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
@@ -32,6 +40,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
|
||||
async (dataUrl) => {
|
||||
if (uploading) return;
|
||||
setUploading(true);
|
||||
setLoading(true);
|
||||
const blob = await b64toBlob(dataUrl);
|
||||
const nameWithoutExt = filename.split(".").slice(0, -1).join(".").trim();
|
||||
const parts = nameWithoutExt.split("-");
|
||||
@@ -70,6 +79,23 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
|
||||
[filename, jobid, notification, uploading]
|
||||
);
|
||||
|
||||
const handleGreyscale = useCallback(() => {
|
||||
if (!imgRef.current || loading || uploaded || imageLoading || !imageLoaded) return;
|
||||
|
||||
imageHistory.current.push(imgRef.current.src);
|
||||
applyGreyscaleToMarkerAreaImage(markerArea.current, imgRef.current);
|
||||
}, [imageLoaded, imageLoading, loading, uploaded]);
|
||||
|
||||
const undoImageEdit = useCallback(() => {
|
||||
if (!imgRef.current) return;
|
||||
|
||||
const previousSrc = imageHistory.current.pop();
|
||||
|
||||
if (previousSrc) {
|
||||
setMarkerAreaImageSource(markerArea.current, imgRef.current, previousSrc);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (imgRef.current !== null && imageLoaded && !markerArea.current) {
|
||||
// create a marker.js MarkerArea
|
||||
@@ -93,8 +119,10 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
|
||||
markerArea.current.renderImageQuality = 1;
|
||||
//markerArea.current.settings.displayMode = "inline";
|
||||
markerArea.current.show();
|
||||
addGreyscaleButtonToMarkerArea(markerArea.current, handleGreyscale, t("documents.labels.greyscale"));
|
||||
addImageHistoryUndoToMarkerArea(markerArea.current, () => imageHistory.current.length > 0, undoImageEdit);
|
||||
}
|
||||
}, [triggerUpload, imageLoaded]);
|
||||
}, [handleGreyscale, imageLoaded, t, triggerUpload, undoImageEdit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageUrl) return;
|
||||
@@ -106,6 +134,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
|
||||
try {
|
||||
const response = await axios.get(imageUrl, { responseType: "blob", signal: controller.signal });
|
||||
const blobUrl = URL.createObjectURL(response.data);
|
||||
imageHistory.current = [];
|
||||
setLoadedImageUrl((prevUrl) => {
|
||||
if (prevUrl) URL.revokeObjectURL(prevUrl);
|
||||
return blobUrl;
|
||||
@@ -142,7 +171,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ background: token.colorBgBase, color: token.colorText, minHeight: "100vh" }}>
|
||||
{!loading && !uploaded && loadedImageUrl && (
|
||||
<img
|
||||
ref={imgRef}
|
||||
@@ -158,7 +187,12 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
|
||||
{(loading || imageLoading || !imageLoaded) && !uploaded && (
|
||||
<LoadingSpinner message={t("documents.labels.uploading")} />
|
||||
)}
|
||||
{uploaded && <Result status="success" title={t("documents.successes.edituploaded")} />}
|
||||
{uploaded && (
|
||||
<Result
|
||||
status="success"
|
||||
title={<span style={{ color: token.colorText }}>{t("documents.successes.edituploaded")}</span>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
//import "tui-image-editor/dist/tui-image-editor.css";
|
||||
import axios from "axios";
|
||||
import { Result } from "antd";
|
||||
import { Result, theme } from "antd";
|
||||
import * as markerjs2 from "markerjs2";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import { handleUpload } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility.js";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import {
|
||||
addGreyscaleButtonToMarkerArea,
|
||||
addImageHistoryUndoToMarkerArea,
|
||||
applyGreyscaleToMarkerAreaImage,
|
||||
setMarkerAreaImageSource
|
||||
} from "./document-editor.utility";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser,
|
||||
@@ -27,7 +33,9 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
const markerArea = useRef(null);
|
||||
const imageHistory = useRef([]);
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const notification = useNotification();
|
||||
|
||||
const triggerUpload = useCallback(
|
||||
@@ -57,6 +65,23 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
||||
[bodyshop, currentUser, document, notification]
|
||||
);
|
||||
|
||||
const handleGreyscale = useCallback(() => {
|
||||
if (!imgRef.current || loading || uploaded || imageLoading || !imageLoaded) return;
|
||||
|
||||
imageHistory.current.push(imgRef.current.src);
|
||||
applyGreyscaleToMarkerAreaImage(markerArea.current, imgRef.current);
|
||||
}, [imageLoaded, imageLoading, loading, uploaded]);
|
||||
|
||||
const undoImageEdit = useCallback(() => {
|
||||
if (!imgRef.current) return;
|
||||
|
||||
const previousSrc = imageHistory.current.pop();
|
||||
|
||||
if (previousSrc) {
|
||||
setMarkerAreaImageSource(markerArea.current, imgRef.current, previousSrc);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (imgRef.current !== null && imageLoaded && !markerArea.current) {
|
||||
// create a marker.js MarkerArea
|
||||
@@ -80,8 +105,10 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
||||
markerArea.current.renderImageQuality = 1;
|
||||
//markerArea.current.settings.displayMode = "inline";
|
||||
markerArea.current.show();
|
||||
addGreyscaleButtonToMarkerArea(markerArea.current, handleGreyscale, t("documents.labels.greyscale"));
|
||||
addImageHistoryUndoToMarkerArea(markerArea.current, () => imageHistory.current.length > 0, undoImageEdit);
|
||||
}
|
||||
}, [triggerUpload, imageLoaded]);
|
||||
}, [handleGreyscale, imageLoaded, t, triggerUpload, undoImageEdit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!document?.id) return;
|
||||
@@ -100,6 +127,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
||||
}
|
||||
);
|
||||
const blobUrl = URL.createObjectURL(response.data);
|
||||
imageHistory.current = [];
|
||||
setImageUrl((prevUrl) => {
|
||||
if (prevUrl) URL.revokeObjectURL(prevUrl);
|
||||
return blobUrl;
|
||||
@@ -134,7 +162,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ background: token.colorBgBase, color: token.colorText, minHeight: "100vh" }}>
|
||||
{!loading && !uploaded && imageUrl && (
|
||||
<img
|
||||
ref={imgRef}
|
||||
@@ -150,7 +178,12 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
||||
{(loading || imageLoading || !imageLoaded) && !uploaded && (
|
||||
<LoadingSpinner message={t("documents.labels.uploading")} />
|
||||
)}
|
||||
{uploaded && <Result status="success" title={t("documents.successes.edituploaded")} />}
|
||||
{uploaded && (
|
||||
<Result
|
||||
status="success"
|
||||
title={<span style={{ color: token.colorText }}>{t("documents.successes.edituploaded")}</span>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
123
client/src/components/document-editor/document-editor.utility.js
Normal file
123
client/src/components/document-editor/document-editor.utility.js
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Converts an image element to a greyscale data URL.
|
||||
* @param imageElement
|
||||
* @returns {string}
|
||||
*/
|
||||
export function convertImageElementToGreyscaleDataUrl(imageElement) {
|
||||
if (!imageElement?.naturalWidth || !imageElement?.naturalHeight) {
|
||||
throw new Error("Image must be loaded before it can be converted to greyscale.");
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = imageElement.naturalWidth;
|
||||
canvas.height = imageElement.naturalHeight;
|
||||
|
||||
const context = canvas.getContext("2d");
|
||||
context.drawImage(imageElement, 0, 0);
|
||||
|
||||
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const pixels = imageData.data;
|
||||
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
const luminance = Math.round(pixels[i] * 0.299 + pixels[i + 1] * 0.587 + pixels[i + 2] * 0.114);
|
||||
pixels[i] = luminance;
|
||||
pixels[i + 1] = luminance;
|
||||
pixels[i + 2] = luminance;
|
||||
}
|
||||
|
||||
context.putImageData(imageData, 0, 0);
|
||||
|
||||
return canvas.toDataURL("image/jpeg", 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a greyscale button to the marker area controls if it doesn't already exist.
|
||||
* @param markerArea
|
||||
* @param onGreyscale
|
||||
* @param title
|
||||
*/
|
||||
export function addGreyscaleButtonToMarkerArea(markerArea, onGreyscale, title) {
|
||||
requestAnimationFrame(() => {
|
||||
const renderButton = markerArea?.coverDiv?.querySelector?.('[data-action="render"]');
|
||||
|
||||
if (!renderButton || markerArea.coverDiv.querySelector('[data-action="greyscale"]')) return;
|
||||
|
||||
const greyscaleButton = document.createElement("div");
|
||||
greyscaleButton.className = renderButton.className;
|
||||
greyscaleButton.innerHTML =
|
||||
'<svg viewBox="0 0 24 24"><path d="M12 2a10 10 0 1 0 0 20V2zm0 2.25v15.5a7.75 7.75 0 0 1 0-15.5z"/></svg>';
|
||||
greyscaleButton.setAttribute("role", "button");
|
||||
greyscaleButton.setAttribute("data-action", "greyscale");
|
||||
greyscaleButton.setAttribute("aria-label", title);
|
||||
greyscaleButton.title = title;
|
||||
greyscaleButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onGreyscale();
|
||||
});
|
||||
|
||||
renderButton.parentElement.insertBefore(greyscaleButton, renderButton);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a greyscale filter to the image in the marker area and updates the image source.
|
||||
* @param markerArea
|
||||
* @param imageElement
|
||||
* @returns {string}
|
||||
*/
|
||||
export function applyGreyscaleToMarkerAreaImage(markerArea, imageElement) {
|
||||
const dataUrl = convertImageElementToGreyscaleDataUrl(imageElement);
|
||||
|
||||
setMarkerAreaImageSource(markerArea, imageElement, dataUrl);
|
||||
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the image source for the marker area and updates the editing target if it's an image element.
|
||||
* @param markerArea
|
||||
* @param imageElement
|
||||
* @param src
|
||||
*/
|
||||
export function setMarkerAreaImageSource(markerArea, imageElement, src) {
|
||||
imageElement.src = src;
|
||||
|
||||
if (markerArea?.editingTarget instanceof HTMLImageElement) {
|
||||
markerArea.editingTarget.src = src;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds undo functionality for image edits to the marker area by tracking the state before and after undo actions.
|
||||
* @param markerArea
|
||||
* @param canUndoImage
|
||||
* @param undoImage
|
||||
*/
|
||||
export function addImageHistoryUndoToMarkerArea(markerArea, canUndoImage, undoImage) {
|
||||
requestAnimationFrame(() => {
|
||||
const undoButton = markerArea?.coverDiv?.querySelector?.('[data-action="undo"]');
|
||||
|
||||
if (!undoButton || undoButton.dataset.imageHistoryUndo === "true") return;
|
||||
|
||||
let markerStateBeforeUndo = null;
|
||||
|
||||
undoButton.dataset.imageHistoryUndo = "true";
|
||||
undoButton.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
markerStateBeforeUndo = JSON.stringify(markerArea.getState(true));
|
||||
},
|
||||
true
|
||||
);
|
||||
undoButton.addEventListener("click", () => {
|
||||
const markerStateAfterUndo = JSON.stringify(markerArea.getState(true));
|
||||
|
||||
if (markerStateBeforeUndo === markerStateAfterUndo && canUndoImage()) {
|
||||
undoImage();
|
||||
}
|
||||
|
||||
markerStateBeforeUndo = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import i18n from "i18next";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import { INSERT_NEW_DOCUMENT } from "../../graphql/documents.queries";
|
||||
import { axiosAuthInterceptorId } from "../../utils/CleanAxios";
|
||||
import { replaceAccents } from "../../utils/replaceAccents.js";
|
||||
import client from "../../utils/GraphQLClient";
|
||||
|
||||
//Context: currentUserEmail, bodyshop, jobid, invoiceid
|
||||
@@ -144,32 +145,3 @@ export const uploadToS3 = async (
|
||||
if (onError) onError(JSON.stringify(error.message));
|
||||
}
|
||||
};
|
||||
|
||||
function replaceAccents(str) {
|
||||
// Verifies if the String has accents and replace them
|
||||
if (str.search(/[\xC0-\xFF]/g) > -1) {
|
||||
str = str
|
||||
.replace(/[\xC0-\xC5]/g, "A")
|
||||
.replace(/[\xC6]/g, "AE")
|
||||
.replace(/[\xC7]/g, "C")
|
||||
.replace(/[\xC8-\xCB]/g, "E")
|
||||
.replace(/[\xCC-\xCF]/g, "I")
|
||||
.replace(/[\xD0]/g, "D")
|
||||
.replace(/[\xD1]/g, "N")
|
||||
.replace(/[\xD2-\xD6\xD8]/g, "O")
|
||||
.replace(/[\xD9-\xDC]/g, "U")
|
||||
.replace(/[\xDD]/g, "Y")
|
||||
.replace(/[\xDE]/g, "P")
|
||||
.replace(/[\xE0-\xE5]/g, "a")
|
||||
.replace(/[\xE6]/g, "ae")
|
||||
.replace(/[\xE7]/g, "c")
|
||||
.replace(/[\xE8-\xEB]/g, "e")
|
||||
.replace(/[\xEC-\xEF]/g, "i")
|
||||
.replace(/[\xF1]/g, "n")
|
||||
.replace(/[\xF2-\xF6\xF8]/g, "o")
|
||||
.replace(/[\xF9-\xFC]/g, "u")
|
||||
.replace(/[\xFE]/g, "p")
|
||||
.replace(/[\xFD\xFF]/g, "y");
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import AlertComponent from "../alert/alert.component";
|
||||
import JobDocumentsGalleryExternal from "../jobs-documents-gallery/jobs-documents-gallery.external.component";
|
||||
import JobsDocumentsLocalGalleryExternalComponent from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import JobsDocumentImgproxyGalleryExternal from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { UploadOutlined } from "@ant-design/icons";
|
||||
import { Button, Upload } from "antd";
|
||||
import axios from "axios";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { setModalContext } from "../../redux/modals/modals.actions";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { hasDocumensoApiKey } from "../../utils/esignature.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
setEsignatureContext: (context) =>
|
||||
dispatch(
|
||||
setModalContext({
|
||||
context,
|
||||
modal: "esignature"
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
export function EsignatureCustomDocument({
|
||||
bodyshop,
|
||||
disabled = false,
|
||||
jobId,
|
||||
setEsignatureContext,
|
||||
showUnavailable = false
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const notification = useNotification();
|
||||
const { t } = useTranslation();
|
||||
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
|
||||
const isDisabled = disabled || !esignatureEnabled;
|
||||
|
||||
if (!esignatureEnabled && !showUnavailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uploadCustomDocument = async ({ file, onError, onSuccess }) => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("document", file);
|
||||
formData.append("jobid", jobId);
|
||||
formData.append("bodyshop", JSON.stringify(bodyshop));
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { token, documentId, envelopeId }
|
||||
} = await axios.post("/esign/new-custom", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data"
|
||||
}
|
||||
});
|
||||
|
||||
setEsignatureContext({ context: { token, documentId, envelopeId, jobid: jobId } });
|
||||
onSuccess?.({ token, documentId, envelopeId });
|
||||
} catch (error) {
|
||||
notification.error({
|
||||
title: t("esignature.errors.upload_title"),
|
||||
description: error?.response?.data?.error || error?.response?.data?.message || error.message
|
||||
});
|
||||
onError?.(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Upload
|
||||
accept="application/pdf,.pdf"
|
||||
beforeUpload={(file) => {
|
||||
if (file.type === "application/pdf" || file.name?.toLowerCase().endsWith(".pdf")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
notification.error({
|
||||
title: t("esignature.errors.upload_title"),
|
||||
description: t("esignature.errors.pdf_only")
|
||||
});
|
||||
return Upload.LIST_IGNORE;
|
||||
}}
|
||||
customRequest={uploadCustomDocument}
|
||||
disabled={isDisabled}
|
||||
maxCount={1}
|
||||
showUploadList={false}
|
||||
multiple={false}
|
||||
>
|
||||
<Button disabled={isDisabled} icon={<UploadOutlined />} loading={loading}>
|
||||
{t("esignature.actions.upload_document")}
|
||||
</Button>
|
||||
</Upload>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EsignatureCustomDocument);
|
||||
@@ -0,0 +1,113 @@
|
||||
import { EmbedUpdateDocumentV1 } from "@documenso/embed-react";
|
||||
import { Modal, notification, Result } from "antd";
|
||||
import axios from "axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { toggleModalVisible } from "../../redux/modals/modals.actions";
|
||||
import { selectEsignature } from "../../redux/modals/modals.selectors";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import { useState } from "react";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
import { hasDocumensoApiKey } from "../../utils/esignature.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
esignatureModal: selectEsignature,
|
||||
bodyshop: selectBodyshop,
|
||||
currentUser: selectCurrentUser
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
toggleModalVisible: () => dispatch(toggleModalVisible("esignature"))
|
||||
});
|
||||
|
||||
export function EsignatureModalContainer({ esignatureModal, toggleModalVisible, bodyshop, currentUser }) {
|
||||
const { t } = useTranslation();
|
||||
const { open, context } = esignatureModal;
|
||||
const { token, envelopeId, documentId, jobid } = context;
|
||||
const [distributing, setDistributing] = useState(false);
|
||||
const hasToken = Boolean(token);
|
||||
|
||||
if (!hasDocumensoApiKey(bodyshop)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={InstanceRenderManager({
|
||||
imex: t("jobs.labels.esignature_imex"),
|
||||
rome: t("jobs.labels.esignature_rome")
|
||||
})}
|
||||
onOk={async () => {
|
||||
if (!hasToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDistributing(true);
|
||||
await axios.post("/esign/distribute", {
|
||||
documentId,
|
||||
envelopeId,
|
||||
jobid,
|
||||
bodyshopid: bodyshop.id
|
||||
});
|
||||
|
||||
toggleModalVisible();
|
||||
} catch (error) {
|
||||
notification.error({
|
||||
message: t("esignature.distribute_error"),
|
||||
description: error?.response?.data?.message || error.message
|
||||
});
|
||||
}
|
||||
setDistributing(false);
|
||||
}}
|
||||
onCancel={async () => {
|
||||
if (!hasToken) {
|
||||
toggleModalVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post("/esign/delete", {
|
||||
documentId,
|
||||
envelopeId,
|
||||
bodyshopid: bodyshop.id
|
||||
});
|
||||
|
||||
toggleModalVisible();
|
||||
} catch (error) {
|
||||
notification.error({
|
||||
message: t("esignature.cancel_error"),
|
||||
description: error?.response?.data?.message || error.message
|
||||
});
|
||||
}
|
||||
}}
|
||||
okButtonProps={{ loading: distributing, style: hasToken ? undefined : { display: "none" } }}
|
||||
okText={t("esignature.actions.distribute")}
|
||||
destroyOnHidden
|
||||
width="calc(100vw - 32px)"
|
||||
wrapClassName="esignature-modal"
|
||||
styles={{ body: { overflow: "hidden", padding: 0 } }}
|
||||
>
|
||||
<div className="esignature-modal-frame">
|
||||
{hasToken ? (
|
||||
<EmbedUpdateDocumentV1
|
||||
presignToken={token}
|
||||
host="https://sign.imex.online"
|
||||
documentId={documentId}
|
||||
externalId={`${jobid}|${currentUser?.email}`}
|
||||
className="esignature-embed"
|
||||
onDocumentUpdated={(data) => {
|
||||
console.log("Document updated:", data);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Result status="warning" title={t("esignature.errors.no_token")} />
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EsignatureModalContainer);
|
||||
@@ -4,20 +4,203 @@ import AlertComponent from "../alert/alert.component";
|
||||
import "./form-fields-changed.styles.scss";
|
||||
import Prompt from "../../utils/prompt";
|
||||
|
||||
export default function FormsFieldChanged({ form, skipPrompt }) {
|
||||
export default function FormsFieldChanged({ form, skipPrompt, onErrorNavigate, onReset, onDirtyChange }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const normalizeNamePath = (namePath) => (Array.isArray(namePath) ? namePath.filter((part) => part !== undefined) : [namePath]);
|
||||
|
||||
const getFieldIdCandidates = (namePath) => {
|
||||
const normalizedNamePath = normalizeNamePath(namePath).map((part) => String(part));
|
||||
const underscoreId = normalizedNamePath.join("_");
|
||||
const dashId = normalizedNamePath.join("-");
|
||||
const dotName = normalizedNamePath.join(".");
|
||||
|
||||
return [underscoreId, dashId, dotName].filter(Boolean);
|
||||
};
|
||||
|
||||
const clearFormMeta = () => {
|
||||
const fieldMeta = form.getFieldsError().map(({ name }) => ({
|
||||
name,
|
||||
touched: false,
|
||||
validating: false,
|
||||
errors: [],
|
||||
warnings: []
|
||||
}));
|
||||
|
||||
if (fieldMeta.length > 0) {
|
||||
form.setFields(fieldMeta);
|
||||
}
|
||||
|
||||
onDirtyChange?.(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
form.resetFields();
|
||||
if (onReset) {
|
||||
onReset();
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
clearFormMeta();
|
||||
});
|
||||
};
|
||||
|
||||
const getFieldDomNode = (namePath) => {
|
||||
const fieldInstance = form.getFieldInstance?.(namePath);
|
||||
const fieldIdCandidates = getFieldIdCandidates(namePath);
|
||||
const domCandidates = [
|
||||
fieldInstance?.nativeElement,
|
||||
fieldInstance?.input,
|
||||
fieldInstance?.resizableTextArea?.textArea,
|
||||
fieldInstance
|
||||
];
|
||||
|
||||
fieldIdCandidates.forEach((fieldId) => {
|
||||
const escapedFieldId = CSS.escape(fieldId);
|
||||
const directNode = document.getElementById(fieldId) || document.querySelector(`#${escapedFieldId}`);
|
||||
const labelNode = document.querySelector(`label[for="${escapedFieldId}"]`);
|
||||
const namedNode = document.querySelector(`[name="${escapedFieldId}"]`);
|
||||
const formItemNode =
|
||||
directNode?.closest?.(".ant-form-item") ||
|
||||
labelNode?.closest?.(".ant-form-item") ||
|
||||
namedNode?.closest?.(".ant-form-item");
|
||||
|
||||
domCandidates.push(directNode);
|
||||
domCandidates.push(namedNode);
|
||||
domCandidates.push(formItemNode);
|
||||
domCandidates.push(formItemNode?.querySelector?.("input, textarea, select, .ant-select-selector"));
|
||||
});
|
||||
|
||||
return domCandidates.find((candidate) => candidate instanceof HTMLElement) ?? null;
|
||||
};
|
||||
|
||||
const waitForAnimationFrames = (frameCount = 1) =>
|
||||
new Promise((resolve) => {
|
||||
let remainingFrames = frameCount;
|
||||
const nextFrame = () => {
|
||||
if (remainingFrames <= 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
remainingFrames -= 1;
|
||||
window.requestAnimationFrame(nextFrame);
|
||||
};
|
||||
window.requestAnimationFrame(nextFrame);
|
||||
});
|
||||
|
||||
const getFieldOwningTabMeta = (namePath) => {
|
||||
const fieldDomNode = getFieldDomNode(namePath);
|
||||
const owningTabPane = fieldDomNode?.closest?.(".ant-tabs-tabpane");
|
||||
const paneId = owningTabPane?.getAttribute?.("id") || null;
|
||||
const owningTabButton = paneId
|
||||
? document.querySelector(`[role="tab"][aria-controls="${paneId.replace(/"/g, '\\"')}"]`)
|
||||
: null;
|
||||
const tabLabel = owningTabButton?.textContent?.trim() || null;
|
||||
|
||||
return {
|
||||
owningTabPane,
|
||||
owningTabButton,
|
||||
tabLabel
|
||||
};
|
||||
};
|
||||
|
||||
const openFieldOwningTab = async (namePath) => {
|
||||
const { owningTabPane, owningTabButton } = getFieldOwningTabMeta(namePath);
|
||||
if (!owningTabPane || owningTabPane.classList.contains("ant-tabs-tabpane-active")) return false;
|
||||
|
||||
if (!(owningTabButton instanceof HTMLElement)) return false;
|
||||
|
||||
owningTabButton.click();
|
||||
|
||||
for (let index = 0; index < 24; index += 1) {
|
||||
await waitForAnimationFrames();
|
||||
if (owningTabPane.classList.contains("ant-tabs-tabpane-active")) return true;
|
||||
}
|
||||
|
||||
return owningTabPane.classList.contains("ant-tabs-tabpane-active");
|
||||
};
|
||||
|
||||
const scrollToErrorField = (namePath) => {
|
||||
const normalizedNamePath = normalizeNamePath(namePath);
|
||||
if (!normalizedNamePath.length) return;
|
||||
|
||||
try {
|
||||
form.scrollToField(normalizedNamePath, {
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
focus: true
|
||||
});
|
||||
window.requestAnimationFrame(() => {
|
||||
const fallbackNode = getFieldDomNode(normalizedNamePath);
|
||||
fallbackNode?.focus?.();
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
const fallbackTarget = document.getElementById(normalizedNamePath[0]?.toString?.() ?? "");
|
||||
fallbackTarget?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleErrorClick = async (namePath) => {
|
||||
const normalizedNamePath = normalizeNamePath(namePath);
|
||||
if (!normalizedNamePath.length) return;
|
||||
|
||||
const switchedTab = await openFieldOwningTab(normalizedNamePath);
|
||||
if (!switchedTab) {
|
||||
const navigationDelayMs = onErrorNavigate?.(normalizedNamePath) ?? 0;
|
||||
if (navigationDelayMs > 0) {
|
||||
window.setTimeout(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
scrollToErrorField(normalizedNamePath);
|
||||
});
|
||||
}, navigationDelayMs);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await waitForAnimationFrames(switchedTab ? 2 : 1);
|
||||
scrollToErrorField(normalizedNamePath);
|
||||
};
|
||||
//if (!form.isFieldsTouched()) return <></>;
|
||||
return (
|
||||
<Form.Item className="form-fields-changed" shouldUpdate style={{ margin: 0, padding: 0, minHeight: "unset" }}>
|
||||
{() => {
|
||||
const errors = form.getFieldsError().filter((e) => e.errors.length > 0);
|
||||
const errors = form
|
||||
.getFieldsError()
|
||||
.filter((fieldError) => fieldError.errors.length > 0)
|
||||
.flatMap((fieldError) => {
|
||||
const tabMeta = getFieldOwningTabMeta(fieldError.name);
|
||||
|
||||
return fieldError.errors.map((errorMessage, errorIndex) => ({
|
||||
key: `${(fieldError.name || []).join(".")}-${errorIndex}-${errorMessage}`,
|
||||
message: errorMessage,
|
||||
namePath: fieldError.name,
|
||||
tabLabel: tabMeta.tabLabel
|
||||
}));
|
||||
});
|
||||
|
||||
const groupedErrors = errors.reduce((groups, error) => {
|
||||
const groupKey = error.tabLabel || "__ungrouped__";
|
||||
if (!groups[groupKey]) {
|
||||
groups[groupKey] = {
|
||||
key: groupKey,
|
||||
label: error.tabLabel,
|
||||
errors: []
|
||||
};
|
||||
}
|
||||
groups[groupKey].errors.push(error);
|
||||
return groups;
|
||||
}, {});
|
||||
const errorGroups = Object.values(groupedErrors);
|
||||
const hasTabbedErrorGroups = errorGroups.some((group) => Boolean(group.label));
|
||||
|
||||
if (form.isFieldsTouched())
|
||||
return (
|
||||
<Space orientation="vertical" style={{ width: "100%" }}>
|
||||
<Space orientation="vertical" style={{ width: "100%", marginBottom: 10 }}>
|
||||
<Prompt when={!skipPrompt} beforeUnload={true} message={t("general.messages.unsavedchangespopup")} />
|
||||
<AlertComponent
|
||||
type="warning"
|
||||
@@ -39,10 +222,35 @@ export default function FormsFieldChanged({ form, skipPrompt }) {
|
||||
{errors.length > 0 && (
|
||||
<AlertComponent
|
||||
type="error"
|
||||
message={t("general.labels.validationerror")}
|
||||
title={t("general.labels.validationerror")}
|
||||
description={
|
||||
<div>
|
||||
<ul>{errors.map((e, idx) => e.errors.map((e2, idx2) => <li key={`${idx}${idx2}`}>{e2}</li>))}</ul>
|
||||
<div className="form-fields-changed__error-groups">
|
||||
{errorGroups.map((group) => (
|
||||
<div key={group.key} className="form-fields-changed__error-group">
|
||||
{hasTabbedErrorGroups && group.label ? (
|
||||
<div className="form-fields-changed__error-group-title">{group.label}</div>
|
||||
) : null}
|
||||
<ul className="form-fields-changed__error-list">
|
||||
{group.errors.map((error) => (
|
||||
<li key={error.key}>
|
||||
{Array.isArray(error.namePath) && error.namePath.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="form-fields-changed__error-link"
|
||||
onClick={() => {
|
||||
handleErrorClick(error.namePath);
|
||||
}}
|
||||
>
|
||||
{error.message}
|
||||
</button>
|
||||
) : (
|
||||
error.message
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
showIcon
|
||||
|
||||
@@ -4,4 +4,47 @@
|
||||
min-height: unset !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__error-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
&__error-groups {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&__error-group {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__error-group-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__error-link {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
|
||||
&:hover {
|
||||
color: color-mix(in srgb, var(--ant-color-error) 82%, var(--ant-color-text));
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--ant-color-error) 32%, transparent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,88 @@
|
||||
import { Input } from "antd";
|
||||
import { PhoneFilled } from "@ant-design/icons";
|
||||
import { Button, Input, Space } from "antd";
|
||||
import i18n from "i18next";
|
||||
import parsePhoneNumber from "libphonenumber-js";
|
||||
import { forwardRef, useMemo, useState } from "react";
|
||||
import "./phone-form-item.styles.scss";
|
||||
|
||||
function FormItemPhone({ ref, ...props }) {
|
||||
return <Input ref={ref} {...props} />;
|
||||
}
|
||||
/**
|
||||
* Formats a phone number for display purposes. If the input value is a valid phone number, it will be formatted in a
|
||||
* national format (e.g., (123) 456-7890 for US/CA). If the input is not a valid phone number, it will be returned as-is.
|
||||
* @param value
|
||||
* @returns {*}
|
||||
*/
|
||||
const formatPhoneDisplayValue = (value) => {
|
||||
if (!value) return value;
|
||||
|
||||
try {
|
||||
const parsedPhone = parsePhoneNumber(value, "CA");
|
||||
return parsedPhone?.isValid() ? parsedPhone.formatNational() : value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a "tel:" URL for a phone number if it's valid. If the input value is a valid phone number, it will return a
|
||||
* URL in the format "tel:+1234567890". If the input is not a valid phone number, it will attempt to trim whitespace and
|
||||
* return a "tel:" URL with the raw value, or null if the trimmed value is empty.
|
||||
* @param value
|
||||
* @returns {string|null}
|
||||
*/
|
||||
const getPhoneActionHref = (value) => {
|
||||
if (!value) return null;
|
||||
|
||||
try {
|
||||
const parsedPhone = parsePhoneNumber(value, "CA");
|
||||
if (parsedPhone?.isValid()) return `tel:${parsedPhone.number}`;
|
||||
} catch {
|
||||
// Fall back to the raw value below.
|
||||
}
|
||||
|
||||
const trimmedValue = String(value).trim();
|
||||
return trimmedValue ? `tel:${trimmedValue}` : null;
|
||||
};
|
||||
|
||||
const FormItemPhone = forwardRef(function FormItemPhone(
|
||||
{ formatDisplayOnly = false, showPhoneAction = false, value, onBlur, onFocus, ...props },
|
||||
ref
|
||||
) {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const displayValue = useMemo(() => {
|
||||
if (!formatDisplayOnly || isFocused) return value;
|
||||
return formatPhoneDisplayValue(value);
|
||||
}, [formatDisplayOnly, isFocused, value]);
|
||||
const phoneActionHref = useMemo(() => (showPhoneAction ? getPhoneActionHref(value) : null), [showPhoneAction, value]);
|
||||
|
||||
const input = (
|
||||
<Input
|
||||
ref={ref}
|
||||
{...props}
|
||||
value={displayValue}
|
||||
onFocus={(event) => {
|
||||
setIsFocused(true);
|
||||
onFocus?.(event);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
setIsFocused(false);
|
||||
onBlur?.(event);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!showPhoneAction) return input;
|
||||
|
||||
return (
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
{input}
|
||||
{phoneActionHref ? (
|
||||
<Button icon={<PhoneFilled />} href={phoneActionHref} />
|
||||
) : (
|
||||
<Button icon={<PhoneFilled />} disabled />
|
||||
)}
|
||||
</Space.Compact>
|
||||
);
|
||||
});
|
||||
|
||||
export default FormItemPhone;
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { LinkOutlined } from "@ant-design/icons";
|
||||
import { Button, Input, Space } from "antd";
|
||||
import { forwardRef, useMemo } from "react";
|
||||
|
||||
const HAS_URL_PROTOCOL_REGEX = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
|
||||
const LOCALHOST_OR_IP_REGEX = /^(localhost|127(?:\.\d{1,3}){3}|\d{1,3}(?:\.\d{1,3}){3})(:\d+)?(\/.*)?$/i;
|
||||
|
||||
const getUrlActionHref = (value) => {
|
||||
const trimmedValue = String(value ?? "").trim();
|
||||
if (!trimmedValue) return null;
|
||||
|
||||
if (HAS_URL_PROTOCOL_REGEX.test(trimmedValue)) return trimmedValue;
|
||||
if (trimmedValue.startsWith("//")) return `https:${trimmedValue}`;
|
||||
if (LOCALHOST_OR_IP_REGEX.test(trimmedValue)) return `http://${trimmedValue}`;
|
||||
|
||||
return `https://${trimmedValue}`;
|
||||
};
|
||||
|
||||
const FormItemUrl = forwardRef(function FormItemUrl({ value, defaultValue, ...props }, ref) {
|
||||
const urlActionHref = useMemo(() => getUrlActionHref(value ?? defaultValue), [defaultValue, value]);
|
||||
|
||||
return (
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<Input ref={ref} {...props} value={value} defaultValue={defaultValue} />
|
||||
{urlActionHref ? (
|
||||
<Button icon={<LinkOutlined />} href={urlActionHref} target="_blank" rel="noopener noreferrer" />
|
||||
) : (
|
||||
<Button icon={<LinkOutlined />} disabled />
|
||||
)}
|
||||
</Space.Compact>
|
||||
);
|
||||
});
|
||||
|
||||
export default FormItemUrl;
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Normalize Form Item List Titles
|
||||
* @param value
|
||||
* @returns {*|string}
|
||||
*/
|
||||
const normalizeFormListTitleValue = (value) => {
|
||||
if (value === null || value === undefined) return "";
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => normalizeFormListTitleValue(item))
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
return String(value).trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get Form Listem Item Title
|
||||
* @param fallbackLabel
|
||||
* @param index
|
||||
* @param candidates
|
||||
* @returns {*|string}
|
||||
*/
|
||||
export function getFormListItemTitle(fallbackLabel, index, ...candidates) {
|
||||
const title = candidates.map((candidate) => normalizeFormListTitleValue(candidate)).find(Boolean);
|
||||
|
||||
return title || `${fallbackLabel} ${index + 1}`;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { connect } from "react-redux";
|
||||
import { Link } from "react-router-dom";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import WssStatusDisplayComponent from "../../components/wss-status-display/wss-status-display.component.jsx";
|
||||
import { useTreatment } from "../../feature-flags/splitio-react-replacement.jsx";
|
||||
import { selectIsPartsEntry } from "../../redux/application/application.selectors.js";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
|
||||
|
||||
@@ -16,6 +17,12 @@ const mapStateToProps = createStructuredSelector({
|
||||
|
||||
export function GlobalFooter({ isPartsEntry }) {
|
||||
const { t } = useTranslation();
|
||||
const testFlagTreatment = useTreatment({ name: "TEST_FLAG" });
|
||||
const testFlagEnabled = testFlagTreatment === "on";
|
||||
|
||||
const testFlagIndicator = testFlagEnabled ? (
|
||||
<div style={{ fontWeight: 600, marginTop: 4 }}>Test Feature Flag Enabled</div>
|
||||
) : null;
|
||||
|
||||
if (isPartsEntry) {
|
||||
return (
|
||||
@@ -38,6 +45,7 @@ export function GlobalFooter({ isPartsEntry }) {
|
||||
<Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}>
|
||||
Disclaimer & Notices
|
||||
</Link>
|
||||
{testFlagIndicator}
|
||||
</div>
|
||||
</Footer>
|
||||
);
|
||||
@@ -74,6 +82,7 @@ export function GlobalFooter({ isPartsEntry }) {
|
||||
<Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}>
|
||||
Disclaimer & Notices
|
||||
</Link>
|
||||
{testFlagIndicator}
|
||||
</div>
|
||||
</Footer>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { BellFilled } from "@ant-design/icons";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Badge, Layout, Menu, Spin, Tooltip } from "antd";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SyncOutlined } from "@ant-design/icons";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { Button, Card, Col, Row, Tag } from "antd";
|
||||
import { Button, Card, Checkbox, Col, Row, Space, Tag } from "antd";
|
||||
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
@@ -12,6 +12,9 @@ import { DateTimeFormatter } from "../../utils/DateFormatter";
|
||||
import BlurWrapperComponent from "../feature-wrapper/blur-wrapper.component";
|
||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
||||
import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
|
||||
import axios from "axios";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext";
|
||||
import { hasDocumensoApiKey } from "../../utils/esignature.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
@@ -23,6 +26,8 @@ export default connect(mapStateToProps, mapDispatchToProps)(JobAuditTrail);
|
||||
|
||||
export function JobAuditTrail({ bodyshop, jobId }) {
|
||||
const { t } = useTranslation();
|
||||
const notification = useNotification();
|
||||
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
|
||||
const { loading, data, refetch } = useQuery(QUERY_AUDIT_TRAIL, {
|
||||
variables: { jobid: jobId },
|
||||
skip: !jobId,
|
||||
@@ -53,6 +58,145 @@ export function JobAuditTrail({ bodyshop, jobId }) {
|
||||
)
|
||||
}
|
||||
];
|
||||
const esigColumns = [
|
||||
{
|
||||
title: t("esignature.fields.created_at"),
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
render: (text) => <DateTimeFormatter>{text}</DateTimeFormatter>
|
||||
},
|
||||
{
|
||||
title: t("esignature.fields.updated_at"),
|
||||
dataIndex: "updated_at",
|
||||
key: "updated_at",
|
||||
render: (text) => <DateTimeFormatter>{text}</DateTimeFormatter>
|
||||
},
|
||||
{
|
||||
title: t("esignature.fields.title"),
|
||||
dataIndex: "title",
|
||||
key: "title",
|
||||
render: (text) => (
|
||||
<BlurWrapperComponent featureName="audit" bypass>
|
||||
<div>{text}</div>
|
||||
</BlurWrapperComponent>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t("esignature.fields.external_document_id"),
|
||||
dataIndex: "external_document_id",
|
||||
key: "external_document_id",
|
||||
render: (text) => (
|
||||
<BlurWrapperComponent featureName="audit" bypass>
|
||||
<div>{text}</div>
|
||||
</BlurWrapperComponent>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t("esignature.fields.status"),
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
render: (text) => (
|
||||
<BlurWrapperComponent featureName="audit" bypass>
|
||||
<div>{text}</div>
|
||||
</BlurWrapperComponent>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t("esignature.fields.opened"),
|
||||
dataIndex: "opened",
|
||||
key: "opened",
|
||||
render: (text) => <Checkbox checked={text} disabled />
|
||||
},
|
||||
{
|
||||
title: t("esignature.fields.rejected"),
|
||||
dataIndex: "rejected",
|
||||
key: "rejected",
|
||||
render: (text) => <Checkbox checked={text} disabled />
|
||||
},
|
||||
{
|
||||
title: t("esignature.fields.completed"),
|
||||
dataIndex: "completed",
|
||||
key: "completed",
|
||||
render: (text) => <Checkbox checked={text} disabled />
|
||||
},
|
||||
{
|
||||
title: t("esignature.fields.completed_at"),
|
||||
dataIndex: "completed_at",
|
||||
key: "completed_at",
|
||||
render: (text) => <DateTimeFormatter>{text}</DateTimeFormatter>
|
||||
},
|
||||
{
|
||||
title: t("general.labels.actions"),
|
||||
dataIndex: "actions",
|
||||
key: "actions",
|
||||
render: (_text, record) => (
|
||||
<Space wrap>
|
||||
<Button
|
||||
disabled={record.completed_at !== null || record.status === "REJECTED"}
|
||||
onClick={async () => {
|
||||
logImEXEvent("job_esig_delete", {});
|
||||
try {
|
||||
await axios.post("/esign/delete", {
|
||||
documentId: record.external_document_id,
|
||||
bodyshopid: bodyshop.id
|
||||
});
|
||||
refetch();
|
||||
} catch (error) {
|
||||
console.error("Error deleting document:", error?.response?.data || error.message);
|
||||
notification.error({
|
||||
message: t("esignature.delete_error"),
|
||||
description: error?.response?.data?.error || error.message
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("esignature.actions.delete")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
logImEXEvent("job_esig_redistribute", {});
|
||||
try {
|
||||
await axios.post("/esign/redistribute", {
|
||||
documentId: record.external_document_id,
|
||||
bodyshopid: bodyshop.id
|
||||
});
|
||||
|
||||
//Pop the success notification. Possible audit requery required.
|
||||
} catch (error) {
|
||||
console.error("Error viewing document:", error?.response?.data || error.message);
|
||||
notification.error({
|
||||
message: t("esignature.view_error"),
|
||||
description: error?.response?.data?.message || error.message
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("esignature.actions.redistribute")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
logImEXEvent("job_esig_view", {});
|
||||
try {
|
||||
const response = await axios.post("/esign/view", {
|
||||
documentId: record.external_document_id,
|
||||
bodyshopid: bodyshop.id
|
||||
});
|
||||
window.open(response.data?.document?.downloadUrl, "_blank");
|
||||
} catch (error) {
|
||||
console.error("Error viewing document:", error?.response?.data || error.message);
|
||||
notification.error({
|
||||
message: t("esignature.view_error"),
|
||||
description: error?.response?.data?.message || error.message
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("esignature.actions.view")}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
const emailColumns = [
|
||||
{
|
||||
title: t("audit.fields.created"),
|
||||
@@ -184,6 +328,20 @@ export function JobAuditTrail({ bodyshop, jobId }) {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
{esignatureEnabled && (
|
||||
<Col span={24}>
|
||||
<Card title={t("jobs.labels.esignatures")}>
|
||||
<ResponsiveTable
|
||||
loading={loading}
|
||||
columns={esigColumns}
|
||||
mobileColumnKeys={["title", "status"]}
|
||||
rowKey="id"
|
||||
scroll={{ x: true }}
|
||||
dataSource={data ? data.esignature_documents : []}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { GET_LINE_TICKET_BY_PK } from "../../graphql/jobs-lines.queries";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useApolloClient } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Popconfirm } from "antd";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -30,7 +30,7 @@ import JobLinesBillRefernece from "../job-lines-bill-reference/job-lines-bill-re
|
||||
// import AllocationsAssignmentContainer from "../allocations-assignment/allocations-assignment.container";
|
||||
// import AllocationsBulkAssignmentContainer from "../allocations-bulk-assignment/allocations-bulk-assignment.container";
|
||||
// import AllocationsEmployeeLabelContainer from "../allocations-employee-label/allocations-employee-label.container";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import _ from "lodash";
|
||||
import { FaTasks } from "react-icons/fa";
|
||||
import { selectAuthLevel, selectBodyshop } from "../../redux/user/user.selectors";
|
||||
@@ -44,6 +44,7 @@ import JoblineTeamAssignment from "../job-line-team-assignment/job-line-team-ass
|
||||
import JobSendPartPriceChangeComponent from "../job-send-parts-price-change/job-send-parts-price-change.component";
|
||||
import PartsOrderDrawer from "../parts-order-list-table/parts-order-list-table-drawer.component";
|
||||
import PartsOrderModalContainer from "../parts-order-modal/parts-order-modal.container";
|
||||
import { buildInHouseBillLines } from "./job-lines.in-house-bill-lines.utils";
|
||||
import JobLinesExpander from "./job-lines-expander.component";
|
||||
import JobLinesPartPriceChange from "./job-lines-part-price-change.component";
|
||||
import JobLinesExpanderSimple from "./jobs-lines-expander-simple.component";
|
||||
@@ -595,16 +596,7 @@ export function JobLinesComponent({
|
||||
isinhouse: true,
|
||||
date: dayjs(),
|
||||
total: 0,
|
||||
billlines: selectedLines.map((p) => ({
|
||||
joblineid: p.id,
|
||||
actual_price: p.act_price,
|
||||
actual_cost: 0,
|
||||
line_desc: p.line_desc,
|
||||
line_remarks: p.line_remarks,
|
||||
part_type: p.part_type,
|
||||
quantity: p.quantity || 1,
|
||||
applicable_taxes: { local: false, state: false, federal: false }
|
||||
}))
|
||||
billlines: buildInHouseBillLines(selectedLines)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const buildInHouseBillLines = (lines) =>
|
||||
lines.map((line) => ({
|
||||
joblineid: line.id,
|
||||
actual_price: line.act_price,
|
||||
actual_cost: 0,
|
||||
line_desc: line.line_desc,
|
||||
line_remarks: line.line_remarks,
|
||||
part_type: line.part_type,
|
||||
quantity: line.part_qty ?? line.quantity ?? 1,
|
||||
applicable_taxes: { local: false, state: false, federal: false }
|
||||
}));
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildInHouseBillLines } from "./job-lines.in-house-bill-lines.utils";
|
||||
|
||||
describe("buildInHouseBillLines", () => {
|
||||
it("carries job line part quantity into the in-house bill line", () => {
|
||||
const billLines = buildInHouseBillLines([
|
||||
{
|
||||
id: "job-line-1",
|
||||
act_price: 125,
|
||||
line_desc: "Door shell",
|
||||
line_remarks: "Left",
|
||||
part_type: "PAA",
|
||||
part_qty: 3
|
||||
}
|
||||
]);
|
||||
|
||||
expect(billLines[0]).toMatchObject({
|
||||
joblineid: "job-line-1",
|
||||
actual_price: 125,
|
||||
actual_cost: 0,
|
||||
line_desc: "Door shell",
|
||||
line_remarks: "Left",
|
||||
part_type: "PAA",
|
||||
quantity: 3,
|
||||
applicable_taxes: { local: false, state: false, federal: false }
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to legacy quantity and then one when part quantity is absent", () => {
|
||||
expect(buildInHouseBillLines([{ id: "legacy", quantity: 2 }])[0].quantity).toBe(2);
|
||||
expect(buildInHouseBillLines([{ id: "missing" }])[0].quantity).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -63,7 +63,9 @@ export function JobLineDispatchButton({
|
||||
}
|
||||
}
|
||||
//joblineids: selectedLines.map((l) => l.id),
|
||||
}
|
||||
},
|
||||
refetchQueries: ["QUERY_PARTS_BILLS_BY_JOBID", "GET_JOB_BY_PK"],
|
||||
awaitRefetchQueries: true
|
||||
});
|
||||
if (result.errors) {
|
||||
console.log("🚀 ~ handleConvert ~ result.errors:", result.errors);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Form, Input, InputNumber, Modal, Select, Switch } from "antd";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -67,22 +67,25 @@ export function JobLinesUpsertModalComponent({ bodyshop, open, jobLine, handleCa
|
||||
</LayoutFormRow>
|
||||
<LayoutFormRow grow>
|
||||
<Form.Item label={t("joblines.fields.mod_lbr_ty")} name="mod_lbr_ty">
|
||||
<Select allowClear options={[
|
||||
{ value: "LAA", label: t("joblines.fields.lbr_types.LAA") },
|
||||
{ value: "LAB", label: t("joblines.fields.lbr_types.LAB") },
|
||||
{ value: "LAD", label: t("joblines.fields.lbr_types.LAD") },
|
||||
{ value: "LAE", label: t("joblines.fields.lbr_types.LAE") },
|
||||
{ value: "LAF", label: t("joblines.fields.lbr_types.LAF") },
|
||||
{ value: "LAG", label: t("joblines.fields.lbr_types.LAG") },
|
||||
{ value: "LAM", label: t("joblines.fields.lbr_types.LAM") },
|
||||
{ value: "LAR", label: t("joblines.fields.lbr_types.LAR") },
|
||||
{ value: "LAS", label: t("joblines.fields.lbr_types.LAS") },
|
||||
{ value: "LAU", label: t("joblines.fields.lbr_types.LAU") },
|
||||
{ value: "LA1", label: t("joblines.fields.lbr_types.LA1") },
|
||||
{ value: "LA2", label: t("joblines.fields.lbr_types.LA2") },
|
||||
{ value: "LA3", label: t("joblines.fields.lbr_types.LA3") },
|
||||
{ value: "LA4", label: t("joblines.fields.lbr_types.LA4") }
|
||||
]} />
|
||||
<Select
|
||||
allowClear
|
||||
options={[
|
||||
{ value: "LAA", label: t("joblines.fields.lbr_types.LAA") },
|
||||
{ value: "LAB", label: t("joblines.fields.lbr_types.LAB") },
|
||||
{ value: "LAD", label: t("joblines.fields.lbr_types.LAD") },
|
||||
{ value: "LAE", label: t("joblines.fields.lbr_types.LAE") },
|
||||
{ value: "LAF", label: t("joblines.fields.lbr_types.LAF") },
|
||||
{ value: "LAG", label: t("joblines.fields.lbr_types.LAG") },
|
||||
{ value: "LAM", label: t("joblines.fields.lbr_types.LAM") },
|
||||
{ value: "LAR", label: t("joblines.fields.lbr_types.LAR") },
|
||||
{ value: "LAS", label: t("joblines.fields.lbr_types.LAS") },
|
||||
{ value: "LAU", label: t("joblines.fields.lbr_types.LAU") },
|
||||
{ value: "LA1", label: t("joblines.fields.lbr_types.LA1") },
|
||||
{ value: "LA2", label: t("joblines.fields.lbr_types.LA2") },
|
||||
{ value: "LA3", label: t("joblines.fields.lbr_types.LA3") },
|
||||
{ value: "LA4", label: t("joblines.fields.lbr_types.LA4") }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("joblines.fields.op_code_desc")} name="op_code_desc">
|
||||
<Input />
|
||||
@@ -128,21 +131,27 @@ export function JobLinesUpsertModalComponent({ bodyshop, open, jobLine, handleCa
|
||||
</LayoutFormRow>
|
||||
<LayoutFormRow>
|
||||
<Form.Item label={t("joblines.fields.part_type")} name="part_type">
|
||||
<Select allowClear options={[
|
||||
{ value: "PAA", label: t("joblines.fields.part_types.PAA") },
|
||||
{ value: "PAC", label: t("joblines.fields.part_types.PAC") },
|
||||
{ value: "PAE", label: t("joblines.fields.part_types.PAE") },
|
||||
{ value: "PAL", label: t("joblines.fields.part_types.PAL") },
|
||||
{ value: "PAM", label: t("joblines.fields.part_types.PAM") },
|
||||
{ value: "PAN", label: t("joblines.fields.part_types.PAN") },
|
||||
{ value: "PAO", label: t("joblines.fields.part_types.PAO") },
|
||||
{ value: "PAR", label: t("joblines.fields.part_types.PAR") },
|
||||
{ value: "PAS", label: t("joblines.fields.part_types.PAS") }
|
||||
]} />
|
||||
<Select
|
||||
allowClear
|
||||
options={[
|
||||
{ value: "PAA", label: t("joblines.fields.part_types.PAA") },
|
||||
{ value: "PAC", label: t("joblines.fields.part_types.PAC") },
|
||||
{ value: "PAE", label: t("joblines.fields.part_types.PAE") },
|
||||
{ value: "PAL", label: t("joblines.fields.part_types.PAL") },
|
||||
{ value: "PAM", label: t("joblines.fields.part_types.PAM") },
|
||||
{ value: "PAN", label: t("joblines.fields.part_types.PAN") },
|
||||
{ value: "PAO", label: t("joblines.fields.part_types.PAO") },
|
||||
{ value: "PAR", label: t("joblines.fields.part_types.PAR") },
|
||||
{ value: "PAS", label: t("joblines.fields.part_types.PAS") }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("joblines.fields.oem_partno")} name="oem_partno">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("joblines.fields.alt_partno")} name="alt_partno">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("joblines.fields.part_qty")}
|
||||
name="part_qty"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import Axios from "axios";
|
||||
import Dinero from "dinero.js";
|
||||
import { useState } from "react";
|
||||
@@ -14,16 +14,20 @@ import CriticalPartsScan from "../../utils/criticalPartsScan";
|
||||
import UndefinedToNull from "../../utils/undefinedtonull";
|
||||
import JobLinesUpdsertModal from "./job-lines-upsert-modal.component";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
|
||||
import { insertAuditTrail } from "../../redux/application/application.actions.js";
|
||||
import { buildJobLineInsertAuditDetails, buildJobLineUpdateAuditDetails } from "../../utils/auditTrailDetails.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
jobLineEditModal: selectJobLineEditModal,
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
toggleModalVisible: () => dispatch(toggleModalVisible("jobLineEdit"))
|
||||
toggleModalVisible: () => dispatch(toggleModalVisible("jobLineEdit")),
|
||||
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
|
||||
});
|
||||
|
||||
function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bodyshop }) {
|
||||
function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bodyshop, insertAuditTrail }) {
|
||||
const {
|
||||
treatments: { CriticalPartsScanning }
|
||||
} = useTreatmentsWithConfig({
|
||||
@@ -74,6 +78,11 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo
|
||||
notification.success({
|
||||
title: t("joblines.successes.created")
|
||||
});
|
||||
insertAuditTrail({
|
||||
jobid: jobLineEditModal.context.jobid,
|
||||
operation: AuditTrailMapping.jobmanuallineinsert(buildJobLineInsertAuditDetails(values)),
|
||||
type: "jobmanuallineinsert"
|
||||
});
|
||||
} else {
|
||||
notification.error({
|
||||
title: t("joblines.errors.creating", {
|
||||
@@ -103,6 +112,17 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo
|
||||
notification.success({
|
||||
title: t("joblines.successes.updated")
|
||||
});
|
||||
insertAuditTrail({
|
||||
jobid: jobLineEditModal.context.jobid,
|
||||
operation: AuditTrailMapping.joblineupdate(
|
||||
values.line_desc || jobLineEditModal.context.line_desc || "manual line",
|
||||
buildJobLineUpdateAuditDetails({
|
||||
originalLine: jobLineEditModal.context,
|
||||
values
|
||||
})
|
||||
),
|
||||
type: "joblineupdate"
|
||||
});
|
||||
} else {
|
||||
notification.success({
|
||||
title: t("joblines.errors.updating", {
|
||||
|
||||
@@ -16,7 +16,7 @@ import DataLabel from "../data-label/data-label.component";
|
||||
import PaymentExpandedRowComponent from "../payment-expanded-row/payment-expanded-row.component";
|
||||
import PaymentsGenerateLink from "../payments-generate-link/payments-generate-link.component";
|
||||
import PrintWrapperComponent from "../print-wrapper/print-wrapper.component";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ADD_JOB_WATCHER, GET_JOB_WATCHERS, REMOVE_JOB_WATCHER } from "../../gra
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import JobWatcherToggleComponent from "./job-watcher-toggle.component.jsx";
|
||||
import { useIsEmployee } from "../../utils/useIsEmployee.js";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useApolloClient, useLazyQuery, useMutation, useQuery } from "@apollo/client/react";
|
||||
import { gql } from "@apollo/client";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Col, Row } from "antd";
|
||||
import Axios from "axios";
|
||||
import _ from "lodash";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
|
||||
import { insertAuditTrail } from "../../redux/application/application.actions";
|
||||
import { selectJobReadOnly } from "../../redux/application/application.selectors";
|
||||
@@ -224,14 +224,10 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
name={["ins_co_nm"]}
|
||||
label={t("jobs.fields.ins_co_nm")}
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Form.Item name={["ins_co_nm"]} label={t("jobs.fields.ins_co_nm")} rules={[{ required: true }]}>
|
||||
<Select
|
||||
showSearch={{
|
||||
optionFilterProp:'label'
|
||||
optionFilterProp: "label"
|
||||
}}
|
||||
options={insuranceOptions}
|
||||
/>
|
||||
@@ -250,7 +246,7 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
|
||||
label={t("jobs.fields.referralsource")}
|
||||
rules={[{ required: bodyshop.enforce_referral }]}
|
||||
>
|
||||
<Select options={referralOptions} />
|
||||
<Select showSearch={{ optionFilterProp: "label" }} options={referralOptions} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
|
||||
@@ -272,19 +268,21 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
|
||||
>
|
||||
<Select
|
||||
showSearch={{
|
||||
optionFilterProp: 'label',
|
||||
filterOption: (input, option) =>
|
||||
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
||||
optionFilterProp: "label",
|
||||
filterOption: (input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
||||
}}
|
||||
style={{ width: 200 }}
|
||||
|
||||
options={csrOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{bodyshop.enforce_conversion_category && (
|
||||
<Form.Item name="category" label={t("jobs.fields.category")} rules={[{ required: bodyshop.enforce_conversion_category }]}>
|
||||
<Form.Item
|
||||
name="category"
|
||||
label={t("jobs.fields.category")}
|
||||
rules={[{ required: bodyshop.enforce_conversion_category }]}
|
||||
>
|
||||
<Select allowClear options={categoryOptions} />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
@@ -193,6 +193,9 @@ export function JobsCreateJobsInfo({ bodyshop, form, selected }) {
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.referralsource")} name="referral_source">
|
||||
<Select
|
||||
showSearch={{
|
||||
optionFilterProp: "label"
|
||||
}}
|
||||
options={bodyshop.md_referral_sources.map((s) => ({
|
||||
value: s,
|
||||
label: s
|
||||
|
||||
@@ -43,19 +43,25 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
|
||||
<Input disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ded_status")} name="ded_status">
|
||||
<Select disabled={jobRO} options={[
|
||||
{ value: "W", label: t("jobs.labels.deductible.waived") },
|
||||
{ value: "Y", label: t("jobs.labels.deductible.stands") }
|
||||
]} />
|
||||
<Select
|
||||
disabled={jobRO}
|
||||
options={[
|
||||
{ value: "W", label: t("jobs.labels.deductible.waived") },
|
||||
{ value: "Y", label: t("jobs.labels.deductible.stands") }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ded_amt")} name="ded_amt">
|
||||
<CurrencyInput disabled={jobRO} min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ded_note")} name="ded_note">
|
||||
<Select disabled={jobRO} options={bodyshop.md_ded_notes.map((n) => ({
|
||||
value: n,
|
||||
label: n
|
||||
}))} />
|
||||
<Select
|
||||
disabled={jobRO}
|
||||
options={bodyshop.md_ded_notes.map((n) => ({
|
||||
value: n,
|
||||
label: n
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.policy_no")} name="policy_no">
|
||||
<Input disabled={jobRO} />
|
||||
@@ -65,10 +71,14 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t("jobs.fields.ins_co_nm")} name="ins_co_nm">
|
||||
<Select disabled={jobRO} onChange={handleInsCoChange} options={bodyshop.md_ins_cos.map((s) => ({
|
||||
value: s.name,
|
||||
label: s.name
|
||||
}))} />
|
||||
<Select
|
||||
disabled={jobRO}
|
||||
onChange={handleInsCoChange}
|
||||
options={bodyshop.md_ins_cos.map((s) => ({
|
||||
value: s.name,
|
||||
label: s.name
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ins_addr1")} name="ins_addr1">
|
||||
<Input disabled={jobRO} />
|
||||
@@ -119,19 +129,30 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select disabled={jobRO} allowClear options={bodyshop.md_referral_sources.map((s) => ({
|
||||
value: s,
|
||||
label: s
|
||||
}))} />
|
||||
<Select
|
||||
disabled={jobRO}
|
||||
allowClear
|
||||
showSearch={{
|
||||
optionFilterProp: "label"
|
||||
}}
|
||||
options={bodyshop.md_referral_sources.map((s) => ({
|
||||
value: s,
|
||||
label: s
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
|
||||
<Input disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.alt_transport")} name="alt_transport">
|
||||
<Select disabled={jobRO} allowClear options={bodyshop.appt_alt_transport.map((s) => ({
|
||||
value: s,
|
||||
label: s
|
||||
}))} />
|
||||
<Select
|
||||
disabled={jobRO}
|
||||
allowClear
|
||||
options={bodyshop.appt_alt_transport.map((s) => ({
|
||||
value: s,
|
||||
label: s
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormRow>
|
||||
<Row gutter={[16, 16]}>
|
||||
@@ -233,10 +254,14 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
|
||||
</FormRow>
|
||||
<FormRow header={t("jobs.forms.other")}>
|
||||
<Form.Item label={t("jobs.fields.category")} name="category">
|
||||
<Select disabled={jobRO} allowClear options={bodyshop.md_categories.map((s) => ({
|
||||
value: s,
|
||||
label: s
|
||||
}))} />
|
||||
<Select
|
||||
disabled={jobRO}
|
||||
allowClear
|
||||
options={bodyshop.md_categories.map((s) => ({
|
||||
value: s,
|
||||
label: s
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer">
|
||||
<Input disabled={jobRO} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DownCircleFilled } from "@ant-design/icons";
|
||||
import { useApolloClient, useMutation, useQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Card, Dropdown, Form, Input, Modal, Popover, Select, Space } from "antd";
|
||||
import axios from "axios";
|
||||
import parsePhoneNumber from "libphonenumber-js";
|
||||
|
||||
@@ -6,7 +6,7 @@ import LaborAllocationsTableComponent from "../labor-allocations-table/labor-all
|
||||
import TimeTicketList from "../time-ticket-list/time-ticket-list.component";
|
||||
import PayrollLaborAllocationsTable from "../labor-allocations-table/labor-allocations-table.payroll.component";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
jobRO: selectJobReadOnly,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import cleanAxios from "../../utils/CleanAxios";
|
||||
import formatBytes from "../../utils/formatbytes";
|
||||
//import yauzl from "yauzl";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
|
||||
@@ -7,7 +7,7 @@ import JobDocuments from "./jobs-documents-gallery.component";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Button, Card, Col, Row, Space } from "antd";
|
||||
import axios from "axios";
|
||||
import i18n from "i18next";
|
||||
import { isFunction } from "lodash";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Lightbox from "react-image-lightbox";
|
||||
import "react-image-lightbox/style.css";
|
||||
@@ -12,12 +12,12 @@ import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import DocumentsUploadImgproxyComponent from "../documents-upload-imgproxy/documents-upload-imgproxy.component";
|
||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
||||
import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component";
|
||||
import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
|
||||
import JobsDocumentsDownloadButton from "./jobs-document-imgproxy-gallery.download.component";
|
||||
import JobsDocumentsGalleryReassign from "./jobs-document-imgproxy-gallery.reassign.component";
|
||||
import JobsDocumentsDeleteButton from "./jobs-documents-imgproxy-gallery.delete.component";
|
||||
import JobsDocumentsGallerySelectAllComponent from "./jobs-documents-imgproxy-gallery.selectall.component";
|
||||
import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
@@ -38,6 +38,9 @@ function JobsDocumentsImgproxyComponent({
|
||||
const [galleryImages, setGalleryImages] = useState({ images: [], other: [] });
|
||||
const { t } = useTranslation();
|
||||
const [modalState, setModalState] = useState({ open: false, index: 0 });
|
||||
const [previewUrls, setPreviewUrls] = useState({});
|
||||
const [previewError, setPreviewError] = useState(null);
|
||||
const previewUrlsRef = useRef({});
|
||||
|
||||
const fetchThumbnails = useCallback(() => {
|
||||
fetchImgproxyThumbnails({ setStateCallback: setGalleryImages, jobId, billId });
|
||||
@@ -49,8 +52,86 @@ function JobsDocumentsImgproxyComponent({
|
||||
}
|
||||
}, [data, fetchThumbnails]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Object.values(previewUrlsRef.current).forEach(URL.revokeObjectURL);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selectedImage = modalState.open ? galleryImages.images[modalState.index] : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalState.open || !selectedImage?.id) return;
|
||||
|
||||
if (previewUrlsRef.current[selectedImage.id]) {
|
||||
setPreviewError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
async function loadPreviewImage() {
|
||||
setPreviewError(null);
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
"/media/imgproxy/original",
|
||||
{ documentId: selectedImage.id },
|
||||
{
|
||||
responseType: "blob",
|
||||
signal: controller.signal
|
||||
}
|
||||
);
|
||||
const blobUrl = URL.createObjectURL(response.data);
|
||||
|
||||
previewUrlsRef.current = {
|
||||
...previewUrlsRef.current,
|
||||
[selectedImage.id]: blobUrl
|
||||
};
|
||||
setPreviewUrls(previewUrlsRef.current);
|
||||
} catch (error) {
|
||||
if (axios.isCancel?.(error) || error.name === "CanceledError") return;
|
||||
|
||||
console.error("Failed to fetch original image blob", error);
|
||||
setPreviewError(error);
|
||||
}
|
||||
}
|
||||
|
||||
loadPreviewImage();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [modalState.open, selectedImage?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (modalState.open && !selectedImage) {
|
||||
setModalState({ open: false, index: 0 });
|
||||
}
|
||||
}, [modalState.open, selectedImage]);
|
||||
|
||||
const openEditorForImage = useCallback((image) => {
|
||||
if (!image?.id) return;
|
||||
|
||||
const newWindow = window.open(
|
||||
`${window.location.protocol}//${window.location.host}/edit?documentId=${image.id}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
);
|
||||
if (newWindow) newWindow.opener = null;
|
||||
}, []);
|
||||
|
||||
const hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" });
|
||||
const hasMobileAccess = HasFeatureAccess({ bodyshop, featureName: "mobile" });
|
||||
const previewSrc = selectedImage ? previewUrls[selectedImage.id] : null;
|
||||
const getLightboxImageSrc = useCallback(
|
||||
(index) => {
|
||||
const image = galleryImages.images[index];
|
||||
return image ? previewUrls[image.id] || image.src : undefined;
|
||||
},
|
||||
[galleryImages.images, previewUrls]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={[16, 16]}>
|
||||
@@ -147,30 +228,33 @@ function JobsDocumentsImgproxyComponent({
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
{modalState.open && (
|
||||
{modalState.open && selectedImage && (
|
||||
<Lightbox
|
||||
toolbarButtons={[
|
||||
<EditFilled
|
||||
key="edit"
|
||||
onClick={() => {
|
||||
const newWindow = window.open(
|
||||
`${window.location.protocol}//${window.location.host}/edit?documentId=${
|
||||
galleryImages.images[modalState.index].id
|
||||
}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
);
|
||||
if (newWindow) newWindow.opener = null;
|
||||
openEditorForImage(selectedImage);
|
||||
}}
|
||||
/>
|
||||
]}
|
||||
mainSrc={galleryImages.images[modalState.index].fullsize}
|
||||
nextSrc={galleryImages.images[(modalState.index + 1) % galleryImages.images.length].fullsize}
|
||||
prevSrc={
|
||||
imageLoadErrorMessage={previewError ? t("general.errors.notfound") : undefined}
|
||||
mainSrc={previewSrc || selectedImage.src}
|
||||
mainSrcThumbnail={selectedImage.src}
|
||||
nextSrc={getLightboxImageSrc((modalState.index + 1) % galleryImages.images.length)}
|
||||
nextSrcThumbnail={galleryImages.images[(modalState.index + 1) % galleryImages.images.length]?.src}
|
||||
prevSrc={getLightboxImageSrc(
|
||||
(modalState.index + galleryImages.images.length - 1) % galleryImages.images.length
|
||||
)}
|
||||
prevSrcThumbnail={
|
||||
galleryImages.images[(modalState.index + galleryImages.images.length - 1) % galleryImages.images.length]
|
||||
.fullsize
|
||||
?.src
|
||||
}
|
||||
onCloseRequest={() => setModalState({ open: false, index: 0 })}
|
||||
reactModalProps={{ ariaHideApp: false }}
|
||||
onCloseRequest={() => {
|
||||
setModalState({ open: false, index: 0 });
|
||||
setPreviewError(null);
|
||||
}}
|
||||
onMovePrevRequest={() =>
|
||||
setModalState({
|
||||
...modalState,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Button } from "antd";
|
||||
import ConfigListEmptyState from "./config-list-empty-state.component.jsx";
|
||||
|
||||
export const buildConfigListActionButton = ({ key, label, onClick, id }) => (
|
||||
<Button key={key} type="primary" block id={id} onClick={onClick}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
|
||||
export const renderConfigListOrEmpty = ({ fields, actionLabel, renderItems }) =>
|
||||
fields.length === 0 ? <ConfigListEmptyState actionLabel={actionLabel} /> : renderItems();
|
||||
|
||||
export const buildSectionActionButton = (key, label, onClick, id) =>
|
||||
buildConfigListActionButton({ key, label, onClick, id });
|
||||
|
||||
export const renderListOrEmpty = (fields, actionLabel, renderItems) =>
|
||||
renderConfigListOrEmpty({ fields, actionLabel, renderItems });
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function ConfigListEmptyState({ actionLabel, minHeight = 96 }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="imex-form-row-empty-state" style={{ minHeight }}>
|
||||
{t("general.labels.click_to_begin", { action: actionLabel })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { UnorderedListOutlined } from "@ant-design/icons";
|
||||
|
||||
export const inlineFormRowTitleStyles = Object.freeze({
|
||||
input: Object.freeze({
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
borderRadius: 0,
|
||||
boxShadow: "none",
|
||||
paddingInline: 0,
|
||||
paddingBlock: 0,
|
||||
lineHeight: 1.35,
|
||||
flex: "1 1 auto",
|
||||
minWidth: 0,
|
||||
width: "100%"
|
||||
}),
|
||||
row: Object.freeze({
|
||||
display: "flex",
|
||||
gap: 6,
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
paddingInline: 4
|
||||
}),
|
||||
group: Object.freeze({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
paddingInline: 8,
|
||||
paddingBlock: 4,
|
||||
borderRadius: 10,
|
||||
border: "1px solid var(--imex-form-title-group-border)",
|
||||
background: "var(--imex-form-title-group-bg)",
|
||||
minWidth: 0,
|
||||
flex: "1 1 0"
|
||||
}),
|
||||
label: Object.freeze({
|
||||
color: "var(--ant-color-text-secondary)",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1,
|
||||
whiteSpace: "nowrap",
|
||||
paddingInline: 6,
|
||||
paddingBlock: 3,
|
||||
borderRadius: 999,
|
||||
border: "1px solid var(--imex-form-title-label-border)",
|
||||
background: "var(--imex-form-title-label-bg)"
|
||||
}),
|
||||
handle: Object.freeze({
|
||||
color: "var(--ant-color-text-tertiary)",
|
||||
fontSize: 14,
|
||||
flex: "0 0 auto",
|
||||
marginRight: 2
|
||||
}),
|
||||
separator: Object.freeze({
|
||||
width: 1,
|
||||
height: 16,
|
||||
background: "color-mix(in srgb, var(--imex-form-surface-border) 58%, transparent)",
|
||||
borderRadius: 999,
|
||||
flex: "0 0 auto",
|
||||
marginInline: 2
|
||||
}),
|
||||
text: Object.freeze({
|
||||
whiteSpace: "nowrap",
|
||||
fontWeight: 500,
|
||||
fontSize: "var(--ant-font-size-lg)",
|
||||
lineHeight: 1.2
|
||||
})
|
||||
});
|
||||
|
||||
export const INLINE_TITLE_INPUT_STYLE = inlineFormRowTitleStyles.input;
|
||||
export const INLINE_TITLE_ROW_STYLE = inlineFormRowTitleStyles.row;
|
||||
export const INLINE_TITLE_GROUP_STYLE = inlineFormRowTitleStyles.group;
|
||||
export const InlineTitleListIcon = UnorderedListOutlined;
|
||||
export const INLINE_TITLE_SWITCH_GROUP_STYLE = Object.freeze({
|
||||
...inlineFormRowTitleStyles.group,
|
||||
flex: "0 0 auto"
|
||||
});
|
||||
export const INLINE_TITLE_LABEL_STYLE = inlineFormRowTitleStyles.label;
|
||||
export const INLINE_TITLE_HANDLE_STYLE = inlineFormRowTitleStyles.handle;
|
||||
export const INLINE_TITLE_SEPARATOR_STYLE = inlineFormRowTitleStyles.separator;
|
||||
export const INLINE_TITLE_TEXT_STYLE = inlineFormRowTitleStyles.text;
|
||||
|
||||
export const INLINE_FORM_ROW_WRAP_TITLE_STYLES = Object.freeze({
|
||||
title: Object.freeze({
|
||||
whiteSpace: "normal",
|
||||
overflow: "visible",
|
||||
textOverflow: "unset"
|
||||
})
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Form } from "antd";
|
||||
import LayoutFormRow from "./layout-form-row.component";
|
||||
|
||||
export default function InlineValidatedFormRow({ actions, errorNames = [], extraErrors = [], form, ...layoutFormRowProps }) {
|
||||
const normalizedErrorNames = Array.isArray(errorNames) ? errorNames : [errorNames];
|
||||
const normalizedExtraErrors = Array.isArray(extraErrors) ? extraErrors.filter(Boolean) : [extraErrors].filter(Boolean);
|
||||
|
||||
return (
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{() => {
|
||||
const fieldErrors = normalizedErrorNames.flatMap((name) => form?.getFieldError?.(name) || []);
|
||||
const errors = [...new Set([...fieldErrors, ...normalizedExtraErrors])];
|
||||
const resolvedClassName = [
|
||||
layoutFormRowProps.className,
|
||||
errors.length > 0 ? "imex-form-row--error" : null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const normalizedActions = Array.isArray(actions) ? actions.filter(Boolean) : [actions].filter(Boolean);
|
||||
const resolvedActions =
|
||||
errors.length > 0
|
||||
? [
|
||||
<div
|
||||
key="inline-form-row-footer"
|
||||
className="imex-inline-form-row-errors"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: normalizedActions.length > 0 ? 8 : 0,
|
||||
width: "100%",
|
||||
textAlign: "left"
|
||||
}}
|
||||
>
|
||||
<Form.ErrorList errors={errors} />
|
||||
{normalizedActions.length > 0 ? <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>{normalizedActions}</div> : null}
|
||||
</div>
|
||||
]
|
||||
: normalizedActions.length > 0
|
||||
? normalizedActions
|
||||
: undefined;
|
||||
|
||||
return <LayoutFormRow {...layoutFormRowProps} className={resolvedClassName} actions={resolvedActions} />;
|
||||
}}
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Card, Col, Row } from "antd";
|
||||
import { Children, isValidElement } from "react";
|
||||
import { INLINE_FORM_ROW_WRAP_TITLE_STYLES } from "./inline-form-row-title.utils.js";
|
||||
import "./layout-form-row.styles.scss";
|
||||
|
||||
export default function LayoutFormRow({
|
||||
@@ -7,32 +8,45 @@ export default function LayoutFormRow({
|
||||
children,
|
||||
grow = false,
|
||||
noDivider = false,
|
||||
gutter = [16, 16], // Responsive gutter: horizontal, vertical
|
||||
titleOnly = false,
|
||||
wrapTitle = false,
|
||||
gutter,
|
||||
rowProps,
|
||||
|
||||
// Optional overrides if you ever need per-section customization
|
||||
surface = true,
|
||||
surfaceBg,
|
||||
surfaceHeaderBg,
|
||||
surfaceBorderColor,
|
||||
|
||||
...cardProps
|
||||
}) {
|
||||
const items = Children.toArray(children).filter(Boolean);
|
||||
if (items.length === 0) return null;
|
||||
const isCompactRow = noDivider;
|
||||
|
||||
const title = !noDivider && header ? header : undefined;
|
||||
const resolvedTitle = cardProps.title ?? title;
|
||||
const isHeaderOnly = titleOnly || items.length === 0;
|
||||
const hideBody = isHeaderOnly;
|
||||
|
||||
if (items.length === 0 && !resolvedTitle) return null;
|
||||
const resolvedGutter = gutter ?? [16, isCompactRow ? 8 : 16];
|
||||
|
||||
const bg = surfaceBg ?? (surface ? "var(--imex-form-surface)" : undefined);
|
||||
const headBg = surfaceHeaderBg ?? (surface ? "var(--imex-form-surface-head)" : undefined);
|
||||
const borderColor = surfaceBorderColor ?? (surface ? "var(--imex-form-surface-border)" : undefined);
|
||||
|
||||
const mergedStyles = mergeSemanticStyles(
|
||||
{
|
||||
...(wrapTitle ? INLINE_FORM_ROW_WRAP_TITLE_STYLES : null),
|
||||
header: {
|
||||
paddingInline: 16,
|
||||
background: headBg
|
||||
paddingInline: isHeaderOnly ? 8 : isCompactRow ? 12 : 16,
|
||||
background: headBg,
|
||||
borderBottomColor: borderColor
|
||||
},
|
||||
body: {
|
||||
padding: 16,
|
||||
padding: hideBody ? 0 : isCompactRow ? 12 : 16,
|
||||
display: hideBody ? "none" : undefined,
|
||||
background: bg
|
||||
}
|
||||
},
|
||||
@@ -40,28 +54,12 @@ export default function LayoutFormRow({
|
||||
);
|
||||
|
||||
const baseCardStyle = {
|
||||
marginBottom: ".8rem",
|
||||
marginBottom: isHeaderOnly ? "0" : isCompactRow ? "8px" : ".8rem",
|
||||
...(bg ? { background: bg } : null), // ensures the “circled area” is tinted
|
||||
...(borderColor ? { borderColor } : null),
|
||||
...cardProps.style
|
||||
};
|
||||
|
||||
// single child => just render it
|
||||
if (items.length === 1) {
|
||||
return (
|
||||
<Card
|
||||
{...cardProps}
|
||||
title={cardProps.title ?? title}
|
||||
size={cardProps.size ?? "small"}
|
||||
variant={cardProps.variant ?? "outlined"}
|
||||
className={["imex-form-row", cardProps.className].filter(Boolean).join(" ")}
|
||||
style={baseCardStyle}
|
||||
styles={mergedStyles}
|
||||
>
|
||||
{items[0]}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const count = items.length;
|
||||
|
||||
// Modern responsive strategy leveraging Ant Design 6:
|
||||
@@ -125,20 +123,32 @@ export default function LayoutFormRow({
|
||||
return (
|
||||
<Card
|
||||
{...cardProps}
|
||||
title={cardProps.title ?? title}
|
||||
title={resolvedTitle}
|
||||
size={cardProps.size ?? "small"}
|
||||
variant={cardProps.variant ?? "outlined"}
|
||||
className={["imex-form-row", cardProps.className].filter(Boolean).join(" ")}
|
||||
className={[
|
||||
"imex-form-row",
|
||||
isCompactRow ? "imex-form-row--compact" : null,
|
||||
isHeaderOnly ? "imex-form-row--title-only" : null,
|
||||
cardProps.className
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
style={baseCardStyle}
|
||||
styles={mergedStyles}
|
||||
>
|
||||
<Row gutter={gutter} wrap {...rowProps}>
|
||||
{items.map((child, idx) => (
|
||||
<Col key={child?.key ?? idx} {...getColPropsForChild(child)}>
|
||||
{child}
|
||||
</Col>
|
||||
{!isHeaderOnly &&
|
||||
(items.length === 1 ? (
|
||||
items[0]
|
||||
) : (
|
||||
<Row gutter={resolvedGutter} wrap {...rowProps}>
|
||||
{items.map((child, idx) => (
|
||||
<Col key={child?.key ?? idx} {...getColPropsForChild(child)}>
|
||||
{child}
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -152,6 +162,7 @@ function mergeSemanticStyles(defaults, userStyles) {
|
||||
return {
|
||||
...defaults,
|
||||
...computed,
|
||||
title: { ...(defaults.title || {}), ...(computed.title || {}) },
|
||||
header: { ...defaults.header, ...(computed.header || {}) },
|
||||
body: { ...defaults.body, ...(computed.body || {}) }
|
||||
};
|
||||
@@ -161,6 +172,7 @@ function mergeSemanticStyles(defaults, userStyles) {
|
||||
return {
|
||||
...defaults,
|
||||
...userStyles,
|
||||
title: { ...(defaults.title || {}), ...(userStyles.title || {}) },
|
||||
header: { ...defaults.header, ...(userStyles.header || {}) },
|
||||
body: { ...defaults.body, ...(userStyles.body || {}) }
|
||||
};
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
--imex-form-surface: #fafafa; /* subtle contrast vs white page */
|
||||
--imex-form-surface-head: #f5f5f5; /* header strip */
|
||||
--imex-form-surface-border: #d9d9d9; /* matches AntD-ish border */
|
||||
--imex-form-title-input-bg: rgba(255, 255, 255, 0.96);
|
||||
--imex-form-title-input-border: rgba(0, 0, 0, 0.08);
|
||||
--imex-form-title-group-bg: rgba(255, 255, 255, 0.72);
|
||||
--imex-form-title-group-border: rgba(0, 0, 0, 0.08);
|
||||
--imex-form-title-label-bg: rgba(0, 0, 0, 0.04);
|
||||
--imex-form-title-label-border: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* Pick the selector that matches your app and remove the rest */
|
||||
@@ -20,6 +26,12 @@ html[data-theme="dark"] {
|
||||
--imex-form-surface: rgba(255, 255, 255, 0.01); /* subtle lift off page bg */
|
||||
--imex-form-surface-head: rgba(255, 255, 255, 0.06); /* slightly stronger for header strip */
|
||||
--imex-form-surface-border: rgba(5, 5, 5, 0.12);
|
||||
--imex-form-title-input-bg: rgba(255, 255, 255, 0.12);
|
||||
--imex-form-title-input-border: rgba(255, 255, 255, 0.2);
|
||||
--imex-form-title-group-bg: rgba(255, 255, 255, 0.08);
|
||||
--imex-form-title-group-border: rgba(255, 255, 255, 0.16);
|
||||
--imex-form-title-label-bg: rgba(255, 255, 255, 0.06);
|
||||
--imex-form-title-label-border: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.imex-form-row {
|
||||
@@ -38,18 +50,111 @@ html[data-theme="dark"] {
|
||||
border-color: var(--imex-form-surface-border);
|
||||
}
|
||||
|
||||
&.imex-form-row--error.ant-card {
|
||||
border-color: var(--ant-color-error);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--ant-color-error) 24%, transparent);
|
||||
}
|
||||
|
||||
.ant-card-head {
|
||||
background: var(--imex-form-surface-head);
|
||||
border-bottom-color: var(--imex-form-surface-border);
|
||||
}
|
||||
|
||||
&.imex-form-row--error {
|
||||
.ant-card-head,
|
||||
.ant-card-actions {
|
||||
border-color: color-mix(in srgb, var(--ant-color-error) 34%, var(--imex-form-surface-border));
|
||||
}
|
||||
}
|
||||
|
||||
&.imex-form-row--compact {
|
||||
.ant-card-head {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.ant-card-head-title,
|
||||
.ant-card-extra {
|
||||
padding-block: 2px;
|
||||
}
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&.imex-form-row--title-only {
|
||||
.ant-card-head {
|
||||
min-height: auto;
|
||||
padding-inline: 6px;
|
||||
padding-block: 0;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.ant-card-head-wrapper {
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ant-card-head-title,
|
||||
.ant-card-extra {
|
||||
padding-block: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ant-card-head-title {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: unset;
|
||||
font-size: var(--ant-font-size);
|
||||
line-height: 1.1;
|
||||
padding-inline: 4px;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
display: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ant-input,
|
||||
.ant-input-number,
|
||||
.ant-input-affix-wrapper,
|
||||
.ant-select-selector,
|
||||
.ant-picker {
|
||||
background: var(--imex-form-title-input-bg);
|
||||
border-color: var(--imex-form-title-input-border);
|
||||
}
|
||||
|
||||
.ant-input-number-input {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
background: var(--imex-form-surface);
|
||||
}
|
||||
|
||||
.ant-card-actions {
|
||||
background: var(--imex-form-surface-head);
|
||||
border-top-color: var(--imex-form-surface-border);
|
||||
}
|
||||
|
||||
.ant-card-actions > li {
|
||||
margin: 10px 0;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.ant-card-actions .ant-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-form-item:last-child {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Optional: tighter spacing on phones for better space usage */
|
||||
@media (max-width: 575px) {
|
||||
.ant-card-head {
|
||||
&:not(.imex-form-row--title-only) .ant-card-head {
|
||||
padding-inline: 12px;
|
||||
padding-block: 12px;
|
||||
}
|
||||
@@ -70,6 +175,14 @@ html[data-theme="dark"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-form-item:has(.imex-form-row--compact) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ant-form-item:has(.imex-form-row--title-only) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Better form item spacing on mobile */
|
||||
@media (max-width: 575px) {
|
||||
.ant-form-item {
|
||||
@@ -77,3 +190,24 @@ html[data-theme="dark"] {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.imex-form-row-empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: var(--ant-color-text-description);
|
||||
font-size: var(--ant-font-size);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.imex-inline-form-row-errors {
|
||||
color: var(--ant-color-error);
|
||||
|
||||
.ant-form-item-explain,
|
||||
.ant-form-item-explain-error,
|
||||
.ant-form-item-additional {
|
||||
color: var(--ant-color-error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
const LockWrapper = ({ featureName, bodyshop, children, disabled = true, bypass }) => {
|
||||
const LockWrapper = ({ featureName, bodyshop, children, disabled = true, bypass, locked }) => {
|
||||
let renderedChildren = children;
|
||||
|
||||
//Mark the child prop as disabled.
|
||||
@@ -36,11 +36,13 @@ const LockWrapper = ({ featureName, bodyshop, children, disabled = true, bypass
|
||||
return <span>{children}</span>;
|
||||
}
|
||||
|
||||
return HasFeatureAccess({ featureName: featureName, bodyshop }) ? (
|
||||
const hasAccess = typeof locked === "boolean" ? !locked : HasFeatureAccess({ featureName: featureName, bodyshop });
|
||||
|
||||
return hasAccess ? (
|
||||
children
|
||||
) : (
|
||||
<Space>
|
||||
{!HasFeatureAccess({ featureName: featureName, bodyshop }) && <LockOutlined style={{ color: "tomato" }} />}
|
||||
<LockOutlined style={{ color: "tomato" }} />
|
||||
{renderedChildren}
|
||||
</Space>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
|
||||
import { Checkbox, Form } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PropTypes from "prop-types";
|
||||
@@ -9,18 +8,18 @@ import PropTypes from "prop-types";
|
||||
* @param form
|
||||
* @param disabled
|
||||
* @param onHeaderChange
|
||||
* @param scenarioKeys
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange }) => {
|
||||
const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange, scenarioKeys }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Subscribe to all form values so that this component re-renders on changes.
|
||||
const formValues = Form.useWatch([], form) || {};
|
||||
|
||||
// Determine if all scenarios for this channel are checked.
|
||||
const allChecked =
|
||||
notificationScenarios.length > 0 && notificationScenarios.every((scenario) => formValues[scenario]?.[channel]);
|
||||
const allChecked = scenarioKeys.length > 0 && scenarioKeys.every((scenario) => formValues[scenario]?.[channel]);
|
||||
|
||||
const onChange = (e) => {
|
||||
const checked = e.target.checked;
|
||||
@@ -28,7 +27,7 @@ const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange
|
||||
const currentValues = form.getFieldsValue();
|
||||
// Update each scenario for this channel.
|
||||
const newValues = { ...currentValues };
|
||||
notificationScenarios.forEach((scenario) => {
|
||||
scenarioKeys.forEach((scenario) => {
|
||||
newValues[scenario] = { ...newValues[scenario], [channel]: checked };
|
||||
});
|
||||
// Update form values.
|
||||
@@ -50,7 +49,8 @@ ColumnHeaderCheckbox.propTypes = {
|
||||
channel: PropTypes.oneOf(["app", "email", "fcm"]).isRequired,
|
||||
form: PropTypes.object.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
onHeaderChange: PropTypes.func
|
||||
onHeaderChange: PropTypes.func,
|
||||
scenarioKeys: PropTypes.arrayOf(PropTypes.string).isRequired
|
||||
};
|
||||
|
||||
export default ColumnHeaderCheckbox;
|
||||
|
||||
@@ -12,12 +12,13 @@ import {
|
||||
UPDATE_NOTIFICATION_SETTINGS,
|
||||
UPDATE_NOTIFICATIONS_AUTOADD
|
||||
} from "../../graphql/user.queries.js";
|
||||
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
|
||||
import { getNotificationScenarios, notificationScenarioDefaults } from "../../utils/jobNotificationScenarios.js";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
|
||||
import PropTypes from "prop-types";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx";
|
||||
import { useIsEmployee } from "../../utils/useIsEmployee.js";
|
||||
import { hasDocumensoApiKey } from "../../utils/esignature.js";
|
||||
|
||||
/**
|
||||
* Notifications Settings Form
|
||||
@@ -35,6 +36,7 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
|
||||
const [initialAutoAdd, setInitialAutoAdd] = useState(false);
|
||||
const notification = useNotification();
|
||||
const isEmployee = useIsEmployee(bodyshop, currentUser);
|
||||
const notificationScenarios = getNotificationScenarios({ includeEsign: hasDocumensoApiKey(bodyshop) });
|
||||
|
||||
// Fetch notification settings and notifications_autoadd
|
||||
const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, {
|
||||
@@ -55,7 +57,8 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
|
||||
|
||||
// Ensure each scenario has an object with { app, email, fcm }
|
||||
const formattedValues = notificationScenarios.reduce((acc, scenario) => {
|
||||
acc[scenario] = settings[scenario] ?? { app: false, email: false, fcm: false };
|
||||
acc[scenario] = settings[scenario] ??
|
||||
notificationScenarioDefaults[scenario] ?? { app: false, email: false, fcm: false };
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
@@ -65,7 +68,7 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
|
||||
setInitialAutoAdd(autoAdd);
|
||||
setIsDirty(false); // Reset dirty state when new data loads
|
||||
}
|
||||
}, [data, form]);
|
||||
}, [data, form, notificationScenarios]);
|
||||
|
||||
// Handle toggle of notifications_autoadd
|
||||
const handleAutoAddToggle = async (checked) => {
|
||||
@@ -136,7 +139,14 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
|
||||
width: "80%"
|
||||
},
|
||||
{
|
||||
title: <ColumnHeaderCheckbox channel="app" form={form} onHeaderChange={() => setIsDirty(true)} />,
|
||||
title: (
|
||||
<ColumnHeaderCheckbox
|
||||
channel="app"
|
||||
form={form}
|
||||
onHeaderChange={() => setIsDirty(true)}
|
||||
scenarioKeys={notificationScenarios}
|
||||
/>
|
||||
),
|
||||
dataIndex: "app",
|
||||
key: "app",
|
||||
align: "center",
|
||||
@@ -147,7 +157,14 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: <ColumnHeaderCheckbox channel="email" form={form} onHeaderChange={() => setIsDirty(true)} />,
|
||||
title: (
|
||||
<ColumnHeaderCheckbox
|
||||
channel="email"
|
||||
form={form}
|
||||
onHeaderChange={() => setIsDirty(true)}
|
||||
scenarioKeys={notificationScenarios}
|
||||
/>
|
||||
),
|
||||
dataIndex: "email",
|
||||
key: "email",
|
||||
align: "center",
|
||||
@@ -162,7 +179,14 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
|
||||
// Currently disabled for prod
|
||||
if (!import.meta.env.PROD) {
|
||||
columns.push({
|
||||
title: <ColumnHeaderCheckbox channel="fcm" form={form} onHeaderChange={() => setIsDirty(true)} />,
|
||||
title: (
|
||||
<ColumnHeaderCheckbox
|
||||
channel="fcm"
|
||||
form={form}
|
||||
onHeaderChange={() => setIsDirty(true)}
|
||||
scenarioKeys={notificationScenarios}
|
||||
/>
|
||||
),
|
||||
dataIndex: "fcm",
|
||||
key: "fcm",
|
||||
align: "center",
|
||||
|
||||
@@ -11,12 +11,13 @@ import ResponsiveTable from "../responsive-table/responsive-table.component";
|
||||
|
||||
export default function OwnersListComponent({ loading, owners, total, refetch }) {
|
||||
const search = queryString.parse(useLocation().search);
|
||||
const {
|
||||
page
|
||||
// sortcolumn, sortorder
|
||||
} = search;
|
||||
const { page, pageSize } = search;
|
||||
const history = useNavigate();
|
||||
|
||||
const currentPage = Number.parseInt(page || "1", 10);
|
||||
const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10);
|
||||
const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize;
|
||||
|
||||
const [state, setState] = useState({
|
||||
sortedInfo: {},
|
||||
filteredInfo: { text: "" }
|
||||
@@ -71,10 +72,14 @@ export default function OwnersListComponent({ loading, owners, total, refetch })
|
||||
];
|
||||
|
||||
const handleTableChange = (pagination, filters, sorter) => {
|
||||
const nextPageSize = pagination?.pageSize || currentPageSize;
|
||||
const pageSizeChanged = nextPageSize !== currentPageSize;
|
||||
|
||||
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
|
||||
const updatedSearch = {
|
||||
...search,
|
||||
page: pagination.current,
|
||||
pageSize: nextPageSize,
|
||||
page: pageSizeChanged ? 1 : pagination.current,
|
||||
sortcolumn: sorter.columnKey,
|
||||
sortorder: sorter.order
|
||||
};
|
||||
@@ -119,7 +124,7 @@ export default function OwnersListComponent({ loading, owners, total, refetch })
|
||||
>
|
||||
<ResponsiveTable
|
||||
loading={loading}
|
||||
pagination={{ placement: "top", pageSize: pageLimit, current: parseInt(page || 1, 10), total: total }}
|
||||
pagination={{ placement: "top", pageSize: currentPageSize, current: currentPage, showSizeChanger: true, total: total }}
|
||||
columns={columns}
|
||||
mobileColumnKeys={["name", "ownr_ph1", "ownr_ph2"]}
|
||||
rowKey="id"
|
||||
|
||||
@@ -8,14 +8,19 @@ import { pageLimit } from "../../utils/config";
|
||||
|
||||
export default function OwnersListContainer() {
|
||||
const searchParams = queryString.parse(useLocation().search);
|
||||
const { page, sortcolumn, sortorder, search } = searchParams;
|
||||
const { page, sortcolumn, sortorder, search, pageSize } = searchParams;
|
||||
|
||||
const currentPage = Number.parseInt(page || "1", 10);
|
||||
const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10);
|
||||
const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize;
|
||||
|
||||
const { loading, error, data, refetch } = useQuery(QUERY_ALL_OWNERS_PAGINATED, {
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only",
|
||||
variables: {
|
||||
search: search || "",
|
||||
offset: page ? (page - 1) * pageLimit : 0,
|
||||
limit: pageLimit,
|
||||
offset: (currentPage - 1) * currentPageSize,
|
||||
limit: currentPageSize,
|
||||
order: [
|
||||
{
|
||||
[sortcolumn || "created_at"]: sortorder ? (sortorder === "descend" ? "desc" : "asc") : "desc"
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { DeleteFilled, DownOutlined, WarningFilled } from "@ant-design/icons";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } from "antd";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||
import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils";
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
|
||||
import PartsOrderModalPriceChange from "./parts-order-modal-price-change.component";
|
||||
@@ -50,6 +51,7 @@ export function PartsOrderModalComponent({
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
const partsOrderLines = Form.useWatch(["parts_order_lines", "data"], form) || [];
|
||||
const handleClick = ({ item }) => {
|
||||
form.setFieldsValue({ comments: item.props.value });
|
||||
};
|
||||
@@ -128,10 +130,38 @@ export function PartsOrderModalComponent({
|
||||
{(fields, { remove, move }) => {
|
||||
return (
|
||||
<div>
|
||||
{fields.map((field, index) => (
|
||||
<Form.Item required={false} key={field.key}>
|
||||
<div style={{ display: "flex" }}>
|
||||
<LayoutFormRow grow noDivider style={{ flex: 1 }}>
|
||||
{fields.map((field, index) => {
|
||||
const partsOrderLine = partsOrderLines[field.name] || {};
|
||||
|
||||
return (
|
||||
<Form.Item required={false} key={field.key}>
|
||||
<LayoutFormRow
|
||||
grow
|
||||
noDivider
|
||||
title={getFormListItemTitle(
|
||||
t("parts_orders.fields.line_desc"),
|
||||
index,
|
||||
partsOrderLine.line_desc,
|
||||
partsOrderLine.oem_partno
|
||||
)}
|
||||
extra={
|
||||
<Space align="center" size="small">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteFilled />}
|
||||
onClick={() => {
|
||||
remove(field.name);
|
||||
}}
|
||||
/>
|
||||
<FormListMoveArrows
|
||||
move={move}
|
||||
index={index}
|
||||
total={fields.length}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form.Item
|
||||
//span={8}
|
||||
label={t("parts_orders.fields.line_desc")}
|
||||
@@ -146,6 +176,9 @@ export function PartsOrderModalComponent({
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item key={`${index}job_line_id`} name={[field.name, "job_line_id"]} hidden>
|
||||
<Input type="hidden" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("parts_orders.fields.line_remarks")}
|
||||
key={`${index}line_remarks`}
|
||||
@@ -220,20 +253,9 @@ export function PartsOrderModalComponent({
|
||||
</Form.Item>
|
||||
)}
|
||||
</LayoutFormRow>
|
||||
<Space wrap size="small" align="center">
|
||||
<div>
|
||||
<DeleteFilled
|
||||
style={{ margin: "1rem" }}
|
||||
onClick={() => {
|
||||
remove(field.name);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<FormListMoveArrows move={move} index={index} total={fields.length} />
|
||||
</Space>
|
||||
</div>
|
||||
</Form.Item>
|
||||
))}
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -19,10 +19,11 @@ import { TemplateList } from "../../utils/TemplateConstants";
|
||||
import AlertComponent from "../alert/alert.component";
|
||||
import PartsOrderModalComponent from "./parts-order-modal.component";
|
||||
import axios from "axios";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import _ from "lodash";
|
||||
import { UPDATE_JOB } from "../../graphql/jobs.queries";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { buildSubmittedPartsOrderLines, getSubmittedPartsOrderJobLineIds } from "./parts-order-modal.utils.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser,
|
||||
@@ -82,15 +83,10 @@ export function PartsOrderModalContainer({
|
||||
|
||||
// Force job_line_id from context so it never gets dropped by AntD form submission behavior.
|
||||
const submittedLines = values?.parts_order_lines?.data ?? [];
|
||||
const forcedLines = submittedLines.map((p, index) => {
|
||||
const originalLine = linesToOrder?.[index];
|
||||
const jobLineId = isReturn ? originalLine?.joblineid : originalLine?.id;
|
||||
|
||||
return {
|
||||
...p,
|
||||
job_line_id: jobLineId,
|
||||
...(isReturn && { cm_received: false })
|
||||
};
|
||||
const forcedLines = buildSubmittedPartsOrderLines({
|
||||
submittedLines,
|
||||
linesToOrder,
|
||||
isReturn
|
||||
});
|
||||
|
||||
let insertResult;
|
||||
@@ -147,10 +143,7 @@ export function PartsOrderModalContainer({
|
||||
type: isReturn ? "jobspartsreturn" : "jobspartsorder"
|
||||
});
|
||||
|
||||
// Use linesToOrder from context instead of form values to preserve job line ids
|
||||
const jobLineIds = (linesToOrder ?? [])
|
||||
.filter((line) => (isReturn ? line.joblineid : line.id))
|
||||
.map((line) => (isReturn ? line.joblineid : line.id));
|
||||
const jobLineIds = getSubmittedPartsOrderJobLineIds(forcedLines);
|
||||
|
||||
try {
|
||||
const jobLinesResult = await updateJobLines({
|
||||
@@ -206,23 +199,20 @@ export function PartsOrderModalContainer({
|
||||
isinhouse: true,
|
||||
date: dayjs(),
|
||||
total: 0,
|
||||
billlines: forcedLines.map((p, index) => {
|
||||
const originalLine = linesToOrder?.[index];
|
||||
return {
|
||||
joblineid: isReturn ? originalLine?.joblineid : originalLine?.id,
|
||||
actual_price: p.act_price,
|
||||
actual_cost: 0, // p.act_price,
|
||||
line_desc: p.line_desc,
|
||||
line_remarks: p.line_remarks,
|
||||
part_type: p.part_type,
|
||||
quantity: p.quantity || 1,
|
||||
applicable_taxes: {
|
||||
local: false,
|
||||
state: false,
|
||||
federal: false
|
||||
}
|
||||
};
|
||||
})
|
||||
billlines: forcedLines.map((p) => ({
|
||||
joblineid: p.job_line_id,
|
||||
actual_price: p.act_price,
|
||||
actual_cost: 0, // p.act_price,
|
||||
line_desc: p.line_desc,
|
||||
line_remarks: p.line_remarks,
|
||||
part_type: p.part_type,
|
||||
quantity: p.quantity || 1,
|
||||
applicable_taxes: {
|
||||
local: false,
|
||||
state: false,
|
||||
federal: false
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export const getPartsOrderJobLineId = ({ line, originalLine, isReturn }) => {
|
||||
return line?.job_line_id || (isReturn ? originalLine?.joblineid : originalLine?.id);
|
||||
};
|
||||
|
||||
export const buildSubmittedPartsOrderLines = ({ submittedLines = [], linesToOrder = [], isReturn }) => {
|
||||
return submittedLines.map((line, index) => {
|
||||
const jobLineId = getPartsOrderJobLineId({
|
||||
line,
|
||||
originalLine: linesToOrder?.[index],
|
||||
isReturn
|
||||
});
|
||||
|
||||
return {
|
||||
...line,
|
||||
job_line_id: jobLineId,
|
||||
...(isReturn && { cm_received: false })
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const getSubmittedPartsOrderJobLineIds = (partsOrderLines = []) => {
|
||||
return partsOrderLines.map((line) => line.job_line_id).filter(Boolean);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSubmittedPartsOrderLines, getSubmittedPartsOrderJobLineIds } from "./parts-order-modal.utils.js";
|
||||
|
||||
describe("parts order modal utilities", () => {
|
||||
it("preserves submitted job line ids after a row is removed", () => {
|
||||
const submittedLines = [
|
||||
{ line_desc: "second line", job_line_id: "job-line-2" },
|
||||
{ line_desc: "third line", job_line_id: "job-line-3" }
|
||||
];
|
||||
const linesToOrder = [{ id: "job-line-1" }, { id: "job-line-2" }, { id: "job-line-3" }];
|
||||
|
||||
const result = buildSubmittedPartsOrderLines({ submittedLines, linesToOrder, isReturn: false });
|
||||
|
||||
expect(result.map((line) => line.job_line_id)).toEqual(["job-line-2", "job-line-3"]);
|
||||
expect(getSubmittedPartsOrderJobLineIds(result)).toEqual(["job-line-2", "job-line-3"]);
|
||||
});
|
||||
|
||||
it("falls back to original return line ids when the form omits hidden metadata", () => {
|
||||
const submittedLines = [{ line_desc: "return line" }];
|
||||
const linesToOrder = [{ joblineid: "return-job-line-1" }];
|
||||
|
||||
const result = buildSubmittedPartsOrderLines({ submittedLines, linesToOrder, isReturn: true });
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
line_desc: "return line",
|
||||
job_line_id: "return-job-line-1",
|
||||
cm_received: false
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,10 @@ const mapStateToProps = createStructuredSelector({
|
||||
|
||||
export function PartsQueueListComponent({ bodyshop }) {
|
||||
const searchParams = queryString.parse(useLocation().search);
|
||||
const { selected, sortcolumn, sortorder, statusFilters } = searchParams;
|
||||
const { selected, sortcolumn, sortorder, statusFilters, page, pageSize } = searchParams;
|
||||
const currentPage = Number.parseInt(page || "1", 10);
|
||||
const parsedPageSize = Number.parseInt(pageSize || String(pageLimit), 10);
|
||||
const currentPageSize = Number.isNaN(parsedPageSize) ? pageLimit : parsedPageSize;
|
||||
const history = useNavigate();
|
||||
const [filter, setFilter] = useLocalStorage("filter_parts_queue", null);
|
||||
const [viewTimeStamp, setViewTimeStamp] = useLocalStorage("parts_queue_timestamps", false);
|
||||
@@ -66,7 +69,11 @@ export function PartsQueueListComponent({ bodyshop }) {
|
||||
: [];
|
||||
|
||||
const handleTableChange = (pagination, filters, sorter) => {
|
||||
// searchParams.page = pagination.current;
|
||||
const nextPageSize = pagination?.pageSize || currentPageSize;
|
||||
const pageSizeChanged = nextPageSize !== currentPageSize;
|
||||
|
||||
searchParams.pageSize = nextPageSize;
|
||||
searchParams.page = pageSizeChanged ? 1 : pagination.current;
|
||||
searchParams.sortcolumn = sorter.columnKey;
|
||||
searchParams.sortorder = sorter.order;
|
||||
|
||||
@@ -315,9 +322,10 @@ export function PartsQueueListComponent({ bodyshop }) {
|
||||
loading={loading}
|
||||
pagination={{
|
||||
placement: "top",
|
||||
pageSize: pageLimit
|
||||
// current: parseInt(page || 1),
|
||||
// total: data && data.jobs_aggregate.aggregate.count,
|
||||
pageSize: currentPageSize,
|
||||
current: currentPage,
|
||||
showSizeChanger: true,
|
||||
total: jobs.length
|
||||
}}
|
||||
columns={columns}
|
||||
mobileColumnKeys={["ro_number", "ownr_ln", "status", "vehicle", "partsstatus"]}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { DeleteFilled } from "@ant-design/icons";
|
||||
import { Form, Input, InputNumber, Select, Typography } from "antd";
|
||||
import { Button, Form, Input, InputNumber, Select, Space, Typography } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||
import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils";
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
@@ -15,6 +16,7 @@ export default connect(mapStateToProps, null)(PartsReceiveModalComponent);
|
||||
|
||||
export function PartsReceiveModalComponent({ bodyshop, form }) {
|
||||
const { t } = useTranslation();
|
||||
const partsOrderLines = Form.useWatch(["partsorderlines"], form) || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -42,16 +44,43 @@ export function PartsReceiveModalComponent({ bodyshop, form }) {
|
||||
{(fields, { remove, move }) => {
|
||||
return (
|
||||
<div>
|
||||
{fields.map((field, index) => (
|
||||
<Form.Item required={false} key={field.key}>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
{fields.map((field, index) => {
|
||||
const partsOrderLine = partsOrderLines[field.name] || {};
|
||||
|
||||
return (
|
||||
<Form.Item required={false} key={field.key}>
|
||||
<Form.Item hidden key={`${index}joblineid`} name={[field.name, "joblineid"]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item hidden key={`${index}id`} name={[field.name, "id"]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<LayoutFormRow grow style={{ flex: 1 }}>
|
||||
<LayoutFormRow
|
||||
grow
|
||||
title={getFormListItemTitle(
|
||||
t("parts_orders.fields.line_desc"),
|
||||
index,
|
||||
partsOrderLine.line_desc,
|
||||
partsOrderLine.oem_partno
|
||||
)}
|
||||
extra={
|
||||
<Space align="center" size="small">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteFilled />}
|
||||
onClick={() => {
|
||||
remove(field.name);
|
||||
}}
|
||||
/>
|
||||
<FormListMoveArrows
|
||||
move={move}
|
||||
index={index}
|
||||
total={fields.length}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form.Item
|
||||
label={t("parts_orders.fields.line_desc")}
|
||||
key={`${index}line_desc`}
|
||||
@@ -84,7 +113,7 @@ export function PartsReceiveModalComponent({ bodyshop, form }) {
|
||||
key={`${index}location`}
|
||||
name={[field.name, "location"]}
|
||||
>
|
||||
<Select
|
||||
<Select
|
||||
style={{ width: "10rem" }}
|
||||
options={bodyshop.md_parts_locations.map((loc, idx) => ({
|
||||
key: idx,
|
||||
@@ -101,16 +130,9 @@ export function PartsReceiveModalComponent({ bodyshop, form }) {
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
<DeleteFilled
|
||||
style={{ margin: "1rem" }}
|
||||
onClick={() => {
|
||||
remove(field.name);
|
||||
}}
|
||||
/>
|
||||
<FormListMoveArrows move={move} index={index} total={fields.length} />
|
||||
</div>
|
||||
</Form.Item>
|
||||
))}
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -2,10 +2,13 @@ import { DeleteFilled } from "@ant-design/icons";
|
||||
import { Button, Form, Input, Select, Space } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||
import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils";
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
|
||||
export default function PartsEmailPresetsComponent() {
|
||||
const { t } = useTranslation();
|
||||
const form = Form.useFormInstance();
|
||||
const emailPresets = Form.useWatch(["md_to_emails"], form) || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -14,31 +17,46 @@ export default function PartsEmailPresetsComponent() {
|
||||
{(fields, { add, remove, move }) => {
|
||||
return (
|
||||
<div>
|
||||
{fields.map((field, index) => (
|
||||
<Form.Item key={field.key}>
|
||||
<LayoutFormRow noDivider>
|
||||
<Form.Item label={t("general.labels.label")} key={`${index}label`} name={[field.name, "label"]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bodyshop.labels.md_to_emails_emails")}
|
||||
key={`${index}emails`}
|
||||
name={[field.name, "emails"]}
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[",", ";"]} />
|
||||
</Form.Item>
|
||||
{fields.map((field, index) => {
|
||||
const preset = emailPresets[field.name] || {};
|
||||
|
||||
<Space>
|
||||
<DeleteFilled
|
||||
onClick={() => {
|
||||
remove(field.name);
|
||||
}}
|
||||
/>
|
||||
<FormListMoveArrows move={move} index={index} total={fields.length} />
|
||||
</Space>
|
||||
</LayoutFormRow>
|
||||
</Form.Item>
|
||||
))}
|
||||
return (
|
||||
<Form.Item key={field.key}>
|
||||
<LayoutFormRow
|
||||
noDivider
|
||||
title={getFormListItemTitle(t("general.labels.label"), index, preset.label, preset.emails)}
|
||||
extra={
|
||||
<Space align="center" size="small">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteFilled />}
|
||||
onClick={() => {
|
||||
remove(field.name);
|
||||
}}
|
||||
/>
|
||||
<FormListMoveArrows
|
||||
move={move}
|
||||
index={index}
|
||||
total={fields.length}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form.Item label={t("general.labels.label")} key={`${index}label`} name={[field.name, "label"]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bodyshop.labels.md_to_emails_emails")}
|
||||
key={`${index}emails`}
|
||||
name={[field.name, "emails"]}
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[",", ";"]} />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="dashed"
|
||||
|
||||
@@ -2,10 +2,13 @@ import { DeleteFilled } from "@ant-design/icons";
|
||||
import { Button, Form, Input, Space } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||
import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils";
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
|
||||
export default function PartsLocationsComponent() {
|
||||
const { t } = useTranslation();
|
||||
const form = Form.useFormInstance();
|
||||
const partsLocations = Form.useWatch(["md_parts_locations"], form) || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -14,34 +17,49 @@ export default function PartsLocationsComponent() {
|
||||
{(fields, { add, remove, move }) => {
|
||||
return (
|
||||
<div>
|
||||
{fields.map((field, index) => (
|
||||
<Form.Item key={field.key}>
|
||||
<LayoutFormRow noDivider>
|
||||
<Form.Item
|
||||
className="imex-flex-row__margin"
|
||||
label={t("bodyshop.fields.partslocation")}
|
||||
key={`${index}`}
|
||||
name={[field.name]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
{fields.map((field, index) => {
|
||||
const location = partsLocations[field.name];
|
||||
|
||||
return (
|
||||
<Form.Item key={field.key}>
|
||||
<LayoutFormRow
|
||||
noDivider
|
||||
title={getFormListItemTitle(t("bodyshop.fields.partslocation"), index, location)}
|
||||
extra={
|
||||
<Space align="center" size="small">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteFilled />}
|
||||
onClick={() => {
|
||||
remove(field.name);
|
||||
}}
|
||||
/>
|
||||
<FormListMoveArrows
|
||||
move={move}
|
||||
index={index}
|
||||
total={fields.length}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Space wrap>
|
||||
<DeleteFilled
|
||||
<Form.Item
|
||||
className="imex-flex-row__margin"
|
||||
onClick={() => {
|
||||
remove(field.name);
|
||||
}}
|
||||
/>
|
||||
<FormListMoveArrows move={move} index={index} total={fields.length} />
|
||||
</Space>
|
||||
</LayoutFormRow>
|
||||
</Form.Item>
|
||||
))}
|
||||
label={t("bodyshop.fields.partslocation")}
|
||||
key={`${index}`}
|
||||
name={[field.name]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="dashed"
|
||||
|
||||
@@ -2,10 +2,13 @@ import { DeleteFilled } from "@ant-design/icons";
|
||||
import { Button, Form, Input, Space } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||
import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils";
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
|
||||
export default function PartsOrderCommentsComponent() {
|
||||
const { t } = useTranslation();
|
||||
const form = Form.useFormInstance();
|
||||
const orderComments = Form.useWatch(["md_parts_order_comment"], form) || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -14,45 +17,65 @@ export default function PartsOrderCommentsComponent() {
|
||||
{(fields, { add, remove, move }) => {
|
||||
return (
|
||||
<div>
|
||||
{fields.map((field, index) => (
|
||||
<Form.Item key={field.key}>
|
||||
<LayoutFormRow noDivider>
|
||||
<Form.Item
|
||||
label={t("general.labels.label")}
|
||||
key={`${index}label`}
|
||||
name={[field.name, "label"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("parts_orders.fields.comments")}
|
||||
key={`${index}comment`}
|
||||
name={[field.name, "comment"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input.TextArea autoSize />
|
||||
</Form.Item>
|
||||
{fields.map((field, index) => {
|
||||
const comment = orderComments[field.name] || {};
|
||||
|
||||
<Space wrap>
|
||||
<DeleteFilled
|
||||
onClick={() => {
|
||||
remove(field.name);
|
||||
}}
|
||||
/>
|
||||
<FormListMoveArrows move={move} index={index} total={fields.length} />
|
||||
</Space>
|
||||
</LayoutFormRow>
|
||||
</Form.Item>
|
||||
))}
|
||||
return (
|
||||
<Form.Item key={field.key}>
|
||||
<LayoutFormRow
|
||||
noDivider
|
||||
title={getFormListItemTitle(
|
||||
t("parts_orders.fields.comments"),
|
||||
index,
|
||||
comment.label,
|
||||
comment.comment
|
||||
)}
|
||||
extra={
|
||||
<Space align="center" size="small">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteFilled />}
|
||||
onClick={() => {
|
||||
remove(field.name);
|
||||
}}
|
||||
/>
|
||||
<FormListMoveArrows
|
||||
move={move}
|
||||
index={index}
|
||||
total={fields.length}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form.Item
|
||||
label={t("general.labels.label")}
|
||||
key={`${index}label`}
|
||||
name={[field.name, "label"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("parts_orders.fields.comments")}
|
||||
key={`${index}comment`}
|
||||
name={[field.name, "comment"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input.TextArea autoSize />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="dashed"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Form, Input, Radio, Select } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MailOutlined, PrinterOutlined } from "@ant-design/icons";
|
||||
import { MailOutlined, PrinterOutlined, SignatureFilled } from "@ant-design/icons";
|
||||
import { Space, Spin } from "antd";
|
||||
import { useState } from "react";
|
||||
import { connect } from "react-redux";
|
||||
@@ -10,6 +10,9 @@ import { GenerateDocument } from "../../utils/RenderTemplate";
|
||||
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
|
||||
import { HasFeatureAccess } from "./../feature-wrapper/feature-wrapper.component";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import axios from "axios";
|
||||
import { setModalContext } from "../../redux/modals/modals.actions.js";
|
||||
import { hasDocumensoApiKey } from "../../utils/esignature.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
printCenterModal: selectPrintCenter,
|
||||
@@ -17,12 +20,29 @@ const mapStateToProps = createStructuredSelector({
|
||||
technician: selectTechnician
|
||||
});
|
||||
|
||||
const mapDispatchToProps = () => ({});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
setEsignatureContext: (context) =>
|
||||
dispatch(
|
||||
setModalContext({
|
||||
context: context,
|
||||
modal: "esignature"
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
export function PrintCenterItemComponent({ printCenterModal, item, id, bodyshop, disabled, technician }) {
|
||||
export function PrintCenterItemComponent({
|
||||
printCenterModal,
|
||||
setEsignatureContext,
|
||||
item,
|
||||
id,
|
||||
bodyshop,
|
||||
disabled,
|
||||
technician
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { context } = printCenterModal;
|
||||
const notification = useNotification();
|
||||
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
|
||||
|
||||
const renderToNewWindow = async () => {
|
||||
setLoading(true);
|
||||
@@ -39,6 +59,30 @@ export function PrintCenterItemComponent({ printCenterModal, item, id, bodyshop,
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const esignatureGenerate = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const {
|
||||
data: { token, documentId, envelopeId }
|
||||
} = await axios.post("/esign/new", {
|
||||
name: item.key,
|
||||
jobid: id,
|
||||
context,
|
||||
bodyshop,
|
||||
templateObject: {
|
||||
name: item.key,
|
||||
variables: { id: id }
|
||||
}
|
||||
});
|
||||
|
||||
setEsignatureContext({ context: { token, documentId, envelopeId, jobid: id } });
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
disabled ||
|
||||
(item.featureNameRestricted && !HasFeatureAccess({ featureName: item.featureNameRestricted, bodyshop }))
|
||||
@@ -54,6 +98,7 @@ export function PrintCenterItemComponent({ printCenterModal, item, id, bodyshop,
|
||||
<li>
|
||||
<Space wrap>
|
||||
{item.title}
|
||||
{esignatureEnabled && <SignatureFilled onClick={esignatureGenerate} />}
|
||||
<PrinterOutlined onClick={renderToNewWindow} />
|
||||
{!technician ? (
|
||||
<MailOutlined
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Card, Col, Row, Space, Tooltip, Typography } from "antd";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { Card, Col, Input, Row, Space, Typography } from "antd";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { CloseOutlined } from "@ant-design/icons";
|
||||
import { Alert, Button, Card, Col, Input, Row, Space, Typography, Tooltip } from "antd";
|
||||
import _ from "lodash";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -9,11 +10,15 @@ import { selectPrintCenter } from "../../redux/modals/modals.selectors";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { TemplateList } from "../../utils/TemplateConstants";
|
||||
import Jobd3RdPartyModal from "../job-3rd-party-modal/job-3rd-party-modal.component";
|
||||
import EsignatureCustomDocument from "../esignature-custom-document/esignature-custom-document.component";
|
||||
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
|
||||
import PrintCenterItem from "../print-center-item/print-center-item.component";
|
||||
import PrintCenterJobsLabels from "../print-center-jobs-labels/print-center-jobs-labels.component";
|
||||
import PrintCenterSpeedPrint from "../print-center-speed-print/print-center-speed-print.component";
|
||||
import { bodyshopHasDmsKey } from "../../utils/dmsUtils";
|
||||
import { bodyshopHasDmsKey, DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
|
||||
import { selectTechnician } from "../../redux/tech/tech.selectors";
|
||||
import { hasDocumensoApiKey } from "../../utils/esignature.js";
|
||||
import useLocalStorage from "../../utils/useLocalStorage";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
printCenterModal: selectPrintCenter,
|
||||
@@ -25,6 +30,10 @@ const mapDispatchToProps = () => ({});
|
||||
|
||||
export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technician }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [esignatureBannerDismissed, setEsignatureBannerDismissed] = useLocalStorage(
|
||||
"print_center_esignature_banner_dismissed",
|
||||
false
|
||||
);
|
||||
const { id: jobId, job } = printCenterModal.context;
|
||||
const tempList = TemplateList("job", {});
|
||||
const { t } = useTranslation();
|
||||
@@ -36,6 +45,10 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
|
||||
splitKey: bodyshop.imexshopid
|
||||
});
|
||||
const hasDMSKey = bodyshopHasDmsKey(bodyshop);
|
||||
const dmsMode = getDmsMode(bodyshop, "off");
|
||||
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
|
||||
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
|
||||
const showEsignatureBanner = !esignatureEnabled && !esignatureBannerDismissed;
|
||||
|
||||
const Templates = !hasDMSKey
|
||||
? Object.keys(tempList)
|
||||
@@ -45,7 +58,7 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
|
||||
.filter(
|
||||
(temp) =>
|
||||
(!temp.regions ||
|
||||
(temp.regions && temp.regions[bodyshop.region_config]) ||
|
||||
temp.regions?.[bodyshop.region_config] ||
|
||||
(temp.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true)) &&
|
||||
(!temp.dms || temp.dms === false)
|
||||
)
|
||||
@@ -57,11 +70,12 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
|
||||
.filter(
|
||||
(temp) =>
|
||||
!temp.regions ||
|
||||
(temp.regions && temp.regions[bodyshop.region_config]) ||
|
||||
temp.regions?.[bodyshop.region_config] ||
|
||||
(temp.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true)
|
||||
)
|
||||
.filter((temp) => !isReynoldsMode || !temp.excludedDmsModes?.includes(dmsMode))
|
||||
.filter((temp) => !technician || temp.group !== "financial");
|
||||
|
||||
|
||||
const JobsReportsList =
|
||||
Enhanced_Payroll.treatment === "on"
|
||||
? Object.keys(Templates)
|
||||
@@ -85,6 +99,23 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showEsignatureBanner && (
|
||||
<Alert
|
||||
action={
|
||||
<Button
|
||||
aria-label={t("general.actions.close")}
|
||||
icon={<CloseOutlined />}
|
||||
onClick={() => setEsignatureBannerDismissed(true)}
|
||||
size="small"
|
||||
type="text"
|
||||
/>
|
||||
}
|
||||
banner
|
||||
title={t("printcenter.banners.esignature_promo")}
|
||||
type="info"
|
||||
className="print-center-esignature-banner"
|
||||
/>
|
||||
)}
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col lg={8} md={12} sm={24}>
|
||||
<PrintCenterSpeedPrint jobId={jobId} />
|
||||
@@ -94,6 +125,13 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
|
||||
extra={
|
||||
<Space wrap>
|
||||
<PrintCenterJobsLabels jobId={jobId} />
|
||||
<Tooltip title={!esignatureEnabled ? t("esignature.tooltips.contact_sales") : null}>
|
||||
<span>
|
||||
<LockWrapperComponent locked={!esignatureEnabled} bodyshop={bodyshop}>
|
||||
<EsignatureCustomDocument jobId={jobId} showUnavailable />
|
||||
</LockWrapperComponent>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Jobd3RdPartyModal jobId={jobId} job={job} />
|
||||
<Input.Search onChange={(e) => setSearch(e.target.value)} value={search} enterButton />
|
||||
</Space>
|
||||
|
||||
@@ -5,3 +5,7 @@
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.print-center-esignature-banner {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@@ -80,14 +80,14 @@ const ModelInfoToolTip = ({ metadata, cardSettings }) =>
|
||||
<Col span={24}>
|
||||
<EllipsesToolTip
|
||||
title={
|
||||
metadata.v_model_yr || metadata.v_make_desc || metadata.v_model_desc
|
||||
? `${metadata.v_model_yr || ""} ${metadata.v_make_desc || ""} ${metadata.v_model_desc || ""}`
|
||||
metadata.v_model_yr || metadata.v_make_desc || metadata.v_model_desc || metadata.v_color
|
||||
? `${metadata.v_model_yr || ""} ${metadata.v_color || ""} ${metadata.v_make_desc || ""} ${metadata.v_model_desc || ""}`
|
||||
: null
|
||||
}
|
||||
kiosk={cardSettings.kiosk}
|
||||
>
|
||||
{metadata.v_model_yr || metadata.v_make_desc || metadata.v_model_desc ? (
|
||||
`${metadata.v_model_yr || ""} ${metadata.v_make_desc || ""} ${metadata.v_model_desc || ""}`
|
||||
{metadata.v_model_yr || metadata.v_make_desc || metadata.v_model_desc || metadata.v_color ? (
|
||||
`${metadata.v_model_yr || ""} ${metadata.v_color || ""} ${metadata.v_make_desc || ""} ${metadata.v_model_desc || ""}`
|
||||
) : (
|
||||
<span> </span>
|
||||
)}
|
||||
@@ -431,6 +431,7 @@ export default function ProductionBoardCard({ technician, card, bodyshop, cardSe
|
||||
<Card
|
||||
className={`react-trello-card ${cardSettings.kiosk ? "kiosk-mode" : ""}`}
|
||||
size="small"
|
||||
styles={{ header: { backgroundColor: "var(--card-bg-fallback)" } }}
|
||||
style={{
|
||||
backgroundColor: cardSettings?.cardcolor
|
||||
? bgColor.fallback || `rgba(${bgColor.r},${bgColor.g},${bgColor.b},${bgColor.a || 1})`
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { QUERY_KANBAN_SETTINGS } from "../../graphql/user.queries";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import ProductionBoardKanbanComponent from "./production-board-kanban.component";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
|
||||
@@ -28,11 +28,14 @@ const ProductionStatistics = ({ data, cardSettings, reducerData }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const calculateTotal = (items, key, subKey) => {
|
||||
return items.reduce((acc, item) => acc + (item[key]?.aggregate?.sum?.[subKey] || 0), 0);
|
||||
return items.reduce((acc, item) => acc + (item?.[key]?.aggregate?.sum?.[subKey] ?? 0), 0);
|
||||
};
|
||||
|
||||
const calculateTotalAmount = (items, key) => {
|
||||
return items.reduce((acc, item) => acc.add(Dinero(item[key]?.totals?.subtotal ?? Dinero())), Dinero({ amount: 0 }));
|
||||
return items.reduce(
|
||||
(acc, item) => acc.add(Dinero(item?.[key]?.totals?.subtotal ?? Dinero())),
|
||||
Dinero({ amount: 0 })
|
||||
);
|
||||
};
|
||||
|
||||
const calculateReducerTotalAmount = (lanes, key) => {
|
||||
@@ -67,58 +70,83 @@ const ProductionStatistics = ({ data, cardSettings, reducerData }) => {
|
||||
return value;
|
||||
};
|
||||
|
||||
const filteredData = cardSettings.excludeSuspended === true ? data.filter((item) => item.suspended !== true) : data;
|
||||
const filteredReducerData =
|
||||
cardSettings.excludeSuspended === true
|
||||
? {
|
||||
...reducerData,
|
||||
lanes: reducerData.lanes.map((lane) => ({
|
||||
...lane,
|
||||
cards: lane.cards.filter((card) => card.metadata.suspended !== true)
|
||||
}))
|
||||
}
|
||||
: reducerData;
|
||||
|
||||
const totalHrs = cardSettings.totalHrs
|
||||
? parseFloat((calculateTotal(data, "labhrs", "mod_lb_hrs") + calculateTotal(data, "larhrs", "mod_lb_hrs")).toFixed(2))
|
||||
? parseFloat(
|
||||
(
|
||||
calculateTotal(filteredData, "labhrs", "mod_lb_hrs") + calculateTotal(filteredData, "larhrs", "mod_lb_hrs")
|
||||
).toFixed(2)
|
||||
)
|
||||
: null;
|
||||
|
||||
const totalLAB = cardSettings.totalLAB
|
||||
? parseFloat(calculateTotal(data, "labhrs", "mod_lb_hrs").toFixed(2))
|
||||
? parseFloat(calculateTotal(filteredData, "labhrs", "mod_lb_hrs").toFixed(2))
|
||||
: null;
|
||||
|
||||
const totalLAR = cardSettings.totalLAR
|
||||
? parseFloat(calculateTotal(data, "larhrs", "mod_lb_hrs").toFixed(2))
|
||||
? parseFloat(calculateTotal(filteredData, "larhrs", "mod_lb_hrs").toFixed(2))
|
||||
: null;
|
||||
|
||||
const jobsInProduction = cardSettings.jobsInProduction ? data.length : null;
|
||||
const jobsInProduction = cardSettings.jobsInProduction ? filteredData.length : null;
|
||||
|
||||
const totalAmountInProduction = cardSettings.totalAmountInProduction
|
||||
? calculateTotalAmount(data, "job_totals").toFormat("$0,0.00")
|
||||
? calculateTotalAmount(filteredData, "job_totals").toFormat("$0,0.00")
|
||||
: null;
|
||||
|
||||
const totalAmountOnBoard = reducerData && cardSettings.totalAmountOnBoard
|
||||
? calculateReducerTotalAmount(reducerData.lanes, "job_totals").toFormat("$0,0.00")
|
||||
: null;
|
||||
const totalAmountOnBoard =
|
||||
filteredReducerData && cardSettings.totalAmountOnBoard
|
||||
? calculateReducerTotalAmount(filteredReducerData.lanes, "job_totals").toFormat("$0,0.00")
|
||||
: null;
|
||||
|
||||
const totalHrsOnBoard = reducerData && cardSettings.totalHrsOnBoard
|
||||
? parseFloat((
|
||||
calculateReducerTotal(reducerData.lanes, "labhrs", "mod_lb_hrs") +
|
||||
calculateReducerTotal(reducerData.lanes, "larhrs", "mod_lb_hrs")
|
||||
).toFixed(2))
|
||||
: null;
|
||||
const totalHrsOnBoard =
|
||||
filteredReducerData && cardSettings.totalHrsOnBoard
|
||||
? parseFloat(
|
||||
(
|
||||
calculateReducerTotal(filteredReducerData.lanes, "labhrs", "mod_lb_hrs") +
|
||||
calculateReducerTotal(filteredReducerData.lanes, "larhrs", "mod_lb_hrs")
|
||||
).toFixed(2)
|
||||
)
|
||||
: null;
|
||||
|
||||
const totalLABOnBoard = reducerData && cardSettings.totalLABOnBoard
|
||||
? parseFloat(calculateReducerTotal(reducerData.lanes, "labhrs", "mod_lb_hrs").toFixed(2))
|
||||
: null;
|
||||
const totalLABOnBoard =
|
||||
filteredReducerData && cardSettings.totalLABOnBoard
|
||||
? parseFloat(calculateReducerTotal(filteredReducerData.lanes, "labhrs", "mod_lb_hrs").toFixed(2))
|
||||
: null;
|
||||
|
||||
const totalLAROnBoard = reducerData && cardSettings.totalLAROnBoard
|
||||
? parseFloat(calculateReducerTotal(reducerData.lanes, "larhrs", "mod_lb_hrs").toFixed(2))
|
||||
: null;
|
||||
const totalLAROnBoard =
|
||||
filteredReducerData && cardSettings.totalLAROnBoard
|
||||
? parseFloat(calculateReducerTotal(filteredReducerData.lanes, "larhrs", "mod_lb_hrs").toFixed(2))
|
||||
: null;
|
||||
|
||||
const jobsOnBoard = reducerData && cardSettings.jobsOnBoard
|
||||
? reducerData.lanes.reduce((acc, lane) => acc + lane.cards.length, 0)
|
||||
: null;
|
||||
const jobsOnBoard =
|
||||
filteredReducerData && cardSettings.jobsOnBoard
|
||||
? filteredReducerData.lanes.reduce((acc, lane) => acc + lane.cards.length, 0)
|
||||
: null;
|
||||
|
||||
const tasksInProduction = cardSettings.tasksInProduction
|
||||
? data.reduce((acc, item) => acc + (item.tasks_aggregate?.aggregate?.count || 0), 0)
|
||||
? filteredData.reduce((acc, item) => acc + (item.tasks_aggregate?.aggregate?.count || 0), 0)
|
||||
: null;
|
||||
|
||||
const tasksOnBoard = reducerData && cardSettings.tasksOnBoard
|
||||
? reducerData.lanes.reduce((acc, lane) => {
|
||||
return (
|
||||
acc + lane.cards.reduce((laneAcc, card) => laneAcc + (card.metadata.tasks_aggregate?.aggregate?.count || 0), 0)
|
||||
);
|
||||
}, 0)
|
||||
: null;
|
||||
const tasksOnBoard =
|
||||
filteredReducerData && cardSettings.tasksOnBoard
|
||||
? filteredReducerData.lanes.reduce((acc, lane) => {
|
||||
return (
|
||||
acc +
|
||||
lane.cards.reduce((laneAcc, card) => laneAcc + (card.metadata.tasks_aggregate?.aggregate?.count || 0), 0)
|
||||
);
|
||||
}, 0)
|
||||
: null;
|
||||
|
||||
const statistics = mergeStatistics(statisticsItems, [
|
||||
{ id: 0, value: totalHrs, type: StatisticType.HOURS },
|
||||
|
||||
@@ -14,7 +14,16 @@ const StatisticsSettings = ({ t, statisticsOrder, setStatisticsOrder, setHasChan
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title={t("production.settings.statistics_title")}>
|
||||
<Card
|
||||
title={t("production.settings.statistics_title")}
|
||||
extra={
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<Form.Item name="excludeSuspended" valuePropName="checked" style={{ marginBottom: 0 }}>
|
||||
<Checkbox>{t("production.settings.statistics.exclude_suspended")}</Checkbox>
|
||||
</Form.Item>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable direction="grid" droppableId="statistics">
|
||||
{(provided) => (
|
||||
|
||||
@@ -91,7 +91,8 @@ const defaultKanbanSettings = {
|
||||
subtotal: false,
|
||||
statisticsOrder: statisticsItems.map((item) => item.id),
|
||||
selectedMdInsCos: [],
|
||||
selectedEstimators: []
|
||||
selectedEstimators: [],
|
||||
excludeSuspended: false
|
||||
};
|
||||
|
||||
const defaultFilters = { search: "", employeeId: null, alert: false };
|
||||
|
||||
@@ -5,7 +5,7 @@ import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectTechnician } from "../../redux/tech/tech.selectors";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
technician: selectTechnician,
|
||||
@@ -58,6 +58,7 @@ export function ProductionColumnsComponent({
|
||||
|
||||
const columnKeys = columns.map((i) => i.key);
|
||||
const cols = dataSource({
|
||||
bodyshop,
|
||||
technician,
|
||||
data,
|
||||
state: tableState,
|
||||
|
||||
@@ -140,13 +140,11 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo
|
||||
sortOrder: state.sortedInfo.columnKey === "vehicle" && state.sortedInfo.order,
|
||||
render: (text, record) =>
|
||||
technician ? (
|
||||
<>{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${record.v_model_desc || ""} ${
|
||||
record.v_color || ""
|
||||
} ${record.plate_no || ""}`}</>
|
||||
<>{`${record.v_model_yr || ""} ${record.v_color || ""}${record.v_make_desc || ""} ${record.v_model_desc || ""} ${record.plate_no || ""}`}</>
|
||||
) : (
|
||||
<Link to={`/manage/vehicles/${record.vehicleid}`}>{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${
|
||||
record.v_model_desc || ""
|
||||
} ${record.v_color || ""} ${record.plate_no || ""}`}</Link>
|
||||
<Link
|
||||
to={`/manage/vehicles/${record.vehicleid}`}
|
||||
>{`${record.v_model_yr || ""} ${record.v_color || ""} ${record.v_make_desc || ""} ${record.v_model_desc || ""} ${record.plate_no || ""}`}</Link>
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -609,7 +607,19 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo
|
||||
ellipsis: true,
|
||||
|
||||
render: (text, record) => <TimeFormatter>{record.date_repairstarted}</TimeFormatter>
|
||||
}
|
||||
},
|
||||
...(bodyshop && bodyshop.rr_dealerid
|
||||
? [
|
||||
{
|
||||
title: i18n.t("jobs.fields.dms.id"),
|
||||
dataIndex: "dms_id",
|
||||
key: "dms_id",
|
||||
ellipsis: true,
|
||||
sorter: (a, b) => alphaSort(a.dms_id, b.dms_id),
|
||||
sortOrder: state.sortedInfo.columnKey === "dms_id" && state.sortedInfo.order
|
||||
}
|
||||
]
|
||||
: [])
|
||||
];
|
||||
};
|
||||
export default productionListColumnsData;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { UPDATE_ACTIVE_PROD_LIST_VIEW } from "../../graphql/associations.queries";
|
||||
import { UPDATE_SHOP } from "../../graphql/bodyshop.queries";
|
||||
import ProductionListColumns from "../production-list-columns/production-list-columns.data";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import { isFunction } from "lodash";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
@@ -244,6 +244,7 @@ export function ProductionListConfigManager({
|
||||
nextConfig.columns.columnKeys.map((k) => {
|
||||
return {
|
||||
...ProductionListColumns({
|
||||
bodyshop,
|
||||
technician,
|
||||
state: ensureDefaultState(state),
|
||||
refetch,
|
||||
@@ -270,6 +271,7 @@ export function ProductionListConfigManager({
|
||||
activeConfig.columns.columnKeys.map((k) => {
|
||||
return {
|
||||
...ProductionListColumns({
|
||||
bodyshop,
|
||||
technician,
|
||||
state: ensureDefaultState(state),
|
||||
refetch,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HolderOutlined, SyncOutlined } from "@ant-design/icons";
|
||||
import { PageHeader } from "@ant-design/pro-layout";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Dropdown, Input, Space, Statistic, Table } from "antd";
|
||||
import _ from "lodash";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "../../graphql/jobs.queries";
|
||||
import ProductionListTable from "./production-list-table.component";
|
||||
import _ from "lodash";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||
|
||||
export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLazyQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Card, Col, DatePicker, Form, Input, Radio, Row, Typography } from "antd";
|
||||
import _ from "lodash";
|
||||
import { useState } from "react";
|
||||
@@ -12,6 +12,7 @@ import { QUERY_ALL_VENDORS } from "../../graphql/vendors.queries";
|
||||
import { selectReportCenter } from "../../redux/modals/modals.selectors";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import DatePickerRanges from "../../utils/DatePickerRanges";
|
||||
import { DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
|
||||
import { GenerateDocument } from "../../utils/RenderTemplate";
|
||||
import { TemplateList } from "../../utils/TemplateConstants";
|
||||
import dayjs from "../../utils/day";
|
||||
@@ -48,12 +49,18 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const Templates = TemplateList("report_center");
|
||||
const dmsMode = getDmsMode(bodyshop, "off");
|
||||
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
|
||||
const ReportsList = Object.keys(Templates)
|
||||
.map((key) => Templates[key])
|
||||
.filter((temp) => {
|
||||
const enhancedPayrollOn = Enhanced_Payroll.treatment === "on";
|
||||
const adpPayrollOn = ADPPayroll.treatment === "on";
|
||||
|
||||
if (isReynoldsMode && temp.excludedDmsModes?.includes(dmsMode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (enhancedPayrollOn && adpPayrollOn) {
|
||||
return temp.enhanced_payroll !== false || temp.adp_payroll !== false;
|
||||
}
|
||||
@@ -408,6 +415,6 @@ const restrictedReports = [
|
||||
{ key: "job_costing_ro_estimator", days: 183 },
|
||||
{ key: "job_lifecycle_date_detail", days: 183 },
|
||||
{ key: "job_lifecycle_date_summary", days: 183 },
|
||||
{ key: "customer_list", days: 183 },
|
||||
{ key: "customer_list_excel", days: 183 }
|
||||
{ key: "customer_list", days: 736 },
|
||||
{ key: "customer_list_excel", days: 736 }
|
||||
];
|
||||
|
||||
@@ -48,7 +48,7 @@ export function ScheduleVerifyIntegrity({ currentUser }) {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (currentUser.email === "patrick@imex.prod")
|
||||
if (currentUser.email === "allan@imex.prod" || currentUser.email === "dave@imex.prod")
|
||||
return (
|
||||
<Button loading={loading} onClick={handleVerify}>
|
||||
Developer Use Only - Verify Schedule Integrity
|
||||
|
||||
@@ -8,7 +8,7 @@ import { INSERT_VACATION } from "../../graphql/employees.queries";
|
||||
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
|
||||
export default function ShopEmployeeAddVacation({ employee }) {
|
||||
export default function ShopEmployeeAddVacation({ employee, buttonProps }) {
|
||||
const { t } = useTranslation();
|
||||
const [insertVacation] = useMutation(INSERT_VACATION);
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function ShopEmployeeAddVacation({ employee }) {
|
||||
|
||||
return (
|
||||
<Popover content={overlay} open={visibility}>
|
||||
<Button loading={loading} disabled={!employee?.active} onClick={handleClick}>
|
||||
<Button loading={loading} disabled={!employee?.active} onClick={handleClick} {...buttonProps}>
|
||||
{t("employees.actions.addvacation")}
|
||||
</Button>
|
||||
</Popover>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { DeleteFilled } from "@ant-design/icons";
|
||||
import { useApolloClient, useMutation, useQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { Button, Card, Form, Input, InputNumber, Select, Switch } from "antd";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
|
||||
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
||||
import { useForm } from "antd/es/form/Form";
|
||||
import queryString from "query-string";
|
||||
import { useEffect } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
@@ -13,11 +12,11 @@ import { createStructuredSelector } from "reselect";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import {
|
||||
CHECK_EMPLOYEE_EMAIL,
|
||||
CHECK_EMPLOYEE_NUMBER,
|
||||
DELETE_VACATION,
|
||||
INSERT_EMPLOYEES,
|
||||
QUERY_EMPLOYEE_BY_ID,
|
||||
QUERY_USERS_BY_EMAIL,
|
||||
UPDATE_EMPLOYEE
|
||||
} from "../../graphql/employees.queries";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
@@ -26,9 +25,24 @@ import { DateFormatter } from "../../utils/DateFormatter";
|
||||
import dayjs from "../../utils/day";
|
||||
import AlertComponent from "../alert/alert.component";
|
||||
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
|
||||
import FormsFieldChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
|
||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
|
||||
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
import {
|
||||
INLINE_TITLE_GROUP_STYLE,
|
||||
INLINE_TITLE_HANDLE_STYLE,
|
||||
INLINE_TITLE_INPUT_STYLE,
|
||||
INLINE_TITLE_LABEL_STYLE,
|
||||
INLINE_TITLE_ROW_STYLE,
|
||||
INLINE_TITLE_SEPARATOR_STYLE,
|
||||
INLINE_TITLE_SWITCH_GROUP_STYLE,
|
||||
INLINE_TITLE_TEXT_STYLE,
|
||||
InlineTitleListIcon
|
||||
} from "../layout-form-row/inline-form-row-title.utils.js";
|
||||
import ShopEmployeeAddVacation from "./shop-employees-add-vacation.component";
|
||||
import FormItemEmail from "../form-items-formatted/email-form-item.component.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
@@ -37,19 +51,38 @@ const mapDispatchToProps = () => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
|
||||
export function ShopEmployeesFormComponent({ bodyshop }) {
|
||||
export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDirty }) {
|
||||
const submitActionRef = useRef("save");
|
||||
const { t } = useTranslation();
|
||||
const [form] = useForm();
|
||||
const [internalIsDirty, setInternalIsDirty] = useState(false);
|
||||
const resolvedIsDirty = typeof isDirty === "boolean" ? isDirty : internalIsDirty;
|
||||
const employeeNumber = Form.useWatch("employee_number", form);
|
||||
const firstName = Form.useWatch("first_name", form);
|
||||
const lastName = Form.useWatch("last_name", form);
|
||||
const employeeOptionsColProps = {
|
||||
xs: 24,
|
||||
sm: 12,
|
||||
md: 12,
|
||||
lg: 8,
|
||||
xl: 8,
|
||||
xxl: 8
|
||||
};
|
||||
const history = useNavigate();
|
||||
const search = queryString.parse(useLocation().search);
|
||||
const [deleteVacation] = useMutation(DELETE_VACATION);
|
||||
const { error, data } = useQuery(QUERY_EMPLOYEE_BY_ID, {
|
||||
const { error, data, refetch } = useQuery(QUERY_EMPLOYEE_BY_ID, {
|
||||
variables: { id: search.employeeId },
|
||||
skip: !search.employeeId || search.employeeId === "new",
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only"
|
||||
});
|
||||
const notification = useNotification();
|
||||
const isNewEmployee = search.employeeId === "new";
|
||||
const currentEmployeeData = data?.employees_by_pk?.id === search.employeeId ? data.employees_by_pk : null;
|
||||
const employeeTitleName = [firstName, lastName].filter(Boolean).join(" ").trim();
|
||||
const employeeCardTitle =
|
||||
[employeeNumber, employeeTitleName].filter(Boolean).join(" - ") ||
|
||||
(isNewEmployee ? t("employees.actions.new") : t("bodyshop.labels.employees"));
|
||||
|
||||
const {
|
||||
treatments: { Enhanced_Payroll }
|
||||
@@ -59,56 +92,155 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
|
||||
splitKey: bodyshop.imexshopid
|
||||
});
|
||||
|
||||
const updateDirtyState = useCallback(
|
||||
(nextDirtyState) => {
|
||||
setInternalIsDirty(nextDirtyState);
|
||||
onDirtyChange?.(nextDirtyState);
|
||||
},
|
||||
[onDirtyChange]
|
||||
);
|
||||
|
||||
const client = useApolloClient();
|
||||
useEffect(() => {
|
||||
if (data && data.employees_by_pk) form.setFieldsValue(data.employees_by_pk);
|
||||
else {
|
||||
form.resetFields();
|
||||
const clearEmployeeFormMeta = useCallback(() => {
|
||||
const fieldMeta = form.getFieldsError().map(({ name }) => ({
|
||||
name,
|
||||
touched: false,
|
||||
validating: false,
|
||||
errors: [],
|
||||
warnings: []
|
||||
}));
|
||||
|
||||
if (fieldMeta.length > 0) {
|
||||
form.setFields(fieldMeta);
|
||||
}
|
||||
}, [form, data, search.employeeId]);
|
||||
|
||||
updateDirtyState(false);
|
||||
}, [form, updateDirtyState]);
|
||||
|
||||
const resetEmployeeFormToCurrentData = useCallback(() => {
|
||||
form.resetFields();
|
||||
|
||||
if (currentEmployeeData) {
|
||||
form.setFieldsValue(currentEmployeeData);
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
clearEmployeeFormMeta();
|
||||
});
|
||||
}, [clearEmployeeFormMeta, currentEmployeeData, form]);
|
||||
|
||||
const syncEmployeeFormToSavedData = useCallback(
|
||||
(employeeData) => {
|
||||
if (employeeData) {
|
||||
form.setFieldsValue(employeeData);
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
clearEmployeeFormMeta();
|
||||
});
|
||||
},
|
||||
[clearEmployeeFormMeta, form]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
resetEmployeeFormToCurrentData();
|
||||
}, [resetEmployeeFormToCurrentData, search.employeeId]);
|
||||
|
||||
const [updateEmployee] = useMutation(UPDATE_EMPLOYEE);
|
||||
const [insertEmployees] = useMutation(INSERT_EMPLOYEES);
|
||||
const saveAndResetSubmitAction = useCallback(() => {
|
||||
const submitAction = submitActionRef.current;
|
||||
submitActionRef.current = "save";
|
||||
return submitAction;
|
||||
}, []);
|
||||
const submitEmployeeForm = useCallback(
|
||||
(submitAction = "save") => {
|
||||
submitActionRef.current = submitAction;
|
||||
form.submit();
|
||||
},
|
||||
[form]
|
||||
);
|
||||
const navigateToEmployee = useCallback(
|
||||
(employeeId) => {
|
||||
history({
|
||||
search: queryString.stringify({
|
||||
...search,
|
||||
employeeId
|
||||
})
|
||||
});
|
||||
},
|
||||
[history, search]
|
||||
);
|
||||
|
||||
const handleFinish = async (values) => {
|
||||
const submitAction = saveAndResetSubmitAction();
|
||||
const userEmail = typeof values.user_email === "string" ? values.user_email.trim() : values.user_email;
|
||||
const normalizedValues = {
|
||||
...values,
|
||||
user_email: userEmail === "" ? null : userEmail
|
||||
};
|
||||
|
||||
const handleFinish = (values) => {
|
||||
if (search.employeeId && search.employeeId !== "new") {
|
||||
//Update a record.
|
||||
logImEXEvent("shop_employee_update");
|
||||
|
||||
updateEmployee({
|
||||
variables: {
|
||||
id: search.employeeId,
|
||||
employee: {
|
||||
...values,
|
||||
user_email: values.user_email === "" ? null : values.user_email
|
||||
try {
|
||||
const result = await updateEmployee({
|
||||
variables: {
|
||||
id: search.employeeId,
|
||||
employee: normalizedValues
|
||||
}
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
notification.success({
|
||||
title: t("employees.successes.save")
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
notification.error({
|
||||
title: t("employees.errors.save", {
|
||||
message: JSON.stringify(error)
|
||||
})
|
||||
});
|
||||
});
|
||||
} else {
|
||||
//New record, insert it.
|
||||
logImEXEvent("shop_employee_insert");
|
||||
|
||||
insertEmployees({
|
||||
variables: { employees: [{ ...values, shopid: bodyshop.id }] },
|
||||
refetchQueries: ["QUERY_EMPLOYEES"]
|
||||
}).then((r) => {
|
||||
search.employeeId = r.data.insert_employees.returning[0].id;
|
||||
history({ search: queryString.stringify(search) });
|
||||
syncEmployeeFormToSavedData(result?.data?.update_employees?.returning?.[0] ?? normalizedValues);
|
||||
void refetch();
|
||||
if (submitAction === "saveAndNew") {
|
||||
navigateToEmployee("new");
|
||||
}
|
||||
notification.success({
|
||||
title: t("employees.successes.save")
|
||||
});
|
||||
} catch (error) {
|
||||
notification.error({
|
||||
title: t("employees.errors.save", {
|
||||
message: JSON.stringify(error)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
//New record, insert it.
|
||||
logImEXEvent("shop_employee_insert");
|
||||
|
||||
try {
|
||||
const result = await insertEmployees({
|
||||
variables: { employees: [{ ...normalizedValues, shopid: bodyshop.id }] },
|
||||
refetchQueries: ["QUERY_EMPLOYEES"]
|
||||
});
|
||||
const savedEmployee = result?.data?.insert_employees?.returning?.[0];
|
||||
|
||||
if (submitAction === "saveAndNew") {
|
||||
if (isNewEmployee) {
|
||||
resetEmployeeFormToCurrentData();
|
||||
}
|
||||
navigateToEmployee("new");
|
||||
} else if (savedEmployee?.id) {
|
||||
syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues);
|
||||
navigateToEmployee(savedEmployee.id);
|
||||
} else {
|
||||
syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues);
|
||||
}
|
||||
|
||||
notification.success({
|
||||
title: t("employees.successes.save")
|
||||
});
|
||||
} catch (error) {
|
||||
notification.error({
|
||||
title: t("employees.errors.save", {
|
||||
message: JSON.stringify(error)
|
||||
})
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -141,6 +273,8 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
|
||||
key: "actions",
|
||||
render: (text, record) => (
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
onClick={async () => {
|
||||
await deleteVacation({
|
||||
variables: { id: record.id },
|
||||
@@ -168,225 +302,376 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={employeeCardTitle}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => form.submit()}>
|
||||
{t("general.actions.save")}
|
||||
</Button>
|
||||
<Space wrap>
|
||||
<Button onClick={() => submitEmployeeForm("saveAndNew")} disabled={!resolvedIsDirty} style={{ minWidth: 170 }}>
|
||||
{t("general.actions.saveandnew") || "Save and New"}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => submitEmployeeForm("save")}
|
||||
disabled={!resolvedIsDirty}
|
||||
style={{ minWidth: 170 }}
|
||||
>
|
||||
{t("employees.actions.save_employee")}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
|
||||
<LayoutFormRow>
|
||||
<Form.Item
|
||||
name="first_name"
|
||||
label={t("employees.fields.first_name")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("employees.fields.last_name")}
|
||||
name="last_name"
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="employee_number"
|
||||
label={t("employees.fields.employee_number")}
|
||||
validateTrigger="onBlur"
|
||||
hasFeedback
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
},
|
||||
() => ({
|
||||
async validator(rule, value) {
|
||||
if (value) {
|
||||
const response = await client.query({
|
||||
query: CHECK_EMPLOYEE_NUMBER,
|
||||
variables: {
|
||||
employeenumber: value
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.employees_aggregate.aggregate.count === 0) {
|
||||
return Promise.resolve();
|
||||
} else if (
|
||||
response.data.employees_aggregate.nodes.length === 1 &&
|
||||
response.data.employees_aggregate.nodes[0].id === form.getFieldValue("id")
|
||||
) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(t("employees.validation.unique_employee_number"));
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
<Form
|
||||
onFinish={handleFinish}
|
||||
onFinishFailed={saveAndResetSubmitAction}
|
||||
autoComplete={"off"}
|
||||
layout="vertical"
|
||||
form={form}
|
||||
onValuesChange={() => {
|
||||
updateDirtyState(form.isFieldsTouched());
|
||||
}}
|
||||
>
|
||||
<FormsFieldChanged form={form} onReset={resetEmployeeFormToCurrentData} onDirtyChange={updateDirtyState} />
|
||||
<LayoutFormRow
|
||||
title={
|
||||
<div
|
||||
style={{
|
||||
...INLINE_TITLE_ROW_STYLE,
|
||||
justifyContent: "space-between"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
...INLINE_TITLE_TEXT_STYLE,
|
||||
marginRight: "auto"
|
||||
}}
|
||||
>
|
||||
{t("bodyshop.labels.employee_options")}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
flexWrap: "wrap",
|
||||
marginLeft: "auto"
|
||||
}}
|
||||
>
|
||||
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
|
||||
<div
|
||||
style={{
|
||||
...INLINE_TITLE_SWITCH_GROUP_STYLE
|
||||
}}
|
||||
>
|
||||
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employees.labels.active")}</div>
|
||||
<Form.Item noStyle valuePropName="checked" name="active">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
|
||||
<div
|
||||
style={{
|
||||
...INLINE_TITLE_SWITCH_GROUP_STYLE
|
||||
}}
|
||||
>
|
||||
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employees.fields.flat_rate")}</div>
|
||||
<Form.Item noStyle valuePropName="checked" name="flat_rate">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
wrapTitle
|
||||
>
|
||||
<Row gutter={[16, 16]} wrap>
|
||||
<Col {...employeeOptionsColProps}>
|
||||
<Form.Item
|
||||
name="first_name"
|
||||
label={t("employees.fields.first_name")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
}
|
||||
})
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("employees.fields.pin")}
|
||||
name="pin"
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
<LayoutFormRow>
|
||||
<Form.Item label={t("employees.fields.active")} valuePropName="checked" name="active">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("employees.fields.flat_rate")} name="flat_rate" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="hire_date"
|
||||
label={t("employees.fields.hire_date")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<DateTimePicker isDateOnly />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("employees.fields.termination_date")} name="termination_date">
|
||||
<DateTimePicker isDateOnly />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("employees.fields.user_email")}
|
||||
name="user_email"
|
||||
validateTrigger="onBlur"
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
async validator(rule, value) {
|
||||
const user_email = getFieldValue("user_email");
|
||||
|
||||
if (user_email && value) {
|
||||
const response = await client.query({
|
||||
query: QUERY_USERS_BY_EMAIL,
|
||||
variables: {
|
||||
email: user_email
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.users.length === 1) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(t("bodyshop.validation.useremailmustexist"));
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col {...employeeOptionsColProps}>
|
||||
<Form.Item
|
||||
label={t("employees.fields.last_name")}
|
||||
name="last_name"
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
}
|
||||
})
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("employees.fields.external_id")} name="external_id">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col {...employeeOptionsColProps}>
|
||||
<Form.Item
|
||||
name="employee_number"
|
||||
label={t("employees.fields.employee_number")}
|
||||
validateTrigger="onBlur"
|
||||
hasFeedback
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
},
|
||||
() => ({
|
||||
async validator(rule, value) {
|
||||
if (value) {
|
||||
const response = await client.query({
|
||||
query: CHECK_EMPLOYEE_NUMBER,
|
||||
variables: {
|
||||
employeenumber: value
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.employees_aggregate.aggregate.count === 0) {
|
||||
return Promise.resolve();
|
||||
} else if (
|
||||
response.data.employees_aggregate.nodes.length === 1 &&
|
||||
response.data.employees_aggregate.nodes[0].id === form.getFieldValue("id")
|
||||
) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(t("employees.validation.unique_employee_number"));
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
})
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col {...employeeOptionsColProps}>
|
||||
<Form.Item
|
||||
label={t("employees.fields.pin")}
|
||||
name="pin"
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col {...employeeOptionsColProps}>
|
||||
<Form.Item
|
||||
name="hire_date"
|
||||
label={t("employees.fields.hire_date")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<DateTimePicker isDateOnly />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col {...employeeOptionsColProps}>
|
||||
<Form.Item label={t("employees.fields.termination_date")} name="termination_date">
|
||||
<DateTimePicker isDateOnly />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col {...employeeOptionsColProps}>
|
||||
<Form.Item
|
||||
label={t("employees.fields.user_email")}
|
||||
name="user_email"
|
||||
validateTrigger="onBlur"
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
async validator(rule, value) {
|
||||
const user_email = typeof value === "string" ? value.trim() : getFieldValue("user_email");
|
||||
|
||||
if (user_email && value) {
|
||||
const response = await client.query({
|
||||
query: CHECK_EMPLOYEE_EMAIL,
|
||||
variables: {
|
||||
email: user_email,
|
||||
shopId: bodyshop.id
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.users.length === 1) {
|
||||
const matchingEmployees = response.data.employees_aggregate.nodes;
|
||||
const currentEmployeeId = form.getFieldValue("id") ?? search.employeeId;
|
||||
|
||||
if (
|
||||
response.data.employees_aggregate.aggregate.count === 0 ||
|
||||
matchingEmployees.every((employee) => employee.id === currentEmployeeId)
|
||||
) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return Promise.reject(t("employees.validation.unique_user_email"));
|
||||
}
|
||||
return Promise.reject(t("bodyshop.validation.useremailmustexist"));
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
})
|
||||
]}
|
||||
>
|
||||
<FormItemEmail />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col {...employeeOptionsColProps}>
|
||||
<Form.Item label={t("employees.fields.external_id")} name="external_id">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</LayoutFormRow>
|
||||
<Form.List name={["rates"]}>
|
||||
{(fields, { add, remove, move }) => {
|
||||
return (
|
||||
<div>
|
||||
{fields.map((field, index) => (
|
||||
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
|
||||
<LayoutFormRow grow>
|
||||
<Form.Item
|
||||
label={t("employees.fields.cost_center")}
|
||||
key={`${field.key}-cost_center`}
|
||||
name={[field.name, "cost_center"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "timetickets.labels.shift", label: t("timetickets.labels.shift") },
|
||||
...(bodyshop.cdk_dealerid ||
|
||||
bodyshop.pbs_serialnumber ||
|
||||
bodyshop.rr_dealerid ||
|
||||
Enhanced_Payroll.treatment === "on"
|
||||
? CiecaSelect(false, true)
|
||||
: bodyshop.md_responsibility_centers.costs.map((c) => ({
|
||||
value: c.name,
|
||||
label: c.name
|
||||
})))
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("employees.fields.rate")}
|
||||
key={`${field.key}-rate`}
|
||||
name={[field.name, "rate"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0} precision={2} />
|
||||
</Form.Item>
|
||||
<DeleteFilled
|
||||
onClick={() => {
|
||||
remove(field.name);
|
||||
}}
|
||||
/>
|
||||
<FormListMoveArrows move={move} index={index} total={fields.length} />
|
||||
</LayoutFormRow>
|
||||
</Form.Item>
|
||||
))}
|
||||
<Form.Item>
|
||||
<LayoutFormRow
|
||||
title={t("bodyshop.labels.employee_rates")}
|
||||
actions={[
|
||||
<Button
|
||||
type="dashed"
|
||||
key="add-rate"
|
||||
type="primary"
|
||||
block
|
||||
onClick={() => {
|
||||
add();
|
||||
}}
|
||||
style={{ width: "100%" }}
|
||||
id="add-employee-rate-button"
|
||||
>
|
||||
<span id="new-employee-rate">{t("employees.actions.newrate")}</span>
|
||||
<span id="new-employee-rate">{t("employees.actions.addrate")}</span>
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</div>
|
||||
]}
|
||||
>
|
||||
<div>
|
||||
{fields.length === 0 ? (
|
||||
<ConfigListEmptyState actionLabel={t("employees.actions.addrate")} />
|
||||
) : (
|
||||
fields.map((field, index) => {
|
||||
return (
|
||||
<Form.Item noStyle key={field.key}>
|
||||
<InlineValidatedFormRow
|
||||
form={form}
|
||||
errorNames={[["rates", field.name, "cost_center"]]}
|
||||
noDivider
|
||||
title={
|
||||
<div style={INLINE_TITLE_ROW_STYLE}>
|
||||
<InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} />
|
||||
<div style={INLINE_TITLE_GROUP_STYLE}>
|
||||
<div style={INLINE_TITLE_LABEL_STYLE}>{t("employees.fields.cost_center")}</div>
|
||||
<Form.Item
|
||||
noStyle
|
||||
name={[field.name, "cost_center"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
size="small"
|
||||
options={[
|
||||
{ value: "timetickets.labels.shift", label: t("timetickets.labels.shift") },
|
||||
...(bodyshop.cdk_dealerid ||
|
||||
bodyshop.pbs_serialnumber ||
|
||||
bodyshop.rr_dealerid ||
|
||||
Enhanced_Payroll.treatment === "on"
|
||||
? CiecaSelect(false, true)
|
||||
: bodyshop.md_responsibility_centers.costs.map((c) => ({
|
||||
value: c.name,
|
||||
label: c.name
|
||||
})))
|
||||
]}
|
||||
style={{ width: "100%" }}
|
||||
styles={{
|
||||
selector: INLINE_TITLE_INPUT_STYLE
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
wrapTitle
|
||||
extra={
|
||||
<Space align="center" size="small">
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteFilled />}
|
||||
onClick={() => {
|
||||
remove(field.name);
|
||||
}}
|
||||
/>
|
||||
<FormListMoveArrows
|
||||
move={move}
|
||||
index={index}
|
||||
total={fields.length}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form.Item
|
||||
label={t("employees.fields.rate")}
|
||||
name={[field.name, "rate"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber min={0} precision={2} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</InlineValidatedFormRow>
|
||||
</Form.Item>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</LayoutFormRow>
|
||||
);
|
||||
}}
|
||||
</Form.List>
|
||||
</Form>
|
||||
|
||||
<ResponsiveTable
|
||||
title={() => <ShopEmployeeAddVacation employee={data && data.employees_by_pk} />}
|
||||
columns={columns}
|
||||
mobileColumnKeys={["start", "length", "actions"]}
|
||||
rowKey={"id"}
|
||||
dataSource={data?.employees_by_pk?.employee_vacations ?? []}
|
||||
/>
|
||||
<LayoutFormRow
|
||||
title={t("bodyshop.labels.employee_vacation")}
|
||||
actions={[
|
||||
<ShopEmployeeAddVacation
|
||||
key="add-vacation"
|
||||
employee={data && data.employees_by_pk}
|
||||
buttonProps={{
|
||||
type: "primary",
|
||||
block: true
|
||||
}}
|
||||
/>
|
||||
]}
|
||||
>
|
||||
{(data?.employees_by_pk?.employee_vacations ?? []).length === 0 ? (
|
||||
<ConfigListEmptyState actionLabel={t("employees.actions.addvacation")} />
|
||||
) : (
|
||||
<div>
|
||||
<ResponsiveTable
|
||||
columns={columns}
|
||||
mobileColumnKeys={["start", "length", "actions"]}
|
||||
rowKey={"id"}
|
||||
dataSource={data?.employees_by_pk?.employee_vacations ?? []}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</LayoutFormRow>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user