Compare commits
179 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 | ||
|
|
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 | ||
|
|
8f4ac866f1 | ||
|
|
8a043767cd | ||
|
|
6ca0ebff5f | ||
|
|
a96a1139fa | ||
|
|
483da283dc | ||
|
|
9ad2a53bec | ||
|
|
9267e584ff | ||
|
|
6590f8961b | ||
|
|
69861af88c | ||
|
|
d7294ebba6 | ||
|
|
7df71b8f44 | ||
|
|
d9270102b1 | ||
|
|
d416780e63 | ||
|
|
b6cbfb8e45 | ||
|
|
9c97b30e8e | ||
|
|
cc48448a07 | ||
|
|
4776b03a21 | ||
|
|
d991e32501 | ||
|
|
20943f74e9 | ||
|
|
4af312854e | ||
|
|
ff084f6fb8 | ||
|
|
5c9e4517a6 | ||
|
|
190217ffce | ||
|
|
28dc1d4533 | ||
|
|
a97e03e0b1 | ||
|
|
e30353cab6 | ||
|
|
c9b9f67170 | ||
|
|
969dd8be8d | ||
|
|
794f64dfba | ||
|
|
4a47f543b2 | ||
|
|
3b60aa89f1 | ||
|
|
220b1c7968 | ||
|
|
7dab60e3bc | ||
|
|
20d2572087 | ||
|
|
d4c7298334 | ||
|
|
ac4c09af60 | ||
|
|
e17b57c705 | ||
|
|
6a60af9dfe | ||
|
|
dfb6f02864 | ||
|
|
48bb494e0f | ||
|
|
9b74cba56b | ||
|
|
6fc8124268 | ||
|
|
4abc1a7d0f | ||
|
|
255d761210 | ||
|
|
2a5e5d2462 | ||
|
|
6ef56f97c0 | ||
|
|
97d8047a3d | ||
|
|
16220d0a27 | ||
|
|
51fba24a3d | ||
|
|
52f43a600c | ||
|
|
e25174ff97 |
@@ -7,6 +7,7 @@ _reference
|
|||||||
client
|
client
|
||||||
redis/dockerdata
|
redis/dockerdata
|
||||||
hasura
|
hasura
|
||||||
|
harness-feature-flags-export
|
||||||
node_modules
|
node_modules
|
||||||
# Files to exclude
|
# Files to exclude
|
||||||
.ebignore
|
.ebignore
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
/client
|
/client
|
||||||
/firebase
|
/firebase
|
||||||
/hasura
|
/hasura
|
||||||
|
/harness-feature-flags-export
|
||||||
/jsreport
|
/jsreport
|
||||||
/node_modules
|
/node_modules
|
||||||
.env.local
|
.env.local
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -17,6 +17,9 @@ jsreport/auth-server/node_modules
|
|||||||
client/coverage
|
client/coverage
|
||||||
admin/coverage
|
admin/coverage
|
||||||
|
|
||||||
|
# Generated Harness/Split feature flag export artifacts
|
||||||
|
/harness-feature-flags-export/
|
||||||
|
|
||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
client/build
|
client/build
|
||||||
@@ -149,3 +152,9 @@ docker_data
|
|||||||
/COPILOT.md
|
/COPILOT.md
|
||||||
/.github/copilot-instructions.md
|
/.github/copilot-instructions.md
|
||||||
/GEMINI.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
3196
client/package-lock.json
generated
3196
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,61 +8,61 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"proxy": "http://localhost:4000",
|
"proxy": "http://localhost:4000",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@amplitude/analytics-browser": "^2.38.0",
|
"@amplitude/analytics-browser": "^2.42.4",
|
||||||
"@ant-design/pro-layout": "^7.22.6",
|
"@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/core": "^6.3.1",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@documenso/embed-react": "^0.6.1",
|
||||||
"@emotion/is-prop-valid": "^1.4.0",
|
"@emotion/is-prop-valid": "^1.4.0",
|
||||||
"@fingerprintjs/fingerprintjs": "^5.1.0",
|
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
||||||
"@firebase/analytics": "^0.10.21",
|
"@firebase/analytics": "^0.10.22",
|
||||||
"@firebase/app": "^0.14.10",
|
"@firebase/app": "^0.14.12",
|
||||||
"@firebase/auth": "^1.12.2",
|
"@firebase/auth": "^1.13.1",
|
||||||
"@firebase/firestore": "^4.13.0",
|
"@firebase/firestore": "^4.14.1",
|
||||||
"@firebase/messaging": "^0.12.25",
|
"@firebase/messaging": "^0.12.26",
|
||||||
"@jsreport/browser-client": "^3.1.0",
|
"@jsreport/browser-client": "^3.1.0",
|
||||||
"@reduxjs/toolkit": "^2.11.2",
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
"@sentry/cli": "^3.3.5",
|
"@sentry/cli": "^3.4.3",
|
||||||
"@sentry/react": "^10.47.0",
|
"@sentry/react": "^10.53.1",
|
||||||
"@sentry/vite-plugin": "^4.9.1",
|
"@sentry/vite-plugin": "^4.9.1",
|
||||||
"@splitsoftware/splitio-react": "^2.6.1",
|
|
||||||
"@tanem/react-nprogress": "^5.0.63",
|
"@tanem/react-nprogress": "^5.0.63",
|
||||||
"antd": "^6.3.5",
|
"antd": "^6.4.3",
|
||||||
"apollo-link-logger": "^3.0.0",
|
"apollo-link-logger": "^3.0.0",
|
||||||
"autosize": "^6.0.1",
|
"autosize": "^6.0.1",
|
||||||
"axios": "^1.14.0",
|
"axios": "^1.16.1",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"css-box-model": "^1.2.1",
|
"css-box-model": "^1.2.1",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"dayjs-business-days2": "^1.3.3",
|
"dayjs-business-days2": "^1.3.3",
|
||||||
"dinero.js": "^1.9.1",
|
"dinero.js": "^1.9.1",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.4.2",
|
||||||
"env-cmd": "^11.0.0",
|
"env-cmd": "^11.0.0",
|
||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
"graphql": "^16.13.2",
|
"graphql": "^16.14.0",
|
||||||
"graphql-ws": "^6.0.8",
|
"graphql-ws": "^6.0.8",
|
||||||
"i18next": "^25.10.10",
|
"i18next": "^25.10.10",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"immutability-helper": "^3.1.1",
|
"immutability-helper": "^3.1.1",
|
||||||
"libphonenumber-js": "^1.12.41",
|
"libphonenumber-js": "^1.13.3",
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"logrocket": "^12.1.0",
|
"logrocket": "^12.1.1",
|
||||||
"markerjs2": "^2.32.7",
|
"markerjs2": "^2.32.7",
|
||||||
"memoize-one": "^6.0.0",
|
"memoize-one": "^6.0.0",
|
||||||
"normalize-url": "^8.1.1",
|
"normalize-url": "^8.1.1",
|
||||||
"object-hash": "^3.0.0",
|
"object-hash": "^3.0.0",
|
||||||
"phone": "^3.1.71",
|
"phone": "^3.1.71",
|
||||||
"posthog-js": "^1.364.4",
|
"posthog-js": "^1.376.0",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"query-string": "^9.3.1",
|
"query-string": "^9.3.1",
|
||||||
"raf-schd": "^4.0.3",
|
"raf-schd": "^4.0.3",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.6",
|
||||||
"react-big-calendar": "^1.19.4",
|
"react-big-calendar": "^1.19.4",
|
||||||
"react-color": "^2.19.3",
|
"react-color": "^2.19.3",
|
||||||
"react-cookie": "^8.1.0",
|
"react-cookie": "^8.1.2",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.6",
|
||||||
"react-grid-gallery": "^1.0.1",
|
"react-grid-gallery": "^1.0.1",
|
||||||
"react-grid-layout": "^2.2.3",
|
"react-grid-layout": "^2.2.3",
|
||||||
"react-i18next": "^16.6.6",
|
"react-i18next": "^16.6.6",
|
||||||
@@ -72,22 +72,22 @@
|
|||||||
"react-number-format": "^5.4.5",
|
"react-number-format": "^5.4.5",
|
||||||
"react-popopo": "^2.1.9",
|
"react-popopo": "^2.1.9",
|
||||||
"react-product-fruits": "^2.2.62",
|
"react-product-fruits": "^2.2.62",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.3.0",
|
||||||
"react-resizable": "^3.1.3",
|
"react-resizable": "^3.1.3",
|
||||||
"react-router-dom": "^7.13.2",
|
"react-router-dom": "^7.15.1",
|
||||||
"react-sticky": "^6.0.3",
|
"react-sticky": "^6.0.3",
|
||||||
"react-virtuoso": "^4.18.3",
|
"react-virtuoso": "^4.18.7",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"redux": "^5.0.1",
|
"redux": "^5.0.1",
|
||||||
"redux-actions": "^3.0.3",
|
"redux-actions": "^3.0.3",
|
||||||
"redux-persist": "^6.0.0",
|
"redux-persist": "^6.0.0",
|
||||||
"redux-saga": "^1.4.2",
|
"redux-saga": "^1.5.0",
|
||||||
"redux-state-sync": "^3.1.4",
|
"redux-state-sync": "^3.1.4",
|
||||||
"reselect": "^5.1.1",
|
"reselect": "^5.2.0",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"sass": "^1.98.0",
|
"sass": "^1.100.0",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"styled-components": "^6.3.12",
|
"styled-components": "^6.4.2",
|
||||||
"vite-plugin-ejs": "^1.7.0",
|
"vite-plugin-ejs": "^1.7.0",
|
||||||
"web-vitals": "^5.2.0"
|
"web-vitals": "^5.2.0"
|
||||||
},
|
},
|
||||||
@@ -137,14 +137,14 @@
|
|||||||
"@rollup/rollup-linux-x64-gnu": "4.6.1"
|
"@rollup/rollup-linux-x64-gnu": "4.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ant-design/icons": "^6.1.1",
|
"@ant-design/icons": "^6.2.3",
|
||||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||||
"@babel/preset-react": "^7.28.5",
|
"@babel/preset-react": "^7.29.7",
|
||||||
"@dotenvx/dotenvx": "^1.59.1",
|
"@dotenvx/dotenvx": "^1.68.1",
|
||||||
"@emotion/babel-plugin": "^11.13.5",
|
"@emotion/babel-plugin": "^11.13.5",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.60.0",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
@@ -156,21 +156,21 @@
|
|||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.6.0",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"memfs": "^4.57.1",
|
"memfs": "^4.57.2",
|
||||||
"os-browserify": "^0.3.0",
|
"os-browserify": "^0.3.0",
|
||||||
"playwright": "^1.58.2",
|
"playwright": "^1.60.0",
|
||||||
"react-error-overlay": "^6.1.0",
|
"react-error-overlay": "^6.1.0",
|
||||||
"redux-logger": "^3.0.6",
|
"redux-logger": "^3.0.6",
|
||||||
"source-map-explorer": "^2.5.3",
|
"source-map-explorer": "^2.5.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-babel": "^1.6.0",
|
"vite-plugin-babel": "^1.7.3",
|
||||||
"vite-plugin-eslint": "^1.8.1",
|
"vite-plugin-eslint": "^1.8.1",
|
||||||
"vite-plugin-node-polyfills": "^0.26.0",
|
"vite-plugin-node-polyfills": "^0.28.0",
|
||||||
"vite-plugin-pwa": "^1.2.0",
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
"vite-plugin-style-import": "^2.0.0",
|
"vite-plugin-style-import": "^2.0.0",
|
||||||
"vitest": "^4.1.2",
|
"vitest": "^4.1.7",
|
||||||
"workbox-window": "^7.4.0"
|
"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:
|
The following NPM packages may be included in this product:
|
||||||
|
|
||||||
- @stripe/react-stripe-js@1.9.0
|
- @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 { ApolloProvider } from "@apollo/client/react";
|
||||||
import * as Sentry from "@sentry/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 { ConfigProvider } from "antd";
|
||||||
import enLocale from "antd/es/locale/en_US";
|
import enLocale from "antd/es/locale/en_US";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
@@ -16,23 +16,21 @@ import client from "../utils/GraphQLClient";
|
|||||||
import App from "./App";
|
import App from "./App";
|
||||||
import getTheme from "./themeProvider";
|
import getTheme from "./themeProvider";
|
||||||
|
|
||||||
// Base Split configuration
|
|
||||||
const config = {
|
const config = {
|
||||||
core: {
|
core: {
|
||||||
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
|
|
||||||
key: "anon"
|
key: "anon"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function SplitClientProvider({ children }) {
|
function FeatureFlagClientProvider({ children }) {
|
||||||
const imexshopid = useSelector((state) => state.user.imexshopid);
|
const imexshopid = useSelector((state) => state.user.imexshopid);
|
||||||
const splitClient = useSplitClient({ key: imexshopid || "anon" });
|
const featureFlagClient = useFeatureFlagClient({ key: imexshopid || "anon" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (import.meta.env.DEV && splitClient && imexshopid) {
|
if (import.meta.env.DEV && featureFlagClient && imexshopid) {
|
||||||
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
|
console.log(`Feature flag client initialized with key: ${imexshopid}, isReady: ${featureFlagClient.isReady}`);
|
||||||
}
|
}
|
||||||
}, [splitClient, imexshopid]);
|
}, [featureFlagClient, imexshopid]);
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
@@ -124,11 +122,11 @@ function AppContainer() {
|
|||||||
<ApolloProvider client={client}>
|
<ApolloProvider client={client}>
|
||||||
<ConfigProvider input={antdInput} locale={enLocale} theme={theme} form={antdForm}>
|
<ConfigProvider input={antdInput} locale={enLocale} theme={theme} form={antdForm}>
|
||||||
<GlobalLoadingBar />
|
<GlobalLoadingBar />
|
||||||
<SplitFactoryProvider config={config}>
|
<FeatureFlagProvider config={config}>
|
||||||
<SplitClientProvider>
|
<FeatureFlagClientProvider>
|
||||||
<App />
|
<App />
|
||||||
</SplitClientProvider>
|
</FeatureFlagClientProvider>
|
||||||
</SplitFactoryProvider>
|
</FeatureFlagProvider>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</ApolloProvider>
|
</ApolloProvider>
|
||||||
</CookiesProvider>
|
</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 { Button, Result } from "antd";
|
||||||
import LogRocket from "logrocket";
|
//import LogRocket from "logrocket";
|
||||||
import { lazy, Suspense, useEffect, useState } from "react";
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
@@ -101,13 +101,13 @@ export function App({
|
|||||||
client.setAttribute("imexshopid", bodyshop.imexshopid);
|
client.setAttribute("imexshopid", bodyshop.imexshopid);
|
||||||
|
|
||||||
if (client.getTreatment("LogRocket_Tracking") === "on") {
|
if (client.getTreatment("LogRocket_Tracking") === "on") {
|
||||||
console.log("LR Start");
|
// console.log("LR Start");
|
||||||
LogRocket.init(
|
// LogRocket.init(
|
||||||
InstanceRenderMgr({
|
// InstanceRenderMgr({
|
||||||
imex: "gvfvfw/bodyshopapp",
|
// imex: "gvfvfw/bodyshopapp",
|
||||||
rome: "rome-online/rome-online"
|
// rome: "rome-online/rome-online"
|
||||||
})
|
// })
|
||||||
);
|
// );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [bodyshop, client, currentUser.authorized]);
|
}, [bodyshop, client, currentUser.authorized]);
|
||||||
@@ -225,13 +225,22 @@ export function App({
|
|||||||
path="/parts/*"
|
path="/parts/*"
|
||||||
element={
|
element={
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
|
||||||
|
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||||
|
</SocketProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="*" element={<SimplifiedPartsPageContainer />} />
|
<Route path="*" element={<SimplifiedPartsPageContainer />} />
|
||||||
</Route>
|
</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 path="*" element={<DocumentEditorContainer />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -509,3 +509,55 @@
|
|||||||
pointer-events: none !important;
|
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
|
isDarkMode
|
||||||
),
|
),
|
||||||
colorError: isDarkMode ? "#ff4d4f" : "#f5222d",
|
colorError: isDarkMode ? "#ff4d4f" : "#f5222d",
|
||||||
|
colorShadow: isDarkMode ? "rgba(0, 0, 0, 0.7)" : "#000000",
|
||||||
colorBgBase: isDarkMode ? "#1f1f1f" : "#ffffff" // Align with Ant Design dark mode
|
colorBgBase: isDarkMode ? "#1f1f1f" : "#ffffff" // Align with Ant Design dark mode
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useApolloClient, useMutation } from "@apollo/client/react";
|
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 { Button, Checkbox, Divider, Form, Modal, Space } from "antd";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Icon, { UploadOutlined } from "@ant-design/icons";
|
import Icon, { UploadOutlined } from "@ant-design/icons";
|
||||||
import { useApolloClient } from "@apollo/client/react";
|
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 { Alert, Divider, Form, Input, Select, Space, Statistic, Switch, Upload } from "antd";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useLazyQuery, useQuery } from "@apollo/client/react";
|
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 { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { QUERY_OUTSTANDING_INVENTORY } from "../../graphql/inventory.queries";
|
import { QUERY_OUTSTANDING_INVENTORY } from "../../graphql/inventory.queries";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { DeleteFilled, DollarCircleFilled } from "@ant-design/icons";
|
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 { Button, Checkbox, Form, Input, InputNumber, Select, Space, Switch, Table, Tooltip } from "antd";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -36,6 +36,7 @@ export function BillEnterModalLinesComponent({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setFieldsValue, getFieldsValue, getFieldValue } = form;
|
const { setFieldsValue, getFieldsValue, getFieldValue } = form;
|
||||||
const firstFieldRefs = useRef({});
|
const firstFieldRefs = useRef({});
|
||||||
|
const lineDescriptionRefs = useRef({});
|
||||||
|
|
||||||
const CONTROL_HEIGHT = 32;
|
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)
|
// Only fill actual_cost when the user forward-tabs out of Retail (actual_price)
|
||||||
const autofillActualCost = (index) => {
|
const autofillActualCost = (index) => {
|
||||||
if (bodyshop.accountingconfig?.disableBillCostCalculation) return;
|
if (bodyshop.accountingconfig?.disableBillCostCalculation) return;
|
||||||
@@ -195,6 +213,12 @@ export function BillEnterModalLinesComponent({
|
|||||||
minHeight: `${CONTROL_HEIGHT}px`
|
minHeight: `${CONTROL_HEIGHT}px`
|
||||||
}}
|
}}
|
||||||
allowRemoved={form.getFieldValue("is_credit_memo") || false}
|
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) => {
|
onSelect={(value, opt) => {
|
||||||
// IMPORTANT:
|
// IMPORTANT:
|
||||||
// Do NOT autofill actual_cost here. It should only fill when the user forward-tabs
|
// 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"),
|
label: t("billlines.fields.line_desc"),
|
||||||
rules: [{ required: true }]
|
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 }]
|
rules: [{ required: true }]
|
||||||
}),
|
}),
|
||||||
formInput: () => (
|
formInput: () => (
|
||||||
<Select
|
<Select
|
||||||
showSearch
|
showSearch
|
||||||
style={{ minWidth: "3rem" }}
|
style={{ minWidth: "3rem" }}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
options={
|
options={
|
||||||
@@ -461,7 +495,7 @@ export function BillEnterModalLinesComponent({
|
|||||||
name: [field.name, "location"]
|
name: [field.name, "location"]
|
||||||
}),
|
}),
|
||||||
formInput: () => (
|
formInput: () => (
|
||||||
<Select
|
<Select
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
options={bodyshop.md_parts_locations.map((loc) => ({ value: loc, label: loc }))}
|
options={bodyshop.md_parts_locations.map((loc) => ({ value: loc, label: loc }))}
|
||||||
@@ -495,7 +529,9 @@ export function BillEnterModalLinesComponent({
|
|||||||
{Enhanced_Payroll.treatment === "on" ? (
|
{Enhanced_Payroll.treatment === "on" ? (
|
||||||
<Space>
|
<Space>
|
||||||
{t("joblines.fields.assigned_team", { name: employeeTeamName?.name })}
|
{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>
|
</Space>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -506,10 +542,7 @@ export function BillEnterModalLinesComponent({
|
|||||||
rules={[{ required: true }]}
|
rules={[{ required: true }]}
|
||||||
name={[record.name, "lbr_adjustment", "mod_lbr_ty"]}
|
name={[record.name, "lbr_adjustment", "mod_lbr_ty"]}
|
||||||
>
|
>
|
||||||
<Select
|
<Select allowClear options={CiecaSelect(false, true)} />
|
||||||
allowClear
|
|
||||||
options={CiecaSelect(false, true)}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{Enhanced_Payroll.treatment === "on" ? (
|
{Enhanced_Payroll.treatment === "on" ? (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
|
|||||||
import GlobalSearch from "../global-search/global-search.component";
|
import GlobalSearch from "../global-search/global-search.component";
|
||||||
import GlobalSearchOs from "../global-search/global-search-os.component";
|
import GlobalSearchOs from "../global-search/global-search-os.component";
|
||||||
import "./breadcrumbs.styles.scss";
|
import "./breadcrumbs.styles.scss";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
breadcrumbs: selectBreadcrumbs,
|
breadcrumbs: selectBreadcrumbs,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { PictureFilled } from "@ant-design/icons";
|
import { PictureFilled } from "@ant-design/icons";
|
||||||
import { useQuery } from "@apollo/client/react";
|
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 { Badge, Popover } from "antd";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
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({
|
const mapStateToProps = createStructuredSelector({
|
||||||
currentUser: selectCurrentUser,
|
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 ResponsiveTable from "../responsive-table/responsive-table.component";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -49,7 +49,13 @@ export default function PBSCustomerSelector({ bodyshop, socket }) {
|
|||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const columns = [
|
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"),
|
title: t("jobs.fields.dms.name1"),
|
||||||
key: "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 DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
|
||||||
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
||||||
import { DMS_MAP } from "../../utils/dmsUtils";
|
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:
|
* CDK-like DMS post form:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Result } from "antd";
|
import { Result, theme } from "antd";
|
||||||
import * as markerjs2 from "markerjs2";
|
import * as markerjs2 from "markerjs2";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { handleUpload } from "../documents-local-upload/documents-local-upload.utility";
|
||||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
import {
|
||||||
|
addGreyscaleButtonToMarkerArea,
|
||||||
|
addImageHistoryUndoToMarkerArea,
|
||||||
|
applyGreyscaleToMarkerAreaImage,
|
||||||
|
setMarkerAreaImageSource
|
||||||
|
} from "./document-editor.utility";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
currentUser: selectCurrentUser,
|
currentUser: selectCurrentUser,
|
||||||
@@ -24,7 +30,9 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
|
|||||||
const [imageLoaded, setImageLoaded] = useState(false);
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
const [imageLoading, setImageLoading] = useState(true);
|
const [imageLoading, setImageLoading] = useState(true);
|
||||||
const markerArea = useRef(null);
|
const markerArea = useRef(null);
|
||||||
|
const imageHistory = useRef([]);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { token } = theme.useToken();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
@@ -32,6 +40,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
|
|||||||
async (dataUrl) => {
|
async (dataUrl) => {
|
||||||
if (uploading) return;
|
if (uploading) return;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
setLoading(true);
|
||||||
const blob = await b64toBlob(dataUrl);
|
const blob = await b64toBlob(dataUrl);
|
||||||
const nameWithoutExt = filename.split(".").slice(0, -1).join(".").trim();
|
const nameWithoutExt = filename.split(".").slice(0, -1).join(".").trim();
|
||||||
const parts = nameWithoutExt.split("-");
|
const parts = nameWithoutExt.split("-");
|
||||||
@@ -70,6 +79,23 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
|
|||||||
[filename, jobid, notification, uploading]
|
[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(() => {
|
useEffect(() => {
|
||||||
if (imgRef.current !== null && imageLoaded && !markerArea.current) {
|
if (imgRef.current !== null && imageLoaded && !markerArea.current) {
|
||||||
// create a marker.js MarkerArea
|
// create a marker.js MarkerArea
|
||||||
@@ -93,8 +119,10 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
|
|||||||
markerArea.current.renderImageQuality = 1;
|
markerArea.current.renderImageQuality = 1;
|
||||||
//markerArea.current.settings.displayMode = "inline";
|
//markerArea.current.settings.displayMode = "inline";
|
||||||
markerArea.current.show();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!imageUrl) return;
|
if (!imageUrl) return;
|
||||||
@@ -106,6 +134,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
|
|||||||
try {
|
try {
|
||||||
const response = await axios.get(imageUrl, { responseType: "blob", signal: controller.signal });
|
const response = await axios.get(imageUrl, { responseType: "blob", signal: controller.signal });
|
||||||
const blobUrl = URL.createObjectURL(response.data);
|
const blobUrl = URL.createObjectURL(response.data);
|
||||||
|
imageHistory.current = [];
|
||||||
setLoadedImageUrl((prevUrl) => {
|
setLoadedImageUrl((prevUrl) => {
|
||||||
if (prevUrl) URL.revokeObjectURL(prevUrl);
|
if (prevUrl) URL.revokeObjectURL(prevUrl);
|
||||||
return blobUrl;
|
return blobUrl;
|
||||||
@@ -142,7 +171,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div style={{ background: token.colorBgBase, color: token.colorText, minHeight: "100vh" }}>
|
||||||
{!loading && !uploaded && loadedImageUrl && (
|
{!loading && !uploaded && loadedImageUrl && (
|
||||||
<img
|
<img
|
||||||
ref={imgRef}
|
ref={imgRef}
|
||||||
@@ -158,7 +187,12 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
|
|||||||
{(loading || imageLoading || !imageLoaded) && !uploaded && (
|
{(loading || imageLoading || !imageLoaded) && !uploaded && (
|
||||||
<LoadingSpinner message={t("documents.labels.uploading")} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
//import "tui-image-editor/dist/tui-image-editor.css";
|
//import "tui-image-editor/dist/tui-image-editor.css";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Result } from "antd";
|
import { Result, theme } from "antd";
|
||||||
import * as markerjs2 from "markerjs2";
|
import * as markerjs2 from "markerjs2";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||||
import { handleUpload } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility.js";
|
import { handleUpload } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility.js";
|
||||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
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({
|
const mapStateToProps = createStructuredSelector({
|
||||||
currentUser: selectCurrentUser,
|
currentUser: selectCurrentUser,
|
||||||
@@ -27,7 +33,9 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
|||||||
const [imageLoaded, setImageLoaded] = useState(false);
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
const [imageLoading, setImageLoading] = useState(true);
|
const [imageLoading, setImageLoading] = useState(true);
|
||||||
const markerArea = useRef(null);
|
const markerArea = useRef(null);
|
||||||
|
const imageHistory = useRef([]);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { token } = theme.useToken();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|
||||||
const triggerUpload = useCallback(
|
const triggerUpload = useCallback(
|
||||||
@@ -57,6 +65,23 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
|||||||
[bodyshop, currentUser, document, notification]
|
[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(() => {
|
useEffect(() => {
|
||||||
if (imgRef.current !== null && imageLoaded && !markerArea.current) {
|
if (imgRef.current !== null && imageLoaded && !markerArea.current) {
|
||||||
// create a marker.js MarkerArea
|
// create a marker.js MarkerArea
|
||||||
@@ -80,8 +105,10 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
|||||||
markerArea.current.renderImageQuality = 1;
|
markerArea.current.renderImageQuality = 1;
|
||||||
//markerArea.current.settings.displayMode = "inline";
|
//markerArea.current.settings.displayMode = "inline";
|
||||||
markerArea.current.show();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!document?.id) return;
|
if (!document?.id) return;
|
||||||
@@ -100,6 +127,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
const blobUrl = URL.createObjectURL(response.data);
|
const blobUrl = URL.createObjectURL(response.data);
|
||||||
|
imageHistory.current = [];
|
||||||
setImageUrl((prevUrl) => {
|
setImageUrl((prevUrl) => {
|
||||||
if (prevUrl) URL.revokeObjectURL(prevUrl);
|
if (prevUrl) URL.revokeObjectURL(prevUrl);
|
||||||
return blobUrl;
|
return blobUrl;
|
||||||
@@ -134,7 +162,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div style={{ background: token.colorBgBase, color: token.colorText, minHeight: "100vh" }}>
|
||||||
{!loading && !uploaded && imageUrl && (
|
{!loading && !uploaded && imageUrl && (
|
||||||
<img
|
<img
|
||||||
ref={imgRef}
|
ref={imgRef}
|
||||||
@@ -150,7 +178,12 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
|||||||
{(loading || imageLoading || !imageLoaded) && !uploaded && (
|
{(loading || imageLoading || !imageLoaded) && !uploaded && (
|
||||||
<LoadingSpinner message={t("documents.labels.uploading")} />
|
<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>
|
</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 { logImEXEvent } from "../../firebase/firebase.utils";
|
||||||
import { INSERT_NEW_DOCUMENT } from "../../graphql/documents.queries";
|
import { INSERT_NEW_DOCUMENT } from "../../graphql/documents.queries";
|
||||||
import { axiosAuthInterceptorId } from "../../utils/CleanAxios";
|
import { axiosAuthInterceptorId } from "../../utils/CleanAxios";
|
||||||
|
import { replaceAccents } from "../../utils/replaceAccents.js";
|
||||||
import client from "../../utils/GraphQLClient";
|
import client from "../../utils/GraphQLClient";
|
||||||
|
|
||||||
//Context: currentUserEmail, bodyshop, jobid, invoiceid
|
//Context: currentUserEmail, bodyshop, jobid, invoiceid
|
||||||
@@ -144,32 +145,3 @@ export const uploadToS3 = async (
|
|||||||
if (onError) onError(JSON.stringify(error.message));
|
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 JobDocumentsGalleryExternal from "../jobs-documents-gallery/jobs-documents-gallery.external.component";
|
||||||
import JobsDocumentsLocalGalleryExternalComponent from "../jobs-documents-local-gallery/jobs-documents-local-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 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";
|
import JobsDocumentImgproxyGalleryExternal from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
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);
|
||||||
@@ -5,6 +5,7 @@ import { connect } from "react-redux";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import WssStatusDisplayComponent from "../../components/wss-status-display/wss-status-display.component.jsx";
|
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 { selectIsPartsEntry } from "../../redux/application/application.selectors.js";
|
||||||
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
|
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
|
||||||
|
|
||||||
@@ -16,6 +17,12 @@ const mapStateToProps = createStructuredSelector({
|
|||||||
|
|
||||||
export function GlobalFooter({ isPartsEntry }) {
|
export function GlobalFooter({ isPartsEntry }) {
|
||||||
const { t } = useTranslation();
|
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) {
|
if (isPartsEntry) {
|
||||||
return (
|
return (
|
||||||
@@ -38,6 +45,7 @@ export function GlobalFooter({ isPartsEntry }) {
|
|||||||
<Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}>
|
<Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}>
|
||||||
Disclaimer & Notices
|
Disclaimer & Notices
|
||||||
</Link>
|
</Link>
|
||||||
|
{testFlagIndicator}
|
||||||
</div>
|
</div>
|
||||||
</Footer>
|
</Footer>
|
||||||
);
|
);
|
||||||
@@ -74,6 +82,7 @@ export function GlobalFooter({ isPartsEntry }) {
|
|||||||
<Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}>
|
<Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}>
|
||||||
Disclaimer & Notices
|
Disclaimer & Notices
|
||||||
</Link>
|
</Link>
|
||||||
|
{testFlagIndicator}
|
||||||
</div>
|
</div>
|
||||||
</Footer>
|
</Footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { BellFilled } from "@ant-design/icons";
|
import { BellFilled } from "@ant-design/icons";
|
||||||
import { useQuery } from "@apollo/client/react";
|
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 { Badge, Layout, Menu, Spin, Tooltip } from "antd";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { SyncOutlined } from "@ant-design/icons";
|
import { SyncOutlined } from "@ant-design/icons";
|
||||||
import { useQuery } from "@apollo/client/react";
|
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 ResponsiveTable from "../responsive-table/responsive-table.component";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
@@ -12,6 +12,9 @@ import { DateTimeFormatter } from "../../utils/DateFormatter";
|
|||||||
import BlurWrapperComponent from "../feature-wrapper/blur-wrapper.component";
|
import BlurWrapperComponent from "../feature-wrapper/blur-wrapper.component";
|
||||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
||||||
import UpsellComponent, { upsellEnum } from "../upsell/upsell.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({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop
|
||||||
@@ -23,6 +26,8 @@ export default connect(mapStateToProps, mapDispatchToProps)(JobAuditTrail);
|
|||||||
|
|
||||||
export function JobAuditTrail({ bodyshop, jobId }) {
|
export function JobAuditTrail({ bodyshop, jobId }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const notification = useNotification();
|
||||||
|
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
|
||||||
const { loading, data, refetch } = useQuery(QUERY_AUDIT_TRAIL, {
|
const { loading, data, refetch } = useQuery(QUERY_AUDIT_TRAIL, {
|
||||||
variables: { jobid: jobId },
|
variables: { jobid: jobId },
|
||||||
skip: !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 = [
|
const emailColumns = [
|
||||||
{
|
{
|
||||||
title: t("audit.fields.created"),
|
title: t("audit.fields.created"),
|
||||||
@@ -184,6 +328,20 @@ export function JobAuditTrail({ bodyshop, jobId }) {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</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>
|
</Row>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery } from "@apollo/client/react";
|
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 { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { GET_LINE_TICKET_BY_PK } from "../../graphql/jobs-lines.queries";
|
import { GET_LINE_TICKET_BY_PK } from "../../graphql/jobs-lines.queries";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useApolloClient } from "@apollo/client/react";
|
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 { Button, Popconfirm } from "antd";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 AllocationsAssignmentContainer from "../allocations-assignment/allocations-assignment.container";
|
||||||
// import AllocationsBulkAssignmentContainer from "../allocations-bulk-assignment/allocations-bulk-assignment.container";
|
// import AllocationsBulkAssignmentContainer from "../allocations-bulk-assignment/allocations-bulk-assignment.container";
|
||||||
// import AllocationsEmployeeLabelContainer from "../allocations-employee-label/allocations-employee-label.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 _ from "lodash";
|
||||||
import { FaTasks } from "react-icons/fa";
|
import { FaTasks } from "react-icons/fa";
|
||||||
import { selectAuthLevel, selectBodyshop } from "../../redux/user/user.selectors";
|
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 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 PartsOrderDrawer from "../parts-order-list-table/parts-order-list-table-drawer.component";
|
||||||
import PartsOrderModalContainer from "../parts-order-modal/parts-order-modal.container";
|
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 JobLinesExpander from "./job-lines-expander.component";
|
||||||
import JobLinesPartPriceChange from "./job-lines-part-price-change.component";
|
import JobLinesPartPriceChange from "./job-lines-part-price-change.component";
|
||||||
import JobLinesExpanderSimple from "./jobs-lines-expander-simple.component";
|
import JobLinesExpanderSimple from "./jobs-lines-expander-simple.component";
|
||||||
@@ -595,16 +596,7 @@ export function JobLinesComponent({
|
|||||||
isinhouse: true,
|
isinhouse: true,
|
||||||
date: dayjs(),
|
date: dayjs(),
|
||||||
total: 0,
|
total: 0,
|
||||||
billlines: selectedLines.map((p) => ({
|
billlines: buildInHouseBillLines(selectedLines)
|
||||||
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 }
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
//joblineids: selectedLines.map((l) => l.id),
|
||||||
}
|
},
|
||||||
|
refetchQueries: ["QUERY_PARTS_BILLS_BY_JOBID", "GET_JOB_BY_PK"],
|
||||||
|
awaitRefetchQueries: true
|
||||||
});
|
});
|
||||||
if (result.errors) {
|
if (result.errors) {
|
||||||
console.log("🚀 ~ handleConvert ~ result.errors:", 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 { Form, Input, InputNumber, Modal, Select, Switch } from "antd";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -67,22 +67,25 @@ export function JobLinesUpsertModalComponent({ bodyshop, open, jobLine, handleCa
|
|||||||
</LayoutFormRow>
|
</LayoutFormRow>
|
||||||
<LayoutFormRow grow>
|
<LayoutFormRow grow>
|
||||||
<Form.Item label={t("joblines.fields.mod_lbr_ty")} name="mod_lbr_ty">
|
<Form.Item label={t("joblines.fields.mod_lbr_ty")} name="mod_lbr_ty">
|
||||||
<Select allowClear options={[
|
<Select
|
||||||
{ value: "LAA", label: t("joblines.fields.lbr_types.LAA") },
|
allowClear
|
||||||
{ value: "LAB", label: t("joblines.fields.lbr_types.LAB") },
|
options={[
|
||||||
{ value: "LAD", label: t("joblines.fields.lbr_types.LAD") },
|
{ value: "LAA", label: t("joblines.fields.lbr_types.LAA") },
|
||||||
{ value: "LAE", label: t("joblines.fields.lbr_types.LAE") },
|
{ value: "LAB", label: t("joblines.fields.lbr_types.LAB") },
|
||||||
{ value: "LAF", label: t("joblines.fields.lbr_types.LAF") },
|
{ value: "LAD", label: t("joblines.fields.lbr_types.LAD") },
|
||||||
{ value: "LAG", label: t("joblines.fields.lbr_types.LAG") },
|
{ value: "LAE", label: t("joblines.fields.lbr_types.LAE") },
|
||||||
{ value: "LAM", label: t("joblines.fields.lbr_types.LAM") },
|
{ value: "LAF", label: t("joblines.fields.lbr_types.LAF") },
|
||||||
{ value: "LAR", label: t("joblines.fields.lbr_types.LAR") },
|
{ value: "LAG", label: t("joblines.fields.lbr_types.LAG") },
|
||||||
{ value: "LAS", label: t("joblines.fields.lbr_types.LAS") },
|
{ value: "LAM", label: t("joblines.fields.lbr_types.LAM") },
|
||||||
{ value: "LAU", label: t("joblines.fields.lbr_types.LAU") },
|
{ value: "LAR", label: t("joblines.fields.lbr_types.LAR") },
|
||||||
{ value: "LA1", label: t("joblines.fields.lbr_types.LA1") },
|
{ value: "LAS", label: t("joblines.fields.lbr_types.LAS") },
|
||||||
{ value: "LA2", label: t("joblines.fields.lbr_types.LA2") },
|
{ value: "LAU", label: t("joblines.fields.lbr_types.LAU") },
|
||||||
{ value: "LA3", label: t("joblines.fields.lbr_types.LA3") },
|
{ value: "LA1", label: t("joblines.fields.lbr_types.LA1") },
|
||||||
{ value: "LA4", label: t("joblines.fields.lbr_types.LA4") }
|
{ 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>
|
||||||
<Form.Item label={t("joblines.fields.op_code_desc")} name="op_code_desc">
|
<Form.Item label={t("joblines.fields.op_code_desc")} name="op_code_desc">
|
||||||
<Input />
|
<Input />
|
||||||
@@ -128,21 +131,27 @@ export function JobLinesUpsertModalComponent({ bodyshop, open, jobLine, handleCa
|
|||||||
</LayoutFormRow>
|
</LayoutFormRow>
|
||||||
<LayoutFormRow>
|
<LayoutFormRow>
|
||||||
<Form.Item label={t("joblines.fields.part_type")} name="part_type">
|
<Form.Item label={t("joblines.fields.part_type")} name="part_type">
|
||||||
<Select allowClear options={[
|
<Select
|
||||||
{ value: "PAA", label: t("joblines.fields.part_types.PAA") },
|
allowClear
|
||||||
{ value: "PAC", label: t("joblines.fields.part_types.PAC") },
|
options={[
|
||||||
{ value: "PAE", label: t("joblines.fields.part_types.PAE") },
|
{ value: "PAA", label: t("joblines.fields.part_types.PAA") },
|
||||||
{ value: "PAL", label: t("joblines.fields.part_types.PAL") },
|
{ value: "PAC", label: t("joblines.fields.part_types.PAC") },
|
||||||
{ value: "PAM", label: t("joblines.fields.part_types.PAM") },
|
{ value: "PAE", label: t("joblines.fields.part_types.PAE") },
|
||||||
{ value: "PAN", label: t("joblines.fields.part_types.PAN") },
|
{ value: "PAL", label: t("joblines.fields.part_types.PAL") },
|
||||||
{ value: "PAO", label: t("joblines.fields.part_types.PAO") },
|
{ value: "PAM", label: t("joblines.fields.part_types.PAM") },
|
||||||
{ value: "PAR", label: t("joblines.fields.part_types.PAR") },
|
{ value: "PAN", label: t("joblines.fields.part_types.PAN") },
|
||||||
{ value: "PAS", label: t("joblines.fields.part_types.PAS") }
|
{ 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>
|
||||||
<Form.Item label={t("joblines.fields.oem_partno")} name="oem_partno">
|
<Form.Item label={t("joblines.fields.oem_partno")} name="oem_partno">
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item label={t("joblines.fields.alt_partno")} name="alt_partno">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("joblines.fields.part_qty")}
|
label={t("joblines.fields.part_qty")}
|
||||||
name="part_qty"
|
name="part_qty"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation } from "@apollo/client/react";
|
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 Axios from "axios";
|
||||||
import Dinero from "dinero.js";
|
import Dinero from "dinero.js";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import DataLabel from "../data-label/data-label.component";
|
|||||||
import PaymentExpandedRowComponent from "../payment-expanded-row/payment-expanded-row.component";
|
import PaymentExpandedRowComponent from "../payment-expanded-row/payment-expanded-row.component";
|
||||||
import PaymentsGenerateLink from "../payments-generate-link/payments-generate-link.component";
|
import PaymentsGenerateLink from "../payments-generate-link/payments-generate-link.component";
|
||||||
import PrintWrapperComponent from "../print-wrapper/print-wrapper.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({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ADD_JOB_WATCHER, GET_JOB_WATCHERS, REMOVE_JOB_WATCHER } from "../../gra
|
|||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
|
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 JobWatcherToggleComponent from "./job-watcher-toggle.component.jsx";
|
||||||
import { useIsEmployee } from "../../utils/useIsEmployee.js";
|
import { useIsEmployee } from "../../utils/useIsEmployee.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useApolloClient, useLazyQuery, useMutation, useQuery } from "@apollo/client/react";
|
import { useApolloClient, useLazyQuery, useMutation, useQuery } from "@apollo/client/react";
|
||||||
import { gql } from "@apollo/client";
|
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 { Col, Row } from "antd";
|
||||||
import Axios from "axios";
|
import Axios from "axios";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useCallback, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
import { CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
|
import { CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
|
||||||
import { insertAuditTrail } from "../../redux/application/application.actions";
|
import { insertAuditTrail } from "../../redux/application/application.actions";
|
||||||
import { selectJobReadOnly } from "../../redux/application/application.selectors";
|
import { selectJobReadOnly } from "../../redux/application/application.selectors";
|
||||||
@@ -224,14 +224,10 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item name={["ins_co_nm"]} label={t("jobs.fields.ins_co_nm")} rules={[{ required: true }]}>
|
||||||
name={["ins_co_nm"]}
|
|
||||||
label={t("jobs.fields.ins_co_nm")}
|
|
||||||
rules={[{ required: true }]}
|
|
||||||
>
|
|
||||||
<Select
|
<Select
|
||||||
showSearch={{
|
showSearch={{
|
||||||
optionFilterProp:'label'
|
optionFilterProp: "label"
|
||||||
}}
|
}}
|
||||||
options={insuranceOptions}
|
options={insuranceOptions}
|
||||||
/>
|
/>
|
||||||
@@ -250,7 +246,7 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
|
|||||||
label={t("jobs.fields.referralsource")}
|
label={t("jobs.fields.referralsource")}
|
||||||
rules={[{ required: bodyshop.enforce_referral }]}
|
rules={[{ required: bodyshop.enforce_referral }]}
|
||||||
>
|
>
|
||||||
<Select options={referralOptions} />
|
<Select showSearch={{ optionFilterProp: "label" }} options={referralOptions} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
|
<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
|
<Select
|
||||||
showSearch={{
|
showSearch={{
|
||||||
optionFilterProp: 'label',
|
optionFilterProp: "label",
|
||||||
filterOption: (input, option) =>
|
filterOption: (input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
||||||
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
|
||||||
}}
|
}}
|
||||||
style={{ width: 200 }}
|
style={{ width: 200 }}
|
||||||
|
|
||||||
options={csrOptions}
|
options={csrOptions}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{bodyshop.enforce_conversion_category && (
|
{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} />
|
<Select allowClear options={categoryOptions} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -193,6 +193,9 @@ export function JobsCreateJobsInfo({ bodyshop, form, selected }) {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("jobs.fields.referralsource")} name="referral_source">
|
<Form.Item label={t("jobs.fields.referralsource")} name="referral_source">
|
||||||
<Select
|
<Select
|
||||||
|
showSearch={{
|
||||||
|
optionFilterProp: "label"
|
||||||
|
}}
|
||||||
options={bodyshop.md_referral_sources.map((s) => ({
|
options={bodyshop.md_referral_sources.map((s) => ({
|
||||||
value: s,
|
value: s,
|
||||||
label: s
|
label: s
|
||||||
|
|||||||
@@ -43,19 +43,25 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
|
|||||||
<Input disabled={jobRO} />
|
<Input disabled={jobRO} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("jobs.fields.ded_status")} name="ded_status">
|
<Form.Item label={t("jobs.fields.ded_status")} name="ded_status">
|
||||||
<Select disabled={jobRO} options={[
|
<Select
|
||||||
{ value: "W", label: t("jobs.labels.deductible.waived") },
|
disabled={jobRO}
|
||||||
{ value: "Y", label: t("jobs.labels.deductible.stands") }
|
options={[
|
||||||
]} />
|
{ value: "W", label: t("jobs.labels.deductible.waived") },
|
||||||
|
{ value: "Y", label: t("jobs.labels.deductible.stands") }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("jobs.fields.ded_amt")} name="ded_amt">
|
<Form.Item label={t("jobs.fields.ded_amt")} name="ded_amt">
|
||||||
<CurrencyInput disabled={jobRO} min={0} />
|
<CurrencyInput disabled={jobRO} min={0} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("jobs.fields.ded_note")} name="ded_note">
|
<Form.Item label={t("jobs.fields.ded_note")} name="ded_note">
|
||||||
<Select disabled={jobRO} options={bodyshop.md_ded_notes.map((n) => ({
|
<Select
|
||||||
value: n,
|
disabled={jobRO}
|
||||||
label: n
|
options={bodyshop.md_ded_notes.map((n) => ({
|
||||||
}))} />
|
value: n,
|
||||||
|
label: n
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("jobs.fields.policy_no")} name="policy_no">
|
<Form.Item label={t("jobs.fields.policy_no")} name="policy_no">
|
||||||
<Input disabled={jobRO} />
|
<Input disabled={jobRO} />
|
||||||
@@ -65,10 +71,14 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label={t("jobs.fields.ins_co_nm")} name="ins_co_nm">
|
<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) => ({
|
<Select
|
||||||
value: s.name,
|
disabled={jobRO}
|
||||||
label: s.name
|
onChange={handleInsCoChange}
|
||||||
}))} />
|
options={bodyshop.md_ins_cos.map((s) => ({
|
||||||
|
value: s.name,
|
||||||
|
label: s.name
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("jobs.fields.ins_addr1")} name="ins_addr1">
|
<Form.Item label={t("jobs.fields.ins_addr1")} name="ins_addr1">
|
||||||
<Input disabled={jobRO} />
|
<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) => ({
|
<Select
|
||||||
value: s,
|
disabled={jobRO}
|
||||||
label: s
|
allowClear
|
||||||
}))} />
|
showSearch={{
|
||||||
|
optionFilterProp: "label"
|
||||||
|
}}
|
||||||
|
options={bodyshop.md_referral_sources.map((s) => ({
|
||||||
|
value: s,
|
||||||
|
label: s
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
|
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
|
||||||
<Input disabled={jobRO} />
|
<Input disabled={jobRO} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("jobs.fields.alt_transport")} name="alt_transport">
|
<Form.Item label={t("jobs.fields.alt_transport")} name="alt_transport">
|
||||||
<Select disabled={jobRO} allowClear options={bodyshop.appt_alt_transport.map((s) => ({
|
<Select
|
||||||
value: s,
|
disabled={jobRO}
|
||||||
label: s
|
allowClear
|
||||||
}))} />
|
options={bodyshop.appt_alt_transport.map((s) => ({
|
||||||
|
value: s,
|
||||||
|
label: s
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
@@ -233,10 +254,14 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
|
|||||||
</FormRow>
|
</FormRow>
|
||||||
<FormRow header={t("jobs.forms.other")}>
|
<FormRow header={t("jobs.forms.other")}>
|
||||||
<Form.Item label={t("jobs.fields.category")} name="category">
|
<Form.Item label={t("jobs.fields.category")} name="category">
|
||||||
<Select disabled={jobRO} allowClear options={bodyshop.md_categories.map((s) => ({
|
<Select
|
||||||
value: s,
|
disabled={jobRO}
|
||||||
label: s
|
allowClear
|
||||||
}))} />
|
options={bodyshop.md_categories.map((s) => ({
|
||||||
|
value: s,
|
||||||
|
label: s
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer">
|
<Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer">
|
||||||
<Input disabled={jobRO} />
|
<Input disabled={jobRO} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DownCircleFilled } from "@ant-design/icons";
|
import { DownCircleFilled } from "@ant-design/icons";
|
||||||
import { useApolloClient, useMutation, useQuery } from "@apollo/client/react";
|
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 { Button, Card, Dropdown, Form, Input, Modal, Popover, Select, Space } from "antd";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import parsePhoneNumber from "libphonenumber-js";
|
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 TimeTicketList from "../time-ticket-list/time-ticket-list.component";
|
||||||
import PayrollLaborAllocationsTable from "../labor-allocations-table/labor-allocations-table.payroll.component";
|
import PayrollLaborAllocationsTable from "../labor-allocations-table/labor-allocations-table.payroll.component";
|
||||||
import { selectBodyshop } from "../../redux/user/user.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({
|
const mapStateToProps = createStructuredSelector({
|
||||||
jobRO: selectJobReadOnly,
|
jobRO: selectJobReadOnly,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { logImEXEvent } from "../../firebase/firebase.utils";
|
|||||||
import cleanAxios from "../../utils/CleanAxios";
|
import cleanAxios from "../../utils/CleanAxios";
|
||||||
import formatBytes from "../../utils/formatbytes";
|
import formatBytes from "../../utils/formatbytes";
|
||||||
//import yauzl from "yauzl";
|
//import yauzl from "yauzl";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
|
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import JobDocuments from "./jobs-documents-gallery.component";
|
|||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Button, Card, Col, Row, Space } from "antd";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { isFunction } from "lodash";
|
import { isFunction } from "lodash";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Lightbox from "react-image-lightbox";
|
import Lightbox from "react-image-lightbox";
|
||||||
import "react-image-lightbox/style.css";
|
import "react-image-lightbox/style.css";
|
||||||
@@ -12,12 +12,12 @@ import { createStructuredSelector } from "reselect";
|
|||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import DocumentsUploadImgproxyComponent from "../documents-upload-imgproxy/documents-upload-imgproxy.component";
|
import DocumentsUploadImgproxyComponent from "../documents-upload-imgproxy/documents-upload-imgproxy.component";
|
||||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.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 UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
|
||||||
import JobsDocumentsDownloadButton from "./jobs-document-imgproxy-gallery.download.component";
|
import JobsDocumentsDownloadButton from "./jobs-document-imgproxy-gallery.download.component";
|
||||||
import JobsDocumentsGalleryReassign from "./jobs-document-imgproxy-gallery.reassign.component";
|
import JobsDocumentsGalleryReassign from "./jobs-document-imgproxy-gallery.reassign.component";
|
||||||
import JobsDocumentsDeleteButton from "./jobs-documents-imgproxy-gallery.delete.component";
|
import JobsDocumentsDeleteButton from "./jobs-documents-imgproxy-gallery.delete.component";
|
||||||
import JobsDocumentsGallerySelectAllComponent from "./jobs-documents-imgproxy-gallery.selectall.component";
|
import JobsDocumentsGallerySelectAllComponent from "./jobs-documents-imgproxy-gallery.selectall.component";
|
||||||
import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component";
|
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop
|
||||||
@@ -38,6 +38,9 @@ function JobsDocumentsImgproxyComponent({
|
|||||||
const [galleryImages, setGalleryImages] = useState({ images: [], other: [] });
|
const [galleryImages, setGalleryImages] = useState({ images: [], other: [] });
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [modalState, setModalState] = useState({ open: false, index: 0 });
|
const [modalState, setModalState] = useState({ open: false, index: 0 });
|
||||||
|
const [previewUrls, setPreviewUrls] = useState({});
|
||||||
|
const [previewError, setPreviewError] = useState(null);
|
||||||
|
const previewUrlsRef = useRef({});
|
||||||
|
|
||||||
const fetchThumbnails = useCallback(() => {
|
const fetchThumbnails = useCallback(() => {
|
||||||
fetchImgproxyThumbnails({ setStateCallback: setGalleryImages, jobId, billId });
|
fetchImgproxyThumbnails({ setStateCallback: setGalleryImages, jobId, billId });
|
||||||
@@ -49,8 +52,86 @@ function JobsDocumentsImgproxyComponent({
|
|||||||
}
|
}
|
||||||
}, [data, fetchThumbnails]);
|
}, [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 hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" });
|
||||||
const hasMobileAccess = HasFeatureAccess({ bodyshop, featureName: "mobile" });
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
@@ -147,30 +228,33 @@ function JobsDocumentsImgproxyComponent({
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
{modalState.open && (
|
{modalState.open && selectedImage && (
|
||||||
<Lightbox
|
<Lightbox
|
||||||
toolbarButtons={[
|
toolbarButtons={[
|
||||||
<EditFilled
|
<EditFilled
|
||||||
key="edit"
|
key="edit"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newWindow = window.open(
|
openEditorForImage(selectedImage);
|
||||||
`${window.location.protocol}//${window.location.host}/edit?documentId=${
|
|
||||||
galleryImages.images[modalState.index].id
|
|
||||||
}`,
|
|
||||||
"_blank",
|
|
||||||
"noopener,noreferrer"
|
|
||||||
);
|
|
||||||
if (newWindow) newWindow.opener = null;
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
]}
|
]}
|
||||||
mainSrc={galleryImages.images[modalState.index].fullsize}
|
imageLoadErrorMessage={previewError ? t("general.errors.notfound") : undefined}
|
||||||
nextSrc={galleryImages.images[(modalState.index + 1) % galleryImages.images.length].fullsize}
|
mainSrc={previewSrc || selectedImage.src}
|
||||||
prevSrc={
|
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]
|
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={() =>
|
onMovePrevRequest={() =>
|
||||||
setModalState({
|
setModalState({
|
||||||
...modalState,
|
...modalState,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const mapStateToProps = createStructuredSelector({
|
|||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop
|
||||||
});
|
});
|
||||||
|
|
||||||
const LockWrapper = ({ featureName, bodyshop, children, disabled = true, bypass }) => {
|
const LockWrapper = ({ featureName, bodyshop, children, disabled = true, bypass, locked }) => {
|
||||||
let renderedChildren = children;
|
let renderedChildren = children;
|
||||||
|
|
||||||
//Mark the child prop as disabled.
|
//Mark the child prop as disabled.
|
||||||
@@ -36,11 +36,13 @@ const LockWrapper = ({ featureName, bodyshop, children, disabled = true, bypass
|
|||||||
return <span>{children}</span>;
|
return <span>{children}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return HasFeatureAccess({ featureName: featureName, bodyshop }) ? (
|
const hasAccess = typeof locked === "boolean" ? !locked : HasFeatureAccess({ featureName: featureName, bodyshop });
|
||||||
|
|
||||||
|
return hasAccess ? (
|
||||||
children
|
children
|
||||||
) : (
|
) : (
|
||||||
<Space>
|
<Space>
|
||||||
{!HasFeatureAccess({ featureName: featureName, bodyshop }) && <LockOutlined style={{ color: "tomato" }} />}
|
<LockOutlined style={{ color: "tomato" }} />
|
||||||
{renderedChildren}
|
{renderedChildren}
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
|
|
||||||
import { Checkbox, Form } from "antd";
|
import { Checkbox, Form } from "antd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
@@ -9,18 +8,18 @@ import PropTypes from "prop-types";
|
|||||||
* @param form
|
* @param form
|
||||||
* @param disabled
|
* @param disabled
|
||||||
* @param onHeaderChange
|
* @param onHeaderChange
|
||||||
|
* @param scenarioKeys
|
||||||
* @returns {JSX.Element}
|
* @returns {JSX.Element}
|
||||||
* @constructor
|
* @constructor
|
||||||
*/
|
*/
|
||||||
const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange }) => {
|
const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange, scenarioKeys }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Subscribe to all form values so that this component re-renders on changes.
|
// Subscribe to all form values so that this component re-renders on changes.
|
||||||
const formValues = Form.useWatch([], form) || {};
|
const formValues = Form.useWatch([], form) || {};
|
||||||
|
|
||||||
// Determine if all scenarios for this channel are checked.
|
// Determine if all scenarios for this channel are checked.
|
||||||
const allChecked =
|
const allChecked = scenarioKeys.length > 0 && scenarioKeys.every((scenario) => formValues[scenario]?.[channel]);
|
||||||
notificationScenarios.length > 0 && notificationScenarios.every((scenario) => formValues[scenario]?.[channel]);
|
|
||||||
|
|
||||||
const onChange = (e) => {
|
const onChange = (e) => {
|
||||||
const checked = e.target.checked;
|
const checked = e.target.checked;
|
||||||
@@ -28,7 +27,7 @@ const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange
|
|||||||
const currentValues = form.getFieldsValue();
|
const currentValues = form.getFieldsValue();
|
||||||
// Update each scenario for this channel.
|
// Update each scenario for this channel.
|
||||||
const newValues = { ...currentValues };
|
const newValues = { ...currentValues };
|
||||||
notificationScenarios.forEach((scenario) => {
|
scenarioKeys.forEach((scenario) => {
|
||||||
newValues[scenario] = { ...newValues[scenario], [channel]: checked };
|
newValues[scenario] = { ...newValues[scenario], [channel]: checked };
|
||||||
});
|
});
|
||||||
// Update form values.
|
// Update form values.
|
||||||
@@ -50,7 +49,8 @@ ColumnHeaderCheckbox.propTypes = {
|
|||||||
channel: PropTypes.oneOf(["app", "email", "fcm"]).isRequired,
|
channel: PropTypes.oneOf(["app", "email", "fcm"]).isRequired,
|
||||||
form: PropTypes.object.isRequired,
|
form: PropTypes.object.isRequired,
|
||||||
disabled: PropTypes.bool,
|
disabled: PropTypes.bool,
|
||||||
onHeaderChange: PropTypes.func
|
onHeaderChange: PropTypes.func,
|
||||||
|
scenarioKeys: PropTypes.arrayOf(PropTypes.string).isRequired
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ColumnHeaderCheckbox;
|
export default ColumnHeaderCheckbox;
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ import {
|
|||||||
UPDATE_NOTIFICATION_SETTINGS,
|
UPDATE_NOTIFICATION_SETTINGS,
|
||||||
UPDATE_NOTIFICATIONS_AUTOADD
|
UPDATE_NOTIFICATIONS_AUTOADD
|
||||||
} from "../../graphql/user.queries.js";
|
} 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 LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx";
|
import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx";
|
||||||
import { useIsEmployee } from "../../utils/useIsEmployee.js";
|
import { useIsEmployee } from "../../utils/useIsEmployee.js";
|
||||||
|
import { hasDocumensoApiKey } from "../../utils/esignature.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notifications Settings Form
|
* Notifications Settings Form
|
||||||
@@ -35,6 +36,7 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
|
|||||||
const [initialAutoAdd, setInitialAutoAdd] = useState(false);
|
const [initialAutoAdd, setInitialAutoAdd] = useState(false);
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
const isEmployee = useIsEmployee(bodyshop, currentUser);
|
const isEmployee = useIsEmployee(bodyshop, currentUser);
|
||||||
|
const notificationScenarios = getNotificationScenarios({ includeEsign: hasDocumensoApiKey(bodyshop) });
|
||||||
|
|
||||||
// Fetch notification settings and notifications_autoadd
|
// Fetch notification settings and notifications_autoadd
|
||||||
const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, {
|
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 }
|
// Ensure each scenario has an object with { app, email, fcm }
|
||||||
const formattedValues = notificationScenarios.reduce((acc, scenario) => {
|
const formattedValues = notificationScenarios.reduce((acc, scenario) => {
|
||||||
acc[scenario] = settings[scenario] ?? { app: false, email: false, fcm: false };
|
acc[scenario] = settings[scenario] ??
|
||||||
|
notificationScenarioDefaults[scenario] ?? { app: false, email: false, fcm: false };
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
@@ -65,7 +68,7 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
|
|||||||
setInitialAutoAdd(autoAdd);
|
setInitialAutoAdd(autoAdd);
|
||||||
setIsDirty(false); // Reset dirty state when new data loads
|
setIsDirty(false); // Reset dirty state when new data loads
|
||||||
}
|
}
|
||||||
}, [data, form]);
|
}, [data, form, notificationScenarios]);
|
||||||
|
|
||||||
// Handle toggle of notifications_autoadd
|
// Handle toggle of notifications_autoadd
|
||||||
const handleAutoAddToggle = async (checked) => {
|
const handleAutoAddToggle = async (checked) => {
|
||||||
@@ -136,7 +139,14 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
|
|||||||
width: "80%"
|
width: "80%"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: <ColumnHeaderCheckbox channel="app" form={form} onHeaderChange={() => setIsDirty(true)} />,
|
title: (
|
||||||
|
<ColumnHeaderCheckbox
|
||||||
|
channel="app"
|
||||||
|
form={form}
|
||||||
|
onHeaderChange={() => setIsDirty(true)}
|
||||||
|
scenarioKeys={notificationScenarios}
|
||||||
|
/>
|
||||||
|
),
|
||||||
dataIndex: "app",
|
dataIndex: "app",
|
||||||
key: "app",
|
key: "app",
|
||||||
align: "center",
|
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",
|
dataIndex: "email",
|
||||||
key: "email",
|
key: "email",
|
||||||
align: "center",
|
align: "center",
|
||||||
@@ -162,7 +179,14 @@ const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
|
|||||||
// Currently disabled for prod
|
// Currently disabled for prod
|
||||||
if (!import.meta.env.PROD) {
|
if (!import.meta.env.PROD) {
|
||||||
columns.push({
|
columns.push({
|
||||||
title: <ColumnHeaderCheckbox channel="fcm" form={form} onHeaderChange={() => setIsDirty(true)} />,
|
title: (
|
||||||
|
<ColumnHeaderCheckbox
|
||||||
|
channel="fcm"
|
||||||
|
form={form}
|
||||||
|
onHeaderChange={() => setIsDirty(true)}
|
||||||
|
scenarioKeys={notificationScenarios}
|
||||||
|
/>
|
||||||
|
),
|
||||||
dataIndex: "fcm",
|
dataIndex: "fcm",
|
||||||
key: "fcm",
|
key: "fcm",
|
||||||
align: "center",
|
align: "center",
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import ResponsiveTable from "../responsive-table/responsive-table.component";
|
|||||||
|
|
||||||
export default function OwnersListComponent({ loading, owners, total, refetch }) {
|
export default function OwnersListComponent({ loading, owners, total, refetch }) {
|
||||||
const search = queryString.parse(useLocation().search);
|
const search = queryString.parse(useLocation().search);
|
||||||
const {
|
const { page, pageSize } = search;
|
||||||
page
|
|
||||||
// sortcolumn, sortorder
|
|
||||||
} = search;
|
|
||||||
const history = useNavigate();
|
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({
|
const [state, setState] = useState({
|
||||||
sortedInfo: {},
|
sortedInfo: {},
|
||||||
filteredInfo: { text: "" }
|
filteredInfo: { text: "" }
|
||||||
@@ -71,10 +72,14 @@ export default function OwnersListComponent({ loading, owners, total, refetch })
|
|||||||
];
|
];
|
||||||
|
|
||||||
const handleTableChange = (pagination, filters, sorter) => {
|
const handleTableChange = (pagination, filters, sorter) => {
|
||||||
|
const nextPageSize = pagination?.pageSize || currentPageSize;
|
||||||
|
const pageSizeChanged = nextPageSize !== currentPageSize;
|
||||||
|
|
||||||
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
|
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
|
||||||
const updatedSearch = {
|
const updatedSearch = {
|
||||||
...search,
|
...search,
|
||||||
page: pagination.current,
|
pageSize: nextPageSize,
|
||||||
|
page: pageSizeChanged ? 1 : pagination.current,
|
||||||
sortcolumn: sorter.columnKey,
|
sortcolumn: sorter.columnKey,
|
||||||
sortorder: sorter.order
|
sortorder: sorter.order
|
||||||
};
|
};
|
||||||
@@ -119,7 +124,7 @@ export default function OwnersListComponent({ loading, owners, total, refetch })
|
|||||||
>
|
>
|
||||||
<ResponsiveTable
|
<ResponsiveTable
|
||||||
loading={loading}
|
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}
|
columns={columns}
|
||||||
mobileColumnKeys={["name", "ownr_ph1", "ownr_ph2"]}
|
mobileColumnKeys={["name", "ownr_ph1", "ownr_ph2"]}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
|||||||
@@ -8,14 +8,19 @@ import { pageLimit } from "../../utils/config";
|
|||||||
|
|
||||||
export default function OwnersListContainer() {
|
export default function OwnersListContainer() {
|
||||||
const searchParams = queryString.parse(useLocation().search);
|
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, {
|
const { loading, error, data, refetch } = useQuery(QUERY_ALL_OWNERS_PAGINATED, {
|
||||||
fetchPolicy: "network-only",
|
fetchPolicy: "network-only",
|
||||||
nextFetchPolicy: "network-only",
|
nextFetchPolicy: "network-only",
|
||||||
variables: {
|
variables: {
|
||||||
search: search || "",
|
search: search || "",
|
||||||
offset: page ? (page - 1) * pageLimit : 0,
|
offset: (currentPage - 1) * currentPageSize,
|
||||||
limit: pageLimit,
|
limit: currentPageSize,
|
||||||
order: [
|
order: [
|
||||||
{
|
{
|
||||||
[sortcolumn || "created_at"]: sortorder ? (sortorder === "descend" ? "desc" : "asc") : "desc"
|
[sortcolumn || "created_at"]: sortorder ? (sortorder === "descend" ? "desc" : "asc") : "desc"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { DeleteFilled, DownOutlined, WarningFilled } from "@ant-design/icons";
|
import { DeleteFilled, DownOutlined, WarningFilled } from "@ant-design/icons";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
import { Button, Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } from "antd";
|
import { Button, Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } from "antd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
@@ -176,6 +176,9 @@ export function PartsOrderModalComponent({
|
|||||||
>
|
>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item key={`${index}job_line_id`} name={[field.name, "job_line_id"]} hidden>
|
||||||
|
<Input type="hidden" />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("parts_orders.fields.line_remarks")}
|
label={t("parts_orders.fields.line_remarks")}
|
||||||
key={`${index}line_remarks`}
|
key={`${index}line_remarks`}
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ import { TemplateList } from "../../utils/TemplateConstants";
|
|||||||
import AlertComponent from "../alert/alert.component";
|
import AlertComponent from "../alert/alert.component";
|
||||||
import PartsOrderModalComponent from "./parts-order-modal.component";
|
import PartsOrderModalComponent from "./parts-order-modal.component";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { UPDATE_JOB } from "../../graphql/jobs.queries";
|
import { UPDATE_JOB } from "../../graphql/jobs.queries";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
import { buildSubmittedPartsOrderLines, getSubmittedPartsOrderJobLineIds } from "./parts-order-modal.utils.js";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
currentUser: selectCurrentUser,
|
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.
|
// Force job_line_id from context so it never gets dropped by AntD form submission behavior.
|
||||||
const submittedLines = values?.parts_order_lines?.data ?? [];
|
const submittedLines = values?.parts_order_lines?.data ?? [];
|
||||||
const forcedLines = submittedLines.map((p, index) => {
|
const forcedLines = buildSubmittedPartsOrderLines({
|
||||||
const originalLine = linesToOrder?.[index];
|
submittedLines,
|
||||||
const jobLineId = isReturn ? originalLine?.joblineid : originalLine?.id;
|
linesToOrder,
|
||||||
|
isReturn
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
job_line_id: jobLineId,
|
|
||||||
...(isReturn && { cm_received: false })
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let insertResult;
|
let insertResult;
|
||||||
@@ -147,10 +143,7 @@ export function PartsOrderModalContainer({
|
|||||||
type: isReturn ? "jobspartsreturn" : "jobspartsorder"
|
type: isReturn ? "jobspartsreturn" : "jobspartsorder"
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use linesToOrder from context instead of form values to preserve job line ids
|
const jobLineIds = getSubmittedPartsOrderJobLineIds(forcedLines);
|
||||||
const jobLineIds = (linesToOrder ?? [])
|
|
||||||
.filter((line) => (isReturn ? line.joblineid : line.id))
|
|
||||||
.map((line) => (isReturn ? line.joblineid : line.id));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const jobLinesResult = await updateJobLines({
|
const jobLinesResult = await updateJobLines({
|
||||||
@@ -206,23 +199,20 @@ export function PartsOrderModalContainer({
|
|||||||
isinhouse: true,
|
isinhouse: true,
|
||||||
date: dayjs(),
|
date: dayjs(),
|
||||||
total: 0,
|
total: 0,
|
||||||
billlines: forcedLines.map((p, index) => {
|
billlines: forcedLines.map((p) => ({
|
||||||
const originalLine = linesToOrder?.[index];
|
joblineid: p.job_line_id,
|
||||||
return {
|
actual_price: p.act_price,
|
||||||
joblineid: isReturn ? originalLine?.joblineid : originalLine?.id,
|
actual_cost: 0, // p.act_price,
|
||||||
actual_price: p.act_price,
|
line_desc: p.line_desc,
|
||||||
actual_cost: 0, // p.act_price,
|
line_remarks: p.line_remarks,
|
||||||
line_desc: p.line_desc,
|
part_type: p.part_type,
|
||||||
line_remarks: p.line_remarks,
|
quantity: p.quantity || 1,
|
||||||
part_type: p.part_type,
|
applicable_taxes: {
|
||||||
quantity: p.quantity || 1,
|
local: false,
|
||||||
applicable_taxes: {
|
state: false,
|
||||||
local: false,
|
federal: 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 }) {
|
export function PartsQueueListComponent({ bodyshop }) {
|
||||||
const searchParams = queryString.parse(useLocation().search);
|
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 history = useNavigate();
|
||||||
const [filter, setFilter] = useLocalStorage("filter_parts_queue", null);
|
const [filter, setFilter] = useLocalStorage("filter_parts_queue", null);
|
||||||
const [viewTimeStamp, setViewTimeStamp] = useLocalStorage("parts_queue_timestamps", false);
|
const [viewTimeStamp, setViewTimeStamp] = useLocalStorage("parts_queue_timestamps", false);
|
||||||
@@ -66,7 +69,11 @@ export function PartsQueueListComponent({ bodyshop }) {
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
const handleTableChange = (pagination, filters, sorter) => {
|
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.sortcolumn = sorter.columnKey;
|
||||||
searchParams.sortorder = sorter.order;
|
searchParams.sortorder = sorter.order;
|
||||||
|
|
||||||
@@ -315,9 +322,10 @@ export function PartsQueueListComponent({ bodyshop }) {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{
|
pagination={{
|
||||||
placement: "top",
|
placement: "top",
|
||||||
pageSize: pageLimit
|
pageSize: currentPageSize,
|
||||||
// current: parseInt(page || 1),
|
current: currentPage,
|
||||||
// total: data && data.jobs_aggregate.aggregate.count,
|
showSizeChanger: true,
|
||||||
|
total: jobs.length
|
||||||
}}
|
}}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
mobileColumnKeys={["ro_number", "ownr_ln", "status", "vehicle", "partsstatus"]}
|
mobileColumnKeys={["ro_number", "ownr_ln", "status", "vehicle", "partsstatus"]}
|
||||||
|
|||||||
@@ -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 { Form, Input, Radio, Select } from "antd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
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 { Space, Spin } from "antd";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
@@ -10,6 +10,9 @@ import { GenerateDocument } from "../../utils/RenderTemplate";
|
|||||||
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
|
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
|
||||||
import { HasFeatureAccess } from "./../feature-wrapper/feature-wrapper.component";
|
import { HasFeatureAccess } from "./../feature-wrapper/feature-wrapper.component";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
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({
|
const mapStateToProps = createStructuredSelector({
|
||||||
printCenterModal: selectPrintCenter,
|
printCenterModal: selectPrintCenter,
|
||||||
@@ -17,12 +20,29 @@ const mapStateToProps = createStructuredSelector({
|
|||||||
technician: selectTechnician
|
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 [loading, setLoading] = useState(false);
|
||||||
const { context } = printCenterModal;
|
const { context } = printCenterModal;
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
|
||||||
|
|
||||||
const renderToNewWindow = async () => {
|
const renderToNewWindow = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -39,6 +59,30 @@ export function PrintCenterItemComponent({ printCenterModal, item, id, bodyshop,
|
|||||||
setLoading(false);
|
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 (
|
if (
|
||||||
disabled ||
|
disabled ||
|
||||||
(item.featureNameRestricted && !HasFeatureAccess({ featureName: item.featureNameRestricted, bodyshop }))
|
(item.featureNameRestricted && !HasFeatureAccess({ featureName: item.featureNameRestricted, bodyshop }))
|
||||||
@@ -54,6 +98,7 @@ export function PrintCenterItemComponent({ printCenterModal, item, id, bodyshop,
|
|||||||
<li>
|
<li>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
{item.title}
|
{item.title}
|
||||||
|
{esignatureEnabled && <SignatureFilled onClick={esignatureGenerate} />}
|
||||||
<PrinterOutlined onClick={renderToNewWindow} />
|
<PrinterOutlined onClick={renderToNewWindow} />
|
||||||
{!technician ? (
|
{!technician ? (
|
||||||
<MailOutlined
|
<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 { Button, Card, Col, Row, Space, Tooltip, Typography } from "antd";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
import { Card, Col, Input, Row, Space, Typography } from "antd";
|
import { CloseOutlined } from "@ant-design/icons";
|
||||||
|
import { Alert, Button, Card, Col, Input, Row, Space, Typography, Tooltip } from "antd";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -9,11 +10,15 @@ import { selectPrintCenter } from "../../redux/modals/modals.selectors";
|
|||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import { TemplateList } from "../../utils/TemplateConstants";
|
import { TemplateList } from "../../utils/TemplateConstants";
|
||||||
import Jobd3RdPartyModal from "../job-3rd-party-modal/job-3rd-party-modal.component";
|
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 PrintCenterItem from "../print-center-item/print-center-item.component";
|
||||||
import PrintCenterJobsLabels from "../print-center-jobs-labels/print-center-jobs-labels.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 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 { selectTechnician } from "../../redux/tech/tech.selectors";
|
||||||
|
import { hasDocumensoApiKey } from "../../utils/esignature.js";
|
||||||
|
import useLocalStorage from "../../utils/useLocalStorage";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
printCenterModal: selectPrintCenter,
|
printCenterModal: selectPrintCenter,
|
||||||
@@ -25,6 +30,10 @@ const mapDispatchToProps = () => ({});
|
|||||||
|
|
||||||
export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technician }) {
|
export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technician }) {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [esignatureBannerDismissed, setEsignatureBannerDismissed] = useLocalStorage(
|
||||||
|
"print_center_esignature_banner_dismissed",
|
||||||
|
false
|
||||||
|
);
|
||||||
const { id: jobId, job } = printCenterModal.context;
|
const { id: jobId, job } = printCenterModal.context;
|
||||||
const tempList = TemplateList("job", {});
|
const tempList = TemplateList("job", {});
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -36,6 +45,10 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
|
|||||||
splitKey: bodyshop.imexshopid
|
splitKey: bodyshop.imexshopid
|
||||||
});
|
});
|
||||||
const hasDMSKey = bodyshopHasDmsKey(bodyshop);
|
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
|
const Templates = !hasDMSKey
|
||||||
? Object.keys(tempList)
|
? Object.keys(tempList)
|
||||||
@@ -45,7 +58,7 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
|
|||||||
.filter(
|
.filter(
|
||||||
(temp) =>
|
(temp) =>
|
||||||
(!temp.regions ||
|
(!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.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true)) &&
|
||||||
(!temp.dms || temp.dms === false)
|
(!temp.dms || temp.dms === false)
|
||||||
)
|
)
|
||||||
@@ -57,11 +70,12 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
|
|||||||
.filter(
|
.filter(
|
||||||
(temp) =>
|
(temp) =>
|
||||||
!temp.regions ||
|
!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.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true)
|
||||||
)
|
)
|
||||||
|
.filter((temp) => !isReynoldsMode || !temp.excludedDmsModes?.includes(dmsMode))
|
||||||
.filter((temp) => !technician || temp.group !== "financial");
|
.filter((temp) => !technician || temp.group !== "financial");
|
||||||
|
|
||||||
const JobsReportsList =
|
const JobsReportsList =
|
||||||
Enhanced_Payroll.treatment === "on"
|
Enhanced_Payroll.treatment === "on"
|
||||||
? Object.keys(Templates)
|
? Object.keys(Templates)
|
||||||
@@ -85,6 +99,23 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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]}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col lg={8} md={12} sm={24}>
|
<Col lg={8} md={12} sm={24}>
|
||||||
<PrintCenterSpeedPrint jobId={jobId} />
|
<PrintCenterSpeedPrint jobId={jobId} />
|
||||||
@@ -94,6 +125,13 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
|
|||||||
extra={
|
extra={
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<PrintCenterJobsLabels jobId={jobId} />
|
<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} />
|
<Jobd3RdPartyModal jobId={jobId} job={job} />
|
||||||
<Input.Search onChange={(e) => setSearch(e.target.value)} value={search} enterButton />
|
<Input.Search onChange={(e) => setSearch(e.target.value)} value={search} enterButton />
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -5,3 +5,7 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.print-center-esignature-banner {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,14 +80,14 @@ const ModelInfoToolTip = ({ metadata, cardSettings }) =>
|
|||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<EllipsesToolTip
|
<EllipsesToolTip
|
||||||
title={
|
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_color
|
||||||
? `${metadata.v_model_yr || ""} ${metadata.v_make_desc || ""} ${metadata.v_model_desc || ""}`
|
? `${metadata.v_model_yr || ""} ${metadata.v_color || ""} ${metadata.v_make_desc || ""} ${metadata.v_model_desc || ""}`
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
kiosk={cardSettings.kiosk}
|
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_color ? (
|
||||||
`${metadata.v_model_yr || ""} ${metadata.v_make_desc || ""} ${metadata.v_model_desc || ""}`
|
`${metadata.v_model_yr || ""} ${metadata.v_color || ""} ${metadata.v_make_desc || ""} ${metadata.v_model_desc || ""}`
|
||||||
) : (
|
) : (
|
||||||
<span> </span>
|
<span> </span>
|
||||||
)}
|
)}
|
||||||
@@ -431,6 +431,7 @@ export default function ProductionBoardCard({ technician, card, bodyshop, cardSe
|
|||||||
<Card
|
<Card
|
||||||
className={`react-trello-card ${cardSettings.kiosk ? "kiosk-mode" : ""}`}
|
className={`react-trello-card ${cardSettings.kiosk ? "kiosk-mode" : ""}`}
|
||||||
size="small"
|
size="small"
|
||||||
|
styles={{ header: { backgroundColor: "var(--card-bg-fallback)" } }}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: cardSettings?.cardcolor
|
backgroundColor: cardSettings?.cardcolor
|
||||||
? bgColor.fallback || `rgba(${bgColor.r},${bgColor.g},${bgColor.b},${bgColor.a || 1})`
|
? 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 { QUERY_KANBAN_SETTINGS } from "../../graphql/user.queries";
|
||||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||||
import ProductionBoardKanbanComponent from "./production-board-kanban.component";
|
import ProductionBoardKanbanComponent from "./production-board-kanban.component";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
|
|||||||
@@ -28,11 +28,14 @@ const ProductionStatistics = ({ data, cardSettings, reducerData }) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const calculateTotal = (items, key, subKey) => {
|
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) => {
|
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) => {
|
const calculateReducerTotalAmount = (lanes, key) => {
|
||||||
@@ -67,58 +70,83 @@ const ProductionStatistics = ({ data, cardSettings, reducerData }) => {
|
|||||||
return value;
|
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
|
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;
|
: null;
|
||||||
|
|
||||||
const totalLAB = cardSettings.totalLAB
|
const totalLAB = cardSettings.totalLAB
|
||||||
? parseFloat(calculateTotal(data, "labhrs", "mod_lb_hrs").toFixed(2))
|
? parseFloat(calculateTotal(filteredData, "labhrs", "mod_lb_hrs").toFixed(2))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const totalLAR = cardSettings.totalLAR
|
const totalLAR = cardSettings.totalLAR
|
||||||
? parseFloat(calculateTotal(data, "larhrs", "mod_lb_hrs").toFixed(2))
|
? parseFloat(calculateTotal(filteredData, "larhrs", "mod_lb_hrs").toFixed(2))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const jobsInProduction = cardSettings.jobsInProduction ? data.length : null;
|
const jobsInProduction = cardSettings.jobsInProduction ? filteredData.length : null;
|
||||||
|
|
||||||
const totalAmountInProduction = cardSettings.totalAmountInProduction
|
const totalAmountInProduction = cardSettings.totalAmountInProduction
|
||||||
? calculateTotalAmount(data, "job_totals").toFormat("$0,0.00")
|
? calculateTotalAmount(filteredData, "job_totals").toFormat("$0,0.00")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const totalAmountOnBoard = reducerData && cardSettings.totalAmountOnBoard
|
const totalAmountOnBoard =
|
||||||
? calculateReducerTotalAmount(reducerData.lanes, "job_totals").toFormat("$0,0.00")
|
filteredReducerData && cardSettings.totalAmountOnBoard
|
||||||
: null;
|
? calculateReducerTotalAmount(filteredReducerData.lanes, "job_totals").toFormat("$0,0.00")
|
||||||
|
: null;
|
||||||
|
|
||||||
const totalHrsOnBoard = reducerData && cardSettings.totalHrsOnBoard
|
const totalHrsOnBoard =
|
||||||
? parseFloat((
|
filteredReducerData && cardSettings.totalHrsOnBoard
|
||||||
calculateReducerTotal(reducerData.lanes, "labhrs", "mod_lb_hrs") +
|
? parseFloat(
|
||||||
calculateReducerTotal(reducerData.lanes, "larhrs", "mod_lb_hrs")
|
(
|
||||||
).toFixed(2))
|
calculateReducerTotal(filteredReducerData.lanes, "labhrs", "mod_lb_hrs") +
|
||||||
: null;
|
calculateReducerTotal(filteredReducerData.lanes, "larhrs", "mod_lb_hrs")
|
||||||
|
).toFixed(2)
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
const totalLABOnBoard = reducerData && cardSettings.totalLABOnBoard
|
const totalLABOnBoard =
|
||||||
? parseFloat(calculateReducerTotal(reducerData.lanes, "labhrs", "mod_lb_hrs").toFixed(2))
|
filteredReducerData && cardSettings.totalLABOnBoard
|
||||||
: null;
|
? parseFloat(calculateReducerTotal(filteredReducerData.lanes, "labhrs", "mod_lb_hrs").toFixed(2))
|
||||||
|
: null;
|
||||||
|
|
||||||
const totalLAROnBoard = reducerData && cardSettings.totalLAROnBoard
|
const totalLAROnBoard =
|
||||||
? parseFloat(calculateReducerTotal(reducerData.lanes, "larhrs", "mod_lb_hrs").toFixed(2))
|
filteredReducerData && cardSettings.totalLAROnBoard
|
||||||
: null;
|
? parseFloat(calculateReducerTotal(filteredReducerData.lanes, "larhrs", "mod_lb_hrs").toFixed(2))
|
||||||
|
: null;
|
||||||
|
|
||||||
const jobsOnBoard = reducerData && cardSettings.jobsOnBoard
|
const jobsOnBoard =
|
||||||
? reducerData.lanes.reduce((acc, lane) => acc + lane.cards.length, 0)
|
filteredReducerData && cardSettings.jobsOnBoard
|
||||||
: null;
|
? filteredReducerData.lanes.reduce((acc, lane) => acc + lane.cards.length, 0)
|
||||||
|
: null;
|
||||||
|
|
||||||
const tasksInProduction = cardSettings.tasksInProduction
|
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;
|
: null;
|
||||||
|
|
||||||
const tasksOnBoard = reducerData && cardSettings.tasksOnBoard
|
const tasksOnBoard =
|
||||||
? reducerData.lanes.reduce((acc, lane) => {
|
filteredReducerData && cardSettings.tasksOnBoard
|
||||||
return (
|
? filteredReducerData.lanes.reduce((acc, lane) => {
|
||||||
acc + lane.cards.reduce((laneAcc, card) => laneAcc + (card.metadata.tasks_aggregate?.aggregate?.count || 0), 0)
|
return (
|
||||||
);
|
acc +
|
||||||
}, 0)
|
lane.cards.reduce((laneAcc, card) => laneAcc + (card.metadata.tasks_aggregate?.aggregate?.count || 0), 0)
|
||||||
: null;
|
);
|
||||||
|
}, 0)
|
||||||
|
: null;
|
||||||
|
|
||||||
const statistics = mergeStatistics(statisticsItems, [
|
const statistics = mergeStatistics(statisticsItems, [
|
||||||
{ id: 0, value: totalHrs, type: StatisticType.HOURS },
|
{ id: 0, value: totalHrs, type: StatisticType.HOURS },
|
||||||
|
|||||||
@@ -14,7 +14,16 @@ const StatisticsSettings = ({ t, statisticsOrder, setStatisticsOrder, setHasChan
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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}>
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
<Droppable direction="grid" droppableId="statistics">
|
<Droppable direction="grid" droppableId="statistics">
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ const defaultKanbanSettings = {
|
|||||||
subtotal: false,
|
subtotal: false,
|
||||||
statisticsOrder: statisticsItems.map((item) => item.id),
|
statisticsOrder: statisticsItems.map((item) => item.id),
|
||||||
selectedMdInsCos: [],
|
selectedMdInsCos: [],
|
||||||
selectedEstimators: []
|
selectedEstimators: [],
|
||||||
|
excludeSuspended: false
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultFilters = { search: "", employeeId: null, alert: false };
|
const defaultFilters = { search: "", employeeId: null, alert: false };
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { connect } from "react-redux";
|
|||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectTechnician } from "../../redux/tech/tech.selectors";
|
import { selectTechnician } from "../../redux/tech/tech.selectors";
|
||||||
import { selectBodyshop } from "../../redux/user/user.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({
|
const mapStateToProps = createStructuredSelector({
|
||||||
technician: selectTechnician,
|
technician: selectTechnician,
|
||||||
|
|||||||
@@ -140,13 +140,11 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo
|
|||||||
sortOrder: state.sortedInfo.columnKey === "vehicle" && state.sortedInfo.order,
|
sortOrder: state.sortedInfo.columnKey === "vehicle" && state.sortedInfo.order,
|
||||||
render: (text, record) =>
|
render: (text, record) =>
|
||||||
technician ? (
|
technician ? (
|
||||||
<>{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${record.v_model_desc || ""} ${
|
<>{`${record.v_model_yr || ""} ${record.v_color || ""}${record.v_make_desc || ""} ${record.v_model_desc || ""} ${record.plate_no || ""}`}</>
|
||||||
record.v_color || ""
|
|
||||||
} ${record.plate_no || ""}`}</>
|
|
||||||
) : (
|
) : (
|
||||||
<Link to={`/manage/vehicles/${record.vehicleid}`}>{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${
|
<Link
|
||||||
record.v_model_desc || ""
|
to={`/manage/vehicles/${record.vehicleid}`}
|
||||||
} ${record.v_color || ""} ${record.plate_no || ""}`}</Link>
|
>{`${record.v_model_yr || ""} ${record.v_color || ""} ${record.v_make_desc || ""} ${record.v_model_desc || ""} ${record.plate_no || ""}`}</Link>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -621,7 +619,7 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo
|
|||||||
sortOrder: state.sortedInfo.columnKey === "dms_id" && state.sortedInfo.order
|
sortOrder: state.sortedInfo.columnKey === "dms_id" && state.sortedInfo.order
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: []),
|
: [])
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
export default productionListColumnsData;
|
export default productionListColumnsData;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { UPDATE_ACTIVE_PROD_LIST_VIEW } from "../../graphql/associations.queries";
|
import { UPDATE_ACTIVE_PROD_LIST_VIEW } from "../../graphql/associations.queries";
|
||||||
import { UPDATE_SHOP } from "../../graphql/bodyshop.queries";
|
import { UPDATE_SHOP } from "../../graphql/bodyshop.queries";
|
||||||
import ProductionListColumns from "../production-list-columns/production-list-columns.data";
|
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 { logImEXEvent } from "../../firebase/firebase.utils";
|
||||||
import { isFunction } from "lodash";
|
import { isFunction } from "lodash";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { HolderOutlined, SyncOutlined } from "@ant-design/icons";
|
import { HolderOutlined, SyncOutlined } from "@ant-design/icons";
|
||||||
import { PageHeader } from "@ant-design/pro-layout";
|
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 { Button, Dropdown, Input, Space, Statistic, Table } from "antd";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "../../graphql/jobs.queries";
|
} from "../../graphql/jobs.queries";
|
||||||
import ProductionListTable from "./production-list-table.component";
|
import ProductionListTable from "./production-list-table.component";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||||
|
|
||||||
export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) {
|
export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useLazyQuery } from "@apollo/client/react";
|
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 { Button, Card, Col, DatePicker, Form, Input, Radio, Row, Typography } from "antd";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -12,6 +12,7 @@ import { QUERY_ALL_VENDORS } from "../../graphql/vendors.queries";
|
|||||||
import { selectReportCenter } from "../../redux/modals/modals.selectors";
|
import { selectReportCenter } from "../../redux/modals/modals.selectors";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import DatePickerRanges from "../../utils/DatePickerRanges";
|
import DatePickerRanges from "../../utils/DatePickerRanges";
|
||||||
|
import { DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
|
||||||
import { GenerateDocument } from "../../utils/RenderTemplate";
|
import { GenerateDocument } from "../../utils/RenderTemplate";
|
||||||
import { TemplateList } from "../../utils/TemplateConstants";
|
import { TemplateList } from "../../utils/TemplateConstants";
|
||||||
import dayjs from "../../utils/day";
|
import dayjs from "../../utils/day";
|
||||||
@@ -48,12 +49,18 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const Templates = TemplateList("report_center");
|
const Templates = TemplateList("report_center");
|
||||||
|
const dmsMode = getDmsMode(bodyshop, "off");
|
||||||
|
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
|
||||||
const ReportsList = Object.keys(Templates)
|
const ReportsList = Object.keys(Templates)
|
||||||
.map((key) => Templates[key])
|
.map((key) => Templates[key])
|
||||||
.filter((temp) => {
|
.filter((temp) => {
|
||||||
const enhancedPayrollOn = Enhanced_Payroll.treatment === "on";
|
const enhancedPayrollOn = Enhanced_Payroll.treatment === "on";
|
||||||
const adpPayrollOn = ADPPayroll.treatment === "on";
|
const adpPayrollOn = ADPPayroll.treatment === "on";
|
||||||
|
|
||||||
|
if (isReynoldsMode && temp.excludedDmsModes?.includes(dmsMode)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (enhancedPayrollOn && adpPayrollOn) {
|
if (enhancedPayrollOn && adpPayrollOn) {
|
||||||
return temp.enhanced_payroll !== false || temp.adp_payroll !== false;
|
return temp.enhanced_payroll !== false || temp.adp_payroll !== false;
|
||||||
}
|
}
|
||||||
@@ -408,6 +415,6 @@ const restrictedReports = [
|
|||||||
{ key: "job_costing_ro_estimator", days: 183 },
|
{ key: "job_costing_ro_estimator", days: 183 },
|
||||||
{ key: "job_lifecycle_date_detail", days: 183 },
|
{ key: "job_lifecycle_date_detail", days: 183 },
|
||||||
{ key: "job_lifecycle_date_summary", days: 183 },
|
{ key: "job_lifecycle_date_summary", days: 183 },
|
||||||
{ key: "customer_list", days: 183 },
|
{ key: "customer_list", days: 736 },
|
||||||
{ key: "customer_list_excel", days: 183 }
|
{ key: "customer_list_excel", days: 736 }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function ScheduleVerifyIntegrity({ currentUser }) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (currentUser.email === "patrick@imex.prod")
|
if (currentUser.email === "allan@imex.prod" || currentUser.email === "dave@imex.prod")
|
||||||
return (
|
return (
|
||||||
<Button loading={loading} onClick={handleVerify}>
|
<Button loading={loading} onClick={handleVerify}>
|
||||||
Developer Use Only - Verify Schedule Integrity
|
Developer Use Only - Verify Schedule Integrity
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DeleteFilled } from "@ant-design/icons";
|
import { DeleteFilled } from "@ant-design/icons";
|
||||||
import { useApolloClient, useMutation, useQuery } from "@apollo/client/react";
|
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, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
|
import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
|
||||||
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
||||||
import queryString from "query-string";
|
import queryString from "query-string";
|
||||||
@@ -12,11 +12,11 @@ import { createStructuredSelector } from "reselect";
|
|||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||||
import {
|
import {
|
||||||
|
CHECK_EMPLOYEE_EMAIL,
|
||||||
CHECK_EMPLOYEE_NUMBER,
|
CHECK_EMPLOYEE_NUMBER,
|
||||||
DELETE_VACATION,
|
DELETE_VACATION,
|
||||||
INSERT_EMPLOYEES,
|
INSERT_EMPLOYEES,
|
||||||
QUERY_EMPLOYEE_BY_ID,
|
QUERY_EMPLOYEE_BY_ID,
|
||||||
QUERY_USERS_BY_EMAIL,
|
|
||||||
UPDATE_EMPLOYEE
|
UPDATE_EMPLOYEE
|
||||||
} from "../../graphql/employees.queries";
|
} from "../../graphql/employees.queries";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
@@ -174,9 +174,10 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi
|
|||||||
|
|
||||||
const handleFinish = async (values) => {
|
const handleFinish = async (values) => {
|
||||||
const submitAction = saveAndResetSubmitAction();
|
const submitAction = saveAndResetSubmitAction();
|
||||||
|
const userEmail = typeof values.user_email === "string" ? values.user_email.trim() : values.user_email;
|
||||||
const normalizedValues = {
|
const normalizedValues = {
|
||||||
...values,
|
...values,
|
||||||
user_email: values.user_email === "" ? null : values.user_email
|
user_email: userEmail === "" ? null : userEmail
|
||||||
};
|
};
|
||||||
|
|
||||||
if (search.employeeId && search.employeeId !== "new") {
|
if (search.employeeId && search.employeeId !== "new") {
|
||||||
@@ -220,12 +221,16 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi
|
|||||||
});
|
});
|
||||||
const savedEmployee = result?.data?.insert_employees?.returning?.[0];
|
const savedEmployee = result?.data?.insert_employees?.returning?.[0];
|
||||||
|
|
||||||
syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues);
|
|
||||||
|
|
||||||
if (submitAction === "saveAndNew") {
|
if (submitAction === "saveAndNew") {
|
||||||
|
if (isNewEmployee) {
|
||||||
|
resetEmployeeFormToCurrentData();
|
||||||
|
}
|
||||||
navigateToEmployee("new");
|
navigateToEmployee("new");
|
||||||
} else if (savedEmployee?.id) {
|
} else if (savedEmployee?.id) {
|
||||||
|
syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues);
|
||||||
navigateToEmployee(savedEmployee.id);
|
navigateToEmployee(savedEmployee.id);
|
||||||
|
} else {
|
||||||
|
syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues);
|
||||||
}
|
}
|
||||||
|
|
||||||
notification.success({
|
notification.success({
|
||||||
@@ -487,18 +492,29 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi
|
|||||||
rules={[
|
rules={[
|
||||||
({ getFieldValue }) => ({
|
({ getFieldValue }) => ({
|
||||||
async validator(rule, value) {
|
async validator(rule, value) {
|
||||||
const user_email = getFieldValue("user_email");
|
const user_email = typeof value === "string" ? value.trim() : getFieldValue("user_email");
|
||||||
|
|
||||||
if (user_email && value) {
|
if (user_email && value) {
|
||||||
const response = await client.query({
|
const response = await client.query({
|
||||||
query: QUERY_USERS_BY_EMAIL,
|
query: CHECK_EMPLOYEE_EMAIL,
|
||||||
variables: {
|
variables: {
|
||||||
email: user_email
|
email: user_email,
|
||||||
|
shopId: bodyshop.id
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data.users.length === 1) {
|
if (response.data.users.length === 1) {
|
||||||
return Promise.resolve();
|
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"));
|
return Promise.reject(t("bodyshop.validation.useremailmustexist"));
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { Form } from "antd";
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { DELETE_VACATION, INSERT_EMPLOYEES, QUERY_EMPLOYEE_BY_ID, UPDATE_EMPLOYEE } from "../../graphql/employees.queries";
|
import {
|
||||||
|
CHECK_EMPLOYEE_EMAIL,
|
||||||
|
DELETE_VACATION,
|
||||||
|
INSERT_EMPLOYEES,
|
||||||
|
QUERY_EMPLOYEE_BY_ID,
|
||||||
|
UPDATE_EMPLOYEE
|
||||||
|
} from "../../graphql/employees.queries";
|
||||||
import { ShopEmployeesFormComponent } from "./shop-employees-form.component.jsx";
|
import { ShopEmployeesFormComponent } from "./shop-employees-form.component.jsx";
|
||||||
|
|
||||||
const insertEmployeesMock = vi.fn();
|
const insertEmployeesMock = vi.fn();
|
||||||
@@ -11,6 +17,7 @@ const updateEmployeeMock = vi.fn();
|
|||||||
const deleteVacationMock = vi.fn();
|
const deleteVacationMock = vi.fn();
|
||||||
const useQueryMock = vi.fn();
|
const useQueryMock = vi.fn();
|
||||||
const useMutationMock = vi.fn();
|
const useMutationMock = vi.fn();
|
||||||
|
const apolloClientQueryMock = vi.fn();
|
||||||
const navigateMock = vi.fn();
|
const navigateMock = vi.fn();
|
||||||
const notification = {
|
const notification = {
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
@@ -28,7 +35,7 @@ vi.mock("@apollo/client/react", async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("@splitsoftware/splitio-react", () => ({
|
vi.mock("../../feature-flags/splitio-react-replacement", () => ({
|
||||||
useTreatmentsWithConfig: () => ({
|
useTreatmentsWithConfig: () => ({
|
||||||
treatments: {
|
treatments: {
|
||||||
Enhanced_Payroll: {
|
Enhanced_Payroll: {
|
||||||
@@ -82,6 +89,10 @@ vi.mock("react-i18next", () => ({
|
|||||||
return "Employee number must be unique";
|
return "Employee number must be unique";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === "employees.validation.unique_user_email") {
|
||||||
|
return "User email already assigned";
|
||||||
|
}
|
||||||
|
|
||||||
if (key === "bodyshop.validation.useremailmustexist") {
|
if (key === "bodyshop.validation.useremailmustexist") {
|
||||||
return "User email must exist";
|
return "User email must exist";
|
||||||
}
|
}
|
||||||
@@ -198,18 +209,20 @@ describe("ShopEmployeesFormComponent", () => {
|
|||||||
return [vi.fn()];
|
return [vi.fn()];
|
||||||
});
|
});
|
||||||
|
|
||||||
useApolloClient.mockReturnValue({
|
apolloClientQueryMock.mockResolvedValue({
|
||||||
query: vi.fn().mockResolvedValue({
|
data: {
|
||||||
data: {
|
employees_aggregate: {
|
||||||
employees_aggregate: {
|
aggregate: {
|
||||||
aggregate: {
|
count: 0
|
||||||
count: 0
|
|
||||||
},
|
|
||||||
nodes: []
|
|
||||||
},
|
},
|
||||||
users: []
|
nodes: []
|
||||||
}
|
},
|
||||||
})
|
users: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useApolloClient.mockReturnValue({
|
||||||
|
query: apolloClientQueryMock
|
||||||
});
|
});
|
||||||
|
|
||||||
insertEmployeesMock.mockResolvedValue({
|
insertEmployeesMock.mockResolvedValue({
|
||||||
@@ -335,6 +348,15 @@ describe("ShopEmployeesFormComponent", () => {
|
|||||||
expect(formInstance.isFieldsTouched()).toBe(false);
|
expect(formInstance.isFieldsTouched()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("textbox", { name: "First Name" })).toHaveValue("");
|
||||||
|
expect(screen.getByRole("textbox", { name: "Last Name" })).toHaveValue("");
|
||||||
|
expect(screen.getByRole("textbox", { name: "Employee Number" })).toHaveValue("");
|
||||||
|
expect(screen.getByRole("textbox", { name: "PIN" })).toHaveValue("");
|
||||||
|
expect(screen.getByRole("textbox", { name: "Hire Date" })).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("New Employee")).toBeInTheDocument();
|
||||||
expect(navigateMock).toHaveBeenCalledWith({
|
expect(navigateMock).toHaveBeenCalledWith({
|
||||||
search: "employeeId=new"
|
search: "employeeId=new"
|
||||||
});
|
});
|
||||||
@@ -342,4 +364,59 @@ describe("ShopEmployeesFormComponent", () => {
|
|||||||
title: "Saved"
|
title: "Saved"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks saving when the user email belongs to another employee in the shop", async () => {
|
||||||
|
apolloClientQueryMock.mockImplementation(({ query }) => {
|
||||||
|
if (query === CHECK_EMPLOYEE_EMAIL) {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: {
|
||||||
|
users: [{ email: "jamie@example.com" }],
|
||||||
|
employees_aggregate: {
|
||||||
|
aggregate: {
|
||||||
|
count: 1
|
||||||
|
},
|
||||||
|
nodes: [{ id: "other-employee" }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve({
|
||||||
|
data: {
|
||||||
|
employees_aggregate: {
|
||||||
|
aggregate: {
|
||||||
|
count: 0
|
||||||
|
},
|
||||||
|
nodes: []
|
||||||
|
},
|
||||||
|
users: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "First Name" }), {
|
||||||
|
target: { value: "Jamie" }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "Last Name" }), {
|
||||||
|
target: { value: "Rivera" }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "Employee Number" }), {
|
||||||
|
target: { value: "42" }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "PIN" }), {
|
||||||
|
target: { value: "1234" }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "Hire Date" }), {
|
||||||
|
target: { value: "2026-04-20" }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "User Email" }), {
|
||||||
|
target: { value: "jamie@example.com" }
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save Employee" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("User email already assigned")).toBeInTheDocument();
|
||||||
|
expect(insertEmployeesMock).not.toHaveBeenCalled();
|
||||||
|
expect(notification.success).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
import { Button, Card, Tabs } from "antd";
|
import { Button, Card, Tabs } from "antd";
|
||||||
import queryString from "query-string";
|
import queryString from "query-string";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.c
|
|||||||
import { buildSectionActionButton, renderListOrEmpty } from "../layout-form-row/config-list-actions.utils.jsx";
|
import { buildSectionActionButton, renderListOrEmpty } from "../layout-form-row/config-list-actions.utils.jsx";
|
||||||
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.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 LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
|
import { bodyshopHasDmsKey, DMS_MAP, getDmsMode } from "../../utils/dmsUtils.js";
|
||||||
import {
|
import {
|
||||||
INLINE_TITLE_GROUP_STYLE,
|
INLINE_TITLE_GROUP_STYLE,
|
||||||
INLINE_TITLE_HANDLE_STYLE,
|
INLINE_TITLE_HANDLE_STYLE,
|
||||||
@@ -25,16 +27,21 @@ import {
|
|||||||
|
|
||||||
const timeZonesList = Intl.supportedValuesOf("timeZone");
|
const timeZonesList = Intl.supportedValuesOf("timeZone");
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({});
|
const mapStateToProps = createStructuredSelector({
|
||||||
|
bodyshop: selectBodyshop
|
||||||
|
});
|
||||||
const mapDispatchToProps = () => ({
|
const mapDispatchToProps = () => ({
|
||||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||||
});
|
});
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoGeneral);
|
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoGeneral);
|
||||||
|
|
||||||
export function ShopInfoGeneral({ form }) {
|
export function ShopInfoGeneral({ form, bodyshop }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insuranceCompanies = Form.useWatch(["md_ins_cos"], form) || [];
|
const insuranceCompanies = Form.useWatch(["md_ins_cos"], form) || [];
|
||||||
const duplicateInsuranceCompanyIndexes = getDuplicateIndexSetByNormalizedName(insuranceCompanies, "name");
|
const duplicateInsuranceCompanyIndexes = getDuplicateIndexSetByNormalizedName(insuranceCompanies, "name");
|
||||||
|
const hasDMSKey = bodyshop ? bodyshopHasDmsKey(bodyshop) : false;
|
||||||
|
const dmsMode = bodyshop ? getDmsMode(bodyshop, "off") : "none";
|
||||||
|
const isReynoldsMode = hasDMSKey && dmsMode === DMS_MAP.reynolds;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -174,7 +181,9 @@ export function ShopInfoGeneral({ form }) {
|
|||||||
>
|
>
|
||||||
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
|
<div aria-hidden style={INLINE_TITLE_SEPARATOR_STYLE} />
|
||||||
<div style={INLINE_TITLE_SWITCH_GROUP_STYLE}>
|
<div style={INLINE_TITLE_SWITCH_GROUP_STYLE}>
|
||||||
<div style={INLINE_TITLE_LABEL_STYLE}>{t("bodyshop.fields.scoreboard_setup.ignore_blocked_days")}</div>
|
<div style={INLINE_TITLE_LABEL_STYLE}>
|
||||||
|
{t("bodyshop.fields.scoreboard_setup.ignore_blocked_days")}
|
||||||
|
</div>
|
||||||
<Form.Item noStyle name={["scoreboard_target", "ignoreblockeddays"]} valuePropName="checked">
|
<Form.Item noStyle name={["scoreboard_target", "ignoreblockeddays"]} valuePropName="checked">
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -311,7 +320,12 @@ export function ShopInfoGeneral({ form }) {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} xl={8}>
|
<Col xs={24} sm={12} xl={8}>
|
||||||
<Form.Item key="use_fippa" label={t("bodyshop.fields.use_fippa")} name={["use_fippa"]} valuePropName="checked">
|
<Form.Item
|
||||||
|
key="use_fippa"
|
||||||
|
label={t("bodyshop.fields.use_fippa")}
|
||||||
|
name={["use_fippa"]}
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -478,7 +492,12 @@ export function ShopInfoGeneral({ form }) {
|
|||||||
<div style={INLINE_TITLE_LABEL_STYLE}>
|
<div style={INLINE_TITLE_LABEL_STYLE}>
|
||||||
{t("bodyshop.fields.system_settings.job_costing.use_paint_scale_data")}
|
{t("bodyshop.fields.system_settings.job_costing.use_paint_scale_data")}
|
||||||
</div>
|
</div>
|
||||||
<Form.Item noStyle key="use_paint_scale_data" name={["use_paint_scale_data"]} valuePropName="checked">
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
key="use_paint_scale_data"
|
||||||
|
name={["use_paint_scale_data"]}
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
@@ -558,7 +577,12 @@ export function ShopInfoGeneral({ form }) {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</LayoutFormRow>
|
</LayoutFormRow>
|
||||||
|
|
||||||
<LayoutFormRow header={t("bodyshop.labels.shop_enabled_features")} id="sharing" grow style={{ marginBottom: 0 }}>
|
<LayoutFormRow
|
||||||
|
header={t("bodyshop.labels.shop_enabled_features")}
|
||||||
|
id="sharing"
|
||||||
|
grow
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("general.actions.sharetoteams")}
|
label={t("general.actions.sharetoteams")}
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
@@ -566,6 +590,16 @@ export function ShopInfoGeneral({ form }) {
|
|||||||
>
|
>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
{isReynoldsMode && (
|
||||||
|
<Form.Item
|
||||||
|
initialValue
|
||||||
|
label={t("bodyshop.fields.md_functionality_toggles.enhanced_early_ros")}
|
||||||
|
name={["md_functionality_toggles", "enhanced_early_ros"]}
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
</LayoutFormRow>
|
</LayoutFormRow>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
|||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
import { Form, InputNumber } from "antd";
|
import { Form, InputNumber } from "antd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { DeleteFilled } from "@ant-design/icons";
|
import { DeleteFilled } from "@ant-design/icons";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
import { Button, Col, DatePicker, Divider, Form, Input, InputNumber, Radio, Row, Select, Space, Switch } from "antd";
|
import { Button, Col, DatePicker, Divider, Form, Input, InputNumber, Radio, Row, Select, Space, Switch } from "antd";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -157,36 +157,36 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
|
|||||||
</Col>
|
</Col>
|
||||||
{HasFeatureAccess({ featureName: "export", bodyshop }) &&
|
{HasFeatureAccess({ featureName: "export", bodyshop }) &&
|
||||||
ClosingPeriod.treatment === "on" && (
|
ClosingPeriod.treatment === "on" && (
|
||||||
<Col xs={24} sm={12} xl={8}>
|
<Col xs={24} sm={12} xl={8}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
key="ClosingPeriod"
|
key="ClosingPeriod"
|
||||||
name={["accountingconfig", "ClosingPeriod"]}
|
name={["accountingconfig", "ClosingPeriod"]}
|
||||||
label={t("bodyshop.fields.closingperiod")}
|
label={t("bodyshop.fields.closingperiod")}
|
||||||
>
|
>
|
||||||
<DatePicker.RangePicker format="MM/DD/YYYY" presets={DatePickerRanges} />
|
<DatePicker.RangePicker format="MM/DD/YYYY" presets={DatePickerRanges} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
{HasFeatureAccess({ featureName: "export", bodyshop }) &&
|
{HasFeatureAccess({ featureName: "export", bodyshop }) &&
|
||||||
ADPPayroll.treatment === "on" && (
|
ADPPayroll.treatment === "on" && (
|
||||||
<Col xs={24} sm={12} xl={8}>
|
<Col xs={24} sm={12} xl={8}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
key="companyCode"
|
key="companyCode"
|
||||||
name={["accountingconfig", "companyCode"]}
|
name={["accountingconfig", "companyCode"]}
|
||||||
label={t("bodyshop.fields.companycode")}
|
label={t("bodyshop.fields.companycode")}
|
||||||
>
|
>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
{HasFeatureAccess({ featureName: "export", bodyshop }) &&
|
{HasFeatureAccess({ featureName: "export", bodyshop }) &&
|
||||||
ADPPayroll.treatment === "on" && (
|
ADPPayroll.treatment === "on" && (
|
||||||
<Col xs={24} sm={12} xl={8}>
|
<Col xs={24} sm={12} xl={8}>
|
||||||
<Form.Item key="batchID" name={["accountingconfig", "batchID"]} label={t("bodyshop.fields.batchid")}>
|
<Form.Item key="batchID" name={["accountingconfig", "batchID"]} label={t("bodyshop.fields.batchid")}>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
{HasFeatureAccess({ featureName: "export", bodyshop }) && !hasDMSKey && (
|
{HasFeatureAccess({ featureName: "export", bodyshop }) && !hasDMSKey && (
|
||||||
<>
|
<>
|
||||||
<Col xs={24} sm={12} xl={8}>
|
<Col xs={24} sm={12} xl={8}>
|
||||||
@@ -512,6 +512,15 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
|
|||||||
>
|
>
|
||||||
<InputNumber min={0} max={100} suffix="%" />
|
<InputNumber min={0} max={100} suffix="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
{bodyshop.cdk_dealerid && (
|
||||||
|
<Form.Item
|
||||||
|
label={t("bodyshop.fields.dms.disablecontact")}
|
||||||
|
valuePropName="checked"
|
||||||
|
name={["cdk_configuration", "disablecontact"]}
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
{bodyshop.pbs_serialnumber && (
|
{bodyshop.pbs_serialnumber && (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("bodyshop.fields.dms.disablecontactvehiclecreation")}
|
label={t("bodyshop.fields.dms.disablecontactvehiclecreation")}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { DEFAULT_TRANSLUCENT_CARD_COLOR, getTintedCardSurfaceStyles } from "./sh
|
|||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
|
|||||||
import JobSearchSelect from "../job-search-select/job-search-select.component";
|
import JobSearchSelect from "../job-search-select/job-search-select.component";
|
||||||
import JobsDetailLaborContainer from "../jobs-detail-labor/jobs-detail-labor.container";
|
import JobsDetailLaborContainer from "../jobs-detail-labor/jobs-detail-labor.container";
|
||||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop,
|
bodyshop: selectBodyshop,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { selectTechnician } from "../../redux/tech/tech.selectors";
|
|||||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||||
import TechJobPrintTickets from "../tech-job-print-tickets/tech-job-print-tickets.component";
|
import TechJobPrintTickets from "../tech-job-print-tickets/tech-job-print-tickets.component";
|
||||||
import TechClockInComponent from "./tech-job-clock-in-form.component";
|
import TechClockInComponent from "./tech-job-clock-in-form.component";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
|
|||||||
import { CalculateAllocationsTotals } from "../labor-allocations-table/labor-allocations-table.utility";
|
import { CalculateAllocationsTotals } from "../labor-allocations-table/labor-allocations-table.utility";
|
||||||
import TechJobClockoutDelete from "../tech-job-clock-out-delete/tech-job-clock-out-delete.component";
|
import TechJobClockoutDelete from "../tech-job-clock-out-delete/tech-job-clock-out-delete.component";
|
||||||
import { LaborAllocationContainer } from "../time-ticket-modal/time-ticket-modal.component";
|
import { LaborAllocationContainer } from "../time-ticket-modal/time-ticket-modal.component";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
|
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { createStructuredSelector } from "reselect";
|
|||||||
import { techLogout } from "../../redux/tech/tech.actions";
|
import { techLogout } from "../../redux/tech/tech.actions";
|
||||||
import { selectTechnician } from "../../redux/tech/tech.selectors";
|
import { selectTechnician } from "../../redux/tech/tech.selectors";
|
||||||
import { BsKanban } from "react-icons/bs";
|
import { BsKanban } from "react-icons/bs";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import { setModalContext } from "../../redux/modals/modals.actions";
|
import { setModalContext } from "../../redux/modals/modals.actions";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { EditFilled, SyncOutlined } from "@ant-design/icons";
|
import { EditFilled, SyncOutlined } from "@ant-design/icons";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
import { Button, Card, Checkbox, Space } from "antd";
|
import { Button, Card, Checkbox, Space } from "antd";
|
||||||
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useLazyQuery } from "@apollo/client/react";
|
import { useLazyQuery } from "@apollo/client/react";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
import { Card, Form, Input, InputNumber, Select, Space, Switch } from "antd";
|
import { Card, Form, Input, InputNumber, Select, Space, Switch } from "antd";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { PageHeader } from "@ant-design/pro-layout";
|
import { PageHeader } from "@ant-design/pro-layout";
|
||||||
import { useMutation, useQuery } from "@apollo/client/react";
|
import { useMutation, useQuery } from "@apollo/client/react";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
import { Button, Form, Modal, Space } from "antd";
|
import { Button, Form, Modal, Space } from "antd";
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import ResponsiveTable from "../responsive-table/responsive-table.component";
|
|||||||
|
|
||||||
export default function VehiclesListComponent({ loading, vehicles, total, refetch, basePath = "/manage" }) {
|
export default function VehiclesListComponent({ loading, vehicles, total, refetch, basePath = "/manage" }) {
|
||||||
const search = queryString.parse(useLocation().search);
|
const search = queryString.parse(useLocation().search);
|
||||||
const {
|
const { page, pageSize } = search;
|
||||||
page
|
|
||||||
//sortcolumn, sortorder,
|
|
||||||
} = search;
|
|
||||||
const history = useNavigate();
|
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({
|
const [state, setState] = useState({
|
||||||
sortedInfo: {},
|
sortedInfo: {},
|
||||||
filteredInfo: { text: "" }
|
filteredInfo: { text: "" }
|
||||||
@@ -43,9 +44,7 @@ export default function VehiclesListComponent({ loading, vehicles, total, refetc
|
|||||||
key: "description",
|
key: "description",
|
||||||
render: (text, record) => {
|
render: (text, record) => {
|
||||||
return (
|
return (
|
||||||
<span>{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${
|
<span>{`${record.v_model_yr || ""} ${record.v_color || ""} ${record.v_make_desc || ""} ${record.v_model_desc || ""} `}</span>
|
||||||
record.v_model_desc || ""
|
|
||||||
} ${record.v_color || ""}`}</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -62,10 +61,14 @@ export default function VehiclesListComponent({ loading, vehicles, total, refetc
|
|||||||
];
|
];
|
||||||
|
|
||||||
const handleTableChange = (pagination, filters, sorter) => {
|
const handleTableChange = (pagination, filters, sorter) => {
|
||||||
|
const nextPageSize = pagination?.pageSize || currentPageSize;
|
||||||
|
const pageSizeChanged = nextPageSize !== currentPageSize;
|
||||||
|
|
||||||
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
|
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
|
||||||
const updatedSearch = {
|
const updatedSearch = {
|
||||||
...search,
|
...search,
|
||||||
page: pagination.current,
|
pageSize: nextPageSize,
|
||||||
|
page: pageSizeChanged ? 1 : pagination.current,
|
||||||
sortcolumn: sorter.columnKey,
|
sortcolumn: sorter.columnKey,
|
||||||
sortorder: sorter.order
|
sortorder: sorter.order
|
||||||
};
|
};
|
||||||
@@ -106,7 +109,13 @@ export default function VehiclesListComponent({ loading, vehicles, total, refetc
|
|||||||
>
|
>
|
||||||
<ResponsiveTable
|
<ResponsiveTable
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{ placement: "top", pageSize: pageLimit, current: parseInt(page || 1), total: total }}
|
pagination={{
|
||||||
|
placement: "top",
|
||||||
|
pageSize: currentPageSize,
|
||||||
|
current: currentPage,
|
||||||
|
showSizeChanger: true,
|
||||||
|
total: total
|
||||||
|
}}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
mobileColumnKeys={["v_vin", "description", "plate_no"]}
|
mobileColumnKeys={["v_vin", "description", "plate_no"]}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
|||||||
@@ -16,14 +16,18 @@ const mapStateToProps = createStructuredSelector({
|
|||||||
|
|
||||||
export function VehiclesListContainer({ isPartsEntry }) {
|
export function VehiclesListContainer({ isPartsEntry }) {
|
||||||
const searchParams = queryString.parse(useLocation().search);
|
const searchParams = queryString.parse(useLocation().search);
|
||||||
const { page, sortcolumn, sortorder, search } = searchParams;
|
const { page, sortcolumn, sortorder, search, pageSize } = searchParams;
|
||||||
const basePath = getPartsBasePath(isPartsEntry);
|
const basePath = getPartsBasePath(isPartsEntry);
|
||||||
|
|
||||||
|
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_VEHICLES_PAGINATED, {
|
const { loading, error, data, refetch } = useQuery(QUERY_ALL_VEHICLES_PAGINATED, {
|
||||||
variables: {
|
variables: {
|
||||||
search: search || "",
|
search: search || "",
|
||||||
offset: page ? (page - 1) * pageLimit : 0,
|
offset: (currentPage - 1) * currentPageSize,
|
||||||
limit: pageLimit,
|
limit: currentPageSize,
|
||||||
order: [
|
order: [
|
||||||
{
|
{
|
||||||
[sortcolumn || "created_at"]: sortorder ? (sortorder === "descend" ? "desc" : "asc") : "desc"
|
[sortcolumn || "created_at"]: sortorder ? (sortorder === "descend" ? "desc" : "asc") : "desc"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DeleteFilled } from "@ant-design/icons";
|
import { DeleteFilled } from "@ant-design/icons";
|
||||||
import { useApolloClient } from "@apollo/client/react";
|
import { useApolloClient } from "@apollo/client/react";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from "antd";
|
import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from "antd";
|
||||||
import { PageHeader } from "@ant-design/pro-layout";
|
import { PageHeader } from "@ant-design/pro-layout";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from "../../graphql/notifications.queries.js";
|
} from "../../graphql/notifications.queries.js";
|
||||||
import { useMutation } from "@apollo/client/react";
|
import { useMutation } from "@apollo/client/react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { FEATURE_FLAGS_CHANGED_EVENT, useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||||
import { INITIAL_NOTIFICATIONS, SocketContext } from "./useSocket.js";
|
import { INITIAL_NOTIFICATIONS, SocketContext } from "./useSocket.js";
|
||||||
|
|
||||||
const LIMIT = INITIAL_NOTIFICATIONS;
|
const LIMIT = INITIAL_NOTIFICATIONS;
|
||||||
@@ -280,6 +280,10 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFeatureFlagsChanged = (payload) => {
|
||||||
|
window.dispatchEvent(new CustomEvent(FEATURE_FLAGS_CHANGED_EVENT, { detail: payload }));
|
||||||
|
};
|
||||||
|
|
||||||
const syncCurrentTokenToSocket = async () => {
|
const syncCurrentTokenToSocket = async () => {
|
||||||
try {
|
try {
|
||||||
if (!auth.currentUser || !bodyshop?.id) return;
|
if (!auth.currentUser || !bodyshop?.id) return;
|
||||||
@@ -574,6 +578,7 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
|
|||||||
socketInstance.on("notification", handleNotification);
|
socketInstance.on("notification", handleNotification);
|
||||||
socketInstance.on("sync-notification-read", handleSyncNotificationRead);
|
socketInstance.on("sync-notification-read", handleSyncNotificationRead);
|
||||||
socketInstance.on("sync-all-notifications-read", handleSyncAllNotificationsRead);
|
socketInstance.on("sync-all-notifications-read", handleSyncAllNotificationsRead);
|
||||||
|
socketInstance.on(FEATURE_FLAGS_CHANGED_EVENT, handleFeatureFlagsChanged);
|
||||||
socketInstance.on("token-updated", handleTokenUpdated);
|
socketInstance.on("token-updated", handleTokenUpdated);
|
||||||
|
|
||||||
if (tokenSyncIntervalRef.current) {
|
if (tokenSyncIntervalRef.current) {
|
||||||
|
|||||||
71
client/src/feature-flags/README.md
Normal file
71
client/src/feature-flags/README.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Feature Flags
|
||||||
|
|
||||||
|
The app imports feature-flag hooks from `src/feature-flags/splitio-react-replacement.jsx`. That module keeps the old
|
||||||
|
Split-shaped component and hook API intact while removing the runtime dependency on Split.
|
||||||
|
|
||||||
|
Code should import this local module directly. We no longer rely on a Vite alias for the old Split package.
|
||||||
|
|
||||||
|
## Current storage contract
|
||||||
|
|
||||||
|
The compatibility layer reads the active shop from Redux, then fetches DB-backed assignments from:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /feature-flags/bodyshops/:bodyshopId
|
||||||
|
```
|
||||||
|
|
||||||
|
That endpoint verifies the Firebase user can access the bodyshop through Hasura permissions, then returns cached Redis
|
||||||
|
data when present or refreshes from `feature_flags` + `bodyshop_feature_flags`.
|
||||||
|
|
||||||
|
On successful backend responses, the client stores the last-known flag payload in browser `localStorage` for the active
|
||||||
|
bodyshop. If the backend cannot be reached later, the client uses that bodyshop-scoped browser cache for up to 24 hours.
|
||||||
|
If there is no browser cache, unknown flags resolve to `"off"`.
|
||||||
|
|
||||||
|
Recommended backend payload shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"flags": {
|
||||||
|
"Enhanced_Payroll": {
|
||||||
|
"treatment": "on",
|
||||||
|
"config": null,
|
||||||
|
"activeDate": null,
|
||||||
|
"deactiveDate": null
|
||||||
|
},
|
||||||
|
"Demo_Feature": {
|
||||||
|
"treatment": "on",
|
||||||
|
"config": null,
|
||||||
|
"activeDate": "2026-06-01T13:00:00-04:00",
|
||||||
|
"deactiveDate": "2026-06-05T17:00:00-04:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported values:
|
||||||
|
|
||||||
|
- `true`, `"true"`, `1`, `"on"` -> treatment `"on"`
|
||||||
|
- `false`, `"false"`, `0`, `"off"` -> treatment `"off"`
|
||||||
|
- ISO-ish future date strings -> `"on"` until the date passes
|
||||||
|
- `{ "treatment": "on" | "off" | "control" | "any-custom-treatment", "config": ... }`
|
||||||
|
- Scheduled demo windows using `activeDate` and `deactiveDate`
|
||||||
|
|
||||||
|
Unknown flags default to `"off"`.
|
||||||
|
|
||||||
|
## Backend registry
|
||||||
|
|
||||||
|
Canonical feature flag definitions live in the Hasura-backed `feature_flags` table and are exposed to the admin panel
|
||||||
|
through `GET /adm/feature-flags`.
|
||||||
|
|
||||||
|
Per-shop assignments live in `bodyshop_feature_flags`. The admin panel reads them through
|
||||||
|
`GET /adm/bodyshops/:bodyshopId/feature-flags` and saves them through `POST /adm/updateshop`.
|
||||||
|
|
||||||
|
Hasura invalidates the Redis cache through `/feature-flags/cache/invalidate` when `bodyshop_feature_flags` or
|
||||||
|
`feature_flags` changes. Assignment changes clear the affected shop cache for the current cache version; definition
|
||||||
|
changes increment a global feature flag cache version so old per-shop cache entries become invisible and expire by TTL.
|
||||||
|
|
||||||
|
The backend also emits `feature-flags-changed` over the existing Socket.IO connection. `SocketProvider` bridges that
|
||||||
|
socket message to a browser event, and `SplitFactoryProvider` refetches flags when the event is global or matches the
|
||||||
|
active bodyshop. This keeps already-open tabs in sync with admin edits and Hasura-triggered invalidation.
|
||||||
|
|
||||||
|
For manual frontend testing, the global footer displays `Test Feature Flag Enabled` when `TEST_FLAG` resolves to
|
||||||
|
the `on` treatment.
|
||||||
411
client/src/feature-flags/splitio-react-replacement.jsx
Normal file
411
client/src/feature-flags/splitio-react-replacement.jsx
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { selectBodyshop } from "../redux/user/user.selectors";
|
||||||
|
|
||||||
|
const FeatureFlagContext = createContext({
|
||||||
|
config: {},
|
||||||
|
factory: null,
|
||||||
|
flags: {},
|
||||||
|
isReady: true,
|
||||||
|
source: "local"
|
||||||
|
});
|
||||||
|
|
||||||
|
const OFF_TREATMENT = Object.freeze({ treatment: "off", config: null });
|
||||||
|
const LOCAL_STORAGE_PREFIX = "bodyshop-feature-flags";
|
||||||
|
const LOCAL_STORAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const FEATURE_FLAGS_REFRESH_DEBOUNCE_MS = 150;
|
||||||
|
const MAX_SCHEDULE_REFRESH_DELAY_MS = 2_147_483_647;
|
||||||
|
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
||||||
|
const hasSchedule = (value) => value.activeDate != null || value.deactiveDate != null;
|
||||||
|
|
||||||
|
export const FEATURE_FLAGS_CHANGED_EVENT = "feature-flags-changed";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses optional schedule timestamps into comparable epoch milliseconds.
|
||||||
|
*/
|
||||||
|
const parseDate = (value) => {
|
||||||
|
if (value == null || value === "") return null;
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
return Number.isNaN(timestamp) ? null : timestamp;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether a scheduled feature flag assignment is active at the current time.
|
||||||
|
*/
|
||||||
|
const isWithinSchedule = (value) => {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return true;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const startsAt = parseDate(value.activeDate);
|
||||||
|
const endsAt = parseDate(value.deactiveDate);
|
||||||
|
|
||||||
|
if (startsAt != null && now < startsAt) return false;
|
||||||
|
if (endsAt != null && now >= endsAt) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes backend config values into the object/string/null shape Split hooks expect.
|
||||||
|
*/
|
||||||
|
const normalizeConfig = (config) => {
|
||||||
|
if (config == null || config === "") return null;
|
||||||
|
if (typeof config === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(config);
|
||||||
|
} catch {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts legacy boolean-ish values and custom treatment strings into a stable treatment value.
|
||||||
|
*/
|
||||||
|
const normalizeTreatment = (value) => {
|
||||||
|
if (typeof value === "boolean") return value ? "on" : "off";
|
||||||
|
if (typeof value === "number") return value > 0 ? "on" : "off";
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const normalized = value.trim();
|
||||||
|
const lowered = normalized.toLowerCase();
|
||||||
|
|
||||||
|
if (lowered === "true") return "on";
|
||||||
|
if (lowered === "false") return "off";
|
||||||
|
if (lowered === "on" || lowered === "off" || lowered === "control") return lowered;
|
||||||
|
|
||||||
|
const dateValue = Date.parse(normalized);
|
||||||
|
if (!Number.isNaN(dateValue)) return dateValue > Date.now() ? "on" : "off";
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value ? "on" : "off";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts any supported backend flag value into a Split-compatible treatment/config pair.
|
||||||
|
*/
|
||||||
|
const normalizeFlagValue = (value) => {
|
||||||
|
if (value == null) return OFF_TREATMENT;
|
||||||
|
|
||||||
|
if (typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
if (!isWithinSchedule(value)) return OFF_TREATMENT;
|
||||||
|
|
||||||
|
if (hasOwn(value, "treatment")) {
|
||||||
|
return {
|
||||||
|
treatment: normalizeTreatment(value.treatment),
|
||||||
|
config: normalizeConfig(value.config)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasOwn(value, "enabled")) {
|
||||||
|
return {
|
||||||
|
treatment: normalizeTreatment(value.enabled),
|
||||||
|
config: normalizeConfig(value.config)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSchedule(value)) {
|
||||||
|
return {
|
||||||
|
treatment: "on",
|
||||||
|
config: normalizeConfig(value.config)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
treatment: normalizeTreatment(value),
|
||||||
|
config: null
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a socket/browser feature-flag change event applies to the active bodyshop.
|
||||||
|
*/
|
||||||
|
const isFeatureFlagChangeRelevant = (detail, bodyshopId) => {
|
||||||
|
if (!detail || detail.scope === "global") return true;
|
||||||
|
if (!bodyshopId) return false;
|
||||||
|
return String(detail.bodyshopId) === String(bodyshopId);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the next scheduled flag boundary that should force a local re-render.
|
||||||
|
*/
|
||||||
|
const getNextScheduleRefreshDelay = (flags = {}, now = Date.now()) => {
|
||||||
|
const nextTimestamp = Object.values(flags).reduce((next, value) => {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return next;
|
||||||
|
|
||||||
|
const timestamps = [parseDate(value.activeDate), parseDate(value.deactiveDate)].filter(
|
||||||
|
(timestamp) => timestamp != null && timestamp > now
|
||||||
|
);
|
||||||
|
if (!timestamps.length) return next;
|
||||||
|
|
||||||
|
const candidate = Math.min(...timestamps);
|
||||||
|
return next == null ? candidate : Math.min(next, candidate);
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
if (nextTimestamp == null) return null;
|
||||||
|
|
||||||
|
return Math.min(Math.max(nextTimestamp - now + 50, 0), MAX_SCHEDULE_REFRESH_DELAY_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether browser localStorage can be used in the current runtime.
|
||||||
|
*/
|
||||||
|
const isBrowserStorageAvailable = () => typeof window !== "undefined" && window.localStorage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the browser cache key for one bodyshop's feature flags.
|
||||||
|
*/
|
||||||
|
const getLocalStorageKey = (bodyshopId) => `${LOCAL_STORAGE_PREFIX}:${bodyshopId}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a bodyshop-scoped last-known-good flag payload from browser storage.
|
||||||
|
*/
|
||||||
|
const readCachedFeatureFlags = (bodyshopId, now = Date.now()) => {
|
||||||
|
if (!bodyshopId || !isBrowserStorageAvailable()) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawValue = window.localStorage.getItem(getLocalStorageKey(bodyshopId));
|
||||||
|
if (!rawValue) return null;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(rawValue);
|
||||||
|
if (!parsed?.flags || typeof parsed.flags !== "object" || Array.isArray(parsed.flags)) return null;
|
||||||
|
const cachedAt = Date.parse(parsed.cachedAt);
|
||||||
|
if (!parsed.cachedAt || Number.isNaN(cachedAt) || now - cachedAt > LOCAL_STORAGE_MAX_AGE_MS) return null;
|
||||||
|
|
||||||
|
return parsed.flags;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists a successful backend flag payload for short-term browser fallback.
|
||||||
|
*/
|
||||||
|
const writeCachedFeatureFlags = (bodyshopId, flags) => {
|
||||||
|
if (!bodyshopId || !flags || !isBrowserStorageAvailable()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
getLocalStorageKey(bodyshopId),
|
||||||
|
JSON.stringify({
|
||||||
|
cachedAt: new Date().toISOString(),
|
||||||
|
flags
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable, full, or blocked. Runtime flags still work without the browser cache.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the local client object that mimics the Split client surface used by the app.
|
||||||
|
*/
|
||||||
|
const createFeatureFlagClient = ({ bodyshop, key, backendFlags }) => {
|
||||||
|
const attributes = {};
|
||||||
|
|
||||||
|
const getTreatmentWithConfig = (name) => normalizeFlagValue(backendFlags?.[name]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
client: null,
|
||||||
|
isReady: true,
|
||||||
|
isReadyFromCache: true,
|
||||||
|
key: key || bodyshop?.imexshopid || "anon",
|
||||||
|
getTreatment: (name) => getTreatmentWithConfig(name).treatment,
|
||||||
|
getTreatmentWithConfig,
|
||||||
|
getTreatments: (names = []) =>
|
||||||
|
names.reduce((acc, name) => {
|
||||||
|
acc[name] = getTreatmentWithConfig(name).treatment;
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
getTreatmentsWithConfig: (names = []) =>
|
||||||
|
names.reduce((acc, name) => {
|
||||||
|
acc[name] = getTreatmentWithConfig(name);
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
setAttribute: (name, value) => {
|
||||||
|
attributes[name] = value;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
setAttributes: (values = {}) => {
|
||||||
|
Object.assign(attributes, values);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
getAttribute: (name) => attributes[name],
|
||||||
|
getAttributes: () => ({ ...attributes }),
|
||||||
|
ready: () => Promise.resolve(),
|
||||||
|
on: () => {},
|
||||||
|
off: () => {},
|
||||||
|
destroy: () => {}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides database-backed feature flags through a Split-shaped React context.
|
||||||
|
*/
|
||||||
|
export function SplitFactoryProvider({ children, config, factory }) {
|
||||||
|
const bodyshop = useSelector(selectBodyshop);
|
||||||
|
const [state, setState] = useState({ flags: {}, isReady: true, source: "local" });
|
||||||
|
const loadIdRef = useRef(0);
|
||||||
|
const refreshTimerRef = useRef(null);
|
||||||
|
|
||||||
|
const loadFeatureFlags = useCallback(async () => {
|
||||||
|
const loadId = (loadIdRef.current += 1);
|
||||||
|
|
||||||
|
if (!bodyshop?.id) {
|
||||||
|
setState({ flags: {}, isReady: true, source: "local" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((current) => ({ ...current, isReady: false }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get(`/feature-flags/bodyshops/${bodyshop.id}`);
|
||||||
|
if (loadId !== loadIdRef.current) return;
|
||||||
|
const flags = data.flags || {};
|
||||||
|
writeCachedFeatureFlags(bodyshop.id, flags);
|
||||||
|
setState({
|
||||||
|
flags,
|
||||||
|
isReady: true,
|
||||||
|
source: data.source || "database"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (loadId !== loadIdRef.current) return;
|
||||||
|
const cachedFlags = readCachedFeatureFlags(bodyshop.id);
|
||||||
|
console.warn("Feature flags backend fetch failed; falling back to last-known browser cache.", error);
|
||||||
|
setState({
|
||||||
|
flags: cachedFlags || {},
|
||||||
|
isReady: true,
|
||||||
|
source: cachedFlags ? "browser-cache" : "local"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [bodyshop?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFeatureFlags();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
loadIdRef.current += 1;
|
||||||
|
};
|
||||||
|
}, [loadFeatureFlags]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bodyshop?.id) return undefined;
|
||||||
|
|
||||||
|
const handleFeatureFlagsChanged = (event) => {
|
||||||
|
if (!isFeatureFlagChangeRelevant(event.detail, bodyshop.id)) return;
|
||||||
|
|
||||||
|
if (refreshTimerRef.current) {
|
||||||
|
clearTimeout(refreshTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshTimerRef.current = setTimeout(() => {
|
||||||
|
refreshTimerRef.current = null;
|
||||||
|
loadFeatureFlags();
|
||||||
|
}, FEATURE_FLAGS_REFRESH_DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(FEATURE_FLAGS_CHANGED_EVENT, handleFeatureFlagsChanged);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(FEATURE_FLAGS_CHANGED_EVENT, handleFeatureFlagsChanged);
|
||||||
|
if (refreshTimerRef.current) {
|
||||||
|
clearTimeout(refreshTimerRef.current);
|
||||||
|
refreshTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [bodyshop?.id, loadFeatureFlags]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const delay = getNextScheduleRefreshDelay(state.flags);
|
||||||
|
if (delay == null) return undefined;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setState((current) => ({ ...current, flags: { ...current.flags } }));
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [state.flags]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ config, factory, flags: state.flags, isReady: state.isReady, source: state.source }),
|
||||||
|
[config, factory, state.flags, state.isReady, state.source]
|
||||||
|
);
|
||||||
|
return <FeatureFlagContext.Provider value={value}>{children}</FeatureFlagContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a Split-compatible client backed by the local feature flag context.
|
||||||
|
*/
|
||||||
|
export function useSplitClient(options = {}) {
|
||||||
|
const bodyshop = useSelector(selectBodyshop);
|
||||||
|
const context = useContext(FeatureFlagContext);
|
||||||
|
|
||||||
|
const client = useMemo(() => {
|
||||||
|
const nextClient = createFeatureFlagClient({
|
||||||
|
bodyshop,
|
||||||
|
key: options.key,
|
||||||
|
backendFlags: context.flags
|
||||||
|
});
|
||||||
|
nextClient.client = nextClient;
|
||||||
|
nextClient.isReady = context.isReady;
|
||||||
|
nextClient.isReadyFromCache = context.source === "redis" || context.source === "browser-cache";
|
||||||
|
return nextClient;
|
||||||
|
}, [bodyshop, options.key, context.flags, context.isReady, context.source]);
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns treatment/config pairs for several feature flags.
|
||||||
|
*/
|
||||||
|
export function useTreatmentsWithConfig({ names = [] } = {}) {
|
||||||
|
const client = useSplitClient();
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
treatments: client.getTreatmentsWithConfig(names),
|
||||||
|
isReady: client.isReady,
|
||||||
|
isReadyFromCache: client.isReadyFromCache,
|
||||||
|
lastUpdate: Date.now()
|
||||||
|
}),
|
||||||
|
[client, names]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns only the treatment string for one feature flag.
|
||||||
|
*/
|
||||||
|
export function useTreatment({ name } = {}) {
|
||||||
|
const client = useSplitClient();
|
||||||
|
return client.getTreatment(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the treatment/config pair for one feature flag.
|
||||||
|
*/
|
||||||
|
export function useTreatmentWithConfig({ name } = {}) {
|
||||||
|
const client = useSplitClient();
|
||||||
|
return client.getTreatmentWithConfig(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FeatureFlagProvider = SplitFactoryProvider;
|
||||||
|
export const useFeatureFlagClient = useSplitClient;
|
||||||
|
export const SplitContext = FeatureFlagContext;
|
||||||
|
export const useSplitContext = () => useContext(FeatureFlagContext);
|
||||||
|
|
||||||
|
export const __featureFlagTesting = {
|
||||||
|
createFeatureFlagClient,
|
||||||
|
getNextScheduleRefreshDelay,
|
||||||
|
getLocalStorageKey,
|
||||||
|
isFeatureFlagChangeRelevant,
|
||||||
|
normalizeFlagValue,
|
||||||
|
readCachedFeatureFlags,
|
||||||
|
writeCachedFeatureFlags
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user