Compare commits

..

82 Commits

Author SHA1 Message Date
Dave Richer
045f36e294 Merged in release/2026-06-05 (pull request #3288)
IO-3722 Remove delivery date for bypass vehicles.
2026-05-28 19:23:09 +00:00
Dave Richer
c7c6dfcd7d Merged in feature/IO-3722-disable-contact-fortellis (pull request #3287)
IO-3722 Remove delivery date for bypass vehicles.
2026-05-28 19:22:53 +00:00
Dave Richer
c024fdd57b Merged in release/2026-06-05 (pull request #3285)
Release/2026 06 05
2026-05-28 16:56:04 +00:00
Dave Richer
a4ccacf83a Merged in feature/IO-3722-disable-contact-fortellis (pull request #3284)
IO-3722 Remove customer lookup by Vehicle Owner.
2026-05-28 16:55:39 +00:00
Patrick Fic
fdaf50d778 Merged in feature/IO-3722-disable-contact-fortellis (pull request #3282)
Feature/IO-3722 disable contact fortellis
2026-05-27 21:48:17 +00:00
Patrick Fic
b887cfed01 Merged in feature/IO-3722-disable-contact-fortellis (pull request #3278)
IO-3722 Add additional await.
2026-05-27 19:41:41 +00:00
Dave Richer
6cce92b0fd Merged in release/2026-06-05 (pull request #3276)
IO-3722 Disable contact API calls for Fortellis.
2026-05-27 18:29:33 +00:00
Dave Richer
60ab04cb38 Merged in feature/IO-3722-disable-contact-fortellis (pull request #3275)
IO-3722 Disable contact API calls for Fortellis.
2026-05-27 18:29:14 +00:00
Dave Richer
0025e113c6 Merged in release/2026-06-05 (pull request #3273)
feature/IO-3541-Parts-Dispatch-Return-Data - Add Refetech queries to keep data in sync
2026-05-26 16:25:53 +00:00
Dave Richer
dc435b2bb0 Merged in feature/IO-3541-Parts-Dispatch-Return-Data (pull request #3272)
feature/IO-3541-Parts-Dispatch-Return-Data - Add Refetech queries to keep data in sync
2026-05-26 16:25:23 +00:00
Dave
fd72d244e7 feature/IO-3541-Parts-Dispatch-Return-Data - Add Refetech queries to keep data in sync 2026-05-26 12:24:56 -04:00
Dave Richer
87bb472271 Merged in release/2026-06-05 (pull request #3271)
feature/IO-2960-Employee-Email-Info - Fix
2026-05-26 16:18:15 +00:00
Dave Richer
825959880e Merged in feature/IO-2960-Employee-Email-Info (pull request #3270)
feature/IO-2960-Employee-Email-Info - Fix
2026-05-26 16:17:55 +00:00
Dave
c40fea0ec9 feature/IO-2960-Employee-Email-Info - Fix 2026-05-26 12:17:25 -04:00
Dave Richer
ebdf427b58 Merged in release/2026-06-05 (pull request #3269)
feature/IO-3567-New-Job-Line-Tab - Fix
2026-05-26 16:06:29 +00:00
Dave Richer
b3fdd68276 Merged in feature/IO-3567-New-Job-Line-Tab (pull request #3268)
feature/IO-3567-New-Job-Line-Tab - Fix
2026-05-26 16:06:02 +00:00
Dave
30e5027c8c feature/IO-3567-New-Job-Line-Tab - Fix 2026-05-26 12:05:31 -04:00
Dave Richer
3e63c58b9b Merged in release/2026-06-05 (pull request #3267)
release/2026-06-05 - Esignture Banner
2026-05-26 15:49:49 +00:00
Dave
938cef1f6b release/2026-06-05 - Esignture Banner 2026-05-26 11:49:08 -04:00
Dave Richer
7e2df3e341 Merged in release/2026-06-05 (pull request #3266)
release/2026-06-05 - Fix Documenso
2026-05-25 20:43:05 +00:00
Dave
45d095a7a3 release/2026-06-05 - Fix Documenso 2026-05-25 16:42:23 -04:00
Dave Richer
709b6ef1d6 Merged in release/2026-06-05 (pull request #3265)
release/2026-06-05 - Package updates, Esig Fixes, Ant tweak for upgrade
2026-05-25 19:52:15 +00:00
Dave
4e98df6694 release/2026-06-05 - Package updates, Esig Fixes, Ant tweak for upgrade 2026-05-25 15:51:37 -04:00
Dave Richer
b920bb4437 Merged in release/2026-06-05 (pull request #3264)
Release/2026 06 05
2026-05-25 19:06:02 +00:00
Dave Richer
e36a110e81 Merged in feature/IO-3713-Esign-Modal-UI (pull request #3263)
Feature/IO-3713 Esign Modal UI
2026-05-25 19:05:41 +00:00
Dave
719d1b6479 Merge remote-tracking branch 'origin/release/2026-06-05' into feature/IO-3713-Esign-Modal-UI 2026-05-25 15:04:55 -04:00
Dave
29ded5efbf feature/IO-3713-Esign-Modal-UI - Fix 2026-05-25 15:01:46 -04:00
Dave
551e0f0592 feature/IO-3713-Esign-Modal-UI - Fix 2026-05-25 15:00:05 -04:00
Dave Richer
4d299bb226 Merged in release/2026-06-05 (pull request #3262)
feature/IO-3701-Harness-Replacement - Implement
2026-05-25 15:44:29 +00:00
Dave Richer
ae9b68a0bc Merged in feature/IO-3701-Harness-Replacement (pull request #3261)
feature/IO-3701-Harness-Replacement - Implement
2026-05-25 15:44:04 +00:00
Dave
cf8df89e30 Merge remote-tracking branch 'origin/release/2026-06-05' into feature/IO-3701-Harness-Replacement 2026-05-25 11:42:54 -04:00
Dave
bfd6cc83af Merge remote-tracking branch 'origin/master-AIO' into feature/IO-3701-Harness-Replacement 2026-05-25 11:29:15 -04:00
Dave Richer
99b65e8186 Merged in release/2026-06-05 (pull request #3260)
feature/IO-3714-Esignature-Lock - Add Lock to Esignatures
2026-05-25 15:27:36 +00:00
Dave Richer
f8fd2ee64c Merged in feature/IO-3714-Esignature-Lock (pull request #3259)
feature/IO-3714-Esignature-Lock - Add Lock to Esignatures
2026-05-25 15:26:49 +00:00
Dave
8240ea9a64 feature/IO-3714-Esignature-Lock - Add Lock to Esignatures 2026-05-25 11:24:55 -04:00
Dave
85b3b88538 Merge remote-tracking branch 'origin/release/2026-05-22' into feature/IO-3701-Harness-Replacement 2026-05-21 13:44:38 -04:00
Dave Richer
426283ffee Merged in release/2026-05-22 (pull request #3256)
IO-3710 Visual Board Vehicle Color
2026-05-20 23:57:48 +00:00
Dave Richer
4fc86ccaa3 Merged in release/2026-05-22 (pull request #3254)
release/2026-05-22 - Remove uncessary require
2026-05-20 20:12:07 +00:00
Dave
519997a8be Merge remote-tracking branch 'origin/release/2026-05-22' into feature/IO-3701-Harness-Replacement 2026-05-20 14:47:17 -04:00
Dave
c4c36b7fd0 Merge remote-tracking branch 'origin/release/2026-05-22' into feature/IO-3701-Harness-Replacement 2026-05-20 14:42:12 -04:00
Dave
deb2fc28ce feature/IO-3701-Harness-Replacement - Implement 2026-05-20 14:41:24 -04:00
Dave Richer
a67946c5a3 Merged in release/2026-05-22 (pull request #3253)
IO-3712 Disable analytics in client side.
2026-05-20 18:11:11 +00:00
Dave Richer
e43923b7a0 Merged in release/2026-05-22 (pull request #3251)
Release/2026 05 22
2026-05-20 16:54:53 +00:00
Dave Richer
e9ef429729 Merged in release/2026-05-22 (pull request #3247)
Release/2026 05 22
2026-05-14 17:56:22 +00:00
Dave Richer
db01ad9155 Merged in release/2026-05-22 (pull request #3245)
Release/2026 05 22
2026-05-13 16:10:49 +00:00
Allan Carr
8bf7fbd1f1 Merged in release/2026-05-22 (pull request #3241)
IO-3691 Job Totals Issues
2026-05-12 15:42:36 +00:00
Dave Richer
c37037ef21 Merged in release/2026-05-22 (pull request #3238)
hotfix/2020hotfix/2026-05-11 - Fix so polling throws error on missing env var (logs error), and does not start poll, vs starting polling and logging error every 10 seconds
2026-05-11 21:26:40 +00:00
Dave Richer
6050aebcd5 Merged in release/2026-05-08 (pull request #3235)
IO-3689 Customer List Restriction
2026-05-08 18:45:38 +00:00
Dave Richer
77d0f5ab38 Merged in release/2026-05-08 (pull request #3233)
feature/IO-3688-Searchable-Referral-Source - Implement (convert button)
2026-05-08 15:43:23 +00:00
Dave Richer
a0692f8c69 Merged in release/2026-05-08 (pull request #3231)
feature/IO-3688-Searchable-Referral-Source - Implement
2026-05-08 14:42:36 +00:00
Dave Richer
4f76aeb06f Merged in release/2026-05-08 (pull request #3229)
feature/IO-3679-Tech-Console-Null-Error - fix
2026-05-07 14:43:08 +00:00
Dave Richer
302a42089f Merged in release/2026-05-08 (pull request #3227)
IO-3686 River city enhancements for AR customers and Contact Code
2026-05-07 14:05:14 +00:00
Dave Richer
906265c4b2 Merged in release/2026-05-08 (pull request #3226)
feature/IO-3687-Grey-Scale-Invisible-text - implement
2026-05-06 20:46:31 +00:00
Dave Richer
388b042037 Merged in release/2026-05-08 (pull request #3223)
Release/2026 05 08
2026-05-06 15:48:38 +00:00
Dave Richer
73eb76a230 Merged in release/2026-05-08 (pull request #3220)
feature/IO-3672-Reynolds-Adjustments-V3 - Expand Export logs for Reynolds
2026-05-05 17:31:56 +00:00
Dave Richer
d5e9b79f75 Merged in release/2026-05-08 (pull request #3218)
feature/IO-3672-Reynolds-Adjustments-V3 - Hide DMS Posting sheet report in reynolds mode.
2026-05-04 21:09:10 +00:00
Dave Richer
56d0c009e2 Merged in release/2026-05-08 (pull request #3216)
feature/IO-3672-Reynolds-Adjustments-V3 - Make sure there is never a scenario where a ROGOG does not have a ROLABOR
2026-05-04 20:33:08 +00:00
Dave Richer
79030f6b36 Merged in release/2026-05-08 (pull request #3214)
feature/IO-3674-Fix-Save-And-New - Fix Save and New so state gets reset on form when starting from a new employee
2026-05-04 20:13:21 +00:00
Dave Richer
5e78cdd8ae Merged in release/2026-05-08 (pull request #3210)
Release/2026 05 08
2026-04-29 16:46:47 +00:00
Dave Richer
8f4ac866f1 Merged in release/2026-05-08 (pull request #3205)
hotfix/2026-04-28 - Add Label, fix exported
2026-04-28 17:52:54 +00:00
Dave Richer
9ad2a53bec Merged in release/2026-05-08 (pull request #3202)
hotfix/2026-04-21 - fix Parts order comments
2026-04-22 16:44:10 +00:00
Dave Richer
6590f8961b Merged in release/2026-05-08 (pull request #3200)
feature/IO-3647-Reynolds-Integration-Phase-2-Optional - Add option to make 'Enhanced Early ROS' optional
2026-04-21 14:52:31 +00:00
Dave Richer
7df71b8f44 Merged in release/2026-05-08 (pull request #3198)
hotfix/2026-04-21 - Fix save dirty state on employees causing prompt, add 'save and new'
2026-04-21 14:29:55 +00:00
Dave Richer
4776b03a21 Merged in release/2026-05-08 (pull request #3194)
hotfix/2026-04-20 - Remove item from Cost centers
2026-04-20 15:41:47 +00:00
Dave Richer
20943f74e9 Merged in release/2026-04-17 (pull request #3190)
feature/IO-3647-Reynolds-Integration-Phase-2 - Enhance early RO with meaningful amounts.
2026-04-13 14:41:37 +00:00
Dave Richer
4af312854e Merged in release/2026-04-17 (pull request #3187)
hotfix/2026-04-10 - Fix Location Identifier in chatter-api
2026-04-10 15:41:08 +00:00
Dave Richer
ff084f6fb8 Merged in release/2026-04-17 (pull request #3185)
feature/IO-3638-Reynolds-OpenSearch - Add Search on DMS id in Reynolds shops
2026-04-09 15:16:48 +00:00
Dave Richer
5c9e4517a6 Merged in release/2026-04-17 (pull request #3183)
Release/2026 04 17
2026-04-08 18:03:10 +00:00
Dave Richer
190217ffce Merged in release/2026-04-17 (pull request #3181)
Release/2026 04 17
2026-04-03 01:56:19 +00:00
Dave Richer
28dc1d4533 Merged in release/2026-04-03 (pull request #3178)
Merged in feature/IO-3637-DMS-ID-Production-Board-Column (pull request #3175)
2026-04-03 01:37:45 +00:00
Allan Carr
a97e03e0b1 Merged in feature/IO-3637-DMS-ID-Production-Board-Column (pull request #3176)
Feature/IO-3637 DMS ID Production Board Column
2026-04-02 23:07:28 +00:00
Dave Richer
e30353cab6 Merged in release/2026-04-03 (pull request #3171)
Release/2026 04 03
2026-03-31 20:19:00 +00:00
Dave Richer
c9b9f67170 Merged in release/2026-04-03 (pull request #3168)
Release/2026 04 03
2026-03-30 19:08:50 +00:00
Dave Richer
4a47f543b2 Merged in release/2026-04-03 (pull request #3164)
IO-3629 PostBatchWip Rtn != 0 error
2026-03-30 15:07:33 +00:00
Dave Richer
3b60aa89f1 Merged in release/2026-04-03 (pull request #3160)
Release/2026 04 03
2026-03-27 18:48:51 +00:00
Dave Richer
20d2572087 Merged in release/2026-04-03 (pull request #3157)
Release/2026 04 03
2026-03-25 22:36:49 +00:00
Dave Richer
ac4c09af60 Merged in release/2026-04-03 (pull request #3154)
Release/2026 04 03
2026-03-24 17:50:29 +00:00
Dave Richer
6a60af9dfe Merged in release/2026-04-03 (pull request #3150)
Release/2026 04 03
2026-03-23 17:05:19 +00:00
Dave Richer
dfb6f02864 Merged in release/2026-04-03 (pull request #3148)
Fix RR
2026-03-20 18:56:28 +00:00
Dave Richer
48bb494e0f Merged in release/2026-04-03 (pull request #3146)
IO-3515 Add shopname to bill ai feedback.
2026-03-20 18:16:04 +00:00
Dave Richer
9b74cba56b Merged in release/2026-04-03 (pull request #3144)
Release/2026 04 03
2026-03-19 22:44:56 +00:00
Dave Richer
6fc8124268 Merged in release/2026-04-03 (pull request #3141)
Release/2026 04 03
2026-03-19 18:47:24 +00:00
117 changed files with 7663 additions and 4351 deletions

View File

@@ -7,6 +7,7 @@ _reference
client
redis/dockerdata
hasura
harness-feature-flags-export
node_modules
# Files to exclude
.ebignore

View File

@@ -7,6 +7,7 @@
/client
/firebase
/hasura
/harness-feature-flags-export
/jsreport
/node_modules
.env.local

6
.gitignore vendored
View File

@@ -17,6 +17,9 @@ jsreport/auth-server/node_modules
client/coverage
admin/coverage
# Generated Harness/Split feature flag export artifacts
/harness-feature-flags-export/
# production
/build
client/build
@@ -153,4 +156,5 @@ docker_data
.terraform
terraform.tfvars
terraform.tfvars
terraform.exe

1297
_reference/feature-flags.md Normal file

File diff suppressed because it is too large Load Diff

3193
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,62 +8,61 @@
"private": true,
"proxy": "http://localhost:4000",
"dependencies": {
"@amplitude/analytics-browser": "^2.38.0",
"@amplitude/analytics-browser": "^2.42.4",
"@ant-design/pro-layout": "^7.22.6",
"@apollo/client": "^4.1.6",
"@apollo/client": "^4.2.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@documenso/embed-react": "^0.5.1",
"@documenso/embed-react": "^0.6.1",
"@emotion/is-prop-valid": "^1.4.0",
"@fingerprintjs/fingerprintjs": "^5.1.0",
"@firebase/analytics": "^0.10.21",
"@firebase/app": "^0.14.10",
"@firebase/auth": "^1.12.2",
"@firebase/firestore": "^4.13.0",
"@firebase/messaging": "^0.12.25",
"@fingerprintjs/fingerprintjs": "^5.2.0",
"@firebase/analytics": "^0.10.22",
"@firebase/app": "^0.14.12",
"@firebase/auth": "^1.13.1",
"@firebase/firestore": "^4.14.1",
"@firebase/messaging": "^0.12.26",
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.11.2",
"@sentry/cli": "^3.3.5",
"@sentry/react": "^10.47.0",
"@reduxjs/toolkit": "^2.12.0",
"@sentry/cli": "^3.4.3",
"@sentry/react": "^10.53.1",
"@sentry/vite-plugin": "^4.9.1",
"@splitsoftware/splitio-react": "^2.6.1",
"@tanem/react-nprogress": "^5.0.63",
"antd": "^6.3.5",
"antd": "^6.4.3",
"apollo-link-logger": "^3.0.0",
"autosize": "^6.0.1",
"axios": "^1.14.0",
"axios": "^1.16.1",
"classnames": "^2.5.1",
"css-box-model": "^1.2.1",
"dayjs": "^1.11.20",
"dayjs-business-days2": "^1.3.3",
"dinero.js": "^1.9.1",
"dotenv": "^17.3.1",
"dotenv": "^17.4.2",
"env-cmd": "^11.0.0",
"exifr": "^7.1.3",
"graphql": "^16.13.2",
"graphql": "^16.14.0",
"graphql-ws": "^6.0.8",
"i18next": "^25.10.10",
"i18next-browser-languagedetector": "^8.2.1",
"immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.12.41",
"libphonenumber-js": "^1.13.3",
"lightningcss": "^1.32.0",
"logrocket": "^12.1.0",
"logrocket": "^12.1.1",
"markerjs2": "^2.32.7",
"memoize-one": "^6.0.0",
"normalize-url": "^8.1.1",
"object-hash": "^3.0.0",
"phone": "^3.1.71",
"posthog-js": "^1.364.4",
"posthog-js": "^1.376.0",
"prop-types": "^15.8.1",
"query-string": "^9.3.1",
"raf-schd": "^4.0.3",
"react": "^19.2.4",
"react": "^19.2.6",
"react-big-calendar": "^1.19.4",
"react-color": "^2.19.3",
"react-cookie": "^8.1.0",
"react-dom": "^19.2.4",
"react-cookie": "^8.1.2",
"react-dom": "^19.2.6",
"react-grid-gallery": "^1.0.1",
"react-grid-layout": "^2.2.3",
"react-i18next": "^16.6.6",
@@ -73,22 +72,22 @@
"react-number-format": "^5.4.5",
"react-popopo": "^2.1.9",
"react-product-fruits": "^2.2.62",
"react-redux": "^9.2.0",
"react-redux": "^9.3.0",
"react-resizable": "^3.1.3",
"react-router-dom": "^7.13.2",
"react-router-dom": "^7.15.1",
"react-sticky": "^6.0.3",
"react-virtuoso": "^4.18.3",
"react-virtuoso": "^4.18.7",
"recharts": "^3.8.1",
"redux": "^5.0.1",
"redux-actions": "^3.0.3",
"redux-persist": "^6.0.0",
"redux-saga": "^1.4.2",
"redux-saga": "^1.5.0",
"redux-state-sync": "^3.1.4",
"reselect": "^5.1.1",
"reselect": "^5.2.0",
"rxjs": "^7.8.2",
"sass": "^1.98.0",
"sass": "^1.100.0",
"socket.io-client": "^4.8.3",
"styled-components": "^6.3.12",
"styled-components": "^6.4.2",
"vite-plugin-ejs": "^1.7.0",
"web-vitals": "^5.2.0"
},
@@ -138,14 +137,14 @@
"@rollup/rollup-linux-x64-gnu": "4.6.1"
},
"devDependencies": {
"@ant-design/icons": "^6.1.1",
"@ant-design/icons": "^6.2.3",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.28.5",
"@dotenvx/dotenvx": "^1.59.1",
"@babel/preset-react": "^7.29.7",
"@dotenvx/dotenvx": "^1.68.1",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.39.2",
"@playwright/test": "^1.58.2",
"@playwright/test": "^1.60.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -157,21 +156,21 @@
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"globals": "^17.4.0",
"globals": "^17.6.0",
"jsdom": "^28.1.0",
"memfs": "^4.57.1",
"memfs": "^4.57.2",
"os-browserify": "^0.3.0",
"playwright": "^1.58.2",
"playwright": "^1.60.0",
"react-error-overlay": "^6.1.0",
"redux-logger": "^3.0.6",
"source-map-explorer": "^2.5.3",
"vite": "^7.3.1",
"vite-plugin-babel": "^1.6.0",
"vite-plugin-babel": "^1.7.3",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-node-polyfills": "^0.26.0",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-node-polyfills": "^0.28.0",
"vite-plugin-pwa": "^1.3.0",
"vite-plugin-style-import": "^2.0.0",
"vitest": "^4.1.2",
"workbox-window": "^7.4.0"
"vitest": "^4.1.7",
"workbox-window": "^7.4.1"
}
}

View File

@@ -5593,29 +5593,6 @@ Demo: https://rawgit.com/Sphinxxxx/color-conversion/master/demo/index.html
-----------
The following NPM packages may be included in this product:
- @splitsoftware/splitio-commons@1.6.1
- @splitsoftware/splitio-react@1.7.1
These packages each contain the following license and notice below:
Copyright © 2022 Split Software, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-----------
The following NPM packages may be included in this product:
- @stripe/react-stripe-js@1.9.0

View File

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

View File

@@ -1,6 +1,6 @@
import { ApolloProvider } from "@apollo/client/react";
import * as Sentry from "@sentry/react";
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
import { FeatureFlagProvider, useFeatureFlagClient } from "../feature-flags/splitio-react-replacement";
import { ConfigProvider } from "antd";
import enLocale from "antd/es/locale/en_US";
import { useEffect, useMemo } from "react";
@@ -16,23 +16,21 @@ import client from "../utils/GraphQLClient";
import App from "./App";
import getTheme from "./themeProvider";
// Base Split configuration
const config = {
core: {
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
key: "anon"
}
};
function SplitClientProvider({ children }) {
function FeatureFlagClientProvider({ children }) {
const imexshopid = useSelector((state) => state.user.imexshopid);
const splitClient = useSplitClient({ key: imexshopid || "anon" });
const featureFlagClient = useFeatureFlagClient({ key: imexshopid || "anon" });
useEffect(() => {
if (import.meta.env.DEV && splitClient && imexshopid) {
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
if (import.meta.env.DEV && featureFlagClient && imexshopid) {
console.log(`Feature flag client initialized with key: ${imexshopid}, isReady: ${featureFlagClient.isReady}`);
}
}, [splitClient, imexshopid]);
}, [featureFlagClient, imexshopid]);
return children;
}
@@ -124,11 +122,11 @@ function AppContainer() {
<ApolloProvider client={client}>
<ConfigProvider input={antdInput} locale={enLocale} theme={theme} form={antdForm}>
<GlobalLoadingBar />
<SplitFactoryProvider config={config}>
<SplitClientProvider>
<FeatureFlagProvider config={config}>
<FeatureFlagClientProvider>
<App />
</SplitClientProvider>
</SplitFactoryProvider>
</FeatureFlagClientProvider>
</FeatureFlagProvider>
</ConfigProvider>
</ApolloProvider>
</CookiesProvider>

View File

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

View File

@@ -1,4 +1,4 @@
import { useSplitClient } from "@splitsoftware/splitio-react";
import { useSplitClient } from "../feature-flags/splitio-react-replacement";
import { Button, Result } from "antd";
//import LogRocket from "logrocket";
import { lazy, Suspense, useEffect, useState } from "react";
@@ -225,13 +225,22 @@ export function App({
path="/parts/*"
element={
<ErrorBoundary>
<PrivateRoute isAuthorized={currentUser.authorized} />
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
<PrivateRoute isAuthorized={currentUser.authorized} />
</SocketProvider>
</ErrorBoundary>
}
>
<Route path="*" element={<SimplifiedPartsPageContainer />} />
</Route>
<Route path="/edit/*" element={<PrivateRoute isAuthorized={currentUser.authorized} />}>
<Route
path="/edit/*"
element={
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
<PrivateRoute isAuthorized={currentUser.authorized} />
</SocketProvider>
}
>
<Route path="*" element={<DocumentEditorContainer />} />
</Route>
</Routes>

View File

@@ -512,7 +512,52 @@
.esignature-embed {
width: 100%;
height: 100%;
border-width: 0;
}
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;
}
}

View File

@@ -37,6 +37,7 @@ const defaultTheme = (isDarkMode) => ({
isDarkMode
),
colorError: isDarkMode ? "#ff4d4f" : "#f5222d",
colorShadow: isDarkMode ? "rgba(0, 0, 0, 0.7)" : "#000000",
colorBgBase: isDarkMode ? "#1f1f1f" : "#ffffff" // Align with Ant Design dark mode
}
});

View File

@@ -1,5 +1,5 @@
import { useApolloClient, useMutation } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Checkbox, Divider, Form, Modal, Space } from "antd";
import _ from "lodash";
import { useEffect, useMemo, useRef, useState } from "react";

View File

@@ -1,6 +1,6 @@
import Icon, { UploadOutlined } from "@ant-design/icons";
import { useApolloClient } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Alert, Divider, Form, Input, Select, Space, Statistic, Switch, Upload } from "antd";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -1,5 +1,5 @@
import { useLazyQuery, useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { QUERY_OUTSTANDING_INVENTORY } from "../../graphql/inventory.queries";

View File

@@ -1,5 +1,5 @@
import { DeleteFilled, DollarCircleFilled } from "@ant-design/icons";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Checkbox, Form, Input, InputNumber, Select, Space, Switch, Table, Tooltip } from "antd";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
@@ -36,6 +36,7 @@ export function BillEnterModalLinesComponent({
const { t } = useTranslation();
const { setFieldsValue, getFieldsValue, getFieldValue } = form;
const firstFieldRefs = useRef({});
const lineDescriptionRefs = useRef({});
const CONTROL_HEIGHT = 32;
@@ -94,6 +95,23 @@ export function BillEnterModalLinesComponent({
});
};
const focusLineDescription = (index) => {
const lineDescription = lineDescriptionRefs.current[index];
if (typeof lineDescription?.focus === "function") {
lineDescription.focus({ preventScroll: true });
return;
}
lineDescription?.resizableTextArea?.textArea?.focus?.({ preventScroll: true });
};
const focusJobLineSelect = (index) => {
window.setTimeout(() => {
firstFieldRefs.current[index]?.focus?.({ preventScroll: true });
}, 0);
};
// Only fill actual_cost when the user forward-tabs out of Retail (actual_price)
const autofillActualCost = (index) => {
if (bodyshop.accountingconfig?.disableBillCostCalculation) return;
@@ -195,6 +213,12 @@ export function BillEnterModalLinesComponent({
minHeight: `${CONTROL_HEIGHT}px`
}}
allowRemoved={form.getFieldValue("is_credit_memo") || false}
onInputKeyDown={(event) => {
if (event.key !== "Tab" || event.shiftKey || event.defaultPrevented) return;
event.preventDefault();
focusLineDescription(index);
}}
onSelect={(value, opt) => {
// IMPORTANT:
// Do NOT autofill actual_cost here. It should only fill when the user forward-tabs
@@ -221,6 +245,7 @@ export function BillEnterModalLinesComponent({
};
})
});
focusJobLineSelect(index);
}}
/>
)
@@ -236,7 +261,16 @@ export function BillEnterModalLinesComponent({
label: t("billlines.fields.line_desc"),
rules: [{ required: true }]
}),
formInput: () => <Input.TextArea disabled={disabled} autoSize tabIndex={0} />
formInput: (record, index) => (
<Input.TextArea
ref={(el) => {
lineDescriptionRefs.current[index] = el;
}}
disabled={disabled}
autoSize
tabIndex={0}
/>
)
},
{

View File

@@ -8,7 +8,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import GlobalSearch from "../global-search/global-search.component";
import GlobalSearchOs from "../global-search/global-search-os.component";
import "./breadcrumbs.styles.scss";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
breadcrumbs: selectBreadcrumbs,

View File

@@ -1,6 +1,6 @@
import { PictureFilled } from "@ant-design/icons";
import { useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Badge, Popover } from "antd";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,

View File

@@ -26,7 +26,7 @@ import DmsCdkMakesRefetch from "../dms-cdk-makes/dms-cdk-makes.refetch.component
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import { DMS_MAP } from "../../utils/dmsUtils";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
/**
* CDK-like DMS post form:

View File

@@ -9,7 +9,7 @@ import AlertComponent from "../alert/alert.component";
import JobDocumentsGalleryExternal from "../jobs-documents-gallery/jobs-documents-gallery.external.component";
import JobsDocumentsLocalGalleryExternalComponent from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import JobsDocumentImgproxyGalleryExternal from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component";
const mapStateToProps = createStructuredSelector({

View File

@@ -24,16 +24,28 @@ const mapDispatchToProps = (dispatch) => ({
)
});
export function EsignatureCustomDocument({ bodyshop, jobId, setEsignatureContext }) {
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 (!hasDocumensoApiKey(bodyshop)) {
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);
@@ -78,11 +90,12 @@ export function EsignatureCustomDocument({ bodyshop, jobId, setEsignatureContext
return Upload.LIST_IGNORE;
}}
customRequest={uploadCustomDocument}
disabled={isDisabled}
maxCount={1}
showUploadList={false}
multiple={false}
>
<Button icon={<UploadOutlined />} loading={loading}>
<Button disabled={isDisabled} icon={<UploadOutlined />} loading={loading}>
{t("esignature.actions.upload_document")}
</Button>
</Upload>

View File

@@ -26,6 +26,7 @@ export function EsignatureModalContainer({ esignatureModal, toggleModalVisible,
const { open, context } = esignatureModal;
const { token, envelopeId, documentId, jobid } = context;
const [distributing, setDistributing] = useState(false);
const hasToken = Boolean(token);
if (!hasDocumensoApiKey(bodyshop)) {
return null;
@@ -39,6 +40,10 @@ export function EsignatureModalContainer({ esignatureModal, toggleModalVisible,
rome: t("jobs.labels.esignature_rome")
})}
onOk={async () => {
if (!hasToken) {
return;
}
try {
setDistributing(true);
await axios.post("/esign/distribute", {
@@ -58,6 +63,11 @@ export function EsignatureModalContainer({ esignatureModal, toggleModalVisible,
setDistributing(false);
}}
onCancel={async () => {
if (!hasToken) {
toggleModalVisible();
return;
}
try {
await axios.post("/esign/delete", {
documentId,
@@ -73,13 +83,15 @@ export function EsignatureModalContainer({ esignatureModal, toggleModalVisible,
});
}
}}
okButtonProps={{ loading: distributing }}
okButtonProps={{ loading: distributing, style: hasToken ? undefined : { display: "none" } }}
okText={t("esignature.actions.distribute")}
destroyOnHidden
width={"80%"}
width="calc(100vw - 32px)"
wrapClassName="esignature-modal"
styles={{ body: { overflow: "hidden", padding: 0 } }}
>
<div style={{ height: "80vh", width: "100%" }}>
{token ? (
<div className="esignature-modal-frame">
{hasToken ? (
<EmbedUpdateDocumentV1
presignToken={token}
host="https://sign.imex.online"

View File

@@ -5,6 +5,7 @@ import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import WssStatusDisplayComponent from "../../components/wss-status-display/wss-status-display.component.jsx";
import { useTreatment } from "../../feature-flags/splitio-react-replacement.jsx";
import { selectIsPartsEntry } from "../../redux/application/application.selectors.js";
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
@@ -16,6 +17,12 @@ const mapStateToProps = createStructuredSelector({
export function GlobalFooter({ isPartsEntry }) {
const { t } = useTranslation();
const testFlagTreatment = useTreatment({ name: "TEST_FLAG" });
const testFlagEnabled = testFlagTreatment === "on";
const testFlagIndicator = testFlagEnabled ? (
<div style={{ fontWeight: 600, marginTop: 4 }}>Test Feature Flag Enabled</div>
) : null;
if (isPartsEntry) {
return (
@@ -38,6 +45,7 @@ export function GlobalFooter({ isPartsEntry }) {
<Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}>
Disclaimer & Notices
</Link>
{testFlagIndicator}
</div>
</Footer>
);
@@ -74,6 +82,7 @@ export function GlobalFooter({ isPartsEntry }) {
<Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}>
Disclaimer & Notices
</Link>
{testFlagIndicator}
</div>
</Footer>
);

View File

@@ -2,7 +2,7 @@
import { BellFilled } from "@ant-design/icons";
import { useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Badge, Layout, Menu, Spin, Tooltip } from "antd";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -1,5 +1,5 @@
import { useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { GET_LINE_TICKET_BY_PK } from "../../graphql/jobs-lines.queries";

View File

@@ -1,5 +1,5 @@
import { useApolloClient } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Popconfirm } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -30,7 +30,7 @@ import JobLinesBillRefernece from "../job-lines-bill-reference/job-lines-bill-re
// import AllocationsAssignmentContainer from "../allocations-assignment/allocations-assignment.container";
// import AllocationsBulkAssignmentContainer from "../allocations-bulk-assignment/allocations-bulk-assignment.container";
// import AllocationsEmployeeLabelContainer from "../allocations-employee-label/allocations-employee-label.container";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import _ from "lodash";
import { FaTasks } from "react-icons/fa";
import { selectAuthLevel, selectBodyshop } from "../../redux/user/user.selectors";

View File

@@ -63,7 +63,9 @@ export function JobLineDispatchButton({
}
}
//joblineids: selectedLines.map((l) => l.id),
}
},
refetchQueries: ["QUERY_PARTS_BILLS_BY_JOBID", "GET_JOB_BY_PK"],
awaitRefetchQueries: true
});
if (result.errors) {
console.log("🚀 ~ handleConvert ~ result.errors:", result.errors);

View File

@@ -1,4 +1,4 @@
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Form, Input, InputNumber, Modal, Select, Switch } from "antd";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -1,5 +1,5 @@
import { useMutation } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import Axios from "axios";
import Dinero from "dinero.js";
import { useState } from "react";

View File

@@ -16,7 +16,7 @@ import DataLabel from "../data-label/data-label.component";
import PaymentExpandedRowComponent from "../payment-expanded-row/payment-expanded-row.component";
import PaymentsGenerateLink from "../payments-generate-link/payments-generate-link.component";
import PrintWrapperComponent from "../print-wrapper/print-wrapper.component";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -4,7 +4,7 @@ import { ADD_JOB_WATCHER, GET_JOB_WATCHERS, REMOVE_JOB_WATCHER } from "../../gra
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import JobWatcherToggleComponent from "./job-watcher-toggle.component.jsx";
import { useIsEmployee } from "../../utils/useIsEmployee.js";

View File

@@ -1,6 +1,6 @@
import { useApolloClient, useLazyQuery, useMutation, useQuery } from "@apollo/client/react";
import { gql } from "@apollo/client";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Col, Row } from "antd";
import Axios from "axios";
import _ from "lodash";

View File

@@ -6,7 +6,7 @@ import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectJobReadOnly } from "../../redux/application/application.selectors";

View File

@@ -1,6 +1,6 @@
import { DownCircleFilled } from "@ant-design/icons";
import { useApolloClient, useMutation, useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Card, Dropdown, Form, Input, Modal, Popover, Select, Space } from "antd";
import axios from "axios";
import parsePhoneNumber from "libphonenumber-js";

View File

@@ -6,7 +6,7 @@ import LaborAllocationsTableComponent from "../labor-allocations-table/labor-all
import TimeTicketList from "../time-ticket-list/time-ticket-list.component";
import PayrollLaborAllocationsTable from "../labor-allocations-table/labor-allocations-table.payroll.component";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly,

View File

@@ -6,7 +6,7 @@ import { logImEXEvent } from "../../firebase/firebase.utils";
import cleanAxios from "../../utils/CleanAxios";
import formatBytes from "../../utils/formatbytes";
//import yauzl from "yauzl";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";

View File

@@ -7,7 +7,7 @@ import JobDocuments from "./jobs-documents-gallery.component";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -14,7 +14,7 @@ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const LockWrapper = ({ featureName, bodyshop, children, disabled = true, bypass }) => {
const LockWrapper = ({ featureName, bodyshop, children, disabled = true, bypass, locked }) => {
let renderedChildren = children;
//Mark the child prop as disabled.
@@ -36,11 +36,13 @@ const LockWrapper = ({ featureName, bodyshop, children, disabled = true, bypass
return <span>{children}</span>;
}
return HasFeatureAccess({ featureName: featureName, bodyshop }) ? (
const hasAccess = typeof locked === "boolean" ? !locked : HasFeatureAccess({ featureName: featureName, bodyshop });
return hasAccess ? (
children
) : (
<Space>
{!HasFeatureAccess({ featureName: featureName, bodyshop }) && <LockOutlined style={{ color: "tomato" }} />}
<LockOutlined style={{ color: "tomato" }} />
{renderedChildren}
</Space>
);

View File

@@ -1,5 +1,5 @@
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 { useTranslation } from "react-i18next";
import { connect } from "react-redux";

View File

@@ -19,7 +19,7 @@ import { TemplateList } from "../../utils/TemplateConstants";
import AlertComponent from "../alert/alert.component";
import PartsOrderModalComponent from "./parts-order-modal.component";
import axios from "axios";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import _ from "lodash";
import { UPDATE_JOB } from "../../graphql/jobs.queries";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";

View File

@@ -1,4 +1,4 @@
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Form, Input, Radio, Select } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";

View File

@@ -1,4 +1,4 @@
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Card, Col, Row, Space, Tooltip, Typography } from "antd";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";

View File

@@ -1,5 +1,6 @@
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Card, Col, Input, Row, Space, Typography } from "antd";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { CloseOutlined } from "@ant-design/icons";
import { Alert, Button, Card, Col, Input, Row, Space, Typography, Tooltip } from "antd";
import _ from "lodash";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -10,12 +11,14 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import { TemplateList } from "../../utils/TemplateConstants";
import Jobd3RdPartyModal from "../job-3rd-party-modal/job-3rd-party-modal.component";
import EsignatureCustomDocument from "../esignature-custom-document/esignature-custom-document.component";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import PrintCenterItem from "../print-center-item/print-center-item.component";
import PrintCenterJobsLabels from "../print-center-jobs-labels/print-center-jobs-labels.component";
import PrintCenterSpeedPrint from "../print-center-speed-print/print-center-speed-print.component";
import { bodyshopHasDmsKey, DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
import { selectTechnician } from "../../redux/tech/tech.selectors";
import { hasDocumensoApiKey } from "../../utils/esignature.js";
import useLocalStorage from "../../utils/useLocalStorage";
const mapStateToProps = createStructuredSelector({
printCenterModal: selectPrintCenter,
@@ -27,6 +30,10 @@ const mapDispatchToProps = () => ({});
export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technician }) {
const [search, setSearch] = useState("");
const [esignatureBannerDismissed, setEsignatureBannerDismissed] = useLocalStorage(
"print_center_esignature_banner_dismissed",
false
);
const { id: jobId, job } = printCenterModal.context;
const tempList = TemplateList("job", {});
const { t } = useTranslation();
@@ -41,6 +48,7 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
const dmsMode = getDmsMode(bodyshop, "off");
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
const showEsignatureBanner = !esignatureEnabled && !esignatureBannerDismissed;
const Templates = !hasDMSKey
? Object.keys(tempList)
@@ -50,7 +58,7 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
.filter(
(temp) =>
(!temp.regions ||
(temp.regions && temp.regions[bodyshop.region_config]) ||
temp.regions?.[bodyshop.region_config] ||
(temp.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true)) &&
(!temp.dms || temp.dms === false)
)
@@ -62,7 +70,7 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
.filter(
(temp) =>
!temp.regions ||
(temp.regions && temp.regions[bodyshop.region_config]) ||
temp.regions?.[bodyshop.region_config] ||
(temp.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true)
)
.filter((temp) => !isReynoldsMode || !temp.excludedDmsModes?.includes(dmsMode))
@@ -91,6 +99,23 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
return (
<div>
{showEsignatureBanner && (
<Alert
action={
<Button
aria-label={t("general.actions.close")}
icon={<CloseOutlined />}
onClick={() => setEsignatureBannerDismissed(true)}
size="small"
type="text"
/>
}
banner
title={t("printcenter.banners.esignature_promo")}
type="info"
className="print-center-esignature-banner"
/>
)}
<Row gutter={[16, 16]}>
<Col lg={8} md={12} sm={24}>
<PrintCenterSpeedPrint jobId={jobId} />
@@ -100,7 +125,13 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
extra={
<Space wrap>
<PrintCenterJobsLabels jobId={jobId} />
{esignatureEnabled && <EsignatureCustomDocument jobId={jobId} />}
<Tooltip title={!esignatureEnabled ? t("esignature.tooltips.contact_sales") : null}>
<span>
<LockWrapperComponent locked={!esignatureEnabled} bodyshop={bodyshop}>
<EsignatureCustomDocument jobId={jobId} showUnavailable />
</LockWrapperComponent>
</span>
</Tooltip>
<Jobd3RdPartyModal jobId={jobId} job={job} />
<Input.Search onChange={(e) => setSearch(e.target.value)} value={search} enterButton />
</Space>

View File

@@ -5,3 +5,7 @@
padding: 0;
}
}
.print-center-esignature-banner {
margin-bottom: 16px;
}

View File

@@ -11,7 +11,7 @@ import {
import { QUERY_KANBAN_SETTINGS } from "../../graphql/user.queries";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import ProductionBoardKanbanComponent from "./production-board-kanban.component";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({

View File

@@ -5,7 +5,7 @@ import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectTechnician } from "../../redux/tech/tech.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
technician: selectTechnician,

View File

@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { UPDATE_ACTIVE_PROD_LIST_VIEW } from "../../graphql/associations.queries";
import { UPDATE_SHOP } from "../../graphql/bodyshop.queries";
import ProductionListColumns from "../production-list-columns/production-list-columns.data";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { isFunction } from "lodash";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";

View File

@@ -1,6 +1,6 @@
import { HolderOutlined, SyncOutlined } from "@ant-design/icons";
import { PageHeader } from "@ant-design/pro-layout";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Dropdown, Input, Space, Statistic, Table } from "antd";
import _ from "lodash";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

View File

@@ -9,7 +9,7 @@ import {
} from "../../graphql/jobs.queries";
import ProductionListTable from "./production-list-table.component";
import _ from "lodash";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) {

View File

@@ -1,5 +1,5 @@
import { useLazyQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Card, Col, DatePicker, Form, Input, Radio, Row, Typography } from "antd";
import _ from "lodash";
import { useState } from "react";

View File

@@ -1,6 +1,6 @@
import { DeleteFilled } from "@ant-design/icons";
import { useApolloClient, useMutation, useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import queryString from "query-string";
@@ -12,11 +12,11 @@ import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { logImEXEvent } from "../../firebase/firebase.utils";
import {
CHECK_EMPLOYEE_EMAIL,
CHECK_EMPLOYEE_NUMBER,
DELETE_VACATION,
INSERT_EMPLOYEES,
QUERY_EMPLOYEE_BY_ID,
QUERY_USERS_BY_EMAIL,
UPDATE_EMPLOYEE
} from "../../graphql/employees.queries";
import { selectBodyshop } from "../../redux/user/user.selectors";
@@ -174,9 +174,10 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi
const handleFinish = async (values) => {
const submitAction = saveAndResetSubmitAction();
const userEmail = typeof values.user_email === "string" ? values.user_email.trim() : values.user_email;
const normalizedValues = {
...values,
user_email: values.user_email === "" ? null : values.user_email
user_email: userEmail === "" ? null : userEmail
};
if (search.employeeId && search.employeeId !== "new") {
@@ -491,18 +492,29 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi
rules={[
({ getFieldValue }) => ({
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) {
const response = await client.query({
query: QUERY_USERS_BY_EMAIL,
query: CHECK_EMPLOYEE_EMAIL,
variables: {
email: user_email
email: user_email,
shopId: bodyshop.id
}
});
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"));
} else {

View File

@@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useEffect } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
CHECK_EMPLOYEE_EMAIL,
DELETE_VACATION,
INSERT_EMPLOYEES,
QUERY_EMPLOYEE_BY_ID,
@@ -16,6 +17,7 @@ const updateEmployeeMock = vi.fn();
const deleteVacationMock = vi.fn();
const useQueryMock = vi.fn();
const useMutationMock = vi.fn();
const apolloClientQueryMock = vi.fn();
const navigateMock = vi.fn();
const notification = {
error: vi.fn(),
@@ -33,7 +35,7 @@ vi.mock("@apollo/client/react", async () => {
};
});
vi.mock("@splitsoftware/splitio-react", () => ({
vi.mock("../../feature-flags/splitio-react-replacement", () => ({
useTreatmentsWithConfig: () => ({
treatments: {
Enhanced_Payroll: {
@@ -87,6 +89,10 @@ vi.mock("react-i18next", () => ({
return "Employee number must be unique";
}
if (key === "employees.validation.unique_user_email") {
return "User email already assigned";
}
if (key === "bodyshop.validation.useremailmustexist") {
return "User email must exist";
}
@@ -203,18 +209,20 @@ describe("ShopEmployeesFormComponent", () => {
return [vi.fn()];
});
useApolloClient.mockReturnValue({
query: vi.fn().mockResolvedValue({
data: {
employees_aggregate: {
aggregate: {
count: 0
},
nodes: []
apolloClientQueryMock.mockResolvedValue({
data: {
employees_aggregate: {
aggregate: {
count: 0
},
users: []
}
})
nodes: []
},
users: []
}
});
useApolloClient.mockReturnValue({
query: apolloClientQueryMock
});
insertEmployeesMock.mockResolvedValue({
@@ -356,4 +364,59 @@ describe("ShopEmployeesFormComponent", () => {
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();
});
});

View File

@@ -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 queryString from "query-string";
import { useRef } from "react";

View File

@@ -4,7 +4,7 @@ import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -1,4 +1,4 @@
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Form, InputNumber } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";

View File

@@ -1,5 +1,5 @@
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 { useState } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -16,7 +16,7 @@ import { DEFAULT_TRANSLUCENT_CARD_COLOR, getTintedCardSurfaceStyles } from "./sh
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -7,7 +7,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import JobSearchSelect from "../job-search-select/job-search-select.component";
import JobsDetailLaborContainer from "../jobs-detail-labor/jobs-detail-labor.container";
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({
bodyshop: selectBodyshop,

View File

@@ -12,7 +12,7 @@ import { selectTechnician } from "../../redux/tech/tech.selectors";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import TechJobPrintTickets from "../tech-job-print-tickets/tech-job-print-tickets.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";
const mapStateToProps = createStructuredSelector({

View File

@@ -14,7 +14,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
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 { 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 { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";

View File

@@ -11,7 +11,7 @@ import { createStructuredSelector } from "reselect";
import { techLogout } from "../../redux/tech/tech.actions";
import { selectTechnician } from "../../redux/tech/tech.selectors";
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 { setModalContext } from "../../redux/modals/modals.actions";

View File

@@ -1,5 +1,5 @@
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 ResponsiveTable from "../responsive-table/responsive-table.component";
import { useMemo, useState } from "react";

View File

@@ -1,5 +1,5 @@
import { useLazyQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Card, Form, Input, InputNumber, Select, Space, Switch } from "antd";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -1,6 +1,6 @@
import { PageHeader } from "@ant-design/pro-layout";
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 { useEffect, useState, useRef } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -1,6 +1,6 @@
import { DeleteFilled } from "@ant-design/icons";
import { useApolloClient } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from "antd";
import { PageHeader } from "@ant-design/pro-layout";
import { useTranslation } from "react-i18next";

View File

@@ -14,7 +14,7 @@ import {
} from "../../graphql/notifications.queries.js";
import { useMutation } from "@apollo/client/react";
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";
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 () => {
try {
if (!auth.currentUser || !bodyshop?.id) return;
@@ -574,6 +578,7 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
socketInstance.on("notification", handleNotification);
socketInstance.on("sync-notification-read", handleSyncNotificationRead);
socketInstance.on("sync-all-notifications-read", handleSyncAllNotificationsRead);
socketInstance.on(FEATURE_FLAGS_CHANGED_EVENT, handleFeatureFlagsChanged);
socketInstance.on("token-updated", handleTokenUpdated);
if (tokenSyncIntervalRef.current) {

View 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.

View 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
};

View File

@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { __featureFlagTesting } from "./splitio-react-replacement";
const {
createFeatureFlagClient,
getNextScheduleRefreshDelay,
getLocalStorageKey,
isFeatureFlagChangeRelevant,
normalizeFlagValue,
readCachedFeatureFlags,
writeCachedFeatureFlags
} = __featureFlagTesting;
beforeEach(() => {
window.localStorage.clear();
vi.useRealTimers();
});
describe("splitio-react-replacement feature flag normalization", () => {
it("returns off for unknown or null values", () => {
expect(normalizeFlagValue(null)).toEqual({ treatment: "off", config: null });
expect(normalizeFlagValue(undefined)).toEqual({ treatment: "off", config: null });
});
it("normalizes primitive values into Split-like treatments", () => {
expect(normalizeFlagValue(true)).toEqual({ treatment: "on", config: null });
expect(normalizeFlagValue(false)).toEqual({ treatment: "off", config: null });
expect(normalizeFlagValue(1)).toEqual({ treatment: "on", config: null });
expect(normalizeFlagValue(0)).toEqual({ treatment: "off", config: null });
expect(normalizeFlagValue("true")).toEqual({ treatment: "on", config: null });
expect(normalizeFlagValue("false")).toEqual({ treatment: "off", config: null });
expect(normalizeFlagValue("variant-a")).toEqual({ treatment: "variant-a", config: null });
});
it("preserves custom treatments and parses JSON config strings", () => {
expect(
normalizeFlagValue({
treatment: "demo",
config: "{\"limit\":25}"
})
).toEqual({
treatment: "demo",
config: { limit: 25 }
});
});
it("respects activeDate and deactiveDate windows", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-19T15:00:00.000Z"));
expect(
normalizeFlagValue({
treatment: "on",
activeDate: "2026-05-19T14:59:00.000Z",
deactiveDate: "2026-05-19T15:01:00.000Z"
})
).toEqual({ treatment: "on", config: null });
expect(
normalizeFlagValue({
treatment: "on",
activeDate: "2026-05-19T15:01:00.000Z"
})
).toEqual({ treatment: "off", config: null });
expect(
normalizeFlagValue({
treatment: "on",
deactiveDate: "2026-05-19T15:00:00.000Z"
})
).toEqual({ treatment: "off", config: null });
vi.useRealTimers();
});
});
describe("splitio-react-replacement feature flag client", () => {
it("uses backend flags", () => {
const client = createFeatureFlagClient({
bodyshop: {
imexshopid: "APPLE"
},
backendFlags: {
Enhanced_Payroll: { treatment: "on" }
}
});
expect(client.getTreatment("Enhanced_Payroll")).toBe("on");
});
it("ignores old bodyshop feature JSON fallback values", () => {
const client = createFeatureFlagClient({
bodyshop: {
imexshopid: "APPLE",
features: {
featureFlags: {
Enhanced_Payroll: { treatment: "on" }
}
}
},
backendFlags: {}
});
expect(client.getTreatment("Enhanced_Payroll")).toBe("off");
});
it("returns off for flags that are not present in any source", () => {
const client = createFeatureFlagClient({
bodyshop: { imexshopid: "APPLE", features: {} },
backendFlags: {}
});
expect(client.getTreatment("Missing_Flag")).toBe("off");
});
it("uses a bodyshop-scoped browser cache key", () => {
expect(getLocalStorageKey("shop-1")).toBe("bodyshop-feature-flags:shop-1");
});
it("stores and reads last-known backend flags from browser storage", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-19T15:00:00.000Z"));
writeCachedFeatureFlags("shop-1", {
Enhanced_Payroll: { treatment: "on", config: null }
});
expect(readCachedFeatureFlags("shop-1")).toEqual({
Enhanced_Payroll: { treatment: "on", config: null }
});
});
it("ignores expired browser cached flags", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-19T15:00:00.000Z"));
writeCachedFeatureFlags("shop-1", {
Enhanced_Payroll: { treatment: "on", config: null }
});
expect(readCachedFeatureFlags("shop-1", Date.parse("2026-05-20T15:00:01.000Z"))).toBeNull();
});
});
describe("splitio-react-replacement live refresh helpers", () => {
it("matches global and bodyshop-scoped socket changes", () => {
expect(isFeatureFlagChangeRelevant({ scope: "global" }, "shop-1")).toBe(true);
expect(isFeatureFlagChangeRelevant({ bodyshopId: "shop-1", scope: "bodyshop" }, "shop-1")).toBe(true);
expect(isFeatureFlagChangeRelevant({ bodyshopId: "shop-2", scope: "bodyshop" }, "shop-1")).toBe(false);
});
it("finds the next active/deactive date boundary that needs a refresh", () => {
const now = Date.parse("2026-05-19T15:00:00.000Z");
expect(
getNextScheduleRefreshDelay(
{
Demo: { treatment: "on", activeDate: "2026-05-19T15:05:00.000Z" },
Expiring: { treatment: "on", deactiveDate: "2026-05-19T15:02:00.000Z" },
Expired: { treatment: "on", deactiveDate: "2026-05-19T14:59:00.000Z" }
},
now
)
).toBe(120050);
});
});

View File

@@ -49,6 +49,22 @@ export const CHECK_EMPLOYEE_NUMBER = gql`
}
`;
export const CHECK_EMPLOYEE_EMAIL = gql`
query CHECK_EMPLOYEE_EMAIL($email: String!, $shopId: uuid!) {
users(where: { email: { _ilike: $email } }) {
email
}
employees_aggregate(where: { user_email: { _ilike: $email }, shopid: { _eq: $shopId } }) {
aggregate {
count
}
nodes {
id
}
}
}
`;
export const QUERY_ACTIVE_EMPLOYEES = gql`
query QUERY_ACTIVE_EMPLOYEES {
employees(where: { active: { _eq: true } }) {

View File

@@ -6,7 +6,7 @@ import { createStructuredSelector } from "reselect";
import queryString from "query-string";
import { useQuery } from "@apollo/client/react";
import { Button, Card, Col, Result, Row, Select, Space, Switch } from "antd";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useParams } from "react-router-dom";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { some } from "lodash";
import axios from "axios";
import AlertComponent from "../../components/alert/alert.component";

View File

@@ -22,7 +22,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
// import { useNavigate } from 'react-router-dom';
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import Dinero from "dinero.js";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect";

View File

@@ -2,7 +2,7 @@ import ProductionBoardKanbanContainer from "../../components/production-board-ka
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser

View File

@@ -3,7 +3,7 @@ import ProductionListTable from "../../components/production-list-table/producti
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -13,7 +13,7 @@ import { GET_UNACCEPTED_PARTS_DISPATCH } from "../../graphql/parts-dispatch.quer
import { selectTechnician } from "../../redux/tech/tech.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { alphaSort } from "../../utils/sorters";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser

View File

@@ -4,7 +4,7 @@ import JobsDocumentsContainer from "../../components/jobs-documents-gallery/jobs
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component";
import { QUERY_TEMPORARY_DOCS } from "../../graphql/documents.queries";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import JobsDocumentsLocalGallery from "../../components/jobs-documents-local-gallery/jobs-documents-local-gallery.container";

View File

@@ -18,7 +18,7 @@ import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/appli
import TimeTicketsCommit from "../../components/time-tickets-commit/time-tickets-commit.component";
import FeatureWrapperComponent from "../../components/feature-wrapper/feature-wrapper.component";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { selectBodyshop } from "../../redux/user/user.selectors";
import UpsellComponent, { upsellEnum } from "../../components/upsell/upsell.component";

View File

@@ -1,5 +1,4 @@
import FingerprintJS from "@fingerprintjs/fingerprintjs";
//import { setUserId, setUserProperties } from "@firebase/analytics";
import {
checkActionCode,
confirmPasswordReset,
@@ -9,11 +8,9 @@ import {
} from "@firebase/auth";
import { arrayUnion, doc, getDoc, setDoc, updateDoc } from "@firebase/firestore";
import { getToken } from "@firebase/messaging";
// import * as Sentry from "@sentry/react";
import { notification } from "antd";
import axios from "axios";
import i18next from "i18next";
//import LogRocket from "logrocket";
import { all, call, delay, put, select, takeLatest } from "redux-saga/effects";
import {
auth,
@@ -48,9 +45,13 @@ import {
validatePasswordResetSuccess
} from "./user.actions";
import UserActionTypes from "./user.types";
//import posthog from "posthog-js";
import { bodyshopHasDmsKey, determineDMSTypeByBodyshop, DMS_MAP } from "../../utils/dmsUtils";
//import { setUserId, setUserProperties } from "@firebase/analytics";
//import * as Sentry from "@sentry/react";
//import LogRocket from "logrocket";
//import posthog from "posthog-js";
const fpPromise = FingerprintJS.load();
export function* onEmailSignInStart() {

View File

@@ -1351,7 +1351,8 @@
"vacationadded": "Employee vacation added."
},
"validation": {
"unique_employee_number": "You must enter a unique employee number."
"unique_employee_number": "You must enter a unique employee number.",
"unique_user_email": "This email is already assigned to another employee."
}
},
"esignature": {
@@ -1367,6 +1368,9 @@
"pdf_only": "Only PDF documents can be uploaded for e-signature.",
"upload_title": "Unable to prepare document for e-signature"
},
"tooltips": {
"contact_sales": "E-signatures are not enabled for this shop. Contact sales to add this feature."
},
"fields": {
"completed": "Completed?",
"completed_at": "Completed At",
@@ -3051,6 +3055,9 @@
"appointments": {
"appointment_confirmation": "Appointment Confirmation"
},
"banners": {
"esignature_promo": "Tired of getting paper signatures? Try E-Signatures today. Contact support to add this feature."
},
"bills": {
"inhouse_invoice": "In House Invoice"
},

View File

@@ -1351,7 +1351,8 @@
"vacationadded": ""
},
"validation": {
"unique_employee_number": ""
"unique_employee_number": "",
"unique_user_email": "Este correo electrónico ya está asignado a otro empleado."
}
},
"esignature": {
@@ -1367,6 +1368,9 @@
"pdf_only": "Only PDF documents can be uploaded for e-signature.",
"upload_title": "Unable to prepare document for e-signature"
},
"tooltips": {
"contact_sales": "Las firmas electronicas no estan habilitadas para este taller. Contacte a ventas para agregar esta funcion."
},
"fields": {
"completed": "",
"completed_at": "",
@@ -3051,6 +3055,9 @@
"appointments": {
"appointment_confirmation": ""
},
"banners": {
"esignature_promo": "¿Cansado de obtener firmas en papel? Prueba las firmas electrónicas hoy. Contacta a ventas para agregar esta función."
},
"bills": {
"inhouse_invoice": ""
},

View File

@@ -1351,7 +1351,8 @@
"vacationadded": ""
},
"validation": {
"unique_employee_number": ""
"unique_employee_number": "",
"unique_user_email": "Cette adresse courriel est déjà assignée à un autre employé."
}
},
"esignature": {
@@ -1367,6 +1368,9 @@
"pdf_only": "Only PDF documents can be uploaded for e-signature.",
"upload_title": "Unable to prepare document for e-signature"
},
"tooltips": {
"contact_sales": "Les signatures electroniques ne sont pas activees pour cet atelier. Contactez les ventes pour ajouter cette fonctionnalite."
},
"fields": {
"completed": "",
"completed_at": "",
@@ -3051,6 +3055,9 @@
"appointments": {
"appointment_confirmation": ""
},
"banners": {
"esignature_promo": "Vous en avez assez des signatures papier? Essayez les signatures électroniques dès aujourd'hui. Communiquez avec les ventes pour ajouter cette fonction."
},
"bills": {
"inhouse_invoice": ""
},

View File

@@ -236,10 +236,8 @@ export default defineConfig(({ command, mode }) => {
redux: ["redux"],
lodash: ["lodash"],
"@sentry/react": ["@sentry/react"],
"@splitsoftware/splitio-react": ["@splitsoftware/splitio-react"],
logrocket: ["logrocket"],
"feature-flags": ["src/feature-flags/splitio-react-replacement.jsx"],
firebase: [
"@firebase/analytics",
"@firebase/app",
"@firebase/firestore",
"@firebase/auth",

View File

@@ -5,6 +5,7 @@ provider "registry.terraform.io/hashicorp/aws" {
version = "6.38.0"
constraints = "~> 6.0"
hashes = [
"h1:IMf41BcW9huOeVcrt6XjQqadYR2xD8zkUpGLLERJ4NM=",
"h1:RDoKIzXmt7H1mNFcNIyRT+nA/gTJyO3+iW9QGN5I2eQ=",
"zh:143f118ae71059a7a7026c6b950da23fef04a06e2362ffa688bef75e43e869ed",
"zh:29ee220a017306effd877e1280f8b2934dc957e16e0e72ca0222e5514d0db522",
@@ -28,6 +29,7 @@ provider "registry.terraform.io/hashicorp/random" {
version = "3.8.1"
constraints = "~> 3.6"
hashes = [
"h1:osH3aBqEARwOz3VBJKdpFKJJCNIdgRC6k8vPojkLmlY=",
"h1:u8AKlWVDTH5r9YLSeswoVEjiY72Rt4/ch7U+61ZDkiQ=",
"zh:08dd03b918c7b55713026037c5400c48af5b9f468f483463321bd18e17b907b4",
"zh:0eee654a5542dc1d41920bbf2419032d6f0d5625b03bd81339e5b33394a3e0ae",

View File

@@ -1,7 +1,7 @@
{
"version": 4,
"terraform_version": "1.14.3",
"serial": 105,
"terraform_version": "1.15.4",
"serial": 111,
"lineage": "2b49a6da-17c7-01da-d62f-9a13def4b683",
"outputs": {
"application_url": {
@@ -21,7 +21,7 @@
"type": "string"
},
"postgres_engine_version": {
"value": "17.9",
"value": "17.10",
"type": "string"
},
"secrets_manager_secret_name": {
@@ -118,7 +118,7 @@
"filter": null,
"has_major_target": null,
"has_minor_target": null,
"id": "17.9",
"id": "17.10",
"include_all": null,
"latest": true,
"parameter_group_family": "postgres17",
@@ -144,15 +144,15 @@
"supports_parallel_query": false,
"supports_read_replica": true,
"valid_major_targets": [
"18.3"
"18.4"
],
"valid_minor_targets": [],
"valid_upgrade_targets": [
"18.3"
"18.4"
],
"version": "17.9",
"version_actual": "17.9",
"version_description": "PostgreSQL 17.9-R1"
"version": "17.10",
"version_actual": "17.10",
"version_description": "PostgreSQL 17.10-R1"
},
"sensitive_attributes": [],
"identity_schema_version": 0
@@ -1085,7 +1085,7 @@
"endpoint": "documenso-postgres.cfo5pnykioqq.ca-central-1.rds.amazonaws.com:5432",
"engine": "postgres",
"engine_lifecycle_support": "open-source-rds-extended-support",
"engine_version": "17.9",
"engine_version": "17.10",
"engine_version_actual": "17.9",
"final_snapshot_identifier": "documenso-final-03443461",
"hosted_zone_id": "Z1JG78A3UK1DU3",
@@ -1096,7 +1096,7 @@
"instance_class": "db.t4g.micro",
"iops": 3000,
"kms_key_id": "arn:aws:kms:ca-central-1:714144183158:key/1237b672-91b3-4d23-958d-1877c5d22eb9",
"latest_restorable_time": "2026-05-01T17:49:36Z",
"latest_restorable_time": "2026-05-25T20:16:55Z",
"license_model": "postgresql-license",
"listener_endpoint": [],
"maintenance_window": "tue:03:10-tue:03:40",
@@ -1384,7 +1384,7 @@
"Application": "documenso",
"ManagedBy": "Terraform"
},
"task_definition": "arn:aws:ecs:ca-central-1:714144183158:task-definition/documenso-task:8",
"task_definition": "arn:aws:ecs:ca-central-1:714144183158:task-definition/documenso-task:9",
"timeouts": null,
"triggers": {},
"volume_configuration": [],
@@ -1451,9 +1451,9 @@
{
"schema_version": 1,
"attributes": {
"arn": "arn:aws:ecs:ca-central-1:714144183158:task-definition/documenso-task:8",
"arn": "arn:aws:ecs:ca-central-1:714144183158:task-definition/documenso-task:9",
"arn_without_revision": "arn:aws:ecs:ca-central-1:714144183158:task-definition/documenso-task",
"container_definitions": "[{\"environment\":[{\"name\":\"NEXT_PRIVATE_INTERNAL_WEBAPP_URL\",\"value\":\"http://127.0.0.1:3000\"},{\"name\":\"NEXT_PRIVATE_SMTP_HOST\",\"value\":\"email-smtp.ca-central-1.amazonaws.com\"},{\"name\":\"NEXT_PRIVATE_SMTP_PORT\",\"value\":\"587\"},{\"name\":\"NEXT_PRIVATE_SMTP_SECURE\",\"value\":\"false\"},{\"name\":\"NEXT_PRIVATE_SMTP_TRANSPORT\",\"value\":\"smtp-auth\"},{\"name\":\"NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS\",\"value\":\"false\"},{\"name\":\"NEXT_PRIVATE_UPLOAD_BUCKET\",\"value\":\"documenso-714144183158-ca-central-1\"},{\"name\":\"NEXT_PRIVATE_UPLOAD_REGION\",\"value\":\"ca-central-1\"},{\"name\":\"NEXT_PUBLIC_DISABLE_SIGNUP\",\"value\":\"true\"},{\"name\":\"NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT\",\"value\":\"10\"},{\"name\":\"NEXT_PUBLIC_UPLOAD_TRANSPORT\",\"value\":\"s3\"},{\"name\":\"NEXT_PUBLIC_WEBAPP_URL\",\"value\":\"https://sign.imex.online\"},{\"name\":\"PORT\",\"value\":\"3000\"}],\"essential\":true,\"image\":\"documenso/documenso:2.10.0\",\"logConfiguration\":{\"logDriver\":\"awslogs\",\"options\":{\"awslogs-group\":\"/ecs/documenso\",\"awslogs-region\":\"ca-central-1\",\"awslogs-stream-prefix\":\"documenso\"}},\"mountPoints\":[],\"name\":\"documenso\",\"portMappings\":[{\"containerPort\":3000,\"hostPort\":3000,\"protocol\":\"tcp\"}],\"secrets\":[{\"name\":\"NEXTAUTH_SECRET\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXTAUTH_SECRET::\"},{\"name\":\"NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS::\"},{\"name\":\"NEXT_PRIVATE_DATABASE_URL\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_DATABASE_URL::\"},{\"name\":\"NEXT_PRIVATE_DIRECT_DATABASE_URL\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_DIRECT_DATABASE_URL::\"},{\"name\":\"NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY::\"},{\"name\":\"NEXT_PRIVATE_ENCRYPTION_KEY\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_ENCRYPTION_KEY::\"},{\"name\":\"NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY::\"},{\"name\":\"NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS::\"},{\"name\":\"NEXT_PRIVATE_SIGNING_PASSPHRASE\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SIGNING_PASSPHRASE::\"},{\"name\":\"NEXT_PRIVATE_SMTP_FROM_ADDRESS\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SMTP_FROM_ADDRESS::\"},{\"name\":\"NEXT_PRIVATE_SMTP_FROM_NAME\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SMTP_FROM_NAME::\"},{\"name\":\"NEXT_PRIVATE_SMTP_PASSWORD\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SMTP_PASSWORD::\"},{\"name\":\"NEXT_PRIVATE_SMTP_USERNAME\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SMTP_USERNAME::\"},{\"name\":\"NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID::\"},{\"name\":\"NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY::\"}],\"systemControls\":[],\"volumesFrom\":[]}]",
"container_definitions": "[{\"environment\":[{\"name\":\"NEXT_PRIVATE_INTERNAL_WEBAPP_URL\",\"value\":\"http://127.0.0.1:3000\"},{\"name\":\"NEXT_PRIVATE_SMTP_HOST\",\"value\":\"email-smtp.ca-central-1.amazonaws.com\"},{\"name\":\"NEXT_PRIVATE_SMTP_PORT\",\"value\":\"587\"},{\"name\":\"NEXT_PRIVATE_SMTP_SECURE\",\"value\":\"false\"},{\"name\":\"NEXT_PRIVATE_SMTP_TRANSPORT\",\"value\":\"smtp-auth\"},{\"name\":\"NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS\",\"value\":\"false\"},{\"name\":\"NEXT_PRIVATE_UPLOAD_BUCKET\",\"value\":\"documenso-714144183158-ca-central-1\"},{\"name\":\"NEXT_PRIVATE_UPLOAD_REGION\",\"value\":\"ca-central-1\"},{\"name\":\"NEXT_PUBLIC_DISABLE_SIGNUP\",\"value\":\"true\"},{\"name\":\"NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT\",\"value\":\"10\"},{\"name\":\"NEXT_PUBLIC_UPLOAD_TRANSPORT\",\"value\":\"s3\"},{\"name\":\"NEXT_PUBLIC_WEBAPP_URL\",\"value\":\"https://sign.imex.online\"},{\"name\":\"PORT\",\"value\":\"3000\"}],\"essential\":true,\"image\":\"documenso/documenso:2.11.0\",\"logConfiguration\":{\"logDriver\":\"awslogs\",\"options\":{\"awslogs-group\":\"/ecs/documenso\",\"awslogs-region\":\"ca-central-1\",\"awslogs-stream-prefix\":\"documenso\"}},\"mountPoints\":[],\"name\":\"documenso\",\"portMappings\":[{\"containerPort\":3000,\"hostPort\":3000,\"protocol\":\"tcp\"}],\"secrets\":[{\"name\":\"NEXTAUTH_SECRET\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXTAUTH_SECRET::\"},{\"name\":\"NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS::\"},{\"name\":\"NEXT_PRIVATE_DATABASE_URL\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_DATABASE_URL::\"},{\"name\":\"NEXT_PRIVATE_DIRECT_DATABASE_URL\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_DIRECT_DATABASE_URL::\"},{\"name\":\"NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY::\"},{\"name\":\"NEXT_PRIVATE_ENCRYPTION_KEY\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_ENCRYPTION_KEY::\"},{\"name\":\"NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY::\"},{\"name\":\"NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS::\"},{\"name\":\"NEXT_PRIVATE_SIGNING_PASSPHRASE\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SIGNING_PASSPHRASE::\"},{\"name\":\"NEXT_PRIVATE_SMTP_FROM_ADDRESS\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SMTP_FROM_ADDRESS::\"},{\"name\":\"NEXT_PRIVATE_SMTP_FROM_NAME\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SMTP_FROM_NAME::\"},{\"name\":\"NEXT_PRIVATE_SMTP_PASSWORD\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SMTP_PASSWORD::\"},{\"name\":\"NEXT_PRIVATE_SMTP_USERNAME\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SMTP_USERNAME::\"},{\"name\":\"NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID::\"},{\"name\":\"NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY::\"}],\"systemControls\":[],\"volumesFrom\":[]}]",
"cpu": "512",
"enable_fault_injection": false,
"ephemeral_storage": [],
@@ -1470,7 +1470,7 @@
"requires_compatibilities": [
"FARGATE"
],
"revision": 8,
"revision": 9,
"runtime_platform": [],
"skip_destroy": false,
"tags": {
@@ -1498,7 +1498,7 @@
"account_id": "714144183158",
"family": "documenso-task",
"region": "ca-central-1",
"revision": 8
"revision": 9
},
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
"dependencies": [

View File

@@ -1,7 +1,7 @@
{
"version": 4,
"terraform_version": "1.14.3",
"serial": 101,
"terraform_version": "1.15.4",
"serial": 105,
"lineage": "2b49a6da-17c7-01da-d62f-9a13def4b683",
"outputs": {
"application_url": {
@@ -1096,7 +1096,7 @@
"instance_class": "db.t4g.micro",
"iops": 3000,
"kms_key_id": "arn:aws:kms:ca-central-1:714144183158:key/1237b672-91b3-4d23-958d-1877c5d22eb9",
"latest_restorable_time": "2026-05-01T15:49:30Z",
"latest_restorable_time": "2026-05-01T17:49:36Z",
"license_model": "postgresql-license",
"listener_endpoint": [],
"maintenance_window": "tue:03:10-tue:03:40",
@@ -3551,7 +3551,7 @@
],
"description": "WAF protection for Documenso",
"id": "04577153-2a1a-462c-94b8-b0a1804755bb",
"lock_token": "e71f2816-492c-4afc-acc2-3700795c2657",
"lock_token": "417061f1-deea-4ac2-b932-9bea49265444",
"name": "documenso-web-acl",
"name_prefix": "",
"region": "ca-central-1",
@@ -3693,7 +3693,24 @@
{
"managed_rule_group_configs": [],
"name": "AWSManagedRulesCommonRuleSet",
"rule_action_override": [],
"rule_action_override": [
{
"action_to_use": [
{
"allow": [],
"block": [],
"captcha": [],
"challenge": [],
"count": [
{
"custom_request_handling": []
}
]
}
],
"name": "SizeRestrictions_BODY"
}
],
"scope_down_statement": [],
"vendor_name": "AWS",
"version": ""

View File

@@ -846,6 +846,13 @@
table:
name: exportlog
schema: public
- name: feature_flags
using:
foreign_key_constraint_on:
column: bodyshopid
table:
name: bodyshop_feature_flags
schema: public
- name: inventories
using:
foreign_key_constraint_on:
@@ -2739,6 +2746,114 @@
- end_date
- content
filter: {}
- table:
name: bodyshop_feature_flags
schema: public
object_relationships:
- name: bodyshop
using:
foreign_key_constraint_on: bodyshopid
- name: feature_flag
using:
foreign_key_constraint_on: name
select_permissions:
- role: user
permission:
columns:
- id
- bodyshopid
- name
- treatment
- config
- activeDate
- deactiveDate
- created_at
- updated_at
filter:
_and:
- bodyshop:
associations:
_and:
- user:
authid:
_eq: X-Hasura-User-Id
- active:
_eq: true
- feature_flag:
active:
_eq: true
event_triggers:
- name: cache_bodyshop_feature_flags
definition:
delete:
columns: '*'
enable_manual: false
insert:
columns: '*'
update:
columns: '*'
retry_conf:
interval_sec: 10
num_retries: 0
timeout_sec: 60
webhook_from_env: HASURA_API_URL
headers:
- name: event-secret
value_from_env: EVENT_SECRET
request_transform:
method: POST
query_params: {}
template_engine: Kriti
url: '{{$base_url}}/feature-flags/cache/invalidate'
version: 2
- table:
name: feature_flags
schema: public
array_relationships:
- name: bodyshop_feature_flags
using:
foreign_key_constraint_on:
column: name
table:
name: bodyshop_feature_flags
schema: public
select_permissions:
- role: user
permission:
columns:
- name
- description
- default_treatment
- active
- created_at
- updated_at
filter:
active:
_eq: true
event_triggers:
- name: cache_feature_flags
definition:
delete:
columns: '*'
enable_manual: false
insert:
columns: '*'
update:
columns: '*'
retry_conf:
interval_sec: 10
num_retries: 0
timeout_sec: 60
webhook_from_env: HASURA_API_URL
headers:
- name: event-secret
value_from_env: EVENT_SECRET
request_transform:
method: POST
query_params: {}
template_engine: Kriti
url: '{{$base_url}}/feature-flags/cache/invalidate'
version: 2
- table:
name: exportlog
schema: public

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS "public"."feature_flags";

View File

@@ -0,0 +1,43 @@
CREATE TABLE "public"."feature_flags" (
"name" text NOT NULL,
"description" text NULL,
"default_treatment" text NOT NULL DEFAULT 'off',
"active" boolean NOT NULL DEFAULT true,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
CONSTRAINT "feature_flags_pkey" PRIMARY KEY ("name"),
CONSTRAINT "feature_flags_default_treatment_check" CHECK (length(btrim("default_treatment")) > 0)
);
INSERT INTO "public"."feature_flags" ("name", "description")
VALUES
('ADPPayroll', 'Enable ADP payroll flows and reporting.'),
('Allow_Negative_Jobline_Price', 'Allow negative pricing on job lines.'),
('Autohouse_Detail_line', 'Enable Autohouse detail line handling.'),
('Bill_OCR_AI', 'Enable AI bill OCR entry.'),
('ClosingPeriod', 'Enable closing period accounting restrictions.'),
('CriticalPartsScanning', 'Enable critical parts scanning workflows.'),
('Direct_Media_Download', 'Enable direct media downloads.'),
('DmsAp', 'Enable DMS accounts payable workflows.'),
('Enhanced_Payroll', 'Enable enhanced payroll and labor allocation features.'),
('Extended_Bill_Posting', 'Enable extended bill posting.'),
('Fortellis', 'Enable Fortellis-backed DMS flows.'),
('IOU_Tracking', 'Enable IOU tracking.'),
('ImEXPay', 'Enable ImEX Pay workflows.'),
('Imgproxy', 'Enable imgproxy-backed media rendering.'),
('LogRocket_Tracking', 'Enable LogRocket tracking.'),
('NewPhotoViewer', 'Enable the newer photo viewer experience.'),
('OEConnection', 'Enable OEConnection parts ordering.'),
('OEConnection_PriceChange', 'Enable OEConnection price changes.'),
('OpenSearch', 'Enable OpenSearch global search.'),
('OpenSearch_PaginatedScreens', 'Enable OpenSearch on paginated screens.'),
('Production_List_Status_Colors', 'Enable status colors on production list.'),
('Production_Use_View', 'Enable production view selection.'),
('Qb_Multi_Ar', 'Enable QuickBooks multi-AR payment options.'),
('Realtime_Notifications_UI', 'Enable realtime notification UI.'),
('Share_To_Teams', 'Enable sharing workflows to Microsoft Teams.'),
('Simple_Inventory', 'Enable simple inventory workflows.'),
('TEST_FLAG', 'Manual test flag used to verify frontend feature flag plumbing.'),
('Use_Graphql_RR', 'Enable GraphQL-backed Rome/RR flows.'),
('Websocket_Production', 'Toggle websocket production board/list behavior.')
ON CONFLICT ("name") DO NOTHING;

View File

@@ -0,0 +1,3 @@
DROP TRIGGER IF EXISTS "set_public_feature_flags_updated_at" ON "public"."feature_flags";
DROP TRIGGER IF EXISTS "set_public_bodyshop_feature_flags_updated_at" ON "public"."bodyshop_feature_flags";
DROP TABLE IF EXISTS "public"."bodyshop_feature_flags";

View File

@@ -0,0 +1,89 @@
CREATE TABLE "public"."bodyshop_feature_flags" (
"id" uuid NOT NULL DEFAULT public.gen_random_uuid(),
"bodyshopid" uuid NOT NULL,
"name" text NOT NULL,
"treatment" text NOT NULL DEFAULT 'off',
"config" jsonb NULL,
"activeDate" timestamptz NULL,
"deactiveDate" timestamptz NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
CONSTRAINT "bodyshop_feature_flags_pkey" PRIMARY KEY ("id"),
CONSTRAINT "bodyshop_feature_flags_bodyshopid_name_key" UNIQUE ("bodyshopid", "name"),
CONSTRAINT "bodyshop_feature_flags_bodyshopid_fkey" FOREIGN KEY ("bodyshopid") REFERENCES "public"."bodyshops" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "bodyshop_feature_flags_name_fkey" FOREIGN KEY ("name") REFERENCES "public"."feature_flags" ("name") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "bodyshop_feature_flags_treatment_check" CHECK (length(btrim("treatment")) > 0),
CONSTRAINT "bodyshop_feature_flags_dates_check" CHECK ("deactiveDate" IS NULL OR "activeDate" IS NULL OR "deactiveDate" > "activeDate")
);
CREATE INDEX "bodyshop_feature_flags_bodyshopid_idx" ON "public"."bodyshop_feature_flags" ("bodyshopid");
CREATE INDEX "bodyshop_feature_flags_name_idx" ON "public"."bodyshop_feature_flags" ("name");
INSERT INTO "public"."bodyshop_feature_flags" (
"bodyshopid",
"name",
"treatment",
"config",
"activeDate",
"deactiveDate"
)
SELECT
"bodyshops"."id",
"feature_flag"."name",
CASE
WHEN jsonb_typeof("feature_flag"."value") = 'object'
AND nullif(btrim("feature_flag"."value" ->> 'treatment'), '') IS NOT NULL
THEN btrim("feature_flag"."value" ->> 'treatment')
WHEN jsonb_typeof("feature_flag"."value") = 'boolean'
THEN CASE WHEN ("feature_flag"."value" #>> '{}')::boolean THEN 'on' ELSE 'off' END
WHEN jsonb_typeof("feature_flag"."value") = 'string'
AND nullif(btrim("feature_flag"."value" #>> '{}'), '') IS NOT NULL
THEN btrim("feature_flag"."value" #>> '{}')
ELSE 'on'
END,
CASE
WHEN jsonb_typeof("feature_flag"."value") = 'object'
THEN "feature_flag"."value" -> 'config'
ELSE NULL
END,
CASE
WHEN jsonb_typeof("feature_flag"."value") = 'object'
AND "feature_flag"."value" ->> 'activeDate' ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'
THEN ("feature_flag"."value" ->> 'activeDate')::timestamptz
ELSE NULL
END,
CASE
WHEN jsonb_typeof("feature_flag"."value") = 'object'
AND "feature_flag"."value" ->> 'deactiveDate' ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'
THEN ("feature_flag"."value" ->> 'deactiveDate')::timestamptz
ELSE NULL
END
FROM "public"."bodyshops"
CROSS JOIN LATERAL jsonb_each(
CASE
WHEN jsonb_typeof(COALESCE("bodyshops"."features" -> 'featureFlags', '{}'::jsonb)) = 'object'
THEN COALESCE("bodyshops"."features" -> 'featureFlags', '{}'::jsonb)
ELSE '{}'::jsonb
END
) AS "feature_flag"("name", "value")
INNER JOIN "public"."feature_flags" ON "feature_flags"."name" = "feature_flag"."name"
ON CONFLICT ("bodyshopid", "name") DO UPDATE
SET
"treatment" = EXCLUDED."treatment",
"config" = EXCLUDED."config",
"activeDate" = EXCLUDED."activeDate",
"deactiveDate" = EXCLUDED."deactiveDate";
CREATE TRIGGER "set_public_bodyshop_feature_flags_updated_at"
BEFORE UPDATE ON "public"."bodyshop_feature_flags"
FOR EACH ROW EXECUTE FUNCTION "public"."set_current_timestamp_updated_at"();
COMMENT ON TRIGGER "set_public_bodyshop_feature_flags_updated_at" ON "public"."bodyshop_feature_flags"
IS 'trigger to set value of column "updated_at" to current timestamp on row update';
CREATE TRIGGER "set_public_feature_flags_updated_at"
BEFORE UPDATE ON "public"."feature_flags"
FOR EACH ROW EXECUTE FUNCTION "public"."set_current_timestamp_updated_at"();
COMMENT ON TRIGGER "set_public_feature_flags_updated_at" ON "public"."feature_flags"
IS 'trigger to set value of column "updated_at" to current timestamp on row update';

View File

@@ -0,0 +1,11 @@
ALTER TABLE "public"."feature_flags"
DROP CONSTRAINT IF EXISTS "feature_flags_default_treatment_check";
ALTER TABLE "public"."feature_flags"
ADD CONSTRAINT "feature_flags_default_treatment_check" CHECK ("default_treatment" IN ('on', 'off', 'control'));
ALTER TABLE "public"."bodyshop_feature_flags"
DROP CONSTRAINT IF EXISTS "bodyshop_feature_flags_treatment_check";
ALTER TABLE "public"."bodyshop_feature_flags"
ADD CONSTRAINT "bodyshop_feature_flags_treatment_check" CHECK ("treatment" IN ('on', 'off', 'control'));

View File

@@ -0,0 +1,11 @@
ALTER TABLE "public"."feature_flags"
DROP CONSTRAINT IF EXISTS "feature_flags_default_treatment_check";
ALTER TABLE "public"."feature_flags"
ADD CONSTRAINT "feature_flags_default_treatment_check" CHECK (length(btrim("default_treatment")) > 0);
ALTER TABLE "public"."bodyshop_feature_flags"
DROP CONSTRAINT IF EXISTS "bodyshop_feature_flags_treatment_check";
ALTER TABLE "public"."bodyshop_feature_flags"
ADD CONSTRAINT "bodyshop_feature_flags_treatment_check" CHECK (length(btrim("treatment")) > 0);

View File

@@ -0,0 +1,2 @@
DELETE FROM "public"."feature_flags"
WHERE "name" = 'TEST_FLAG';

View File

@@ -0,0 +1,3 @@
INSERT INTO "public"."feature_flags" ("name", "description")
VALUES ('TEST_FLAG', 'Manual test flag used to verify frontend feature flag plumbing.')
ON CONFLICT ("name") DO NOTHING;

2166
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,66 +15,67 @@
"lint:fix": "eslint . --fix",
"test:unit": "vitest run",
"test:watch": "vitest",
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js",
"feature-flags:export-harness": "node scripts/export-harness-feature-flags.js"
},
"dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.1020.0",
"@aws-sdk/client-elasticache": "^3.1020.0",
"@aws-sdk/client-s3": "^3.1020.0",
"@aws-sdk/client-secrets-manager": "^3.1020.0",
"@aws-sdk/client-ses": "^3.1020.0",
"@aws-sdk/client-sqs": "^3.1020.0",
"@aws-sdk/client-textract": "^3.1020.0",
"@aws-sdk/credential-provider-node": "^3.972.28",
"@aws-sdk/lib-storage": "^3.1020.0",
"@aws-sdk/s3-request-presigner": "^3.1020.0",
"@documenso/sdk-typescript": "^0.8.0",
"@jsreport/nodejs-client": "^4.1.0",
"@aws-sdk/client-cloudwatch-logs": "^3.1053.0",
"@aws-sdk/client-elasticache": "^3.1053.0",
"@aws-sdk/client-s3": "^3.1053.0",
"@aws-sdk/client-secrets-manager": "^3.1053.0",
"@aws-sdk/client-ses": "^3.1053.0",
"@aws-sdk/client-sqs": "^3.1053.0",
"@aws-sdk/client-textract": "^3.1053.0",
"@aws-sdk/credential-provider-node": "^3.972.44",
"@aws-sdk/lib-storage": "^3.1053.0",
"@aws-sdk/s3-request-presigner": "^3.1053.0",
"@documenso/sdk-typescript": "^0.8.1",
"@jsreport/nodejs-client": "^4.1.1",
"@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0",
"archiver": "^7.0.1",
"aws4": "^1.13.2",
"axios": "^1.14.0",
"axios": "^1.16.1",
"axios-curlirize": "^2.0.0",
"better-queue": "^3.8.12",
"bullmq": "^5.71.1",
"bullmq": "^5.77.3",
"chart.js": "^4.5.1",
"cloudinary": "^2.9.0",
"cloudinary": "^2.10.0",
"compression": "^1.8.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"crisp-status-reporter": "^1.2.2",
"dinero.js": "^1.9.1",
"dotenv": "^17.3.1",
"dotenv": "^17.4.2",
"express": "^4.21.1",
"fast-xml-parser": "^5.5.9",
"firebase-admin": "^13.7.0",
"fuse.js": "^7.1.0",
"graphql": "^16.13.2",
"fast-xml-parser": "^5.8.0",
"firebase-admin": "^13.10.0",
"fuse.js": "^7.3.0",
"graphql": "^16.14.0",
"graphql-request": "^6.1.0",
"intuit-oauth": "^4.2.2",
"intuit-oauth": "^4.2.3",
"ioredis": "^5.10.1",
"json-2-csv": "^5.5.10",
"jsonwebtoken": "^9.0.3",
"juice": "^11.1.1",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"moment": "^2.30.1",
"moment-timezone": "^0.6.1",
"moment-timezone": "^0.6.2",
"multer": "^2.1.1",
"mustache": "^4.2.0",
"node-persist": "^4.0.4",
"nodemailer": "^6.10.0",
"normalize-url": "^9.0.0",
"normalize-url": "^9.0.1",
"pdf-lib": "^1.17.1",
"phone": "^3.1.71",
"query-string": "7.1.3",
"recursive-diff": "^1.0.9",
"rimraf": "^6.1.3",
"skia-canvas": "^3.0.8",
"soap": "^1.8.0",
"soap": "^1.9.3",
"socket.io": "^4.8.3",
"socket.io-adapter": "^2.5.6",
"socket.io-adapter": "^2.5.7",
"ssh2-sftp-client": "^11.0.0",
"twilio": "^5.13.1",
"uuid": "^11.1.0",
@@ -89,11 +90,11 @@
"@eslint/js": "^9.39.2",
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.4.0",
"globals": "^17.6.0",
"mock-require": "^3.0.3",
"p-limit": "^3.1.0",
"prettier": "^3.8.1",
"prettier": "^3.8.3",
"supertest": "^7.2.2",
"vitest": "^4.1.2"
"vitest": "^4.1.7"
}
}

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