Compare commits
236 Commits
feature/IO
...
feature/IO
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a9b0cae69 | ||
|
|
87a57e057d | ||
|
|
69da6bccf7 | ||
|
|
f2e399f0df | ||
|
|
9a1f0e1e42 | ||
|
|
0675f84386 | ||
|
|
6994e44bd3 | ||
|
|
0d6d8e9d7c | ||
|
|
f7c01d5b35 | ||
|
|
e3d7ebd7d8 | ||
|
|
acea8d2fee | ||
|
|
5f0b63a192 | ||
|
|
a36db7cee7 | ||
|
|
7a5ac739ab | ||
|
|
e2297be0af | ||
|
|
a3c0e25407 | ||
|
|
73c4983342 | ||
|
|
166e1e4030 | ||
|
|
a6c863f67d | ||
|
|
5fa7377121 | ||
|
|
f21ba8e087 | ||
|
|
169b5265c3 | ||
|
|
d56d1f369c | ||
|
|
360a1954f4 | ||
|
|
72ee621303 | ||
|
|
478e5fb569 | ||
|
|
6b047418cc | ||
|
|
87db292e5d | ||
|
|
9ef8440e64 | ||
|
|
8ae3b28cb6 | ||
|
|
87a55028e1 | ||
|
|
8045c228d6 | ||
|
|
b97bc0df8e | ||
|
|
0d80854196 | ||
|
|
029fb58f48 | ||
|
|
85929b0bb1 | ||
|
|
dc234e4d72 | ||
|
|
cf86430aa9 | ||
|
|
212fc4a7cc | ||
|
|
8de7db60e6 | ||
|
|
d6df5af1a4 | ||
|
|
8d36ad3589 | ||
|
|
9061821347 | ||
|
|
aa6fc78aa0 | ||
|
|
77e4d72a54 | ||
|
|
1fad3968bb | ||
|
|
1d84dd1a83 | ||
|
|
9a5a2c7497 | ||
|
|
a492909ad7 | ||
|
|
14a885b443 | ||
|
|
d5bd9d9b59 | ||
|
|
774f1fea68 | ||
|
|
6e6cabbd63 | ||
|
|
480838b1dc | ||
|
|
57930005b2 | ||
|
|
e7bbb96dc3 | ||
|
|
ffadd31a5f | ||
|
|
235527140c | ||
|
|
af6139dcaf | ||
|
|
ef22ba3d2c | ||
|
|
11ff8e91c7 | ||
|
|
f120116e52 | ||
|
|
71dd138f2f | ||
|
|
36f4cc8cb8 | ||
|
|
d2944ff902 | ||
|
|
46af401e9b | ||
|
|
3cbcbb92eb | ||
|
|
02e6c6007c | ||
|
|
2cee5f1944 | ||
|
|
1c1f0a16e2 | ||
|
|
ef695776cd | ||
|
|
53580fbc78 | ||
|
|
21335d4e8c | ||
|
|
8b98206e63 | ||
|
|
9b545d6c8c | ||
|
|
fbe674a2e5 | ||
|
|
2a65cb5025 | ||
|
|
14cffd3ad4 | ||
|
|
b4a3960eac | ||
|
|
358503f9ef | ||
|
|
25a9e6cea1 | ||
|
|
9567cd88b1 | ||
|
|
e40e0bbb8f | ||
|
|
8fdd07827e | ||
|
|
059067bc61 | ||
|
|
f8ae6dc5af | ||
|
|
ac2bb42124 | ||
|
|
b149f70b6f | ||
|
|
ec8a413ed1 | ||
|
|
76ec755d07 | ||
|
|
07faa5eec2 | ||
|
|
7bbbf5934a | ||
|
|
fd7850b551 | ||
|
|
2b76f8a12d | ||
|
|
aa073cfd68 | ||
|
|
03863ce838 | ||
|
|
1b22697429 | ||
|
|
4fc3fbdcc0 | ||
|
|
163978930f | ||
|
|
c75e27e018 | ||
|
|
555bedbb6c | ||
|
|
a57abec81b | ||
|
|
b9df4c2587 | ||
|
|
15686bdab8 | ||
|
|
175e2097fa | ||
|
|
359c4c75a1 | ||
|
|
86aa5bf5e7 | ||
|
|
35b92570e5 | ||
|
|
b5c03b8cf0 | ||
|
|
3c45519457 | ||
|
|
dc60b8d18e | ||
|
|
ea75ac49aa | ||
|
|
f3c6c7f004 | ||
|
|
65fb73ae82 | ||
|
|
617e39eb17 | ||
|
|
f4a3b75a86 | ||
|
|
c0ffda27cf | ||
|
|
f51fa08961 | ||
|
|
ba63e8054f | ||
|
|
32813032e6 | ||
|
|
a5904f55aa | ||
|
|
f6acc1107c | ||
|
|
9b871149ac | ||
|
|
9a71779cfe | ||
|
|
5bd6f0453d | ||
|
|
f6328d10f7 | ||
|
|
3766c3d938 | ||
|
|
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 | ||
|
|
07b18836f5 | ||
|
|
ff08d19d79 | ||
|
|
68584243f4 | ||
|
|
994d7e17aa | ||
|
|
fd1dd6dddd | ||
|
|
1e9b82ba1e | ||
|
|
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
|
||||
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
name: Install Dependencies
|
||||
command: npm i
|
||||
|
||||
- run: npm run build:production:imex
|
||||
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:production:imex
|
||||
|
||||
- aws-cli/setup:
|
||||
aws_access_key_id: AWS_ACCESS_KEY_ID
|
||||
@@ -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,8 +150,8 @@ jobs:
|
||||
pipeline_number: << pipeline.number >>
|
||||
rome-app-build:
|
||||
docker:
|
||||
- image: cimg/node:18.18.2
|
||||
|
||||
- image: cimg/node:22.13.1
|
||||
resource_class: large
|
||||
working_directory: ~/repo/client
|
||||
|
||||
steps:
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
name: Install Dependencies
|
||||
command: npm i
|
||||
|
||||
- run: npm run build:production:rome
|
||||
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:production:rome
|
||||
|
||||
- aws-cli/setup:
|
||||
aws_access_key_id: AWS_ACCESS_KEY_ID
|
||||
@@ -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,8 +208,8 @@ jobs:
|
||||
|
||||
test-rome-app-build:
|
||||
docker:
|
||||
- image: cimg/node:18.18.2
|
||||
|
||||
- image: cimg/node:22.13.1
|
||||
resource_class: large
|
||||
working_directory: ~/repo/client
|
||||
|
||||
steps:
|
||||
@@ -219,7 +219,7 @@ jobs:
|
||||
name: Install Dependencies
|
||||
command: npm i
|
||||
|
||||
- run: npm run build:test:rome
|
||||
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:test:rome
|
||||
|
||||
- aws-cli/setup:
|
||||
aws_access_key_id: AWS_ACCESS_KEY_ID
|
||||
@@ -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
|
||||
|
||||
@@ -277,7 +277,7 @@ jobs:
|
||||
name: Install Dependencies
|
||||
command: npm i
|
||||
|
||||
- run: npm run build:test:imex
|
||||
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:test:imex
|
||||
|
||||
- aws-s3/sync:
|
||||
from: build
|
||||
@@ -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
|
||||
|
||||
@@ -298,7 +298,7 @@ jobs:
|
||||
name: Install Dependencies
|
||||
command: npm i
|
||||
|
||||
- run: npm run build:test:imex
|
||||
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:test:imex
|
||||
|
||||
- aws-cli/setup:
|
||||
aws_access_key_id: AWS_ACCESS_KEY_ID
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -9,6 +9,6 @@ VITE_APP_CLOUDINARY_THUMB_TRANSFORMATIONS=c_fill,h_250,w_250
|
||||
VITE_APP_FIREBASE_PUBLIC_VAPID_KEY='BG3tzU7L2BXlGZ_3VLK4PNaRceoEXEnmHfxcVbRMF5o5g05ejslhVPki9kBM9cBBT-08Ad9kN3HSpS6JmrWD6h4'
|
||||
VITE_APP_STRIPE_PUBLIC_KEY=pk_test_51GqB4TJl3nQjrZ0wCQWAxAhlNF8jKe0tipIa6ExBaxwJGitwvFsIZUEua4dUzaMIAuXp4qwYHXx7lgjyQSwP0Pe900vzm38C7g
|
||||
VITE_APP_AXIOS_BASE_API_URL=/api/
|
||||
VITE_APP_REPORTS_SERVER_URL=https://reports3.test.imex.online
|
||||
VITE_APP_REPORTS_SERVER_URL=https://reports.test.imex.online
|
||||
VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
|
||||
VITE_APP_INSTANCE=IMEX
|
||||
|
||||
@@ -10,7 +10,7 @@ VITE_APP_CLOUDINARY_THUMB_TRANSFORMATIONS=c_fill,h_250,w_250
|
||||
VITE_APP_FIREBASE_PUBLIC_VAPID_KEY='BP1B7ZTYpn-KMt6nOxlld6aS8Skt3Q7ZLEqP0hAvGHxG4UojPYiXZ6kPlzZkUC5jH-EcWXomTLtmadAIxurfcHo'
|
||||
VITE_APP_STRIPE_PUBLIC_KEY=pk_test_51GqB4TJl3nQjrZ0wCQWAxAhlNF8jKe0tipIa6ExBaxwJGitwvFsIZUEua4dUzaMIAuXp4qwYHXx7lgjyQSwP0Pe900vzm38C7g
|
||||
VITE_APP_AXIOS_BASE_API_URL=/api/
|
||||
VITE_APP_REPORTS_SERVER_URL=https://reports3.test.imex.online
|
||||
VITE_APP_REPORTS_SERVER_URL=https://reports.test.romeonline.io
|
||||
VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
|
||||
VITE_APP_COUNTRY=USA
|
||||
VITE_APP_INSTANCE=ROME
|
||||
|
||||
@@ -9,7 +9,7 @@ VITE_APP_CLOUDINARY_THUMB_TRANSFORMATIONS=c_fill,h_250,w_250
|
||||
VITE_APP_FIREBASE_PUBLIC_VAPID_KEY='BN2GcDPjipR5MTEosO5dT4CfQ3cmrdBIsI4juoOQrRijn_5aRiHlwj1mlq0W145mOusx6xynEKl_tvYJhpCc9lo'
|
||||
VITE_APP_STRIPE_PUBLIC_KEY=pk_test_51GqB4TJl3nQjrZ0wCQWAxAhlNF8jKe0tipIa6ExBaxwJGitwvFsIZUEua4dUzaMIAuXp4qwYHXx7lgjyQSwP0Pe900vzm38C7g
|
||||
VITE_APP_AXIOS_BASE_API_URL=https://api.test.imex.online/
|
||||
VITE_APP_REPORTS_SERVER_URL=https://reports3.test.imex.online
|
||||
VITE_APP_REPORTS_SERVER_URL=https://reports.test.imex.online
|
||||
VITE_APP_IS_TEST=true
|
||||
VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
|
||||
VITE_APP_INSTANCE=IMEX
|
||||
|
||||
9638
client/package-lock.json
generated
9638
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,77 +8,78 @@
|
||||
"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",
|
||||
"@sentry/react": "^7.114.0",
|
||||
"@reduxjs/toolkit": "^2.6.0",
|
||||
"@sentry/cli": "^2.42.2",
|
||||
"@sentry/react": "^9.3.0",
|
||||
"@sentry/vite-plugin": "^3.2.2",
|
||||
"@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",
|
||||
"apollo-link-sentry": "^4.1.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"
|
||||
},
|
||||
@@ -98,8 +99,7 @@
|
||||
"test": "cypress open",
|
||||
"eject": "react-scripts eject",
|
||||
"madge": "madge --image ./madge-graph.svg --extensions js,jsx,ts,tsx --circular .",
|
||||
"eulaize": "node src/utils/eulaize.js",
|
||||
"sentry:sourcemaps:imex": "sentry-cli sourcemaps inject --org imex --project imexonline ./build && sentry-cli sourcemaps upload --org imex --project imexonline ./build"
|
||||
"eulaize": "node src/utils/eulaize.js"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@@ -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",
|
||||
"@sentry/webpack-plugin": "^2.22.4",
|
||||
"@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": "^3.2.2",
|
||||
"@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
|
||||
@@ -21,7 +21,7 @@ import "./App.styles.scss";
|
||||
import Eula from "../components/eula/eula.component";
|
||||
import InstanceRenderMgr from "../utils/instanceRenderMgr";
|
||||
import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx";
|
||||
import { SocketProvider } from "../contexts/SocketIO/socketContext.jsx";
|
||||
import { SocketProvider } from "../contexts/SocketIO/useSocket.jsx";
|
||||
import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx";
|
||||
|
||||
const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component"));
|
||||
@@ -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} currentUser={currentUser}>
|
||||
<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} currentUser={currentUser}>
|
||||
<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;
|
||||
@@ -173,3 +180,13 @@
|
||||
.muted-button:hover {
|
||||
color: darkgrey;
|
||||
}
|
||||
|
||||
.notification-alert-unordered-list {
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
.notification-alert-unordered-list-item {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/useSocket.jsx";
|
||||
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/useSocket.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/useSocket.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/useSocket.jsx";
|
||||
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/useSocket.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/useSocket.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/useSocket.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/useSocket.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/useSocket.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);
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ class ErrorBoundary extends React.Component {
|
||||
<Row>
|
||||
<Col offset={6} span={12}>
|
||||
<Collapse bordered={false}>
|
||||
<Collapse.Panel header={t("general.labels.errors")}>
|
||||
<Collapse.Panel key="errors-panel" header={t("general.labels.errors")}>
|
||||
<div>
|
||||
<strong>{this.state.error.message}</strong>
|
||||
</div>
|
||||
|
||||
@@ -78,9 +78,7 @@ const Eula = ({ currentEula, currentUser, acceptEula }) => {
|
||||
} catch (err) {
|
||||
notification.error({
|
||||
message: t("eula.errors.acceptance.message"),
|
||||
description: t("eula.errors.acceptance.description"),
|
||||
placement: "bottomRight",
|
||||
duration: 5000
|
||||
description: t("eula.errors.acceptance.description")
|
||||
});
|
||||
console.log(`${t("eula.errors.acceptance.message")}`);
|
||||
console.dir({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Icon, {
|
||||
import {
|
||||
BankFilled,
|
||||
BarChartOutlined,
|
||||
BellFilled,
|
||||
CarFilled,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleFilled,
|
||||
@@ -25,8 +26,10 @@ import Icon, {
|
||||
UnorderedListOutlined,
|
||||
UserOutlined
|
||||
} from "@ant-design/icons";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import { Layout, Menu, Space } from "antd";
|
||||
import { Badge, Layout, Menu, Spin } from "antd";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BsKanban } from "react-icons/bs";
|
||||
import { FaCalendarAlt, FaCarCrash, FaCreditCard, FaFileInvoiceDollar, FaTasks } from "react-icons/fa";
|
||||
@@ -37,14 +40,19 @@ import { RiSurveyLine } from "react-icons/ri";
|
||||
import { connect } from "react-redux";
|
||||
import { Link } from "react-router-dom";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
|
||||
import { selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors";
|
||||
import { setModalContext } from "../../redux/modals/modals.actions";
|
||||
import { signOutStart } from "../../redux/user/user.actions";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import day from "../../utils/day.js";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
||||
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
|
||||
import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
|
||||
|
||||
// Redux mappings
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser,
|
||||
recentItems: selectRecentItems,
|
||||
@@ -53,43 +61,13 @@ const mapStateToProps = createStructuredSelector({
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
setBillEnterContext: (context) =>
|
||||
dispatch(
|
||||
setModalContext({
|
||||
context: context,
|
||||
modal: "billEnter"
|
||||
})
|
||||
),
|
||||
setTimeTicketContext: (context) =>
|
||||
dispatch(
|
||||
setModalContext({
|
||||
context: context,
|
||||
modal: "timeTicket"
|
||||
})
|
||||
),
|
||||
setPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "payment" })),
|
||||
setReportCenterContext: (context) =>
|
||||
dispatch(
|
||||
setModalContext({
|
||||
context: context,
|
||||
modal: "reportCenter"
|
||||
})
|
||||
),
|
||||
setBillEnterContext: (context) => dispatch(setModalContext({ context, modal: "billEnter" })),
|
||||
setTimeTicketContext: (context) => dispatch(setModalContext({ context, modal: "timeTicket" })),
|
||||
setPaymentContext: (context) => dispatch(setModalContext({ context, modal: "payment" })),
|
||||
setReportCenterContext: (context) => dispatch(setModalContext({ context, modal: "reportCenter" })),
|
||||
signOutStart: () => dispatch(signOutStart()),
|
||||
setCardPaymentContext: (context) =>
|
||||
dispatch(
|
||||
setModalContext({
|
||||
context: context,
|
||||
modal: "cardPayment"
|
||||
})
|
||||
),
|
||||
setTaskUpsertContext: (context) =>
|
||||
dispatch(
|
||||
setModalContext({
|
||||
context: context,
|
||||
modal: "taskUpsert"
|
||||
})
|
||||
)
|
||||
setCardPaymentContext: (context) => dispatch(setModalContext({ context, modal: "cardPayment" })),
|
||||
setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" }))
|
||||
});
|
||||
|
||||
function Header({
|
||||
@@ -115,24 +93,81 @@ function Header({
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { isConnected, scenarioNotificationsOn } = useSocket();
|
||||
const [notificationVisible, setNotificationVisible] = useState(false);
|
||||
const baseTitleRef = useRef(document.title || "");
|
||||
const lastSetTitleRef = useRef("");
|
||||
const userAssociationId = bodyshop?.associations?.[0]?.id;
|
||||
|
||||
// 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 {
|
||||
data: unreadData,
|
||||
refetch: refetchUnread,
|
||||
loading: unreadLoading
|
||||
} = useQuery(GET_UNREAD_COUNT, {
|
||||
variables: { associationid: userAssociationId },
|
||||
fetchPolicy: "network-only",
|
||||
pollInterval: isConnected ? 0 : day.duration(60, "seconds").asMilliseconds(),
|
||||
skip: !userAssociationId || !scenarioNotificationsOn
|
||||
});
|
||||
|
||||
const accountingChildren = [];
|
||||
const unreadCount = unreadData?.notifications_aggregate?.aggregate?.count ?? 0;
|
||||
|
||||
accountingChildren.push(
|
||||
useEffect(() => {
|
||||
if (userAssociationId) {
|
||||
refetchUnread().catch((e) => console.error(`Error fetching unread notifications: ${e?.message}`));
|
||||
}
|
||||
}, [refetchUnread, userAssociationId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected && !unreadLoading && userAssociationId) {
|
||||
refetchUnread().catch((e) => console.error(`Error fetching unread notifications: ${e?.message}`));
|
||||
}
|
||||
}, [isConnected, unreadLoading, refetchUnread, userAssociationId]);
|
||||
|
||||
// Keep The unread count in the title.
|
||||
useEffect(() => {
|
||||
const updateTitle = () => {
|
||||
const currentTitle = document.title;
|
||||
// Check if the current title differs from what we last set
|
||||
if (currentTitle !== lastSetTitleRef.current) {
|
||||
// Extract base title by removing any unread count prefix
|
||||
const baseTitleMatch = currentTitle.match(/^\(\d+\)\s*(.*)$/);
|
||||
baseTitleRef.current = baseTitleMatch ? baseTitleMatch[1] : currentTitle;
|
||||
}
|
||||
|
||||
// Apply unread count to the base title
|
||||
const newTitle = unreadCount > 0 ? `(${unreadCount}) ${baseTitleRef.current}` : baseTitleRef.current;
|
||||
|
||||
// Only update if the title has changed to avoid unnecessary DOM writes
|
||||
if (document.title !== newTitle) {
|
||||
document.title = newTitle;
|
||||
lastSetTitleRef.current = newTitle; // Store what we set
|
||||
}
|
||||
};
|
||||
|
||||
// Initial update
|
||||
updateTitle();
|
||||
|
||||
// Poll every 100ms to catch child component changes
|
||||
const interval = setInterval(updateTitle, 100);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
document.title = baseTitleRef.current; // Reset to base title on unmount
|
||||
};
|
||||
}, [unreadCount]); // Re-run when unreadCount changes
|
||||
|
||||
const handleNotificationClick = (e) => {
|
||||
setNotificationVisible(!notificationVisible);
|
||||
if (handleMenuClick) handleMenuClick(e);
|
||||
};
|
||||
|
||||
const accountingChildren = [
|
||||
{
|
||||
key: "bills",
|
||||
id: "header-accounting-bills",
|
||||
icon: <Icon component={FaFileInvoiceDollar} />,
|
||||
icon: <FaFileInvoiceDollar />,
|
||||
label: (
|
||||
<Link to="/manage/bills">
|
||||
<LockWrapper featureName="bills" bodyshop={bodyshop}>
|
||||
@@ -144,92 +179,60 @@ function Header({
|
||||
{
|
||||
key: "enterbills",
|
||||
id: "header-accounting-enterbills",
|
||||
icon: <Icon component={GiPayMoney} />,
|
||||
icon: <GiPayMoney />,
|
||||
label: (
|
||||
<Space>
|
||||
<LockWrapper featureName="bills" bodyshop={bodyshop}>
|
||||
{t("menus.header.enterbills")}
|
||||
</LockWrapper>
|
||||
</Space>
|
||||
<LockWrapper featureName="bills" bodyshop={bodyshop}>
|
||||
{t("menus.header.enterbills")}
|
||||
</LockWrapper>
|
||||
),
|
||||
onClick: () => {
|
||||
onClick: () =>
|
||||
HasFeatureAccess({ featureName: "bills", bodyshop }) &&
|
||||
setBillEnterContext({
|
||||
actions: {},
|
||||
context: {}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (Simple_Inventory.treatment === "on") {
|
||||
accountingChildren.push(
|
||||
{
|
||||
type: "divider"
|
||||
},
|
||||
{
|
||||
key: "inventory",
|
||||
id: "header-accounting-inventory",
|
||||
icon: <Icon component={FaFileInvoiceDollar} />,
|
||||
label: <Link to="/manage/inventory">{t("menus.header.inventory")}</Link>
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
accountingChildren.push(
|
||||
{
|
||||
type: "divider"
|
||||
setBillEnterContext({
|
||||
actions: {},
|
||||
context: {}
|
||||
})
|
||||
},
|
||||
...(Simple_Inventory.treatment === "on"
|
||||
? [
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "inventory",
|
||||
id: "header-accounting-inventory",
|
||||
icon: <FaFileInvoiceDollar />,
|
||||
label: <Link to="/manage/inventory">{t("menus.header.inventory")}</Link>
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "allpayments",
|
||||
id: "header-accounting-allpayments",
|
||||
icon: <BankFilled />,
|
||||
label: (
|
||||
<Link to="/manage/payments">
|
||||
<LockWrapper featureName="payments" bodyshop={bodyshop}>
|
||||
{t("menus.header.allpayments")}
|
||||
</LockWrapper>
|
||||
</Link>
|
||||
)
|
||||
label: <Link to="/manage/payments">{t("menus.header.allpayments")}</Link>
|
||||
},
|
||||
{
|
||||
key: "enterpayments",
|
||||
id: "header-accounting-enterpayments",
|
||||
icon: <Icon component={FaCreditCard} />,
|
||||
label: (
|
||||
<LockWrapper featureName="payments" bodyshop={bodyshop}>
|
||||
{t("menus.header.enterpayment")}
|
||||
</LockWrapper>
|
||||
),
|
||||
onClick: () => {
|
||||
HasFeatureAccess({ featureName: "payments", bodyshop }) &&
|
||||
setPaymentContext({
|
||||
actions: {},
|
||||
context: null
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (ImEXPay.treatment === "on") {
|
||||
accountingChildren.push({
|
||||
key: "entercardpayments",
|
||||
id: "header-accounting-entercardpayments",
|
||||
icon: <Icon component={FaCreditCard} />,
|
||||
label: t("menus.header.entercardpayment"),
|
||||
onClick: () => {
|
||||
setCardPaymentContext({
|
||||
icon: <FaCreditCard />,
|
||||
label: t("menus.header.enterpayment"),
|
||||
onClick: () =>
|
||||
setPaymentContext({
|
||||
actions: {},
|
||||
context: {}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
accountingChildren.push(
|
||||
{
|
||||
type: "divider"
|
||||
context: null
|
||||
})
|
||||
},
|
||||
...(ImEXPay.treatment === "on"
|
||||
? [
|
||||
{
|
||||
key: "entercardpayments",
|
||||
id: "header-accounting-entercardpayments",
|
||||
icon: <FaCreditCard />,
|
||||
label: t("menus.header.entercardpayment"),
|
||||
onClick: () => setCardPaymentContext({ actions: {}, context: {} })
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "timetickets",
|
||||
id: "header-accounting-timetickets",
|
||||
@@ -241,132 +244,124 @@ function Header({
|
||||
</LockWrapper>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
if (bodyshop?.md_tasks_presets?.use_approvals) {
|
||||
accountingChildren.push({
|
||||
key: "ttapprovals",
|
||||
id: "header-accounting-ttapprovals",
|
||||
icon: <FieldTimeOutlined />,
|
||||
label: <Link to="/manage/ttapprovals">{t("menus.header.ttapprovals")}</Link>
|
||||
});
|
||||
}
|
||||
accountingChildren.push(
|
||||
},
|
||||
...(bodyshop?.md_tasks_presets?.use_approvals
|
||||
? [
|
||||
{
|
||||
key: "ttapprovals",
|
||||
id: "header-accounting-ttapprovals",
|
||||
icon: <FieldTimeOutlined />,
|
||||
label: <Link to="/manage/ttapprovals">{t("menus.header.ttapprovals")}</Link>
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "entertimetickets",
|
||||
icon: <Icon component={GiPlayerTime} />,
|
||||
id: "header-accounting-entertimetickets",
|
||||
icon: <GiPlayerTime />,
|
||||
label: (
|
||||
<LockWrapper featureName="timetickets" bodyshop={bodyshop}>
|
||||
{t("menus.header.entertimeticket")}
|
||||
</LockWrapper>
|
||||
),
|
||||
id: "header-accounting-entertimetickets",
|
||||
onClick: () => {
|
||||
onClick: () =>
|
||||
HasFeatureAccess({ featureName: "timetickets", bodyshop }) &&
|
||||
setTimeTicketContext({
|
||||
actions: {},
|
||||
context: {
|
||||
created_by: currentUser.displayName
|
||||
? currentUser.email.concat(" | ", currentUser.displayName)
|
||||
: currentUser.email
|
||||
}
|
||||
});
|
||||
}
|
||||
setTimeTicketContext({
|
||||
actions: {},
|
||||
context: {
|
||||
created_by: currentUser.displayName
|
||||
? `${currentUser.email} | ${currentUser.displayName}`
|
||||
: currentUser.email
|
||||
}
|
||||
})
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
type: "divider"
|
||||
}
|
||||
);
|
||||
|
||||
const accountingExportChildren = [
|
||||
{
|
||||
key: "receivables",
|
||||
id: "header-accounting-receivables",
|
||||
key: "accountingexport",
|
||||
id: "header-accounting-export",
|
||||
icon: <ExportOutlined />,
|
||||
label: (
|
||||
<Link to="/manage/accounting/receivables">
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
{t("menus.header.accounting-receivables")}
|
||||
</LockWrapper>
|
||||
</Link>
|
||||
)
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
{t("menus.header.export")}
|
||||
</LockWrapper>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
key: "receivables",
|
||||
id: "header-accounting-receivables",
|
||||
label: (
|
||||
<Link to="/manage/accounting/receivables">
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
{t("menus.header.accounting-receivables")}
|
||||
</LockWrapper>
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) ||
|
||||
DmsAp.treatment === "on"
|
||||
? [
|
||||
{
|
||||
key: "payables",
|
||||
id: "header-accounting-payables",
|
||||
label: (
|
||||
<Link to="/manage/accounting/payables">
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
{t("menus.header.accounting-payables")}
|
||||
</LockWrapper>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber))
|
||||
? [
|
||||
{
|
||||
key: "payments",
|
||||
id: "header-accounting-payments",
|
||||
label: (
|
||||
<Link to="/manage/accounting/payments">
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
{t("menus.header.accounting-payments")}
|
||||
</LockWrapper>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "exportlogs",
|
||||
id: "header-accounting-exportlogs",
|
||||
label: (
|
||||
<Link to="/manage/accounting/exportlogs">
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
{t("menus.header.export-logs")}
|
||||
</LockWrapper>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
if (!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) || DmsAp.treatment === "on") {
|
||||
accountingExportChildren.push({
|
||||
key: "payables",
|
||||
id: "header-accounting-payables",
|
||||
label: (
|
||||
<Link to="/manage/accounting/payables">
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
{t("menus.header.accounting-payables")}
|
||||
</LockWrapper>
|
||||
</Link>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
if (!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber))) {
|
||||
accountingExportChildren.push({
|
||||
key: "payments",
|
||||
id: "header-accounting-payments",
|
||||
label: (
|
||||
<Link to="/manage/accounting/payments">
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
{t("menus.header.accounting-payments")}
|
||||
</LockWrapper>
|
||||
</Link>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
accountingExportChildren.push(
|
||||
{
|
||||
type: "divider"
|
||||
},
|
||||
{
|
||||
key: "exportlogs",
|
||||
id: "header-accounting-exportlogs",
|
||||
label: (
|
||||
<Link to="/manage/accounting/exportlogs">
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
{t("menus.header.export-logs")}
|
||||
</LockWrapper>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
accountingChildren.push({
|
||||
key: "accountingexport",
|
||||
id: "header-accounting-export",
|
||||
icon: <ExportOutlined />,
|
||||
label: (
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
{t("menus.header.export")}
|
||||
</LockWrapper>
|
||||
),
|
||||
children: accountingExportChildren
|
||||
});
|
||||
|
||||
const menuItems = [
|
||||
// Left menu items (includes original navigation items)
|
||||
const leftMenuItems = [
|
||||
{
|
||||
key: "home",
|
||||
icon: <HomeFilled />,
|
||||
id: "header-home",
|
||||
icon: <HomeFilled />,
|
||||
label: <Link to="/manage/">{t("menus.header.home")}</Link>
|
||||
},
|
||||
{
|
||||
key: "schedule",
|
||||
id: "header-schedule",
|
||||
icon: <Icon component={FaCalendarAlt} />,
|
||||
icon: <FaCalendarAlt />,
|
||||
label: <Link to="/manage/schedule">{t("menus.header.schedule")}</Link>
|
||||
},
|
||||
{
|
||||
key: "jobssubmenu",
|
||||
id: "header-jobs",
|
||||
icon: <Icon component={FaCarCrash} />,
|
||||
icon: <FaCarCrash />,
|
||||
label: t("menus.header.jobs"),
|
||||
children: [
|
||||
{
|
||||
@@ -399,31 +394,24 @@ function Header({
|
||||
icon: <FileAddOutlined />,
|
||||
label: <Link to="/manage/jobs/new">{t("menus.header.newjob")}</Link>
|
||||
},
|
||||
{
|
||||
type: "divider",
|
||||
id: "header-jobs-divider"
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "alljobs",
|
||||
id: "header-all-jobs",
|
||||
icon: <UnorderedListOutlined />,
|
||||
label: <Link to="/manage/jobs/all">{t("menus.header.alljobs")}</Link>
|
||||
},
|
||||
{
|
||||
type: "divider",
|
||||
id: "header-jobs-divider2"
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "productionlist",
|
||||
id: "header-production-list",
|
||||
icon: <ScheduleOutlined />,
|
||||
label: <Link to="/manage/production/list">{t("menus.header.productionlist")}</Link>
|
||||
},
|
||||
|
||||
{
|
||||
key: "productionboard",
|
||||
id: "header-production-board",
|
||||
icon: <Icon component={BsKanban} />,
|
||||
icon: <BsKanban />,
|
||||
label: (
|
||||
<Link to="/manage/production/board">
|
||||
<LockWrapper featureName="visualboard" bodyshop={bodyshop}>
|
||||
@@ -432,11 +420,7 @@ function Header({
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
|
||||
{
|
||||
type: "divider",
|
||||
id: "header-jobs-divider3"
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "scoreboard",
|
||||
id: "header-scoreboard",
|
||||
@@ -453,8 +437,8 @@ function Header({
|
||||
},
|
||||
{
|
||||
key: "customers",
|
||||
icon: <UserOutlined />,
|
||||
id: "header-customers",
|
||||
icon: <UserOutlined />,
|
||||
label: t("menus.header.customers"),
|
||||
children: [
|
||||
{
|
||||
@@ -519,7 +503,6 @@ function Header({
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
...(accountingChildren.length > 0
|
||||
? [
|
||||
{
|
||||
@@ -537,7 +520,6 @@ function Header({
|
||||
icon: <PhoneOutlined />,
|
||||
label: <Link to="/manage/phonebook">{t("menus.header.phonebook")}</Link>
|
||||
},
|
||||
|
||||
{
|
||||
key: "temporarydocs",
|
||||
id: "header-temporarydocs",
|
||||
@@ -550,7 +532,6 @@ function Header({
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
|
||||
{
|
||||
key: "tasks",
|
||||
id: "tasks",
|
||||
@@ -562,12 +543,7 @@ function Header({
|
||||
id: "header-create-task",
|
||||
icon: <PlusCircleOutlined />,
|
||||
label: t("menus.header.create_task"),
|
||||
onClick: () => {
|
||||
setTaskUpsertContext({
|
||||
actions: {},
|
||||
context: {}
|
||||
});
|
||||
}
|
||||
onClick: () => setTaskUpsertContext({ actions: {}, context: {} })
|
||||
},
|
||||
{
|
||||
key: "mytasks",
|
||||
@@ -592,7 +568,7 @@ function Header({
|
||||
{
|
||||
key: "shop",
|
||||
id: "header-shop",
|
||||
icon: <Icon component={GiSettingsKnobs} />,
|
||||
icon: <GiSettingsKnobs />,
|
||||
label: <Link to="/manage/shop?tab=info">{t("menus.header.shop_config")}</Link>
|
||||
},
|
||||
{
|
||||
@@ -610,24 +586,18 @@ function Header({
|
||||
id: "header-reportcenter",
|
||||
icon: <BarChartOutlined />,
|
||||
label: t("menus.header.reportcenter"),
|
||||
onClick: () => {
|
||||
setReportCenterContext({
|
||||
actions: {},
|
||||
context: {}
|
||||
});
|
||||
}
|
||||
onClick: () => setReportCenterContext({ actions: {}, context: {} })
|
||||
},
|
||||
{
|
||||
key: "shop-vendors",
|
||||
id: "header-shop-vendors",
|
||||
icon: <Icon component={IoBusinessOutline} />,
|
||||
icon: <IoBusinessOutline />,
|
||||
label: <Link to="/manage/shop/vendors">{t("menus.header.shop_vendors")}</Link>
|
||||
},
|
||||
|
||||
{
|
||||
key: "shop-csi",
|
||||
id: "header-shop-csi",
|
||||
icon: <Icon component={RiSurveyLine} />,
|
||||
icon: <RiSurveyLine />,
|
||||
label: (
|
||||
<Link to="/manage/shop/csi">
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
@@ -638,14 +608,27 @@ function Header({
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "recent",
|
||||
id: "header-recent",
|
||||
icon: <ClockCircleFilled />,
|
||||
label: t("menus.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"),
|
||||
id: "header-user",
|
||||
icon: <UserOutlined />,
|
||||
label: t("menus.currentuser.profile"),
|
||||
children: [
|
||||
{
|
||||
key: "signout",
|
||||
id: "header-signout",
|
||||
icon: <Icon component={FiLogOut} />,
|
||||
icon: <FiLogOut />,
|
||||
danger: true,
|
||||
label: t("user.actions.signout"),
|
||||
onClick: () => signOutStart()
|
||||
@@ -653,33 +636,25 @@ function Header({
|
||||
{
|
||||
key: "help",
|
||||
id: "header-help",
|
||||
icon: <Icon component={QuestionCircleFilled} />,
|
||||
icon: <QuestionCircleFilled />,
|
||||
label: t("menus.header.help"),
|
||||
onClick: () => {
|
||||
window.open("https://help.imex.online/", "_blank");
|
||||
}
|
||||
onClick: () => window.open("https://help.imex.online/", "_blank")
|
||||
},
|
||||
...(InstanceRenderManager({
|
||||
imex: true,
|
||||
rome: false
|
||||
})
|
||||
...(InstanceRenderManager({ imex: true, rome: false })
|
||||
? [
|
||||
{
|
||||
key: "rescue",
|
||||
id: "header-rescue",
|
||||
icon: <Icon component={CarFilled} />,
|
||||
icon: <CarFilled />,
|
||||
label: t("menus.header.rescueme"),
|
||||
onClick: () => {
|
||||
window.open("https://imexrescue.com/", "_blank");
|
||||
}
|
||||
onClick: () => window.open("https://imexrescue.com/", "_blank")
|
||||
}
|
||||
]
|
||||
: []),
|
||||
|
||||
{
|
||||
key: "shiftclock",
|
||||
id: "header-shiftclock",
|
||||
icon: <Icon component={GiPlayerTime} />,
|
||||
icon: <GiPlayerTime />,
|
||||
label: (
|
||||
<Link to="/manage/shiftclock">
|
||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||
@@ -688,64 +663,79 @@ 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>
|
||||
}))
|
||||
}
|
||||
];
|
||||
|
||||
// Notifications item (always on the right)
|
||||
const notificationItem = scenarioNotificationsOn
|
||||
? [
|
||||
{
|
||||
key: "notifications",
|
||||
id: "header-notifications",
|
||||
icon: unreadLoading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<Badge offset={[8, 0]} size="small" count={unreadCount}>
|
||||
<BellFilled />
|
||||
</Badge>
|
||||
),
|
||||
onClick: handleNotificationClick
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Layout.Header>
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
theme={"dark"}
|
||||
selectedKeys={[selectedHeader]}
|
||||
onClick={handleMenuClick}
|
||||
subMenuCloseDelay={0.3}
|
||||
items={menuItems}
|
||||
/>
|
||||
<Layout.Header style={{ padding: 0, background: "#001529" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
overflow: "hidden"
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
theme="dark"
|
||||
selectedKeys={[selectedHeader]}
|
||||
onClick={handleMenuClick}
|
||||
subMenuCloseDelay={0.3}
|
||||
items={leftMenuItems}
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
minWidth: 0,
|
||||
overflowX: "auto",
|
||||
borderBottom: "none",
|
||||
background: "transparent"
|
||||
}}
|
||||
/>
|
||||
{scenarioNotificationsOn && (
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
theme="dark"
|
||||
selectedKeys={[selectedHeader]}
|
||||
onClick={handleMenuClick}
|
||||
subMenuCloseDelay={0.3}
|
||||
items={notificationItem}
|
||||
style={{ flex: "0 0 auto", minWidth: 0, borderBottom: "none", background: "transparent" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{scenarioNotificationsOn && (
|
||||
<NotificationCenterContainer
|
||||
visible={notificationVisible}
|
||||
onClose={() => setNotificationVisible(false)}
|
||||
unreadCount={unreadCount}
|
||||
/>
|
||||
)}
|
||||
</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/useSocket.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 = (
|
||||
|
||||
@@ -216,7 +216,7 @@ export function JobCloseRoGuardContainer({ job, jobRO, bodyshop, form }) {
|
||||
</Form.Item>
|
||||
</Collapse.Panel>
|
||||
|
||||
<Collapse.Panel header={t("jobs.labels.performance")}>
|
||||
<Collapse.Panel key="job-performance" header={t("jobs.labels.performance")}>
|
||||
<Row gutter={[32, 32]}>
|
||||
<Col className="ro-guard-col" span={24}>
|
||||
<JobCloseRoGuardTtLifecycle job={job} />
|
||||
|
||||
@@ -21,6 +21,8 @@ import JobDetailCardsInsuranceComponent from "./job-detail-cards.insurance.compo
|
||||
import JobDetailCardsNotesComponent from "./job-detail-cards.notes.component";
|
||||
import JobDetailCardsPartsComponent from "./job-detail-cards.parts.component";
|
||||
import JobDetailCardsTotalsComponent from "./job-detail-cards.totals.component";
|
||||
import JobWatcherToggleContainer from "../job-watcher-toggle/job-watcher-toggle.container.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
@@ -37,6 +39,7 @@ const span = {
|
||||
};
|
||||
|
||||
export function JobDetailCards({ bodyshop, setPrintCenterContext }) {
|
||||
const { scenarioNotificationsOn } = useSocket();
|
||||
const selectedBreakpoint = Object.entries(Grid.useBreakpoint())
|
||||
.filter((screen) => !!screen[1])
|
||||
.slice(-1)[0];
|
||||
@@ -78,7 +81,12 @@ export function JobDetailCards({ bodyshop, setPrintCenterContext }) {
|
||||
{data ? (
|
||||
<Card
|
||||
title={
|
||||
<Link to={`/manage/jobs/${data.jobs_by_pk.id}`}>{data.jobs_by_pk.ro_number || t("general.labels.na")}</Link>
|
||||
<Space>
|
||||
{scenarioNotificationsOn && <JobWatcherToggleContainer job={data.jobs_by_pk} />}
|
||||
<Link to={`/manage/jobs/${data.jobs_by_pk.id}`}>
|
||||
{data.jobs_by_pk.ro_number || t("general.labels.na")}
|
||||
</Link>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space wrap>
|
||||
@@ -122,7 +130,11 @@ export function JobDetailCards({ bodyshop, setPrintCenterContext }) {
|
||||
</Col>
|
||||
{!bodyshop.uselocalmediaserver && (
|
||||
<Col {...span}>
|
||||
<JobDetailCardsDocumentsComponent loading={loading} data={data ? data.jobs_by_pk : null} bodyshop={bodyshop} />
|
||||
<JobDetailCardsDocumentsComponent
|
||||
loading={loading}
|
||||
data={data ? data.jobs_by_pk : null}
|
||||
bodyshop={bodyshop}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
<Col {...span}>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export function JobsTotalsTableComponent({ jobRO, currentUser, job }) {
|
||||
<Card title="DEVELOPMENT USE ONLY">
|
||||
<JobCalculateTotals job={job} disabled={jobRO} />
|
||||
<Collapse>
|
||||
<Collapse.Panel header="JSON Tree Totals">
|
||||
<Collapse.Panel key="json-totals" header="JSON Tree Totals">
|
||||
<div>
|
||||
<pre>
|
||||
{JSON.stringify(
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import React from "react";
|
||||
import { EyeFilled, EyeOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { Avatar, Button, Divider, List, Popover, Select, Tooltip, Typography } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EmployeeSearchSelectComponent from "../../components/employee-search-select/employee-search-select.component.jsx";
|
||||
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component.jsx";
|
||||
import { BiSolidTrash } from "react-icons/bi";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function JobWatcherToggleComponent({
|
||||
jobWatchers,
|
||||
isWatching,
|
||||
watcherLoading,
|
||||
adding,
|
||||
removing,
|
||||
open,
|
||||
setOpen,
|
||||
selectedWatcher,
|
||||
setSelectedWatcher,
|
||||
selectedTeam,
|
||||
bodyshop,
|
||||
Enhanced_Payroll,
|
||||
handleToggleSelf,
|
||||
handleRemoveWatcher,
|
||||
handleWatcherSelect,
|
||||
handleTeamSelect
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleRenderItem = (watcher) => {
|
||||
// Check if watcher is defined and has user_email
|
||||
if (!watcher || !watcher.user_email) {
|
||||
return null; // Skip rendering invalid watchers
|
||||
}
|
||||
|
||||
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="default"
|
||||
danger
|
||||
size="medium"
|
||||
icon={<BiSolidTrash />}
|
||||
onClick={() => handleRemoveWatcher(watcher.user_email)}
|
||||
disabled={adding || removing} // Optional: Disable button during mutations
|
||||
>
|
||||
{t("notifications.actions.remove")}
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar icon={<UserOutlined />} />}
|
||||
title={<Text>{displayName}</Text>}
|
||||
description={watcher.user_email}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const popoverContent = (
|
||||
<div style={{ width: "30em" }}>
|
||||
<List>
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button
|
||||
type={isWatching ? "primary" : "default"}
|
||||
danger={!isWatching}
|
||||
icon={isWatching ? <EyeOutlined /> : <EyeFilled />}
|
||||
size="medium"
|
||||
onClick={handleToggleSelf}
|
||||
loading={adding || removing}
|
||||
>
|
||||
{isWatching ? t("notifications.labels.unwatch") : t("notifications.labels.watch")}
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta>
|
||||
<Text type="secondary" style={{ marginBottom: 8, display: "block" }}>
|
||||
{t("notifications.labels.watching-issue")}
|
||||
</Text>
|
||||
</List.Item.Meta>
|
||||
</List.Item>
|
||||
</List>
|
||||
{watcherLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : jobWatchers && jobWatchers.length > 0 ? (
|
||||
<List dataSource={jobWatchers} renderItem={handleRenderItem} />
|
||||
) : (
|
||||
<Text type="secondary">{t("notifications.labels.no-watchers")}</Text>
|
||||
)}
|
||||
|
||||
<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 && e.active && e.user_email)
|
||||
) || []
|
||||
}
|
||||
placeholder={t("notifications.labels.employee-search")}
|
||||
value={selectedWatcher}
|
||||
onChange={(value) => {
|
||||
setSelectedWatcher(value);
|
||||
handleWatcherSelect(value);
|
||||
}}
|
||||
/>
|
||||
{Enhanced_Payroll && 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}
|
||||
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?.user_email && employee?.active ? employee.user_email : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
return {
|
||||
value: JSON.stringify(teamMembers),
|
||||
label: team.name
|
||||
};
|
||||
}) || []
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover placement="rightTop" content={popoverContent} trigger="click" open={open} onOpenChange={setOpen}>
|
||||
<Tooltip title={t("notifications.tooltips.job-watchers")}>
|
||||
<Button
|
||||
shape="circle"
|
||||
type={isWatching ? "primary" : "default"}
|
||||
icon={isWatching ? <EyeFilled /> : <EyeOutlined />}
|
||||
loading={watcherLoading}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import { ADD_JOB_WATCHER, GET_JOB_WATCHERS, REMOVE_JOB_WATCHER } from "../../graphql/jobs.queries.js";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import JobWatcherToggleComponent from "./job-watcher-toggle.component.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
currentUser: selectCurrentUser
|
||||
});
|
||||
|
||||
function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
|
||||
const {
|
||||
treatments: { Enhanced_Payroll }
|
||||
} = useSplitTreatments({
|
||||
attributes: {},
|
||||
names: ["Enhanced_Payroll"],
|
||||
splitKey: bodyshop && bodyshop.imexshopid
|
||||
});
|
||||
|
||||
const userEmail = currentUser.email;
|
||||
const jobid = job.id;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedWatcher, setSelectedWatcher] = useState(null);
|
||||
const [selectedTeam, setSelectedTeam] = useState(null);
|
||||
|
||||
// Fetch current watchers with refetch capability
|
||||
const {
|
||||
data: watcherData,
|
||||
loading: watcherLoading,
|
||||
refetch
|
||||
} = useQuery(GET_JOB_WATCHERS, {
|
||||
variables: { jobid },
|
||||
fetchPolicy: "cache-and-network" // Ensure fresh data from server
|
||||
});
|
||||
|
||||
// Refetch jobWatchers when the popover opens (open changes to true)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
refetch().catch((err) =>
|
||||
console.error(`Something went wrong fetching Notification Watchers on popover open: ${err?.message}`, {
|
||||
stack: err?.stack
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [open, refetch]);
|
||||
|
||||
const jobWatchers = useMemo(() => (watcherData?.job_watchers ? [...watcherData.job_watchers] : []), [watcherData]);
|
||||
const isWatching = jobWatchers.some((w) => w.user_email === userEmail);
|
||||
|
||||
const [addWatcher, { loading: adding }] = useMutation(ADD_JOB_WATCHER, {
|
||||
onCompleted: () =>
|
||||
refetch().catch((err) =>
|
||||
console.error(`Something went wrong fetching Notification Watchers after add: ${err?.message}`, {
|
||||
stack: err?.stack
|
||||
})
|
||||
),
|
||||
onError: (err) => {
|
||||
if (err.graphQLErrors && err.graphQLErrors.length > 0) {
|
||||
const errorMessage = err.graphQLErrors[0].message;
|
||||
if (
|
||||
errorMessage.includes("Uniqueness violation") ||
|
||||
errorMessage.includes("idx_job_watchers_jobid_user_email_unique")
|
||||
) {
|
||||
console.warn("Watcher already exists for this job and user.");
|
||||
refetch().catch((err) =>
|
||||
console.error(
|
||||
`Something went wrong fetching Notification Watchers after uniqueness violation: ${err?.message}`,
|
||||
{ stack: err?.stack }
|
||||
)
|
||||
); // Sync with server to ensure UI reflects actual state
|
||||
} else {
|
||||
console.error(`Error adding job watcher: ${errorMessage}`);
|
||||
}
|
||||
} else {
|
||||
console.error(`Unexpected error adding job watcher: ${err.message || JSON.stringify(err)}`);
|
||||
}
|
||||
},
|
||||
update(cache, { data }) {
|
||||
if (!data || !data.insert_job_watchers_one) {
|
||||
console.warn("No data or insert_job_watchers_one returned from mutation, skipping cache update.");
|
||||
refetch().catch((err) =>
|
||||
console.error(`Something went wrong updating Notification Watchers after add: ${err?.message}`, {
|
||||
stack: err?.stack
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const insert_job_watchers_one = data.insert_job_watchers_one;
|
||||
const existingData = cache.readQuery({
|
||||
query: GET_JOB_WATCHERS,
|
||||
variables: { jobid }
|
||||
});
|
||||
|
||||
cache.writeQuery({
|
||||
query: GET_JOB_WATCHERS,
|
||||
variables: { jobid },
|
||||
data: {
|
||||
...existingData,
|
||||
job_watchers: [...(existingData?.job_watchers || []), insert_job_watchers_one]
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const [removeWatcher, { loading: removing }] = useMutation(REMOVE_JOB_WATCHER, {
|
||||
onCompleted: () =>
|
||||
refetch().catch((err) =>
|
||||
console.error(`Something went wrong fetching Notification Watchers after remove: ${err?.message}`, {
|
||||
stack: err?.stack
|
||||
})
|
||||
), // Refetch to sync with server after success
|
||||
onError: (err) => console.error(`Error removing job watcher: ${err.message}`),
|
||||
update(cache, { data: { delete_job_watchers } }) {
|
||||
const existingData = cache.readQuery({
|
||||
query: GET_JOB_WATCHERS,
|
||||
variables: { jobid }
|
||||
});
|
||||
|
||||
const deletedWatcher = delete_job_watchers.returning[0];
|
||||
const updatedWatchers = deletedWatcher
|
||||
? (existingData?.job_watchers || []).filter((watcher) => watcher.user_email !== deletedWatcher.user_email)
|
||||
: existingData?.job_watchers || [];
|
||||
|
||||
cache.writeQuery({
|
||||
query: GET_JOB_WATCHERS,
|
||||
variables: { jobid },
|
||||
data: {
|
||||
...existingData,
|
||||
job_watchers: updatedWatchers
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const handleToggleSelf = useCallback(async () => {
|
||||
if (adding || removing) return;
|
||||
if (isWatching) {
|
||||
await removeWatcher({ variables: { jobid, userEmail } });
|
||||
} else {
|
||||
await addWatcher({ variables: { jobid, userEmail } });
|
||||
}
|
||||
}, [isWatching, addWatcher, removeWatcher, jobid, userEmail, adding, removing]);
|
||||
|
||||
const handleRemoveWatcher = useCallback(
|
||||
async (email) => {
|
||||
if (removing) return;
|
||||
await removeWatcher({ variables: { jobid, userEmail: email } });
|
||||
},
|
||||
[removeWatcher, jobid, removing]
|
||||
);
|
||||
|
||||
const handleWatcherSelect = useCallback(
|
||||
async (selectedUser) => {
|
||||
if (adding || removing) return;
|
||||
const employee = bodyshop.employees.find((e) => e.id === selectedUser);
|
||||
if (!employee) return;
|
||||
|
||||
const email = employee.user_email;
|
||||
const isAlreadyWatching = jobWatchers.some((w) => w.user_email === email);
|
||||
|
||||
if (isAlreadyWatching) {
|
||||
await handleRemoveWatcher(email);
|
||||
} else {
|
||||
await addWatcher({ variables: { jobid, userEmail: email } });
|
||||
}
|
||||
setSelectedWatcher(null);
|
||||
},
|
||||
[jobWatchers, addWatcher, handleRemoveWatcher, jobid, bodyshop, adding, removing]
|
||||
);
|
||||
|
||||
const handleTeamSelect = useCallback(
|
||||
async (team) => {
|
||||
if (adding) return;
|
||||
const selectedTeamMembers = JSON.parse(team);
|
||||
const newWatchers = selectedTeamMembers.filter(
|
||||
(email) => !jobWatchers.some((watcher) => watcher.user_email === email)
|
||||
);
|
||||
|
||||
if (newWatchers.length === 0) {
|
||||
console.warn("All selected team members are already watchers.");
|
||||
setSelectedTeam(null);
|
||||
return;
|
||||
}
|
||||
await Promise.all(newWatchers.map((email) => addWatcher({ variables: { jobid, userEmail: email } })));
|
||||
},
|
||||
[jobWatchers, addWatcher, jobid, adding]
|
||||
);
|
||||
|
||||
return (
|
||||
<JobWatcherToggleComponent
|
||||
jobWatchers={jobWatchers}
|
||||
isWatching={isWatching}
|
||||
watcherLoading={watcherLoading}
|
||||
adding={adding}
|
||||
removing={removing}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
selectedWatcher={selectedWatcher}
|
||||
setSelectedWatcher={setSelectedWatcher}
|
||||
selectedTeam={selectedTeam}
|
||||
setSelectedTeam={setSelectedTeam}
|
||||
bodyshop={bodyshop}
|
||||
Enhanced_Payroll={Enhanced_Payroll}
|
||||
handleToggleSelf={handleToggleSelf}
|
||||
handleRemoveWatcher={handleRemoveWatcher}
|
||||
handleWatcherSelect={handleWatcherSelect}
|
||||
handleTeamSelect={handleTeamSelect}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(JobWatcherToggleContainer);
|
||||
@@ -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"]({
|
||||
|
||||
@@ -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/useSocket.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";
|
||||
@@ -28,11 +28,11 @@ import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
||||
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
|
||||
import LockerWrapperComponent from "../lock-wrapper/lock-wrapper.component";
|
||||
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
|
||||
import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
|
||||
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,
|
||||
@@ -130,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 {
|
||||
@@ -775,15 +775,14 @@ export function JobsDetailHeaderActions({
|
||||
key: "enterpayments",
|
||||
id: "job-actions-enterpayments",
|
||||
disabled: !job.converted,
|
||||
label: <LockerWrapperComponent featureName="payments">{t("menus.header.enterpayment")}</LockerWrapperComponent>,
|
||||
label: t("menus.header.enterpayment"),
|
||||
onClick: () => {
|
||||
logImEXEvent("job_header_enter_payment");
|
||||
|
||||
HasFeatureAccess({ featureName: "payments", bodyshop }) &&
|
||||
setPaymentContext({
|
||||
actions: {},
|
||||
context: { jobid: job.id }
|
||||
});
|
||||
setPaymentContext({
|
||||
actions: {},
|
||||
context: { jobid: job.id }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { Badge, Button, Space, Spin, Switch, Tooltip, Typography } from "antd";
|
||||
import { CheckCircleFilled, CheckCircleOutlined, EyeFilled, EyeOutlined } from "@ant-design/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "./notification-center.styles.scss";
|
||||
import day from "../../utils/day.js";
|
||||
import { forwardRef, useRef, useEffect } from "react";
|
||||
import { DateTimeFormat } from "../../utils/DateFormatter.jsx";
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
/**
|
||||
* Notification Center Component
|
||||
* @type {React.ForwardRefExoticComponent<React.PropsWithoutRef<{readonly visible?: *, readonly onClose?: *, readonly notifications?: *, readonly loading?: *, readonly showUnreadOnly?: *, readonly toggleUnreadOnly?: *, readonly markAllRead?: *, readonly loadMore?: *, readonly onNotificationClick?: *, readonly unreadCount?: *}> & React.RefAttributes<unknown>>}
|
||||
*/
|
||||
const NotificationCenterComponent = forwardRef(
|
||||
(
|
||||
{
|
||||
visible,
|
||||
onClose,
|
||||
notifications,
|
||||
loading,
|
||||
showUnreadOnly,
|
||||
toggleUnreadOnly,
|
||||
markAllRead,
|
||||
loadMore,
|
||||
onNotificationClick,
|
||||
unreadCount
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const virtuosoRef = useRef(null);
|
||||
|
||||
// Scroll to top when showUnreadOnly changes
|
||||
useEffect(() => {
|
||||
if (virtuosoRef.current) {
|
||||
virtuosoRef.current.scrollToIndex({ index: 0, behavior: "smooth" });
|
||||
}
|
||||
}, [showUnreadOnly]);
|
||||
|
||||
const renderNotification = (index, notification) => {
|
||||
const handleClick = () => {
|
||||
if (!notification.read) {
|
||||
onNotificationClick(notification.id);
|
||||
}
|
||||
navigate(`/manage/jobs/${notification.jobid}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${notification.id}-${index}`}
|
||||
className={`notification-item ${notification.read ? "notification-read" : "notification-unread"}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Badge dot={!notification.read}>
|
||||
<div className="notification-content">
|
||||
<Title level={5} className="notification-title">
|
||||
<span className="ro-number">
|
||||
{t("notifications.labels.ro-number", { ro_number: notification.roNumber || t("general.labels.na") })}
|
||||
</span>
|
||||
<Text type="secondary" className="relative-time" title={DateTimeFormat(notification.created_at)}>
|
||||
{day(notification.created_at).fromNow()}
|
||||
</Text>
|
||||
</Title>
|
||||
<Text strong={!notification.read} className="notification-body">
|
||||
<ul>
|
||||
{notification.scenarioText.map((text, idx) => (
|
||||
<li key={`${notification.id}-${idx}`}>{text}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Text>
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`notification-center ${visible ? "visible" : ""}`} ref={ref}>
|
||||
<div className="notification-header">
|
||||
<Space direction="horizontal">
|
||||
<h3>{t("notifications.labels.notification-center")}</h3>
|
||||
{loading && <Spin spinning={loading} size="small"></Spin>}
|
||||
</Space>
|
||||
<div className="notification-controls">
|
||||
<Tooltip title={t("notifications.labels.show-unread-only")}>
|
||||
<Space size={4} align="center" className="notification-toggle">
|
||||
{showUnreadOnly ? (
|
||||
<EyeFilled className="notification-toggle-icon" />
|
||||
) : (
|
||||
<EyeOutlined className="notification-toggle-icon" />
|
||||
)}
|
||||
<Switch checked={showUnreadOnly} onChange={(checked) => toggleUnreadOnly(checked)} size="small" />
|
||||
</Space>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("notifications.labels.mark-all-read")}>
|
||||
<Button
|
||||
type="link"
|
||||
icon={!unreadCount ? <CheckCircleFilled /> : <CheckCircleOutlined />}
|
||||
onClick={markAllRead}
|
||||
disabled={!unreadCount}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
style={{ height: "400px", width: "100%" }}
|
||||
data={notifications}
|
||||
totalCount={notifications.length}
|
||||
endReached={loadMore}
|
||||
itemContent={renderNotification}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default NotificationCenterComponent;
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, 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/useSocket.jsx";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||
import day from "../../utils/day.js";
|
||||
|
||||
// This will be used to poll for notifications when the socket is disconnected
|
||||
const NOTIFICATION_POLL_INTERVAL_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Notification Center Container
|
||||
* @param visible
|
||||
* @param onClose
|
||||
* @param bodyshop
|
||||
* @param unreadCount
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }) => {
|
||||
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { isConnected, markNotificationRead, markAllNotificationsRead } = useSocket();
|
||||
const notificationRef = useRef(null);
|
||||
|
||||
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: queryLoading,
|
||||
refetch
|
||||
} = useQuery(GET_NOTIFICATIONS, {
|
||||
variables: {
|
||||
limit: INITIAL_NOTIFICATIONS,
|
||||
offset: 0,
|
||||
where: whereClause
|
||||
},
|
||||
fetchPolicy: "cache-and-network",
|
||||
notifyOnNetworkStatusChange: true,
|
||||
pollInterval: isConnected ? 0 : day.duration(NOTIFICATION_POLL_INTERVAL_SECONDS, "seconds").asMilliseconds(),
|
||||
skip: !userAssociationId,
|
||||
onError: (err) => {
|
||||
console.error(`Error polling Notifications: ${err?.message || ""}`);
|
||||
setTimeout(() => refetch(), day.duration(2, "seconds").asMilliseconds());
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
// Prevent open + close behavior from the header
|
||||
if (event.target.closest("#header-notifications")) return;
|
||||
if (visible && notificationRef.current && !notificationRef.current.contains(event.target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [visible, onClose]);
|
||||
|
||||
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);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!queryLoading && data?.notifications.length) {
|
||||
setIsLoading(true); // Show spinner during fetchMore
|
||||
fetchMore({
|
||||
variables: { offset: data.notifications.length, where: whereClause },
|
||||
updateQuery: (prev, { fetchMoreResult }) => {
|
||||
if (!fetchMoreResult) return prev;
|
||||
return {
|
||||
notifications: [...prev.notifications, ...fetchMoreResult.notifications]
|
||||
};
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Fetch more error:", err);
|
||||
})
|
||||
.finally(() => setIsLoading(false)); // Hide spinner when done
|
||||
}
|
||||
}, [data?.notifications?.length, fetchMore, queryLoading, whereClause]);
|
||||
|
||||
const handleToggleUnreadOnly = (value) => {
|
||||
setShowUnreadOnly(value);
|
||||
};
|
||||
|
||||
const handleMarkAllRead = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
markAllNotificationsRead()
|
||||
.then(() => {
|
||||
const timestamp = new Date().toISOString();
|
||||
setNotifications((prev) => {
|
||||
const updatedNotifications = prev.map((notif) =>
|
||||
notif.read === null && notif.associationid === userAssociationId
|
||||
? {
|
||||
...notif,
|
||||
read: timestamp
|
||||
}
|
||||
: notif
|
||||
);
|
||||
// Filter out read notifications if in unread only mode
|
||||
return showUnreadOnly ? updatedNotifications.filter((notif) => !notif.read) : updatedNotifications;
|
||||
});
|
||||
})
|
||||
.catch((e) => console.error(`Error marking all notifications read: ${e?.message || ""}`))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [markAllNotificationsRead, userAssociationId, showUnreadOnly]);
|
||||
|
||||
const handleNotificationClick = useCallback(
|
||||
(notificationId) => {
|
||||
setIsLoading(true);
|
||||
markNotificationRead({ variables: { id: notificationId } })
|
||||
.then(() => {
|
||||
const timestamp = new Date().toISOString();
|
||||
setNotifications((prev) => {
|
||||
const updatedNotifications = prev.map((notif) =>
|
||||
notif.id === notificationId && !notif.read ? { ...notif, read: timestamp } : notif
|
||||
);
|
||||
// Filter out the read notification if in unread only mode
|
||||
return showUnreadOnly ? updatedNotifications.filter((notif) => !notif.read) : updatedNotifications;
|
||||
});
|
||||
})
|
||||
.catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`))
|
||||
.finally(() => setIsLoading(false));
|
||||
},
|
||||
[markNotificationRead, showUnreadOnly]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && !isConnected) {
|
||||
setIsLoading(true);
|
||||
refetch()
|
||||
.catch((err) => console.error(`Error re-fetching notifications: ${err?.message || ""}`))
|
||||
.finally(() => setIsLoading(false));
|
||||
}
|
||||
}, [visible, isConnected, refetch]);
|
||||
|
||||
return (
|
||||
<NotificationCenterComponent
|
||||
ref={notificationRef}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
notifications={notifications}
|
||||
loading={isLoading}
|
||||
showUnreadOnly={showUnreadOnly}
|
||||
toggleUnreadOnly={handleToggleUnreadOnly}
|
||||
markAllRead={handleMarkAllRead}
|
||||
loadMore={loadMore}
|
||||
onNotificationClick={handleNotificationClick}
|
||||
unreadCount={unreadCount}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, null)(NotificationCenterContainer);
|
||||
@@ -0,0 +1,175 @@
|
||||
.notification-center {
|
||||
position: absolute;
|
||||
top: 64px;
|
||||
right: 0;
|
||||
width: 400px;
|
||||
max-width: 400px;
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08), 0 3px 6px rgba(0, 0, 0, 0.06);
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
overflow-x: hidden;
|
||||
|
||||
&.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
padding: 4px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #fafafa;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.notification-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
// Styles for the eye icon and switch (custom classes)
|
||||
.notification-toggle {
|
||||
align-items: center; // Ensure vertical alignment
|
||||
}
|
||||
|
||||
.notification-toggle-icon {
|
||||
font-size: 14px;
|
||||
color: #1677ff;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ant-switch {
|
||||
&.ant-switch-small {
|
||||
min-width: 28px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
|
||||
.ant-switch-handle {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
&.ant-switch-checked {
|
||||
background-color: #1677ff;
|
||||
.ant-switch-handle {
|
||||
left: calc(100% - 14px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Styles for the "Mark All Read" button (restore original link button style)
|
||||
.ant-btn-link {
|
||||
padding: 0;
|
||||
color: #1677ff;
|
||||
|
||||
&:hover {
|
||||
color: #69b1ff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #0958d9;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notification-read {
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.notification-unread {
|
||||
background: #f5f5f5;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.ro-number {
|
||||
margin: 0;
|
||||
color: #1677ff;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.relative-time {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-body {
|
||||
margin-top: 4px;
|
||||
|
||||
.ant-typography {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-badge {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-alert {
|
||||
margin: 8px;
|
||||
background: #fff1f0;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
border: 1px solid #ffa39e;
|
||||
|
||||
.ant-alert-message {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
|
||||
import { Checkbox, Form } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
/**
|
||||
* ColumnHeaderCheckbox
|
||||
* @param channel
|
||||
* @param form
|
||||
* @param disabled
|
||||
* @param onHeaderChange
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
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
|
||||
};
|
||||
|
||||
export default ColumnHeaderCheckbox;
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Card, Checkbox, Form, Space, 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";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx";
|
||||
|
||||
/**
|
||||
* Notifications Settings Form
|
||||
* @param currentUser
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const NotificationSettingsForm = ({ currentUser }) => {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm();
|
||||
const [initialValues, setInitialValues] = useState({});
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const notification = useNotification();
|
||||
|
||||
// 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.
|
||||
const result = await updateNotificationSettings({ variables: { id: userId, ns: values } });
|
||||
if (!result?.errors) {
|
||||
notification.success({ message: t("notifications.labels.notification-settings-success") });
|
||||
setInitialValues(values);
|
||||
setIsDirty(false);
|
||||
} else {
|
||||
notification.error({ message: t("notifications.labels.notification-settings-failure") });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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={
|
||||
<Space>
|
||||
<Button type="default" onClick={handleReset} disabled={!isDirty}>
|
||||
{t("general.actions.clear")}
|
||||
</Button>
|
||||
|
||||
<Button type="primary" htmlType="submit" disabled={!isDirty} loading={saving}>
|
||||
{t("notifications.labels.save")}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<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,17 +1,16 @@
|
||||
import { DeleteFilled, DownOutlined, WarningFilled } from "@ant-design/icons";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import { Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } 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 DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
|
||||
import CurrencyInput from "../form-items-formatted/currency-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";
|
||||
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
|
||||
import PartsOrderModalPriceChange from "./parts-order-modal-price-change.component";
|
||||
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
@@ -33,7 +32,7 @@ export function PartsOrderModalComponent({ bodyshop, vendorList, sendTypeState,
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
const handleClick = ({ item, key, keyPath }) => {
|
||||
const handleClick = ({ item }) => {
|
||||
form.setFieldsValue({ comments: item.props.value });
|
||||
};
|
||||
|
||||
@@ -98,17 +97,18 @@ export function PartsOrderModalComponent({ bodyshop, vendorList, sendTypeState,
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item name="order_type" initialValue="parts_order" label={t("parts_orders.labels.order_type")}>
|
||||
<Radio.Group disabled={sendType === "oec"}>
|
||||
<Radio value={"parts_order"}>{t("parts_orders.labels.parts_order")}</Radio>
|
||||
<Radio value={"sublet"}>{t("parts_orders.labels.sublet_order")}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{!isReturn && (
|
||||
<Form.Item name="order_type" initialValue="parts_order" label={t("parts_orders.labels.order_type")}>
|
||||
<Radio.Group disabled={sendType === "oec"}>
|
||||
<Radio value={"parts_order"}>{t("parts_orders.labels.parts_order")}</Radio>
|
||||
<Radio value={"sublet"}>{t("parts_orders.labels.sublet_order")}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
)}
|
||||
</LayoutFormRow>
|
||||
<Divider orientation="left">{t("parts_orders.labels.inthisorder")}</Divider>
|
||||
<Form.List name={["parts_order_lines", "data"]}>
|
||||
{(fields, { add, remove, move }) => {
|
||||
{(fields, { remove, move }) => {
|
||||
return (
|
||||
<div>
|
||||
{fields.map((field, index) => (
|
||||
|
||||
@@ -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/useSocket.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,
|
||||
|
||||
@@ -21,7 +21,7 @@ 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 { SiMicrosoftteams } from "react-icons/si";
|
||||
import { PiMicrosoftTeamsLogo } from "react-icons/pi";
|
||||
|
||||
const cardColor = (ssbuckets, totalHrs) => {
|
||||
const bucket = ssbuckets.find((bucket) => bucket.gte <= totalHrs && (!bucket.lt || bucket.lt > totalHrs));
|
||||
@@ -424,7 +424,7 @@ export default function ProductionBoardCard({ technician, card, bodyshop, cardSe
|
||||
noIcon={true}
|
||||
linkText={
|
||||
<div className="share-to-teams-badge">
|
||||
<SiMicrosoftteams />
|
||||
<PiMicrosoftTeamsLogo />
|
||||
</div>
|
||||
}
|
||||
urlOverride={`${window.location.origin}/manage/jobs/${card.id}`}
|
||||
|
||||
@@ -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/useSocket.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
|
||||
|
||||
@@ -27,6 +27,8 @@ import ScoreboardAddButton from "../job-scoreboard-add-button/job-scoreboard-add
|
||||
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
|
||||
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
|
||||
import ProductionRemoveButton from "../production-remove-button/production-remove-button.component";
|
||||
import JobWatcherToggleContainer from "../job-watcher-toggle/job-watcher-toggle.container.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
@@ -41,6 +43,7 @@ export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, te
|
||||
const search = queryString.parse(useLocation().search);
|
||||
const history = useNavigate();
|
||||
const { selected } = search;
|
||||
const { scenarioNotificationsOn } = useSocket();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const theJob = jobs.find((j) => j.id === selected) || {};
|
||||
@@ -60,7 +63,12 @@ export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, te
|
||||
<Drawer
|
||||
title={
|
||||
<PageHeader
|
||||
title={theJob.ro_number}
|
||||
title={
|
||||
<Space>
|
||||
{!technician && scenarioNotificationsOn && <JobWatcherToggleContainer job={theJob} />}
|
||||
{theJob.ro_number}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space wrap>
|
||||
{!technician ? <ProductionRemoveButton jobId={theJob.id} /> : null}
|
||||
|
||||
@@ -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/useSocket.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
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function ProductionRemoveButton({ jobId }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Button loading={loading} onClick={handleRemoveFromProd} type={"danger"}>
|
||||
<Button loading={loading} onClick={handleRemoveFromProd} type="default" danger>
|
||||
{t("production.actions.remove")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -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,8 @@ 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 { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
import NotificationSettingsForm from "../notification-settings/notification-settings-form.component.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser
|
||||
@@ -22,6 +23,7 @@ export default connect(
|
||||
)(function ProfileMyComponent({ currentUser, updateUserDetails }) {
|
||||
const { t } = useTranslation();
|
||||
const notification = useNotification();
|
||||
const { scenarioNotificationsOn } = useSocket();
|
||||
|
||||
const handleFinish = (values) => {
|
||||
logImEXEvent("profile_update");
|
||||
@@ -117,6 +119,11 @@ export default connect(
|
||||
</Card>
|
||||
</Form>
|
||||
</Col>
|
||||
{scenarioNotificationsOn && (
|
||||
<Col span={24}>
|
||||
<NotificationSettingsForm />
|
||||
</Col>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { Button } from "antd";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { SiMicrosoftteams } from "react-icons/si";
|
||||
import { PiMicrosoftTeamsLogo } from "react-icons/pi";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -90,7 +90,7 @@ const ShareToTeamsComponent = ({
|
||||
color: "#FFFFFF",
|
||||
...buttonStyle
|
||||
}}
|
||||
icon={<SiMicrosoftteams style={{ color: "#FFFFFF", ...buttonIconStyle }} />}
|
||||
icon={<PiMicrosoftTeamsLogo style={{ color: "#FFFFFF", ...buttonIconStyle }} />}
|
||||
onClick={handleShare}
|
||||
title={linkText === null ? t("general.actions.sharetoteams") : linkText}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AlertOutlined } from "@ant-design/icons";
|
||||
import { Alert, Button, Col, Row, Space } from "antd";
|
||||
import i18n from "i18next";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -81,8 +81,7 @@ export function UpdateAlert({ updateAvailable }) {
|
||||
imex: "$t(titles.imexonline)",
|
||||
rome: "$t(titles.romeonline)"
|
||||
})
|
||||
}),
|
||||
placement: "bottomRight"
|
||||
})
|
||||
});
|
||||
}
|
||||
if (needRefresh && timerStarted && timeLeft <= 0) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// NotificationProvider.jsx
|
||||
import React, { createContext, useContext } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
import { notification } from "antd";
|
||||
|
||||
/**
|
||||
@@ -22,7 +21,11 @@ export const useNotification = () => {
|
||||
* - Provide `api` via the NotificationContext.
|
||||
*/
|
||||
export const NotificationProvider = ({ children }) => {
|
||||
const [api, contextHolder] = notification.useNotification();
|
||||
const [api, contextHolder] = notification.useNotification({
|
||||
placement: "bottomRight",
|
||||
bottom: 70,
|
||||
showProgress: true
|
||||
});
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={api}>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import React, { createContext } from "react";
|
||||
import useSocket from "./useSocket"; // Import the custom hook
|
||||
|
||||
// Create the SocketContext
|
||||
const SocketContext = createContext(null);
|
||||
|
||||
export const SocketProvider = ({ children, bodyshop }) => {
|
||||
const { socket, clientId } = useSocket(bodyshop);
|
||||
|
||||
return <SocketContext.Provider value={{ socket, clientId }}> {children}</SocketContext.Provider>;
|
||||
};
|
||||
|
||||
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;
|
||||
500
client/src/contexts/SocketIO/useSocket.jsx
Normal file
500
client/src/contexts/SocketIO/useSocket.jsx
Normal file
@@ -0,0 +1,500 @@
|
||||
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,
|
||||
UPDATE_NOTIFICATIONS_READ_FRAGMENT
|
||||
} from "../../graphql/notifications.queries.js";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
|
||||
const SocketContext = createContext(null);
|
||||
|
||||
const INITIAL_NOTIFICATIONS = 10;
|
||||
|
||||
/**
|
||||
* Socket Provider - Scenario Notifications / Web Socket related items
|
||||
* @param children
|
||||
* @param bodyshop
|
||||
* @param navigate
|
||||
* @param currentUser
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
|
||||
const socketRef = useRef(null);
|
||||
const [clientId, setClientId] = useState(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const notification = useNotification();
|
||||
const userAssociationId = bodyshop?.associations?.[0]?.id;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
treatments: { Realtime_Notifications_UI }
|
||||
} = useSplitTreatments({
|
||||
attributes: {},
|
||||
names: ["Realtime_Notifications_UI"],
|
||||
splitKey: bodyshop?.imexshopid
|
||||
});
|
||||
|
||||
const [markNotificationRead] = useMutation(MARK_NOTIFICATION_READ, {
|
||||
update: (cache, { data: { update_notifications } }) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
const updatedNotification = update_notifications.returning[0];
|
||||
|
||||
cache.modify({
|
||||
fields: {
|
||||
notifications(existing = [], { readField }) {
|
||||
return existing.map((notif) =>
|
||||
readField("id", notif) === updatedNotification.id
|
||||
? {
|
||||
...notif,
|
||||
read: timestamp
|
||||
}
|
||||
: notif
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (socketRef.current && isConnected) {
|
||||
socketRef.current.emit("sync-notification-read", {
|
||||
email: currentUser?.email,
|
||||
bodyshopId: bodyshop.id,
|
||||
notificationId: updatedNotification.id
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (err) =>
|
||||
console.error("MARK_NOTIFICATION_READ error:", {
|
||||
message: err?.message,
|
||||
stack: err?.stack
|
||||
})
|
||||
});
|
||||
|
||||
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) =>
|
||||
readField("read", notif) === null && readField("associationid", notif) === userAssociationId
|
||||
? { ...notif, read: timestamp }
|
||||
: 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
|
||||
)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (socketRef.current && isConnected) {
|
||||
socketRef.current.emit("sync-all-notifications-read", {
|
||||
email: currentUser?.email,
|
||||
bodyshopId: bodyshop.id
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (err) => console.error("MARK_ALL_NOTIFICATIONS_READ error:", 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) => {
|
||||
// Scenario Notifications have been disabled, bail.
|
||||
if (Realtime_Notifications_UI?.treatment !== "on") {
|
||||
return;
|
||||
}
|
||||
|
||||
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)) {
|
||||
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 } }) {
|
||||
return {
|
||||
...existing,
|
||||
aggregate: {
|
||||
...existing.aggregate,
|
||||
count: existing.aggregate.count + (newNotification.read === null ? 1 : 0)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
notification.info({
|
||||
message: (
|
||||
<div
|
||||
onClick={() => {
|
||||
markNotificationRead({ variables: { id: notificationId } })
|
||||
.then(() => navigate(`/manage/jobs/${jobId}`))
|
||||
.catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`));
|
||||
}}
|
||||
>
|
||||
{t("notifications.labels.notification-popup-title", {
|
||||
ro_number: jobRoNumber || t("general.labels.na")
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
description: (
|
||||
<ul
|
||||
className="notification-alert-unordered-list"
|
||||
onClick={() => {
|
||||
markNotificationRead({ variables: { id: notificationId } })
|
||||
.then(() => navigate(`/manage/jobs/${jobId}`))
|
||||
.catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`));
|
||||
}}
|
||||
>
|
||||
{notifications.map((notif, index) => (
|
||||
<li className="notification-alert-unordered-list-item" key={index}>
|
||||
{notif.body}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error handling new notification: ${error?.message || ""}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncNotificationRead = ({ notificationId, timestamp }) => {
|
||||
// Scenario Notifications have been disabled, bail.
|
||||
if (Realtime_Notifications_UI?.treatment !== "on") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const notificationRef = client.cache.identify({
|
||||
__typename: "notifications",
|
||||
id: notificationId
|
||||
});
|
||||
client.cache.writeFragment({
|
||||
id: notificationRef,
|
||||
fragment: UPDATE_NOTIFICATIONS_READ_FRAGMENT,
|
||||
data: { read: timestamp }
|
||||
});
|
||||
|
||||
const unreadCountData = client.cache.readQuery({
|
||||
query: GET_UNREAD_COUNT,
|
||||
variables: { associationid: userAssociationId }
|
||||
});
|
||||
if (unreadCountData?.notifications_aggregate?.aggregate?.count > 0) {
|
||||
const newCount = Math.max(unreadCountData.notifications_aggregate.aggregate.count - 1, 0);
|
||||
client.cache.writeQuery({
|
||||
query: GET_UNREAD_COUNT,
|
||||
variables: { associationid: userAssociationId },
|
||||
data: {
|
||||
notifications_aggregate: {
|
||||
__typename: "notifications_aggregate",
|
||||
aggregate: {
|
||||
__typename: "notifications_aggregate_fields",
|
||||
count: newCount
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in handleSyncNotificationRead:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncAllNotificationsRead = ({ timestamp }) => {
|
||||
// Scenario Notifications have been disabled, bail.
|
||||
if (Realtime_Notifications_UI?.treatment !== "on") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const queryVars = {
|
||||
limit: INITIAL_NOTIFICATIONS,
|
||||
offset: 0,
|
||||
where: { associationid: { _eq: userAssociationId } }
|
||||
};
|
||||
const cachedData = client.cache.readQuery({
|
||||
query: GET_NOTIFICATIONS,
|
||||
variables: queryVars
|
||||
});
|
||||
|
||||
if (cachedData?.notifications) {
|
||||
cachedData.notifications.forEach((notif) => {
|
||||
if (!notif.read) {
|
||||
const notifRef = client.cache.identify({ __typename: "notifications", id: notif.id });
|
||||
client.cache.writeFragment({
|
||||
id: notifRef,
|
||||
fragment: UPDATE_NOTIFICATIONS_READ_FRAGMENT,
|
||||
data: { read: timestamp }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
client.cache.writeQuery({
|
||||
query: GET_UNREAD_COUNT,
|
||||
variables: { associationid: userAssociationId },
|
||||
data: {
|
||||
notifications_aggregate: {
|
||||
__typename: "notifications_aggregate",
|
||||
aggregate: {
|
||||
__typename: "notifications_aggregate_fields",
|
||||
count: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error In HandleSyncAllNotificationsRead: ${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("notification", handleNotification);
|
||||
socketInstance.on("sync-notification-read", handleSyncNotificationRead);
|
||||
socketInstance.on("sync-all-notifications-read", handleSyncAllNotificationsRead);
|
||||
};
|
||||
|
||||
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,
|
||||
currentUser,
|
||||
Realtime_Notifications_UI,
|
||||
t
|
||||
]);
|
||||
|
||||
return (
|
||||
<SocketContext.Provider
|
||||
value={{
|
||||
socket: socketRef.current,
|
||||
clientId,
|
||||
isConnected,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead,
|
||||
scenarioNotificationsOn: Realtime_Notifications_UI?.treatment === "on"
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useSocket = () => {
|
||||
const context = useContext(SocketContext);
|
||||
// NOTE: Not sure if we absolutely require this, does cause slipups on dev env
|
||||
if (!context) throw new Error("useSocket must be used within a SocketProvider");
|
||||
return context;
|
||||
};
|
||||
|
||||
export { SocketContext, SocketProvider, INITIAL_NOTIFICATIONS, useSocket };
|
||||
@@ -1,12 +1,10 @@
|
||||
import { onError } from "@apollo/client/link/error";
|
||||
//https://stackoverflow.com/questions/57163454/refreshing-a-token-with-apollo-client-firebase-auth
|
||||
import * as Sentry from "@sentry/react";
|
||||
|
||||
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
|
||||
if (graphQLErrors) {
|
||||
graphQLErrors.forEach(({ message, locations, path }) => {
|
||||
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`);
|
||||
Sentry.captureException({ message, locations, path });
|
||||
});
|
||||
}
|
||||
if (networkError) console.log(`[Network error]: ${JSON.stringify(networkError)}`);
|
||||
|
||||
@@ -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,10 @@ export const GET_JOB_BY_PK = gql`
|
||||
invoice_final_note
|
||||
iouparent
|
||||
job_totals
|
||||
job_watchers {
|
||||
id
|
||||
user_email
|
||||
}
|
||||
joblines(where: { removed: { _eq: false } }, order_by: { line_no: asc }) {
|
||||
act_price
|
||||
act_price_before_ppc
|
||||
@@ -1890,6 +1894,7 @@ export const QUERY_JOB_CLOSE_DETAILS = gql`
|
||||
kmout
|
||||
qb_multiple_payers
|
||||
lbr_adjustments
|
||||
ownr_ea
|
||||
payments {
|
||||
amount
|
||||
created_at
|
||||
@@ -2566,3 +2571,34 @@ 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
|
||||
returning {
|
||||
id
|
||||
user_email
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
58
client/src/graphql/notifications.queries.js
Normal file
58
client/src/graphql/notifications.queries.js
Normal file
@@ -0,0 +1,58 @@
|
||||
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 {
|
||||
id
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_NOTIFICATIONS_READ_FRAGMENT = gql`
|
||||
fragment UpdateNotificationRead on notifications {
|
||||
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,10 +1,12 @@
|
||||
import "./utils/sentry"; //Must be first.
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { ConfigProvider } from "antd";
|
||||
import Dinero from "dinero.js";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { Provider } from "react-redux";
|
||||
import { createBrowserRouter, createRoutesFromElements, Route, RouterProvider } from "react-router-dom";
|
||||
import { PersistGate } from "redux-persist/integration/react";
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
import AppContainer from "./App/App.container";
|
||||
import LoadingSpinner from "./components/loading-spinner/loading-spinner.component";
|
||||
import "./index.css";
|
||||
@@ -12,56 +14,18 @@ import { persistor, store } from "./redux/store";
|
||||
import reportWebVitals from "./reportWebVitals";
|
||||
import "./translations/i18n";
|
||||
import "./utils/CleanAxios";
|
||||
import { ConfigProvider } from "antd";
|
||||
import InstanceRenderManager from "./utils/instanceRenderMgr";
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
|
||||
window.global ||= window;
|
||||
|
||||
registerSW({ immediate: true });
|
||||
//import { BrowserTracing } from "@sentry/tracing";
|
||||
//import "antd/dist/antd.css";
|
||||
// import "antd/dist/antd.less";
|
||||
|
||||
// Dinero.defaultCurrency = "CAD";
|
||||
// Dinero.globalLocale = "en-CA";
|
||||
Dinero.globalRoundingMode = "HALF_EVEN";
|
||||
|
||||
if (import.meta.env.PROD) {
|
||||
Sentry.init({
|
||||
dsn: InstanceRenderManager({
|
||||
imex: "https://fd7e89369b6b4bdc9c6c4c9f22fa4ee4@o492140.ingest.sentry.io/5651027",
|
||||
rome: "https://a6acc91c073e414196014b8484627a61@o492140.ingest.sentry.io/4504561071161344"
|
||||
}),
|
||||
const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV6(createBrowserRouter);
|
||||
|
||||
ignoreErrors: [
|
||||
"ResizeObserver loop",
|
||||
"ResizeObserver loop limit exceeded",
|
||||
"Module specifier, 'fs' does not start",
|
||||
"Module specifier, 'zlib' does not start with"
|
||||
],
|
||||
integrations: [
|
||||
Sentry.replayIntegration({
|
||||
maskAllText: false,
|
||||
blockAllMedia: true
|
||||
}),
|
||||
Sentry.browserTracingIntegration()
|
||||
],
|
||||
tracePropagationTargets: [
|
||||
"api.imex.online",
|
||||
"api.test.imex.online",
|
||||
"db.imex.online",
|
||||
"api.romeonline.io",
|
||||
"api.test.romeonline.io",
|
||||
"db.romeonline.io"
|
||||
],
|
||||
tracesSampleRate: 1.0,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
environment: import.meta.env.MODE
|
||||
});
|
||||
}
|
||||
|
||||
const router = createBrowserRouter(createRoutesFromElements(<Route path="*" element={<AppContainer />} />));
|
||||
const router = sentryCreateBrowserRouter(createRoutesFromElements(<Route path="*" element={<AppContainer />} />));
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
let styles =
|
||||
|
||||
@@ -56,6 +56,8 @@ import { DateTimeFormat } from "../../utils/DateFormatter";
|
||||
import dayjs from "../../utils/day";
|
||||
import UndefinedToNull from "../../utils/undefinedtonull";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
import JobWatcherToggleContainer from "../../components/job-watcher-toggle/job-watcher-toggle.container.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
@@ -102,6 +104,7 @@ export function JobsDetailPage({
|
||||
nextFetchPolicy: "network-only"
|
||||
});
|
||||
const notification = useNotification();
|
||||
const { scenarioNotificationsOn } = useSocket();
|
||||
|
||||
useEffect(() => {
|
||||
//form.setFieldsValue(transormJobToForm(job));
|
||||
@@ -319,7 +322,13 @@ export function JobsDetailPage({
|
||||
>
|
||||
<PageHeader
|
||||
// onBack={() => window.history.back()}
|
||||
title={job.ro_number || t("general.labels.na")}
|
||||
|
||||
title={
|
||||
<Space>
|
||||
{scenarioNotificationsOn && <JobWatcherToggleContainer 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/useSocket.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
|
||||
@@ -142,11 +143,11 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
|
||||
const fetchedAlerts = await response.json();
|
||||
setAlerts(fetchedAlerts);
|
||||
} catch (error) {
|
||||
console.error("Error fetching alerts:", error);
|
||||
console.warn("Error fetching alerts:", error.message);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAlerts();
|
||||
fetchAlerts().catch((err) => `Error fetching Bodyshop Alerts: ${err?.message || ""}`);
|
||||
}, [setAlerts]);
|
||||
|
||||
// Use useEffect to watch for new alerts
|
||||
@@ -166,7 +167,6 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
|
||||
description: alert.description,
|
||||
type: alert.type || "info",
|
||||
duration: 0,
|
||||
placement: "bottomRight",
|
||||
closable: true,
|
||||
onClose: () => {
|
||||
// When the notification is closed, update displayed alerts state and localStorage
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from "@apollo/client";
|
||||
import queryString from "query-string";
|
||||
import React, { useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { useLocation } from "react-router-dom";
|
||||
@@ -10,23 +10,17 @@ import PaymentsListPaginated from "../../components/payments-list-paginated/paym
|
||||
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
|
||||
import { QUERY_ALL_PAYMENTS_PAGINATED } from "../../graphql/payments.queries";
|
||||
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { pageLimit } from "../../utils/config";
|
||||
import FeatureWrapperComponent from "../../components/feature-wrapper/feature-wrapper.component";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
import UpsellComponent, { upsellEnum } from "../../components/upsell/upsell.component";
|
||||
import { Card } from "antd";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapStateToProps = createStructuredSelector({});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
|
||||
setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
|
||||
});
|
||||
|
||||
export function AllJobs({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
|
||||
export function AllJobs({ setBreadcrumbs, setSelectedHeader }) {
|
||||
const searchParams = queryString.parse(useLocation().search);
|
||||
const { page, sortcolumn, sortorder, searchObj } = searchParams;
|
||||
|
||||
@@ -60,25 +54,15 @@ export function AllJobs({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
|
||||
|
||||
if (error) return <AlertComponent message={error.message} type="error" />;
|
||||
return (
|
||||
<FeatureWrapperComponent
|
||||
featureName="payments"
|
||||
noauth={
|
||||
<Card>
|
||||
<UpsellComponent upsell={upsellEnum().payments.general} />
|
||||
</Card>
|
||||
}
|
||||
z
|
||||
>
|
||||
<RbacWrapper action="payments:list">
|
||||
<PaymentsListPaginated
|
||||
refetch={refetch}
|
||||
loading={loading}
|
||||
searchParams={searchParams}
|
||||
total={data ? data.payments_aggregate.aggregate.count : 0}
|
||||
payments={data ? data.payments : []}
|
||||
/>
|
||||
</RbacWrapper>
|
||||
</FeatureWrapperComponent>
|
||||
<RbacWrapper action="payments:list">
|
||||
<PaymentsListPaginated
|
||||
refetch={refetch}
|
||||
loading={loading}
|
||||
searchParams={searchParams}
|
||||
total={data ? data.payments_aggregate.aggregate.count : 0}
|
||||
payments={data ? data.payments : []}
|
||||
/>
|
||||
</RbacWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -347,8 +347,11 @@ 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.");
|
||||
console.warn("Couldnt find $crisp.", error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
yield put(signInFailure(error.message));
|
||||
|
||||
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
@@ -7,9 +7,9 @@ import { getMainDefinition } from "@apollo/client/utilities";
|
||||
//import { split } from "apollo-link";
|
||||
import apolloLogger from "apollo-link-logger";
|
||||
//import axios from "axios";
|
||||
import { SentryLink } from "apollo-link-sentry";
|
||||
import { auth } from "../firebase/firebase.utils";
|
||||
import errorLink from "../graphql/apollo-error-handling";
|
||||
import { SentryLink } from "apollo-link-sentry";
|
||||
|
||||
//import { store } from "../redux/store";
|
||||
const httpLink = new HttpLink({
|
||||
@@ -143,7 +143,41 @@ middlewares.push(
|
||||
new SentryLink().concat(roundTripLink.concat(retryLink.concat(errorLink.concat(authLink.concat(link)))))
|
||||
);
|
||||
|
||||
const cache = new InMemoryCache({});
|
||||
const cache = new InMemoryCache({
|
||||
typePolicies: {
|
||||
Query: {
|
||||
fields: {
|
||||
// Note: This is required because we switch from a read to an unread state with a toggle,
|
||||
notifications: {
|
||||
merge(existing = [], incoming = [], { readField }) {
|
||||
// Create a map to deduplicate by __ref
|
||||
const merged = new Map();
|
||||
|
||||
// Add existing items to retain cached data
|
||||
existing.forEach((item) => {
|
||||
const ref = readField("__ref", item);
|
||||
if (ref) {
|
||||
merged.set(ref, item);
|
||||
}
|
||||
});
|
||||
|
||||
// Add incoming items, overwriting duplicates
|
||||
incoming.forEach((item) => {
|
||||
const ref = readField("__ref", item);
|
||||
if (ref) {
|
||||
merged.set(ref, item);
|
||||
}
|
||||
});
|
||||
|
||||
// Return incoming to respect the current query’s filter (e.g., unread-only or all)
|
||||
return incoming;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const client = new ApolloClient({
|
||||
link: ApolloLink.from(middlewares),
|
||||
cache,
|
||||
@@ -163,4 +197,5 @@ const client = new ApolloClient({
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default client;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
23
client/src/utils/jobNotificationScenarios.js
Normal file
23
client/src/utils/jobNotificationScenarios.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/** Notification Scenarios
|
||||
* @description This file contains the scenarios for job notifications.
|
||||
* @type {string[]}
|
||||
*/
|
||||
const notificationScenarios = [
|
||||
"job-assigned-to-me",
|
||||
"bill-posted",
|
||||
"critical-parts-status-changed",
|
||||
"part-marked-back-ordered",
|
||||
"new-note-added",
|
||||
"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"
|
||||
// "supplement-imported", // Disabled for now
|
||||
];
|
||||
|
||||
export { notificationScenarios };
|
||||
63
client/src/utils/sentry.js
Normal file
63
client/src/utils/sentry.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { excludeGraphQLFetch } from "apollo-link-sentry";
|
||||
import { useEffect } from "react";
|
||||
import { createRoutesFromChildren, matchRoutes, useLocation, useNavigationType } from "react-router-dom";
|
||||
import InstanceRenderManager from "./instanceRenderMgr";
|
||||
|
||||
const currentDatePST = new Date()
|
||||
.toLocaleDateString("en-US", {
|
||||
timeZone: "America/Los_Angeles",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit"
|
||||
})
|
||||
.split("/")
|
||||
.reverse()
|
||||
.join("-");
|
||||
const sentryRelease =
|
||||
`${import.meta.env.VITE_APP_IS_TEST ? "test" : "production"}-${currentDatePST}-${process.env.VITE_GIT_COMMIT_HASH}`.trim();
|
||||
|
||||
if (!import.meta.env.DEV) {
|
||||
Sentry.init({
|
||||
dsn: InstanceRenderManager({
|
||||
imex: "https://fd7e89369b6b4bdc9c6c4c9f22fa4ee4@o492140.ingest.sentry.io/5651027",
|
||||
rome: "https://a6acc91c073e414196014b8484627a61@o492140.ingest.sentry.io/4504561071161344"
|
||||
}),
|
||||
release: sentryRelease,
|
||||
|
||||
ignoreErrors: [
|
||||
"ResizeObserver loop",
|
||||
"ResizeObserver loop limit exceeded",
|
||||
"Module specifier, 'fs' does not start",
|
||||
"Module specifier, 'zlib' does not start with",
|
||||
"Messaging: This browser doesn't support the API's required to use the Firebase SDK.",
|
||||
"Failed to update a ServiceWorker for scope"
|
||||
],
|
||||
integrations: [
|
||||
// See docs for support of different versions of variation of react router
|
||||
// https://docs.sentry.io/platforms/javascript/guides/react/configuration/integrations/react-router/
|
||||
Sentry.reactRouterV6BrowserTracingIntegration({
|
||||
useEffect,
|
||||
useLocation,
|
||||
useNavigationType,
|
||||
createRoutesFromChildren,
|
||||
matchRoutes
|
||||
}),
|
||||
Sentry.replayIntegration(),
|
||||
Sentry.browserProfilingIntegration()
|
||||
],
|
||||
|
||||
tracePropagationTargets: [
|
||||
"api.imex.online",
|
||||
"api.test.imex.online",
|
||||
"db.imex.online",
|
||||
"api.romeonline.io",
|
||||
"api.test.romeonline.io",
|
||||
"db.romeonline.io"
|
||||
],
|
||||
tracesSampleRate: 1.0,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
environment: import.meta.env.MODE,
|
||||
beforeBreadcrumb: excludeGraphQLFetch
|
||||
});
|
||||
}
|
||||
@@ -1,16 +1,31 @@
|
||||
import { sentryVitePlugin } from "@sentry/vite-plugin";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import chalk from "chalk";
|
||||
import * as child from "child_process";
|
||||
import { promises as fsPromises } from "fs";
|
||||
import { createLogger, defineConfig } from "vite";
|
||||
import { ViteEjsPlugin } from "vite-plugin-ejs";
|
||||
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"
|
||||
});
|
||||
const commitHash = child.execSync("git rev-parse HEAD").toString().trimEnd();
|
||||
process.env.VITE_GIT_COMMIT_HASH = commitHash;
|
||||
|
||||
const currentDatePST = new Date()
|
||||
.toLocaleDateString("en-US", {
|
||||
timeZone: "America/Los_Angeles",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit"
|
||||
})
|
||||
.split("/")
|
||||
.reverse()
|
||||
.join("-");
|
||||
|
||||
const getFormattedTimestamp = () =>
|
||||
new Date().toLocaleTimeString("en-US", { hour12: true }).replace("AM", "a.m.").replace("PM", "p.m.");
|
||||
@@ -22,7 +37,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 +46,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 +59,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 +68,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 +77,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,17 +86,32 @@ 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"
|
||||
}
|
||||
}),
|
||||
react(),
|
||||
eslint()
|
||||
eslint(),
|
||||
sentryVitePlugin({
|
||||
org: "imex",
|
||||
reactComponentAnnotation: {
|
||||
enabled: true
|
||||
},
|
||||
release: {
|
||||
name: `${process.env.VITE_APP_IS_TEST ? "test" : "production"}-${currentDatePST}-${commitHash}`.trim()
|
||||
},
|
||||
project: InstanceRenderManager({
|
||||
instance: process.env.VITE_APP_INSTANCE,
|
||||
imex: "imexonline",
|
||||
rome: "rome-online"
|
||||
})
|
||||
})
|
||||
],
|
||||
define: {
|
||||
APP_VERSION: JSON.stringify(process.env.npm_package_version)
|
||||
APP_VERSION: JSON.stringify(process.env.npm_package_version),
|
||||
__COMMIT_HASH__: JSON.stringify(commitHash)
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
@@ -186,7 +214,9 @@ export default defineConfig({
|
||||
"libphonenumber-js": ["libphonenumber-js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
sourcemap: true
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
@@ -204,8 +234,10 @@ export default defineConfig({
|
||||
"react-redux"
|
||||
],
|
||||
esbuildOptions: {
|
||||
// Update for Vite 6: Use proper file extensions
|
||||
loader: {
|
||||
".js": "jsx"
|
||||
".jsx": "jsx",
|
||||
".tsx": "tsx"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
221
docker-compose-cluster.yml
Normal file
221
docker-compose-cluster.yml
Normal file
@@ -0,0 +1,221 @@
|
||||
services:
|
||||
# Load Balancer (NGINX) with WebSocket support and session persistence
|
||||
load-balancer:
|
||||
image: nginx:latest
|
||||
container_name: load-balancer
|
||||
ports:
|
||||
- "4000:80" # External port 4000 maps to NGINX's port 80
|
||||
volumes:
|
||||
- ./nginx-websocket.conf:/etc/nginx/nginx.conf:ro # Mount NGINX configuration
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
depends_on:
|
||||
- node-app-1
|
||||
- node-app-2
|
||||
- node-app-3
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "-f", "http://localhost/health" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Node App Instance 1
|
||||
node-app-1:
|
||||
build:
|
||||
context: .
|
||||
container_name: node-app-1
|
||||
hostname: node-app-1
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
env_file:
|
||||
- .env.development
|
||||
depends_on:
|
||||
redis-node-1:
|
||||
condition: service_healthy
|
||||
redis-node-2:
|
||||
condition: service_healthy
|
||||
redis-node-3:
|
||||
condition: service_healthy
|
||||
localstack:
|
||||
condition: service_healthy
|
||||
aws-cli:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
- "4001:4000" # Different external port for local access
|
||||
volumes:
|
||||
- .:/app
|
||||
- node-app-npm-cache:/app/node_modules
|
||||
|
||||
# Node App Instance 2
|
||||
node-app-2:
|
||||
build:
|
||||
context: .
|
||||
container_name: node-app-2
|
||||
hostname: node-app-2
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
env_file:
|
||||
- .env.development
|
||||
depends_on:
|
||||
redis-node-1:
|
||||
condition: service_healthy
|
||||
redis-node-2:
|
||||
condition: service_healthy
|
||||
redis-node-3:
|
||||
condition: service_healthy
|
||||
localstack:
|
||||
condition: service_healthy
|
||||
aws-cli:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
- "4002:4000" # Different external port for local access
|
||||
volumes:
|
||||
- .:/app
|
||||
- node-app-npm-cache:/app/node_modules
|
||||
|
||||
# Node App Instance 3
|
||||
node-app-3:
|
||||
build:
|
||||
context: .
|
||||
container_name: node-app-3
|
||||
hostname: node-app-3
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
env_file:
|
||||
- .env.development
|
||||
depends_on:
|
||||
redis-node-1:
|
||||
condition: service_healthy
|
||||
redis-node-2:
|
||||
condition: service_healthy
|
||||
redis-node-3:
|
||||
condition: service_healthy
|
||||
localstack:
|
||||
condition: service_healthy
|
||||
aws-cli:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
- "4003:4000" # Different external port for local access
|
||||
volumes:
|
||||
- .:/app
|
||||
- node-app-npm-cache:/app/node_modules
|
||||
|
||||
# Redis Node 1
|
||||
redis-node-1:
|
||||
build:
|
||||
context: ./redis
|
||||
container_name: redis-node-1
|
||||
hostname: redis-node-1
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
volumes:
|
||||
- redis-node-1-data:/data
|
||||
- redis-lock:/redis-lock
|
||||
healthcheck:
|
||||
test: [ "CMD", "redis-cli", "ping" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# Redis Node 2
|
||||
redis-node-2:
|
||||
build:
|
||||
context: ./redis
|
||||
container_name: redis-node-2
|
||||
hostname: redis-node-2
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
volumes:
|
||||
- redis-node-2-data:/data
|
||||
- redis-lock:/redis-lock
|
||||
healthcheck:
|
||||
test: [ "CMD", "redis-cli", "ping" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# Redis Node 3
|
||||
redis-node-3:
|
||||
build:
|
||||
context: ./redis
|
||||
container_name: redis-node-3
|
||||
hostname: redis-node-3
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
volumes:
|
||||
- redis-node-3-data:/data
|
||||
- redis-lock:/redis-lock
|
||||
healthcheck:
|
||||
test: [ "CMD", "redis-cli", "ping" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# LocalStack
|
||||
localstack:
|
||||
image: localstack/localstack
|
||||
container_name: localstack
|
||||
hostname: localstack
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- SERVICES=s3,ses,secretsmanager,cloudwatch,logs
|
||||
- DEBUG=0
|
||||
- AWS_ACCESS_KEY_ID=test
|
||||
- AWS_SECRET_ACCESS_KEY=test
|
||||
- AWS_DEFAULT_REGION=ca-central-1
|
||||
- EXTRA_CORS_ALLOWED_HEADERS=Authorization,Content-Type
|
||||
- EXTRA_CORS_ALLOWED_ORIGINS=*
|
||||
- EXTRA_CORS_EXPOSE_HEADERS=Authorization,Content-Type
|
||||
ports:
|
||||
- "4566:4566"
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "-f", "http://localhost:4566/_localstack/health" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
# AWS-CLI
|
||||
aws-cli:
|
||||
image: amazon/aws-cli
|
||||
container_name: aws-cli
|
||||
hostname: aws-cli
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
depends_on:
|
||||
localstack:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- './localstack:/tmp/localstack'
|
||||
- './certs:/tmp/certs'
|
||||
environment:
|
||||
- AWS_ACCESS_KEY_ID=test
|
||||
- AWS_SECRET_ACCESS_KEY=test
|
||||
- AWS_DEFAULT_REGION=ca-central-1
|
||||
entrypoint: /bin/sh -c
|
||||
command: >
|
||||
"
|
||||
aws --endpoint-url=http://localstack:4566 ses verify-domain-identity --domain imex.online --region ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 ses verify-email-identity --email-address noreply@imex.online --region ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/io-ftp-test.key
|
||||
aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
"
|
||||
|
||||
networks:
|
||||
redis-cluster-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
node-app-npm-cache:
|
||||
redis-node-1-data:
|
||||
redis-node-2-data:
|
||||
redis-node-3-data:
|
||||
redis-lock:
|
||||
@@ -31,14 +31,6 @@
|
||||
headers:
|
||||
- name: x-imex-auth
|
||||
value_from_env: DATAPUMP_AUTH
|
||||
- name: Task Reminders
|
||||
webhook: '{{HASURA_API_URL}}/tasks-remind-handler'
|
||||
schedule: '*/15 * * * *'
|
||||
include_in_metadata: true
|
||||
payload: {}
|
||||
headers:
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
- name: Rome Usage Report
|
||||
webhook: '{{HASURA_API_URL}}/data/usagereport'
|
||||
schedule: 0 12 * * 5
|
||||
@@ -47,3 +39,11 @@
|
||||
headers:
|
||||
- name: x-imex-auth
|
||||
value_from_env: DATAPUMP_AUTH
|
||||
- name: Task Reminders
|
||||
webhook: '{{HASURA_API_URL}}/tasks-remind-handler'
|
||||
schedule: '*/15 * * * *'
|
||||
include_in_metadata: true
|
||||
payload: {}
|
||||
headers:
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
|
||||
@@ -198,6 +198,14 @@
|
||||
- name: user
|
||||
using:
|
||||
foreign_key_constraint_on: useremail
|
||||
array_relationships:
|
||||
- name: notifications
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: associationid
|
||||
table:
|
||||
name: notifications
|
||||
schema: public
|
||||
select_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
@@ -697,12 +705,6 @@
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
body:
|
||||
action: transform
|
||||
template: |-
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
@@ -1133,6 +1135,46 @@
|
||||
- active:
|
||||
_eq: true
|
||||
check: null
|
||||
event_triggers:
|
||||
- name: cache_bodyshop
|
||||
definition:
|
||||
enable_manual: false
|
||||
update:
|
||||
columns:
|
||||
- shopname
|
||||
- md_order_statuses
|
||||
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:
|
||||
body:
|
||||
action: transform
|
||||
template: |-
|
||||
{
|
||||
"created_at": {{$body.created_at}},
|
||||
"delivery_info": {{$body.delivery_info}},
|
||||
"event": {
|
||||
"data": {
|
||||
"new": {
|
||||
"id": {{$body.event.data.new.id}},
|
||||
"shopname": {{$body.event.data.new.shopname}},
|
||||
"md_order_statuses": {{$body.event.data.new.md_order_statuses}}
|
||||
}
|
||||
},
|
||||
"op": {{$body.event.op}},
|
||||
"session_variables": {{$body.event.session_variables}}
|
||||
}
|
||||
}
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/bodyshop-cache'
|
||||
version: 2
|
||||
- table:
|
||||
name: cccontracts
|
||||
schema: public
|
||||
@@ -1958,6 +2000,29 @@
|
||||
_eq: X-Hasura-User-Id
|
||||
- active:
|
||||
_eq: true
|
||||
event_triggers:
|
||||
- name: notifications_documents
|
||||
definition:
|
||||
enable_manual: false
|
||||
insert:
|
||||
columns: '*'
|
||||
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 +2911,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 +2932,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 +2948,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 +3284,31 @@
|
||||
_eq: X-Hasura-User-Id
|
||||
- active:
|
||||
_eq: true
|
||||
event_triggers:
|
||||
- name: notifications_joblines
|
||||
definition:
|
||||
enable_manual: false
|
||||
update:
|
||||
columns:
|
||||
- critical
|
||||
- status
|
||||
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:
|
||||
body:
|
||||
action: transform
|
||||
template: "{\r\n \"event\": {\r\n \"session_variables\": {\r\n \"x-hasura-user-id\": {{$body?.event?.session_variables?.x-hasura-user-id ?? \"Internal\"}},\r\n \"x-hasura-role\": {{$body?.event?.session_variables?.x-hasura-role ?? \"Internal\"}}\r\n }, \r\n \"op\": \"UPDATE\",\r\n \"data\": {\r\n \"old\": {\r\n \"id\": {{$body.event.data.old.id}},\r\n \"jobid\": {{$body.event.data.old.jobid}},\r\n \"critical\": {{$body.event.data.old.critical}},\r\n \"status\": {{$body.event.data.old.status}},\r\n \"line_desc\": {{$body.event.data.old.line_desc}}\r\n },\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"jobid\": {{$body.event.data.new.jobid}},\r\n \"critical\": {{$body.event.data.new.critical}},\r\n \"status\": {{$body.event.data.new.status}},\r\n \"line_desc\": {{$body.event.data.new.line_desc}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_joblines\"\r\n },\r\n \"table\": {\r\n \"schema\": \"public\",\r\n \"name\": \"joblines\"\r\n }\r\n}\r\n"
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/notifications/events/handleJobLinesChange'
|
||||
version: 2
|
||||
- table:
|
||||
name: joblines_status
|
||||
schema: public
|
||||
@@ -3369,6 +3455,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:
|
||||
@@ -3399,6 +3492,13 @@
|
||||
table:
|
||||
name: notes
|
||||
schema: public
|
||||
- name: notifications
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: jobid
|
||||
table:
|
||||
name: notifications
|
||||
schema: public
|
||||
- name: parts_dispatches
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
@@ -4473,10 +4573,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 ?? \"Internal\"}},\r\n \"x-hasura-role\": {{$body?.event?.session_variables?.x-hasura-role ?? \"Internal\"}}\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 +4922,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 +4952,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
|
||||
@@ -5116,32 +5266,6 @@
|
||||
- active:
|
||||
_eq: true
|
||||
check: null
|
||||
event_triggers:
|
||||
- name: notifications_parts_dispatch
|
||||
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:
|
||||
body:
|
||||
action: transform
|
||||
template: |-
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/notifications/events/handlePartsDispatchChange'
|
||||
version: 2
|
||||
- table:
|
||||
name: parts_dispatch_lines
|
||||
schema: public
|
||||
@@ -5648,6 +5772,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 +6262,15 @@
|
||||
columns: '*'
|
||||
update:
|
||||
columns:
|
||||
- joblineid
|
||||
- assigned_to
|
||||
- due_date
|
||||
- partsorderid
|
||||
- completed
|
||||
- description
|
||||
- billid
|
||||
- title
|
||||
- priority
|
||||
retry_conf:
|
||||
interval_sec: 10
|
||||
num_retries: 0
|
||||
@@ -6131,12 +6280,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 +6456,6 @@
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
body:
|
||||
action: transform
|
||||
template: |-
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
@@ -6586,6 +6723,13 @@
|
||||
table:
|
||||
name: ioevents
|
||||
schema: public
|
||||
- name: job_watchers
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: user_email
|
||||
table:
|
||||
name: job_watchers
|
||||
schema: public
|
||||
- name: messages
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
|
||||
@@ -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";
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS "public"."idx_job_watchers_jobid_user_email_unique";
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE UNIQUE INDEX "idx_job_watchers_jobid_user_email_unique" on
|
||||
"public"."job_watchers" using btree ("jobid", "user_email");
|
||||
45
nginx-websocket.conf
Normal file
45
nginx-websocket.conf
Normal file
@@ -0,0 +1,45 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
upstream node_app {
|
||||
ip_hash; # Enables session persistence based on client IP
|
||||
server node-app-1:4000;
|
||||
server node-app-2:4000;
|
||||
server node-app-3:4000;
|
||||
}
|
||||
|
||||
# WebSocket upgrade configuration
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
location / {
|
||||
proxy_pass http://node_app;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket headers
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 86400; # Keep WebSocket connections alive (24 hours)
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
proxy_pass http://node_app;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
}
|
||||
3778
package-lock.json
generated
3778
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