Compare commits

...

54 Commits

Author SHA1 Message Date
Patrick Fic
d52426f5f5 IO-3239 Additional logging fixes. 2025-05-28 15:21:42 -07:00
Patrick Fic
2c508cf1a1 IO-3239 QBO Logging and integration log schema changes. 2025-05-22 11:54:17 -07:00
Patrick Fic
16a91c772a Merged in release/2025-06-02 (pull request #2330)
Release/2025 06 02
2025-05-22 16:46:27 +00:00
Dave Richer
5c47088b11 release/2025-06-02 - Lint Updates 2025-05-22 11:37:47 -04:00
Allan Carr
8e5dc4fa71 Merged in feature/IO-3243-Job-Costing-TOW (pull request #2327)
IO-3243 Job Costing TOW

Approved-by: Dave Richer
2025-05-22 15:13:34 +00:00
Allan Carr
39c3729f6d Merged in feature/IO-3230-Customer-List-Excel (pull request #2326)
IO-3230 Customer List Excel

Approved-by: Dave Richer
2025-05-22 15:13:03 +00:00
Allan Carr
e3d854e02b IO-3243 Job Costing TOW
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-05-21 17:53:33 -07:00
Allan Carr
618acf2acf IO-3230 Customer List Excel
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-05-21 16:28:09 -07:00
Dave Richer
2cf2b70293 Merged in feature/IO-3182-Phone-Number-Consent (pull request #2325)
Feature/IO-3182 Phone Number Consent
2025-05-21 19:17:52 +00:00
Dave Richer
0541afceb8 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-21 15:17:11 -04:00
Dave Richer
28ed3f9936 Merged in feature/IO-3182-Phone-Number-Consent (pull request #2324)
DO NOT MERGE JUST USING TO UNDO
2025-05-21 19:03:58 +00:00
Dave Richer
6afa50332b feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-21 15:03:02 -04:00
Dave Richer
8c8c68867d feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-21 14:39:17 -04:00
Dave Richer
8ee52598e8 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-21 14:32:35 -04:00
Allan Carr
c822028174 Merged in feature/IO-3235-FeatureAccess-VisualBoard-Color (pull request #2322)
IO-3235 FeatureAccess on VisualBoard for SmartSchedule Option of Color Cards

Approved-by: Dave Richer
2025-05-21 18:06:04 +00:00
Allan Carr
36b82c6195 Merged in feature/IO-3236-HasFeatureAccess-Date (pull request #2323)
IO-3236 HasFeatureAccess Date

Approved-by: Dave Richer
2025-05-21 18:05:23 +00:00
Allan Carr
079dffce4d IO-3236 HasFeatureAccess Date
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-05-20 17:16:27 -07:00
Allan Carr
831802f5af IO-3235 FeatureAccess on VisualBoard for SmartSchedule Option of Color Cards
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-05-20 15:25:29 -07:00
Dave Richer
7bd5190bf2 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-20 18:19:39 -04:00
Dave Richer
83860152a9 feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-20 16:04:36 -04:00
Dave Richer
1e10493615 Merged in feature/IO-3182-Phone-Number-Consent (pull request #2321)
Feature/IO-3182 Phone Number Consent
2025-05-20 17:45:24 +00:00
Dave Richer
9d81c68a4d feature/IO-3182-Phone-Number-Consent - Checkpoint 2025-05-20 13:14:05 -04:00
Dave Richer
985d066978 feature/IO-3182-Phone-Number-Consent - Finish Database changes 2025-05-20 12:49:32 -04:00
Dave Richer
6ad9e27d1d feature/IO-3182-Phone-Number-Consent - Merge master / bump deps 2025-05-20 12:38:31 -04:00
Dave Richer
19ebdda5b3 Merge remote-tracking branch 'origin/master-AIO' into feature/IO-3182-Phone-Number-Consent 2025-05-20 12:30:15 -04:00
Allan Carr
4602dd1183 Merged in master-AIO (pull request #2320)
IO-3217 OTSL Labor Type
2025-05-19 21:03:00 +00:00
Allan Carr
6005eaee6a Merged in feature/IO-3217-OTSL-Labor-Type (pull request #2319)
IO-3217 OTSL Labor Type
2025-05-19 21:02:01 +00:00
Patrick Fic
6d59e3994f Merged in feature/IO-3105-qbo-version-update (pull request #2318)
feature/IO-3105-qbo-version-update

Approved-by: Patrick Fic
2025-05-19 19:30:34 +00:00
Patrick Fic
f770b2f1b1 IO-3105 Add QBO Minor Version. 2025-05-19 12:28:50 -07:00
Patrick Fic
b014744940 IO-3239 Add integration log statements on QBO. 2025-05-19 11:10:02 -07:00
Dave Richer
714c90c25e Merge remote-tracking branch 'origin/master-AIO' into feature/IO-3182-Phone-Number-Consent 2025-05-15 18:55:23 -04:00
Dave Richer
9a3a971da6 Clear Stage 2025-05-15 18:55:20 -04:00
Dave Richer
96cba0aaab Clear Stage 2025-05-15 18:54:55 -04:00
Patrick Fic
c069600cfd Merged in hotfix/2025-05-15 (pull request #2317)
Hotfix/2025 05 15 IO-3217 IO-3066 IO-3210 IO-2328
2025-05-15 22:47:26 +00:00
Patrick Fic
186cbf2c97 Merge branch 'feature/IO-3066-ems-upload' into hotfix/2025-05-15 2025-05-15 15:47:04 -07:00
Patrick Fic
392988ae11 Io-3066 resolve typo. 2025-05-15 15:46:45 -07:00
Allan Carr
2e33b79eb9 Merged in feature/IO-3210-Podium-Datapump (pull request #2315)
IO-3210 Podium Datapump

Approved-by: Patrick Fic
2025-05-15 22:39:39 +00:00
Allan Carr
d4f718c44c Merged in feature/IO-3210-Podium-Datapump (pull request #2314)
IO-3210 Podium Datapump

Approved-by: Patrick Fic
2025-05-15 22:39:28 +00:00
Patrick Fic
fa99ef7b37 Merge branch 'feature/IO-3066-ems-upload' into hotfix/2025-05-15 2025-05-15 15:36:44 -07:00
Patrick Fic
c4aff1b516 Merge branch 'feature/IO-2328-intellipay-querystring-package' into hotfix/2025-05-15 2025-05-15 15:36:31 -07:00
Patrick Fic
61276bb2d1 IO-2328 change querystring versions. 2025-05-15 15:35:56 -07:00
Allan Carr
8b89e2eb9d IO-3210 Podium Datapump
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-05-15 15:27:59 -07:00
Patrick Fic
9ab41308e7 IO-3066 add EMS upload functionality. 2025-05-15 12:53:41 -07:00
Allan Carr
f76052ec9b Merge branch 'master-AIO' into feature/IO-3210-Podium-Datapump
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-05-15 12:41:09 -07:00
Patrick Fic
b8841e3ded Merged in release/2025-05-09 (pull request #2312)
IO-3190 Add nulll coalesce and check for non-intake events.

Approved-by: Patrick Fic
2025-05-15 00:57:44 +00:00
Patrick Fic
a49b3f6496 IO-3190 Add nulll coalesce and check for non-intake events. 2025-05-14 17:56:15 -07:00
Dave Richer
3e17ec3cf8 Merged in release/2025-05-09 (pull request #2310)
[DO NOT MERGE ] Release/2025-05-09 into master-AIO
2025-05-14 20:10:07 +00:00
Dave Richer
76c0c7c41e release/2025-05-09 - Bump Deps 2025-05-13 10:43:15 -04:00
Dave Richer
025b986f60 Merged in feature/IO-3228-Notifications-1.6-and-Deprecations (pull request #2308)
feature/IO-3228-Notifications-1.6-and-Deprecations
2025-05-09 14:39:59 +00:00
Dave Richer
6e6addd62f feature/IO-3228-Notifications-1.6-and-Deprecations
- See Ticket for full details (Notifications restrictions, AntD deprecations)
2025-05-09 10:38:19 -04:00
Dave Richer
266c3acf34 Merged in feature/IO-3214-Job-Status-Card-Extension (pull request #2306)
feature/IO-3214-Job-Status-Card-Extension - PR Notes/Package Updates
2025-05-08 15:27:53 +00:00
Dave Richer
ca18291425 Merged in feature/IO-3214-Job-Status-Card-Extension (pull request #2304)
feature/IO-3214-Job-Status-Card-Extension - Complete
2025-05-06 21:10:52 +00:00
Dave Richer
a9814c1eb1 Merged in release/2025-04-25 (pull request #2287)
Release/2025-04-25 into Master-AIO -  IO-2282, IO-3066, IO-3164, IO-3187, IO-3190, IO-3200, IO-3210, IO-3212, IO-3213, IO-3215, IO-3220, IO-3223
2025-04-26 00:43:22 +00:00
Allan Carr
12c87ed689 IO-3217 OTSL Labor Type
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-23 18:48:35 -07:00
96 changed files with 1938 additions and 1637 deletions

363
client/package-lock.json generated
View File

@@ -13,21 +13,21 @@
"@apollo/client": "^3.13.6", "@apollo/client": "^3.13.6",
"@emotion/is-prop-valid": "^1.3.1", "@emotion/is-prop-valid": "^1.3.1",
"@fingerprintjs/fingerprintjs": "^4.6.1", "@fingerprintjs/fingerprintjs": "^4.6.1",
"@firebase/analytics": "^0.10.13", "@firebase/analytics": "^0.10.16",
"@firebase/app": "^0.12.1", "@firebase/app": "^0.13.0",
"@firebase/auth": "^1.10.2", "@firebase/auth": "^1.10.5",
"@firebase/firestore": "^4.7.12", "@firebase/firestore": "^4.7.15",
"@firebase/messaging": "^0.12.18", "@firebase/messaging": "^0.12.21",
"@jsreport/browser-client": "^3.1.0", "@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.8.1", "@reduxjs/toolkit": "^2.8.2",
"@sentry/cli": "^2.45.0", "@sentry/cli": "^2.45.0",
"@sentry/react": "^9.17.0", "@sentry/react": "^9.22.0",
"@sentry/vite-plugin": "^3.4.0", "@sentry/vite-plugin": "^3.5.0",
"@splitsoftware/splitio-react": "^2.1.1", "@splitsoftware/splitio-react": "^2.1.1",
"@tanem/react-nprogress": "^5.0.53", "@tanem/react-nprogress": "^5.0.53",
"antd": "^5.25.0", "antd": "^5.25.2",
"apollo-link-logger": "^2.0.1", "apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^4.2.0", "apollo-link-sentry": "^4.3.0",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.8.4", "axios": "^1.8.4",
"classnames": "^2.5.1", "classnames": "^2.5.1",
@@ -49,7 +49,7 @@
"normalize-url": "^8.0.1", "normalize-url": "^8.0.1",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"query-string": "^9.1.2", "query-string": "^9.2.0",
"raf-schd": "^4.0.3", "raf-schd": "^4.0.3",
"react": "^18.3.1", "react": "^18.3.1",
"react-big-calendar": "^1.18.0", "react-big-calendar": "^1.18.0",
@@ -78,7 +78,7 @@
"redux-saga": "^1.3.0", "redux-saga": "^1.3.0",
"redux-state-sync": "^3.1.4", "redux-state-sync": "^3.1.4",
"reselect": "^5.1.1", "reselect": "^5.1.1",
"sass": "^1.86.3", "sass": "^1.89.0",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"styled-components": "^6.1.18", "styled-components": "^6.1.18",
"subscriptions-transport-ws": "^0.11.0", "subscriptions-transport-ws": "^0.11.0",
@@ -90,12 +90,12 @@
"@ant-design/icons": "^6.0.0", "@ant-design/icons": "^6.0.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.27.1", "@babel/preset-react": "^7.27.1",
"@dotenvx/dotenvx": "^1.43.0", "@dotenvx/dotenvx": "^1.44.1",
"@emotion/babel-plugin": "^11.13.5", "@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@eslint/js": "^9.26.0", "@eslint/js": "^9.27.0",
"@playwright/test": "^1.51.1", "@playwright/test": "^1.51.1",
"@sentry/webpack-plugin": "^3.4.0", "@sentry/webpack-plugin": "^3.5.0",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
@@ -108,7 +108,7 @@
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0", "globals": "^15.15.0",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"memfs": "^4.17.1", "memfs": "^4.17.2",
"os-browserify": "^0.3.0", "os-browserify": "^0.3.0",
"playwright": "^1.51.1", "playwright": "^1.51.1",
"react-error-overlay": "^6.1.0", "react-error-overlay": "^6.1.0",
@@ -120,7 +120,7 @@
"vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-pwa": "^1.0.0", "vite-plugin-pwa": "^1.0.0",
"vite-plugin-style-import": "^2.0.0", "vite-plugin-style-import": "^2.0.0",
"vitest": "^3.1.3", "vitest": "^3.1.4",
"workbox-window": "^7.3.0" "workbox-window": "^7.3.0"
}, },
"engines": { "engines": {
@@ -2587,9 +2587,9 @@
} }
}, },
"node_modules/@dotenvx/dotenvx": { "node_modules/@dotenvx/dotenvx": {
"version": "1.43.0", "version": "1.44.1",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.43.0.tgz", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.44.1.tgz",
"integrity": "sha512-Z8XjM75aWZ/ekUzBjlr/OqQsLWtJY4nVtruxopAt+FlYHfY0/gKl85nD16aEqbTkU53kJcm5psID0L2/sQMmuw==", "integrity": "sha512-j1QImCqf/XJmhIjC1OPpgiZV9g370HG9MNT9s/CDwCKsoYzNCPEKK+GfsidahJx7yIlBbm+4dPLlGec+bKn7oA==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
@@ -2912,13 +2912,16 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.26.0", "version": "9.27.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.26.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz",
"integrity": "sha512-I9XlJawFdSMvWjDt6wksMCrgns5ggLNfFwFvnShsleWruvXM514Qxk8V246efTw+eo9JABvVz+u3q2RiAowKxQ==", "integrity": "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://eslint.org/donate"
} }
}, },
"node_modules/@fingerprintjs/fingerprintjs": { "node_modules/@fingerprintjs/fingerprintjs": {
@@ -2931,15 +2934,15 @@
} }
}, },
"node_modules/@firebase/analytics": { "node_modules/@firebase/analytics": {
"version": "0.10.13", "version": "0.10.16",
"resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.13.tgz", "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.16.tgz",
"integrity": "sha512-X+6wMOPgA9l0AeeMdMcMfaCP4XKPvrhx55MGuMrfHvUrOvFKldpzBum7KkoGJMoexKmqmKP+mCmJMY9Fb8K6Hw==", "integrity": "sha512-cMtp19He7Fd6uaj/nDEul+8JwvJsN8aRSJyuA1QN3QrKvfDDp+efjVurJO61sJpkVftw9O9nNMdhFbRcTmTfRQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@firebase/component": "0.6.14", "@firebase/component": "0.6.17",
"@firebase/installations": "0.6.14", "@firebase/installations": "0.6.17",
"@firebase/logger": "0.4.4", "@firebase/logger": "0.4.4",
"@firebase/util": "1.11.1", "@firebase/util": "1.12.0",
"tslib": "^2.1.0" "tslib": "^2.1.0"
}, },
"peerDependencies": { "peerDependencies": {
@@ -2947,14 +2950,14 @@
} }
}, },
"node_modules/@firebase/app": { "node_modules/@firebase/app": {
"version": "0.12.1", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.12.1.tgz", "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.13.0.tgz",
"integrity": "sha512-ASExOlmmjRMdwOQ65Oj6R9JBqa7iiT1/LgZjtbU7FqxoJZNWHrt39NJ/z2bjyYDdAHX8jkY7muFqzahScCXgfA==", "integrity": "sha512-Vj3MST245nq+V5UmmfEkB3isIgPouyUr8yGJlFeL9Trg/umG5ogAvrjAYvQ8gV7daKDoQSRnJKWI2JFpQqRsuQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@firebase/component": "0.6.14", "@firebase/component": "0.6.17",
"@firebase/logger": "0.4.4", "@firebase/logger": "0.4.4",
"@firebase/util": "1.11.1", "@firebase/util": "1.12.0",
"idb": "7.1.1", "idb": "7.1.1",
"tslib": "^2.1.0" "tslib": "^2.1.0"
}, },
@@ -2963,14 +2966,14 @@
} }
}, },
"node_modules/@firebase/auth": { "node_modules/@firebase/auth": {
"version": "1.10.2", "version": "1.10.5",
"resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.10.2.tgz", "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.10.5.tgz",
"integrity": "sha512-HHudcj3CJyXpoMKslNOVHGSNJdAUjvy5xBA/G/uPb32QFqvx5F3EW9RDYvve2IHEN7Vpc1QTkk/28J32x83UGA==", "integrity": "sha512-6wF/NdMTwObL4RNQePunuzMr9O3gyftisvFZFFKf57D2HONXo87YymogRV8d+Z7SLA0rcNBN1gLJVk2D0y97gA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@firebase/component": "0.6.14", "@firebase/component": "0.6.17",
"@firebase/logger": "0.4.4", "@firebase/logger": "0.4.4",
"@firebase/util": "1.11.1", "@firebase/util": "1.12.0",
"tslib": "^2.1.0" "tslib": "^2.1.0"
}, },
"engines": { "engines": {
@@ -2987,12 +2990,12 @@
} }
}, },
"node_modules/@firebase/component": { "node_modules/@firebase/component": {
"version": "0.6.14", "version": "0.6.17",
"resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.14.tgz", "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.17.tgz",
"integrity": "sha512-kf/zAT8GQJ9nYoHuj0mv7twp1QzifKYrO+GsmsVHHM+Hi9KkmI7E3B3J0CtihHpb34vinl4gbJrYJ2p2wfvc9A==", "integrity": "sha512-M6DOg7OySrKEFS8kxA3MU5/xc37fiOpKPMz6cTsMUcsuKB6CiZxxNAvgFta8HGRgEpZbi8WjGIj6Uf+TpOhyzg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@firebase/util": "1.11.1", "@firebase/util": "1.12.0",
"tslib": "^2.1.0" "tslib": "^2.1.0"
}, },
"engines": { "engines": {
@@ -3000,14 +3003,14 @@
} }
}, },
"node_modules/@firebase/firestore": { "node_modules/@firebase/firestore": {
"version": "4.7.12", "version": "4.7.15",
"resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.7.12.tgz", "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.7.15.tgz",
"integrity": "sha512-50KRdSp8xA7+G0wfWxlnCoEN951mt8BVdLMxeP57Rehj2DqIb41q6Fc6JH0dfQ4TlMqWua1YfVY1jPEAaHVF9w==", "integrity": "sha512-FgWTmkNBEXdKCoN2ngBNjrMaXuBx6QwjiZZVnOGg+VjUmiBq5gAqlDIW5bZY6i/NYvLUrWugdqIs7y9GHEqwww==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@firebase/component": "0.6.14", "@firebase/component": "0.6.17",
"@firebase/logger": "0.4.4", "@firebase/logger": "0.4.4",
"@firebase/util": "1.11.1", "@firebase/util": "1.12.0",
"@firebase/webchannel-wrapper": "1.0.3", "@firebase/webchannel-wrapper": "1.0.3",
"@grpc/grpc-js": "~1.9.0", "@grpc/grpc-js": "~1.9.0",
"@grpc/proto-loader": "^0.7.8", "@grpc/proto-loader": "^0.7.8",
@@ -3021,13 +3024,13 @@
} }
}, },
"node_modules/@firebase/installations": { "node_modules/@firebase/installations": {
"version": "0.6.14", "version": "0.6.17",
"resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.14.tgz", "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.17.tgz",
"integrity": "sha512-uE837g9+sv6PfjWPgOfG3JtjZ+hJ7KBHO4UVenVsvhzgOxFkvLjO/bgE7fyvsaD3fOHSXunx3adRIg4eUEMPyA==", "integrity": "sha512-zfhqCNJZRe12KyADtRrtOj+SeSbD1H/K8J24oQAJVv/u02eQajEGlhZtcx9Qk7vhGWF5z9dvIygVDYqLL4o1XQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@firebase/component": "0.6.14", "@firebase/component": "0.6.17",
"@firebase/util": "1.11.1", "@firebase/util": "1.12.0",
"idb": "7.1.1", "idb": "7.1.1",
"tslib": "^2.1.0" "tslib": "^2.1.0"
}, },
@@ -3048,15 +3051,15 @@
} }
}, },
"node_modules/@firebase/messaging": { "node_modules/@firebase/messaging": {
"version": "0.12.18", "version": "0.12.21",
"resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.18.tgz", "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.21.tgz",
"integrity": "sha512-2MGhUGoCZloB7ysoYzG/T2nnRmHYLT+AcqYouZuD6APabpkDhF8lHsmSQq4MFSlXhI3DKFOXxjuvbY8ec4C2JQ==", "integrity": "sha512-bYJ2Evj167Z+lJ1ach6UglXz5dUKY1zrJZd15GagBUJSR7d9KfiM1W8dsyL0lDxcmhmA/sLaBYAAhF1uilwN0g==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@firebase/component": "0.6.14", "@firebase/component": "0.6.17",
"@firebase/installations": "0.6.14", "@firebase/installations": "0.6.17",
"@firebase/messaging-interop-types": "0.2.3", "@firebase/messaging-interop-types": "0.2.3",
"@firebase/util": "1.11.1", "@firebase/util": "1.12.0",
"idb": "7.1.1", "idb": "7.1.1",
"tslib": "^2.1.0" "tslib": "^2.1.0"
}, },
@@ -3071,9 +3074,9 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@firebase/util": { "node_modules/@firebase/util": {
"version": "1.11.1", "version": "1.12.0",
"resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.12.0.tgz",
"integrity": "sha512-RXg4WE8C2LUrvoV/TMGRTu223zZf9Dq9MR8yHZio9nF9TpLnpCPURw9VWWB2WATDl6HfIdWfl2x2SJYtHkN4hw==", "integrity": "sha512-Z4rK23xBCwgKDqmzGVMef+Vb4xso2j5Q8OG0vVL4m4fA5ZjPMYQazu8OJJC3vtQRC3SQ/Pgx/6TPNVsCd70QRw==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -3836,9 +3839,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@reduxjs/toolkit": { "node_modules/@reduxjs/toolkit": {
"version": "2.8.1", "version": "2.8.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.1.tgz", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz",
"integrity": "sha512-GLjHS13LiBdiuxSJvfWs3+Cx5yt97mCbuVlDteTusS6VRksPhoWviO8L1e3Re1G94m6lkw/l4pjEEyyNaGf19g==", "integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
@@ -4458,88 +4461,88 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@sentry-internal/browser-utils": { "node_modules/@sentry-internal/browser-utils": {
"version": "9.17.0", "version": "9.22.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.17.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.22.0.tgz",
"integrity": "sha512-37n6NXtkUfdK7YiP3L5DJvhA/iusOmnjHQdX1e2VwI6a29xHCl/vRqLR3XNr5K4m+49al+3fWo2ltcKsfV+0xw==", "integrity": "sha512-Ou1tBnVxFAIn8i9gvrWzRotNJQYiu3awNXpsFCw6qFwmiKAVPa6b13vCdolhXnrIiuR77jY1LQnKh9hXpoRzsg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry/core": "9.17.0" "@sentry/core": "9.22.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry-internal/feedback": { "node_modules/@sentry-internal/feedback": {
"version": "9.17.0", "version": "9.22.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.17.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.22.0.tgz",
"integrity": "sha512-C2jBlGgYVGm8eXK38wlYQyd6NsHKaQlENg5fx8TDFMKWMNmLf6BmnPZ+y73OsFwcUtBz04CwZteybYB2GgYrvQ==", "integrity": "sha512-zgMVkoC61fgi41zLcSZA59vOtKxcLrKBo1ECYhPD1hxEaneNqY5fhXDwlQBw96P5l2yqkgfX6YZtSdU4ejI9yA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry/core": "9.17.0" "@sentry/core": "9.22.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry-internal/replay": { "node_modules/@sentry-internal/replay": {
"version": "9.17.0", "version": "9.22.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.17.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.22.0.tgz",
"integrity": "sha512-oH4NolXkEpe73eRP9r3K6WpERYItZisYQudsNrtkUBQL5M/uENiE7YTOvL5osD8AWmU0hCKY3Oua+qDi2lB+8g==", "integrity": "sha512-9GOycoKbrclcRXfcbNV8svbmAsOS5R4wXBQmKF4pFLkmFA/lJv9kdZSNYkRvkrxdNfbMIJXP+DV9EqTZcryXig==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry-internal/browser-utils": "9.17.0", "@sentry-internal/browser-utils": "9.22.0",
"@sentry/core": "9.17.0" "@sentry/core": "9.22.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry-internal/replay-canvas": { "node_modules/@sentry-internal/replay-canvas": {
"version": "9.17.0", "version": "9.22.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.17.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.22.0.tgz",
"integrity": "sha512-w9AxBJIa+MbxDngvwnqouoJ/ezb7wNjxzFXtmaVtGp7hbC4yme/TOTNtFYg2J/ceQf3GMc8AfW5tsP6zU0R7gg==", "integrity": "sha512-EcG9IMSEalFe49kowBTJObWjof/iHteDwpyuAszsFDdQUYATrVUtwpwN7o52vDYWJud4arhjrQnMamIGxa79eQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry-internal/replay": "9.17.0", "@sentry-internal/replay": "9.22.0",
"@sentry/core": "9.17.0" "@sentry/core": "9.22.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry/babel-plugin-component-annotate": { "node_modules/@sentry/babel-plugin-component-annotate": {
"version": "3.4.0", "version": "3.5.0",
"resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.5.0.tgz",
"integrity": "sha512-tSzfc3aE7m0PM0Aj7HBDet5llH9AB9oc+tBQ8AvOqUSnWodLrNCuWeQszJ7mIBovD3figgCU3h0cvI6U5cDtsg==", "integrity": "sha512-s2go8w03CDHbF9luFGtBHKJp4cSpsQzNVqgIa9Pfa4wnjipvrK6CxVT4icpLA3YO6kg5u622Yoa5GF3cJdippw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/@sentry/browser": { "node_modules/@sentry/browser": {
"version": "9.17.0", "version": "9.22.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.17.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.22.0.tgz",
"integrity": "sha512-3e/Q5bv06Q+XYV2cKmUgfMfnJtBY8MZKufpcwQ2ab2eMrastqau9KjYeWXapskDm179oPLfzLcDCSlDSTcvqpQ==", "integrity": "sha512-3TeRm74dvX0JdjX0AgkQa+22iUHwHnY+Q6M05NZ+tDeCNHGK/mEBTeqquS1oQX67jWyuvYmG3VV6RJUxtG9Paw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry-internal/browser-utils": "9.17.0", "@sentry-internal/browser-utils": "9.22.0",
"@sentry-internal/feedback": "9.17.0", "@sentry-internal/feedback": "9.22.0",
"@sentry-internal/replay": "9.17.0", "@sentry-internal/replay": "9.22.0",
"@sentry-internal/replay-canvas": "9.17.0", "@sentry-internal/replay-canvas": "9.22.0",
"@sentry/core": "9.17.0" "@sentry/core": "9.22.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry/bundler-plugin-core": { "node_modules/@sentry/bundler-plugin-core": {
"version": "3.4.0", "version": "3.5.0",
"resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-3.5.0.tgz",
"integrity": "sha512-X1Q41AsQ6xcT6hB4wYmBDBukndKM/inT4IsR7pdKLi7ICpX2Qq6lisamBAEPCgEvnLpazSFguaiC0uiwMKAdqw==", "integrity": "sha512-zDzPrhJqAAy2VzV4g540qAZH4qxzisstK2+NIJPZUUKztWRWUV2cMHsyUtdctYgloGkLyGpZJBE3RE6dmP/xqQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/core": "^7.18.5", "@babel/core": "^7.18.5",
"@sentry/babel-plugin-component-annotate": "3.4.0", "@sentry/babel-plugin-component-annotate": "3.5.0",
"@sentry/cli": "2.42.2", "@sentry/cli": "2.42.2",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"find-up": "^5.0.0", "find-up": "^5.0.0",
@@ -4899,22 +4902,22 @@
} }
}, },
"node_modules/@sentry/core": { "node_modules/@sentry/core": {
"version": "9.17.0", "version": "9.22.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.17.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.22.0.tgz",
"integrity": "sha512-9f1A93/kY9lLH06L1thPx94IhyLjEP3aRxYAtjtBfzId8UtubSpwP92sbxgslodD73R4tURwWJj7nYZ9HLYBUg==", "integrity": "sha512-ixvtKmPF42Y6ckGUbFlB54OWI75H2gO5UYHojO6eXFpS7xO3ZGgV/QH6wb40mWK+0w5XZ0233FuU9VpsuE6mKA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry/react": { "node_modules/@sentry/react": {
"version": "9.17.0", "version": "9.22.0",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-9.17.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/react/-/react-9.22.0.tgz",
"integrity": "sha512-hJOVUheFoUKr5e4vHxyKiu72FRgqmMTFIUG9myim8PH8mJYDqab7Z7cOt4dsBR86soKanaRB5PJq5jGFuipLfg==", "integrity": "sha512-mI43NnioBYdG5TiXqRlhV1feZs9bnrrl+k5HOHBK7VQtymaXO0fkcsRLZTkdSgLRLMJGasZuvVhq2xK+18QyWQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry/browser": "9.17.0", "@sentry/browser": "9.22.0",
"@sentry/core": "9.17.0", "@sentry/core": "9.22.0",
"hoist-non-react-statics": "^3.3.2" "hoist-non-react-statics": "^3.3.2"
}, },
"engines": { "engines": {
@@ -4925,12 +4928,12 @@
} }
}, },
"node_modules/@sentry/vite-plugin": { "node_modules/@sentry/vite-plugin": {
"version": "3.4.0", "version": "3.5.0",
"resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-3.5.0.tgz",
"integrity": "sha512-pUFBGrKsHuc8K6A7B1wU2nx65n9aIzvTlcHX9yZ1qvjEO0cZFih0JCwu1Fcav/yrtT9RMN44L/ugu/kMBHQhjQ==", "integrity": "sha512-jUnpTdpicG8wefamw7eNo2uO+Q3KCbOAiF76xH4gfNHSW6TN2hBfOtmLu7J+ive4c0Al3+NEHz19bIPR0lkwWg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry/bundler-plugin-core": "3.4.0", "@sentry/bundler-plugin-core": "3.5.0",
"unplugin": "1.0.1" "unplugin": "1.0.1"
}, },
"engines": { "engines": {
@@ -4938,13 +4941,13 @@
} }
}, },
"node_modules/@sentry/webpack-plugin": { "node_modules/@sentry/webpack-plugin": {
"version": "3.4.0", "version": "3.5.0",
"resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-3.4.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-3.5.0.tgz",
"integrity": "sha512-i+nAxxniJV5ovijojjTF5n+Yj08Xk8my+vm8+oo0C0I7xcnI2gOKft6B0sJOq01CNbo85X5m/3/edL0PKoWE9w==", "integrity": "sha512-xvclj0QY2HyU7uJLzOlHSrZQBDwfnGKJxp8mmlU4L7CwmK+8xMCqlO7tYZoqE4K/wU3c2xpXql70x8qmvNMxzQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry/bundler-plugin-core": "3.4.0", "@sentry/bundler-plugin-core": "3.5.0",
"unplugin": "1.0.1", "unplugin": "1.0.1",
"uuid": "^9.0.0" "uuid": "^9.0.0"
}, },
@@ -5810,14 +5813,14 @@
} }
}, },
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "3.1.3", "version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.3.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.4.tgz",
"integrity": "sha512-7FTQQuuLKmN1Ig/h+h/GO+44Q1IlglPlR2es4ab7Yvfx+Uk5xsv+Ykk+MEt/M2Yn/xGmzaLKxGw2lgy2bwuYqg==", "integrity": "sha512-xkD/ljeliyaClDYqHPNCiJ0plY5YIcM0OlRiZizLhlPmpXWpxnGMyTZXOHFhFeG7w9P5PBeL4IdtJ/HeQwTbQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/spy": "3.1.3", "@vitest/spy": "3.1.4",
"@vitest/utils": "3.1.3", "@vitest/utils": "3.1.4",
"chai": "^5.2.0", "chai": "^5.2.0",
"tinyrainbow": "^2.0.0" "tinyrainbow": "^2.0.0"
}, },
@@ -5826,13 +5829,13 @@
} }
}, },
"node_modules/@vitest/mocker": { "node_modules/@vitest/mocker": {
"version": "3.1.3", "version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.3.tgz", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.4.tgz",
"integrity": "sha512-PJbLjonJK82uCWHjzgBJZuR7zmAOrSvKk1QBxrennDIgtH4uK0TB1PvYmc0XBCigxxtiAVPfWtAdy4lpz8SQGQ==", "integrity": "sha512-8IJ3CvwtSw/EFXqWFL8aCMu+YyYXG2WUSrQbViOZkWTKTVicVwZ/YiEZDSqD00kX+v/+W+OnxhNWoeVKorHygA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/spy": "3.1.3", "@vitest/spy": "3.1.4",
"estree-walker": "^3.0.3", "estree-walker": "^3.0.3",
"magic-string": "^0.30.17" "magic-string": "^0.30.17"
}, },
@@ -5873,9 +5876,9 @@
} }
}, },
"node_modules/@vitest/pretty-format": { "node_modules/@vitest/pretty-format": {
"version": "3.1.3", "version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.3.tgz", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.4.tgz",
"integrity": "sha512-i6FDiBeJUGLDKADw2Gb01UtUNb12yyXAqC/mmRWuYl+m/U9GS7s8us5ONmGkGpUUo7/iAYzI2ePVfOZTYvUifA==", "integrity": "sha512-cqv9H9GvAEoTaoq+cYqUTCGscUjKqlJZC7PRwY5FMySVj5J+xOm1KQcCiYHJOEzOKRUhLH4R2pTwvFlWCEScsg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -5886,13 +5889,13 @@
} }
}, },
"node_modules/@vitest/runner": { "node_modules/@vitest/runner": {
"version": "3.1.3", "version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.3.tgz", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.4.tgz",
"integrity": "sha512-Tae+ogtlNfFei5DggOsSUvkIaSuVywujMj6HzR97AHK6XK8i3BuVyIifWAm/sE3a15lF5RH9yQIrbXYuo0IFyA==", "integrity": "sha512-djTeF1/vt985I/wpKVFBMWUlk/I7mb5hmD5oP8K9ACRmVXgKTae3TUOtXAEBfslNKPzUQvnKhNd34nnRSYgLNQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/utils": "3.1.3", "@vitest/utils": "3.1.4",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
"funding": { "funding": {
@@ -5907,13 +5910,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vitest/snapshot": { "node_modules/@vitest/snapshot": {
"version": "3.1.3", "version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.3.tgz", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.4.tgz",
"integrity": "sha512-XVa5OPNTYUsyqG9skuUkFzAeFnEzDp8hQu7kZ0N25B1+6KjGm4hWLtURyBbsIAOekfWQ7Wuz/N/XXzgYO3deWQ==", "integrity": "sha512-JPHf68DvuO7vilmvwdPr9TS0SuuIzHvxeaCkxYcCD4jTk67XwL45ZhEHFKIuCm8CYstgI6LZ4XbwD6ANrwMpFg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "3.1.3", "@vitest/pretty-format": "3.1.4",
"magic-string": "^0.30.17", "magic-string": "^0.30.17",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
@@ -5939,9 +5942,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vitest/spy": { "node_modules/@vitest/spy": {
"version": "3.1.3", "version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.3.tgz", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.4.tgz",
"integrity": "sha512-x6w+ctOEmEXdWaa6TO4ilb7l9DxPR5bwEb6hILKuxfU1NqWT2mpJD9NJN7t3OTfxmVlOMrvtoFJGdgyzZ605lQ==", "integrity": "sha512-Xg1bXhu+vtPXIodYN369M86K8shGLouNjoVI78g8iAq2rFoHFdajNvJJ5A/9bPMFcfQqdaCpOgWKEoMQg/s0Yg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -5952,13 +5955,13 @@
} }
}, },
"node_modules/@vitest/utils": { "node_modules/@vitest/utils": {
"version": "3.1.3", "version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.3.tgz", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.4.tgz",
"integrity": "sha512-2Ltrpht4OmHO9+c/nmHtF09HWiyWdworqnHIwjfvDyWjuwKbdkcS9AnhsDn+8E2RM4x++foD1/tNuLPVvWG1Rg==", "integrity": "sha512-yriMuO1cfFhmiGc8ataN51+9ooHRuURdfAZfwFd3usWynjzpLslZdYnRegTv32qdgtJTsj15FoeZe2g15fY1gg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "3.1.3", "@vitest/pretty-format": "3.1.4",
"loupe": "^3.1.3", "loupe": "^3.1.3",
"tinyrainbow": "^2.0.0" "tinyrainbow": "^2.0.0"
}, },
@@ -6088,9 +6091,9 @@
} }
}, },
"node_modules/antd": { "node_modules/antd": {
"version": "5.25.0", "version": "5.25.2",
"resolved": "https://registry.npmjs.org/antd/-/antd-5.25.0.tgz", "resolved": "https://registry.npmjs.org/antd/-/antd-5.25.2.tgz",
"integrity": "sha512-p9d8Kuj/bipjNdg9NrTu1VmTrhcwIhURu2NfK6qaBMbb+LRyFdAUoseT+7J4a+5z3jNVjxH5zaYv/45Zf8Coyg==", "integrity": "sha512-7R2nUvlHhey7Trx64+hCtGXOiy+DTUs1Lv5bwbV1LzEIZIhWb0at1AM6V3K108a5lyoR9n7DX3ptlLF7uYV/DQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ant-design/colors": "^7.2.0", "@ant-design/colors": "^7.2.0",
@@ -6128,11 +6131,11 @@
"rc-rate": "~2.13.1", "rc-rate": "~2.13.1",
"rc-resize-observer": "^1.4.3", "rc-resize-observer": "^1.4.3",
"rc-segmented": "~2.7.0", "rc-segmented": "~2.7.0",
"rc-select": "~14.16.6", "rc-select": "~14.16.8",
"rc-slider": "~11.1.8", "rc-slider": "~11.1.8",
"rc-steps": "~6.0.1", "rc-steps": "~6.0.1",
"rc-switch": "~4.1.0", "rc-switch": "~4.1.0",
"rc-table": "~7.50.4", "rc-table": "~7.50.5",
"rc-tabs": "~15.6.1", "rc-tabs": "~15.6.1",
"rc-textarea": "~1.10.0", "rc-textarea": "~1.10.0",
"rc-tooltip": "~6.4.0", "rc-tooltip": "~6.4.0",
@@ -6232,9 +6235,9 @@
} }
}, },
"node_modules/apollo-link-sentry": { "node_modules/apollo-link-sentry": {
"version": "4.2.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/apollo-link-sentry/-/apollo-link-sentry-4.2.0.tgz", "resolved": "https://registry.npmjs.org/apollo-link-sentry/-/apollo-link-sentry-4.3.0.tgz",
"integrity": "sha512-w8EUM4aEw1/VxIB3KOP11T8qz44oWRcbXRd2vJq/qHnfRMKS5HkMerSIYwKN2e8k9H8ubfkwBvStH51CVf4wwg==", "integrity": "sha512-C3WK4iwIzW5vC5BoY3VPdKjm16P6ca/LGKFnxg6PvUuboxPlqs7LHQCYvEsdAxBkoY+8kRXd8Q3+3oU+HHUceA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"deepmerge": "^4.2.2", "deepmerge": "^4.2.2",
@@ -11833,9 +11836,9 @@
} }
}, },
"node_modules/memfs": { "node_modules/memfs": {
"version": "4.17.1", "version": "4.17.2",
"resolved": "https://registry.npmjs.org/memfs/-/memfs-4.17.1.tgz", "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.17.2.tgz",
"integrity": "sha512-thuTRd7F4m4dReCIy7vv4eNYnU6XI/tHMLSMMHLiortw/Y0QxqKtinG523U2aerzwYWGi606oBP4oMPy4+edag==", "integrity": "sha512-NgYhCOWgovOXSzvYgUW0LQ7Qy72rWQMGGFJDoWg4G30RHd3z77VbYdtJ4fembJXBy8pMIUA31XNAupobOQlwdg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -13569,9 +13572,9 @@
} }
}, },
"node_modules/query-string": { "node_modules/query-string": {
"version": "9.1.2", "version": "9.2.0",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-9.1.2.tgz", "resolved": "https://registry.npmjs.org/query-string/-/query-string-9.2.0.tgz",
"integrity": "sha512-s3UlTyjxRux4KjwWaJsjh1Mp8zoCkSGKirbD9H89pEM9UOZsfpRZpdfzvsy2/mGlLfC3NnYVpy2gk7jXITHEtA==", "integrity": "sha512-YIRhrHujoQxhexwRLxfy3VSjOXmvZRd2nyw1PwL1UUqZ/ys1dEZd1+NSgXkne2l/4X/7OXkigEAuhTX0g/ivJQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"decode-uri-component": "^0.4.1", "decode-uri-component": "^0.4.1",
@@ -14024,9 +14027,9 @@
} }
}, },
"node_modules/rc-select": { "node_modules/rc-select": {
"version": "14.16.6", "version": "14.16.8",
"resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.6.tgz", "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz",
"integrity": "sha512-YPMtRPqfZWOm2XGTbx5/YVr1HT0vn//8QS77At0Gjb3Lv+Lbut0IORJPKLWu1hQ3u4GsA0SrDzs7nI8JG7Zmyg==", "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.10.1", "@babel/runtime": "^7.10.1",
@@ -14097,9 +14100,9 @@
} }
}, },
"node_modules/rc-table": { "node_modules/rc-table": {
"version": "7.50.4", "version": "7.50.5",
"resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.50.4.tgz", "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.50.5.tgz",
"integrity": "sha512-Y+YuncnQqoS5e7yHvfvlv8BmCvwDYDX/2VixTBEhkMDk9itS9aBINp4nhzXFKiBP/frG4w0pS9d9Rgisl0T1Bw==", "integrity": "sha512-FDZu8aolhSYd3v9KOc3lZOVAU77wmRRu44R0Wfb8Oj1dXRUsloFaXMSl6f7yuWZUxArJTli7k8TEOX2mvhDl4A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.10.1", "@babel/runtime": "^7.10.1",
@@ -15375,9 +15378,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/sass": { "node_modules/sass": {
"version": "1.87.0", "version": "1.89.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.87.0.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.89.0.tgz",
"integrity": "sha512-d0NoFH4v6SjEK7BoX810Jsrhj7IQSYHAHLi/iSpgqKc7LaIDshFRlSg5LOymf9FqQhxEHs2W5ZQXlvy0KD45Uw==", "integrity": "sha512-ld+kQU8YTdGNjOLfRWBzewJpU5cwEv/h5yyqlSeJcj6Yh8U4TDA9UA5FPicqDz/xgRPWRSYIQNiFks21TbA9KQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
@@ -17533,9 +17536,9 @@
} }
}, },
"node_modules/vite-node": { "node_modules/vite-node": {
"version": "3.1.3", "version": "3.1.4",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.3.tgz", "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.4.tgz",
"integrity": "sha512-uHV4plJ2IxCl4u1up1FQRrqclylKAogbtBfOTwcuJ28xFi+89PZ57BRh+naIRvH70HPwxy5QHYzg1OrEaC7AbA==", "integrity": "sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -17731,19 +17734,19 @@
} }
}, },
"node_modules/vitest": { "node_modules/vitest": {
"version": "3.1.3", "version": "3.1.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.3.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.4.tgz",
"integrity": "sha512-188iM4hAHQ0km23TN/adso1q5hhwKqUpv+Sd6p5sOuh6FhQnRNW3IsiIpvxqahtBabsJ2SLZgmGSpcYK4wQYJw==", "integrity": "sha512-Ta56rT7uWxCSJXlBtKgIlApJnT6e6IGmTYxYcmxjJ4ujuZDI59GUQgVDObXXJujOmPDBYXHK1qmaGtneu6TNIQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/expect": "3.1.3", "@vitest/expect": "3.1.4",
"@vitest/mocker": "3.1.3", "@vitest/mocker": "3.1.4",
"@vitest/pretty-format": "^3.1.3", "@vitest/pretty-format": "^3.1.4",
"@vitest/runner": "3.1.3", "@vitest/runner": "3.1.4",
"@vitest/snapshot": "3.1.3", "@vitest/snapshot": "3.1.4",
"@vitest/spy": "3.1.3", "@vitest/spy": "3.1.4",
"@vitest/utils": "3.1.3", "@vitest/utils": "3.1.4",
"chai": "^5.2.0", "chai": "^5.2.0",
"debug": "^4.4.0", "debug": "^4.4.0",
"expect-type": "^1.2.1", "expect-type": "^1.2.1",
@@ -17756,7 +17759,7 @@
"tinypool": "^1.0.2", "tinypool": "^1.0.2",
"tinyrainbow": "^2.0.0", "tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0", "vite": "^5.0.0 || ^6.0.0",
"vite-node": "3.1.3", "vite-node": "3.1.4",
"why-is-node-running": "^2.3.0" "why-is-node-running": "^2.3.0"
}, },
"bin": { "bin": {
@@ -17772,8 +17775,8 @@
"@edge-runtime/vm": "*", "@edge-runtime/vm": "*",
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.1.3", "@vitest/browser": "3.1.4",
"@vitest/ui": "3.1.3", "@vitest/ui": "3.1.4",
"happy-dom": "*", "happy-dom": "*",
"jsdom": "*" "jsdom": "*"
}, },

