Compare commits
131 Commits
feature/IO
...
feature/IO
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3877462eed | ||
|
|
01b18a4a02 | ||
|
|
17c4e2fd0e | ||
|
|
eb51085055 | ||
|
|
abd530b8b2 | ||
|
|
e4d437018d | ||
|
|
0767e290f4 | ||
|
|
b86309e74b | ||
|
|
7e2bd128e8 | ||
|
|
7f547c90c2 | ||
|
|
fa39e2b97e | ||
|
|
c5d00f7641 | ||
|
|
08b7f0e59c | ||
|
|
f0af12bc2c | ||
|
|
ace9ec792d | ||
|
|
015f4cc5bd | ||
|
|
4f1c0b9996 | ||
|
|
b395839b37 | ||
|
|
0f067fc503 | ||
|
|
a5cf81bd28 | ||
|
|
e892e4cab1 | ||
|
|
ef4bb75ce7 | ||
|
|
459af4f537 | ||
|
|
f860931eab | ||
|
|
0bf9f932b7 | ||
|
|
a077cf0820 | ||
|
|
c1abe98b89 | ||
|
|
0f32e6ffc7 | ||
|
|
eca7ff4a42 | ||
|
|
7d6b95d344 | ||
|
|
9e44ee2a26 | ||
|
|
5d0500582e | ||
|
|
f53fcc345e | ||
|
|
1b7cb7c852 | ||
|
|
c82cfb3ec2 | ||
|
|
cc5fea9410 | ||
|
|
29f7144e72 | ||
|
|
1384616d66 | ||
|
|
366f7b9c4a | ||
|
|
67e904e121 | ||
|
|
83ea51157d | ||
|
|
9f207f0946 | ||
|
|
2a81517104 | ||
|
|
00005c881e | ||
|
|
c1ea8e8a3d | ||
|
|
adb15a4748 | ||
|
|
c214ed1dfb | ||
|
|
c02c36c548 | ||
|
|
a15f86cc4e | ||
|
|
8a88a241d6 | ||
|
|
df13f257db | ||
|
|
5cfadf7929 | ||
|
|
4a46870327 | ||
|
|
4684bada1e | ||
|
|
163354f4b4 | ||
|
|
3d225c9f92 | ||
|
|
f3b2edea1c | ||
|
|
01e103fd0e | ||
|
|
1fc21e49a0 | ||
|
|
19d608e2b0 | ||
|
|
4b184d1d42 | ||
|
|
3f75041ad9 | ||
|
|
8c541dad05 | ||
|
|
921cca86c1 | ||
|
|
841312ebcd | ||
|
|
5ed00eaffe | ||
|
|
994ea8bb20 | ||
|
|
580641bae6 | ||
|
|
024b4fe21b | ||
|
|
40aca91c76 | ||
|
|
72305f91d8 | ||
|
|
abe4f4fb3d | ||
|
|
142617bc3d | ||
|
|
2ee582bfa2 | ||
|
|
35a3726cf0 | ||
|
|
54820fe3c8 | ||
|
|
b1ffbe0e12 | ||
|
|
ba2d03176f | ||
|
|
95a592fb9a | ||
|
|
6d343e9b7f | ||
|
|
c27b1d802f | ||
|
|
f11d9dd804 | ||
|
|
996f5b3c71 | ||
|
|
9bb7f647a7 | ||
|
|
760f2ac7f9 | ||
|
|
872e36a61a | ||
|
|
779f608506 | ||
|
|
d9f562faa4 | ||
|
|
14e362ec3f | ||
|
|
c213e13624 | ||
|
|
dae7642a8c | ||
|
|
c751f0cba4 | ||
|
|
e128c108f8 | ||
|
|
b8b76cb96c | ||
|
|
fa958cbbfe | ||
|
|
2146672916 | ||
|
|
f96460f332 | ||
|
|
04c70876d0 | ||
|
|
bc6a94eede | ||
|
|
f288b0ee22 | ||
|
|
e54692928b | ||
|
|
0fd8bcb1b1 | ||
|
|
07b18836f5 | ||
|
|
ff08d19d79 | ||
|
|
bd6f300c8d | ||
|
|
ac2fbaf6f7 | ||
|
|
f409acc7fd | ||
|
|
06dcb20b2b | ||
|
|
f4fed0db9d | ||
|
|
8430f500ef | ||
|
|
68584243f4 | ||
|
|
c8f5c3ed9e | ||
|
|
994d7e17aa | ||
|
|
fd1dd6dddd | ||
|
|
1e9b82ba1e | ||
|
|
312795618e | ||
|
|
35b5645d6f | ||
|
|
a49d845f50 | ||
|
|
9d44540ca8 | ||
|
|
cc95d3bd44 | ||
|
|
648c47cde2 | ||
|
|
17cf6e7696 | ||
|
|
7326ffbae6 | ||
|
|
b2f73c4fba | ||
|
|
6628d43e12 | ||
|
|
a064b8e07e | ||
|
|
da41668b3f | ||
|
|
df008abec9 | ||
|
|
29c99f2dd9 | ||
|
|
3033e84f45 | ||
|
|
18966476e4 |
@@ -9,13 +9,13 @@ orbs:
|
||||
jobs:
|
||||
imex-api-deploy:
|
||||
docker:
|
||||
- image: cimg/node:18.18.2
|
||||
- image: cimg/node:22.13.1
|
||||
steps:
|
||||
- checkout
|
||||
- eb/setup
|
||||
- run:
|
||||
command: |
|
||||
eb init imex-online-production-api -r ca-central-1 -p "Node.js 18 running on 64bit Amazon Linux 2"
|
||||
eb init imex-online-production-api -r ca-central-1 -p "Node.js 22 running on 64bit Amazon Linux 2023"
|
||||
eb status --verbose
|
||||
eb deploy
|
||||
eb status
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
imex-hasura-migrate:
|
||||
docker:
|
||||
- image: cimg/node:18.18.2
|
||||
- image: cimg/node:22.13.1
|
||||
parameters:
|
||||
secret:
|
||||
type: string
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
pipeline_number: << pipeline.number >>
|
||||
imex-app-build:
|
||||
docker:
|
||||
- image: cimg/node:18.18.2
|
||||
- image: cimg/node:22.13.1
|
||||
resource_class: large
|
||||
working_directory: ~/repo/client
|
||||
steps:
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
|
||||
imex-app-beta-build:
|
||||
docker:
|
||||
- image: cimg/node:18.18.2
|
||||
- image: cimg/node:22.13.1
|
||||
resource_class: large
|
||||
working_directory: ~/repo/client
|
||||
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
- eb/setup
|
||||
- run:
|
||||
command: |
|
||||
eb init romeonline-productionapi -r us-east-2 -p "Node.js 18 running on 64bit Amazon Linux 2"
|
||||
eb init romeonline-productionapi -r us-east-2 -p "Node.js 22 running on 64bit Amazon Linux 2023"
|
||||
eb status --verbose
|
||||
eb deploy
|
||||
eb status
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
pipeline_number: << pipeline.number >>
|
||||
rome-hasura-migrate:
|
||||
docker:
|
||||
- image: cimg/node:18.18.2
|
||||
- image: cimg/node:22.13.1
|
||||
parameters:
|
||||
secret:
|
||||
type: string
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
pipeline_number: << pipeline.number >>
|
||||
rome-app-build:
|
||||
docker:
|
||||
- image: cimg/node:18.18.2
|
||||
- image: cimg/node:22.13.1
|
||||
|
||||
working_directory: ~/repo/client
|
||||
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
|
||||
test-rome-hasura-migrate:
|
||||
docker:
|
||||
- image: cimg/node:18.18.2
|
||||
- image: cimg/node:22.13.1
|
||||
parameters:
|
||||
secret:
|
||||
type: string
|
||||
@@ -208,7 +208,7 @@ jobs:
|
||||
|
||||
test-rome-app-build:
|
||||
docker:
|
||||
- image: cimg/node:18.18.2
|
||||
- image: cimg/node:22.13.1
|
||||
|
||||
working_directory: ~/repo/client
|
||||
|
||||
@@ -239,7 +239,7 @@ jobs:
|
||||
|
||||
test-hasura-migrate:
|
||||
docker:
|
||||
- image: cimg/node:18.18.2
|
||||
- image: cimg/node:22.13.1
|
||||
parameters:
|
||||
secret:
|
||||
type: string
|
||||
@@ -266,7 +266,7 @@ jobs:
|
||||
|
||||
imex-test-app-build:
|
||||
docker:
|
||||
- image: cimg/node:18.18.2
|
||||
- image: cimg/node:22.13.1
|
||||
resource_class: large
|
||||
working_directory: ~/repo/client
|
||||
|
||||
@@ -286,7 +286,7 @@ jobs:
|
||||
|
||||
imex-test-app-beta-build:
|
||||
docker:
|
||||
- image: cimg/node:18.18.2
|
||||
- image: cimg/node:22.13.1
|
||||
resource_class: large
|
||||
working_directory: ~/repo/client
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ FROM amazonlinux:2023
|
||||
|
||||
# Install Git and Node.js (Amazon Linux 2023 uses the DNF package manager)
|
||||
RUN dnf install -y git \
|
||||
&& curl -sL https://rpm.nodesource.com/setup_20.x | bash - \
|
||||
&& curl -sL https://rpm.nodesource.com/setup_22.x | bash - \
|
||||
&& dnf install -y nodejs \
|
||||
&& dnf clean all
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<babeledit_project version="1.2" be_version="2.7.1">
|
||||
<babeledit_project be_version="2.7.1" version="1.2">
|
||||
<!--
|
||||
|
||||
BabelEdit project file
|
||||
@@ -6453,6 +6453,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>mark_critical</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>operation</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -6474,6 +6495,48 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>update_field</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>update_value</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>value</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -11943,6 +12006,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>shop_enabled_features</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>shopinfo</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -12312,6 +12396,37 @@
|
||||
</concept_node>
|
||||
</children>
|
||||
</folder_node>
|
||||
<folder_node>
|
||||
<name>tooltips</name>
|
||||
<children>
|
||||
<folder_node>
|
||||
<name>md_parts_scan</name>
|
||||
<children>
|
||||
<concept_node>
|
||||
<name>update_value_tooltip</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
</children>
|
||||
</folder_node>
|
||||
</children>
|
||||
</folder_node>
|
||||
<folder_node>
|
||||
<name>validation</name>
|
||||
<children>
|
||||
@@ -19091,6 +19206,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>ok</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>previous</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -19385,6 +19521,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>sharetoteams</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>submit</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -43090,6 +43247,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>parts_returns</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>print</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -48557,6 +48735,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>unassigned</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>vertical</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -52732,6 +52931,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>purchases_by_date_excel</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>purchases_by_date_range_detail</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -54483,6 +54703,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>view</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
</children>
|
||||
</folder_node>
|
||||
<folder_node>
|
||||
|
||||
9740
client/package-lock.json
generated
9740
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,77 +8,77 @@
|
||||
"private": true,
|
||||
"proxy": "http://localhost:4000",
|
||||
"dependencies": {
|
||||
"@ant-design/pro-layout": "^7.19.12",
|
||||
"@apollo/client": "^3.11.8",
|
||||
"@ant-design/pro-layout": "^7.22.3",
|
||||
"@apollo/client": "^3.13.1",
|
||||
"@emotion/is-prop-valid": "^1.3.1",
|
||||
"@fingerprintjs/fingerprintjs": "^4.5.0",
|
||||
"@fingerprintjs/fingerprintjs": "^4.6.1",
|
||||
"@jsreport/browser-client": "^3.1.0",
|
||||
"@reduxjs/toolkit": "^2.2.7",
|
||||
"@sentry/cli": "^2.36.2",
|
||||
"@reduxjs/toolkit": "^2.6.0",
|
||||
"@sentry/cli": "^2.42.2",
|
||||
"@sentry/react": "^7.114.0",
|
||||
"@splitsoftware/splitio-react": "^1.13.0",
|
||||
"@tanem/react-nprogress": "^5.0.51",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"antd": "^5.20.1",
|
||||
"@tanem/react-nprogress": "^5.0.53",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"antd": "^5.24.2",
|
||||
"apollo-link-logger": "^2.0.1",
|
||||
"apollo-link-sentry": "^3.3.0",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.7.7",
|
||||
"axios": "^1.8.1",
|
||||
"classnames": "^2.5.1",
|
||||
"css-box-model": "^1.2.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"dayjs-business-days2": "^1.2.2",
|
||||
"dayjs-business-days2": "^1.3.0",
|
||||
"dinero.js": "^1.9.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"env-cmd": "^10.1.0",
|
||||
"exifr": "^7.1.3",
|
||||
"firebase": "^10.13.2",
|
||||
"graphql": "^16.9.0",
|
||||
"graphql": "^16.10.0",
|
||||
"i18next": "^23.15.1",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"libphonenumber-js": "^1.11.9",
|
||||
"libphonenumber-js": "^1.12.4",
|
||||
"logrocket": "^8.1.2",
|
||||
"markerjs2": "^2.32.2",
|
||||
"markerjs2": "^2.32.3",
|
||||
"memoize-one": "^6.0.0",
|
||||
"normalize-url": "^8.0.1",
|
||||
"object-hash": "^3.0.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"query-string": "^9.1.0",
|
||||
"query-string": "^9.1.1",
|
||||
"raf-schd": "^4.0.3",
|
||||
"react": "^18.3.1",
|
||||
"react-big-calendar": "^1.14.1",
|
||||
"react-big-calendar": "^1.18.0",
|
||||
"react-color": "^2.19.3",
|
||||
"react-cookie": "^7.2.0",
|
||||
"react-cookie": "^7.2.2",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-drag-listview": "^2.0.0",
|
||||
"react-grid-gallery": "^1.0.1",
|
||||
"react-grid-layout": "1.3.4",
|
||||
"react-i18next": "^14.1.3",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-image-lightbox": "^5.1.4",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-number-format": "^5.4.2",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-number-format": "^5.4.3",
|
||||
"react-popopo": "^2.1.9",
|
||||
"react-product-fruits": "^2.2.61",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-resizable": "^3.0.5",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-router-dom": "^6.30.0",
|
||||
"react-sticky": "^6.0.3",
|
||||
"react-virtuoso": "^4.10.4",
|
||||
"recharts": "^2.12.7",
|
||||
"react-virtuoso": "^4.12.5",
|
||||
"recharts": "^2.15.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-actions": "^3.0.3",
|
||||
"redux-persist": "^6.0.0",
|
||||
"redux-saga": "^1.3.0",
|
||||
"redux-state-sync": "^3.1.4",
|
||||
"reselect": "^5.1.1",
|
||||
"sass": "^1.79.3",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"styled-components": "^6.1.13",
|
||||
"sass": "^1.85.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"styled-components": "^6.1.15",
|
||||
"subscriptions-transport-ws": "^0.11.0",
|
||||
"use-memo-one": "^1.1.3",
|
||||
"userpilot": "^1.3.6",
|
||||
"userpilot": "^1.3.8",
|
||||
"vite-plugin-ejs": "^1.7.0",
|
||||
"web-vitals": "^3.5.2"
|
||||
},
|
||||
@@ -120,36 +120,36 @@
|
||||
"@rollup/rollup-linux-x64-gnu": "4.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ant-design/icons": "^5.5.1",
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@babel/preset-react": "^7.24.7",
|
||||
"@dotenvx/dotenvx": "^1.14.1",
|
||||
"@emotion/babel-plugin": "^11.12.0",
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@eslint/js": "^9.15.0",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@dotenvx/dotenvx": "^1.38.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@sentry/webpack-plugin": "^2.22.4",
|
||||
"@testing-library/cypress": "^10.0.2",
|
||||
"browserslist": "^4.23.3",
|
||||
"browserslist": "^4.24.4",
|
||||
"browserslist-to-esbuild": "^2.1.1",
|
||||
"chalk": "^5.3.0",
|
||||
"chalk": "^5.4.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"cypress": "^13.14.2",
|
||||
"cypress": "^13.17.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
"eslint-plugin-cypress": "^2.15.1",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"globals": "^15.12.0",
|
||||
"memfs": "^4.12.0",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"globals": "^15.15.0",
|
||||
"memfs": "^4.17.0",
|
||||
"os-browserify": "^0.3.0",
|
||||
"react-error-overlay": "6.0.11",
|
||||
"react-error-overlay": "^6.1.0",
|
||||
"redux-logger": "^3.0.6",
|
||||
"source-map-explorer": "^2.5.3",
|
||||
"vite": "^5.4.7",
|
||||
"vite-plugin-babel": "^1.2.0",
|
||||
"vite": "^6.2.0",
|
||||
"vite-plugin-babel": "^1.3.0",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-plugin-node-polyfills": "^0.22.0",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"vite-plugin-node-polyfills": "^0.23.0",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vite-plugin-style-import": "^2.0.0",
|
||||
"workbox-window": "^7.1.0"
|
||||
"workbox-window": "^7.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useSplitClient } from "@splitsoftware/splitio-react";
|
||||
import { Button, Result } from "antd";
|
||||
import LogRocket from "logrocket";
|
||||
import React, { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import { Route, Routes, useNavigate } from "react-router-dom";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import DocumentEditorContainer from "../components/document-editor/document-editor.container";
|
||||
import ErrorBoundary from "../components/error-boundary/error-boundary.component"; // Component Imports
|
||||
@@ -46,6 +46,7 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
|
||||
const client = useSplitClient().client;
|
||||
const [listenersAdded, setListenersAdded] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!navigator.onLine) {
|
||||
@@ -200,7 +201,7 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
|
||||
path="/manage/*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<SocketProvider bodyshop={bodyshop}>
|
||||
<SocketProvider bodyshop={bodyshop} navigate={navigate}>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
</SocketProvider>
|
||||
</ErrorBoundary>
|
||||
@@ -212,7 +213,7 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
|
||||
path="/tech/*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<SocketProvider bodyshop={bodyshop}>
|
||||
<SocketProvider bodyshop={bodyshop} navigate={navigate}>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
</SocketProvider>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
border-bottom: 1px solid #74695c !important;
|
||||
}
|
||||
|
||||
// TODO: This was added because the newest release of ant was making the text color and the background color the same on a selected header
|
||||
// Tried all available tokens (https://ant.design/components/menu?locale=en-US) and even reverted all our custom styles, to no avail
|
||||
// This should be kept an eye on, especially if implementing DARK MODE
|
||||
.ant-menu-submenu-title {
|
||||
color: rgba(255, 255, 255, 0.65) !important;
|
||||
}
|
||||
|
||||
.imex-table-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useApolloClient } from "@apollo/client";
|
||||
import { getToken } from "@firebase/messaging";
|
||||
import axios from "axios";
|
||||
import React, { useContext, useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext";
|
||||
import { useSocket } from "../../contexts/SocketIO/socketContext";
|
||||
import { messaging, requestForToken } from "../../firebase/firebase.utils";
|
||||
import ChatPopupComponent from "../chat-popup/chat-popup.component";
|
||||
import "./chat-affix.styles.scss";
|
||||
@@ -12,7 +12,7 @@ import { registerMessagingHandlers, unregisterMessagingHandlers } from "./regist
|
||||
export function ChatAffixContainer({ bodyshop, chatVisible }) {
|
||||
const { t } = useTranslation();
|
||||
const client = useApolloClient();
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
|
||||
useEffect(() => {
|
||||
if (!bodyshop || !bodyshop.messagingservicesid) return;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { Button } from "antd";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOGGLE_CONVERSATION_ARCHIVE } from "../../graphql/conversations.queries";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||
import { connect } from "react-redux";
|
||||
@@ -18,7 +18,7 @@ export function ChatArchiveButton({ conversation, bodyshop }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const [updateConversation] = useMutation(TOGGLE_CONVERSATION_ARCHIVE);
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
|
||||
const handleToggleArchive = async () => {
|
||||
setLoading(true);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { Tag } from "antd";
|
||||
import React, { useContext } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import { REMOVE_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
|
||||
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||
import { connect } from "react-redux";
|
||||
@@ -18,7 +17,7 @@ const mapDispatchToProps = () => ({});
|
||||
|
||||
export function ChatConversationTitleTags({ jobConversations, bodyshop }) {
|
||||
const [removeJobConversation] = useMutation(REMOVE_CONVERSATION_TAG);
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
|
||||
const handleRemoveTag = async (jobId) => {
|
||||
const convId = jobConversations[0].conversationid;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { gql, useApolloClient, useQuery, useSubscription } from "@apollo/client";
|
||||
import axios from "axios";
|
||||
import React, { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext";
|
||||
import { GET_CONVERSATION_DETAILS, CONVERSATION_SUBSCRIPTION_BY_PK } from "../../graphql/conversations.queries";
|
||||
import { useSocket } from "../../contexts/SocketIO/socketContext";
|
||||
import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
|
||||
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import ChatConversationComponent from "./chat-conversation.component";
|
||||
@@ -16,7 +16,7 @@ const mapStateToProps = createStructuredSelector({
|
||||
|
||||
function ChatConversationContainer({ bodyshop, selectedConversation }) {
|
||||
const client = useApolloClient();
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
|
||||
|
||||
// Fetch conversation details
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { Input, Spin, Tag, Tooltip } from "antd";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UPDATE_CONVERSATION_LABEL } from "../../graphql/conversations.queries";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||
import { connect } from "react-redux";
|
||||
@@ -20,7 +20,7 @@ export function ChatLabel({ conversation, bodyshop }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [value, setValue] = useState(conversation.label);
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
const notification = useNotification();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { PlusCircleFilled } from "@ant-design/icons";
|
||||
import { Button, Form, Popover } from "antd";
|
||||
import React, { useContext } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
|
||||
import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
//currentUser: selectCurrentUser
|
||||
@@ -18,7 +17,7 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
export function ChatNewConversation({ openChatByPhone }) {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm();
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
|
||||
const handleFinish = (values) => {
|
||||
openChatByPhone({ phone_num: values.phoneNumber, socket });
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import parsePhoneNumber from "libphonenumber-js";
|
||||
import React, { useContext } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
|
||||
@@ -8,7 +7,7 @@ import PhoneNumberFormatter from "../../utils/PhoneFormatter";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { searchingForConversation } from "../../redux/messaging/messaging.selectors";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
@@ -22,7 +21,7 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
|
||||
export function ChatOpenButton({ bodyshop, searchingForConversation, phone, jobid, openChatByPhone }) {
|
||||
const { t } = useTranslation();
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
const notification = useNotification();
|
||||
|
||||
if (!phone) return <></>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { InfoCircleOutlined, MessageOutlined, ShrinkOutlined, SyncOutlined } from "@ant-design/icons";
|
||||
import { useApolloClient, useLazyQuery, useQuery } from "@apollo/client";
|
||||
import { Badge, Card, Col, Row, Space, Tag, Tooltip, Typography } from "antd";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -12,8 +12,9 @@ import ChatConversationListComponent from "../chat-conversation-list/chat-conver
|
||||
import ChatConversationContainer from "../chat-conversation/chat-conversation.container";
|
||||
import ChatNewConversation from "../chat-new-conversation/chat-new-conversation.component";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
|
||||
|
||||
import "./chat-popup.styles.scss";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
selectedConversation: selectSelectedConversation,
|
||||
@@ -27,7 +28,7 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
export function ChatPopupComponent({ chatVisible, selectedConversation, toggleChatVisible }) {
|
||||
const { t } = useTranslation();
|
||||
const [pollInterval, setPollInterval] = useState(0);
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
const client = useApolloClient(); // Apollo Client instance for cache operations
|
||||
|
||||
// Lazy query for conversations
|
||||
@@ -42,8 +43,7 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
|
||||
const { data: unreadData } = useQuery(UNREAD_CONVERSATION_COUNT, {
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only",
|
||||
skip: chatVisible, // Skip when chat is visible
|
||||
...(pollInterval > 0 ? { pollInterval } : {})
|
||||
pollInterval: 60 * 1000 // TODO: This is a fix for now, should be coming from sockets
|
||||
});
|
||||
|
||||
// Socket connection status
|
||||
@@ -85,29 +85,25 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
|
||||
|
||||
// Get unread count from the cache
|
||||
const unreadCount = (() => {
|
||||
if (chatVisible) {
|
||||
try {
|
||||
const cachedData = client.readQuery({
|
||||
query: CONVERSATION_LIST_QUERY,
|
||||
variables: { offset: 0 }
|
||||
});
|
||||
try {
|
||||
const cachedData = client.readQuery({
|
||||
query: CONVERSATION_LIST_QUERY,
|
||||
variables: { offset: 0 }
|
||||
});
|
||||
|
||||
if (!cachedData?.conversations) return 0;
|
||||
|
||||
// Aggregate unread message count
|
||||
return cachedData.conversations.reduce((total, conversation) => {
|
||||
const unread = conversation.messages_aggregate?.aggregate?.count || 0;
|
||||
return total + unread;
|
||||
}, 0);
|
||||
} catch (error) {
|
||||
console.warn("Unread count not found in cache:", error);
|
||||
return 0; // Fallback if not in cache
|
||||
if (!cachedData?.conversations) {
|
||||
return unreadData?.messages_aggregate?.aggregate?.count;
|
||||
}
|
||||
} else if (unreadData?.messages_aggregate?.aggregate?.count) {
|
||||
// Use the unread count from the query result
|
||||
return unreadData.messages_aggregate.aggregate.count;
|
||||
|
||||
// Aggregate unread message count
|
||||
return cachedData.conversations.reduce((total, conversation) => {
|
||||
const unread = conversation.messages_aggregate?.aggregate?.count || 0;
|
||||
return total + unread;
|
||||
}, 0);
|
||||
} catch (error) {
|
||||
console.warn("Unread count not found in cache:", error);
|
||||
return 0; // Fallback if not in cache
|
||||
}
|
||||
return 0;
|
||||
})();
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,13 +2,13 @@ import { PlusOutlined } from "@ant-design/icons";
|
||||
import { useLazyQuery, useMutation } from "@apollo/client";
|
||||
import { Tag } from "antd";
|
||||
import _ from "lodash";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
|
||||
import { SEARCH_FOR_JOBS } from "../../graphql/jobs.queries";
|
||||
import ChatTagRo from "./chat-tag-ro.component";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||
import { connect } from "react-redux";
|
||||
@@ -22,7 +22,7 @@ const mapDispatchToProps = () => ({});
|
||||
export function ChatTagRoContainer({ conversation, bodyshop }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
|
||||
const [loadRo, { loading, data }] = useLazyQuery(SEARCH_FOR_JOBS);
|
||||
|
||||
|
||||
@@ -75,6 +75,22 @@ class ErrorBoundary extends React.Component {
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
const { error, info } = this.state;
|
||||
|
||||
const collapseItems = error
|
||||
? [
|
||||
{
|
||||
key: "errors",
|
||||
label: t("general.labels.errors"),
|
||||
children: (
|
||||
<div>
|
||||
<strong>{error.message || "Unknown error"}</strong>
|
||||
<div>{error.stack || "No stack trace available"}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
if (this.state.hasErrored === true) {
|
||||
logImEXEvent("error_boundary_rendered", { error, info });
|
||||
|
||||
@@ -122,14 +138,7 @@ class ErrorBoundary extends React.Component {
|
||||
/>
|
||||
<Row>
|
||||
<Col offset={6} span={12}>
|
||||
<Collapse bordered={false}>
|
||||
<Collapse.Panel header={t("general.labels.errors")}>
|
||||
<div>
|
||||
<strong>{this.state.error.message}</strong>
|
||||
</div>
|
||||
<div>{this.state.error.stack}</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<Collapse bordered={false} items={collapseItems} />
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Icon, {
|
||||
BankFilled,
|
||||
BarChartOutlined,
|
||||
BellFilled,
|
||||
CarFilled,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleFilled,
|
||||
@@ -26,7 +27,7 @@ import Icon, {
|
||||
UserOutlined
|
||||
} from "@ant-design/icons";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import { Layout, Menu, Space } from "antd";
|
||||
import { Badge, Layout, Menu, Space, Spin } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BsKanban } from "react-icons/bs";
|
||||
import { FaCalendarAlt, FaCarCrash, FaCreditCard, FaFileInvoiceDollar, FaTasks } from "react-icons/fa";
|
||||
@@ -44,6 +45,15 @@ import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selecto
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
||||
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
|
||||
import { useState, useEffect } from "react";
|
||||
import { debounce } from "lodash";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
|
||||
import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
|
||||
|
||||
// Used to Determine if the Header is in Mobile Mode, and to toggle the multiple menus
|
||||
const HEADER_MOBILE_BREAKPOINT = 576;
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser,
|
||||
@@ -116,18 +126,64 @@ function Header({
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// const deleteBetaCookie = () => {
|
||||
// const cookieExists = document.cookie.split("; ").some((row) => row.startsWith(`betaSwitchImex=`));
|
||||
// if (cookieExists) {
|
||||
// const domain = window.location.hostname.split(".").slice(-2).join(".");
|
||||
// document.cookie = `betaSwitchImex=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=.${domain}`;
|
||||
// }
|
||||
// };
|
||||
//
|
||||
// deleteBetaCookie();
|
||||
const { isConnected } = useSocket();
|
||||
const [notificationVisible, setNotificationVisible] = useState(false);
|
||||
|
||||
const userAssociationId = bodyshop?.associations?.[0]?.id;
|
||||
|
||||
const {
|
||||
data: unreadData,
|
||||
refetch: refetchUnread,
|
||||
loading: unreadLoading
|
||||
} = useQuery(GET_UNREAD_COUNT, {
|
||||
variables: { associationid: userAssociationId },
|
||||
fetchPolicy: "network-only",
|
||||
pollInterval: isConnected ? 0 : 30000, // Poll only if socket is down
|
||||
skip: !userAssociationId // Skip query if no userAssociationId
|
||||
});
|
||||
|
||||
const unreadCount = unreadData?.notifications_aggregate?.aggregate?.count ?? 0;
|
||||
|
||||
// Initial fetch and socket status handling
|
||||
useEffect(() => {
|
||||
if (userAssociationId) {
|
||||
refetchUnread().catch((e) => console.error(`Something went wrong fetching unread notifications: ${e?.message}`));
|
||||
}
|
||||
}, [refetchUnread, userAssociationId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected && !unreadLoading && userAssociationId) {
|
||||
refetchUnread().catch((e) => console.error(`Something went wrong fetching unread notifications: ${e?.message}`));
|
||||
}
|
||||
}, [isConnected, unreadLoading, refetchUnread, userAssociationId]);
|
||||
|
||||
const handleNotificationClick = (e) => {
|
||||
setNotificationVisible(!notificationVisible);
|
||||
if (handleMenuClick) handleMenuClick(e);
|
||||
};
|
||||
|
||||
const [isMobile, setIsMobile] = useState(() => {
|
||||
const effectiveWidth = window.innerWidth / (window.devicePixelRatio || 1);
|
||||
return effectiveWidth <= HEADER_MOBILE_BREAKPOINT;
|
||||
});
|
||||
|
||||
const handleResize = debounce(() => {
|
||||
const effectiveWidth = window.innerWidth / (window.devicePixelRatio || 1);
|
||||
setIsMobile(effectiveWidth <= HEADER_MOBILE_BREAKPOINT);
|
||||
}, 200);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("resize", handleResize);
|
||||
window.addEventListener("orientationchange", handleResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
window.removeEventListener("orientationchange", handleResize);
|
||||
handleResize.cancel(); // Cancel any pending debounced calls on cleanup
|
||||
};
|
||||
}, [handleResize]);
|
||||
|
||||
// Accounting children setup (unchanged)
|
||||
const accountingChildren = [];
|
||||
|
||||
accountingChildren.push(
|
||||
{
|
||||
key: "bills",
|
||||
@@ -350,6 +406,7 @@ function Header({
|
||||
children: accountingExportChildren
|
||||
});
|
||||
|
||||
// Define all menu items
|
||||
const menuItems = [
|
||||
{
|
||||
key: "home",
|
||||
@@ -419,7 +476,6 @@ function Header({
|
||||
icon: <ScheduleOutlined />,
|
||||
label: <Link to="/manage/production/list">{t("menus.header.productionlist")}</Link>
|
||||
},
|
||||
|
||||
{
|
||||
key: "productionboard",
|
||||
id: "header-production-board",
|
||||
@@ -432,7 +488,6 @@ function Header({
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
|
||||
{
|
||||
type: "divider",
|
||||
id: "header-jobs-divider3"
|
||||
@@ -519,7 +574,6 @@ function Header({
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
...(accountingChildren.length > 0
|
||||
? [
|
||||
{
|
||||
@@ -537,7 +591,6 @@ function Header({
|
||||
icon: <PhoneOutlined />,
|
||||
label: <Link to="/manage/phonebook">{t("menus.header.phonebook")}</Link>
|
||||
},
|
||||
|
||||
{
|
||||
key: "temporarydocs",
|
||||
id: "header-temporarydocs",
|
||||
@@ -550,7 +603,6 @@ function Header({
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
|
||||
{
|
||||
key: "tasks",
|
||||
id: "tasks",
|
||||
@@ -623,7 +675,6 @@ function Header({
|
||||
icon: <Icon component={IoBusinessOutline} />,
|
||||
label: <Link to="/manage/shop/vendors">{t("menus.header.shop_vendors")}</Link>
|
||||
},
|
||||
|
||||
{
|
||||
key: "shop-csi",
|
||||
id: "header-shop-csi",
|
||||
@@ -638,9 +689,33 @@ function Header({
|
||||
}
|
||||
]
|
||||
},
|
||||
// Right-aligned items on desktop, merged on mobile
|
||||
{
|
||||
key: "notifications",
|
||||
icon: unreadLoading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<Badge count={unreadCount}>
|
||||
<BellFilled />
|
||||
</Badge>
|
||||
),
|
||||
id: "header-notifications",
|
||||
onClick: handleNotificationClick
|
||||
},
|
||||
{
|
||||
key: "recent",
|
||||
icon: <ClockCircleFilled />,
|
||||
id: "header-recent",
|
||||
children: recentItems.map((i, idx) => ({
|
||||
key: idx,
|
||||
id: `header-recent-${idx}`,
|
||||
label: <Link to={i.url}>{i.label}</Link>
|
||||
}))
|
||||
},
|
||||
{
|
||||
key: "user",
|
||||
label: currentUser.displayName || currentUser.email || t("general.labels.unknown"),
|
||||
icon: <UserOutlined />,
|
||||
// label: currentUser.displayName || currentUser.email || t("general.labels.unknown"),
|
||||
children: [
|
||||
{
|
||||
key: "signout",
|
||||
@@ -675,7 +750,6 @@ function Header({
|
||||
}
|
||||
]
|
||||
: []),
|
||||
|
||||
{
|
||||
key: "shiftclock",
|
||||
id: "header-shiftclock",
|
||||
@@ -688,64 +762,68 @@ function Header({
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
|
||||
{
|
||||
key: "profile",
|
||||
id: "header-profile",
|
||||
icon: <UserOutlined />,
|
||||
label: <Link to="/manage/profile">{t("menus.currentuser.profile")}</Link>
|
||||
}
|
||||
// {
|
||||
// key: 'langselecter',
|
||||
// label: t("menus.currentuser.languageselector"),
|
||||
// children: [
|
||||
// {
|
||||
// key: 'en-US',
|
||||
// label: t("general.languages.english"),
|
||||
// onClick: () => {
|
||||
// window.location.href = "/?lang=en-US";
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// key: 'fr-CA',
|
||||
// label: t("general.languages.french"),
|
||||
// onClick: () => {
|
||||
// window.location.href = "/?lang=fr-CA";
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// key: 'es-MX',
|
||||
// label: t("general.languages.spanish"),
|
||||
// onClick: () => {
|
||||
// window.location.href = "/?lang=es-MX";
|
||||
// }
|
||||
// },
|
||||
// ]
|
||||
// },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "recent",
|
||||
icon: <ClockCircleFilled />,
|
||||
id: "header-recent",
|
||||
children: recentItems.map((i, idx) => ({
|
||||
key: idx,
|
||||
id: `header-recent-${idx}`,
|
||||
label: <Link to={i.url}>{i.label}</Link>
|
||||
}))
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout.Header>
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
theme={"dark"}
|
||||
selectedKeys={[selectedHeader]}
|
||||
onClick={handleMenuClick}
|
||||
subMenuCloseDelay={0.3}
|
||||
items={menuItems}
|
||||
/>
|
||||
<Layout.Header style={{ padding: 0 }}>
|
||||
{isMobile ? (
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
theme="dark"
|
||||
selectedKeys={[selectedHeader]}
|
||||
onClick={handleMenuClick}
|
||||
subMenuCloseDelay={0.3}
|
||||
items={menuItems}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
width: "100%"
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
theme="dark"
|
||||
selectedKeys={[selectedHeader]}
|
||||
onClick={handleMenuClick}
|
||||
subMenuCloseDelay={0.3}
|
||||
items={menuItems.slice(0, -3)}
|
||||
style={{
|
||||
flex: "0 1 auto",
|
||||
justifyContent: "flex-start",
|
||||
minWidth: 0,
|
||||
overflow: "visible"
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: "1 0 0" }} />
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
theme="dark"
|
||||
selectedKeys={[selectedHeader]}
|
||||
onClick={handleMenuClick}
|
||||
subMenuCloseDelay={0.3}
|
||||
items={menuItems.slice(-3)}
|
||||
style={{
|
||||
flex: "0 0 auto",
|
||||
justifyContent: "flex-end",
|
||||
overflow: "visible"
|
||||
}}
|
||||
/>
|
||||
<NotificationCenterContainer visible={notificationVisible} onClose={() => setNotificationVisible(false)} />
|
||||
</div>
|
||||
)}
|
||||
</Layout.Header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,8 @@
|
||||
import i18next from "i18next";
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { setUserLanguage } from "../../redux/user/user.actions";
|
||||
import HeaderComponent from "./header.component";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
setUserLanguage: (language) => dispatch(setUserLanguage(language))
|
||||
});
|
||||
|
||||
export function HeaderContainer({ setUserLanguage }) {
|
||||
const handleMenuClick = (e) => {
|
||||
if (e.item.props.actiontype === "lang-select") {
|
||||
i18next.changeLanguage(e.key, (err, t) => {
|
||||
if (err) {
|
||||
logImEXEvent("language_change_error", { error: err });
|
||||
|
||||
return console.log("Error encountered when changing languages.", err);
|
||||
}
|
||||
logImEXEvent("language_change", { language: e.key });
|
||||
|
||||
setUserLanguage(e.key);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return <HeaderComponent handleMenuClick={handleMenuClick} />;
|
||||
export function HeaderContainer() {
|
||||
return <HeaderComponent />;
|
||||
}
|
||||
|
||||
export default connect(null, mapDispatchToProps)(HeaderContainer);
|
||||
export default connect(null, null)(HeaderContainer);
|
||||
|
||||
@@ -3,12 +3,12 @@ import { useMutation } from "@apollo/client";
|
||||
import { Button, Divider, Dropdown, Form, Input, Popover, Select, Space } from "antd";
|
||||
import parsePhoneNumber from "libphonenumber-js";
|
||||
import queryString from "query-string";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries";
|
||||
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
|
||||
import { setModalContext } from "../../redux/modals/modals.actions";
|
||||
@@ -51,7 +51,7 @@ export function ScheduleEventComponent({
|
||||
const searchParams = queryString.parse(useLocation().search);
|
||||
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT);
|
||||
const [title, setTitle] = useState(event.title);
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
const notification = useNotification();
|
||||
|
||||
const blockContent = (
|
||||
|
||||
@@ -16,7 +16,6 @@ import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
import JobCloseRoGuardTtLifecycle from "./job-close-ro-guard.tt-lifecycle";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
//currentUser: selectCurrentUser
|
||||
bodyshop: selectBodyshop,
|
||||
jobRO: selectJobReadOnly
|
||||
});
|
||||
@@ -40,34 +39,13 @@ export function JobCloseRoGuardContainer({ job, jobRO, bodyshop, form }) {
|
||||
|
||||
if (!bodyshop?.md_ro_guard?.enabled) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{warnings.length > 0 && (
|
||||
<Card
|
||||
title={
|
||||
<Space size="small">
|
||||
<Badge count={warnings.length} />
|
||||
{t("jobs.labels.roguardwarnings")}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<ul>
|
||||
{warnings.map((w, index) => (
|
||||
<li key={index}>
|
||||
{bodyshop.md_ro_guard[`enforce_${w.key}`] && (
|
||||
<Tooltip title={t("jobs.labels.ro_guard.enforced")}>
|
||||
<LockOutlined style={{ color: "tomato", marginRight: "8px" }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{w.warning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Collapse>
|
||||
<Collapse.Panel forceRender key="roguard" header={t("jobs.labels.roguard")}>
|
||||
// Define collapse items for both panels
|
||||
const collapseItems = [
|
||||
{
|
||||
key: "roguard",
|
||||
label: t("jobs.labels.roguard"),
|
||||
children: (
|
||||
<>
|
||||
<Row gutter={[32, 32]}>
|
||||
<Col span={24}>
|
||||
<JobCloseRoGuardBills job={job} form={form} warningCallback={warningCallback} />
|
||||
@@ -85,7 +63,6 @@ export function JobCloseRoGuardContainer({ job, jobRO, bodyshop, form }) {
|
||||
{InstanceRenderManager({
|
||||
rome: (
|
||||
<Col md={24} lg={8}>
|
||||
{/* <JobCloseRoGuardSublet job={job} warningCallback={warningCallback} /> */}
|
||||
<JobCloseRoGuardPpd job={job} warningCallback={warningCallback} />
|
||||
</Col>
|
||||
)
|
||||
@@ -214,16 +191,50 @@ export function JobCloseRoGuardContainer({ job, jobRO, bodyshop, form }) {
|
||||
>
|
||||
<Input prefix={<LockOutlined />} type="password" placeholder="Password" disabled={jobRO} />
|
||||
</Form.Item>
|
||||
</Collapse.Panel>
|
||||
</>
|
||||
),
|
||||
forceRender: true // Preserve the forceRender prop from the original
|
||||
},
|
||||
{
|
||||
key: "performance",
|
||||
label: t("jobs.labels.performance"),
|
||||
children: (
|
||||
<Row gutter={[32, 32]}>
|
||||
<Col className="ro-guard-col" span={24}>
|
||||
<JobCloseRoGuardTtLifecycle job={job} />
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
<Collapse.Panel header={t("jobs.labels.performance")}>
|
||||
<Row gutter={[32, 32]}>
|
||||
<Col className="ro-guard-col" span={24}>
|
||||
<JobCloseRoGuardTtLifecycle job={job} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
return (
|
||||
<>
|
||||
{warnings.length > 0 && (
|
||||
<Card
|
||||
title={
|
||||
<Space size="small">
|
||||
<Badge count={warnings.length} />
|
||||
{t("jobs.labels.roguardwarnings")}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<ul>
|
||||
{warnings.map((w, index) => (
|
||||
<li key={index}>
|
||||
{bodyshop.md_ro_guard[`enforce_${w.key}`] && (
|
||||
<Tooltip title={t("jobs.labels.ro_guard.enforced")}>
|
||||
<LockOutlined style={{ color: "tomato", marginRight: "8px" }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{w.warning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Collapse items={collapseItems} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,6 +62,9 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo
|
||||
refetchQueries: ["GET_LINE_TICKET_BY_PK"]
|
||||
});
|
||||
if (!r.errors) {
|
||||
if (CriticalPartsScanning.treatment === "on") {
|
||||
await CriticalPartsScan(jobLineEditModal.context.jobid, notification);
|
||||
}
|
||||
await Axios.post("/job/totalsssu", {
|
||||
id: jobLineEditModal.context.jobid
|
||||
});
|
||||
@@ -107,7 +110,9 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if (CriticalPartsScanning.treatment === "on") {
|
||||
await CriticalPartsScan(jobLineEditModal.context.jobid, notification);
|
||||
}
|
||||
if (jobLineEditModal.actions.submit) {
|
||||
jobLineEditModal.actions.submit();
|
||||
} else {
|
||||
@@ -115,9 +120,7 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo
|
||||
}
|
||||
toggleModalVisible();
|
||||
}
|
||||
if (CriticalPartsScanning.treatment === "on") {
|
||||
CriticalPartsScan(jobLineEditModal.context.jobid, notification);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ const colSpan = {
|
||||
export function JobsTotalsTableComponent({ jobRO, currentUser, job }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!!!job.job_totals) {
|
||||
if (!job.job_totals) {
|
||||
return (
|
||||
<Card>
|
||||
<Result title={t("jobs.errors.nofinancial")} extra={<JobCalculateTotals job={job} disabled={jobRO} />} />
|
||||
@@ -35,6 +35,29 @@ export function JobsTotalsTableComponent({ jobRO, currentUser, job }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Define collapse items
|
||||
const collapseItems = [
|
||||
{
|
||||
key: "json-tree-totals",
|
||||
label: "JSON Tree Totals",
|
||||
children: (
|
||||
<div>
|
||||
<pre>
|
||||
{JSON.stringify(
|
||||
{
|
||||
CIECA: job.cieca_ttl && job.cieca_ttl.data,
|
||||
CIECASTL: job.cieca_stl && job.cieca_stl.data,
|
||||
ImEXCalc: job.job_totals
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={[16, 16]}>
|
||||
@@ -68,23 +91,7 @@ export function JobsTotalsTableComponent({ jobRO, currentUser, job }) {
|
||||
<Col span={24}>
|
||||
<Card title="DEVELOPMENT USE ONLY">
|
||||
<JobCalculateTotals job={job} disabled={jobRO} />
|
||||
<Collapse>
|
||||
<Collapse.Panel header="JSON Tree Totals">
|
||||
<div>
|
||||
<pre>
|
||||
{JSON.stringify(
|
||||
{
|
||||
CIECA: job.cieca_ttl && job.cieca_ttl.data,
|
||||
CIECASTL: job.cieca_stl && job.cieca_stl.data,
|
||||
ImEXCalc: job.job_totals
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<Collapse items={collapseItems} />
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
|
||||
@@ -172,13 +172,13 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail
|
||||
job: newJob
|
||||
}
|
||||
});
|
||||
if (CriticalPartsScanning.treatment === "on") {
|
||||
await CriticalPartsScan(r.data.insert_jobs.returning[0].id, notification);
|
||||
}
|
||||
await Axios.post("/job/totalsssu", {
|
||||
id: r.data.insert_jobs.returning[0].id
|
||||
});
|
||||
|
||||
if (CriticalPartsScanning.treatment === "on") {
|
||||
CriticalPartsScan(r.data.insert_jobs.returning[0].id, notification);
|
||||
}
|
||||
notification["success"]({
|
||||
message: t("jobs.successes.created"),
|
||||
onClick: () => {
|
||||
@@ -281,6 +281,7 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail
|
||||
if (CriticalPartsScanning.treatment === "on") {
|
||||
CriticalPartsScan(updateResult.data.update_jobs.returning[0].id, notification);
|
||||
}
|
||||
|
||||
if (updateResult.errors) {
|
||||
//error while inserting
|
||||
notification["error"]({
|
||||
|
||||
@@ -1,29 +1,25 @@
|
||||
import { Collapse, Form, Input, InputNumber, Select, Space, Switch } from "antd";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
|
||||
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
||||
import FormItemEmail from "../form-items-formatted/email-form-item.component";
|
||||
import FormItemPhone, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
|
||||
import JobsDetailChangeEstimator from "../jobs-detail-change-estimator/jobs-detail-change-estimator.component";
|
||||
import JobsDetailChangeFilehandler from "../jobs-detail-change-filehandler/jobs-detail-change-filehandler.component";
|
||||
import JobsDetailRatesChangeButton from "../jobs-detail-rates-change-button/jobs-detail-rates-change-button.component";
|
||||
import JobsDetailRatesParts from "../jobs-detail-rates/jobs-detail-rates.parts.component";
|
||||
|
||||
import JobsDetailRatesLabor from "../jobs-detail-rates/jobs-detail-rates.labor.component";
|
||||
import JobsDetailRatesMaterials from "../jobs-detail-rates/jobs-detail-rates.materials.component";
|
||||
import JobsDetailRatesOther from "../jobs-detail-rates/jobs-detail-rates.other.component";
|
||||
import JobsDetailRatesParts from "../jobs-detail-rates/jobs-detail-rates.parts.component";
|
||||
import JobsDetailRatesTaxes from "../jobs-detail-rates/jobs-detail-rates.taxes.component";
|
||||
|
||||
import JobsMarkPstExempt from "../jobs-mark-pst-exempt/jobs-mark-pst-exempt.component";
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
//currentUser: selectCurrentUser
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
@@ -42,164 +38,179 @@ export function JobsCreateJobsInfo({ bodyshop, form, selected }) {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Collapse defaultActiveKey="insurance">
|
||||
<Collapse.Panel key="insurance" header={t("menus.jobsdetail.insurance")} forceRender>
|
||||
<LayoutFormRow>
|
||||
<Form.Item label={t("jobs.fields.clm_no")} name="clm_no">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.policy_no")} name="policy_no">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.regie_number")} name="regie_number">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ins_co_nm")} name="ins_co_nm">
|
||||
<Select onChange={handleInsCoChange}>
|
||||
{bodyshop.md_ins_cos.map((s) => (
|
||||
<Select.Option key={s.name} value={s.name}>
|
||||
{s.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ins_addr1")} name="ins_addr1">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ins_city")} name="ins_city">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<Space>
|
||||
{t("jobs.fields.ins_ct_ln")}
|
||||
<JobsDetailChangeFilehandler form={form} />
|
||||
</Space>
|
||||
// Define collapse items for all three panels
|
||||
const collapseItems = [
|
||||
{
|
||||
key: "insurance",
|
||||
label: t("menus.jobsdetail.insurance"),
|
||||
children: (
|
||||
<LayoutFormRow>
|
||||
<Form.Item label={t("jobs.fields.clm_no")} name="clm_no">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.policy_no")} name="policy_no">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.regie_number")} name="regie_number">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ins_co_nm")} name="ins_co_nm">
|
||||
<Select onChange={handleInsCoChange}>
|
||||
{bodyshop.md_ins_cos.map((s) => (
|
||||
<Select.Option key={s.name} value={s.name}>
|
||||
{s.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ins_addr1")} name="ins_addr1">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ins_city")} name="ins_city">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<Space>
|
||||
{t("jobs.fields.ins_ct_ln")}
|
||||
<JobsDetailChangeFilehandler form={form} />
|
||||
</Space>
|
||||
}
|
||||
name="ins_ct_ln"
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ins_ct_fn")} name="ins_ct_fn">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.ins_ph1")}
|
||||
name="ins_ph1"
|
||||
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ins_ph1")]}
|
||||
>
|
||||
<FormItemPhone />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.ins_ea")}
|
||||
name="ins_ea"
|
||||
rules={[
|
||||
{
|
||||
type: "email",
|
||||
message: "This is not a valid email address."
|
||||
}
|
||||
name="ins_ct_ln"
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ins_ct_fn")} name="ins_ct_fn">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.ins_ph1")}
|
||||
name="ins_ph1"
|
||||
rules={[({ getFieldValue }) => PhoneItemFormatterValidation(getFieldValue, "ins_ph1")]}
|
||||
>
|
||||
<FormItemPhone />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.ins_ea")}
|
||||
name="ins_ea"
|
||||
rules={[
|
||||
{
|
||||
type: "email",
|
||||
message: "This is not a valid email address."
|
||||
}
|
||||
]}
|
||||
>
|
||||
<FormItemEmail email={getFieldValue("ins_ea")} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.loss_date")} name="loss_date">
|
||||
<DateTimePicker isDateOnly />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.kmin")} name="kmin">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.est_co_nm")} name="est_co_nm">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<Space>
|
||||
{t("jobs.fields.est_ct_fn")}
|
||||
<JobsDetailChangeEstimator form={form} />
|
||||
</Space>
|
||||
]}
|
||||
>
|
||||
<FormItemEmail email={getFieldValue("ins_ea")} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.loss_date")} name="loss_date">
|
||||
<DateTimePicker isDateOnly />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.kmin")} name="kmin">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.est_co_nm")} name="est_co_nm">
|
||||
sausage <Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<Space>
|
||||
{t("jobs.fields.est_ct_fn")}
|
||||
<JobsDetailChangeEstimator form={form} />
|
||||
</Space>
|
||||
}
|
||||
name="est_ct_fn"
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.est_ct_ln")} name="est_ct_ln">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.est_ph1")} name="est_ph1">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.est_ea")}
|
||||
name="est_ea"
|
||||
rules={[
|
||||
{
|
||||
type: "email",
|
||||
message: "This is not a valid email address."
|
||||
}
|
||||
name="est_ct_fn"
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.est_ct_ln")} name="est_ct_ln">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.est_ph1")} name="est_ph1">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.est_ea")}
|
||||
name="est_ea"
|
||||
rules={[
|
||||
{
|
||||
type: "email",
|
||||
message: "This is not a valid email address."
|
||||
}
|
||||
]}
|
||||
>
|
||||
<FormItemEmail email={getFieldValue("est_ea")} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.servicing_dealer")} name="servicing_dealer">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.selling_dealer_contact")} name="selling_dealer_contact">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.servicing_dealer_contact")} name="servicing_dealer_contact">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
</Collapse.Panel>
|
||||
<Collapse.Panel forceRender key="claim" header={t("menus.jobsdetail.claimdetail")}>
|
||||
<LayoutFormRow>
|
||||
<Form.Item label={t("jobs.fields.loss_desc")} name="loss_desc">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.loss_of_use")} name="loss_of_use">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ponumber")} name="po_number">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.unitnumber")} name="unit_number">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.specialcoveragepolicy")}
|
||||
valuePropName="checked"
|
||||
name="special_coverage_policy"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.kmin")} name="kmin">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.kmout")} name="kmout">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.referralsource")} name="referral_source">
|
||||
<Select>
|
||||
{bodyshop.md_referral_sources.map((s) => (
|
||||
<Select.Option key={s} value={s}>
|
||||
{s}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
</Collapse.Panel>
|
||||
<Collapse.Panel forceRender key="financial" header={t("menus.jobsdetail.financials")}>
|
||||
]}
|
||||
>
|
||||
<FormItemEmail email={getFieldValue("est_ea")} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.servicing_dealer")} name="servicing_dealer">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.selling_dealer_contact")} name="selling_dealer_contact">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.servicing_dealer_contact")} name="servicing_dealer_contact">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
),
|
||||
forceRender: true
|
||||
},
|
||||
{
|
||||
key: "claim",
|
||||
label: t("menus.jobsdetail.claimdetail"),
|
||||
children: (
|
||||
<LayoutFormRow>
|
||||
<Form.Item label={t("jobs.fields.loss_desc")} name="loss_desc">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.loss_of_use")} name="loss_of_use">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ponumber")} name="po_number">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.unitnumber")} name="unit_number">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.specialcoveragepolicy")}
|
||||
valuePropName="checked"
|
||||
name="special_coverage_policy"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.kmin")} name="kmin">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.kmout")} name="kmout">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.referralsource")} name="referral_source">
|
||||
<Select>
|
||||
{bodyshop.md_referral_sources.map((s) => (
|
||||
<Select.Option key={s} value={s}>
|
||||
{s}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
),
|
||||
forceRender: true
|
||||
},
|
||||
{
|
||||
key: "financial",
|
||||
label: t("menus.jobsdetail.financials"),
|
||||
children: (
|
||||
<>
|
||||
<JobsDetailRatesChangeButton form={form} />
|
||||
<JobsMarkPstExempt form={form} />
|
||||
{InstanceRenderManager({
|
||||
imex: <JobsMarkPstExempt form={form} />
|
||||
})}
|
||||
<LayoutFormRow>
|
||||
<Form.Item label={t("jobs.fields.ded_amt")} name="ded_amt">
|
||||
<CurrencyInput min={0} />
|
||||
@@ -246,7 +257,6 @@ export function JobsCreateJobsInfo({ bodyshop, form, selected }) {
|
||||
</LayoutFormRow>
|
||||
)
|
||||
})}
|
||||
|
||||
<LayoutFormRow>
|
||||
<Form.Item label={t("jobs.fields.rate_lab")} name="rate_lab">
|
||||
<CurrencyInput />
|
||||
@@ -317,8 +327,15 @@ export function JobsCreateJobsInfo({ bodyshop, form, selected }) {
|
||||
<CurrencyInput />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</>
|
||||
),
|
||||
forceRender: true
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Collapse defaultActiveKey="insurance" items={collapseItems} />
|
||||
<JobsDetailRatesParts jobRO={false} expanded required={selected && true} form={form} />
|
||||
{InstanceRenderManager({
|
||||
rome: (
|
||||
|
||||
@@ -4,12 +4,12 @@ import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import { Button, Card, Dropdown, Form, Input, Modal, Popconfirm, Popover, Select, Space } from "antd";
|
||||
import axios from "axios";
|
||||
import parsePhoneNumber from "libphonenumber-js";
|
||||
import { useContext, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { auth, logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import { CANCEL_APPOINTMENTS_BY_JOB_ID, INSERT_MANUAL_APPT } from "../../graphql/appointments.queries";
|
||||
import { GET_CURRENT_QUESTIONSET_ID, INSERT_CSI } from "../../graphql/csi.queries";
|
||||
@@ -32,6 +32,7 @@ import AddToProduction from "./jobs-detail-header-actions.addtoproduction.util";
|
||||
import DuplicateJob from "./jobs-detail-header-actions.duplicate.util";
|
||||
import JobsDetailHeaderActionsToggleProduction from "./jobs-detail-header-actions.toggle-production";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
@@ -129,7 +130,7 @@ export function JobsDetailHeaderActions({
|
||||
const [updateJob] = useMutation(UPDATE_JOB);
|
||||
const [voidJob] = useMutation(VOID_JOB);
|
||||
const [cancelAllAppointments] = useMutation(CANCEL_APPOINTMENTS_BY_JOB_ID);
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
const notification = useNotification();
|
||||
|
||||
const {
|
||||
@@ -201,7 +202,10 @@ export function JobsDetailHeaderActions({
|
||||
message: t("appointments.successes.created")
|
||||
});
|
||||
} catch (error) {
|
||||
notification.open({ type: "error", message: t("appointments.errors.saving", { error: error.message }) });
|
||||
notification.open({
|
||||
type: "error",
|
||||
message: t("appointments.errors.saving", { error: error.message })
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setVisibility(false);
|
||||
@@ -838,7 +842,7 @@ export function JobsDetailHeaderActions({
|
||||
id: "job-actions-addtoproduction",
|
||||
disabled: !job.converted,
|
||||
label: t("jobs.actions.addtoproduction"),
|
||||
onClick: () => AddToProduction(client, job.id, refetch, notification)
|
||||
onClick: () => AddToProduction(client, job.id, refetch, false, notification)
|
||||
}
|
||||
);
|
||||
|
||||
@@ -971,6 +975,14 @@ export function JobsDetailHeaderActions({
|
||||
}
|
||||
);
|
||||
|
||||
if (bodyshop?.md_functionality_toggles?.teams) {
|
||||
menuItems.push({
|
||||
key: "sharetoteams",
|
||||
id: "job-actions-sharetoteams",
|
||||
label: <ShareToTeamsButton noIcon={true} urlOverride={`${window.location.origin}${window.location.pathname}`} />
|
||||
});
|
||||
}
|
||||
|
||||
menuItems.push({
|
||||
key: "exportcustdata",
|
||||
id: "job-actions-exportcustdata",
|
||||
|
||||
@@ -13,6 +13,7 @@ import AuditTrailMapping from "../../utils/AuditTrailMappings";
|
||||
import { DateTimeFormatterFunction } from "../../utils/DateFormatter";
|
||||
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
//currentUser: selectCurrentUser,
|
||||
@@ -39,7 +40,7 @@ export function JobsDetailHeaderActionsToggleProduction({
|
||||
const [form] = Form.useForm();
|
||||
const notification = useNotification();
|
||||
|
||||
const [getJobDetails] = useLazyQuery(GET_JOB_BY_PK_QUICK_INTAKE, {
|
||||
const [getJobDetails, { loading: jobDetailsLoading }] = useLazyQuery(GET_JOB_BY_PK_QUICK_INTAKE, {
|
||||
variables: { id: job.id },
|
||||
onCompleted: (data) => {
|
||||
if (data?.jobs_by_pk) {
|
||||
@@ -109,65 +110,69 @@ export function JobsDetailHeaderActionsToggleProduction({
|
||||
|
||||
const popMenu = (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Form layout="vertical" form={form} onFinish={handleConvert}>
|
||||
{scenario === "pre" && (
|
||||
<>
|
||||
<Form.Item
|
||||
name={["actual_in"]}
|
||||
label={t("jobs.fields.actual_in")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<FormDateTimePickerComponent disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["scheduled_completion"]}
|
||||
label={t("jobs.fields.scheduled_completion")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<FormDateTimePickerComponent disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item name={["scheduled_delivery"]} label={t("jobs.fields.scheduled_delivery")}>
|
||||
<FormDateTimePickerComponent disabled={jobRO} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{scenario === "prod" && (
|
||||
<>
|
||||
<Form.Item
|
||||
name={["actual_completion"]}
|
||||
label={t("jobs.fields.actual_completion")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<FormDateTimePickerComponent disabled={jobRO} />
|
||||
</Form.Item>
|
||||
{jobDetailsLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<Form layout="vertical" form={form} onFinish={handleConvert}>
|
||||
{scenario === "pre" && (
|
||||
<>
|
||||
<Form.Item
|
||||
name={["actual_in"]}
|
||||
label={t("jobs.fields.actual_in")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<FormDateTimePickerComponent disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["scheduled_completion"]}
|
||||
label={t("jobs.fields.scheduled_completion")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<FormDateTimePickerComponent disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item name={["scheduled_delivery"]} label={t("jobs.fields.scheduled_delivery")}>
|
||||
<FormDateTimePickerComponent disabled={jobRO} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{scenario === "prod" && (
|
||||
<>
|
||||
<Form.Item
|
||||
name={["actual_completion"]}
|
||||
label={t("jobs.fields.actual_completion")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<FormDateTimePickerComponent disabled={jobRO} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name={["actual_delivery"]} label={t("jobs.fields.actual_delivery")}>
|
||||
<FormDateTimePickerComponent disabled={jobRO} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Form.Item name={["actual_delivery"]} label={t("jobs.fields.actual_delivery")}>
|
||||
<FormDateTimePickerComponent disabled={jobRO} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Space wrap>
|
||||
<Button type="primary" onClick={() => form.submit()} loading={loading}>
|
||||
{t("general.actions.save")}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
<Space wrap>
|
||||
<Button type="primary" onClick={() => form.submit()} loading={loading}>
|
||||
{t("general.actions.save")}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled }) {
|
||||
<DataLabel label={t("jobs.labels.contracts")}>
|
||||
{job.cccontracts.map((c, index) => (
|
||||
<Space key={c.id} wrap>
|
||||
<Link to={`/manage/courtesycars/contracts/${c.id}`}>
|
||||
<Link to={`/manage/courtesycars/contracts/${c.id}`}>
|
||||
{`${c.agreementnumber} - ${c.courtesycar.fleetnumber} ${c.courtesycar.year} ${c.courtesycar.make} ${c.courtesycar.model}`}
|
||||
{index !== job.cccontracts.length - 1 ? "," : null}
|
||||
</Link>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,154 +13,162 @@ const mapStateToProps = createStructuredSelector({
|
||||
export function JobsDetailRatesMaterials({ jobRO, expanded, required = true, form }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Collapse defaultActiveKey={expanded && "rates"}>
|
||||
<Collapse.Panel forceRender header={t("jobs.fields.materials.materials")} key="materials">
|
||||
<LayoutFormRow header={t("jobs.fields.materials.MAPA")}>
|
||||
<Form.Item label={t("jobs.fields.materials.cal_maxdlr")} name={["materials", "MAPA", "cal_maxdlr"]}>
|
||||
<InputNumber min={0} precision={2} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.materials.cal_opcode")} name={["materials", "MAPA", "cal_opcode"]}>
|
||||
<Input disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.materials.mat_adjp")} name={["materials", "MAPA", "mat_adjp"]}>
|
||||
<InputNumber min={-100} max={100} precision={4} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.tax_ind")}
|
||||
name={["materials", "MAPA", "tax_ind"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate>
|
||||
{() => {
|
||||
return (
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_taxp")}
|
||||
name={["materials", "MAPA", "mat_taxp"]}
|
||||
rules={[
|
||||
{
|
||||
required: form.getFieldValue(["materials", "MAPA", "tax_ind"])
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0} max={100} precision={4} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in1")}
|
||||
name={["materials", "MAPA", "mat_tx_in1"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in2")}
|
||||
name={["materials", "MAPA", "mat_tx_in2"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in3")}
|
||||
name={["materials", "MAPA", "mat_tx_in3"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in4")}
|
||||
name={["materials", "MAPA", "mat_tx_in4"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in5")}
|
||||
name={["materials", "MAPA", "mat_tx_in5"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
<LayoutFormRow header={t("jobs.fields.materials.MASH")}>
|
||||
<Form.Item label={t("jobs.fields.materials.cal_maxdlr")} name={["materials", "MASH", "cal_maxdlr"]}>
|
||||
<InputNumber min={0} precision={2} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.materials.cal_opcode")} name={["materials", "MASH", "cal_opcode"]}>
|
||||
<Input disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.materials.mat_adjp")} name={["materials", "MAPA", "mat_adjp"]}>
|
||||
<InputNumber min={-100} max={100} precision={4} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.tax_ind")}
|
||||
name={["materials", "MASH", "tax_ind"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate>
|
||||
{() => {
|
||||
return (
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_taxp")}
|
||||
name={["materials", "MASH", "mat_taxp"]}
|
||||
rules={[
|
||||
{
|
||||
required: form.getFieldValue(["materials", "MASH", "tax_ind"])
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0} max={100} precision={4} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in1")}
|
||||
name={["materials", "MASH", "mat_tx_in1"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in2")}
|
||||
name={["materials", "MASH", "mat_tx_in2"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in3")}
|
||||
name={["materials", "MASH", "mat_tx_in3"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in4")}
|
||||
name={["materials", "MASH", "mat_tx_in4"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in5")}
|
||||
name={["materials", "MASH", "mat_tx_in5"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
);
|
||||
// Define collapse items
|
||||
const collapseItems = [
|
||||
{
|
||||
key: "materials",
|
||||
label: t("jobs.fields.materials.materials"),
|
||||
children: (
|
||||
<>
|
||||
<LayoutFormRow header={t("jobs.fields.materials.MAPA")}>
|
||||
<Form.Item label={t("jobs.fields.materials.cal_maxdlr")} name={["materials", "MAPA", "cal_maxdlr"]}>
|
||||
<InputNumber min={0} precision={2} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.materials.cal_opcode")} name={["materials", "MAPA", "cal_opcode"]}>
|
||||
<Input disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.materials.mat_adjp")} name={["materials", "MAPA", "mat_adjp"]}>
|
||||
<InputNumber min={-100} max={100} precision={4} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.tax_ind")}
|
||||
name={["materials", "MAPA", "tax_ind"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate>
|
||||
{() => {
|
||||
return (
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_taxp")}
|
||||
name={["materials", "MAPA", "mat_taxp"]}
|
||||
rules={[
|
||||
{
|
||||
required: form.getFieldValue(["materials", "MAPA", "tax_ind"])
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0} max={100} precision={4} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in1")}
|
||||
name={["materials", "MAPA", "mat_tx_in1"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in2")}
|
||||
name={["materials", "MAPA", "mat_tx_in2"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in3")}
|
||||
name={["materials", "MAPA", "mat_tx_in3"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in4")}
|
||||
name={["materials", "MAPA", "mat_tx_in4"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in5")}
|
||||
name={["materials", "MAPA", "mat_tx_in5"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
<LayoutFormRow header={t("jobs.fields.materials.MASH")}>
|
||||
<Form.Item label={t("jobs.fields.materials.cal_maxdlr")} name={["materials", "MASH", "cal_maxdlr"]}>
|
||||
<InputNumber min={0} precision={2} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.materials.cal_opcode")} name={["materials", "MASH", "cal_opcode"]}>
|
||||
<Input disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.materials.mat_adjp")} name={["materials", "MAPA", "mat_adjp"]}>
|
||||
<InputNumber min={-100} max={100} precision={4} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.tax_ind")}
|
||||
name={["materials", "MASH", "tax_ind"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate>
|
||||
{() => {
|
||||
return (
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_taxp")}
|
||||
name={["materials", "MASH", "mat_taxp"]}
|
||||
rules={[
|
||||
{
|
||||
required: form.getFieldValue(["materials", "MASH", "tax_ind"])
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0} max={100} precision={4} disabled={jobRO} />
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in1")}
|
||||
name={["materials", "MASH", "mat_tx_in1"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in2")}
|
||||
name={["materials", "MASH", "mat_tx_in2"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in3")}
|
||||
name={["materials", "MASH", "mat_tx_in3"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in4")}
|
||||
name={["materials", "MASH", "mat_tx_in4"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.materials.mat_tx_in5")}
|
||||
name={["materials", "MASH", "mat_tx_in5"]}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
</>
|
||||
),
|
||||
forceRender: true
|
||||
}
|
||||
];
|
||||
|
||||
return <Collapse defaultActiveKey={expanded && "rates"} items={collapseItems} />;
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, null)(JobsDetailRatesMaterials);
|
||||
|
||||
@@ -13,9 +13,12 @@ const mapStateToProps = createStructuredSelector({
|
||||
export function JobsDetailRatesOther({ jobRO, expanded, required = true, form }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Collapse defaultActiveKey={expanded && "rates"}>
|
||||
<Collapse.Panel forceRender header={t("jobs.labels.cieca_pfo")} key="cieca_pfo">
|
||||
// Define collapse items
|
||||
const collapseItems = [
|
||||
{
|
||||
key: "cieca_pfo",
|
||||
label: t("jobs.labels.cieca_pfo"),
|
||||
children: (
|
||||
<LayoutFormRow noDivider>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.cieca_pfo.tow_t_in1")}
|
||||
@@ -52,7 +55,6 @@ export function JobsDetailRatesOther({ jobRO, expanded, required = true, form })
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t("jobs.fields.cieca_pfo.stor_t_in1")}
|
||||
name={["cieca_pfo", "stor_t_in1"]}
|
||||
@@ -89,9 +91,12 @@ export function JobsDetailRatesOther({ jobRO, expanded, required = true, form })
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
);
|
||||
),
|
||||
forceRender: true
|
||||
}
|
||||
];
|
||||
|
||||
return <Collapse defaultActiveKey={expanded && "rates"} items={collapseItems} />;
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, null)(JobsDetailRatesOther);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,9 @@ const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
//If the panel doesn’t expand correctly due to the key mismatch, update the Collapse line to:
|
||||
// <Collapse defaultActiveKey={expanded && "cieca_pft"} items={collapseItems} />
|
||||
|
||||
export function JobsDetailRatesTaxes({ jobRO, expanded, bodyshop, required = true, form }) {
|
||||
const { t } = useTranslation();
|
||||
const formItems = [];
|
||||
@@ -47,13 +50,18 @@ export function JobsDetailRatesTaxes({ jobRO, expanded, bodyshop, required = tru
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Collapse defaultActiveKey={expanded && "rates"}>
|
||||
<Collapse.Panel forceRender header={t("jobs.labels.cieca_pft")} key="cieca_pft">
|
||||
{formItems}
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
);
|
||||
|
||||
// Define collapse items
|
||||
const collapseItems = [
|
||||
{
|
||||
key: "cieca_pft",
|
||||
label: t("jobs.labels.cieca_pft"),
|
||||
children: <>{formItems}</>,
|
||||
forceRender: true
|
||||
}
|
||||
];
|
||||
|
||||
return <Collapse defaultActiveKey={expanded && "rates"} items={collapseItems} />;
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, null)(JobsDetailRatesTaxes);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useApolloClient } from "@apollo/client";
|
||||
import { Button, Form, Popover, Space } from "antd";
|
||||
import axios from "axios";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { GET_DOC_SIZE_BY_JOB } from "../../graphql/documents.queries";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import JobSearchSelect from "../job-search-select/job-search-select.component";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
@@ -134,7 +134,7 @@ export function JobsDocumentsGalleryReassign({ bodyshop, galleryImages, callback
|
||||
]}
|
||||
name={"jobid"}
|
||||
>
|
||||
<JobSearchSelect />
|
||||
<JobSearchSelect notExported={false} notInvoiced={false} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Space>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, Form, Popover, Space } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -60,7 +60,7 @@ export function JobsDocumentsLocalGalleryReassign({ bodyshop, jobid, allMedia, g
|
||||
]}
|
||||
name={"jobid"}
|
||||
>
|
||||
<JobSearchSelect />
|
||||
<JobSearchSelect notExported={false} notInvoiced={false}/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Space>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// notification-center.component.jsx
|
||||
import React from "react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { Button, Checkbox, List, Badge, Typography, Alert } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./notification-center.styles.scss";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const NotificationCenterComponent = ({
|
||||
visible,
|
||||
onClose,
|
||||
notifications,
|
||||
loading,
|
||||
error,
|
||||
showUnreadOnly,
|
||||
toggleUnreadOnly,
|
||||
markAllRead,
|
||||
loadMore,
|
||||
onNotificationClick
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renderNotification = (index, notification) => {
|
||||
return (
|
||||
<List.Item
|
||||
key={`${notification.id}-${index}`}
|
||||
className={notification.read ? "notification-read" : "notification-unread"}
|
||||
onClick={() => !notification.read && onNotificationClick(notification.id)}
|
||||
>
|
||||
<Badge dot={!notification.read}>
|
||||
<div>
|
||||
<Title
|
||||
level={5}
|
||||
style={{
|
||||
margin: "0 0 8px 0",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
to={`/manage/jobs/${notification.jobid}`}
|
||||
target="_blank"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent List.Item click handler from firing
|
||||
!notification.read && onNotificationClick(notification.id); // Mark as read when link clicked
|
||||
}}
|
||||
>
|
||||
RO #{notification.roNumber}
|
||||
</Link>
|
||||
<Text type="secondary">{new Date(notification.created_at).toLocaleString()}</Text>
|
||||
</Title>
|
||||
<Text strong={!notification.read}>
|
||||
<ul>
|
||||
{notification.scenarioText.map((text, idx) => (
|
||||
<li key={`${notification.id}-${idx}`}>{text}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Text>
|
||||
</div>
|
||||
</Badge>
|
||||
</List.Item>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`notification-center ${visible ? "visible" : ""}`}>
|
||||
<div className="notification-header">
|
||||
<h3>{t("notifications.labels.notification-center")}</h3>
|
||||
<div className="notification-controls">
|
||||
<Checkbox checked={showUnreadOnly} onChange={(e) => toggleUnreadOnly(e.target.checked)}>
|
||||
{t("notifications.labels.show-unread-only")}
|
||||
</Checkbox>
|
||||
<Button type="link" onClick={markAllRead} disabled={!notifications.some((n) => !n.read)}>
|
||||
{t("notifications.labels.mark-all-read")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <Alert message="Error" description={error} type="error" closable onClose={() => onClose()} />}
|
||||
<Virtuoso
|
||||
style={{ height: "400px", width: "100%" }}
|
||||
data={notifications}
|
||||
totalCount={notifications.length}
|
||||
endReached={loadMore}
|
||||
itemContent={renderNotification}
|
||||
/>
|
||||
{loading && !error && <div>{t("notifications.labels.loading")}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationCenterComponent;
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { connect } from "react-redux";
|
||||
import NotificationCenterComponent from "./notification-center.component";
|
||||
import { GET_NOTIFICATIONS } from "../../graphql/notifications.queries";
|
||||
import { INITIAL_NOTIFICATIONS, useSocket } from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||
|
||||
export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
|
||||
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const { isConnected, markNotificationRead, markAllNotificationsRead } = useSocket();
|
||||
|
||||
const userAssociationId = bodyshop?.associations?.[0]?.id;
|
||||
|
||||
const baseWhereClause = useMemo(() => {
|
||||
return { associationid: { _eq: userAssociationId } };
|
||||
}, [userAssociationId]);
|
||||
|
||||
const whereClause = useMemo(() => {
|
||||
return showUnreadOnly ? { ...baseWhereClause, read: { _is_null: true } } : baseWhereClause;
|
||||
}, [baseWhereClause, showUnreadOnly]);
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchMore,
|
||||
loading,
|
||||
error: queryError,
|
||||
refetch
|
||||
} = useQuery(GET_NOTIFICATIONS, {
|
||||
variables: {
|
||||
limit: INITIAL_NOTIFICATIONS,
|
||||
offset: 0,
|
||||
where: whereClause
|
||||
},
|
||||
fetchPolicy: "cache-and-network",
|
||||
notifyOnNetworkStatusChange: true,
|
||||
pollInterval: isConnected ? 0 : 30000,
|
||||
skip: !userAssociationId,
|
||||
onError: (err) => {
|
||||
setError(err.message);
|
||||
console.error("GET_NOTIFICATIONS error:", err);
|
||||
setTimeout(() => refetch(), 2000);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.notifications) {
|
||||
const processedNotifications = data.notifications
|
||||
.map((notif) => {
|
||||
let scenarioText;
|
||||
let scenarioMeta;
|
||||
try {
|
||||
scenarioText = notif.scenario_text ? JSON.parse(notif.scenario_text) : [];
|
||||
scenarioMeta = notif.scenario_meta ? JSON.parse(notif.scenario_meta) : {};
|
||||
} catch (e) {
|
||||
console.error("Error parsing JSON for notification:", notif.id, e);
|
||||
scenarioText = [notif.fcm_text || "Invalid notification data"];
|
||||
scenarioMeta = {};
|
||||
}
|
||||
if (!Array.isArray(scenarioText)) scenarioText = [scenarioText];
|
||||
const roNumber = notif.job.ro_number;
|
||||
if (!Array.isArray(scenarioMeta)) scenarioMeta = [scenarioMeta];
|
||||
return {
|
||||
id: notif.id,
|
||||
jobid: notif.jobid,
|
||||
associationid: notif.associationid,
|
||||
scenarioText,
|
||||
scenarioMeta,
|
||||
roNumber,
|
||||
created_at: notif.created_at,
|
||||
read: notif.read,
|
||||
__typename: notif.__typename
|
||||
};
|
||||
})
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
setNotifications(processedNotifications);
|
||||
setError(null);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (queryError) {
|
||||
setError(queryError.message);
|
||||
}
|
||||
}, [queryError]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!loading && data?.notifications.length) {
|
||||
fetchMore({
|
||||
variables: { offset: data.notifications.length, where: whereClause },
|
||||
updateQuery: (prev, { fetchMoreResult }) => {
|
||||
if (!fetchMoreResult) return prev;
|
||||
return {
|
||||
notifications: [...prev.notifications, ...fetchMoreResult.notifications]
|
||||
};
|
||||
}
|
||||
}).catch((err) => {
|
||||
setError(err.message);
|
||||
console.error("Fetch more error:", err);
|
||||
});
|
||||
}
|
||||
}, [data?.notifications?.length, fetchMore, loading, whereClause]);
|
||||
|
||||
const handleToggleUnreadOnly = (value) => {
|
||||
setShowUnreadOnly(value);
|
||||
};
|
||||
|
||||
const handleMarkAllRead = useCallback(() => {
|
||||
markAllNotificationsRead()
|
||||
.then(() => {
|
||||
const timestamp = new Date().toISOString();
|
||||
setNotifications((prev) => {
|
||||
const updatedNotifications = prev.map((notif) =>
|
||||
notif.read === null && notif.associationid === userAssociationId
|
||||
? {
|
||||
...notif,
|
||||
read: timestamp
|
||||
}
|
||||
: notif
|
||||
);
|
||||
return [...updatedNotifications];
|
||||
});
|
||||
})
|
||||
.catch((e) => console.error(`Error marking all notifications read: ${e?.message || ""}`));
|
||||
}, [markAllNotificationsRead, userAssociationId]);
|
||||
|
||||
const handleNotificationClick = useCallback(
|
||||
(notificationId) => {
|
||||
markNotificationRead({
|
||||
variables: { id: notificationId }
|
||||
})
|
||||
.then(() => {
|
||||
const timestamp = new Date().toISOString();
|
||||
setNotifications((prev) => {
|
||||
return prev.map((notif) =>
|
||||
notif.id === notificationId && !notif.read ? { ...notif, read: timestamp } : notif
|
||||
);
|
||||
});
|
||||
})
|
||||
.catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`));
|
||||
},
|
||||
[markNotificationRead]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && !isConnected) {
|
||||
refetch().catch(
|
||||
(err) => `Something went wrong re-fetching notifications in the notification-center: ${err?.message || ""}`
|
||||
);
|
||||
}
|
||||
}, [visible, isConnected, refetch]);
|
||||
|
||||
return (
|
||||
<NotificationCenterComponent
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
notifications={notifications}
|
||||
loading={loading}
|
||||
error={error}
|
||||
showUnreadOnly={showUnreadOnly}
|
||||
toggleUnreadOnly={handleToggleUnreadOnly}
|
||||
markAllRead={handleMarkAllRead}
|
||||
loadMore={loadMore}
|
||||
onNotificationClick={handleNotificationClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, null)(NotificationCenterContainer);
|
||||
@@ -0,0 +1,113 @@
|
||||
.notification-center {
|
||||
position: absolute;
|
||||
top: 64px;
|
||||
right: 0;
|
||||
//width: 600px;
|
||||
background: #fff; /* White background, Ant’s default */
|
||||
color: rgba(0, 0, 0, 0.85); /* Primary text color in Ant 5 */
|
||||
border: 1px solid #d9d9d9; /* Neutral gray border */
|
||||
border-radius: 6px; /* Slightly larger radius per Ant 5 */
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08), 0 3px 6px rgba(0, 0, 0, 0.06); /* Subtle Ant 5 shadow */
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
|
||||
&.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0; /* Light gray border from Ant 5 */
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #fafafa; /* Light gray background for header */
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: rgba(0, 0, 0, 0.85); /* Primary text color */
|
||||
}
|
||||
|
||||
.notification-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.ant-checkbox-wrapper {
|
||||
color: rgba(0, 0, 0, 0.85); /* Match Ant’s text color */
|
||||
}
|
||||
|
||||
.ant-btn-link {
|
||||
color: #1677ff; /* Ant 5 primary blue */
|
||||
&:hover {
|
||||
color: #69b1ff; /* Lighter blue on hover */
|
||||
}
|
||||
&:disabled {
|
||||
color: rgba(0, 0, 0, 0.25); /* Disabled text color from Ant 5 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notification-read {
|
||||
background: #fff; /* White background for read items */
|
||||
color: rgba(0, 0, 0, 0.65); /* Secondary text color */
|
||||
}
|
||||
|
||||
.notification-unread {
|
||||
background: #f5f5f5; /* Very light gray for unread items */
|
||||
color: rgba(0, 0, 0, 0.85); /* Primary text color */
|
||||
}
|
||||
|
||||
.ant-list {
|
||||
overflow: auto; /* Match Virtuoso’s default scrolling behavior */
|
||||
max-height: 100%; /* Allow full height, let Virtuoso handle virtualization */
|
||||
}
|
||||
|
||||
.ant-list-item {
|
||||
padding: 2px 16px;
|
||||
border-bottom: 1px solid #f0f0f0; /* Light gray border */
|
||||
display: block; /* Ensure visibility */
|
||||
overflow: visible; /* Prevent clipping within items */
|
||||
min-height: 80px; /* Minimum height for multi-line content */
|
||||
|
||||
.ant-typography {
|
||||
color: inherit; /* Inherit from parent (read/unread) */
|
||||
}
|
||||
|
||||
.ant-typography-secondary {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45); /* Ant 5 secondary text color */
|
||||
}
|
||||
|
||||
.ant-badge-dot {
|
||||
background: #ff4d4f; /* Keep red dot for unread, consistent with Ant */
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 20px; /* Standard list padding */
|
||||
list-style-type: disc; /* Ensure bullet points */
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 4px; /* Space between list items */
|
||||
}
|
||||
}
|
||||
|
||||
.ant-alert {
|
||||
margin: 8px;
|
||||
background: #fff1f0; /* Light red background for error per Ant 5 */
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
border: 1px solid #ffa39e; /* Light red border */
|
||||
|
||||
.ant-alert-message {
|
||||
color: #ff4d4f; /* Red text for message */
|
||||
}
|
||||
|
||||
.ant-alert-description {
|
||||
color: rgba(0, 0, 0, 0.65); /* Slightly muted description */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
|
||||
import PartsReceiveModalContainer from "../parts-receive-modal/parts-receive-modal.container";
|
||||
import PrintWrapper from "../print-wrapper/print-wrapper.component";
|
||||
import PartsOrderDrawer from "./parts-order-list-table-drawer.component";
|
||||
import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
jobRO: selectJobReadOnly,
|
||||
@@ -66,19 +67,20 @@ export function PartsOrderListTableComponent({
|
||||
|
||||
const parts_orders = billsQuery.data ? billsQuery.data.parts_orders : [];
|
||||
const { refetch } = billsQuery;
|
||||
|
||||
const recordActions = (record, showView = false) => (
|
||||
<Space direction="horizontal" wrap>
|
||||
<ShareToTeamsButton
|
||||
linkText={""}
|
||||
urlOverride={`${window.location.origin}/manage/jobs/${job.id}?partsorderid=${record.id}&tab=partssublet `}
|
||||
/>
|
||||
{showView && (
|
||||
<Button
|
||||
icon={<EyeFilled />}
|
||||
onClick={() => {
|
||||
handleOnRowClick(record);
|
||||
}}
|
||||
>
|
||||
<EyeFilled />
|
||||
</Button>
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
disabled={jobRO || record.return || record.vendor.id === bodyshop.inhousevendorid}
|
||||
onClick={() => {
|
||||
@@ -106,6 +108,7 @@ export function PartsOrderListTableComponent({
|
||||
</Button>
|
||||
<Button
|
||||
title={t("tasks.buttons.create")}
|
||||
icon={<FaTasks />}
|
||||
onClick={() => {
|
||||
setTaskUpsertContext({
|
||||
context: {
|
||||
@@ -114,9 +117,7 @@ export function PartsOrderListTableComponent({
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FaTasks />
|
||||
</Button>
|
||||
/>
|
||||
<Popconfirm
|
||||
title={t("parts_orders.labels.confirmdelete")}
|
||||
disabled={jobRO}
|
||||
@@ -137,9 +138,7 @@ export function PartsOrderListTableComponent({
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button disabled={jobRO}>
|
||||
<DeleteFilled />
|
||||
</Button>
|
||||
<Button disabled={jobRO} icon={<DeleteFilled />} />
|
||||
</Popconfirm>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -2,15 +2,15 @@ import { CopyFilled } from "@ant-design/icons";
|
||||
import { Button, Form, message, Popover, Space } from "antd";
|
||||
import axios from "axios";
|
||||
import Dinero from "dinero.js";
|
||||
import { parsePhoneNumber } from "libphonenumber-js";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { parsePhoneNumberWithError, ParseError } from "libphonenumber-js";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
@@ -29,22 +29,34 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [paymentLink, setPaymentLink] = useState(null);
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
|
||||
const handleFinish = async ({ amount }) => {
|
||||
setLoading(true);
|
||||
let p;
|
||||
try {
|
||||
p = parsePhoneNumber(job.ownr_ph1 || "", "CA");
|
||||
// Updated to use parsePhoneNumberWithError
|
||||
p = parsePhoneNumberWithError(job.ownr_ph1 || "", "CA");
|
||||
} catch (error) {
|
||||
console.log("Unable to parse phone number");
|
||||
if (error instanceof ParseError) {
|
||||
// Handle specific parsing errors
|
||||
console.log(`Phone number parsing failed: ${error.message}`);
|
||||
} else {
|
||||
// Handle other unexpected errors
|
||||
console.log("Unexpected error while parsing phone number:", error);
|
||||
}
|
||||
}
|
||||
setLoading(true);
|
||||
const response = await axios.post("/intellipay/generate_payment_url", {
|
||||
bodyshop,
|
||||
amount: amount,
|
||||
account: job.ro_number,
|
||||
comment: btoa(JSON.stringify({ payments: [{ jobid: job.id, amount }], userEmail: currentUser.email }))
|
||||
comment: btoa(
|
||||
JSON.stringify({
|
||||
payments: [{ jobid: job.id, amount }],
|
||||
userEmail: currentUser.email
|
||||
})
|
||||
)
|
||||
});
|
||||
setLoading(false);
|
||||
setPaymentLink(response.data.shorUrl);
|
||||
@@ -106,7 +118,20 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope
|
||||
</Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const p = parsePhoneNumber(job.ownr_ph1, "CA");
|
||||
let p;
|
||||
try {
|
||||
// Updated second instance of phone parsing
|
||||
p = parsePhoneNumberWithError(job.ownr_ph1, "CA");
|
||||
} catch (error) {
|
||||
if (error instanceof ParseError) {
|
||||
// Handle specific parsing errors
|
||||
console.log(`Phone number parsing failed: ${error.message}`);
|
||||
} else {
|
||||
// Handle other unexpected errors
|
||||
console.log("Unexpected error while parsing phone number:", error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
openChatByPhone({
|
||||
phone_num: p.formatInternational(),
|
||||
jobid: job.id,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Button, Input, Space, Spin } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { ExclamationCircleFilled, ExclamationCircleOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
ExclamationCircleFilled,
|
||||
ExclamationCircleOutlined,
|
||||
UserDeleteOutlined,
|
||||
UsergroupDeleteOutlined
|
||||
} from "@ant-design/icons";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import EmployeeSearchSelectComponent from "../employee-search-select/employee-search-select.component";
|
||||
|
||||
@@ -19,12 +23,13 @@ export default connect(mapStateToProps, mapDispatchToProps)(ProductionBoardFilte
|
||||
|
||||
export function ProductionBoardFilters({ bodyshop, filter, setFilter, loading }) {
|
||||
const { t } = useTranslation();
|
||||
const [alertFilter, setAlertFilter] = useState(false);
|
||||
|
||||
const toggleAlertFilter = () => {
|
||||
const newAlertFilter = !alertFilter;
|
||||
setAlertFilter(newAlertFilter);
|
||||
setFilter({ ...filter, alert: newAlertFilter });
|
||||
setFilter({ ...filter, alert: !filter.alert });
|
||||
};
|
||||
|
||||
const toggleUnassignedFilter = () => {
|
||||
setFilter({ ...filter, unassigned: !filter.unassigned });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -46,12 +51,19 @@ export function ProductionBoardFilters({ bodyshop, filter, setFilter, loading })
|
||||
allowClear
|
||||
/>
|
||||
<Button
|
||||
type={alertFilter ? "primary" : "default"}
|
||||
type={filter?.alert ? "primary" : "default"}
|
||||
onClick={toggleAlertFilter}
|
||||
icon={alertFilter ? <ExclamationCircleFilled /> : <ExclamationCircleOutlined />}
|
||||
icon={filter?.alert ? <ExclamationCircleFilled /> : <ExclamationCircleOutlined />}
|
||||
>
|
||||
{t("production.labels.alerts")}
|
||||
</Button>
|
||||
<Button
|
||||
type={filter?.unassigned ? "primary" : "default"}
|
||||
onClick={toggleUnassignedFilter}
|
||||
icon={filter?.unassigned ? <UserDeleteOutlined /> : <UsergroupDeleteOutlined />}
|
||||
>
|
||||
{t("production.labels.unassigned")}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import dayjs from "../../utils/day";
|
||||
|
||||
import JobPartsQueueCount from "../job-parts-queue-count/job-parts-queue-count.component";
|
||||
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
|
||||
import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
|
||||
import { PiMicrosoftTeamsLogo } from "react-icons/pi";
|
||||
|
||||
const cardColor = (ssbuckets, totalHrs) => {
|
||||
const bucket = ssbuckets.find((bucket) => bucket.gte <= totalHrs && (!bucket.lt || bucket.lt > totalHrs));
|
||||
@@ -417,9 +419,20 @@ export default function ProductionBoardCard({ technician, card, bodyshop, cardSe
|
||||
title={!isBodyEmpty ? headerContent : null}
|
||||
extra={
|
||||
!isBodyEmpty && (
|
||||
<Link to={{ search: `?selected=${card.id}` }}>
|
||||
<EyeFilled />
|
||||
</Link>
|
||||
<Space>
|
||||
<ShareToTeamsButton
|
||||
noIcon={true}
|
||||
linkText={
|
||||
<div className="share-to-teams-badge">
|
||||
<PiMicrosoftTeamsLogo />
|
||||
</div>
|
||||
}
|
||||
urlOverride={`${window.location.origin}/manage/jobs/${card.id}`}
|
||||
/>
|
||||
<Link to={{ search: `?selected=${card.id}` }}>
|
||||
<EyeFilled />
|
||||
</Link>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useApolloClient } from "@apollo/client";
|
||||
import { Button, Skeleton, Space } from "antd";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useContext, useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useApolloClient, useQuery, useSubscription } from "@apollo/client";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -12,7 +12,7 @@ import { QUERY_KANBAN_SETTINGS } from "../../graphql/user.queries";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import ProductionBoardKanbanComponent from "./production-board-kanban.component";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
@@ -22,7 +22,7 @@ const mapStateToProps = createStructuredSelector({
|
||||
function ProductionBoardKanbanContainer({ bodyshop, currentUser, subscriptionType = "direct" }) {
|
||||
const fired = useRef(false);
|
||||
const client = useApolloClient();
|
||||
const { socket } = useContext(SocketContext); // Get the socket from context
|
||||
const { socket } = useSocket();
|
||||
const reconnectTimeout = useRef(null); // To store the reconnect timeout
|
||||
const disconnectTime = useRef(null); // To track disconnection time
|
||||
const acceptableReconnectTime = 2000; // 2 seconds threshold
|
||||
|
||||
@@ -10,6 +10,16 @@
|
||||
.height-preserving-container {
|
||||
}
|
||||
|
||||
.share-to-teams-badge {
|
||||
background-color: #cccccc;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.react-trello-column-header {
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -29,7 +29,7 @@ const sortByParentId = (arr) => {
|
||||
|
||||
// Function to create board data based on statuses and jobs, with optional filtering
|
||||
export const createBoardData = ({ statuses, data, filter, cardSettings }) => {
|
||||
const { search, employeeId, alert } = filter;
|
||||
const { search, employeeId, alert, unassigned } = filter;
|
||||
|
||||
const lanes = statuses.map((status) => ({
|
||||
id: status,
|
||||
@@ -40,6 +40,13 @@ export const createBoardData = ({ statuses, data, filter, cardSettings }) => {
|
||||
let filteredJobs =
|
||||
(search === "" || !search) && !employeeId ? data : data.filter((job) => checkFilter(search, employeeId, job));
|
||||
|
||||
// Apply "Unassigned" filter
|
||||
if (unassigned) {
|
||||
filteredJobs = filteredJobs.filter(
|
||||
(job) => !job.employee_body && !job.employee_prep && !job.employee_refinish && !job.employee_csr
|
||||
);
|
||||
}
|
||||
|
||||
// Filter jobs by selectedMdInsCos if it has values
|
||||
if (cardSettings?.selectedMdInsCos?.length > 0) {
|
||||
filteredJobs = filteredJobs.filter((job) => cardSettings.selectedMdInsCos.includes(job.ins_co_nm));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { Button, Card, Col, Form, Popover, Row, Tabs } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UPDATE_KANBAN_SETTINGS } from "../../../graphql/user.queries.js";
|
||||
import { defaultKanbanSettings, mergeWithDefaults } from "./defaultKanbanSettings.js";
|
||||
@@ -11,6 +11,7 @@ import FilterSettings from "./FilterSettings.jsx";
|
||||
import PropTypes from "prop-types";
|
||||
import { isFunction } from "lodash";
|
||||
import { useNotification } from "../../../contexts/Notifications/notificationContext.jsx";
|
||||
import { SettingOutlined } from "@ant-design/icons";
|
||||
|
||||
function ProductionBoardKanbanSettings({ associationSettings, parentLoading, bodyshop, data, onSettingsChange }) {
|
||||
const [form] = Form.useForm();
|
||||
@@ -153,7 +154,7 @@ function ProductionBoardKanbanSettings({ associationSettings, parentLoading, bod
|
||||
|
||||
return (
|
||||
<Popover content={overlay} open={open} placement="topRight">
|
||||
<Button loading={loading} onClick={() => setOpen(!open)}>
|
||||
<Button icon={<SettingOutlined />} loading={loading} onClick={() => setOpen(!open)}>
|
||||
{t("production.settings.board_settings")}
|
||||
</Button>
|
||||
</Popover>
|
||||
|
||||
@@ -27,6 +27,7 @@ import ProductionListColumnNote from "./production-list-columns.productionnote.c
|
||||
import ProductionListColumnCategory from "./production-list-columns.status.category";
|
||||
import ProductionListColumnStatus from "./production-list-columns.status.component";
|
||||
import ProductionListColumnTouchTime from "./prodution-list-columns.touchtime.component";
|
||||
import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
|
||||
|
||||
const getEmployeeName = (employeeId, employees) => {
|
||||
const employee = employees.find((e) => e.id === employeeId);
|
||||
@@ -41,7 +42,17 @@ const r = ({ technician, state, activeStatuses, data, bodyshop, refetch, treatme
|
||||
dataIndex: "viewdetail",
|
||||
key: "viewdetail",
|
||||
ellipsis: true,
|
||||
render: (text, record) => <Link to={{ search: `?selected=${record.id}` }}>{i18n.t("general.labels.view")}</Link>
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
<Link to={{ search: `?selected=${record.id}` }}>{i18n.t("general.labels.view")}</Link>
|
||||
<ShareToTeamsButton
|
||||
noIcon={true}
|
||||
linkText={"Share"}
|
||||
noIconStyle={{ color: "#1890ff" }}
|
||||
urlOverride={`${window.location.origin}/manage/jobs/${record.id}`}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
...(Enhanced_Payroll.treatment === "on"
|
||||
? [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, Dropdown } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { TemplateList } from "../../utils/TemplateConstants";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GenerateDocument } from "../../utils/RenderTemplate";
|
||||
@@ -7,6 +7,7 @@ import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { PrinterFilled } from "@ant-design/icons";
|
||||
|
||||
const ProdTemplates = TemplateList("production");
|
||||
const { production_by_technician_one, production_by_category_one, production_by_repair_status_one } =
|
||||
@@ -123,7 +124,9 @@ export function ProductionListPrint({ bodyshop }) {
|
||||
|
||||
return (
|
||||
<Dropdown trigger="click" menu={menu}>
|
||||
<Button loading={loading}>{t("general.labels.print")}</Button>
|
||||
<Button icon={<PrinterFilled />} loading={loading}>
|
||||
{t("general.labels.print")}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useApolloClient, useQuery, useSubscription } from "@apollo/client";
|
||||
import React, { useContext, useEffect, useState, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
QUERY_EXACT_JOB_IN_PRODUCTION,
|
||||
QUERY_EXACT_JOBS_IN_PRODUCTION,
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
import ProductionListTable from "./production-list-table.component";
|
||||
import _ from "lodash";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
|
||||
|
||||
export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) {
|
||||
const client = useApolloClient();
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { socket } = useSocket();
|
||||
const [joblist, setJoblist] = useState([]);
|
||||
const reconnectTimeout = useRef(null); // To store the reconnect timeout
|
||||
const disconnectTime = useRef(null); // To store the time of disconnection
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Card, Checkbox, Form, Table } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import AlertComponent from "../alert/alert.component";
|
||||
import { QUERY_NOTIFICATION_SETTINGS, UPDATE_NOTIFICATION_SETTINGS } from "../../graphql/user.queries.js";
|
||||
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange }) => {
|
||||
const { t } = useTranslation();
|
||||
// Subscribe to all form values so that this component re-renders on changes.
|
||||
const formValues = Form.useWatch([], form) || {};
|
||||
|
||||
// Determine if all scenarios for this channel are checked.
|
||||
const allChecked =
|
||||
notificationScenarios.length > 0 && notificationScenarios.every((scenario) => formValues[scenario]?.[channel]);
|
||||
|
||||
const onChange = (e) => {
|
||||
const checked = e.target.checked;
|
||||
// Get current form values.
|
||||
const currentValues = form.getFieldsValue();
|
||||
// Update each scenario for this channel.
|
||||
const newValues = { ...currentValues };
|
||||
notificationScenarios.forEach((scenario) => {
|
||||
newValues[scenario] = { ...newValues[scenario], [channel]: checked };
|
||||
});
|
||||
// Update form values.
|
||||
form.setFieldsValue(newValues);
|
||||
// Manually mark the form as dirty.
|
||||
if (onHeaderChange) {
|
||||
onHeaderChange();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Checkbox onChange={onChange} checked={allChecked} disabled={disabled}>
|
||||
{t(`notifications.channels.${channel}`)}
|
||||
</Checkbox>
|
||||
);
|
||||
};
|
||||
|
||||
ColumnHeaderCheckbox.propTypes = {
|
||||
channel: PropTypes.oneOf(["app", "email", "fcm"]).isRequired,
|
||||
form: PropTypes.object.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
onHeaderChange: PropTypes.func
|
||||
};
|
||||
|
||||
function NotificationSettingsForm({ currentUser }) {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm();
|
||||
const [initialValues, setInitialValues] = useState({});
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
// Fetch notification settings.
|
||||
const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, {
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only",
|
||||
variables: { email: currentUser.email },
|
||||
skip: !currentUser
|
||||
});
|
||||
|
||||
const [updateNotificationSettings, { loading: saving }] = useMutation(UPDATE_NOTIFICATION_SETTINGS);
|
||||
|
||||
// Populate form with fetched data.
|
||||
useEffect(() => {
|
||||
if (data?.associations?.length > 0) {
|
||||
const settings = data.associations[0].notification_settings || {};
|
||||
// Ensure each scenario has an object with { app, email, fcm }.
|
||||
const formattedValues = notificationScenarios.reduce((acc, scenario) => {
|
||||
acc[scenario] = settings[scenario] ?? { app: false, email: false, fcm: false };
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
setInitialValues(formattedValues);
|
||||
form.setFieldsValue(formattedValues);
|
||||
setIsDirty(false); // Reset dirty state when new data loads.
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
const handleSave = async (values) => {
|
||||
if (data?.associations?.length > 0) {
|
||||
const userId = data.associations[0].id;
|
||||
// Save the updated notification settings.
|
||||
await updateNotificationSettings({ variables: { id: userId, ns: values } });
|
||||
setInitialValues(values);
|
||||
setIsDirty(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Mark the form as dirty on any manual change.
|
||||
const handleFormChange = () => {
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
form.setFieldsValue(initialValues);
|
||||
setIsDirty(false);
|
||||
};
|
||||
|
||||
if (error) return <AlertComponent type="error" message={error.message} />;
|
||||
if (loading) return <LoadingSpinner />;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t("notifications.labels.scenario"),
|
||||
dataIndex: "scenarioLabel",
|
||||
key: "scenario",
|
||||
render: (_, record) => t(`notifications.scenarios.${record.key}`),
|
||||
width: "90%"
|
||||
},
|
||||
{
|
||||
title: <ColumnHeaderCheckbox channel="app" form={form} onHeaderChange={() => setIsDirty(true)} />,
|
||||
dataIndex: "app",
|
||||
key: "app",
|
||||
align: "center",
|
||||
render: (_, record) => (
|
||||
<Form.Item name={[record.key, "app"]} valuePropName="checked" noStyle>
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: <ColumnHeaderCheckbox channel="email" form={form} onHeaderChange={() => setIsDirty(true)} />,
|
||||
dataIndex: "email",
|
||||
key: "email",
|
||||
align: "center",
|
||||
render: (_, record) => (
|
||||
<Form.Item name={[record.key, "email"]} valuePropName="checked" noStyle>
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
// TODO: Disabled for now until FCM is implemented.
|
||||
// {
|
||||
// title: <ColumnHeaderCheckbox channel="fcm" form={form} disabled onHeaderChange={() => setIsDirty(true)} />,
|
||||
// dataIndex: "fcm",
|
||||
// key: "fcm",
|
||||
// align: "center",
|
||||
// render: (_, record) => (
|
||||
// <Form.Item name={[record.key, "fcm"]} valuePropName="checked" noStyle>
|
||||
// <Checkbox disabled />
|
||||
// </Form.Item>
|
||||
// )
|
||||
// }
|
||||
];
|
||||
|
||||
const dataSource = notificationScenarios.map((scenario) => ({ key: scenario }));
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleSave}
|
||||
onValuesChange={handleFormChange}
|
||||
initialValues={initialValues}
|
||||
autoComplete="off"
|
||||
layout="vertical"
|
||||
>
|
||||
<Card
|
||||
title={t("notifications.labels.notificationscenarios")}
|
||||
extra={
|
||||
<>
|
||||
<Button type="default" onClick={handleReset} disabled={!isDirty} style={{ marginRight: 8 }}>
|
||||
{t("general.actions.clear")}
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit" disabled={!isDirty} loading={saving}>
|
||||
{t("notifications.labels.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Table dataSource={dataSource} columns={columns} pagination={false} bordered rowKey="key" />
|
||||
</Card>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
NotificationSettingsForm.propTypes = {
|
||||
currentUser: PropTypes.shape({
|
||||
email: PropTypes.string.isRequired
|
||||
}).isRequired
|
||||
};
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(NotificationSettingsForm);
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Button, Card, Col, Form, Input } from "antd";
|
||||
import { LockOutlined } from "@ant-design/icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -9,6 +8,7 @@ import { selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import { logImEXEvent, updateCurrentPassword } from "../../firebase/firebase.utils";
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import NotificationSettingsForm from "./notification-settings.component.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser
|
||||
@@ -117,6 +117,9 @@ export default connect(
|
||||
</Card>
|
||||
</Form>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<NotificationSettingsForm />
|
||||
</Col>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ export function ScheduleCalendarWrapperComponent({
|
||||
const search = queryString.parse(useLocation().search);
|
||||
const history = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleEventPropStyles = (event, start, end, isSelected) => {
|
||||
return {
|
||||
...(event.color && !((search.view || defaultView) === "agenda")
|
||||
@@ -51,37 +52,41 @@ export function ScheduleCalendarWrapperComponent({
|
||||
|
||||
const selectedDate = new Date(date || dayjs(search.date) || Date.now());
|
||||
|
||||
// Convert Collapse to use items prop
|
||||
const collapseItems = [
|
||||
{
|
||||
key: "1",
|
||||
label: <span style={{ color: "tomato" }}>{t("appointments.labels.severalerrorsfound")}</span>,
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
{problemJobs.map((problem) => (
|
||||
<Alert
|
||||
key={problem.id}
|
||||
type="error"
|
||||
message={
|
||||
<Trans
|
||||
i18nKey="appointments.labels.dataconsistency"
|
||||
components={[<Link to={`/manage/jobs/${problem.id}`} target="_blank" />]}
|
||||
values={{
|
||||
ro_number: problem.ro_number,
|
||||
code: problem.code
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<JobDetailCards />
|
||||
{HasFeatureAccess({ featureName: "smartscheduling", bodyshop }) &&
|
||||
problemJobs &&
|
||||
(problemJobs.length > 2 ? (
|
||||
<Collapse style={{ marginBottom: "5px" }}>
|
||||
<Collapse.Panel
|
||||
key="1"
|
||||
header={<span style={{ color: "tomato" }}>{t("appointments.labels.severalerrorsfound")}</span>}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
{problemJobs.map((problem) => (
|
||||
<Alert
|
||||
key={problem.id}
|
||||
type="error"
|
||||
message={
|
||||
<Trans
|
||||
i18nKey="appointments.labels.dataconsistency"
|
||||
components={[<Link to={`/manage/jobs/${problem.id}`} target="_blank" />]}
|
||||
values={{
|
||||
ro_number: problem.ro_number,
|
||||
code: problem.code
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
<Collapse items={collapseItems} style={{ marginBottom: "5px" }} />
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: "100%", marginBottom: "5px" }}>
|
||||
{problemJobs.map((problem) => (
|
||||
@@ -119,7 +124,6 @@ export function ScheduleCalendarWrapperComponent({
|
||||
history({ search: queryString.stringify(search) });
|
||||
}}
|
||||
step={15}
|
||||
// timeslots={1}
|
||||
showMultiDayTimes
|
||||
localizer={localizer}
|
||||
min={bodyshop.schedule_start_time ? new Date(bodyshop.schedule_start_time) : new Date("2020-01-01T06:00:00")}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { Button } from "antd";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { PiMicrosoftTeamsLogo } from "react-icons/pi";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
/**
|
||||
* ShareToTeamsButton component for sharing content to Microsoft Teams via an HTTP link.
|
||||
*
|
||||
* This component creates a button or link that opens the Microsoft Teams share dialog with
|
||||
* the provided URL, title, and message text through query parameters. The popup window is centered on the screen.
|
||||
*
|
||||
* @param {Object} props - The component's props.
|
||||
* @param {string} [props.messageTextOverride] - Custom message text for sharing.
|
||||
* @param {string} [props.urlOverride] - Custom URL to share instead of the current page's URL.
|
||||
* @param {string} [props.pageTitleOverride] - Custom title for the shared page.
|
||||
* @param {boolean} [props.noIcon=false] - If true, renders as a simple text link instead of a button with an icon.
|
||||
* @param {Object} [props.noIconStyle={}] - Style object for the text link when noIcon is true.
|
||||
* @param {Object} [props.buttonStyle={}] - Style object for the Ant Design button.
|
||||
* @param {Object} [props.buttonIconStyle={}] - Style object for the icon within the button.
|
||||
* @param {string} [props.linkText] - Text to display on the button or link.
|
||||
* @returns {React.ReactElement} A button or text link for sharing to Microsoft Teams.
|
||||
*/
|
||||
const ShareToTeamsComponent = ({
|
||||
bodyshop,
|
||||
messageTextOverride,
|
||||
urlOverride,
|
||||
pageTitleOverride,
|
||||
noIcon = false,
|
||||
noIconStyle = {},
|
||||
buttonStyle = {},
|
||||
buttonIconStyle = {},
|
||||
linkText
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const currentUrl =
|
||||
urlOverride ||
|
||||
encodeURIComponent(`${window.location.origin}${location.pathname}${location.search}${location.hash}`);
|
||||
const pageTitle =
|
||||
pageTitleOverride ||
|
||||
encodeURIComponent(typeof document !== "undefined" ? document.title : t("general.actions.sharetoteams"));
|
||||
const messageText = messageTextOverride || encodeURIComponent(t("general.actions.sharetoteams"));
|
||||
|
||||
// Construct the Teams share URL with parameters
|
||||
const teamsShareUrl = `https://teams.microsoft.com/share?href=${currentUrl}&preText=${messageText}&title=${pageTitle}`;
|
||||
|
||||
// Function to open the centered share link in a new window/tab
|
||||
const handleShare = () => {
|
||||
const screenWidth = window.screen.width;
|
||||
const screenHeight = window.screen.height;
|
||||
const windowWidth = 600;
|
||||
const windowHeight = 400;
|
||||
|
||||
const left = screenWidth / 2 - windowWidth / 2;
|
||||
const top = screenHeight / 2 - windowHeight / 2;
|
||||
|
||||
const windowFeatures = `width=${windowWidth},height=${windowHeight},left=${left},top=${top}`;
|
||||
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
window.open(teamsShareUrl, "_blank", windowFeatures);
|
||||
};
|
||||
|
||||
// Feature is disabled
|
||||
if (!bodyshop?.md_functionality_toggles?.teams) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (noIcon) {
|
||||
return (
|
||||
<div style={{ cursor: "pointer", ...noIconStyle }} onClick={handleShare}>
|
||||
{!linkText ? t("general.actions.sharetoteams") : linkText}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
style={{
|
||||
backgroundColor: "#6264A7",
|
||||
borderColor: "#6264A7",
|
||||
color: "#FFFFFF",
|
||||
...buttonStyle
|
||||
}}
|
||||
icon={<PiMicrosoftTeamsLogo style={{ color: "#FFFFFF", ...buttonIconStyle }} />}
|
||||
onClick={handleShare}
|
||||
title={linkText === null ? t("general.actions.sharetoteams") : linkText}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
ShareToTeamsComponent.propTypes = {
|
||||
messageTextOverride: PropTypes.string,
|
||||
urlOverride: PropTypes.string,
|
||||
pageTitleOverride: PropTypes.string,
|
||||
noIcon: PropTypes.bool,
|
||||
noIconStyle: PropTypes.object,
|
||||
buttonStyle: PropTypes.object,
|
||||
buttonIconStyle: PropTypes.object,
|
||||
linkText: PropTypes.oneOfType([PropTypes.string, PropTypes.node])
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(ShareToTeamsComponent);
|
||||
@@ -14,7 +14,7 @@ import FormItemEmail from "../form-items-formatted/email-form-item.component";
|
||||
import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
|
||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
// TODO: Client Update, this might break
|
||||
|
||||
const timeZonesList = Intl.supportedValuesOf("timeZone");
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
@@ -642,6 +642,15 @@ export function ShopInfoGeneral({ form, bodyshop }) {
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
<LayoutFormRow header={t("bodyshop.labels.shop_enabled_features")} id="sharing">
|
||||
<Form.Item
|
||||
label={t("general.actions.sharetoteams")}
|
||||
valuePropName="checked"
|
||||
name={["md_functionality_toggles", "teams"]}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
<LayoutFormRow grow header={t("bodyshop.labels.messagingpresets")} id="messagingpresets">
|
||||
<Form.List name={["md_messaging_presets"]}>
|
||||
{(fields, { add, remove, move }) => {
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import {DeleteFilled} from "@ant-design/icons";
|
||||
import {Button, Col, Form, Input, Row, Select, Space, Switch} from "antd";
|
||||
import React, {useMemo} from "react";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import { DeleteFilled } from "@ant-design/icons";
|
||||
import { Button, Col, Form, Input, Row, Select, Space, Switch } from "antd";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
import i18n from "i18next";
|
||||
|
||||
const predefinedPartTypes = [
|
||||
"PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG"
|
||||
];
|
||||
const predefinedPartTypes = ["PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG"];
|
||||
const predefinedModLbrTypes = [
|
||||
"LAA", "LAB", "LAD", "LAE", "LAF", "LAG", "LAM", "LAR", "LAS", "LAU",
|
||||
"LA1", "LA2", "LA3", "LA4"
|
||||
"LAA",
|
||||
"LAB",
|
||||
"LAD",
|
||||
"LAE",
|
||||
"LAF",
|
||||
"LAG",
|
||||
"LAM",
|
||||
"LAR",
|
||||
"LAS",
|
||||
"LAU",
|
||||
"LA1",
|
||||
"LA2",
|
||||
"LA3",
|
||||
"LA4"
|
||||
];
|
||||
|
||||
const getFieldType = (field) => {
|
||||
@@ -20,30 +31,46 @@ const getFieldType = (field) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function ShopInfoPartsScan({form}) {
|
||||
const {t} = useTranslation();
|
||||
const fieldSelectOptions = [
|
||||
{ label: i18n.t("joblines.fields.line_desc"), value: "line_desc" },
|
||||
{ label: i18n.t("joblines.fields.part_type"), value: "part_type" },
|
||||
{ label: i18n.t("joblines.fields.act_price"), value: "act_price" },
|
||||
{ label: i18n.t("joblines.fields.part_qty"), value: "part_qty" },
|
||||
{ label: i18n.t("joblines.fields.mod_lbr_ty"), value: "mod_lbr_ty" },
|
||||
|
||||
{
|
||||
label: `${i18n.t("joblines.fields.oem_partno")} / ${i18n.t("joblines.fields.alt_partno")}`,
|
||||
value: "part_number"
|
||||
},
|
||||
{ label: i18n.t("joblines.fields.op_code_desc"), value: "op_code_desc" }
|
||||
];
|
||||
export default function ShopInfoPartsScan({ form }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const watchedFields = Form.useWatch("md_parts_scan", form);
|
||||
|
||||
const operationOptions = useMemo(() => ({
|
||||
string: [
|
||||
{label: t("bodyshop.operations.contains"), value: "contains"},
|
||||
{label: t("bodyshop.operations.equals"), value: "equals"},
|
||||
{label: t("bodyshop.operations.starts_with"), value: "startsWith"},
|
||||
{label: t("bodyshop.operations.ends_with"), value: "endsWith"},
|
||||
],
|
||||
number: [
|
||||
{label: t("bodyshop.operations.equals"), value: "="},
|
||||
{label: t("bodyshop.operations.greater_than"), value: ">"},
|
||||
{label: t("bodyshop.operations.less_than"), value: "<"},
|
||||
],
|
||||
}), [t]);
|
||||
const operationOptions = useMemo(
|
||||
() => ({
|
||||
string: [
|
||||
{ label: t("bodyshop.operations.contains"), value: "contains" },
|
||||
{ label: t("bodyshop.operations.equals"), value: "equals" },
|
||||
{ label: t("bodyshop.operations.starts_with"), value: "startsWith" },
|
||||
{ label: t("bodyshop.operations.ends_with"), value: "endsWith" }
|
||||
],
|
||||
number: [
|
||||
{ label: t("bodyshop.operations.equals"), value: "=" },
|
||||
{ label: t("bodyshop.operations.greater_than"), value: ">" },
|
||||
{ label: t("bodyshop.operations.less_than"), value: "<" }
|
||||
]
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<LayoutFormRow header={t("bodyshop.labels.md_parts_scan")}>
|
||||
<Form.List name={["md_parts_scan"]}>
|
||||
{(fields, {add, remove, move}) => (
|
||||
{(fields, { add, remove, move }) => (
|
||||
<div>
|
||||
{fields.map((field, index) => {
|
||||
const selectedField = watchedFields?.[index]?.field || "line_desc";
|
||||
@@ -61,28 +88,17 @@ export default function ShopInfoPartsScan({form}) {
|
||||
{
|
||||
required: true,
|
||||
message: t("general.validation.required", {
|
||||
label: t("bodyshop.fields.md_parts_scan.field"),
|
||||
}),
|
||||
},
|
||||
label: t("bodyshop.fields.md_parts_scan.field")
|
||||
})
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{label: t("joblines.fields.line_desc"), value: "line_desc"},
|
||||
{label: t("joblines.fields.part_type"), value: "part_type"},
|
||||
{label: t("joblines.fields.act_price"), value: "act_price"},
|
||||
{label: t("joblines.fields.part_qty"), value: "part_qty"},
|
||||
{label: t("joblines.fields.mod_lbr_ty"), value: "mod_lbr_ty"},
|
||||
{label: t("joblines.fields.mod_lb_hrs"), value: "mod_lb_hrs"},
|
||||
{
|
||||
label: `${t("joblines.fields.oem_partno")} / ${t("joblines.fields.alt_partno")}`,
|
||||
value: "part_number"
|
||||
},
|
||||
]}
|
||||
options={fieldSelectOptions}
|
||||
onChange={() => {
|
||||
form.setFields([
|
||||
{name: ["md_parts_scan", index, "operation"], value: "contains"},
|
||||
{name: ["md_parts_scan", index, "value"], value: undefined},
|
||||
{ name: ["md_parts_scan", index, "operation"], value: "contains" },
|
||||
{ name: ["md_parts_scan", index, "value"], value: undefined }
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
@@ -99,12 +115,12 @@ export default function ShopInfoPartsScan({form}) {
|
||||
{
|
||||
required: true,
|
||||
message: t("general.validation.required", {
|
||||
label: t("bodyshop.fields.md_parts_scan.operation"),
|
||||
}),
|
||||
},
|
||||
label: t("bodyshop.fields.md_parts_scan.operation")
|
||||
})
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select options={operationOptions[fieldType]}/>
|
||||
<Select options={operationOptions[fieldType]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
@@ -119,9 +135,9 @@ export default function ShopInfoPartsScan({form}) {
|
||||
{
|
||||
required: true,
|
||||
message: t("general.validation.required", {
|
||||
label: t("bodyshop.fields.md_parts_scan.value"),
|
||||
}),
|
||||
},
|
||||
label: t("bodyshop.fields.md_parts_scan.value")
|
||||
})
|
||||
}
|
||||
]}
|
||||
>
|
||||
{fieldType === "predefined" ? (
|
||||
@@ -129,17 +145,17 @@ export default function ShopInfoPartsScan({form}) {
|
||||
options={
|
||||
selectedField === "part_type"
|
||||
? predefinedPartTypes.map((type) => ({
|
||||
label: type,
|
||||
value: type
|
||||
}))
|
||||
label: type,
|
||||
value: type
|
||||
}))
|
||||
: predefinedModLbrTypes.map((type) => ({
|
||||
label: type,
|
||||
value: type
|
||||
}))
|
||||
label: type,
|
||||
value: type
|
||||
}))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input/>
|
||||
<Input />
|
||||
)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
@@ -152,19 +168,70 @@ export default function ShopInfoPartsScan({form}) {
|
||||
label={t("bodyshop.fields.md_parts_scan.caseInsensitive")}
|
||||
name={[field.name, "caseInsensitive"]}
|
||||
valuePropName="checked"
|
||||
labelCol={{span: 14}}
|
||||
wrapperCol={{span: 10}}
|
||||
initialValue={true}
|
||||
labelCol={{ span: 14 }}
|
||||
wrapperCol={{ span: 10 }}
|
||||
>
|
||||
<Switch defaultChecked={true}/>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
|
||||
{/* Mark Line as Critical */}
|
||||
<Col span={4}>
|
||||
<Form.Item
|
||||
label={t("bodyshop.fields.md_parts_scan.mark_critical")}
|
||||
name={[field.name, "mark_critical"]}
|
||||
valuePropName="checked"
|
||||
initialValue={true}
|
||||
labelCol={{ span: 14 }}
|
||||
wrapperCol={{ span: 10 }}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
{/* Update Field */}
|
||||
<Col span={4}>
|
||||
<Form.Item
|
||||
label={t("bodyshop.fields.md_parts_scan.update_field")}
|
||||
name={[field.name, "update_field"]}
|
||||
>
|
||||
<Select
|
||||
options={fieldSelectOptions}
|
||||
allowClear
|
||||
onClear={() =>
|
||||
form.setFields([{ name: ["md_parts_scan", index, "update_field"], value: null }])
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
{/* Update Field */}
|
||||
<Col span={4}>
|
||||
<Form.Item
|
||||
label={t("bodyshop.fields.md_parts_scan.update_value")}
|
||||
name={[field.name, "update_value"]}
|
||||
dependencies={[["md_parts_scan", index, "update_field"]]}
|
||||
tooltip={t("bodyshop.tooltips.md_parts_scan.update_value_tooltip")}
|
||||
rules={[
|
||||
{
|
||||
required: form.getFieldValue(["md_parts_scan", index, "update_field"]),
|
||||
message: t("general.validation.required", {
|
||||
label: t("bodyshop.fields.md_parts_scan.update_value")
|
||||
})
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
{/* Actions */}
|
||||
<Col span={2}>
|
||||
<Space>
|
||||
<DeleteFilled onClick={() => remove(field.name)}/>
|
||||
<FormListMoveArrows move={move} index={index} total={fields.length}/>
|
||||
<DeleteFilled onClick={() => remove(field.name)} />
|
||||
<FormListMoveArrows move={move} index={index} total={fields.length} />
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -175,8 +242,8 @@ export default function ShopInfoPartsScan({form}) {
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={() => add({field: "line_desc", operation: "contains"})}
|
||||
style={{width: "100%"}}
|
||||
onClick={() => add({ field: "line_desc", operation: "contains" })}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{t("bodyshop.actions.addpartsrule")}
|
||||
</Button>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { setModalContext } from "../../redux/modals/modals.actions";
|
||||
import { pageLimit } from "../../utils/config";
|
||||
import { DateFormatter, DateTimeFormatter } from "../../utils/DateFormatter.jsx";
|
||||
import dayjs from "../../utils/day";
|
||||
import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
|
||||
|
||||
/**
|
||||
* Task List Component
|
||||
@@ -266,8 +267,13 @@ function TaskListComponent({
|
||||
width: "8%",
|
||||
render: (text, record) => (
|
||||
<Space direction="horizontal">
|
||||
<ShareToTeamsButton
|
||||
linkText=""
|
||||
urlOverride={`${window.location.origin}/manage/tasks/alltasks?taskid=${record.id}`}
|
||||
/>
|
||||
<Button
|
||||
title={t("tasks.buttons.edit")}
|
||||
icon={<EditFilled />}
|
||||
onClick={() => {
|
||||
setTaskUpsertContext({
|
||||
context: {
|
||||
@@ -276,18 +282,18 @@ function TaskListComponent({
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<EditFilled />
|
||||
</Button>
|
||||
/>
|
||||
<Button
|
||||
title={t("tasks.buttons.complete")}
|
||||
onClick={() => toggleCompletedStatus(record.id, record.completed)}
|
||||
>
|
||||
{record.completed ? <CheckCircleOutlined /> : <CheckCircleFilled />}
|
||||
</Button>
|
||||
<Button title={t("tasks.buttons.delete")} onClick={() => toggleDeletedStatus(record.id, record.deleted)}>
|
||||
{record.deleted ? <DeleteOutlined /> : <DeleteFilled />}
|
||||
</Button>
|
||||
icon={record.completed ? <CheckCircleOutlined /> : <CheckCircleFilled />}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title={t("tasks.buttons.delete")}
|
||||
onClick={() => toggleDeletedStatus(record.id, record.deleted)}
|
||||
icon={record.deleted ? <DeleteOutlined /> : <DeleteFilled />}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import { Form, Modal } from "antd";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -35,7 +35,7 @@ export function TaskUpsertModalContainer({ bodyshop, currentUser, taskUpsert, to
|
||||
const [insertTask] = useMutation(MUTATION_INSERT_NEW_TASK);
|
||||
const [updateTask] = useMutation(MUTATION_UPDATE_TASK);
|
||||
const { open, context } = taskUpsert;
|
||||
const { jobid, joblineid, billid, partsorderid, taskId, existingTask, query } = context;
|
||||
const { jobid, joblineid, billid, partsorderid, taskId, existingTask, query, view } = context;
|
||||
const [form] = Form.useForm();
|
||||
const [selectedJobId, setSelectedJobId] = useState(null);
|
||||
const [selectedJobDetails, setSelectedJobDetails] = useState(null);
|
||||
@@ -255,16 +255,17 @@ export function TaskUpsertModalContainer({ bodyshop, currentUser, taskUpsert, to
|
||||
}
|
||||
};
|
||||
|
||||
const taskTitle = useMemo(() => {
|
||||
return existingTask ? t("tasks.actions.edit") : t("tasks.actions.new");
|
||||
}, [existingTask, t]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<span id="task-upsert-modal-title">{taskTitle}</span>}
|
||||
title={
|
||||
<span id="task-upsert-modal-title">
|
||||
{view ? t("tasks.actions.view") : existingTask ? t("tasks.actions.edit") : t("tasks.actions.new")}
|
||||
</span>
|
||||
}
|
||||
open={open}
|
||||
okText={t("general.actions.save")}
|
||||
width="50%"
|
||||
cancelText={!isTouched ? t("general.actions.ok") : t("general.actions.cancel")}
|
||||
onOk={() => {
|
||||
removeTaskIdFromUrl();
|
||||
form.submit();
|
||||
@@ -289,6 +290,7 @@ export function TaskUpsertModalContainer({ bodyshop, currentUser, taskUpsert, to
|
||||
loading={loading || (taskId && taskLoading)}
|
||||
error={error}
|
||||
data={data}
|
||||
view={view}
|
||||
existingTask={existingTask || taskData?.tasks_by_pk}
|
||||
selectedJobId={selectedJobId}
|
||||
setSelectedJobId={setSelectedJobId}
|
||||
|
||||
@@ -1,13 +1,384 @@
|
||||
import React, { createContext } from "react";
|
||||
import useSocket from "./useSocket"; // Import the custom hook
|
||||
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||
import SocketIO from "socket.io-client";
|
||||
import { auth } from "../../firebase/firebase.utils";
|
||||
import { store } from "../../redux/store";
|
||||
import { addAlerts, setWssStatus } from "../../redux/application/application.actions";
|
||||
import client from "../../utils/GraphQLClient";
|
||||
import { useNotification } from "../Notifications/notificationContext.jsx";
|
||||
import {
|
||||
GET_NOTIFICATIONS,
|
||||
GET_UNREAD_COUNT,
|
||||
MARK_ALL_NOTIFICATIONS_READ,
|
||||
MARK_NOTIFICATION_READ
|
||||
} from "../../graphql/notifications.queries.js";
|
||||
import { useMutation } from "@apollo/client";
|
||||
|
||||
// Create the SocketContext
|
||||
const SocketContext = createContext(null);
|
||||
|
||||
export const SocketProvider = ({ children, bodyshop }) => {
|
||||
const { socket, clientId } = useSocket(bodyshop);
|
||||
// This is how many notifications the database will populate on load, and the increment for load more
|
||||
export const INITIAL_NOTIFICATIONS = 10;
|
||||
|
||||
return <SocketContext.Provider value={{ socket, clientId }}> {children}</SocketContext.Provider>;
|
||||
export const SCENARIO_NOTIFICATION_LOCATION = "bottomRight";
|
||||
export const SCENARIO_NOTIFICATION_DURATION = 15; // Seconds
|
||||
|
||||
export const SocketProvider = ({ children, bodyshop, navigate }) => {
|
||||
const socketRef = useRef(null);
|
||||
const [clientId, setClientId] = useState(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const notification = useNotification();
|
||||
const userAssociationId = bodyshop?.associations?.[0]?.id;
|
||||
|
||||
const [markNotificationRead] = useMutation(MARK_NOTIFICATION_READ, {
|
||||
update: (cache, { data: { update_notifications } }) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
const updatedNotification = update_notifications.returning[0];
|
||||
|
||||
// Update the notifications list
|
||||
cache.modify({
|
||||
fields: {
|
||||
notifications(existing = [], { readField }) {
|
||||
return existing.map((notif) => {
|
||||
if (readField("id", notif) === updatedNotification.id) {
|
||||
return { ...notif, read: timestamp };
|
||||
}
|
||||
return notif;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update the unread count in notifications_aggregate
|
||||
const unreadCountQuery = cache.readQuery({
|
||||
query: GET_UNREAD_COUNT,
|
||||
variables: { associationid: userAssociationId }
|
||||
});
|
||||
|
||||
if (unreadCountQuery?.notifications_aggregate?.aggregate?.count > 0) {
|
||||
cache.writeQuery({
|
||||
query: GET_UNREAD_COUNT,
|
||||
variables: { associationid: userAssociationId },
|
||||
data: {
|
||||
notifications_aggregate: {
|
||||
...unreadCountQuery.notifications_aggregate,
|
||||
aggregate: {
|
||||
...unreadCountQuery.notifications_aggregate.aggregate,
|
||||
count: unreadCountQuery.notifications_aggregate.aggregate.count - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error("MARK_NOTIFICATION_READ error in SocketProvider:", err);
|
||||
}
|
||||
});
|
||||
|
||||
const [markAllNotificationsRead] = useMutation(MARK_ALL_NOTIFICATIONS_READ, {
|
||||
variables: { associationid: userAssociationId },
|
||||
update: (cache) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
cache.modify({
|
||||
fields: {
|
||||
notifications(existing = [], { readField }) {
|
||||
return existing.map((notif) => {
|
||||
if (readField("read", notif) === null && readField("associationid", notif) === userAssociationId) {
|
||||
return { ...notif, read: timestamp };
|
||||
}
|
||||
return notif;
|
||||
});
|
||||
},
|
||||
notifications_aggregate() {
|
||||
return { aggregate: { count: 0, __typename: "notifications_aggregate_fields" } };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const baseWhereClause = { associationid: { _eq: userAssociationId } };
|
||||
const cachedNotifications = cache.readQuery({
|
||||
query: GET_NOTIFICATIONS,
|
||||
variables: {
|
||||
limit: INITIAL_NOTIFICATIONS,
|
||||
offset: 0,
|
||||
where: baseWhereClause
|
||||
}
|
||||
});
|
||||
|
||||
if (cachedNotifications?.notifications) {
|
||||
cache.writeQuery({
|
||||
query: GET_NOTIFICATIONS,
|
||||
variables: {
|
||||
limit: INITIAL_NOTIFICATIONS,
|
||||
offset: 0,
|
||||
where: baseWhereClause
|
||||
},
|
||||
data: {
|
||||
notifications: cachedNotifications.notifications.map((notif) =>
|
||||
notif.read === null ? { ...notif, read: timestamp } : notif
|
||||
)
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error("MARK_ALL_NOTIFICATIONS_READ error in SocketProvider:", err);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const initializeSocket = async (token) => {
|
||||
if (!bodyshop || !bodyshop.id || socketRef.current) return;
|
||||
|
||||
const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "";
|
||||
const socketInstance = SocketIO(endpoint, {
|
||||
path: "/wss",
|
||||
withCredentials: true,
|
||||
auth: { token, bodyshopId: bodyshop.id },
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: 2000,
|
||||
reconnectionDelayMax: 10000
|
||||
});
|
||||
|
||||
socketRef.current = socketInstance;
|
||||
|
||||
const handleBodyshopMessage = (message) => {
|
||||
if (!message || !message.type) return;
|
||||
switch (message.type) {
|
||||
case "alert-update":
|
||||
store.dispatch(addAlerts(message.payload));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = () => {
|
||||
socketInstance.emit("join-bodyshop-room", bodyshop.id);
|
||||
setClientId(socketInstance.id);
|
||||
setIsConnected(true);
|
||||
store.dispatch(setWssStatus("connected"));
|
||||
};
|
||||
|
||||
const handleReconnect = () => {
|
||||
setIsConnected(true);
|
||||
store.dispatch(setWssStatus("connected"));
|
||||
};
|
||||
|
||||
const handleConnectionError = (err) => {
|
||||
console.error("Socket connection error:", err);
|
||||
setIsConnected(false);
|
||||
if (err.message.includes("auth/id-token-expired")) {
|
||||
console.warn("Token expired, refreshing...");
|
||||
auth.currentUser?.getIdToken(true).then((newToken) => {
|
||||
socketInstance.auth = { token: newToken };
|
||||
socketInstance.connect();
|
||||
});
|
||||
} else {
|
||||
store.dispatch(setWssStatus("error"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = (reason) => {
|
||||
console.warn("Socket disconnected:", reason);
|
||||
setIsConnected(false);
|
||||
store.dispatch(setWssStatus("disconnected"));
|
||||
if (!socketInstance.connected && reason !== "io server disconnect") {
|
||||
setTimeout(() => {
|
||||
if (socketInstance.disconnected) {
|
||||
console.log("Manually triggering reconnection...");
|
||||
socketInstance.connect();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotification = (data) => {
|
||||
const { jobId, jobRoNumber, notificationId, associationId, notifications } = data;
|
||||
|
||||
if (associationId !== userAssociationId) return;
|
||||
|
||||
const newNotification = {
|
||||
__typename: "notifications",
|
||||
id: notificationId,
|
||||
jobid: jobId,
|
||||
associationid: associationId,
|
||||
scenario_text: JSON.stringify(notifications.map((notif) => notif.body)),
|
||||
fcm_text: notifications.map((notif) => notif.body).join(". ") + ".",
|
||||
scenario_meta: JSON.stringify(notifications.map((notif) => notif.variables || {})),
|
||||
created_at: new Date(notifications[0].timestamp).toISOString(),
|
||||
read: null,
|
||||
job: {
|
||||
ro_number: jobRoNumber
|
||||
}
|
||||
};
|
||||
|
||||
const baseVariables = {
|
||||
limit: INITIAL_NOTIFICATIONS,
|
||||
offset: 0,
|
||||
where: { associationid: { _eq: userAssociationId } }
|
||||
};
|
||||
|
||||
try {
|
||||
const existingNotifications =
|
||||
client.cache.readQuery({
|
||||
query: GET_NOTIFICATIONS,
|
||||
variables: baseVariables
|
||||
})?.notifications || [];
|
||||
|
||||
if (existingNotifications.some((n) => n.id === newNotification.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
client.cache.writeQuery({
|
||||
query: GET_NOTIFICATIONS,
|
||||
variables: baseVariables,
|
||||
data: {
|
||||
notifications: [newNotification, ...existingNotifications].sort(
|
||||
(a, b) => new Date(b.created_at) - new Date(a.created_at)
|
||||
)
|
||||
},
|
||||
broadcast: true
|
||||
});
|
||||
|
||||
const unreadVariables = {
|
||||
...baseVariables,
|
||||
where: { ...baseVariables.where, read: { _is_null: true } }
|
||||
};
|
||||
const unreadNotifications =
|
||||
client.cache.readQuery({
|
||||
query: GET_NOTIFICATIONS,
|
||||
variables: unreadVariables
|
||||
})?.notifications || [];
|
||||
|
||||
if (newNotification.read === null && !unreadNotifications.some((n) => n.id === newNotification.id)) {
|
||||
client.cache.writeQuery({
|
||||
query: GET_NOTIFICATIONS,
|
||||
variables: unreadVariables,
|
||||
data: {
|
||||
notifications: [newNotification, ...unreadNotifications].sort(
|
||||
(a, b) => new Date(b.created_at) - new Date(a.created_at)
|
||||
)
|
||||
},
|
||||
broadcast: true
|
||||
});
|
||||
}
|
||||
|
||||
client.cache.modify({
|
||||
id: "ROOT_QUERY",
|
||||
fields: {
|
||||
notifications_aggregate(existing = { aggregate: { count: 0 } }) {
|
||||
const isUnread = newNotification.read === null;
|
||||
const countChange = isUnread ? 1 : 0;
|
||||
return {
|
||||
...existing,
|
||||
aggregate: {
|
||||
...existing.aggregate,
|
||||
count: existing.aggregate.count + countChange
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
notification.info({
|
||||
message: `Changes for ${jobRoNumber}:`,
|
||||
description: (
|
||||
<ul
|
||||
className="notification-alert-unorderd-list"
|
||||
onClick={() => {
|
||||
markNotificationRead({ variables: { id: notificationId } })
|
||||
.then(() => navigate(`/manage/jobs/${jobId}`))
|
||||
.catch((e) => console.error(`Error marking notification read from info: ${e?.message || ""}`));
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{notifications.map((notif, index) => (
|
||||
<li className="notification-alert-unorderd-list-item" key={index}>
|
||||
{notif.body}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
placement: SCENARIO_NOTIFICATION_LOCATION,
|
||||
duration: SCENARIO_NOTIFICATION_DURATION
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Something went wrong handling a new notification: ${error?.message || ""}`);
|
||||
}
|
||||
};
|
||||
|
||||
socketInstance.on("connect", handleConnect);
|
||||
socketInstance.on("reconnect", handleReconnect);
|
||||
socketInstance.on("connect_error", handleConnectionError);
|
||||
socketInstance.on("disconnect", handleDisconnect);
|
||||
socketInstance.on("bodyshop-message", handleBodyshopMessage);
|
||||
socketInstance.on("message", (message) => {
|
||||
try {
|
||||
if (typeof message === "string" && message.startsWith("42")) {
|
||||
const parsedMessage = JSON.parse(message.slice(2));
|
||||
const [event, data] = parsedMessage;
|
||||
if (event === "notification") handleNotification(data);
|
||||
} else if (Array.isArray(message)) {
|
||||
const [event, data] = message;
|
||||
if (event === "notification") handleNotification(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing socket message:", error);
|
||||
}
|
||||
});
|
||||
socketInstance.on("notification", handleNotification);
|
||||
};
|
||||
|
||||
const unsubscribe = auth.onIdTokenChanged(async (user) => {
|
||||
if (user) {
|
||||
const token = await user.getIdToken();
|
||||
if (socketRef.current) {
|
||||
socketRef.current.emit("update-token", { token, bodyshopId: bodyshop.id });
|
||||
} else {
|
||||
initializeSocket(token).catch((err) =>
|
||||
console.error(`Something went wrong Initializing Sockets: ${err?.message || ""}`)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (socketRef.current) {
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
setIsConnected(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (socketRef.current) {
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
setIsConnected(false);
|
||||
}
|
||||
};
|
||||
}, [bodyshop, notification, userAssociationId, markNotificationRead, markAllNotificationsRead, navigate]);
|
||||
|
||||
return (
|
||||
<SocketContext.Provider
|
||||
value={{
|
||||
socket: socketRef.current,
|
||||
clientId,
|
||||
isConnected,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSocket = () => {
|
||||
const context = useContext(SocketContext);
|
||||
if (!context) {
|
||||
throw new Error("useSocket must be used within a SocketProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export default SocketContext;
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import SocketIO from "socket.io-client";
|
||||
import { auth } from "../../firebase/firebase.utils";
|
||||
import { store } from "../../redux/store";
|
||||
import { addAlerts, setWssStatus } from "../../redux/application/application.actions";
|
||||
|
||||
const useSocket = (bodyshop) => {
|
||||
const socketRef = useRef(null);
|
||||
const [clientId, setClientId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeSocket = async (token) => {
|
||||
if (!bodyshop || !bodyshop.id) return;
|
||||
|
||||
const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "";
|
||||
|
||||
const socketInstance = SocketIO(endpoint, {
|
||||
path: "/wss",
|
||||
withCredentials: true,
|
||||
auth: { token },
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: 2000,
|
||||
reconnectionDelayMax: 10000
|
||||
});
|
||||
|
||||
socketRef.current = socketInstance;
|
||||
|
||||
// Handle socket events
|
||||
const handleBodyshopMessage = (message) => {
|
||||
if (!message || !message.type) return;
|
||||
|
||||
switch (message.type) {
|
||||
case "alert-update":
|
||||
store.dispatch(addAlerts(message.payload));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (!import.meta.env.DEV) return;
|
||||
console.log(`Received message for bodyshop ${bodyshop.id}:`, message);
|
||||
};
|
||||
|
||||
const handleConnect = () => {
|
||||
socketInstance.emit("join-bodyshop-room", bodyshop.id);
|
||||
setClientId(socketInstance.id);
|
||||
store.dispatch(setWssStatus("connected"));
|
||||
};
|
||||
|
||||
const handleReconnect = () => {
|
||||
store.dispatch(setWssStatus("connected"));
|
||||
};
|
||||
|
||||
const handleConnectionError = (err) => {
|
||||
console.error("Socket connection error:", err);
|
||||
|
||||
// Handle token expiration
|
||||
if (err.message.includes("auth/id-token-expired")) {
|
||||
console.warn("Token expired, refreshing...");
|
||||
auth.currentUser?.getIdToken(true).then((newToken) => {
|
||||
socketInstance.auth = { token: newToken }; // Update socket auth
|
||||
socketInstance.connect(); // Retry connection
|
||||
});
|
||||
} else {
|
||||
store.dispatch(setWssStatus("error"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = (reason) => {
|
||||
console.warn("Socket disconnected:", reason);
|
||||
store.dispatch(setWssStatus("disconnected"));
|
||||
|
||||
// Manually trigger reconnection if necessary
|
||||
if (!socketInstance.connected && reason !== "io server disconnect") {
|
||||
setTimeout(() => {
|
||||
if (socketInstance.disconnected) {
|
||||
console.log("Manually triggering reconnection...");
|
||||
socketInstance.connect();
|
||||
}
|
||||
}, 2000); // Retry after 2 seconds
|
||||
}
|
||||
};
|
||||
|
||||
// Register event handlers
|
||||
socketInstance.on("connect", handleConnect);
|
||||
socketInstance.on("reconnect", handleReconnect);
|
||||
socketInstance.on("connect_error", handleConnectionError);
|
||||
socketInstance.on("disconnect", handleDisconnect);
|
||||
socketInstance.on("bodyshop-message", handleBodyshopMessage);
|
||||
};
|
||||
|
||||
const unsubscribe = auth.onIdTokenChanged(async (user) => {
|
||||
if (user) {
|
||||
const token = await user.getIdToken();
|
||||
|
||||
if (socketRef.current) {
|
||||
// Update token if socket exists
|
||||
socketRef.current.emit("update-token", token);
|
||||
} else {
|
||||
// Initialize socket if not already connected
|
||||
initializeSocket(token);
|
||||
}
|
||||
} else {
|
||||
// User is not authenticated
|
||||
if (socketRef.current) {
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up on unmount
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (socketRef.current) {
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [bodyshop]);
|
||||
|
||||
return { socket: socketRef.current, clientId };
|
||||
};
|
||||
|
||||
export default useSocket;
|
||||
@@ -349,3 +349,13 @@ export const QUERY_STRIPE_ID = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_ACTIVE_EMPLOYEES_IN_SHOP = gql`
|
||||
query GetActiveEmployeesInShop($shopid: uuid!) {
|
||||
associations(where: { shopid: { _eq: $shopid } }) {
|
||||
id
|
||||
useremail
|
||||
shopid
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -524,6 +524,9 @@ export const GET_JOB_BY_PK = gql`
|
||||
invoice_final_note
|
||||
iouparent
|
||||
job_totals
|
||||
job_watchers {
|
||||
user_email
|
||||
}
|
||||
joblines(where: { removed: { _eq: false } }, order_by: { line_no: asc }) {
|
||||
act_price
|
||||
act_price_before_ppc
|
||||
@@ -1890,6 +1893,7 @@ export const QUERY_JOB_CLOSE_DETAILS = gql`
|
||||
kmout
|
||||
qb_multiple_payers
|
||||
lbr_adjustments
|
||||
ownr_ea
|
||||
payments {
|
||||
amount
|
||||
created_at
|
||||
@@ -2566,3 +2570,30 @@ export const GET_JOB_BY_PK_QUICK_INTAKE = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_JOB_WATCHERS = gql`
|
||||
query GET_JOB_WATCHERS($jobid: uuid!) {
|
||||
job_watchers(where: { jobid: { _eq: $jobid } }) {
|
||||
id
|
||||
user_email
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ADD_JOB_WATCHER = gql`
|
||||
mutation ADD_JOB_WATCHER($jobid: uuid!, $userEmail: String!) {
|
||||
insert_job_watchers_one(object: { jobid: $jobid, user_email: $userEmail }) {
|
||||
id
|
||||
jobid
|
||||
user_email
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const REMOVE_JOB_WATCHER = gql`
|
||||
mutation REMOVE_JOB_WATCHER($jobid: uuid!, $userEmail: String!) {
|
||||
delete_job_watchers(where: { jobid: { _eq: $jobid }, user_email: { _eq: $userEmail } }) {
|
||||
affected_rows
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
51
client/src/graphql/notifications.queries.js
Normal file
51
client/src/graphql/notifications.queries.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { gql } from "@apollo/client";
|
||||
|
||||
export const GET_NOTIFICATIONS = gql`
|
||||
query GetNotifications($limit: Int!, $offset: Int!, $where: notifications_bool_exp) {
|
||||
notifications(limit: $limit, offset: $offset, order_by: { created_at: desc }, where: $where) {
|
||||
id
|
||||
jobid
|
||||
associationid
|
||||
scenario_text
|
||||
fcm_text
|
||||
scenario_meta
|
||||
created_at
|
||||
read
|
||||
job {
|
||||
ro_number
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_UNREAD_COUNT = gql`
|
||||
query GetUnreadCount($associationid: uuid!) {
|
||||
notifications_aggregate(where: { read: { _is_null: true }, associationid: { _eq: $associationid } }) {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const MARK_ALL_NOTIFICATIONS_READ = gql`
|
||||
mutation MarkAllNotificationsRead($associationid: uuid!) {
|
||||
update_notifications(
|
||||
where: { read: { _is_null: true }, associationid: { _eq: $associationid } }
|
||||
_set: { read: "now()" }
|
||||
) {
|
||||
affected_rows
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const MARK_NOTIFICATION_READ = gql`
|
||||
mutation MarkNotificationRead($id: uuid!) {
|
||||
update_notifications(where: { id: { _eq: $id } }, _set: { read: "now()" }) {
|
||||
returning {
|
||||
id
|
||||
read
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -85,3 +85,21 @@ export const UPDATE_KANBAN_SETTINGS = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const QUERY_NOTIFICATION_SETTINGS = gql`
|
||||
query QUERY_NOTIFICATION_SETTINGS($email: String!) {
|
||||
associations(where: { _and: { useremail: { _eq: $email }, active: { _eq: true } } }) {
|
||||
id
|
||||
notification_settings
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_NOTIFICATION_SETTINGS = gql`
|
||||
mutation UPDATE_NOTIFICATION_SETTINGS($id: uuid!, $ns: jsonb) {
|
||||
update_associations_by_pk(pk_columns: { id: $id }, _set: { notification_settings: $ns }) {
|
||||
id
|
||||
notification_settings
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DeleteFilled } from "@ant-design/icons";
|
||||
import { DeleteFilled, PrinterFilled } from "@ant-design/icons";
|
||||
import { PageHeader } from "@ant-design/pro-layout";
|
||||
import { useApolloClient, useMutation } from "@apollo/client";
|
||||
import {
|
||||
Alert,
|
||||
@@ -16,32 +17,31 @@ import {
|
||||
Switch,
|
||||
Typography
|
||||
} from "antd";
|
||||
import { PageHeader } from "@ant-design/pro-layout";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
// import { useNavigate } from 'react-router-dom';
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import Dinero from "dinero.js";
|
||||
import dayjs from "../../utils/day";
|
||||
import { Link } from "react-router-dom";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import DateTimePicker from "../../components/form-date-time-picker/form-date-time-picker.component";
|
||||
import FormsFieldChanged from "../../components/form-fields-changed-alert/form-fields-changed-alert.component";
|
||||
import CurrencyInput from "../../components/form-items-formatted/currency-form-item.component";
|
||||
import JobCloseRoGuardContainer from "../../components/job-close-ro-guard/job-close-ro-guard.container";
|
||||
import JobsScoreboardAdd from "../../components/job-scoreboard-add-button/job-scoreboard-add-button.component";
|
||||
import JobsCloseAutoAllocate from "../../components/jobs-close-auto-allocate/jobs-close-auto-allocate.component";
|
||||
import JobsCloseLines from "../../components/jobs-close-lines/jobs-close-lines.component";
|
||||
import LayoutFormRow from "../../components/layout-form-row/layout-form-row.component";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { generateJobLinesUpdatesForInvoicing } from "../../graphql/jobs-lines.queries";
|
||||
import { UPDATE_JOB } from "../../graphql/jobs.queries";
|
||||
import { insertAuditTrail } from "../../redux/application/application.actions";
|
||||
import { selectJobReadOnly } from "../../redux/application/application.selectors";
|
||||
import { setModalContext } from "../../redux/modals/modals.actions.js";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import AuditTrailMapping from "../../utils/AuditTrailMappings";
|
||||
import JobCloseRoGuardContainer from "../../components/job-close-ro-guard/job-close-ro-guard.container";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import dayjs from "../../utils/day";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
@@ -49,10 +49,17 @@ const mapStateToProps = createStructuredSelector({
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
|
||||
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type })),
|
||||
setPrintCenterContext: (context) =>
|
||||
dispatch(
|
||||
setModalContext({
|
||||
context: context,
|
||||
modal: "printCenter"
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail }) {
|
||||
export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, setPrintCenterContext }) {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm();
|
||||
const client = useApolloClient();
|
||||
@@ -171,7 +178,6 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail }) {
|
||||
extra={
|
||||
<Space>
|
||||
<JobsCloseAutoAllocate joblines={job.joblines} form={form} disabled={!!job.date_exported || jobRO} />
|
||||
|
||||
<Popconfirm
|
||||
onConfirm={() => form.submit()}
|
||||
disabled={jobRO}
|
||||
@@ -188,6 +194,21 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail }) {
|
||||
<Button disabled={job.date_exported || !jobRO}>{t("jobs.actions.sendtodms")}</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setPrintCenterContext({
|
||||
context: {
|
||||
id: job.id,
|
||||
job: job,
|
||||
type: "job"
|
||||
}
|
||||
});
|
||||
}}
|
||||
key="printing"
|
||||
icon={<PrinterFilled />}
|
||||
>
|
||||
{t("jobs.actions.printCenter")}
|
||||
</Button>
|
||||
<JobsScoreboardAdd job={job} disabled={false} />
|
||||
</Space>
|
||||
}
|
||||
|
||||
196
client/src/pages/jobs-detail/job-watcher-toggle.component.jsx
Normal file
196
client/src/pages/jobs-detail/job-watcher-toggle.component.jsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import { EyeFilled, EyeOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { ADD_JOB_WATCHER, GET_JOB_WATCHERS, REMOVE_JOB_WATCHER } from "../../graphql/jobs.queries.js";
|
||||
import { Avatar, Button, Divider, List, Popover, Select, Tooltip, Typography } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
|
||||
import EmployeeSearchSelectComponent from "../../components/employee-search-select/employee-search-select.component.jsx";
|
||||
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component.jsx";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
currentUser: selectCurrentUser
|
||||
});
|
||||
|
||||
const JobWatcherToggle = ({ job, currentUser, bodyshop }) => {
|
||||
const { t } = useTranslation();
|
||||
const userEmail = currentUser.email;
|
||||
const jobid = job.id;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedWatcher, setSelectedWatcher] = useState(null); // New state for selected value
|
||||
const [selectedTeam, setSelectedTeam] = useState(null); // New state to track selected team
|
||||
|
||||
// Fetch current watchers
|
||||
const { data: watcherData, loading: watcherLoading } = useQuery(GET_JOB_WATCHERS, { variables: { jobid } });
|
||||
|
||||
// Extract watchers list
|
||||
const jobWatchers = useMemo(() => watcherData?.job_watchers || [], [watcherData]);
|
||||
const isWatching = useMemo(() => jobWatchers.some((w) => w.user_email === userEmail), [jobWatchers, userEmail]);
|
||||
|
||||
// Add watcher mutation
|
||||
const [addWatcher, { loading: adding }] = useMutation(ADD_JOB_WATCHER, {
|
||||
refetchQueries: [{ query: GET_JOB_WATCHERS, variables: { jobid } }]
|
||||
});
|
||||
|
||||
// Remove watcher mutation
|
||||
const [removeWatcher, { loading: removing }] = useMutation(REMOVE_JOB_WATCHER, {
|
||||
refetchQueries: [{ query: GET_JOB_WATCHERS, variables: { jobid } }]
|
||||
});
|
||||
|
||||
// Toggle watcher for self
|
||||
const handleToggleSelf = useCallback(() => {
|
||||
(isWatching
|
||||
? removeWatcher({ variables: { jobid, userEmail } })
|
||||
: addWatcher({ variables: { jobid, userEmail } })
|
||||
).catch((err) => console.error(`Error updating job watcher: ${err.message}`));
|
||||
}, [isWatching, addWatcher, removeWatcher, jobid, userEmail]);
|
||||
|
||||
// Handle removing a watcher
|
||||
const handleRemoveWatcher = (userEmail) => {
|
||||
removeWatcher({ variables: { jobid, userEmail } }).catch((err) =>
|
||||
console.error(`Error removing job watcher: ${err.message}`)
|
||||
);
|
||||
};
|
||||
|
||||
const handleWatcherSelect = (selectedUser) => {
|
||||
const employee = bodyshop.employees.find((e) => e.id === selectedUser);
|
||||
if (!employee) return;
|
||||
|
||||
const isAlreadyWatching = jobWatchers.some((w) => w.user_email === employee.user_email);
|
||||
|
||||
if (isAlreadyWatching) {
|
||||
handleRemoveWatcher(employee.user_email);
|
||||
} else {
|
||||
addWatcher({ variables: { jobid, userEmail: employee.user_email } }).catch((err) =>
|
||||
console.error(`Error adding job watcher: ${err.message}`)
|
||||
);
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
setSelectedWatcher(null);
|
||||
};
|
||||
|
||||
const handleTeamSelect = (team) => {
|
||||
const selectedTeamMembers = JSON.parse(team); // Parse the array of emails
|
||||
|
||||
const newWatchers = selectedTeamMembers.filter(
|
||||
(email) => !jobWatchers.some((watcher) => watcher.user_email === email)
|
||||
);
|
||||
|
||||
// Add each new watcher
|
||||
newWatchers.forEach((email) => {
|
||||
addWatcher({ variables: { jobid, userEmail: email } }).catch((err) =>
|
||||
console.error(`Error adding job watcher: ${err.message}`)
|
||||
);
|
||||
});
|
||||
|
||||
// Clear selection
|
||||
setSelectedTeam(null);
|
||||
};
|
||||
|
||||
const handleRenderItem = (watcher) => {
|
||||
const employee = bodyshop.employees.find((e) => e.user_email === watcher.user_email);
|
||||
const displayName = employee ? `${employee.first_name} ${employee.last_name}` : watcher.user_email;
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button type="link" danger size="small" onClick={() => handleRemoveWatcher(watcher.user_email)}>
|
||||
{t("notifications.actions.remove")}
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar icon={<UserOutlined />} />}
|
||||
title={<Text>{displayName}</Text>}
|
||||
description={watcher.user_email} // Keep the email for reference
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
};
|
||||
|
||||
// Popover content
|
||||
const popoverContent = (
|
||||
<div style={{ width: 600 }}>
|
||||
{/* Self-toggle Button */}
|
||||
<Button
|
||||
block
|
||||
type="text"
|
||||
icon={isWatching ? <EyeOutlined /> : <EyeFilled />}
|
||||
onClick={handleToggleSelf}
|
||||
loading={adding || removing}
|
||||
>
|
||||
{isWatching ? t("notifications.tooltips.unwatch") : t("notifications.tooltips.watch")}
|
||||
</Button>
|
||||
{/* List of Watchers */}
|
||||
<Text type="secondary" style={{ marginBottom: 8, display: "block" }}>
|
||||
{t("notifications.labels.watching-issue")}
|
||||
</Text>
|
||||
{watcherLoading ? <LoadingSpinner /> : <List dataSource={jobWatchers} renderItem={handleRenderItem} />}
|
||||
{/* Employee Search Select (for adding watchers) */}
|
||||
<Divider />
|
||||
|
||||
<Text type="secondary">{t("notifications.labels.add-watchers")}</Text>
|
||||
<EmployeeSearchSelectComponent
|
||||
style={{ minWidth: "100%" }}
|
||||
options={bodyshop.employees.filter((e) => jobWatchers.every((w) => w.user_email !== e.user_email))}
|
||||
placeholder={t("notifications.labels.employee-search")}
|
||||
value={selectedWatcher} // Controlled value
|
||||
onChange={(value) => {
|
||||
setSelectedWatcher(value); // Update selected state
|
||||
handleWatcherSelect(value); // Add watcher logic
|
||||
}}
|
||||
/>
|
||||
{/* Divider for UI separation */}
|
||||
{/* Only show team selection if there are available teams */}
|
||||
{bodyshop?.employee_teams?.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Text type="secondary">{t("notifications.labels.add-watchers-team")}</Text>
|
||||
|
||||
<Select
|
||||
showSearch
|
||||
style={{ minWidth: "100%" }}
|
||||
placeholder={t("notifications.labels.teams-search")}
|
||||
value={selectedTeam} // Controlled value
|
||||
onChange={handleTeamSelect}
|
||||
options={bodyshop.employee_teams.map((team) => {
|
||||
const teamMembers = team.employee_team_members
|
||||
.map((member) => {
|
||||
const employee = bodyshop.employees.find((e) => e.id === member.employeeid);
|
||||
return employee ? employee.user_email : null;
|
||||
})
|
||||
.filter(Boolean); // Remove nulls
|
||||
|
||||
return {
|
||||
value: JSON.stringify(teamMembers), // Store array as string
|
||||
label: team.name // Use team name as label
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={popoverContent} trigger="click" open={open} onOpenChange={setOpen}>
|
||||
<Tooltip title={isWatching ? t("notifications.tooltips.unwatch") : t("notifications.tooltips.watch")}>
|
||||
<Button
|
||||
shape="circle"
|
||||
type={isWatching ? "primary" : "default"}
|
||||
icon={isWatching ? <EyeFilled /> : <EyeOutlined />}
|
||||
loading={watcherLoading}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(JobWatcherToggle);
|
||||
@@ -56,6 +56,7 @@ import { DateTimeFormat } from "../../utils/DateFormatter";
|
||||
import dayjs from "../../utils/day";
|
||||
import UndefinedToNull from "../../utils/undefinedtonull";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import JobWatcherToggle from "./job-watcher-toggle.component.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
@@ -319,7 +320,13 @@ export function JobsDetailPage({
|
||||
>
|
||||
<PageHeader
|
||||
// onBack={() => window.history.back()}
|
||||
title={job.ro_number || t("general.labels.na")}
|
||||
|
||||
title={
|
||||
<Space>
|
||||
<JobWatcherToggle job={job} />
|
||||
{job.ro_number || t("general.labels.na")}
|
||||
</Space>
|
||||
}
|
||||
extra={menuExtra}
|
||||
/>
|
||||
<JobsDetailHeader job={job} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FloatButton, Layout, Spin } from "antd";
|
||||
|
||||
// import preval from "preval.macro";
|
||||
import React, { lazy, Suspense, useContext, useEffect, useState } from "react";
|
||||
import React, { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { Link, Route, Routes } from "react-router-dom";
|
||||
@@ -20,7 +20,7 @@ import PartnerPingComponent from "../../components/partner-ping/partner-ping.com
|
||||
import PrintCenterModalContainer from "../../components/print-center-modal/print-center-modal.container";
|
||||
import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-status.component";
|
||||
import { requestForToken } from "../../firebase/firebase.utils";
|
||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
|
||||
import { selectBodyshop, selectInstanceConflict } from "../../redux/user/user.selectors";
|
||||
import UpdateAlert from "../../components/update-alert/update-alert.component";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
|
||||
@@ -29,6 +29,7 @@ import WssStatusDisplayComponent from "../../components/wss-status-display/wss-s
|
||||
import { selectAlerts } from "../../redux/application/application.selectors.js";
|
||||
import { addAlerts } from "../../redux/application/application.actions.js";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
|
||||
const JobsPage = lazy(() => import("../jobs/jobs.page"));
|
||||
|
||||
const CardPaymentModalContainer = lazy(
|
||||
@@ -122,7 +123,7 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
|
||||
const { t } = useTranslation();
|
||||
const [chatVisible] = useState(false);
|
||||
const { socket, clientId } = useContext(SocketContext);
|
||||
const { socket, clientId } = useSocket();
|
||||
const notification = useNotification();
|
||||
|
||||
// State to track displayed alerts
|
||||
@@ -146,7 +147,7 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
|
||||
}
|
||||
};
|
||||
|
||||
fetchAlerts();
|
||||
fetchAlerts().catch((err) => `Error fetching Bodyshop Alerts: ${err?.message || ""}`);
|
||||
}, [setAlerts]);
|
||||
|
||||
// Use useEffect to watch for new alerts
|
||||
|
||||
@@ -46,7 +46,8 @@ export function AllTasksPageContainer({ setBreadcrumbs, setSelectedHeader, setTa
|
||||
if (taskId) {
|
||||
setTaskUpsertContext({
|
||||
context: {
|
||||
taskId
|
||||
taskId,
|
||||
view: true
|
||||
}
|
||||
});
|
||||
urlParams.delete("taskid");
|
||||
|
||||
@@ -235,7 +235,12 @@ export function* signInSuccessSaga({ payload }) {
|
||||
|
||||
try {
|
||||
window.$crisp.push(["set", "user:nickname", [payload.displayName || payload.email]]);
|
||||
window.$crisp.push(["set", "session:segments", [["user"]]]);
|
||||
const currentUserSegment = InstanceRenderManager({
|
||||
imex: "imex-online-user",
|
||||
rome: "rome-online-user"
|
||||
});
|
||||
window.$crisp.push(["set", "session:segments", [[currentUserSegment]]]);
|
||||
|
||||
InstanceRenderManager({
|
||||
executeFunction: true,
|
||||
args: [],
|
||||
@@ -342,6 +347,9 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
|
||||
window.$zoho.salesiq.visitor.info({ "Shop Name": payload.shopname });
|
||||
}
|
||||
});
|
||||
payload.features?.allAccess === true
|
||||
? window.$crisp.push(["set", "session:segments", [["allAccess"]]])
|
||||
: window.$crisp.push(["set", "session:segments", [["basic"]]]);
|
||||
} catch (error) {
|
||||
console.error("Couldnt find $crisp.");
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,6 @@ import client from "../utils/GraphQLClient";
|
||||
import cleanAxios from "./CleanAxios";
|
||||
import { TemplateList } from "./TemplateConstants";
|
||||
import { generateTemplate } from "./graphQLmodifier";
|
||||
import InstanceRenderManager from "./instanceRenderMgr";
|
||||
|
||||
const server = import.meta.env.VITE_APP_REPORTS_SERVER_URL;
|
||||
|
||||
@@ -39,7 +38,7 @@ export default async function RenderTemplate(
|
||||
jsreport.headers["Authorization"] = jsrAuth;
|
||||
|
||||
//Query assets that match the template name. Must be in format <<templateName>>.query
|
||||
let { contextData, useShopSpecificTemplate } = await fetchContextData(templateObject, jsrAuth);
|
||||
let { contextData, useShopSpecificTemplate, shopSpecificFolder } = await fetchContextData(templateObject, jsrAuth);
|
||||
|
||||
const { ignoreCustomMargins } = Templates[templateObject.name];
|
||||
|
||||
@@ -74,14 +73,8 @@ export default async function RenderTemplate(
|
||||
...contextData,
|
||||
...templateObject.variables,
|
||||
...templateObject.context,
|
||||
headerpath: `/${InstanceRenderManager({
|
||||
imex: bodyshop.imexshopid,
|
||||
rome: bodyshop.imexshopid
|
||||
})}/header.html`,
|
||||
footerpath: `/${InstanceRenderManager({
|
||||
imex: bodyshop.imexshopid,
|
||||
rome: bodyshop.imexshopid
|
||||
})}/footer.html`,
|
||||
headerpath: shopSpecificFolder ? `/${bodyshop.imexshopid}/header.html` : `/GENERIC/header.html`,
|
||||
footerpath: shopSpecificFolder ? `/${bodyshop.imexshopid}/footer.html` : `/GENERIC/footer.html`,
|
||||
bodyshop: bodyshop,
|
||||
filters: templateObject?.filters,
|
||||
sorters: templateObject?.sorters,
|
||||
@@ -149,11 +142,12 @@ export async function RenderTemplates(templateObjects, bodyshop, renderAsHtml =
|
||||
templateObjects.forEach((template) => {
|
||||
proms.push(
|
||||
(async () => {
|
||||
let { contextData, useShopSpecificTemplate } = await fetchContextData(template, jsrAuth);
|
||||
let { contextData, useShopSpecificTemplate, shopSpecificFolder } = await fetchContextData(template, jsrAuth);
|
||||
unsortedTemplatesAndData.push({
|
||||
templateObject: template,
|
||||
contextData,
|
||||
useShopSpecificTemplate
|
||||
useShopSpecificTemplate,
|
||||
shopSpecificFolder
|
||||
});
|
||||
})()
|
||||
);
|
||||
@@ -248,8 +242,8 @@ export async function RenderTemplates(templateObjects, bodyshop, renderAsHtml =
|
||||
|
||||
// ...rootTemplate.templateObject.variables,
|
||||
// ...rootTemplate.templateObject.context,
|
||||
headerpath: `/${bodyshop.imexshopid}/header.html`,
|
||||
footerpath: `/${bodyshop.imexshopid}/footer.html`,
|
||||
headerpath: rootTemplate.shopSpecificFolder ? `/${bodyshop.imexshopid}/header.html` : `/GENERIC/header.html`,
|
||||
footerpath: rootTemplate.shopSpecificFolder ? `/${bodyshop.imexshopid}/footer.html` : `/GENERIC/footer.html`,
|
||||
bodyshop: bodyshop,
|
||||
offset: bodyshop.timezone
|
||||
}
|
||||
@@ -397,10 +391,10 @@ const fetchContextData = async (templateObject, jsrAuth) => {
|
||||
});
|
||||
contextData = data;
|
||||
}
|
||||
return { contextData, useShopSpecificTemplate };
|
||||
return { contextData, useShopSpecificTemplate, shopSpecificFolder };
|
||||
}
|
||||
|
||||
return await generateTemplate(templateQueryToExecute, templateObject, useShopSpecificTemplate);
|
||||
return await generateTemplate(templateQueryToExecute, templateObject, useShopSpecificTemplate, shopSpecificFolder);
|
||||
};
|
||||
|
||||
//export const displayTemplateInWindow = (html) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { gql } from "@apollo/client";
|
||||
import { Kind, parse, print, visit } from "graphql";
|
||||
import client from "./GraphQLClient";
|
||||
import { gql } from "@apollo/client";
|
||||
|
||||
/* eslint-disable no-loop-func */
|
||||
|
||||
@@ -114,9 +114,10 @@ export function printQuery(query) {
|
||||
* @param templateQueryToExecute
|
||||
* @param templateObject
|
||||
* @param useShopSpecificTemplate
|
||||
* @returns {Promise<{contextData: {}, useShopSpecificTemplate}>}
|
||||
* @param shopSpecificTemplate
|
||||
* @returns {Promise<{contextData: {}, useShopSpecificTemplate, shopSpecificTemplate}>}
|
||||
*/
|
||||
export async function generateTemplate(templateQueryToExecute, templateObject, useShopSpecificTemplate) {
|
||||
export async function generateTemplate(templateQueryToExecute, templateObject, useShopSpecificTemplate, shopSpecificFolder) {
|
||||
// Advanced Filtering and Sorting modifications start here
|
||||
|
||||
// Parse the query and apply the filters and sorters
|
||||
@@ -147,7 +148,7 @@ export async function generateTemplate(templateQueryToExecute, templateObject, u
|
||||
contextData = data;
|
||||
}
|
||||
|
||||
return { contextData, useShopSpecificTemplate };
|
||||
return { contextData, useShopSpecificTemplate, shopSpecificFolder };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
19
client/src/utils/jobNotificationScenarios.js
Normal file
19
client/src/utils/jobNotificationScenarios.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const notificationScenarios = [
|
||||
"job-assigned-to-me",
|
||||
"bill-posted",
|
||||
"critical-parts-status-changed",
|
||||
"part-marked-back-ordered",
|
||||
"new-note-added",
|
||||
"supplement-imported",
|
||||
"schedule-dates-changed",
|
||||
"tasks-updated-created",
|
||||
"new-media-added-reassigned",
|
||||
"new-time-ticket-posted",
|
||||
"intake-delivery-checklist-completed",
|
||||
"job-added-to-production",
|
||||
"job-status-change",
|
||||
"payment-collected-completed",
|
||||
"alternate-transport-changed"
|
||||
];
|
||||
|
||||
export { notificationScenarios };
|
||||
@@ -6,8 +6,8 @@ import eslint from "vite-plugin-eslint";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import InstanceRenderManager from "./src/utils/instanceRenderMgr";
|
||||
import chalk from "chalk";
|
||||
//import { visualizer } from "rollup-plugin-visualizer";
|
||||
|
||||
// Ensure your environment variables are set correctly for Vite 6
|
||||
process.env.VITE_APP_GIT_SHA_DATE = new Date().toLocaleString("en-US", {
|
||||
timeZone: "America/Los_Angeles"
|
||||
});
|
||||
@@ -22,7 +22,7 @@ export const logger = createLogger("info", {
|
||||
export default defineConfig({
|
||||
base: "/",
|
||||
plugins: [
|
||||
//visualizer(),
|
||||
// Ensure all plugins are Vite 6 compatible
|
||||
ViteEjsPlugin((viteConfig) => ({ env: viteConfig.env })),
|
||||
VitePWA({
|
||||
injectRegister: "auto",
|
||||
@@ -31,14 +31,12 @@ export default defineConfig({
|
||||
short_name: InstanceRenderManager({
|
||||
instance: process.env.VITE_APP_INSTANCE,
|
||||
imex: "ImEX Online",
|
||||
rome: "Rome Online",
|
||||
|
||||
rome: "Rome Online"
|
||||
}),
|
||||
name: InstanceRenderManager({
|
||||
instance: process.env.VITE_APP_INSTANCE,
|
||||
imex: "ImEX Online",
|
||||
rome: "Rome Online",
|
||||
|
||||
rome: "Rome Online"
|
||||
}),
|
||||
description: "The ultimate bodyshop management system.",
|
||||
icons: [
|
||||
@@ -46,7 +44,7 @@ export default defineConfig({
|
||||
src: InstanceRenderManager({
|
||||
instance: process.env.VITE_APP_INSTANCE,
|
||||
imex: "favicon.png",
|
||||
rome: "ro-favicon.png",
|
||||
rome: "ro-favicon.png"
|
||||
}),
|
||||
sizes: "64x64 32x32 24x24 16x16",
|
||||
type: "image/x-icon"
|
||||
@@ -55,7 +53,7 @@ export default defineConfig({
|
||||
src: InstanceRenderManager({
|
||||
instance: process.env.VITE_APP_INSTANCE,
|
||||
imex: "logo192.png",
|
||||
rome: "logo192.png",
|
||||
rome: "logo192.png"
|
||||
}),
|
||||
type: "image/png",
|
||||
sizes: "192x192"
|
||||
@@ -64,7 +62,7 @@ export default defineConfig({
|
||||
src: InstanceRenderManager({
|
||||
instance: process.env.VITE_APP_INSTANCE,
|
||||
imex: "logo512.png",
|
||||
rome: "ro-favicon.png",
|
||||
rome: "ro-favicon.png"
|
||||
}),
|
||||
type: "image/png",
|
||||
sizes: "512x512"
|
||||
@@ -73,7 +71,7 @@ export default defineConfig({
|
||||
theme_color: InstanceRenderManager({
|
||||
instance: process.env.VITE_APP_INSTANCE,
|
||||
imex: "#1890ff",
|
||||
rome: "#fff",
|
||||
rome: "#fff"
|
||||
}),
|
||||
background_color: "#fff",
|
||||
gcm_sender_id: "103953800507"
|
||||
@@ -204,8 +202,10 @@ export default defineConfig({
|
||||
"react-redux"
|
||||
],
|
||||
esbuildOptions: {
|
||||
// Update for Vite 6: Use proper file extensions
|
||||
loader: {
|
||||
".js": "jsx"
|
||||
".jsx": "jsx",
|
||||
".tsx": "tsx"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -31,6 +31,14 @@
|
||||
headers:
|
||||
- name: x-imex-auth
|
||||
value_from_env: DATAPUMP_AUTH
|
||||
- name: Rome Usage Report
|
||||
webhook: '{{HASURA_API_URL}}/data/usagereport'
|
||||
schedule: 0 12 * * 5
|
||||
include_in_metadata: true
|
||||
payload: {}
|
||||
headers:
|
||||
- name: x-imex-auth
|
||||
value_from_env: DATAPUMP_AUTH
|
||||
- name: Task Reminders
|
||||
webhook: '{{HASURA_API_URL}}/tasks-remind-handler'
|
||||
schedule: '*/15 * * * *'
|
||||
|
||||
@@ -697,12 +697,6 @@
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
body:
|
||||
action: transform
|
||||
template: |-
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
@@ -1958,6 +1952,27 @@
|
||||
_eq: X-Hasura-User-Id
|
||||
- active:
|
||||
_eq: true
|
||||
event_triggers:
|
||||
- name: notifications_docuemtns
|
||||
definition:
|
||||
enable_manual: false
|
||||
update:
|
||||
columns:
|
||||
- jobid
|
||||
retry_conf:
|
||||
interval_sec: 10
|
||||
num_retries: 0
|
||||
timeout_sec: 60
|
||||
webhook_from_env: HASURA_API_URL
|
||||
headers:
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/notifications/events/handleDocumentsChange'
|
||||
version: 2
|
||||
- table:
|
||||
name: email_audit_trail
|
||||
schema: public
|
||||
@@ -2846,13 +2861,12 @@
|
||||
- role: user
|
||||
permission:
|
||||
check:
|
||||
user:
|
||||
_and:
|
||||
- associations:
|
||||
active:
|
||||
_eq: true
|
||||
- authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
job:
|
||||
bodyshop:
|
||||
associations:
|
||||
user:
|
||||
authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
columns:
|
||||
- user_email
|
||||
- created_at
|
||||
@@ -2868,13 +2882,12 @@
|
||||
- id
|
||||
- jobid
|
||||
filter:
|
||||
user:
|
||||
_and:
|
||||
- associations:
|
||||
active:
|
||||
_eq: true
|
||||
- authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
job:
|
||||
bodyshop:
|
||||
associations:
|
||||
user:
|
||||
authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
comment: ""
|
||||
update_permissions:
|
||||
- role: user
|
||||
@@ -2885,26 +2898,24 @@
|
||||
- id
|
||||
- jobid
|
||||
filter:
|
||||
user:
|
||||
_and:
|
||||
- associations:
|
||||
active:
|
||||
_eq: true
|
||||
- authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
job:
|
||||
bodyshop:
|
||||
associations:
|
||||
user:
|
||||
authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
check: null
|
||||
comment: ""
|
||||
delete_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
filter:
|
||||
user:
|
||||
_and:
|
||||
- associations:
|
||||
active:
|
||||
_eq: true
|
||||
- authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
job:
|
||||
bodyshop:
|
||||
associations:
|
||||
user:
|
||||
authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
comment: ""
|
||||
- table:
|
||||
name: joblines
|
||||
@@ -3223,6 +3234,29 @@
|
||||
_eq: X-Hasura-User-Id
|
||||
- active:
|
||||
_eq: true
|
||||
event_triggers:
|
||||
- name: notifications_joblines
|
||||
definition:
|
||||
enable_manual: false
|
||||
insert:
|
||||
columns: '*'
|
||||
update:
|
||||
columns:
|
||||
- critical
|
||||
retry_conf:
|
||||
interval_sec: 10
|
||||
num_retries: 0
|
||||
timeout_sec: 60
|
||||
webhook_from_env: HASURA_API_URL
|
||||
headers:
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/notifications/events/handleJobLinesChange'
|
||||
version: 1
|
||||
- table:
|
||||
name: joblines_status
|
||||
schema: public
|
||||
@@ -3369,6 +3403,13 @@
|
||||
table:
|
||||
name: job_conversations
|
||||
schema: public
|
||||
- name: job_watchers
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: jobid
|
||||
table:
|
||||
name: job_watchers
|
||||
schema: public
|
||||
- name: joblines
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
@@ -4473,10 +4514,7 @@
|
||||
request_transform:
|
||||
body:
|
||||
action: transform
|
||||
template: |-
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
template: "{\r\n \"event\": {\r\n \"session_variables\": {\r\n \"x-hasura-user-id\": {{$body.event.session_variables.x-hasura-user-id}}\r\n }, \r\n \"op\": \"UPDATE\",\r\n \"data\": {\r\n \"old\": {\r\n \"id\": {{$body.event.data.old.id}},\r\n \"ro_number\": {{$body.event.data.old.ro_number}},\r\n \"queued_for_parts\": {{$body.event.data.old.queued_for_parts}},\r\n \"employee_prep\": {{$body.event.data.old.employee_prep}},\r\n \"clm_total\": {{$body.event.data.old.clm_total}},\r\n \"towin\": {{$body.event.data.old.towin}},\r\n \"employee_body\": {{$body.event.data.old.employee_body}},\r\n \"converted\": {{$body.event.data.old.converted}},\r\n \"scheduled_in\": {{$body.event.data.old.scheduled_in}},\r\n \"scheduled_completion\": {{$body.event.data.old.scheduled_completion}},\r\n \"scheduled_delivery\": {{$body.event.data.old.scheduled_delivery}},\r\n \"actual_delivery\": {{$body.event.data.old.actual_delivery}},\r\n \"actual_completion\": {{$body.event.data.old.actual_completion}},\r\n \"alt_transport\": {{$body.event.data.old.alt_transport}},\r\n \"date_exported\": {{$body.event.data.old.date_exported}},\r\n \"status\": {{$body.event.data.old.status}},\r\n \"employee_csr\": {{$body.event.data.old.employee_csr}},\r\n \"actual_in\": {{$body.event.data.old.actual_in}},\r\n \"deliverchecklist\": {{$body.event.data.old.deliverchecklist}},\r\n \"comment\": {{$body.event.data.old.comment}},\r\n \"employee_refinish\": {{$body.event.data.old.employee_refinish}},\r\n \"inproduction\": {{$body.event.data.old.inproduction}},\r\n \"production_vars\": {{$body.event.data.old.production_vars}},\r\n \"intakechecklist\": {{$body.event.data.old.intakechecklist}},\r\n \"cieca_ttl\": {{$body.event.data.old.cieca_ttl}},\r\n \"date_invoiced\": {{$body.event.data.old.date_invoiced}}\r\n },\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"ro_number\": {{$body.event.data.old.ro_number}},\r\n \"queued_for_parts\": {{$body.event.data.new.queued_for_parts}},\r\n \"employee_prep\": {{$body.event.data.new.employee_prep}},\r\n \"clm_total\": {{$body.event.data.new.clm_total}},\r\n \"towin\": {{$body.event.data.new.towin}},\r\n \"employee_body\": {{$body.event.data.new.employee_body}},\r\n \"converted\": {{$body.event.data.new.converted}},\r\n \"scheduled_in\": {{$body.event.data.new.scheduled_in}},\r\n \"scheduled_completion\": {{$body.event.data.new.scheduled_completion}},\r\n \"scheduled_delivery\": {{$body.event.data.new.scheduled_delivery}},\r\n \"actual_delivery\": {{$body.event.data.new.actual_delivery}},\r\n \"actual_completion\": {{$body.event.data.new.actual_completion}},\r\n \"alt_transport\": {{$body.event.data.new.alt_transport}},\r\n \"date_exported\": {{$body.event.data.new.date_exported}},\r\n \"status\": {{$body.event.data.new.status}},\r\n \"employee_csr\": {{$body.event.data.new.employee_csr}},\r\n \"actual_in\": {{$body.event.data.new.actual_in}},\r\n \"deliverchecklist\": {{$body.event.data.new.deliverchecklist}},\r\n \"comment\": {{$body.event.data.new.comment}},\r\n \"employee_refinish\": {{$body.event.data.new.employee_refinish}},\r\n \"inproduction\": {{$body.event.data.new.inproduction}},\r\n \"production_vars\": {{$body.event.data.new.production_vars}},\r\n \"intakechecklist\": {{$body.event.data.new.intakechecklist}},\r\n \"cieca_ttl\": {{$body.event.data.new.cieca_ttl}},\r\n \"date_invoiced\": {{$body.event.data.new.date_invoiced}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_jobs\"\r\n },\r\n \"table\": {\r\n \"schema\": \"public\",\r\n \"name\": \"jobs\"\r\n }\r\n}\r\n"
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
@@ -4825,6 +4863,26 @@
|
||||
_eq: X-Hasura-User-Id
|
||||
- active:
|
||||
_eq: true
|
||||
event_triggers:
|
||||
- name: notifications_notes
|
||||
definition:
|
||||
enable_manual: false
|
||||
insert:
|
||||
columns: '*'
|
||||
retry_conf:
|
||||
interval_sec: 10
|
||||
num_retries: 0
|
||||
timeout_sec: 60
|
||||
webhook_from_env: HASURA_API_URL
|
||||
headers:
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/notifications/events/handleNotesChange'
|
||||
version: 2
|
||||
- table:
|
||||
name: notifications
|
||||
schema: public
|
||||
@@ -4835,46 +4893,79 @@
|
||||
- name: job
|
||||
using:
|
||||
foreign_key_constraint_on: jobid
|
||||
insert_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
check:
|
||||
job:
|
||||
bodyshop:
|
||||
associations:
|
||||
_and:
|
||||
- user:
|
||||
authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
- active:
|
||||
_eq: true
|
||||
columns:
|
||||
- scenario_meta
|
||||
- scenario_text
|
||||
- fcm_text
|
||||
- created_at
|
||||
- read
|
||||
- updated_at
|
||||
- associationid
|
||||
- id
|
||||
- jobid
|
||||
comment: ""
|
||||
select_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- associationid
|
||||
- scenario_meta
|
||||
- scenario_text
|
||||
- fcm_text
|
||||
- created_at
|
||||
- fcm_data
|
||||
- fcm_message
|
||||
- fcm_title
|
||||
- read
|
||||
- updated_at
|
||||
- associationid
|
||||
- id
|
||||
- jobid
|
||||
- meta
|
||||
- read
|
||||
- ui_translation_meta
|
||||
- ui_translation_string
|
||||
- updated_at
|
||||
filter:
|
||||
association:
|
||||
_and:
|
||||
- active:
|
||||
_eq: true
|
||||
- user:
|
||||
authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
job:
|
||||
bodyshop:
|
||||
associations:
|
||||
_and:
|
||||
- user:
|
||||
authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
- active:
|
||||
_eq: true
|
||||
allow_aggregations: true
|
||||
comment: ""
|
||||
update_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- meta
|
||||
- scenario_meta
|
||||
- scenario_text
|
||||
- fcm_text
|
||||
- created_at
|
||||
- read
|
||||
filter:
|
||||
association:
|
||||
_and:
|
||||
- active:
|
||||
_eq: true
|
||||
- user:
|
||||
authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
check: null
|
||||
- updated_at
|
||||
- associationid
|
||||
- id
|
||||
- jobid
|
||||
filter: {}
|
||||
check:
|
||||
job:
|
||||
bodyshop:
|
||||
associations:
|
||||
_and:
|
||||
- user:
|
||||
authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
- active:
|
||||
_eq: true
|
||||
comment: ""
|
||||
- table:
|
||||
name: owners
|
||||
@@ -5648,6 +5739,25 @@
|
||||
- active:
|
||||
_eq: true
|
||||
event_triggers:
|
||||
- name: notifications_payments
|
||||
definition:
|
||||
enable_manual: false
|
||||
insert:
|
||||
columns: '*'
|
||||
retry_conf:
|
||||
interval_sec: 10
|
||||
num_retries: 0
|
||||
timeout_sec: 60
|
||||
webhook_from_env: HASURA_API_URL
|
||||
headers:
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/notifications/events/handlePaymentsChange'
|
||||
version: 2
|
||||
- name: os_payments
|
||||
definition:
|
||||
delete:
|
||||
@@ -6119,9 +6229,13 @@
|
||||
columns: '*'
|
||||
update:
|
||||
columns:
|
||||
- joblineid
|
||||
- assigned_to
|
||||
- partsorderid
|
||||
- completed
|
||||
- description
|
||||
- billid
|
||||
- priority
|
||||
retry_conf:
|
||||
interval_sec: 10
|
||||
num_retries: 0
|
||||
@@ -6131,12 +6245,6 @@
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
body:
|
||||
action: transform
|
||||
template: |-
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
@@ -6313,12 +6421,6 @@
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
body:
|
||||
action: transform
|
||||
template: |-
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- alter table "public"."notifications" add column "html_body" text
|
||||
-- not null;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."notifications" add column "html_body" text
|
||||
not null;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" alter column "fcm_title" set not null;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" alter column "fcm_title" drop not null;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" alter column "fcm_message" set not null;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" alter column "fcm_message" drop not null;
|
||||
@@ -0,0 +1,3 @@
|
||||
comment on column "public"."notifications"."html_body" is E'Real Time Notifications System';
|
||||
alter table "public"."notifications" alter column "html_body" drop not null;
|
||||
alter table "public"."notifications" add column "html_body" text;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" drop column "html_body" cascade;
|
||||
@@ -0,0 +1,4 @@
|
||||
comment on column "public"."notifications"."fcm_data" is E'Real Time Notifications System';
|
||||
alter table "public"."notifications" alter column "fcm_data" set default jsonb_build_object();
|
||||
alter table "public"."notifications" alter column "fcm_data" drop not null;
|
||||
alter table "public"."notifications" add column "fcm_data" jsonb;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" drop column "fcm_data" cascade;
|
||||
@@ -0,0 +1,3 @@
|
||||
comment on column "public"."notifications"."fcm_message" is E'Real Time Notifications System';
|
||||
alter table "public"."notifications" alter column "fcm_message" drop not null;
|
||||
alter table "public"."notifications" add column "fcm_message" text;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" drop column "fcm_message" cascade;
|
||||
@@ -0,0 +1,3 @@
|
||||
comment on column "public"."notifications"."ui_translation_string" is E'Real Time Notifications System';
|
||||
alter table "public"."notifications" alter column "ui_translation_string" drop not null;
|
||||
alter table "public"."notifications" add column "ui_translation_string" text;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" drop column "ui_translation_string" cascade;
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" rename column "fcm_text" to "fcm_title";
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" rename column "fcm_title" to "fcm_text";
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" rename column "scenario_text" to "ui_translation_meta";
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" rename column "ui_translation_meta" to "scenario_text";
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" rename column "scenario_meta" to "meta";
|
||||
@@ -0,0 +1 @@
|
||||
alter table "public"."notifications" rename column "meta" to "scenario_meta";
|
||||
3769
package-lock.json
generated
3769
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user