View File

@@ -12,21 +12,21 @@
"@apollo/client": "^3.13.6", "@apollo/client": "^3.13.6",
"@emotion/is-prop-valid": "^1.3.1", "@emotion/is-prop-valid": "^1.3.1",
"@fingerprintjs/fingerprintjs": "^4.6.1", "@fingerprintjs/fingerprintjs": "^4.6.1",
"@firebase/analytics": "^0.10.13", "@firebase/analytics": "^0.10.16",
"@firebase/app": "^0.12.1", "@firebase/app": "^0.13.0",
"@firebase/auth": "^1.10.2", "@firebase/auth": "^1.10.5",
"@firebase/firestore": "^4.7.12", "@firebase/firestore": "^4.7.15",
"@firebase/messaging": "^0.12.18", "@firebase/messaging": "^0.12.21",
"@jsreport/browser-client": "^3.1.0", "@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.8.1", "@reduxjs/toolkit": "^2.8.2",
"@sentry/cli": "^2.45.0", "@sentry/cli": "^2.45.0",
"@sentry/react": "^9.17.0", "@sentry/react": "^9.22.0",
"@sentry/vite-plugin": "^3.4.0", "@sentry/vite-plugin": "^3.5.0",
"@splitsoftware/splitio-react": "^2.1.1", "@splitsoftware/splitio-react": "^2.1.1",
"@tanem/react-nprogress": "^5.0.53", "@tanem/react-nprogress": "^5.0.53",
"antd": "^5.25.0", "antd": "^5.25.2",
"apollo-link-logger": "^2.0.1", "apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^4.2.0", "apollo-link-sentry": "^4.3.0",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.8.4", "axios": "^1.8.4",
"classnames": "^2.5.1", "classnames": "^2.5.1",
@@ -48,7 +48,7 @@
"normalize-url": "^8.0.1", "normalize-url": "^8.0.1",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"query-string": "^9.1.2", "query-string": "^9.2.0",
"raf-schd": "^4.0.3", "raf-schd": "^4.0.3",
"react": "^18.3.1", "react": "^18.3.1",
"react-big-calendar": "^1.18.0", "react-big-calendar": "^1.18.0",
@@ -77,7 +77,7 @@
"redux-saga": "^1.3.0", "redux-saga": "^1.3.0",
"redux-state-sync": "^3.1.4", "redux-state-sync": "^3.1.4",
"reselect": "^5.1.1", "reselect": "^5.1.1",
"sass": "^1.86.3", "sass": "^1.89.0",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"styled-components": "^6.1.18", "styled-components": "^6.1.18",
"subscriptions-transport-ws": "^0.11.0", "subscriptions-transport-ws": "^0.11.0",
@@ -130,12 +130,12 @@
"@ant-design/icons": "^6.0.0", "@ant-design/icons": "^6.0.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.27.1", "@babel/preset-react": "^7.27.1",
"@dotenvx/dotenvx": "^1.43.0", "@dotenvx/dotenvx": "^1.44.1",
"@emotion/babel-plugin": "^11.13.5", "@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@eslint/js": "^9.26.0", "@eslint/js": "^9.27.0",
"@playwright/test": "^1.51.1", "@playwright/test": "^1.51.1",
"@sentry/webpack-plugin": "^3.4.0", "@sentry/webpack-plugin": "^3.5.0",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
@@ -148,7 +148,7 @@
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0", "globals": "^15.15.0",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"memfs": "^4.17.1", "memfs": "^4.17.2",
"os-browserify": "^0.3.0", "os-browserify": "^0.3.0",
"playwright": "^1.51.1", "playwright": "^1.51.1",
"react-error-overlay": "^6.1.0", "react-error-overlay": "^6.1.0",
@@ -160,7 +160,7 @@
"vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-pwa": "^1.0.0", "vite-plugin-pwa": "^1.0.0",
"vite-plugin-style-import": "^2.0.0", "vite-plugin-style-import": "^2.0.0",
"vitest": "^3.1.3", "vitest": "^3.1.4",
"workbox-window": "^7.3.0" "workbox-window": "^7.3.0"
} }
} }

View File

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

View File

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

View File

@@ -412,7 +412,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
)} )}
</Space> </Space>
} }
destroyOnClose destroyOnHidden
> >
<Form <Form
onFinish={handleFinish} onFinish={handleFinish}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,7 +37,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only", nextFetchPolicy: "network-only",
variables: { variables: {
jobId: conversation.job_conversations[0] && conversation.job_conversations[0].jobid jobId: conversation.job_conversations[0] && conversation.job_conversations[0]?.jobid
}, },
skip: !open || !conversation.job_conversations || conversation.job_conversations.length === 0 skip: !open || !conversation.job_conversations || conversation.job_conversations.length === 0
@@ -67,14 +67,14 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
<> <>
{!bodyshop.uselocalmediaserver && ( {!bodyshop.uselocalmediaserver && (
<JobsDocumentImgproxyGalleryExternal <JobsDocumentImgproxyGalleryExternal
jobId={conversation.job_conversations[0].jobid} jobId={conversation.job_conversations[0]?.jobid}
externalMediaState={[selectedMedia, setSelectedMedia]} externalMediaState={[selectedMedia, setSelectedMedia]}
/> />
)} )}
{bodyshop.uselocalmediaserver && open && ( {bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal <JobDocumentsLocalGalleryExternal
externalMediaState={[selectedMedia, setSelectedMedia]} externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid} jobId={conversation.job_conversations[0] && conversation.job_conversations[0]?.jobid}
/> />
)} )}
</> </>
@@ -89,7 +89,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
{bodyshop.uselocalmediaserver && open && ( {bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal <JobDocumentsLocalGalleryExternal
externalMediaState={[selectedMedia, setSelectedMedia]} externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid} jobId={conversation.job_conversations[0] && conversation.job_conversations[0]?.jobid}
/> />
)} )}
</> </>

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,6 +51,7 @@ import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapper from "../lock-wrapper/lock-wrapper.component"; import LockWrapper from "../lock-wrapper/lock-wrapper.component";
import NotificationCenterContainer from "../notification-center/notification-center.container.jsx"; import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js"; import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
// Redux mappings // Redux mappings
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
@@ -98,6 +99,7 @@ function Header({
const baseTitleRef = useRef(document.title || ""); const baseTitleRef = useRef(document.title || "");
const lastSetTitleRef = useRef(""); const lastSetTitleRef = useRef("");
const userAssociationId = bodyshop?.associations?.[0]?.id; const userAssociationId = bodyshop?.associations?.[0]?.id;
const isEmployee = useIsEmployee(bodyshop, currentUser);
const { const {
data: unreadData, data: unreadData,
@@ -682,7 +684,7 @@ function Header({
icon: unreadLoading ? ( icon: unreadLoading ? (
<Spin size="small" /> <Spin size="small" />
) : ( ) : (
<Badge offset={[8, 0]} size="small" count={unreadCount}> <Badge offset={[8, 0]} size="small" count={isEmployee ? unreadCount : 0}>
<BellFilled /> <BellFilled />
</Badge> </Badge>
), ),

View File

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

View File

@@ -66,7 +66,7 @@ export function ScheduleEventComponent({
const [popOverVisible, setPopOverVisible] = useState(false); const [popOverVisible, setPopOverVisible] = useState(false);
const [getJobDetails] = useLazyQuery(GET_JOB_BY_PK_QUICK_INTAKE, { const [getJobDetails] = useLazyQuery(GET_JOB_BY_PK_QUICK_INTAKE, {
variables: { id: event.job.id }, variables: { id: event.job?.id },
onCompleted: (data) => { onCompleted: (data) => {
if (data?.jobs_by_pk) { if (data?.jobs_by_pk) {
const totalHours = const totalHours =
@@ -83,6 +83,7 @@ export function ScheduleEventComponent({
}); });
} }
}, },
fetchPolicy: "network-only" fetchPolicy: "network-only"
}); });
@@ -409,8 +410,10 @@ export function ScheduleEventComponent({
open={popOverVisible} open={popOverVisible}
onOpenChange={setPopOverVisible} onOpenChange={setPopOverVisible}
onClick={(e) => { onClick={(e) => {
getJobDetails(); if (event.job?.id) {
e.stopPropagation(); e.stopPropagation();
getJobDetails();
}
}} }}
getPopupContainer={(trigger) => trigger.parentNode} getPopupContainer={(trigger) => trigger.parentNode}
trigger="click" trigger="click"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -65,7 +65,7 @@ export default connect(
<Modal <Modal
title={t("jobs.labels.existing_jobs")} title={t("jobs.labels.existing_jobs")}
width={"80%"} width={"80%"}
destroyOnClose destroyOnHidden
okButtonProps={{ disabled: selectedJob ? false : true }} okButtonProps={{ disabled: selectedJob ? false : true }}
{...modalProps} {...modalProps}
> >

View File

@@ -20,7 +20,14 @@ const mapStateToProps = createStructuredSelector({
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("noteUpsert")), toggleModalVisible: () => dispatch(toggleModalVisible("noteUpsert")),
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type })) insertAuditTrail: ({ jobid, operation, type }) =>
dispatch(
insertAuditTrail({
jobid,
operation,
type
})
)
}); });
export function NoteUpsertModalContainer({ currentUser, noteUpsertModal, toggleModalVisible, insertAuditTrail }) { export function NoteUpsertModalContainer({ currentUser, noteUpsertModal, toggleModalVisible, insertAuditTrail }) {
@@ -123,7 +130,7 @@ export function NoteUpsertModalContainer({ currentUser, noteUpsertModal, toggleM
onCancel={() => { onCancel={() => {
toggleModalVisible(); toggleModalVisible();
}} }}
destroyOnClose destroyOnHidden
> >
<Form form={form} onFinish={handleFinish} layout="vertical"> <Form form={form} onFinish={handleFinish} layout="vertical">
<NoteUpsertModalComponent form={form} /> <NoteUpsertModalComponent form={form} />

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
import { useMutation, useQuery } from "@apollo/client"; import { useMutation, useQuery } from "@apollo/client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button, Card, Checkbox, Divider, Form, Space, Switch, Table, Typography } from "antd"; import { Alert, Button, Card, Checkbox, Divider, Form, Space, Switch, Table, Typography } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectCurrentUser } from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import { import {
QUERY_NOTIFICATION_SETTINGS, QUERY_NOTIFICATION_SETTINGS,
@@ -16,14 +16,16 @@ import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx"; import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
/** /**
* Notifications Settings Form * Notifications Settings Form
* @param currentUser * @param currentUser
* @param bodyshop
* @returns {JSX.Element} * @returns {JSX.Element}
* @constructor * @constructor
*/ */
const NotificationSettingsForm = ({ currentUser }) => { const NotificationSettingsForm = ({ currentUser, bodyshop }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [initialValues, setInitialValues] = useState({}); const [initialValues, setInitialValues] = useState({});
@@ -31,6 +33,7 @@ const NotificationSettingsForm = ({ currentUser }) => {
const [autoAddEnabled, setAutoAddEnabled] = useState(false); const [autoAddEnabled, setAutoAddEnabled] = useState(false);
const [initialAutoAdd, setInitialAutoAdd] = useState(false); const [initialAutoAdd, setInitialAutoAdd] = useState(false);
const notification = useNotification(); const notification = useNotification();
const isEmployee = useIsEmployee(bodyshop, currentUser);
// Fetch notification settings and notifications_autoadd // Fetch notification settings and notifications_autoadd
const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, { const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, {
@@ -199,6 +202,11 @@ const NotificationSettingsForm = ({ currentUser }) => {
</Space> </Space>
} }
> >
{!isEmployee && (
<div style={{ width: "100%", marginBottom: "10px" }}>
<Alert message={t("notifications.labels.employee-notification")} type="warning" />
</div>
)}
<Table dataSource={dataSource} columns={columns} pagination={false} bordered rowKey="key" /> <Table dataSource={dataSource} columns={columns} pagination={false} bordered rowKey="key" />
<Divider /> <Divider />
</Card> </Card>
@@ -209,11 +217,13 @@ const NotificationSettingsForm = ({ currentUser }) => {
NotificationSettingsForm.propTypes = { NotificationSettingsForm.propTypes = {
currentUser: PropTypes.shape({ currentUser: PropTypes.shape({
email: PropTypes.string.isRequired email: PropTypes.string.isRequired
}).isRequired }).isRequired,
bodyshop: PropTypes.object.isRequired
}; };
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser currentUser: selectCurrentUser,
bodyshop: selectBodyshop
}); });
export default connect(mapStateToProps)(NotificationSettingsForm); export default connect(mapStateToProps)(NotificationSettingsForm);

View File

@@ -333,7 +333,7 @@ export function PartsOrderModalContainer({
onOk={() => form.submit()} onOk={() => form.submit()}
okButtonProps={{ loading: saving }} okButtonProps={{ loading: saving }}
cancelButtonProps={{ loading: saving }} cancelButtonProps={{ loading: saving }}
destroyOnClose destroyOnHidden
width="75%" width="75%"
forceRender forceRender
> >

View File

@@ -46,7 +46,7 @@ export default function PartsQueueDetailCard() {
}; };
return ( return (
<Drawer open={!!selected} destroyOnClose width={drawerPercentage} placement="right" onClose={handleDrawerClose}> <Drawer open={!!selected} destroyOnHidden width={drawerPercentage} placement="right" onClose={handleDrawerClose}>
{loading ? <LoadingSpinner /> : null} {loading ? <LoadingSpinner /> : null}
{error ? <AlertComponent message={error.message} type="error" /> : null} {error ? <AlertComponent message={error.message} type="error" /> : null}
{data ? ( {data ? (

View File

@@ -90,7 +90,7 @@ export function PartsReceiveModalContainer({ partsReceiveModal, toggleModalVisib
onCancel={() => toggleModalVisible()} onCancel={() => toggleModalVisible()}
onOk={() => form.submit()} onOk={() => form.submit()}
okButtonProps={{ loading: loading }} okButtonProps={{ loading: loading }}
destroyOnClose destroyOnHidden
forceRender forceRender
width="50%" width="50%"
> >

View File

@@ -134,7 +134,7 @@ function PaymentModalContainer({ paymentModal, toggleModalVisible, bodyshop }) {
<Modal <Modal
title={!context || (context && !context.id) ? t("payments.labels.new") : t("payments.labels.edit")} title={!context || (context && !context.id) ? t("payments.labels.new") : t("payments.labels.edit")}
open={open} open={open}
destroyOnClose destroyOnHidden
okText={t("general.actions.save")} okText={t("general.actions.save")}
onOk={() => form.submit()} onOk={() => form.submit()}
width="50%" width="50%"

View File

@@ -0,0 +1,62 @@
import { useQuery } from "@apollo/client";
import { Input, Table } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { GET_PHONE_NUMBER_OPT_OUTS } from "../../graphql/phone-number-opt-out.queries";
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import { TimeAgoFormatter } from "../../utils/DateFormatter";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
currentUser: selectCurrentUser
});
const mapDispatchToProps = () => ({});
function PhoneNumberConsentList({ bodyshop, currentUser }) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const { loading, data } = useQuery(GET_PHONE_NUMBER_OPT_OUTS, {
variables: { bodyshopid: bodyshop.id, search: search ? `%${search}%` : undefined },
fetchPolicy: "network-only"
});
const columns = [
{
title: t("consent.phone_number"),
dataIndex: "phone_number",
render: (text) => <PhoneNumberFormatter>{text}</PhoneNumberFormatter>,
sorter: (a, b) => a.phone_number.localeCompare(b.phone_number)
},
{
title: t("consent.created_at"),
dataIndex: "created_at",
render: (text) => <TimeAgoFormatter>{text}</TimeAgoFormatter>,
sorter: (a, b) => new Date(a.created_at) - new Date(b.created_at)
}
];
return (
<div>
<Input.Search
placeholder={t("general.labels.search")}
onSearch={(value) => setSearch(value)}
style={{ marginBottom: 16 }}
/>
<Table
columns={columns}
dataSource={data?.phone_number_opt_out}
loading={loading}
rowKey="id"
style={{ marginTop: 16 }}
/>
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(PhoneNumberConsentList);

View File

@@ -32,7 +32,7 @@ export function PrintCenterModalContainer({ printCenterModal, toggleModalVisible
okText={t("general.actions.close")} okText={t("general.actions.close")}
width="90%" width="90%"
title={t("printcenter.labels.title")} title={t("printcenter.labels.title")}
destroyOnClose destroyOnHidden
> >
<PrintCenterModalComponent context={context} /> <PrintCenterModalComponent context={context} />
</Modal> </Modal>

View File

@@ -1,7 +1,15 @@
import { Card, Col, Form, Radio, Row } from "antd"; import { Card, Col, Form, Radio, Row } from "antd";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../../redux/user/user.selectors";
import { HasFeatureAccess } from "../../feature-wrapper/feature-wrapper.component";
const LayoutSettings = ({ t }) => ( const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const LayoutSettings = ({ t, bodyshop }) => (
<Card title={t("production.settings.layout")} style={{ maxWidth: "100%", overflowX: "auto" }}> <Card title={t("production.settings.layout")} style={{ maxWidth: "100%", overflowX: "auto" }}>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{[ {[
@@ -30,14 +38,18 @@ const LayoutSettings = ({ t }) => (
{ value: false, label: t("production.labels.wide") } { value: false, label: t("production.labels.wide") }
] ]
}, },
{ ...(HasFeatureAccess({ bodyshop, featureName: "smartscheduling" })
name: "cardcolor", ? [
label: t("production.labels.cardcolor"), {
options: [ name: "cardcolor",
{ value: true, label: t("production.labels.on") }, label: t("production.labels.cardcolor"),
{ value: false, label: t("production.labels.off") } options: [
] { value: true, label: t("production.labels.on") },
}, { value: false, label: t("production.labels.off") }
]
}
]
: []),
{ {
name: "kiosk", name: "kiosk",
label: t("production.labels.kiosk_mode"), label: t("production.labels.kiosk_mode"),
@@ -67,4 +79,4 @@ LayoutSettings.propTypes = {
t: PropTypes.func.isRequired t: PropTypes.func.isRequired
}; };
export default LayoutSettings; export default connect(mapStateToProps)(LayoutSettings);

View File

@@ -6,6 +6,7 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { QUERY_ACTIVE_EMPLOYEES, QUERY_ACTIVE_EMPLOYEES_WITH_EMAIL } from "../../graphql/employees.queries"; import { QUERY_ACTIVE_EMPLOYEES, QUERY_ACTIVE_EMPLOYEES_WITH_EMAIL } from "../../graphql/employees.queries";
import { QUERY_ALL_VENDORS } from "../../graphql/vendors.queries"; import { QUERY_ALL_VENDORS } from "../../graphql/vendors.queries";
import { selectReportCenter } from "../../redux/modals/modals.selectors"; import { selectReportCenter } from "../../redux/modals/modals.selectors";
@@ -18,11 +19,10 @@ import EmployeeSearchSelectEmail from "../employee-search-select/employee-search
import EmployeeSearchSelect from "../employee-search-select/employee-search-select.component"; import EmployeeSearchSelect from "../employee-search-select/employee-search-select.component";
import BlurWrapperComponent from "../feature-wrapper/blur-wrapper.component"; import BlurWrapperComponent from "../feature-wrapper/blur-wrapper.component";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component"; import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component"; import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
import ReportCenterModalFiltersSortersComponent from "./report-center-modal-filters-sorters-component"; import ReportCenterModalFiltersSortersComponent from "./report-center-modal-filters-sorters-component";
import "./report-center-modal.styles.scss"; import "./report-center-modal.styles.scss";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
reportCenterModal: selectReportCenter, reportCenterModal: selectReportCenter,
@@ -389,5 +389,7 @@ const restrictedReports = [
{ key: "job_costing_ro_date_detail", days: 183 }, { key: "job_costing_ro_date_detail", days: 183 },
{ key: "job_costing_ro_estimator", days: 183 }, { key: "job_costing_ro_estimator", days: 183 },
{ key: "job_lifecycle_date_detail", days: 183 }, { key: "job_lifecycle_date_detail", days: 183 },
{ key: "job_lifecycle_date_summary", days: 183 } { key: "job_lifecycle_date_summary", days: 183 },
{ key: "customer_list", days: 183 },
{ key: "customer_list_excel", days: 183 }
]; ];

View File

@@ -28,7 +28,7 @@ export function ReportCenterModalContainer({ reportCenterModal, toggleModalVisib
onOk={() => toggleModalVisible()} onOk={() => toggleModalVisible()}
onCancel={() => toggleModalVisible()} onCancel={() => toggleModalVisible()}
cancelButtonProps={{ style: { display: "none" } }} cancelButtonProps={{ style: { display: "none" } }}
destroyOnClose destroyOnHidden
width="80%" width="80%"
> >
<RbacWrapperComponent action="shop:reportcenter"> <RbacWrapperComponent action="shop:reportcenter">

View File

@@ -209,7 +209,7 @@ export function ScheduleJobModalContainer({
onOk={() => form.submit()} onOk={() => form.submit()}
width={"90%"} width={"90%"}
maskClosable={false} maskClosable={false}
destroyOnClose destroyOnHidden
okButtonProps={{ okButtonProps={{
loading: loading loading: loading
}} }}

View File

@@ -106,7 +106,7 @@ export default function ScoreboardJobsList({ scoreBoardlist }) {
<> <>
<Modal <Modal
open={state.open} open={state.open}
destroyOnClose destroyOnHidden
width="80%" width="80%"
closable={false} closable={false}
cancelButtonProps={{ style: { display: "none" } }} cancelButtonProps={{ style: { display: "none" } }}

View File

@@ -0,0 +1,25 @@
import { Typography } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import PhoneNumberConsentList from "../phone-number-consent/phone-number-consent.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({});
function ShopInfoConsentComponent({ bodyshop }) {
const { t } = useTranslation();
return (
<div>
<Typography.Title level={4}>{t("settings.title")}</Typography.Title>
{<PhoneNumberConsentList bodyshop={bodyshop} />}
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoConsentComponent);

View File

@@ -8,7 +8,7 @@ export default function ShopInfoNotificationsAutoadd({ bodyshop }) {
const { t } = useTranslation(); const { t } = useTranslation();
// Filter employee options to ensure active employees with valid IDs // Filter employee options to ensure active employees with valid IDs
const employeeOptions = bodyshop?.employees?.filter((e) => e.active && e.id && typeof e.id === "string") || []; const employeeOptions = bodyshop?.employees?.filter((e) => e.active && e.user_email && e.id) || [];
return ( return (
<div> <div>

View File

@@ -275,7 +275,7 @@ export function TaskUpsertModalContainer({ bodyshop, currentUser, taskUpsert, to
toggleModalVisible(); toggleModalVisible();
}} }}
okButtonProps={{ disabled: !isTouched }} okButtonProps={{ disabled: !isTouched }}
destroyOnClose destroyOnHidden
> >
<Form <Form
form={form} form={form}

View File

@@ -70,7 +70,7 @@ export function TechLookupJobsDrawer({ bodyshop, setPrintCenterContext }) {
}; };
return ( return (
<Drawer open={!!selected} destroyOnClose width={drawerPercentage} placement="right" onClose={handleDrawerClose}> <Drawer open={!!selected} destroyOnHidden width={drawerPercentage} placement="right" onClose={handleDrawerClose}>
{loading ? <LoadingSpinner /> : null} {loading ? <LoadingSpinner /> : null}
{error ? <AlertComponent message={error.message} type="error" /> : null} {error ? <AlertComponent message={error.message} type="error" /> : null}
{data ? ( {data ? (

View File

@@ -39,7 +39,7 @@ export function TimeTicketListTeamPay({ bodyshop, context, actions }) {
return ( return (
<> <>
<Modal width={"80%"} open={visible} destroyOnClose onOk={handleOk} onCancel={() => setVisible(false)}> <Modal width={"80%"} open={visible} destroyOnHidden onOk={handleOk} onCancel={() => setVisible(false)}>
<Form layout="vertical" form={form} initialValues={{ jobid: jobId }}> <Form layout="vertical" form={form} initialValues={{ jobid: jobId }}>
<LayoutFormRow grow noDivider> <LayoutFormRow grow noDivider>
<Form.Item shouldUpdate> <Form.Item shouldUpdate>

View File

@@ -181,7 +181,7 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
)} )}
</Space> </Space>
} }
destroyOnClose destroyOnHidden
id="time-ticket-modal" id="time-ticket-modal"
> >
<Form <Form

View File

@@ -119,7 +119,7 @@ export function TimeTickeTaskModalContainer({
return ( return (
<Modal <Modal
destroyOnClose destroyOnHidden
open={open} open={open}
onCancel={() => { onCancel={() => {
toggleModalVisible(); toggleModalVisible();

View File

@@ -0,0 +1,28 @@
import { gql } from "@apollo/client";
export const GET_PHONE_NUMBER_OPT_OUT = gql`
query GET_PHONE_NUMBER_OPT_OUT($bodyshopid: uuid!, $phone_number: String!) {
phone_number_opt_out(where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _eq: $phone_number } }) {
id
bodyshopid
phone_number
created_at
updated_at
}
}
`;
export const GET_PHONE_NUMBER_OPT_OUTS = gql`
query GET_PHONE_NUMBER_OPT_OUTS($bodyshopid: uuid!, $search: String) {
phone_number_opt_out(
where: { bodyshopid: { _eq: $bodyshopid }, phone_number: { _ilike: $search } }
order_by: [{ phone_number: asc }, { updated_at: desc }]
) {
id
bodyshopid
phone_number
created_at
updated_at
}
}
`;

View File

@@ -10,10 +10,10 @@ import ShopCsiConfig from "../../components/shop-csi-config/shop-csi-config.comp
import ShopEmployeesContainer from "../../components/shop-employees/shop-employees.container"; import ShopEmployeesContainer from "../../components/shop-employees/shop-employees.container";
import ShopInfoContainer from "../../components/shop-info/shop-info.container"; import ShopInfoContainer from "../../components/shop-info/shop-info.container";
import ShopInfoUsersComponent from "../../components/shop-users/shop-users.component"; import ShopInfoUsersComponent from "../../components/shop-users/shop-users.component";
import ShopInfoConsentComponent from "../../components/shop-info/shop-info.consent.component";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions"; import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr"; import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { HasFeatureAccess } from "../../components/feature-wrapper/feature-wrapper.component"; import { HasFeatureAccess } from "../../components/feature-wrapper/feature-wrapper.component";
import ShopTeamsContainer from "../../components/shop-teams/shop-teams.container"; import ShopTeamsContainer from "../../components/shop-teams/shop-teams.container";
@@ -91,6 +91,14 @@ export function ShopPage({ bodyshop, setSelectedHeader, setBreadcrumbs }) {
children: <ShopCsiConfig /> children: <ShopCsiConfig />
}); });
} }
// Add Consent Settings tab
items.push({
key: "consent",
label: t("bodyshop.labels.consent_settings"),
children: <ShopInfoConsentComponent bodyshop={bodyshop} />
});
return ( return (
<RbacWrapper action="shop:config"> <RbacWrapper action="shop:config">
<Tabs activeKey={search.tab} onChange={(key) => history({ search: `?tab=${key}` })} items={items} /> <Tabs activeKey={search.tab} onChange={(key) => history({ search: `?tab=${key}` })} items={items} />

View File

@@ -105,7 +105,6 @@ const userReducer = (state = INITIAL_STATE, action) => {
...action.payload //Spread current user details in. ...action.payload //Spread current user details in.
} }
}; };
case UserActionTypes.SET_SHOP_DETAILS: case UserActionTypes.SET_SHOP_DETAILS:
return { return {
...state, ...state,
@@ -126,6 +125,7 @@ const userReducer = (state = INITIAL_STATE, action) => {
...state, ...state,
imexshopid: action.payload imexshopid: action.payload
}; };
default: default:
return state; return state;
} }

View File

@@ -656,6 +656,7 @@
} }
}, },
"labels": { "labels": {
"consent_settings": "Phone Number Opt-Out List",
"2tiername": "Name => RO", "2tiername": "Name => RO",
"2tiersetup": "2 Tier Setup", "2tiersetup": "2 Tier Setup",
"2tiersource": "Source => RO", "2tiersource": "Source => RO",
@@ -2377,7 +2378,8 @@
"errors": { "errors": {
"invalidphone": "The phone number is invalid. Unable to open conversation. ", "invalidphone": "The phone number is invalid. Unable to open conversation. ",
"noattachedjobs": "No Jobs have been associated to this conversation. ", "noattachedjobs": "No Jobs have been associated to this conversation. ",
"updatinglabel": "Error updating label. {{error}}" "updatinglabel": "Error updating label. {{error}}",
"no_consent": "This phone number has not consented to receive messages."
}, },
"labels": { "labels": {
"addlabel": "Add a label to this conversation.", "addlabel": "Add a label to this conversation.",
@@ -2393,7 +2395,8 @@
"selectmedia": "Select Media", "selectmedia": "Select Media",
"sentby": "Sent by {{by}} at {{time}}", "sentby": "Sent by {{by}} at {{time}}",
"typeamessage": "Send a message...", "typeamessage": "Send a message...",
"unarchive": "Unarchive" "unarchive": "Unarchive",
"no_consent": "No Consent"
}, },
"render": { "render": {
"conversation_list": "Conversation List" "conversation_list": "Conversation List"
@@ -2474,7 +2477,8 @@
"teams-search": "Search for a Team", "teams-search": "Search for a Team",
"unwatch": "Unwatch", "unwatch": "Unwatch",
"watch": "Watch", "watch": "Watch",
"watching-issue": "Watching" "watching-issue": "Watching",
"employee-notification": "Notifications are disabled because you do not have an associated Employee record."
}, },
"scenarios": { "scenarios": {
"alternate-transport-changed": "Alternate Transport Changed", "alternate-transport-changed": "Alternate Transport Changed",
@@ -2494,7 +2498,9 @@
"tasks-updated-created": "Tasks Updated / Created" "tasks-updated-created": "Tasks Updated / Created"
}, },
"tooltips": { "tooltips": {
"job-watchers": "Job Watchers" "job-watchers": "Job Watchers",
"not-employee": "You need to be an employee to watch this job. Reach out to your admin to get set up!",
"not-employee-notifications": "You must be an employee to receive notifications"
} }
}, },
"owner": { "owner": {
@@ -3096,6 +3102,7 @@
"credits_not_received_date_vendorid": "Credits not Received by Vendor", "credits_not_received_date_vendorid": "Credits not Received by Vendor",
"csi": "CSI Responses", "csi": "CSI Responses",
"customer_list": "Customer List", "customer_list": "Customer List",
"customer_list_excel": "Customer List - Excel",
"cycle_time_analysis": "Cycle Time Analysis", "cycle_time_analysis": "Cycle Time Analysis",
"estimates_written_converted": "Estimates Written/Converted", "estimates_written_converted": "Estimates Written/Converted",
"estimator_detail": "Jobs by Estimator (Detail)", "estimator_detail": "Jobs by Estimator (Detail)",
@@ -3859,6 +3866,14 @@
"validation": { "validation": {
"unique_vendor_name": "You must enter a unique vendor name." "unique_vendor_name": "You must enter a unique vendor name."
} }
},
"consent": {
"phone_number": "Phone Number",
"status": "Consent Status",
"created_at": "Created At"
},
"settings": {
"title": "Phone Number Opt-Out List"
} }
} }
} }

View File

@@ -2476,7 +2476,8 @@
"teams-search": "", "teams-search": "",
"unwatch": "", "unwatch": "",
"watch": "", "watch": "",
"watching-issue": "" "watching-issue": "",
"employee-notification": ""
}, },
"scenarios": { "scenarios": {
"alternate-transport-changed": "", "alternate-transport-changed": "",
@@ -2496,7 +2497,8 @@
"tasks-updated-created": "" "tasks-updated-created": ""
}, },
"tooltips": { "tooltips": {
"job-watchers": "" "job-watchers": "",
"not-employee": ""
} }
}, },
"owner": { "owner": {
@@ -3098,6 +3100,7 @@
"credits_not_received_date_vendorid": "", "credits_not_received_date_vendorid": "",
"csi": "", "csi": "",
"customer_list": "", "customer_list": "",
"customer_list_excel": "",
"cycle_time_analysis": "", "cycle_time_analysis": "",
"estimates_written_converted": "", "estimates_written_converted": "",
"estimator_detail": "", "estimator_detail": "",

View File

@@ -2476,7 +2476,8 @@
"teams-search": "", "teams-search": "",
"unwatch": "", "unwatch": "",
"watch": "", "watch": "",
"watching-issue": "" "watching-issue": "",
"employee-notification": ""
}, },
"scenarios": { "scenarios": {
"alternate-transport-changed": "", "alternate-transport-changed": "",
@@ -2496,7 +2497,8 @@
"tasks-updated-created": "" "tasks-updated-created": ""
}, },
"tooltips": { "tooltips": {
"job-watchers": "" "job-watchers": "",
"not-employee": ""
} }
}, },
"owner": { "owner": {
@@ -3098,6 +3100,7 @@
"credits_not_received_date_vendorid": "", "credits_not_received_date_vendorid": "",
"csi": "", "csi": "",
"customer_list": "", "customer_list": "",
"customer_list_excel": "",
"cycle_time_analysis": "", "cycle_time_analysis": "",
"estimates_written_converted": "", "estimates_written_converted": "",
"estimator_detail": "", "estimator_detail": "",

View File

@@ -2004,6 +2004,18 @@ export const TemplateList = (type, context) => {
}, },
group: "customers" group: "customers"
}, },
customer_list_excel: {
title: i18n.t("reportcenter.templates.customer_list_excel"),
subject: i18n.t("reportcenter.templates.customer_list_excel"),
key: "customer_list_excel",
reporttype: "excel",
disabled: false,
rangeFilter: {
object: i18n.t("reportcenter.labels.objects.jobs"),
field: i18n.t("jobs.fields.date_invoiced")
},
group: "customers"
},
exported_gsr_by_ro: { exported_gsr_by_ro: {
title: i18n.t("reportcenter.templates.exported_gsr_by_ro"), title: i18n.t("reportcenter.templates.exported_gsr_by_ro"),
subject: i18n.t("reportcenter.templates.exported_gsr_by_ro"), subject: i18n.t("reportcenter.templates.exported_gsr_by_ro"),
@@ -2241,7 +2253,7 @@ export const TemplateList = (type, context) => {
field: i18n.t("bills.fields.date") field: i18n.t("bills.fields.date")
}, },
group: "purchases" group: "purchases"
}, }
} }
: {}), : {}),
...(!type || type === "courtesycarcontract" ...(!type || type === "courtesycarcontract"

View File

@@ -0,0 +1,19 @@
import { useMemo } from "react";
/**
* Check if the user is an employee of the bodyshop
* @param bodyshop
* @param userOrEmail
* @returns {boolean|*}
*/
export function useIsEmployee(bodyshop, userOrEmail) {
return useMemo(() => {
if (!bodyshop || !bodyshop.employees) return false;
// Handle both user object and email string
const email = typeof userOrEmail === "string" ? userOrEmail : userOrEmail?.email;
if (!email) return false;
return bodyshop.employees.some((employee) => employee.user_email === email);
}, [bodyshop, userOrEmail]);
}

View File

@@ -2681,6 +2681,9 @@
- active: - active:
_eq: true _eq: true
allow_aggregations: true allow_aggregations: true
- table:
name: integration_log
schema: public
- table: - table:
name: inventory name: inventory
schema: public schema: public
@@ -5861,6 +5864,32 @@
template_engine: Kriti template_engine: Kriti
url: '{{$base_url}}/opensearch' url: '{{$base_url}}/opensearch'
version: 2 version: 2
- table:
name: phone_number_opt_out
schema: public
object_relationships:
- name: bodyshop
using:
foreign_key_constraint_on: bodyshopid
select_permissions:
- role: user
permission:
columns:
- phone_number
- created_at
- updated_at
- bodyshopid
- id
filter:
bodyshop:
associations:
_and:
- user:
authid:
_eq: X-Hasura-User-Id
- active:
_eq: true
comment: ""
- table: - table:
name: phonebook name: phonebook
schema: public schema: public

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."bodyshops" add column "enforce_sms_consent" boolean
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" add column "enforce_sms_consent" boolean
null;

View File

@@ -0,0 +1 @@
DROP TABLE "public"."phone_number_consent";

View File

@@ -0,0 +1,2 @@
CREATE TABLE "public"."phone_number_consent" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "bodyshopid" uuid NOT NULL, "phone_number" text NOT NULL, "consent_status" boolean NOT NULL, "created_at" timestamptz NOT NULL DEFAULT now(), "updated_at" timestamptz NOT NULL DEFAULT now(), "consent_updated_at" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY ("id") , FOREIGN KEY ("bodyshopid") REFERENCES "public"."bodyshops"("id") ON UPDATE restrict ON DELETE restrict, UNIQUE ("id"), UNIQUE ("bodyshopid", "phone_number"));
CREATE EXTENSION IF NOT EXISTS pgcrypto;

View File

@@ -0,0 +1 @@
DROP TABLE "public"."phone_number_consent_history";

View File

@@ -0,0 +1,2 @@
CREATE TABLE "public"."phone_number_consent_history" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "phone_number_consent_id" uuid NOT NULL, "old_value" boolean NOT NULL, "new_value" boolean NOT NULL, "reason" text NOT NULL, "changed_at" timestamptz NOT NULL DEFAULT now(), "changed_by" text NOT NULL, PRIMARY KEY ("id") , FOREIGN KEY ("phone_number_consent_id") REFERENCES "public"."phone_number_consent"("id") ON UPDATE restrict ON DELETE restrict, UNIQUE ("id"));
CREATE EXTENSION IF NOT EXISTS pgcrypto;

View File

@@ -0,0 +1 @@
alter table "public"."phone_number_consent_history" alter column "old_value" set not null;

View File

@@ -0,0 +1 @@
alter table "public"."phone_number_consent_history" alter column "old_value" drop not null;

View File

@@ -0,0 +1,3 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- DROP table "public"."phone_number_consent_history";

View File

@@ -0,0 +1 @@
DROP table "public"."phone_number_consent_history";

View File

@@ -0,0 +1,2 @@
alter table "public"."phone_number_consent" alter column "consent_status" drop not null;
alter table "public"."phone_number_consent" add column "consent_status" bool;

View File

@@ -0,0 +1 @@
alter table "public"."phone_number_consent" drop column "consent_status" cascade;

View File

@@ -0,0 +1,3 @@
alter table "public"."phone_number_consent" alter column "consent_updated_at" set default now();
alter table "public"."phone_number_consent" alter column "consent_updated_at" drop not null;
alter table "public"."phone_number_consent" add column "consent_updated_at" timestamptz;

View File

@@ -0,0 +1 @@
alter table "public"."phone_number_consent" drop column "consent_updated_at" cascade;

View File

@@ -0,0 +1 @@
alter table "public"."phone_number_opt_out" rename to "phone_number_consent";

View File

@@ -0,0 +1 @@
alter table "public"."phone_number_consent" rename to "phone_number_opt_out";

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" alter column "enforce_sms_consent" drop not null;
alter table "public"."bodyshops" add column "enforce_sms_consent" bool;

View File

@@ -0,0 +1 @@
alter table "public"."bodyshops" drop column "enforce_sms_consent" cascade;

View File

@@ -0,0 +1 @@
DROP TABLE "public"."integration_log";

View File

@@ -0,0 +1,18 @@
CREATE TABLE "public"."integration_log" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "created_at" timestamptz NOT NULL DEFAULT now(), "updated_at" timestamptz NOT NULL DEFAULT now(), "bodyshopid" uuid NOT NULL, "email" text NOT NULL, "jobid" uuid, "billid" uuid, "paymentid" uuid, "method" text NOT NULL, "name" text NOT NULL, "status" text NOT NULL, "platform" text NOT NULL, PRIMARY KEY ("id") , FOREIGN KEY ("bodyshopid") REFERENCES "public"."bodyshops"("id") ON UPDATE restrict ON DELETE restrict, FOREIGN KEY ("email") REFERENCES "public"."users"("email") ON UPDATE restrict ON DELETE restrict, FOREIGN KEY ("billid") REFERENCES "public"."bills"("id") ON UPDATE restrict ON DELETE set null, FOREIGN KEY ("jobid") REFERENCES "public"."jobs"("id") ON UPDATE restrict ON DELETE restrict, FOREIGN KEY ("paymentid") REFERENCES "public"."payments"("id") ON UPDATE restrict ON DELETE set null);
CREATE OR REPLACE FUNCTION "public"."set_current_timestamp_updated_at"()
RETURNS TRIGGER AS $$
DECLARE
_new record;
BEGIN
_new := NEW;
_new."updated_at" = NOW();
RETURN _new;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "set_public_integration_log_updated_at"
BEFORE UPDATE ON "public"."integration_log"
FOR EACH ROW
EXECUTE PROCEDURE "public"."set_current_timestamp_updated_at"();
COMMENT ON TRIGGER "set_public_integration_log_updated_at" ON "public"."integration_log"
IS 'trigger to set value of column "updated_at" to current timestamp on row update';
CREATE EXTENSION IF NOT EXISTS pgcrypto;

1716
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,14 +16,14 @@
"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"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.804.0", "@aws-sdk/client-cloudwatch-logs": "^3.812.0",
"@aws-sdk/client-elasticache": "^3.804.0", "@aws-sdk/client-elasticache": "^3.812.0",
"@aws-sdk/client-s3": "^3.804.0", "@aws-sdk/client-s3": "^3.812.0",
"@aws-sdk/client-secrets-manager": "^3.804.0", "@aws-sdk/client-secrets-manager": "^3.812.0",
"@aws-sdk/client-ses": "^3.804.0", "@aws-sdk/client-ses": "^3.812.0",
"@aws-sdk/credential-provider-node": "^3.804.0", "@aws-sdk/credential-provider-node": "^3.812.0",
"@aws-sdk/lib-storage": "^3.804.0", "@aws-sdk/lib-storage": "^3.812.0",
"@aws-sdk/s3-request-presigner": "^3.804.0", "@aws-sdk/s3-request-presigner": "^3.812.0",
"@opensearch-project/opensearch": "^2.13.0", "@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1", "@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
@@ -31,18 +31,18 @@
"aws4": "^1.13.2", "aws4": "^1.13.2",
"axios": "^1.8.4", "axios": "^1.8.4",
"better-queue": "^3.8.12", "better-queue": "^3.8.12",
"bullmq": "^5.52.2", "bullmq": "^5.53.0",
"chart.js": "^4.4.8", "chart.js": "^4.4.8",
"cloudinary": "^2.6.1", "cloudinary": "^2.6.1",
"compression": "^1.8.0", "compression": "^1.8.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "2.8.5", "cors": "^2.8.5",
"crisp-status-reporter": "^1.2.2", "crisp-status-reporter": "^1.2.2",
"dd-trace": "^5.51.0", "dd-trace": "^5.52.0",
"dinero.js": "^1.9.1", "dinero.js": "^1.9.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.21.1", "express": "^4.21.1",
"firebase-admin": "^13.2.0", "firebase-admin": "^13.4.0",
"graphql": "^16.11.0", "graphql": "^16.11.0",
"graphql-request": "^6.1.0", "graphql-request": "^6.1.0",
"intuit-oauth": "^4.2.0", "intuit-oauth": "^4.2.0",
@@ -57,7 +57,7 @@
"node-persist": "^4.0.4", "node-persist": "^4.0.4",
"nodemailer": "^6.10.0", "nodemailer": "^6.10.0",
"phone": "^3.1.58", "phone": "^3.1.58",
"query-string": "^9.1.2", "query-string": "7.1.3",
"recursive-diff": "^1.0.9", "recursive-diff": "^1.0.9",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"skia-canvas": "^2.0.2", "skia-canvas": "^2.0.2",
@@ -65,7 +65,7 @@
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"socket.io-adapter": "^2.5.5", "socket.io-adapter": "^2.5.5",
"ssh2-sftp-client": "^11.0.0", "ssh2-sftp-client": "^11.0.0",
"twilio": "^5.6.0", "twilio": "^5.6.1",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"winston": "^3.17.0", "winston": "^3.17.0",
"winston-cloudwatch": "^6.3.0", "winston-cloudwatch": "^6.3.0",
@@ -73,14 +73,14 @@
"xmlbuilder2": "^3.1.1" "xmlbuilder2": "^3.1.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.26.0", "@eslint/js": "^9.27.0",
"eslint": "^9.26.0", "eslint": "^9.27.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0", "globals": "^15.15.0",
"mock-require": "^3.0.3", "mock-require": "^3.0.3",
"p-limit": "^3.1.0", "p-limit": "^3.1.0",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"supertest": "^7.1.0", "supertest": "^7.1.1",
"vitest": "^3.1.3" "vitest": "^3.1.4"
} }
} }

View File

@@ -14,10 +14,8 @@ const oauthClient = new OAuthClient({
clientSecret: process.env.QBO_SECRET, clientSecret: process.env.QBO_SECRET,
environment: process.env.NODE_ENV === "production" ? "production" : "sandbox", environment: process.env.NODE_ENV === "production" ? "production" : "sandbox",
redirectUri: process.env.QBO_REDIRECT_URI, redirectUri: process.env.QBO_REDIRECT_URI,
logging: true
}); });
//TODO:AIO Add in QBO callbacks.
const url = InstanceEndpoints(); const url = InstanceEndpoints();
exports.default = async (req, res) => { exports.default = async (req, res) => {

View File

@@ -20,7 +20,6 @@ exports.default = async (req, res) => {
clientSecret: process.env.QBO_SECRET, clientSecret: process.env.QBO_SECRET,
environment: process.env.NODE_ENV === "production" ? "production" : "sandbox", environment: process.env.NODE_ENV === "production" ? "production" : "sandbox",
redirectUri: process.env.QBO_REDIRECT_URI, redirectUri: process.env.QBO_REDIRECT_URI,
logging: true
}); });
try { try {
@@ -149,6 +148,15 @@ async function QueryVendorRecord(oauthClient, qbo_realmId, req, bill) {
"Content-Type": "application/json" "Content-Type": "application/json"
} }
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryVendorRecord",
billid: bill.id,
status: result.response?.status,
bodyshopid: bill.job.shopid,
email: req.user.email
})
setNewRefreshToken(req.user.email, result); setNewRefreshToken(req.user.email, result);
return ( return (
result.json && result.json &&
@@ -178,6 +186,15 @@ async function InsertVendorRecord(oauthClient, qbo_realmId, req, bill) {
}, },
body: JSON.stringify(Vendor) body: JSON.stringify(Vendor)
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertVendorRecord",
billid: bill.id,
status: result.response?.status,
bodyshopid: bill.job.shopid,
email: req.user.email
})
setNewRefreshToken(req.user.email, result); setNewRefreshToken(req.user.email, result);
return result && result.json && result.json.Vendor; return result && result.json && result.json.Vendor;
} catch (error) { } catch (error) {
@@ -190,7 +207,7 @@ async function InsertVendorRecord(oauthClient, qbo_realmId, req, bill) {
} }
async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop) { async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop) {
const { accounts, taxCodes, classes } = await QueryMetaData(oauthClient, qbo_realmId, req); const { accounts, taxCodes, classes } = await QueryMetaData(oauthClient, qbo_realmId, req, bill.job.shopid);
const lines = bill.billlines.map((il) => const lines = bill.billlines.map((il) =>
generateBillLine( generateBillLine(
@@ -246,11 +263,11 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
.format("YYYY-MM-DD"), .format("YYYY-MM-DD"),
...(!bill.is_credit_memo && ...(!bill.is_credit_memo &&
bill.vendor.due_date && { bill.vendor.due_date && {
DueDate: moment(bill.date) DueDate: moment(bill.date)
//.tz(bill.job.bodyshop.timezone) //.tz(bill.job.bodyshop.timezone)
.add(bill.vendor.due_date, "days") .add(bill.vendor.due_date, "days")
.format("YYYY-MM-DD") .format("YYYY-MM-DD")
}), }),
DocNumber: bill.invoice_number, DocNumber: bill.invoice_number,
//...(bill.job.class ? { ClassRef: { Id: classes[bill.job.class] } } : {}), //...(bill.job.class ? { ClassRef: { Id: classes[bill.job.class] } } : {}),
...(!( ...(!(
@@ -263,8 +280,8 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
: {}), : {}),
...(bodyshop.accountingconfig.qbo_departmentid && ...(bodyshop.accountingconfig.qbo_departmentid &&
bodyshop.accountingconfig.qbo_departmentid.trim() !== "" && { bodyshop.accountingconfig.qbo_departmentid.trim() !== "" && {
DepartmentRef: { value: bodyshop.accountingconfig.qbo_departmentid } DepartmentRef: { value: bodyshop.accountingconfig.qbo_departmentid }
}), }),
PrivateNote: `RO ${bill.job.ro_number || ""}`, PrivateNote: `RO ${bill.job.ro_number || ""}`,
Line: lines Line: lines
}; };
@@ -280,6 +297,15 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
}, },
body: JSON.stringify(billQbo) body: JSON.stringify(billQbo)
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertBill",
billid: bill.id,
status: result.response?.status,
bodyshopid: bill.job.shopid,
email: req.user.email
})
setNewRefreshToken(req.user.email, result); setNewRefreshToken(req.user.email, result);
return result && result.json && result.json.Bill; return result && result.json && result.json.Bill;
} catch (error) { } catch (error) {
@@ -327,8 +353,8 @@ const generateBillLine = (
accountingconfig.qbo && accountingconfig.qbo_usa && region_config.includes("CA_") accountingconfig.qbo && accountingconfig.qbo_usa && region_config.includes("CA_")
? {} ? {}
: { : {
value: taxCodes[findTaxCode(billLine.applicable_taxes, ioSalesTaxCodes)] value: taxCodes[findTaxCode(billLine.applicable_taxes, ioSalesTaxCodes)]
}, },
AccountRef: { AccountRef: {
value: accounts[account.accountname] value: accounts[account.accountname]
} }
@@ -342,7 +368,7 @@ const generateBillLine = (
}; };
}; };
async function QueryMetaData(oauthClient, qbo_realmId, req) { async function QueryMetaData(oauthClient, qbo_realmId, req, bodyshopid) {
const accounts = await oauthClient.makeApiCall({ const accounts = await oauthClient.makeApiCall({
url: urlBuilder( url: urlBuilder(
qbo_realmId, qbo_realmId,
@@ -354,6 +380,14 @@ async function QueryMetaData(oauthClient, qbo_realmId, req) {
"Content-Type": "application/json" "Content-Type": "application/json"
} }
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryAccountType",
status: accounts.response?.status,
bodyshopid,
email: req.user.email
})
setNewRefreshToken(req.user.email, accounts); setNewRefreshToken(req.user.email, accounts);
const taxCodes = await oauthClient.makeApiCall({ const taxCodes = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "query", `select * From TaxCode`), url: urlBuilder(qbo_realmId, "query", `select * From TaxCode`),
@@ -362,7 +396,14 @@ async function QueryMetaData(oauthClient, qbo_realmId, req) {
"Content-Type": "application/json" "Content-Type": "application/json"
} }
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryTaxCode",
status: taxCodes.status,
bodyshopid,
email: req.user.email
})
const classes = await oauthClient.makeApiCall({ const classes = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "query", `select * From Class`), url: urlBuilder(qbo_realmId, "query", `select * From Class`),
method: "POST", method: "POST",
@@ -370,7 +411,14 @@ async function QueryMetaData(oauthClient, qbo_realmId, req) {
"Content-Type": "application/json" "Content-Type": "application/json"
} }
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryClasses",
status: classes.status,
bodyshopid,
email: req.user.email
})
const taxCodeMapping = {}; const taxCodeMapping = {};
taxCodes.json && taxCodes.json &&

View File

@@ -29,7 +29,6 @@ exports.default = async (req, res) => {
clientSecret: process.env.QBO_SECRET, clientSecret: process.env.QBO_SECRET,
environment: process.env.NODE_ENV === "production" ? "production" : "sandbox", environment: process.env.NODE_ENV === "production" ? "production" : "sandbox",
redirectUri: process.env.QBO_REDIRECT_URI, redirectUri: process.env.QBO_REDIRECT_URI,
logging: true
}); });
try { try {
//Fetch the API Access Tokens & Set them for the session. //Fetch the API Access Tokens & Set them for the session.
@@ -198,7 +197,8 @@ async function InsertPayment(oauthClient, qbo_realmId, req, payment, parentRef,
req, req,
payment.job.ro_number, payment.job.ro_number,
false, false,
parentRef parentRef,
payment.job.shopid
); );
if (invoices && invoices.length !== 1) { if (invoices && invoices.length !== 1) {
@@ -227,20 +227,20 @@ async function InsertPayment(oauthClient, qbo_realmId, req, payment, parentRef,
PaymentRefNum: payment.transactionid, PaymentRefNum: payment.transactionid,
...(invoices && invoices.length === 1 && invoices[0] ...(invoices && invoices.length === 1 && invoices[0]
? { ? {
Line: [ Line: [
{ {
Amount: Dinero({ Amount: Dinero({
amount: Math.round(payment.amount * 100) amount: Math.round(payment.amount * 100)
}).toFormat(DineroQbFormat), }).toFormat(DineroQbFormat),
LinkedTxn: [ LinkedTxn: [
{ {
TxnId: invoices[0].Id, TxnId: invoices[0].Id,
TxnType: "Invoice" TxnType: "Invoice"
} }
] ]
} }
] ]
} }
: {}) : {})
}; };
logger.log("qbo-payments-objectlog", "DEBUG", req.user.email, payment.id, { logger.log("qbo-payments-objectlog", "DEBUG", req.user.email, payment.id, {
@@ -255,6 +255,15 @@ async function InsertPayment(oauthClient, qbo_realmId, req, payment, parentRef,
}, },
body: JSON.stringify(paymentQbo) body: JSON.stringify(paymentQbo)
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertPayment",
paymentid: payment.id,
status: result.response?.status,
bodyshopid: payment.job.shopid,
email: req.user.email
})
setNewRefreshToken(req.user.email, result); setNewRefreshToken(req.user.email, result);
return result && result.Bill; return result && result.Bill;
} catch (error) { } catch (error) {
@@ -266,7 +275,7 @@ async function InsertPayment(oauthClient, qbo_realmId, req, payment, parentRef,
} }
} }
async function QueryMetaData(oauthClient, qbo_realmId, req, ro_number, isCreditMemo, parentTierRef) { async function QueryMetaData(oauthClient, qbo_realmId, req, ro_number, isCreditMemo, parentTierRef, bodyshopid) {
const invoice = await oauthClient.makeApiCall({ const invoice = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "query", `select * From Invoice where DocNumber like '${ro_number}%'`), url: urlBuilder(qbo_realmId, "query", `select * From Invoice where DocNumber like '${ro_number}%'`),
method: "POST", method: "POST",
@@ -274,7 +283,15 @@ async function QueryMetaData(oauthClient, qbo_realmId, req, ro_number, isCreditM
"Content-Type": "application/json" "Content-Type": "application/json"
} }
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryInvoice",
status: invoice.response?.status,
bodyshopid,
email: req.user.email
})
const paymentMethods = await oauthClient.makeApiCall({ const paymentMethods = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "query", `select * From PaymentMethod`), url: urlBuilder(qbo_realmId, "query", `select * From PaymentMethod`),
method: "POST", method: "POST",
@@ -282,6 +299,14 @@ async function QueryMetaData(oauthClient, qbo_realmId, req, ro_number, isCreditM
"Content-Type": "application/json" "Content-Type": "application/json"
} }
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryPaymentMethod",
status: paymentMethods.response?.status,
bodyshopid,
email: req.user.email
})
setNewRefreshToken(req.user.email, paymentMethods); setNewRefreshToken(req.user.email, paymentMethods);
// const classes = await oauthClient.makeApiCall({ // const classes = await oauthClient.makeApiCall({
@@ -325,6 +350,15 @@ async function QueryMetaData(oauthClient, qbo_realmId, req, ro_number, isCreditM
"Content-Type": "application/json" "Content-Type": "application/json"
} }
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryTaxCode",
status: taxCodes.response?.status,
bodyshopid,
email: req.user.email
})
const items = await oauthClient.makeApiCall({ const items = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "query", `select * From Item`), url: urlBuilder(qbo_realmId, "query", `select * From Item`),
method: "POST", method: "POST",
@@ -332,6 +366,14 @@ async function QueryMetaData(oauthClient, qbo_realmId, req, ro_number, isCreditM
"Content-Type": "application/json" "Content-Type": "application/json"
} }
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryItems",
status: items.response?.status,
bodyshopid,
email: req.user.email
})
setNewRefreshToken(req.user.email, items); setNewRefreshToken(req.user.email, items);
const itemMapping = {}; const itemMapping = {};
@@ -377,7 +419,8 @@ async function InsertCreditMemo(oauthClient, qbo_realmId, req, payment, parentRe
req, req,
payment.job.ro_number, payment.job.ro_number,
true, true,
parentRef parentRef,
payment.job.shopid
); );
if (invoices && invoices.length !== 1) { if (invoices && invoices.length !== 1) {
@@ -406,14 +449,14 @@ async function InsertCreditMemo(oauthClient, qbo_realmId, req, payment, parentRe
TaxCodeRef: { TaxCodeRef: {
value: value:
taxCodes[ taxCodes[
findTaxCode( findTaxCode(
{ {
local: false, local: false,
federal: false, federal: false,
state: false state: false
}, },
payment.job.bodyshop.md_responsibility_centers.sales_tax_codes payment.job.bodyshop.md_responsibility_centers.sales_tax_codes
) )
] ]
} }
} }
@@ -432,6 +475,15 @@ async function InsertCreditMemo(oauthClient, qbo_realmId, req, payment, parentRe
}, },
body: JSON.stringify(paymentQbo) body: JSON.stringify(paymentQbo)
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertCreditMemo",
paymentid: payment.id,
status: result.response?.status,
bodyshopid: req.user.bodyshopid,
email: req.user.email
})
setNewRefreshToken(req.user.email, result); setNewRefreshToken(req.user.email, result);
return result && result.Bill; return result && result.Bill;
} catch (error) { } catch (error) {

View File

@@ -22,8 +22,8 @@ exports.default = async (req, res) => {
clientSecret: process.env.QBO_SECRET, clientSecret: process.env.QBO_SECRET,
environment: process.env.NODE_ENV === "production" ? "production" : "sandbox", environment: process.env.NODE_ENV === "production" ? "production" : "sandbox",
redirectUri: process.env.QBO_REDIRECT_URI, redirectUri: process.env.QBO_REDIRECT_URI,
logging: true
}); });
try { try {
//Fetch the API Access Tokens & Set them for the session. //Fetch the API Access Tokens & Set them for the session.
const response = await apiGqlClient.request(queries.GET_QBO_AUTH, { const response = await apiGqlClient.request(queries.GET_QBO_AUTH, {
@@ -233,6 +233,15 @@ async function QueryInsuranceCo(oauthClient, qbo_realmId, req, job) {
"Content-Type": "application/json" "Content-Type": "application/json"
} }
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryCustomer",
status: result.response?.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
})
setNewRefreshToken(req.user.email, result); setNewRefreshToken(req.user.email, result);
return ( return (
result.json && result.json &&
@@ -279,6 +288,15 @@ async function InsertInsuranceCo(oauthClient, qbo_realmId, req, job, bodyshop) {
}, },
body: JSON.stringify(Customer) body: JSON.stringify(Customer)
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertCustomer",
status: result.response.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
})
setNewRefreshToken(req.user.email, result); setNewRefreshToken(req.user.email, result);
return result && result.json.Customer; return result && result.json.Customer;
} catch (error) { } catch (error) {
@@ -305,6 +323,15 @@ async function QueryOwner(oauthClient, qbo_realmId, req, job, isThreeTier, paren
"Content-Type": "application/json" "Content-Type": "application/json"
} }
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryCustomer",
status: result.response?.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
})
setNewRefreshToken(req.user.email, result); setNewRefreshToken(req.user.email, result);
return ( return (
result.json && result.json &&
@@ -331,11 +358,11 @@ async function InsertOwner(oauthClient, qbo_realmId, req, job, isThreeTier, pare
...(job.ownr_ea ? { PrimaryEmailAddr: { Address: job.ownr_ea.trim() } } : {}), ...(job.ownr_ea ? { PrimaryEmailAddr: { Address: job.ownr_ea.trim() } } : {}),
...(isThreeTier ...(isThreeTier
? { ? {
Job: true, Job: true,
ParentRef: { ParentRef: {
value: parentTierRef.Id value: parentTierRef.Id
}
} }
}
: {}) : {})
}; };
try { try {
@@ -347,6 +374,15 @@ async function InsertOwner(oauthClient, qbo_realmId, req, job, isThreeTier, pare
}, },
body: JSON.stringify(Customer) body: JSON.stringify(Customer)
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertCustomer",
status: result.response?.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
})
setNewRefreshToken(req.user.email, result); setNewRefreshToken(req.user.email, result);
return result && result.json.Customer; return result && result.json.Customer;
} catch (error) { } catch (error) {
@@ -372,6 +408,15 @@ async function QueryJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
"Content-Type": "application/json" "Content-Type": "application/json"
} }
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryCustomer",
status: result.response?.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
})
setNewRefreshToken(req.user.email, result); setNewRefreshToken(req.user.email, result);
return ( return (
result.json && result.json &&
@@ -411,6 +456,15 @@ async function InsertJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
}, },
body: JSON.stringify(Customer) body: JSON.stringify(Customer)
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertCustomer",
status: result.response?.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
})
setNewRefreshToken(req.user.email, result); setNewRefreshToken(req.user.email, result);
return result && result.json.Customer; return result && result.json.Customer;
} catch (error) { } catch (error) {
@@ -424,7 +478,7 @@ async function InsertJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
exports.InsertJob = InsertJob; exports.InsertJob = InsertJob;
async function QueryMetaData(oauthClient, qbo_realmId, req) { async function QueryMetaData(oauthClient, qbo_realmId, req, bodyshopid, jobid) {
const items = await oauthClient.makeApiCall({ const items = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "query", `select * From Item where active=true maxresults 1000`), url: urlBuilder(qbo_realmId, "query", `select * From Item where active=true maxresults 1000`),
method: "POST", method: "POST",
@@ -432,6 +486,15 @@ async function QueryMetaData(oauthClient, qbo_realmId, req) {
"Content-Type": "application/json" "Content-Type": "application/json"
} }
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryItems",
status: items.response?.status,
bodyshopid,
jobid: jobid,
email: req.user.email
})
setNewRefreshToken(req.user.email, items); setNewRefreshToken(req.user.email, items);
const taxCodes = await oauthClient.makeApiCall({ const taxCodes = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "query", `select * From TaxCode where active=true`), url: urlBuilder(qbo_realmId, "query", `select * From TaxCode where active=true`),
@@ -440,7 +503,15 @@ async function QueryMetaData(oauthClient, qbo_realmId, req) {
"Content-Type": "application/json" "Content-Type": "application/json"
} }
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryTaxCodes",
status: taxCodes.response?.status,
bodyshopid,
jobid: jobid,
email: req.user.email
})
const classes = await oauthClient.makeApiCall({ const classes = await oauthClient.makeApiCall({
url: urlBuilder(qbo_realmId, "query", `select * From Class`), url: urlBuilder(qbo_realmId, "query", `select * From Class`),
method: "POST", method: "POST",
@@ -448,7 +519,15 @@ async function QueryMetaData(oauthClient, qbo_realmId, req) {
"Content-Type": "application/json" "Content-Type": "application/json"
} }
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "QueryClasses",
status: classes.response?.status,
bodyshopid,
jobid: jobid,
email: req.user.email
})
const taxCodeMapping = {}; const taxCodeMapping = {};
taxCodes.json && taxCodes.json &&
@@ -483,7 +562,7 @@ async function QueryMetaData(oauthClient, qbo_realmId, req) {
} }
async function InsertInvoice(oauthClient, qbo_realmId, req, job, bodyshop, parentTierRef) { async function InsertInvoice(oauthClient, qbo_realmId, req, job, bodyshop, parentTierRef) {
const { items, taxCodes, classes } = await QueryMetaData(oauthClient, qbo_realmId, req); const { items, taxCodes, classes } = await QueryMetaData(oauthClient, qbo_realmId, req, job.shopid, job.id);
const InvoiceLineAdd = CreateInvoiceLines({ const InvoiceLineAdd = CreateInvoiceLines({
bodyshop, bodyshop,
jobs_by_pk: job, jobs_by_pk: job,
@@ -499,57 +578,55 @@ async function InsertInvoice(oauthClient, qbo_realmId, req, job, bodyshop, paren
DocNumber: job.ro_number, DocNumber: job.ro_number,
...(job.class ? { ClassRef: { value: classes[job.class] } } : {}), ...(job.class ? { ClassRef: { value: classes[job.class] } } : {}),
CustomerMemo: { CustomerMemo: {
value: `${job.clm_no ? `Claim No: ${job.clm_no}` : ``}${ value: `${job.clm_no ? `Claim No: ${job.clm_no}` : ``}${job.po_number ? `PO No: ${job.po_number}` : ``
job.po_number ? `PO No: ${job.po_number}` : `` } Vehicle:${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""
} Vehicle:${job.v_model_yr || ""} ${job.v_make_desc || ""} ${ } ${job.v_vin || ""} ${job.plate_no || ""} `.trim()
job.v_model_desc || ""
} ${job.v_vin || ""} ${job.plate_no || ""} `.trim()
}, },
CustomerRef: { CustomerRef: {
value: parentTierRef.Id value: parentTierRef.Id
}, },
...(bodyshop.accountingconfig.qbo_departmentid && ...(bodyshop.accountingconfig.qbo_departmentid &&
bodyshop.accountingconfig.qbo_departmentid.trim() !== "" && { bodyshop.accountingconfig.qbo_departmentid.trim() !== "" && {
DepartmentRef: { value: bodyshop.accountingconfig.qbo_departmentid } DepartmentRef: { value: bodyshop.accountingconfig.qbo_departmentid }
}), }),
CustomField: [ CustomField: [
...(bodyshop.accountingconfig.ReceivableCustomField1 ...(bodyshop.accountingconfig.ReceivableCustomField1
? [ ? [
{ {
DefinitionId: "1", DefinitionId: "1",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField1], StringValue: job[bodyshop.accountingconfig.ReceivableCustomField1],
Type: "StringType" Type: "StringType"
} }
] ]
: []), : []),
...(bodyshop.accountingconfig.ReceivableCustomField2 ...(bodyshop.accountingconfig.ReceivableCustomField2
? [ ? [
{ {
DefinitionId: "2", DefinitionId: "2",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField2], StringValue: job[bodyshop.accountingconfig.ReceivableCustomField2],
Type: "StringType" Type: "StringType"
} }
] ]
: []), : []),
...(bodyshop.accountingconfig.ReceivableCustomField3 ...(bodyshop.accountingconfig.ReceivableCustomField3
? [ ? [
{ {
DefinitionId: "3", DefinitionId: "3",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField3], StringValue: job[bodyshop.accountingconfig.ReceivableCustomField3],
Type: "StringType" Type: "StringType"
} }
] ]
: []) : [])
], ],
...(bodyshop.accountingconfig && ...(bodyshop.accountingconfig &&
bodyshop.accountingconfig.qbo && bodyshop.accountingconfig.qbo &&
bodyshop.accountingconfig.qbo_usa && { bodyshop.accountingconfig.qbo_usa && {
TxnTaxDetail: { TxnTaxDetail: {
TxnTaxCodeRef: { TxnTaxCodeRef: {
value: taxCodes[bodyshop.md_responsibility_centers.taxes.state.accountitem] value: taxCodes[bodyshop.md_responsibility_centers.taxes.state.accountitem]
}
} }
}), }
}),
...(bodyshop.accountingconfig.printlater ? { PrintStatus: "NeedToPrint" } : {}), ...(bodyshop.accountingconfig.printlater ? { PrintStatus: "NeedToPrint" } : {}),
...(bodyshop.accountingconfig.emaillater && job.ownr_ea ? { EmailStatus: "NeedToSend" } : {}), ...(bodyshop.accountingconfig.emaillater && job.ownr_ea ? { EmailStatus: "NeedToSend" } : {}),
@@ -575,6 +652,15 @@ async function InsertInvoice(oauthClient, qbo_realmId, req, job, bodyshop, paren
}, },
body: JSON.stringify(invoiceObj) body: JSON.stringify(invoiceObj)
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertInvoice",
status: result.response?.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
})
setNewRefreshToken(req.user.email, result); setNewRefreshToken(req.user.email, result);
return result && result.json && result.json.Invoice; return result && result.json && result.json.Invoice;
} catch (error) { } catch (error) {
@@ -598,7 +684,7 @@ async function InsertInvoiceMultiPayerInvoice(
payer, payer,
suffix suffix
) { ) {
const { items, taxCodes, classes } = await QueryMetaData(oauthClient, qbo_realmId, req); const { items, taxCodes, classes } = await QueryMetaData(oauthClient, qbo_realmId, req, job.shopid);
const InvoiceLineAdd = createMultiQbPayerLines({ const InvoiceLineAdd = createMultiQbPayerLines({
bodyshop, bodyshop,
jobs_by_pk: job, jobs_by_pk: job,
@@ -616,58 +702,56 @@ async function InsertInvoiceMultiPayerInvoice(
DocNumber: job.ro_number + suffix, DocNumber: job.ro_number + suffix,
...(job.class ? { ClassRef: { value: classes[job.class] } } : {}), ...(job.class ? { ClassRef: { value: classes[job.class] } } : {}),
CustomerMemo: { CustomerMemo: {
value: `${job.clm_no ? `Claim No: ${job.clm_no}` : ``}${ value: `${job.clm_no ? `Claim No: ${job.clm_no}` : ``}${job.po_number ? `PO No: ${job.po_number}` : ``
job.po_number ? `PO No: ${job.po_number}` : `` } Vehicle:${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""
} Vehicle:${job.v_model_yr || ""} ${job.v_make_desc || ""} ${ } ${job.v_vin || ""} ${job.plate_no || ""} `.trim()
job.v_model_desc || ""
} ${job.v_vin || ""} ${job.plate_no || ""} `.trim()
}, },
CustomerRef: { CustomerRef: {
value: parentTierRef.Id value: parentTierRef.Id
}, },
...(bodyshop.accountingconfig.qbo_departmentid && ...(bodyshop.accountingconfig.qbo_departmentid &&
bodyshop.accountingconfig.qbo_departmentid.trim() !== "" && { bodyshop.accountingconfig.qbo_departmentid.trim() !== "" && {
DepartmentRef: { value: bodyshop.accountingconfig.qbo_departmentid } DepartmentRef: { value: bodyshop.accountingconfig.qbo_departmentid }
}), }),
CustomField: [ CustomField: [
...(bodyshop.accountingconfig.ReceivableCustomField1 ...(bodyshop.accountingconfig.ReceivableCustomField1
? [ ? [
{ {
DefinitionId: "1", DefinitionId: "1",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField1], StringValue: job[bodyshop.accountingconfig.ReceivableCustomField1],
Type: "StringType" Type: "StringType"
} }
] ]
: []), : []),
...(bodyshop.accountingconfig.ReceivableCustomField2 ...(bodyshop.accountingconfig.ReceivableCustomField2
? [ ? [
{ {
DefinitionId: "2", DefinitionId: "2",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField2], StringValue: job[bodyshop.accountingconfig.ReceivableCustomField2],
Type: "StringType" Type: "StringType"
} }
] ]
: []), : []),
...(bodyshop.accountingconfig.ReceivableCustomField3 ...(bodyshop.accountingconfig.ReceivableCustomField3
? [ ? [
{ {
DefinitionId: "3", DefinitionId: "3",
StringValue: job[bodyshop.accountingconfig.ReceivableCustomField3], StringValue: job[bodyshop.accountingconfig.ReceivableCustomField3],
Type: "StringType" Type: "StringType"
} }
] ]
: []) : [])
], ],
...(bodyshop.accountingconfig && ...(bodyshop.accountingconfig &&
bodyshop.accountingconfig.qbo && bodyshop.accountingconfig.qbo &&
bodyshop.accountingconfig.qbo_usa && bodyshop.accountingconfig.qbo_usa &&
bodyshop.region_config.includes("CA_") && { bodyshop.region_config.includes("CA_") && {
TxnTaxDetail: { TxnTaxDetail: {
TxnTaxCodeRef: { TxnTaxCodeRef: {
value: taxCodes[bodyshop.md_responsibility_centers.taxes.state.accountitem] value: taxCodes[bodyshop.md_responsibility_centers.taxes.state.accountitem]
}
} }
}), }
}),
...(bodyshop.accountingconfig.printlater ? { PrintStatus: "NeedToPrint" } : {}), ...(bodyshop.accountingconfig.printlater ? { PrintStatus: "NeedToPrint" } : {}),
...(bodyshop.accountingconfig.emaillater && job.ownr_ea ? { EmailStatus: "NeedToSend" } : {}), ...(bodyshop.accountingconfig.emaillater && job.ownr_ea ? { EmailStatus: "NeedToSend" } : {}),
@@ -693,6 +777,15 @@ async function InsertInvoiceMultiPayerInvoice(
}, },
body: JSON.stringify(invoiceObj) body: JSON.stringify(invoiceObj)
}); });
logger.LogIntegrationCall({
platform: "QBO",
method: "POST",
name: "InsertInvoice",
status: result.response?.status,
bodyshopid: job.shopid,
jobid: job.id,
email: req.user.email
})
setNewRefreshToken(req.user.email, result); setNewRefreshToken(req.user.email, result);
return result && result.json && result.json.Invoice; return result && result.json && result.json.Invoice;
} catch (error) { } catch (error) {

View File

@@ -6,7 +6,7 @@ require("dotenv").config({
function urlBuilder(realmId, object, query = null) { function urlBuilder(realmId, object, query = null) {
return `https://${ return `https://${
process.env.NODE_ENV === "production" ? "" : "sandbox-" process.env.NODE_ENV === "production" ? "" : "sandbox-"
}quickbooks.api.intuit.com/v3/company/${realmId}/${object}${query ? `?query=${encodeURIComponent(query)}` : ""}`; }quickbooks.api.intuit.com/v3/company/${realmId}/${object}?minorversion=75${query ? `&query=${encodeURIComponent(query)}` : ""}`;
} }
function StandardizeName(str) { function StandardizeName(str) {

View File

@@ -4,4 +4,5 @@ exports.chatter = require("./chatter").default;
exports.claimscorp = require("./claimscorp").default; exports.claimscorp = require("./claimscorp").default;
exports.kaizen = require("./kaizen").default; exports.kaizen = require("./kaizen").default;
exports.usageReport = require("./usageReport").default; exports.usageReport = require("./usageReport").default;
exports.podium = require("./podium").default; exports.podium = require("./podium").default;
exports.emsUpload = require("./emsUpload").default;

22
server/data/emsUpload.js Normal file
View File

@@ -0,0 +1,22 @@
const moment = require("moment-timezone");
const logger = require("../utils/logger");
const s3Client = require("../utils/s3"); // Using the S3 client utilities with LocalStack support
const emsUpload = async (req, res) => {
try {
const { bodyshopid, ciecaid, clm_no, ownr_ln } = req.body;
const presignedUrl = await s3Client.getPresignedUrl({
bucketName: process.env.S3_EMS_UPLOAD_BUCKET,
key: `${bodyshopid}/${ciecaid}-${clm_no}-${ownr_ln}-${moment().format("YYYY-MM-DD--HH-mm-ss")}.zip`
});
res.status(200).json({ presignedUrl });
} catch (error) {
logger.log("ems-upload-presign-error", "ERROR", req?.user?.email, null, {
error: error.message,
stack: error.stack
});
res.status(500).json({ error: error.message, stack: error.stack });
}
};
exports.default = emsUpload;

View File

@@ -185,7 +185,7 @@ async function uploadViaSFTP(csvObj) {
await sftp.connect(ftpSetup); await sftp.connect(ftpSetup);
try { try {
csvObj.result = await sftp.put(Buffer.from(csvObj.xml), `${csvObj.filename}`); csvObj.result = await sftp.put(Buffer.from(csvObj.csv), `${csvObj.filename}`);
logger.log("podium-sftp-upload", "DEBUG", "api", csvObj.bodyshopid, { logger.log("podium-sftp-upload", "DEBUG", "api", csvObj.bodyshopid, {
imexshopid: csvObj.imexshopid, imexshopid: csvObj.imexshopid,
filename: csvObj.filename, filename: csvObj.filename,

View File

@@ -222,6 +222,7 @@ query QUERY_JOBS_FOR_RECEIVABLES_EXPORT($ids: [uuid!]!) {
rate_mash rate_mash
rate_matd rate_matd
class class
shopid
ca_bc_pvrt ca_bc_pvrt
ca_customer_gst ca_customer_gst
towing_payable towing_payable
@@ -480,6 +481,7 @@ query QUERY_BILLS_FOR_PAYABLES_EXPORT($bills: [uuid!]!) {
ownr_ln ownr_ln
ownr_co_nm ownr_co_nm
class class
shopid
} }
billlines{ billlines{
id id
@@ -530,6 +532,7 @@ exports.QUERY_PAYMENTS_FOR_EXPORT = `
ownr_fn ownr_fn
ownr_ln ownr_ln
ownr_co_nm ownr_co_nm
shopid
bodyshop { bodyshop {
accountingconfig accountingconfig
md_responsibility_centers md_responsibility_centers
@@ -1596,6 +1599,7 @@ query QUERY_JOB_COSTING_DETAILS($id: uuid!) {
ca_customer_gst ca_customer_gst
dms_allocation dms_allocation
cieca_pfl cieca_pfl
cieca_stl
materials materials
joblines(where: { removed: { _eq: false } }) { joblines(where: { removed: { _eq: false } }) {
id id
@@ -1712,6 +1716,7 @@ exports.QUERY_JOB_COSTING_DETAILS_MULTI = ` query QUERY_JOB_COSTING_DETAILS_MULT
ca_customer_gst ca_customer_gst
dms_allocation dms_allocation
cieca_pfl cieca_pfl
cieca_stl
materials materials
joblines(where: {removed: {_eq: false}}) { joblines(where: {removed: {_eq: false}}) {
id id
@@ -2968,3 +2973,11 @@ exports.GET_JOB_WATCHERS_MINIMAL = `
} }
} }
`; `;
exports.INSERT_INTEGRATION_LOG = `
mutation INSERT_INTEGRATION_LOG($log: integration_log_insert_input!) {
insert_integration_log_one(object: $log) {
id
}
}
`;

View File

@@ -567,6 +567,29 @@ function GenerateCostingData(job) {
); );
} }
if (InstanceManager({ imex: false, rome: true })) {
const stlTowing = job.cieca_stl?.data.find((c) => c.ttl_type === "OTTW");
const stlStorage = job.cieca_stl?.data.find((c) => c.ttl_type === "OTST");
if (!jobLineTotalsByProfitCenter.additional[defaultProfits["TOW"]])
jobLineTotalsByProfitCenter.additional[defaultProfits["TOW"]] = Dinero();
jobLineTotalsByProfitCenter.additional[defaultProfits["TOW"]] = stlTowing
? Dinero({ amount: Math.round(stlTowing.ttl_amt * 100) })
: Dinero({
amount: Math.round((job.towing_payable || 0) * 100)
});
if (!jobLineTotalsByProfitCenter.additional[defaultProfits["STO"]])
jobLineTotalsByProfitCenter.additional[defaultProfits["STO"]] = Dinero();
jobLineTotalsByProfitCenter.additional[defaultProfits["STO"]] = stlStorage
? Dinero({ amount: Math.round(stlStorage.ttl_amt * 100) })
: Dinero({
amount: Math.round((job.storage_payable || 0) * 100)
});
}
//Is it a DMS Setup? //Is it a DMS Setup?
const selectedDmsAllocationConfig = const selectedDmsAllocationConfig =
(job.bodyshop.md_responsibility_centers.dms_defaults && (job.bodyshop.md_responsibility_centers.dms_defaults &&

View File

@@ -138,6 +138,9 @@ router.post("/canvastest", validateFirebaseIdTokenMiddleware, canvastest);
// Alert Check // Alert Check
router.post("/alertcheck", eventAuthorizationMiddleware, alertCheck); router.post("/alertcheck", eventAuthorizationMiddleware, alertCheck);
//EMS Upload
router.post("/emsupload", validateFirebaseIdTokenMiddleware, data.emsUpload);
// Redis Cache Routes // Redis Cache Routes
router.post("/bodyshop-cache", eventAuthorizationMiddleware, updateBodyshopCache); router.post("/bodyshop-cache", eventAuthorizationMiddleware, updateBodyshopCache);

View File

@@ -7,7 +7,7 @@ const { status, markConversationRead } = require("../sms/status");
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware"); const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
// Twilio Webhook Middleware for production // Twilio Webhook Middleware for production
// TODO: Look into this because it technically is never validating anything // TODO: This is never actually doing anything, we should probably verify
const twilioWebhookMiddleware = twilio.webhook({ validate: process.env.NODE_ENV === "PRODUCTION" }); const twilioWebhookMiddleware = twilio.webhook({ validate: process.env.NODE_ENV === "PRODUCTION" });
router.post("/receive", twilioWebhookMiddleware, receive); router.post("/receive", twilioWebhookMiddleware, receive);

View File

@@ -1,17 +1,23 @@
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const client = require("../graphql-client/graphql-client").client; const client = require("../graphql-client/graphql-client").client;
const queries = require("../graphql-client/queries"); const {
FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID,
UNARCHIVE_CONVERSATION,
CREATE_CONVERSATION,
INSERT_MESSAGE
} = require("../graphql-client/queries");
const { phone } = require("phone"); const { phone } = require("phone");
const { admin } = require("../firebase/firebase-handler"); const { admin } = require("../firebase/firebase-handler");
const logger = require("../utils/logger");
const InstanceManager = require("../utils/instanceMgr").default; const InstanceManager = require("../utils/instanceMgr").default;
exports.receive = async (req, res) => { /**
* Receive SMS messages from Twilio and process them
* @param req
* @param res
* @returns {Promise<*>}
*/
const receive = async (req, res) => {
const { const {
logger,
ioRedis, ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom } ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
} = req; } = req;
@@ -20,7 +26,7 @@ exports.receive = async (req, res) => {
msid: req.body.SmsMessageSid, msid: req.body.SmsMessageSid,
text: req.body.Body, text: req.body.Body,
image: !!req.body.MediaUrl0, image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body) image_path: generateMediaArray(req.body, logger)
}; };
logger.log("sms-inbound", "DEBUG", "api", null, loggerData); logger.log("sms-inbound", "DEBUG", "api", null, loggerData);
@@ -35,7 +41,7 @@ exports.receive = async (req, res) => {
try { try {
// Step 1: Find the bodyshop and existing conversation // Step 1: Find the bodyshop and existing conversation
const response = await client.request(queries.FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, { const response = await client.request(FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, {
mssid: req.body.MessagingServiceSid, mssid: req.body.MessagingServiceSid,
phone: phone(req.body.From).phoneNumber phone: phone(req.body.From).phoneNumber
}); });
@@ -46,7 +52,7 @@ exports.receive = async (req, res) => {
const bodyshop = response.bodyshops[0]; const bodyshop = response.bodyshops[0];
// Sort conversations by `updated_at` (or `created_at`) and pick the last one // Step 4: Process conversation
const sortedConversations = bodyshop.conversations.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); const sortedConversations = bodyshop.conversations.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
const existingConversation = sortedConversations.length const existingConversation = sortedConversations.length
? sortedConversations[sortedConversations.length - 1] ? sortedConversations[sortedConversations.length - 1]
@@ -57,25 +63,21 @@ exports.receive = async (req, res) => {
msid: req.body.SmsMessageSid, msid: req.body.SmsMessageSid,
text: req.body.Body, text: req.body.Body,
image: !!req.body.MediaUrl0, image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body), image_path: generateMediaArray(req.body, logger),
isoutbound: false, isoutbound: false,
userid: null // Add additional fields as necessary userid: null
}; };
if (existingConversation) { if (existingConversation) {
// Use the existing conversation
conversationid = existingConversation.id; conversationid = existingConversation.id;
// Unarchive the conversation if necessary
if (existingConversation.archived) { if (existingConversation.archived) {
await client.request(queries.UNARCHIVE_CONVERSATION, { await client.request(UNARCHIVE_CONVERSATION, {
id: conversationid, id: conversationid,
archived: false archived: false
}); });
} }
} else { } else {
// Create a new conversation const newConversationResponse = await client.request(CREATE_CONVERSATION, {
const newConversationResponse = await client.request(queries.CREATE_CONVERSATION, {
conversation: { conversation: {
bodyshopid: bodyshop.id, bodyshopid: bodyshop.id,
phone_num: phone(req.body.From).phoneNumber, phone_num: phone(req.body.From).phoneNumber,
@@ -86,13 +88,12 @@ exports.receive = async (req, res) => {
conversationid = createdConversation.id; conversationid = createdConversation.id;
} }
// Ensure `conversationid` is added to the message
newMessage.conversationid = conversationid; newMessage.conversationid = conversationid;
// Step 3: Insert the message into the conversation // Step 5: Insert the message
const insertresp = await client.request(queries.INSERT_MESSAGE, { const insertresp = await client.request(INSERT_MESSAGE, {
msg: newMessage, msg: newMessage,
conversationid: conversationid conversationid
}); });
const message = insertresp?.insert_messages?.returning?.[0]; const message = insertresp?.insert_messages?.returning?.[0];
@@ -102,8 +103,7 @@ exports.receive = async (req, res) => {
throw new Error("Conversation data is missing from the response."); throw new Error("Conversation data is missing from the response.");
} }
// Step 4: Notify clients through Redis // Step 6: Notify clients
const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id);
const conversationRoom = getBodyshopConversationRoom({ const conversationRoom = getBodyshopConversationRoom({
bodyshopId: conversation.bodyshop.id, bodyshopId: conversation.bodyshop.id,
conversationId: conversation.id conversationId: conversation.id
@@ -116,6 +116,8 @@ exports.receive = async (req, res) => {
msid: message.sid msid: message.sid
}; };
const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id);
ioRedis.to(broadcastRoom).emit("new-message-summary", { ioRedis.to(broadcastRoom).emit("new-message-summary", {
...commonPayload, ...commonPayload,
existingConversation: !!existingConversation, existingConversation: !!existingConversation,
@@ -131,13 +133,13 @@ exports.receive = async (req, res) => {
summary: false summary: false
}); });
// Step 5: Send FCM notification // Step 7: Send FCM notification
const fcmresp = await admin.messaging().send({ const fcmresp = await admin.messaging().send({
topic: `${message.conversation.bodyshop.imexshopid}-messaging`, topic: `${message.conversation.bodyshop.imexshopid}-messaging`,
notification: { notification: {
title: InstanceManager({ title: InstanceManager({
imex: `ImEX Online Message - ${message.conversation.phone_num}`, imex: `ImEX Online Message - ${message.conversation.phone_num}`,
rome: `Rome Online Message - ${message.conversation.phone_num}`, rome: `Rome Online Message - ${message.conversation.phone_num}`
}), }),
body: message.image_path ? `Image ${message.text}` : message.text body: message.image_path ? `Image ${message.text}` : message.text
}, },
@@ -157,11 +159,17 @@ exports.receive = async (req, res) => {
res.status(200).send(""); res.status(200).send("");
} catch (e) { } catch (e) {
handleError(req, e, res, "RECEIVE_MESSAGE"); handleError(req, e, res, "RECEIVE_MESSAGE", logger);
} }
}; };
const generateMediaArray = (body) => { /**
* Generate media array from the request body
* @param body
* @param logger
* @returns {null|*[]}
*/
const generateMediaArray = (body, logger) => {
const { NumMedia } = body; const { NumMedia } = body;
if (parseInt(NumMedia) > 0) { if (parseInt(NumMedia) > 0) {
const ret = []; const ret = [];
@@ -174,12 +182,20 @@ const generateMediaArray = (body) => {
} }
}; };
const handleError = (req, error, res, context) => { /**
* Handle error logging and response
* @param req
* @param error
* @param res
* @param context
* @param logger
*/
const handleError = (req, error, res, context, logger) => {
logger.log("sms-inbound-error", "ERROR", "api", null, { logger.log("sms-inbound-error", "ERROR", "api", null, {
msid: req.body.SmsMessageSid, msid: req.body.SmsMessageSid,
text: req.body.Body, text: req.body.Body,
image: !!req.body.MediaUrl0, image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body), image_path: generateMediaArray(req.body, logger),
messagingServiceSid: req.body.MessagingServiceSid, messagingServiceSid: req.body.MessagingServiceSid,
context, context,
error error
@@ -187,3 +203,7 @@ const handleError = (req, error, res, context) => {
res.status(500).json({ error: error.message || "Internal Server Error" }); res.status(500).json({ error: error.message || "Internal Server Error" });
}; };
module.exports = {
receive
};

View File

@@ -1,19 +1,20 @@
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const twilio = require("twilio"); const twilio = require("twilio");
const { phone } = require("phone"); const { phone } = require("phone");
const queries = require("../graphql-client/queries"); const { INSERT_MESSAGE } = require("../graphql-client/queries");
const logger = require("../utils/logger");
const client = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY); const client = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY);
const gqlClient = require("../graphql-client/graphql-client").client; const gqlClient = require("../graphql-client/graphql-client").client;
exports.send = async (req, res) => { /**
* Send an outbound SMS message
* @param req
* @param res
* @returns {Promise<void>}
*/
const send = async (req, res) => {
const { to, messagingServiceSid, body, conversationid, selectedMedia, imexshopid } = req.body; const { to, messagingServiceSid, body, conversationid, selectedMedia, imexshopid } = req.body;
const { const {
ioRedis, ioRedis,
logger,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom } ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
} = req; } = req;
@@ -25,8 +26,8 @@ exports.send = async (req, res) => {
conversationid, conversationid,
isoutbound: true, isoutbound: true,
userid: req.user.email, userid: req.user.email,
image: req.body.selectedMedia.length > 0, image: selectedMedia.length > 0,
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
}); });
if (!to || !messagingServiceSid || (!body && selectedMedia.length === 0) || !conversationid) { if (!to || !messagingServiceSid || (!body && selectedMedia.length === 0) || !conversationid) {
@@ -38,8 +39,8 @@ exports.send = async (req, res) => {
conversationid, conversationid,
isoutbound: true, isoutbound: true,
userid: req.user.email, userid: req.user.email,
image: req.body.selectedMedia.length > 0, image: selectedMedia.length > 0,
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
}); });
res.status(400).json({ success: false, message: "Missing required parameter(s)." }); res.status(400).json({ success: false, message: "Missing required parameter(s)." });
return; return;
@@ -59,12 +60,15 @@ exports.send = async (req, res) => {
conversationid, conversationid,
isoutbound: true, isoutbound: true,
userid: req.user.email, userid: req.user.email,
image: req.body.selectedMedia.length > 0, image: selectedMedia.length > 0,
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : [] image_path: selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
}; };
try { try {
const gqlResponse = await gqlClient.request(queries.INSERT_MESSAGE, { msg: newMessage, conversationid }); const gqlResponse = await gqlClient.request(INSERT_MESSAGE, {
msg: newMessage,
conversationid
});
logger.log("sms-outbound-success", "DEBUG", req.user.email, null, { logger.log("sms-outbound-success", "DEBUG", req.user.email, null, {
msid: message.sid, msid: message.sid,
@@ -111,3 +115,7 @@ exports.send = async (req, res) => {
res.status(500).json({ success: false, message: "Failed to send message through Twilio." }); res.status(500).json({ success: false, message: "Failed to send message through Twilio." });
} }
}; };
module.exports = {
send
};

View File

@@ -1,13 +1,14 @@
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const client = require("../graphql-client/graphql-client").client; const client = require("../graphql-client/graphql-client").client;
const queries = require("../graphql-client/queries"); const { UPDATE_MESSAGE_STATUS, MARK_MESSAGES_AS_READ } = require("../graphql-client/queries");
const logger = require("../utils/logger"); const logger = require("../utils/logger");
exports.status = async (req, res) => { /**
* Handle the status of an SMS message
* @param req
* @param res
* @returns {Promise<*>}
*/
const status = async (req, res) => {
const { SmsSid, SmsStatus } = req.body; const { SmsSid, SmsStatus } = req.body;
const { const {
ioRedis, ioRedis,
@@ -21,7 +22,7 @@ exports.status = async (req, res) => {
} }
// Update message status in the database // Update message status in the database
const response = await client.request(queries.UPDATE_MESSAGE_STATUS, { const response = await client.request(UPDATE_MESSAGE_STATUS, {
msid: SmsSid, msid: SmsSid,
fields: { status: SmsStatus } fields: { status: SmsStatus }
}); });
@@ -65,7 +66,13 @@ exports.status = async (req, res) => {
} }
}; };
exports.markConversationRead = async (req, res) => { /**
* Mark a conversation as read
* @param req
* @param res
* @returns {Promise<*>}
*/
const markConversationRead = async (req, res) => {
const { const {
ioRedis, ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom } ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
@@ -80,7 +87,7 @@ exports.markConversationRead = async (req, res) => {
} }
try { try {
const response = await client.request(queries.MARK_MESSAGES_AS_READ, { const response = await client.request(MARK_MESSAGES_AS_READ, {
conversationId conversationId
}); });
@@ -104,3 +111,8 @@ exports.markConversationRead = async (req, res) => {
res.status(500).json({ error: "Failed to mark conversation as read." }); res.status(500).json({ error: "Failed to mark conversation as read." });
} }
}; };
module.exports = {
status,
markConversationRead
};

View File

@@ -1,3 +1,11 @@
/**
* @module ioHelpers
* @param app
* @param api
* @param io
* @param logger
* @returns {{getBodyshopRoom: (function(*): string), getBodyshopConversationRoom: (function({bodyshopId: *, conversationId: *}): string)}}
*/
const applyIOHelpers = ({ app, api, io, logger }) => { const applyIOHelpers = ({ app, api, io, logger }) => {
// Global Bodyshop Room // Global Bodyshop Room
const getBodyshopRoom = (bodyshopId) => `bodyshop-broadcast-room:${bodyshopId}`; const getBodyshopRoom = (bodyshopId) => `bodyshop-broadcast-room:${bodyshopId}`;

View File

@@ -12,6 +12,9 @@ const { uploadFileToS3 } = require("./s3");
const { v4 } = require("uuid"); const { v4 } = require("uuid");
const { InstanceRegion } = require("./instanceMgr"); const { InstanceRegion } = require("./instanceMgr");
const getHostNameOrIP = require("./getHostNameOrIP"); const getHostNameOrIP = require("./getHostNameOrIP");
const client = require("../graphql-client/graphql-client").client;
const queries = require("../graphql-client/queries");
const LOG_LEVELS = { const LOG_LEVELS = {
error: { level: 0, name: "error" }, error: { level: 0, name: "error" },
@@ -99,13 +102,11 @@ const createLogger = () => {
const labelColor = "\x1b[33m"; // Yellow const labelColor = "\x1b[33m"; // Yellow
const separatorColor = "\x1b[35m|\x1b[0m"; // Magenta for separators const separatorColor = "\x1b[35m|\x1b[0m"; // Magenta for separators
return `${timestampColor} [${hostnameColor}] [${level}]: ${message} ${ return `${timestampColor} [${hostnameColor}] [${level}]: ${message} ${user ? `${separatorColor} ${labelColor}user:\x1b[0m ${JSON.stringify(user)}` : ""
user ? `${separatorColor} ${labelColor}user:\x1b[0m ${JSON.stringify(user)}` : "" } ${record ? `${separatorColor} ${labelColor}record:\x1b[0m ${JSON.stringify(record)}` : ""}${meta
} ${record ? `${separatorColor} ${labelColor}record:\x1b[0m ${JSON.stringify(record)}` : ""}${
meta
? `\n${separatorColor} ${labelColor}meta:\x1b[0m ${JSON.stringify(meta, null, 2)} ${separatorColor}` ? `\n${separatorColor} ${labelColor}meta:\x1b[0m ${JSON.stringify(meta, null, 2)} ${separatorColor}`
: "" : ""
}`; }`;
}) })
) )
}) })
@@ -194,9 +195,45 @@ const createLogger = () => {
winstonLogger.log(logEntry); winstonLogger.log(logEntry);
}; };
const LogIntegrationCall = async ({ platform, method, name, jobid, paymentid, billid, status, bodyshopid, email }) => {
try {
//Insert the record.
await client.request(queries.INSERT_INTEGRATION_LOG, {
log: {
platform,
method,
name,
jobid,
paymentid,
billid,
status: status?.toString() ?? "0",
bodyshopid,
email
}
});
} catch (error) {
console.trace("Stack", error?.stack);
log("integration-log-error", "ERROR", email, null, {
message: error?.message,
stack: error?.stack,
platform,
method,
name,
jobid,
paymentid,
billid,
status,
bodyshopid,
email
});
}
};
return { return {
log, log,
logger: winstonLogger logger: winstonLogger,
LogIntegrationCall
}; };
} catch (e) { } catch (e) {
console.error("Error setting up enhanced Logger, defaulting to console.: " + e?.message || ""); console.error("Error setting up enhanced Logger, defaulting to console.: " + e?.message || "");

View File

@@ -9,6 +9,7 @@ const {
const { defaultProvider } = require("@aws-sdk/credential-provider-node"); const { defaultProvider } = require("@aws-sdk/credential-provider-node");
const { InstanceRegion } = require("./instanceMgr"); const { InstanceRegion } = require("./instanceMgr");
const { isString, isEmpty } = require("lodash"); const { isString, isEmpty } = require("lodash");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
const createS3Client = () => { const createS3Client = () => {
const S3Options = { const S3Options = {
@@ -95,6 +96,17 @@ const createS3Client = () => {
throw error; throw error;
} }
}; };
const getPresignedUrl = async ({ bucketName, key }) => {
const command = new PutObjectCommand({
Bucket: bucketName,
Key: key,
StorageClass: "INTELLIGENT_TIERING"
});
const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: 360 });
return presignedUrl;
}
return { return {
uploadFileToS3, uploadFileToS3,
downloadFileFromS3, downloadFileFromS3,
@@ -102,8 +114,12 @@ const createS3Client = () => {
deleteFileFromS3, deleteFileFromS3,
copyFileInS3, copyFileInS3,
fileExistsInS3, fileExistsInS3,
getPresignedUrl,
...s3Client ...s3Client
}; };
}; };
module.exports = createS3Client(); module.exports = createS3Client();