Compare commits
1269 Commits
feature/IO
...
bugfix/IO-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1566084d9c | ||
|
|
849d967b56 | ||
|
|
519d7e8d87 | ||
|
|
b08435607e | ||
|
|
ea9e4ffcad | ||
|
|
6c814c7dc6 | ||
|
|
cc9e536059 | ||
|
|
dadc9892d0 | ||
|
|
b05e20ce0d | ||
|
|
eb36b12cb0 | ||
|
|
bf5a099fa6 | ||
|
|
ff3d24c623 | ||
|
|
27b955a701 | ||
|
|
1896c4db59 | ||
|
|
78770ed54e | ||
|
|
9e2ae2cc10 | ||
|
|
3a0f6101c8 | ||
|
|
f0dfa2717f | ||
|
|
1f3be72d9d | ||
|
|
3d9ad799f3 | ||
|
|
6e17ef10bb | ||
|
|
fdc06e79a6 | ||
|
|
66924367fc | ||
|
|
f76165552e | ||
|
|
80fbb847d8 | ||
|
|
ca1703e724 | ||
|
|
163819809c | ||
|
|
42fa85e145 | ||
|
|
13104f36e3 | ||
|
|
0c9f7df9ac | ||
|
|
a9280a83ba | ||
|
|
78d816fa8b | ||
|
|
9f573fc5b4 | ||
|
|
4a1b1fe905 | ||
|
|
5f81ec2099 | ||
|
|
147977be58 | ||
|
|
4dfda4b371 | ||
|
|
02feba2804 | ||
|
|
a9fb77189e | ||
|
|
3bacad69e3 | ||
|
|
70b6aa63ed | ||
|
|
844a879f1c | ||
|
|
6415b302dc | ||
|
|
d40dd649e2 | ||
|
|
35366eda22 | ||
|
|
9a53896aa4 | ||
|
|
278765d019 | ||
|
|
6fd5fc8f66 | ||
|
|
346a6e69c7 | ||
|
|
5d5fa8fead | ||
|
|
30dae4e365 | ||
|
|
f6899f744b | ||
|
|
cca23a5b11 | ||
|
|
2acddcb9ac | ||
|
|
4efa01edd3 | ||
|
|
ff57592c12 | ||
|
|
6643e92665 | ||
|
|
2e3452bc61 | ||
|
|
b394b85923 | ||
|
|
94a5b4901b | ||
|
|
f6637dcae8 | ||
|
|
9a93a43642 | ||
|
|
9475dfb4e8 | ||
|
|
fe0ddc5824 | ||
|
|
14365d45d2 | ||
|
|
587a3104db | ||
|
|
745ec57510 | ||
|
|
5ad13e1060 | ||
|
|
e1666baddd | ||
|
|
7f43ba33f6 | ||
|
|
5c95b9fc5a | ||
|
|
2faeca3069 | ||
|
|
53cb1d2f65 | ||
|
|
360421b254 | ||
|
|
3918f3d72b | ||
|
|
c29ac5f711 | ||
|
|
40e8529eeb | ||
|
|
d0148f48a8 | ||
|
|
48336b88e0 | ||
|
|
415f256f07 | ||
|
|
7fe9098f69 | ||
|
|
e598ee69e6 | ||
|
|
c7df7a7d47 | ||
|
|
9b81cb7314 | ||
|
|
83439ecb15 | ||
|
|
fabf2fb8dd | ||
|
|
d92ca15056 | ||
|
|
49bfb0849d | ||
|
|
538dcce78a | ||
|
|
f5a618319a | ||
|
|
151598c563 | ||
|
|
d06b20b1a8 | ||
|
|
407c6456ae | ||
|
|
803a811039 | ||
|
|
645b20bf8a | ||
|
|
32541a82e3 | ||
|
|
ee7892974f | ||
|
|
a0857c3865 | ||
|
|
7c3db5c7bd | ||
|
|
8de507bf37 | ||
|
|
4c8783a2c2 | ||
|
|
976b3aa7d4 | ||
|
|
d7e3b52dc6 | ||
|
|
a91bfea581 | ||
|
|
1716c3e6b2 | ||
|
|
bc78bbd5fa | ||
|
|
44533e9777 | ||
|
|
a22c0298d1 | ||
|
|
423077b79c | ||
|
|
640e0987ad | ||
|
|
c3e12cfeff | ||
|
|
fb9c294dd8 | ||
|
|
89622f0af2 | ||
|
|
7a0187afbe | ||
|
|
f3513a80c5 | ||
|
|
ac1dcf4604 | ||
|
|
3c38d9daeb | ||
|
|
a9a49009ba | ||
|
|
8bd7e5cc6d | ||
|
|
27b9c3f342 | ||
|
|
334077a39d | ||
|
|
23ce1c42d1 | ||
|
|
8ede23d55a | ||
|
|
6a521c0f46 | ||
|
|
fe8200dadd | ||
|
|
ddf4256e58 | ||
|
|
5271970ec1 | ||
|
|
6f8b91d9d0 | ||
|
|
a2230be5fe | ||
|
|
7f0f5c2aa3 | ||
|
|
a97a9c8d28 | ||
|
|
4896746600 | ||
|
|
f2eb4abfca | ||
|
|
480ee27b80 | ||
|
|
e46d819979 | ||
|
|
55dd0c6e14 | ||
|
|
d30e03a184 | ||
|
|
f89112902c | ||
|
|
f40af8cba4 | ||
|
|
75a8669034 | ||
|
|
3810cbbdff | ||
|
|
e4aa920b1a | ||
|
|
aef04ec29e | ||
|
|
e23c5a654b | ||
|
|
69e57195d3 | ||
|
|
1165fc1489 | ||
|
|
ad99cd4c18 | ||
|
|
883c7257db | ||
|
|
92fd5b0315 | ||
|
|
5fa7f6a8f0 | ||
|
|
76d90f8f1f | ||
|
|
a68e52234a | ||
|
|
d52f12f16d | ||
|
|
be42eae5a3 | ||
|
|
7d7742a7fa | ||
|
|
36fd077bab | ||
|
|
183774d7cd | ||
|
|
53d556a621 | ||
|
|
2fee2ae264 | ||
|
|
54ce0e1802 | ||
|
|
eadbf3237d | ||
|
|
7bdfbfabe9 | ||
|
|
1764397195 | ||
|
|
361bbc9abb | ||
|
|
2c3f12aabd | ||
|
|
be2df79555 | ||
|
|
9a526caa2d | ||
|
|
9c733702e4 | ||
|
|
997aebddb0 | ||
|
|
17e3f39706 | ||
|
|
c770533cdc | ||
|
|
09f5264110 | ||
|
|
f4df6ed5dd | ||
|
|
436a72d017 | ||
|
|
8aae43614b | ||
|
|
ebe51fed2c | ||
|
|
8bc1184bf4 | ||
|
|
a4f1bb6c83 | ||
|
|
359834a6db | ||
|
|
495e5ffad8 | ||
|
|
912d503ef8 | ||
|
|
225e19b40c | ||
|
|
d90acf4b89 | ||
|
|
68dd7f33ab | ||
|
|
f99f8ab7f8 | ||
|
|
0b2a7f07a7 | ||
|
|
2436ba0678 | ||
|
|
a0ae6a30a9 | ||
|
|
766848989d | ||
|
|
37b0bb2cd3 | ||
|
|
2a931181d1 | ||
|
|
1b08e4c54c | ||
|
|
4b419bf62b | ||
|
|
dd3eda12ce | ||
|
|
c119a66f27 | ||
|
|
8decbf8874 | ||
|
|
4192c87a34 | ||
|
|
c3da0d9035 | ||
|
|
30e137eaf6 | ||
|
|
2015e88a27 | ||
|
|
f4b45c693a | ||
|
|
3cf1d9a59d | ||
|
|
27c6a6e768 | ||
|
|
6c9dd969e5 | ||
|
|
011528aee8 | ||
|
|
344b6114f4 | ||
|
|
45e0f61f06 | ||
|
|
a906bc5816 | ||
|
|
9b62633ba6 | ||
|
|
3082fd22ac | ||
|
|
6886f7923a | ||
|
|
a1a608b8cc | ||
|
|
5efd9e43be | ||
|
|
2e356d2a18 | ||
|
|
febabc56f0 | ||
|
|
e26df780bf | ||
|
|
fef6036bfd | ||
|
|
a6bd3d1383 | ||
|
|
4f93eb6200 | ||
|
|
00bf5977ae | ||
|
|
0ec90c9c54 | ||
|
|
decb75a579 | ||
|
|
4cdc15f70b | ||
|
|
021bf714d6 | ||
|
|
33494f2991 | ||
|
|
4190372b92 | ||
|
|
5729368098 | ||
|
|
0dd29580d0 | ||
|
|
4a22aeca46 | ||
|
|
05414d9177 | ||
|
|
0f5dd02d75 | ||
|
|
5310866302 | ||
|
|
ca01e98046 | ||
|
|
2eca085284 | ||
|
|
e3ab229ac5 | ||
|
|
b0f3bc86f7 | ||
|
|
955150ba97 | ||
|
|
a5f7ff3089 | ||
|
|
5772e95a94 | ||
|
|
37c16c2328 | ||
|
|
864baebcdb | ||
|
|
63ce7b5c79 | ||
|
|
79a3b58a86 | ||
|
|
45dd3d8cd6 | ||
|
|
28e470cf9d | ||
|
|
931096f829 | ||
|
|
a70e3e26d0 | ||
|
|
7a38a01233 | ||
|
|
03c7781d59 | ||
|
|
c68feef0b5 | ||
|
|
6db1ddd2d7 | ||
|
|
e9dd155875 | ||
|
|
5b11587380 | ||
|
|
9dbe246575 | ||
|
|
657720cb10 | ||
|
|
2c7b328596 | ||
|
|
41ea0a09ba | ||
|
|
21271004c5 | ||
|
|
155d0af509 | ||
|
|
4aff6aaa50 | ||
|
|
b92b92b2cd | ||
|
|
9627800277 | ||
|
|
ff654d01bb | ||
|
|
edf65e4fd1 | ||
|
|
1ad7468d14 | ||
|
|
b27f5fc641 | ||
|
|
1582c2ed45 | ||
|
|
9b44dd844f | ||
|
|
ae95283328 | ||
|
|
e22aa60a14 | ||
|
|
4a7bb07345 | ||
|
|
01fec9fa79 | ||
|
|
2f88d613c3 | ||
|
|
c3a49d8282 | ||
|
|
c9467b3982 | ||
|
|
5be5f12fae | ||
|
|
ca1a456312 | ||
|
|
ca4c48bd5c | ||
|
|
e5fd5c8bcb | ||
|
|
46945a24a7 | ||
|
|
be746500a6 | ||
|
|
517b98d288 | ||
|
|
40263791b8 | ||
|
|
71c6d9fa94 | ||
|
|
6a8d22ed31 | ||
|
|
86381adff9 | ||
|
|
c010665ea9 | ||
|
|
c1f705deb0 | ||
|
|
781b6c8df6 | ||
|
|
dfe0afd4f3 | ||
|
|
78f7239f91 | ||
|
|
c3b976f6d3 | ||
|
|
6d94ce7e5c | ||
|
|
3a9d18072e | ||
|
|
f65acdd660 | ||
|
|
d6fba12cd9 | ||
|
|
60c603c102 | ||
|
|
9bc308f60f | ||
|
|
faf00ca845 | ||
|
|
0b24cb484a | ||
|
|
182a8d59ab | ||
|
|
7ea81465ee | ||
|
|
19ecbad9c7 | ||
|
|
aa722a97f0 | ||
|
|
d8815e3e08 | ||
|
|
c675a328a8 | ||
|
|
e8bf687d58 | ||
|
|
f1847ef650 | ||
|
|
007bfef791 | ||
|
|
6d6fc9d552 | ||
|
|
6eb432b5b7 | ||
|
|
56d50b855b | ||
|
|
6ea1c291e6 | ||
|
|
6140903529 | ||
|
|
05d5c96491 | ||
|
|
35a566cbe5 | ||
|
|
d6079f7861 | ||
|
|
f12e40e4c6 | ||
|
|
bb4e671c83 | ||
|
|
b2b4ff7917 | ||
|
|
d1637d2432 | ||
|
|
2408511cdc | ||
|
|
1c79628613 | ||
|
|
288c8e6347 | ||
|
|
521a7084b7 | ||
|
|
56738f800c | ||
|
|
bedf4f2c02 | ||
|
|
6032ff0e5d | ||
|
|
77268d5f5b | ||
|
|
1b3abf17ec | ||
|
|
0ef68afa0c | ||
|
|
12b4ae3b8d | ||
|
|
3cfd445894 | ||
|
|
b510eec9aa | ||
|
|
e92bab0455 | ||
|
|
4de3d3c6fc | ||
|
|
e5eac0933f | ||
|
|
a3c71fdfc0 | ||
|
|
a6b3bd573e | ||
|
|
18373fc865 | ||
|
|
3ae8ed8496 | ||
|
|
78750d3d96 | ||
|
|
90edf94fee | ||
|
|
3507e60356 | ||
|
|
43feb16950 | ||
|
|
827f1c2c40 | ||
|
|
58f5ed1ce7 | ||
|
|
c1e3c08652 | ||
|
|
d885bac7d0 | ||
|
|
065fb72677 | ||
|
|
ddc6141480 | ||
|
|
fa7da3cad0 | ||
|
|
f1bad01cec | ||
|
|
3d6498f938 | ||
|
|
7bc137fa79 | ||
|
|
dafe9de753 | ||
|
|
78a8474a24 | ||
|
|
910d388e05 | ||
|
|
9faad53b99 | ||
|
|
3b07055d5a | ||
|
|
ec29a22984 | ||
|
|
2b1836d450 | ||
|
|
ae7d150a6c | ||
|
|
123066f1cd | ||
|
|
b2184a2d11 | ||
|
|
a153cca3c0 | ||
|
|
35c7c32c8e | ||
|
|
9b1c8fa72b | ||
|
|
6d6b64ebc3 | ||
|
|
c3bc29fa9b | ||
|
|
c954695d3c | ||
|
|
338d8e2136 | ||
|
|
6674206b4f | ||
|
|
c46ad521d1 | ||
|
|
34f45379a6 | ||
|
|
66e5bec4d8 | ||
|
|
0d3161ef84 | ||
|
|
1cd11bdc18 | ||
|
|
9cce2696e2 | ||
|
|
e20ef4374c | ||
|
|
43dc760c95 | ||
|
|
cfd5aaff87 | ||
|
|
6162e7f18d | ||
|
|
cdee754042 | ||
|
|
a8fb16122a | ||
|
|
a88be98d45 | ||
|
|
e44bc07ffe | ||
|
|
60e8aadd8c | ||
|
|
daf9f197eb | ||
|
|
5848daef72 | ||
|
|
bc77bec610 | ||
|
|
c655badae2 | ||
|
|
7af70f7512 | ||
|
|
a8dcc542cc | ||
|
|
e4c87dd06d | ||
|
|
16899007d8 | ||
|
|
9cb1b25b1d | ||
|
|
4c250f6189 | ||
|
|
9c2c0b665d | ||
|
|
09ea6dff2b | ||
|
|
577c3bec04 | ||
|
|
90f653c0b7 | ||
|
|
556cd993b9 | ||
|
|
e3b4620d0c | ||
|
|
cbfda822c6 | ||
|
|
52811f5f45 | ||
|
|
508d32d2d9 | ||
|
|
cccc307862 | ||
|
|
29049cf1b0 | ||
|
|
b517f3966d | ||
|
|
0772139a60 | ||
|
|
70028c8be6 | ||
|
|
3dc22bfdab | ||
|
|
91f419f4b3 | ||
|
|
f3ee421030 | ||
|
|
a5f8fbacc1 | ||
|
|
8bb58df32e | ||
|
|
1e3b3b853e | ||
|
|
02eb212758 | ||
|
|
6b41d6f2a2 | ||
|
|
d45d557a81 | ||
|
|
c0157454e1 | ||
|
|
00e6d31a88 | ||
|
|
e5ed11287d | ||
|
|
fa250f10a2 | ||
|
|
b8fed77f43 | ||
|
|
3f5614d77e | ||
|
|
5a8a5bf7ab | ||
|
|
9ce022b5e8 | ||
|
|
b0b73f1af8 | ||
|
|
a788beaa19 | ||
|
|
6c5c4bd333 | ||
|
|
43c1eef70c | ||
|
|
b8d97d9821 | ||
|
|
6843441b17 | ||
|
|
d6b295855d | ||
|
|
e36baaa682 | ||
|
|
409e04ed0e | ||
|
|
95c7872b34 | ||
|
|
91bf5c8d0f | ||
|
|
3660fb1b1b | ||
|
|
35f00df77e | ||
|
|
7573286163 | ||
|
|
34ab42c0ad | ||
|
|
a3c3f60d2a | ||
|
|
286c49deb1 | ||
|
|
a16c680d04 | ||
|
|
9341806b0f | ||
|
|
da28fe8592 | ||
|
|
f2faa5b686 | ||
|
|
bedca60744 | ||
|
|
5344a2031d | ||
|
|
8147bc76fd | ||
|
|
c60dfa4319 | ||
|
|
aa692d4d05 | ||
|
|
9ef1022311 | ||
|
|
5bbda89fb9 | ||
|
|
ccf3d7df5b | ||
|
|
3c0e62ffac | ||
|
|
eeb685802e | ||
|
|
ea14606538 | ||
|
|
3d24d44274 | ||
|
|
a9a0415501 | ||
|
|
fbaf47b89b | ||
|
|
65e26ed5c9 | ||
|
|
a73617fd3c | ||
|
|
32a0e89467 | ||
|
|
e06f0f9918 | ||
|
|
907f291f90 | ||
|
|
abce19530f | ||
|
|
1fd9b68320 | ||
|
|
cff1afe605 | ||
|
|
b337309f94 | ||
|
|
319f3220ed | ||
|
|
77f041b0f1 | ||
|
|
68be8670b4 | ||
|
|
745c429f08 | ||
|
|
cc9e4740de | ||
|
|
a4da874a1a | ||
|
|
2f4c0e329a | ||
|
|
06ebcbaa07 | ||
|
|
42427c4569 | ||
|
|
c6d083ce02 | ||
|
|
d5f921ed35 | ||
|
|
043471fdbc | ||
|
|
54850e8ee2 | ||
|
|
199ddc7d9e | ||
|
|
e514cf8d6a | ||
|
|
6671db1724 | ||
|
|
5a9381ebdb | ||
|
|
6bab792b5e | ||
|
|
13a44b9a59 | ||
|
|
e3337bacea | ||
|
|
8e6c809fc6 | ||
|
|
41afedd02c | ||
|
|
64207ef76c | ||
|
|
29df829120 | ||
|
|
11e0c3e507 | ||
|
|
6c1c7c9c2c | ||
|
|
d2b6054e60 | ||
|
|
7004ed9880 | ||
|
|
36dfed80fb | ||
|
|
de02b34a63 | ||
|
|
e3df22160b | ||
|
|
2ffc4b81f4 | ||
|
|
ec30e73b3e | ||
|
|
0ccfe6f3aa | ||
|
|
34d70f6a18 | ||
|
|
fc1bf213c7 | ||
|
|
4dc72986d0 | ||
|
|
f19b9cb8e1 | ||
|
|
c149d457e7 | ||
|
|
88c00cf34f | ||
|
|
c9ade68dc9 | ||
|
|
0d247a38d2 | ||
|
|
595ffa02ba | ||
|
|
d573335eb0 | ||
|
|
bdd5056c9a | ||
|
|
4f6db827e7 | ||
|
|
d16c8d5bd5 | ||
|
|
92b05a290e | ||
|
|
6421fc8002 | ||
|
|
0d9a7dda53 | ||
|
|
601eed6db8 | ||
|
|
9b708d5e8e | ||
|
|
6b4bc27205 | ||
|
|
e50bbc3bcc | ||
|
|
15792cb0ef | ||
|
|
856a24d496 | ||
|
|
74d6bcc004 | ||
|
|
2af95de353 | ||
|
|
7ffb2c1aad | ||
|
|
cf3f94bf98 | ||
|
|
4899297539 | ||
|
|
0bd8e862e4 | ||
|
|
4afafef98f | ||
|
|
48a3220c49 | ||
|
|
31529fad80 | ||
|
|
7bc105ea46 | ||
|
|
ac4fcf1694 | ||
|
|
678a87f55d | ||
|
|
9e977d9a58 | ||
|
|
43f822e6ad | ||
|
|
7239698a21 | ||
|
|
46be1fa889 | ||
|
|
40a8036472 | ||
|
|
22970ac149 | ||
|
|
0eb0e335fe | ||
|
|
63eeab888f | ||
|
|
367ced88d8 | ||
|
|
71b08855b8 | ||
|
|
01664c52ec | ||
|
|
67b1a7f9f4 | ||
|
|
7f67b01c87 | ||
|
|
e66d52784f | ||
|
|
a75969097e | ||
|
|
5e308f13d3 | ||
|
|
24f017bfd2 | ||
|
|
93e137c84e | ||
|
|
42027f0858 | ||
|
|
3b663d7954 | ||
|
|
e737af2d41 | ||
|
|
10d3b4a485 | ||
|
|
99b79126c3 | ||
|
|
d0eeb7d55d | ||
|
|
36d4a02be0 | ||
|
|
d95c5ce8f9 | ||
|
|
b995e1f35d | ||
|
|
3d112ed2cd | ||
|
|
e978a5a561 | ||
|
|
31367f278e | ||
|
|
a384fd5072 | ||
|
|
490c66a9cb | ||
|
|
d38dab0738 | ||
|
|
37624d385f | ||
|
|
d565934288 | ||
|
|
5d03574d65 | ||
|
|
34f7c115b4 | ||
|
|
3ac74df504 | ||
|
|
9ff3311579 | ||
|
|
d5f13f750f | ||
|
|
b9ddac36a9 | ||
|
|
c982dde1f5 | ||
|
|
cd1e8b0b15 | ||
|
|
3d910aa246 | ||
|
|
f7799ffd03 | ||
|
|
57baa3d9fd | ||
|
|
7f61f652f7 | ||
|
|
c1e1d5e82c | ||
|
|
5ae2e33596 | ||
|
|
e11260e8fc | ||
|
|
282a22ff51 | ||
|
|
dfd88308e0 | ||
|
|
33579c3e6a | ||
|
|
0b9b3c027f | ||
|
|
154f9cdfe6 | ||
|
|
5b8c7d922c | ||
|
|
d20347d5dc | ||
|
|
68c4a1efd7 | ||
|
|
3e6d6fdbd1 | ||
|
|
c6cd6d0f4e | ||
|
|
f796dd0f89 | ||
|
|
302fd58a56 | ||
|
|
31cfdf9ea3 | ||
|
|
f93800ded4 | ||
|
|
05385fca6d | ||
|
|
1355d79fa4 | ||
|
|
8805538706 | ||
|
|
9e35b0f123 | ||
|
|
517c30787d | ||
|
|
252758747b | ||
|
|
8b39b7c7be | ||
|
|
ada07bad62 | ||
|
|
166a33af4e | ||
|
|
038aa82087 | ||
|
|
99f425eac4 | ||
|
|
b2c504c69d | ||
|
|
ac6856b136 | ||
|
|
521955089f | ||
|
|
4afff893c0 | ||
|
|
cc934fe333 | ||
|
|
ddd3b3d056 | ||
|
|
8ded028197 | ||
|
|
2660466db1 | ||
|
|
c8771275ce | ||
|
|
fd229d5d09 | ||
|
|
7c8260685e | ||
|
|
2fec9fd16e | ||
|
|
0f1348496c | ||
|
|
c42a0139fc | ||
|
|
02974e6e4b | ||
|
|
de26979e44 | ||
|
|
1bb66a5378 | ||
|
|
fe67efe47c | ||
|
|
69a35772e5 | ||
|
|
38932f4bf9 | ||
|
|
3fcb36a28e | ||
|
|
fe78f5c7ff | ||
|
|
683846c3b0 | ||
|
|
2cc0b247b6 | ||
|
|
661678eb1c | ||
|
|
31579354d4 | ||
|
|
0e7531dc54 | ||
|
|
268b57c38a | ||
|
|
2b8b8b8073 | ||
|
|
808eeb91e9 | ||
|
|
23dd8fc9de | ||
|
|
f499859078 | ||
|
|
84d9e3251a | ||
|
|
bd7db4dd02 | ||
|
|
e9804b736b | ||
|
|
a14874f116 | ||
|
|
ac9fac458c | ||
|
|
8f9db15852 | ||
|
|
1728982b2b | ||
|
|
8aa747edc5 | ||
|
|
01fc0cbc08 | ||
|
|
61b3d3c18c | ||
|
|
e6f08d3b1c | ||
|
|
d5cf0f8371 | ||
|
|
52e230fc54 | ||
|
|
4011237c22 | ||
|
|
c24bfbf655 | ||
|
|
08fe8c3c70 | ||
|
|
771a239773 | ||
|
|
82195a0584 | ||
|
|
838c24b3f1 | ||
|
|
13cb68b0af | ||
|
|
7c84b08707 | ||
|
|
3165957e95 | ||
|
|
d9f59fcad4 | ||
|
|
edaeb5d77a | ||
|
|
544494ce0d | ||
|
|
5365d95d6f | ||
|
|
5cfefd5afd | ||
|
|
bbccdb0650 | ||
|
|
f3535c01af | ||
|
|
7f8c82b300 | ||
|
|
9ab3d8c81a | ||
|
|
83c0696a3e | ||
|
|
609ac2bd33 | ||
|
|
0883274320 | ||
|
|
fa33b88632 | ||
|
|
bec32c1d70 | ||
|
|
eb18130e51 | ||
|
|
0fbf63dec8 | ||
|
|
f817902d5c | ||
|
|
7a383aaec9 | ||
|
|
e5c0ace6cb | ||
|
|
814447373a | ||
|
|
d766a468c3 | ||
|
|
0ede2d0649 | ||
|
|
54089c2ab3 | ||
|
|
73e3d71cf1 | ||
|
|
f071a5cc9e | ||
|
|
67002b8443 | ||
|
|
3f83c6afa7 | ||
|
|
b7f57e91aa | ||
|
|
b8465c0cc7 | ||
|
|
6bae0b8406 | ||
|
|
2e25324ae9 | ||
|
|
f525ec6fb8 | ||
|
|
8296d914c5 | ||
|
|
3d691568ff | ||
|
|
553c154e46 | ||
|
|
35cbb921d2 | ||
|
|
6b926401d0 | ||
|
|
5b400dce4f | ||
|
|
914946c264 | ||
|
|
2939b5795b | ||
|
|
531f968ce6 | ||
|
|
24cc3fa6a4 | ||
|
|
a6f17e7db5 | ||
|
|
29c904feea | ||
|
|
0743048e75 | ||
|
|
e2f1758378 | ||
|
|
b6dc7a4d92 | ||
|
|
130745d7e7 | ||
|
|
a722ab9758 | ||
|
|
41c9c0be49 | ||
|
|
52f62126e1 | ||
|
|
cfdfb8110b | ||
|
|
eb04a8b6c5 | ||
|
|
141b05f558 | ||
|
|
e9ea36fdad | ||
|
|
e990781ff7 | ||
|
|
e24237e010 | ||
|
|
f00f5f2e4a | ||
|
|
f38b550e75 | ||
|
|
fabe1508ac | ||
|
|
5f3c880d0b | ||
|
|
86f0c02c82 | ||
|
|
2004eb840f | ||
|
|
2f0190e190 | ||
|
|
e5a48531a0 | ||
|
|
5552c73721 | ||
|
|
9860447e42 | ||
|
|
364813193f | ||
|
|
2c8f3a173e | ||
|
|
1577921334 | ||
|
|
a934249c02 | ||
|
|
6486de3c61 | ||
|
|
ed6a8b210d | ||
|
|
96e38d509f | ||
|
|
42e072e8ff | ||
|
|
6942d6e4f9 | ||
|
|
43d5a77d60 | ||
|
|
a74ce063ec | ||
|
|
7ab070e2bc | ||
|
|
47a974e3cb | ||
|
|
9c45b49ab9 | ||
|
|
d690790dfe | ||
|
|
eac81cdffb | ||
|
|
70fa638c37 | ||
|
|
77c3e6f7e7 | ||
|
|
7f07da4360 | ||
|
|
c47144df72 | ||
|
|
77cacdec91 | ||
|
|
33fb60ca1a | ||
|
|
f6d6b548be | ||
|
|
c0a215d20d | ||
|
|
8c9ef375be | ||
|
|
5342377ca9 | ||
|
|
8295cb111a | ||
|
|
cd4754069b | ||
|
|
951d214d49 | ||
|
|
c586d0283b | ||
|
|
203cc1ebdf | ||
|
|
db09f33e5c | ||
|
|
1941034dcb | ||
|
|
6bd2828176 | ||
|
|
6f19c1dd3f | ||
|
|
b428a1078c | ||
|
|
cc232eac93 | ||
|
|
5e90504e56 | ||
|
|
73e103f2df | ||
|
|
b09b8b4f34 | ||
|
|
9dd34c9f6c | ||
|
|
cf60f7cd03 | ||
|
|
637e95c351 | ||
|
|
7af7f3c4e7 | ||
|
|
0cadf007b5 | ||
|
|
1394176218 | ||
|
|
284d25eeb9 | ||
|
|
60258a0f5d | ||
|
|
7873405a30 | ||
|
|
c38d7d9aea | ||
|
|
7639655911 | ||
|
|
4fb1871044 | ||
|
|
e5dd1edf13 | ||
|
|
542c95c395 | ||
|
|
a3122a59b1 | ||
|
|
81d642fcd3 | ||
|
|
960a2ccd30 | ||
|
|
2ab6093bd8 | ||
|
|
7ed7b6117f | ||
|
|
7158676562 | ||
|
|
67a8c13bad | ||
|
|
766b4b950a | ||
|
|
88ba8ab929 | ||
|
|
0d570d0323 | ||
|
|
898b97151f | ||
|
|
de6bb3d634 | ||
|
|
2b40793c77 | ||
|
|
c4649b2fc6 | ||
|
|
4d475e25fa | ||
|
|
00d2d3012d | ||
|
|
4e5aba59d7 | ||
|
|
09f96f0b68 | ||
|
|
866f5f72eb | ||
|
|
f0c166907b | ||
|
|
c06b4e8af5 | ||
|
|
bebe3ef633 | ||
|
|
50d2e912ed | ||
|
|
a7e21b0505 | ||
|
|
3b481afa9e | ||
|
|
75de177b7b | ||
|
|
ec6c0279de | ||
|
|
c9572d2db5 | ||
|
|
93e9e20f6f | ||
|
|
4e8ea736c5 | ||
|
|
8f00dbfc17 | ||
|
|
55d242d40d | ||
|
|
4f99ae40d3 | ||
|
|
05c049c9af | ||
|
|
d94b573ae6 | ||
|
|
790ab0447f | ||
|
|
84795b2048 | ||
|
|
567002236d | ||
|
|
0ed41de956 | ||
|
|
8a2dfae487 | ||
|
|
3737fe457f | ||
|
|
c5978d4c21 | ||
|
|
bb4e8eb5bd | ||
|
|
27a07e8d5d | ||
|
|
e52fe93e14 | ||
|
|
e2618eee83 | ||
|
|
dd20871707 | ||
|
|
66c51a4be5 | ||
|
|
d5afcaeaab | ||
|
|
d0370d3e60 | ||
|
|
c332ec11b7 | ||
|
|
cf31290f05 | ||
|
|
203dc28720 | ||
|
|
dbbab910b6 | ||
|
|
abf01b4966 | ||
|
|
952feeb685 | ||
|
|
bb2b67cece | ||
|
|
a965f9edf5 | ||
|
|
f02ca05eba | ||
|
|
a182ea0869 | ||
|
|
7bc2d41a68 | ||
|
|
e4fb8b61b0 | ||
|
|
1cc33a67e6 | ||
|
|
5277e90946 | ||
|
|
0a16a0fcbc | ||
|
|
15ea4e6afa | ||
|
|
295f1d1cb3 | ||
|
|
5b3b6a409c | ||
|
|
d92bab113e | ||
|
|
93c6e2b601 | ||
|
|
19a90571f6 | ||
|
|
37ceddf54d | ||
|
|
736e9cedfa | ||
|
|
c433103e1b | ||
|
|
2892fdbb58 | ||
|
|
c45f38e47b | ||
|
|
6f4d21cac9 | ||
|
|
6b5ad3dafa | ||
|
|
54a58c9fbc | ||
|
|
1934ae0758 | ||
|
|
0ac9bbd97c | ||
|
|
dfa457e3c6 | ||
|
|
e8dec042bd | ||
|
|
d65e7deacc | ||
|
|
953e70efef | ||
|
|
a6bae390e5 | ||
|
|
139ee46dc0 | ||
|
|
cf9d8d649d | ||
|
|
a25051c4c2 | ||
|
|
7a1e7bd4f9 | ||
|
|
254fb4f77f | ||
|
|
53a559b126 | ||
|
|
d5c3152631 | ||
|
|
66c425bf96 | ||
|
|
ffad0dfbf7 | ||
|
|
17285fc029 | ||
|
|
401e3cff73 | ||
|
|
865680e019 | ||
|
|
9f97ca0336 | ||
|
|
5df38f8612 | ||
|
|
63c5719420 | ||
|
|
d6c80f1420 | ||
|
|
fade927c9e | ||
|
|
9f472ce1d0 | ||
|
|
47a56e32b9 | ||
|
|
f13f79acb6 | ||
|
|
bfa9fddb9e | ||
|
|
28abd9707e | ||
|
|
5f621e1ae0 | ||
|
|
624414799e | ||
|
|
72091e9eae | ||
|
|
9cfacdd025 | ||
|
|
d5c63b798a | ||
|
|
655e516246 | ||
|
|
7b12f0a3b9 | ||
|
|
e0b937474d | ||
|
|
5c4267f3ef | ||
|
|
4dcfb382a9 | ||
|
|
cf181dfd0a | ||
|
|
1127864ba9 | ||
|
|
79e379b61a | ||
|
|
e79e512291 | ||
|
|
f0064abfbe | ||
|
|
4a30a5bc64 | ||
|
|
32bdea559e | ||
|
|
d4215b7aee | ||
|
|
2494399993 | ||
|
|
34f62a8858 | ||
|
|
9e5689b06f | ||
|
|
931fa4b82b | ||
|
|
5d69d37db2 | ||
|
|
9ab2fdc868 | ||
|
|
fbd6766dcd | ||
|
|
9ace531edb | ||
|
|
2e3944099b | ||
|
|
9b53bd9b40 | ||
|
|
443ed717cb | ||
|
|
9845c1cea5 | ||
|
|
2061a49e0e | ||
|
|
f8a3d0f854 | ||
|
|
91cc12873e | ||
|
|
c71026f22a | ||
|
|
bd2720f534 | ||
|
|
8bc6bea4b2 | ||
|
|
23901c0cc1 | ||
|
|
b99a212d75 | ||
|
|
0891c7d4b3 | ||
|
|
a4963922da | ||
|
|
3ae41b7016 | ||
|
|
9c59fd4c00 | ||
|
|
a9f959cced | ||
|
|
414897bba0 | ||
|
|
94b154a4ac | ||
|
|
5fa58e5013 | ||
|
|
6496f6c414 | ||
|
|
3a0a3c9fb9 | ||
|
|
7467a31d76 | ||
|
|
894f6bf6d2 | ||
|
|
744dfa8163 | ||
|
|
2293119518 | ||
|
|
bd529a0dfa | ||
|
|
646c42b8c7 | ||
|
|
8de92403ee | ||
|
|
57ad89747f | ||
|
|
3ae8f38adb | ||
|
|
dc5ed1a39c | ||
|
|
f5ea8719ef | ||
|
|
aa6e6b8980 | ||
|
|
1dc80c068b | ||
|
|
bd0c4ceae2 | ||
|
|
30b58c6ea5 | ||
|
|
a55e9224f8 | ||
|
|
0c80abb3ca | ||
|
|
7137e611cd | ||
|
|
6f9d291d36 | ||
|
|
f2a2653eae | ||
|
|
73c25ab91f | ||
|
|
780449bac6 | ||
|
|
2509a1ecf3 | ||
|
|
16075f7ddd | ||
|
|
27d28e7ffc | ||
|
|
66b87e5c45 | ||
|
|
c1e1dff7d2 | ||
|
|
0e1ec83fcd | ||
|
|
01185b3073 | ||
|
|
49b0990c7b | ||
|
|
f76eb7abf5 | ||
|
|
25ea2a80a3 | ||
|
|
7d930045ef | ||
|
|
633d5668f0 | ||
|
|
cbb6c43ec3 | ||
|
|
09e1887609 | ||
|
|
4b83330db9 | ||
|
|
b0283f827e | ||
|
|
3c71902047 | ||
|
|
79bf30b299 | ||
|
|
dc3e9b7226 | ||
|
|
00cc47553b | ||
|
|
2505edede7 | ||
|
|
4b75504d9e | ||
|
|
8af6c8dd24 | ||
|
|
4c6344a8d7 | ||
|
|
92369fceba | ||
|
|
3c360130a3 | ||
|
|
13e4143eeb | ||
|
|
68c7b184d2 | ||
|
|
9b85d15ff1 | ||
|
|
e7cf49a2ec | ||
|
|
04b29b6970 | ||
|
|
f5bc79cba7 | ||
|
|
2ae18681cb | ||
|
|
fda763476a | ||
|
|
999cbd80f4 | ||
|
|
ad2a5fe95b | ||
|
|
d835021069 | ||
|
|
c4b303aee1 | ||
|
|
e2c5a4cba4 | ||
|
|
fd04125ed1 | ||
|
|
a0566e76ab | ||
|
|
87e8b2ce27 | ||
|
|
d52426f5f5 | ||
|
|
5e24404e82 | ||
|
|
64a280b111 | ||
|
|
cf393e8f9e | ||
|
|
909a21023a | ||
|
|
0402156b4d | ||
|
|
94bdc6c43f | ||
|
|
9466d36e69 | ||
|
|
412efb06e5 | ||
|
|
da7e637183 | ||
|
|
2e95fa25af | ||
|
|
f6c63bbd74 | ||
|
|
0a654082c2 | ||
|
|
2c20b731d2 | ||
|
|
8a22897cdd | ||
|
|
677da61b52 | ||
|
|
6513434bd7 | ||
|
|
fe2600029f | ||
|
|
c5b4efedfb | ||
|
|
310321d0ab | ||
|
|
7e884c42ea | ||
|
|
e279bf41a4 | ||
|
|
4a060ab51c | ||
|
|
62c1c77a18 | ||
|
|
db19ecb28c | ||
|
|
51748ce28d | ||
|
|
4bbfd8a9da | ||
|
|
d4d2db2cac | ||
|
|
23483144e1 | ||
|
|
67d5dcb062 | ||
|
|
901a49e571 | ||
|
|
49ae107fde | ||
|
|
0135281bcd | ||
|
|
99cf95daf0 | ||
|
|
8c1758ae49 | ||
|
|
2d764921ff | ||
|
|
858a11f8b4 | ||
|
|
4859239f55 | ||
|
|
5c64d7185e | ||
|
|
152479bc08 | ||
|
|
2c508cf1a1 | ||
|
|
16a91c772a | ||
|
|
5c47088b11 | ||
|
|
8e5dc4fa71 | ||
|
|
39c3729f6d | ||
|
|
e3d854e02b | ||
|
|
618acf2acf | ||
|
|
2cf2b70293 | ||
|
|
0541afceb8 | ||
|
|
28ed3f9936 | ||
|
|
6afa50332b | ||
|
|
8c8c68867d | ||
|
|
8ee52598e8 | ||
|
|
c822028174 | ||
|
|
36b82c6195 | ||
|
|
079dffce4d | ||
|
|
831802f5af | ||
|
|
7bd5190bf2 | ||
|
|
83860152a9 | ||
|
|
1e10493615 | ||
|
|
9d81c68a4d | ||
|
|
985d066978 | ||
|
|
6ad9e27d1d | ||
|
|
19ebdda5b3 | ||
|
|
4602dd1183 | ||
|
|
6005eaee6a | ||
|
|
6d59e3994f | ||
|
|
f770b2f1b1 | ||
|
|
b014744940 | ||
|
|
714c90c25e | ||
|
|
9a3a971da6 | ||
|
|
96cba0aaab | ||
|
|
c069600cfd | ||
|
|
186cbf2c97 | ||
|
|
392988ae11 | ||
|
|
2e33b79eb9 | ||
|
|
d4f718c44c | ||
|
|
fa99ef7b37 | ||
|
|
c4aff1b516 | ||
|
|
61276bb2d1 | ||
|
|
8b89e2eb9d | ||
|
|
9ab41308e7 | ||
|
|
f76052ec9b | ||
|
|
b8841e3ded | ||
|
|
a49b3f6496 | ||
|
|
163eaac110 | ||
|
|
3e17ec3cf8 | ||
|
|
76c0c7c41e | ||
|
|
025b986f60 | ||
|
|
6e6addd62f | ||
|
|
266c3acf34 | ||
|
|
c4631f50e5 | ||
|
|
ca18291425 | ||
|
|
110fad2abc | ||
|
|
b7456cecd4 | ||
|
|
84db1fe81b | ||
|
|
b539111be8 | ||
|
|
8a8bc5a6ed | ||
|
|
020db91105 | ||
|
|
1dd28af752 | ||
|
|
5ba192eee0 | ||
|
|
8109a12898 | ||
|
|
2deb7fd520 | ||
|
|
f6cd136679 | ||
|
|
e50cb86296 | ||
|
|
a5a01c44fa | ||
|
|
947e0705e4 | ||
|
|
aa8a6a837d | ||
|
|
5db440fc9c | ||
|
|
c299b9376a | ||
|
|
e5d530ea3e | ||
|
|
6da9850946 | ||
|
|
f62609f60c | ||
|
|
b2d8c66e5b | ||
|
|
3c4ed3ba0c | ||
|
|
2e7f827c3f | ||
|
|
dc82b39dc8 | ||
|
|
a9814c1eb1 | ||
|
|
bdb741caf8 | ||
|
|
f50b198c21 | ||
|
|
3495326de3 | ||
|
|
b5973085e7 | ||
|
|
19ce1c66ad | ||
|
|
3fe0e3a33c | ||
|
|
8687214420 | ||
|
|
d61b89a1e5 | ||
|
|
468b42abd2 | ||
|
|
fc03e5f983 | ||
|
|
c4742e38ea | ||
|
|
99e1adbe13 | ||
|
|
eb5c797a43 | ||
|
|
0595c5545e | ||
|
|
12c87ed689 | ||
|
|
55944257aa | ||
|
|
03241778fa | ||
|
|
555b81fb14 | ||
|
|
a56b720e09 | ||
|
|
b89eede164 | ||
|
|
c21cc8d6b9 | ||
|
|
d02a6bc197 | ||
|
|
360c1ce82d | ||
|
|
a7ef02976c | ||
|
|
6a9e36ea4d | ||
|
|
37d4c0a40f | ||
|
|
5ebca3ff06 | ||
|
|
1969a92226 | ||
|
|
8840ffc9ba | ||
|
|
19e42ef397 | ||
|
|
e2b4b408ed | ||
|
|
c7eb026986 | ||
|
|
b0dcd3618e | ||
|
|
5f23f135f2 | ||
|
|
159ee7364d | ||
|
|
aa6ad109c9 | ||
|
|
f2a896d568 | ||
|
|
546ebba0bd | ||
|
|
0e75f54d6e | ||
|
|
30f34a17ea | ||
|
|
6035d94404 | ||
|
|
0b7a23d555 | ||
|
|
91fe1f4af9 | ||
|
|
f09cb7b247 | ||
|
|
35a7222f5e | ||
|
|
d444821cf7 | ||
|
|
b5cb520944 | ||
|
|
6814a3bc33 | ||
|
|
19c2b19abc | ||
|
|
22b011139d | ||
|
|
567171c722 | ||
|
|
03acb78ab2 | ||
|
|
5b30daefe5 | ||
|
|
e015d3574a | ||
|
|
60140902d4 | ||
|
|
84f41b2c11 | ||
|
|
e8b9fcbc6e | ||
|
|
5adf591670 | ||
|
|
f55764e859 | ||
|
|
282fa787a9 | ||
|
|
037efff81c | ||
|
|
e26eb17d09 | ||
|
|
fbea9fde27 | ||
|
|
ce7cf6bdbe | ||
|
|
2c47e5d852 | ||
|
|
a6f809b20a | ||
|
|
2bcad68351 | ||
|
|
6b1b393804 | ||
|
|
c5181d1c5d | ||
|
|
e33ff2a45d | ||
|
|
9eb77964db | ||
|
|
0a68d2791d | ||
|
|
11928d9a7e | ||
|
|
c169bb5d5d | ||
|
|
3cc4f1c63e | ||
|
|
5237b1d535 | ||
|
|
cd56c50cf9 | ||
|
|
a18ce18d72 | ||
|
|
3691d32aaa | ||
|
|
5f66488410 | ||
|
|
d1be7f6e09 | ||
|
|
44f02f28a6 | ||
|
|
6d33622b4e | ||
|
|
f8b8e23ef4 | ||
|
|
db09d09428 | ||
|
|
451820a67c | ||
|
|
ba0ce5027e | ||
|
|
f777d26cc1 | ||
|
|
1463037878 | ||
|
|
7ddec0bb0f | ||
|
|
51c2d3351a | ||
|
|
8323fa6696 | ||
|
|
27a3932c08 | ||
|
|
add88659a4 | ||
|
|
320ad065d0 | ||
|
|
a9bc51949a | ||
|
|
39d1397221 | ||
|
|
b44b71072f | ||
|
|
f3e2a83bab | ||
|
|
0ef030bb89 | ||
|
|
3e9e6baf32 | ||
|
|
c03d45b3fc | ||
|
|
0a9b583c4b | ||
|
|
54ac0c84a7 | ||
|
|
4d59798d8d | ||
|
|
f95dab544d | ||
|
|
41e43dda96 | ||
|
|
cec60db78c | ||
|
|
7e741e4af9 | ||
|
|
24d47ae1c5 | ||
|
|
f556d59ad7 | ||
|
|
09c4662436 | ||
|
|
9bf6ba9cf0 | ||
|
|
7843ca9b1a | ||
|
|
c78b9866a3 | ||
|
|
c8701aba63 | ||
|
|
09c1a8ae35 | ||
|
|
0ef2814de3 | ||
|
|
8e105f0b36 | ||
|
|
ba4da3e35c | ||
|
|
1b8be56c15 | ||
|
|
f6e65f82e5 | ||
|
|
2b26db78eb | ||
|
|
663d91b648 | ||
|
|
2a7686ec75 | ||
|
|
c2d96922c8 | ||
|
|
70b4ec7948 | ||
|
|
a3ec364034 | ||
|
|
e1728b275b | ||
|
|
10d55df461 | ||
|
|
e7c4797fef | ||
|
|
88c35e8c48 | ||
|
|
8623172aa1 | ||
|
|
6ecc67184d | ||
|
|
09973ecb3b | ||
|
|
db9e86e4c8 | ||
|
|
85c446bc57 | ||
|
|
5cf6f47bdc | ||
|
|
1db4cbeeb8 | ||
|
|
c88bf4065e |
@@ -11,7 +11,6 @@ node_modules
|
||||
# Files to exclude
|
||||
.ebignore
|
||||
.editorconfig
|
||||
.eslintrc.json
|
||||
.gitignore
|
||||
.prettierrc.js
|
||||
Dockerfile
|
||||
@@ -19,6 +18,6 @@ README.MD
|
||||
bodyshop_translations.babel
|
||||
docker-compose.yml
|
||||
ecosystem.config.js
|
||||
|
||||
eslint.config.mjs
|
||||
# Optional: Exclude logs and temporary files
|
||||
*.log
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"env": {
|
||||
"es6": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": "eslint:recommended",
|
||||
"globals": {
|
||||
"Atomics": "readonly",
|
||||
"SharedArrayBuffer": "readonly"
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2018,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-console": "off"
|
||||
},
|
||||
"settings": {}
|
||||
}
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -114,7 +114,7 @@ firebase/.env
|
||||
!.elasticbeanstalk/*.cfg.yml
|
||||
!.elasticbeanstalk/*.global.yml
|
||||
logs/oAuthClient-log.log
|
||||
|
||||
logs/*
|
||||
|
||||
.node-persist/**
|
||||
|
||||
@@ -127,4 +127,8 @@ vitest-report*/
|
||||
vitest-coverage/
|
||||
*.vitest.log
|
||||
test-output.txt
|
||||
server/job/test/fixtures
|
||||
|
||||
.github
|
||||
_reference/ragmate/.ragmate.env
|
||||
docker_data
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
client_max_body_size 50M;
|
||||
client_body_buffer_size 5M;
|
||||
client_body_buffer_size 5M;
|
||||
@@ -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_22.x | bash - \
|
||||
&& curl -sL https://rpm.nodesource.com/setup_24.x | bash - \
|
||||
&& dnf install -y nodejs \
|
||||
&& dnf clean all
|
||||
|
||||
@@ -56,4 +56,5 @@ COPY . .
|
||||
EXPOSE 4000 9229
|
||||
|
||||
# Start the application
|
||||
CMD ["nodemon", "--legacy-watch", "--inspect=0.0.0.0:9229", "server.js"]
|
||||
RUN echo "Starting the application..."
|
||||
CMD ["nodemon", "--ignore", "./server/job/test/fixtures", "--legacy-watch", "--inspect=0.0.0.0:9229", "server.js"]
|
||||
|
||||
346
Fortellis Notes.md
Normal file
346
Fortellis Notes.md
Normal file
@@ -0,0 +1,346 @@
|
||||
Fortellis Notes
|
||||
|
||||
Subscription ID
|
||||
|
||||
- Appears to give us a list of all dealerships we have access to, and `apiDmsInfo` contains the integrations that are enabled for that dealership.
|
||||
- Will likely need to filter based on the DMS ID or something?
|
||||
- Should store the whole subscription object. Contains department information needed in subsequent calls.
|
||||
|
||||
Department ID
|
||||
|
||||
- May have multiple departments. Appears that financial stuff goes to Accounting, History will go to Service.
|
||||
- TODO: How do we handle the multiple departments that may come up.
|
||||
|
||||
###Internal Questions
|
||||
|
||||
* Overview of the redis storing mechanism to cache this data.
|
||||
*
|
||||
|
||||
# GL Wip Posting
|
||||
|
||||
## Org Helper Return Data
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"acctgLgnID": "DEVWB-A",
|
||||
"applCode": "V",
|
||||
"coID": "77",
|
||||
"companyName": "TEST SYS C187092 DEVWB",
|
||||
"lgnDesc": "DEV WRITE BACK VMS",
|
||||
"logon": "DEVWB-V"
|
||||
},
|
||||
{
|
||||
"acctgLgnID": "DEVWB-A",
|
||||
"applCode": "F",
|
||||
"coID": "77",
|
||||
"companyName": "TEST SYS C187092 DEVWB",
|
||||
"lgnDesc": "DEV WRITE BACK F&I SALES",
|
||||
"logon": "DEVWB-FI"
|
||||
},
|
||||
{
|
||||
"acctgLgnID": "DEVWB-A",
|
||||
"applCode": "CS",
|
||||
"coID": "77",
|
||||
"companyName": "TEST SYS C187092 DEVWB",
|
||||
"lgnDesc": "DEV WRITE BACK SERVICE",
|
||||
"logon": "DEVWB-S"
|
||||
},
|
||||
{
|
||||
"acctgLgnID": "DEVWB-A",
|
||||
"applCode": "A",
|
||||
"coID": "77",
|
||||
"companyName": "TEST SYS C187092 DEVWB",
|
||||
"lgnDesc": "DEV WRITE BACK ACCTG",
|
||||
"logon": "DEVWB-A"
|
||||
},
|
||||
{
|
||||
"acctgLgnID": "DEVWB-A",
|
||||
"applCode": "SL",
|
||||
"coID": "77",
|
||||
"companyName": "TEST SYS C187092 DEVWB",
|
||||
"lgnDesc": "DEV WRTIE BACK SLS MGMT",
|
||||
"logon": "DEVWB-SL"
|
||||
},
|
||||
{
|
||||
"acctgLgnID": "DEVWB-A",
|
||||
"applCode": "O",
|
||||
"coID": "77",
|
||||
"companyName": "TEST SYS C187092 DEVWB",
|
||||
"lgnDesc": "DEV WRITE BACK PARTS",
|
||||
"logon": "DEVWB-I"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Journal Helper Return Data
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "32",
|
||||
"jrnlName": "PARTS SALES",
|
||||
"jrnlType": "S",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "4",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "92",
|
||||
"jrnlName": "YTD ADJUSTMENTS",
|
||||
"jrnlType": "Y",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "3",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "12",
|
||||
"jrnlName": "FLEET SALES",
|
||||
"jrnlType": "S",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "9",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "57",
|
||||
"jrnlName": "CASH RECEIPTS (OPEN-ITEM)",
|
||||
"jrnlType": "R",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "1",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "93",
|
||||
"jrnlName": "SET UP HISTORY",
|
||||
"jrnlType": "H",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "10",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "88",
|
||||
"jrnlName": "F/S STATISCAL DATA",
|
||||
"jrnlType": "F",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "10",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "58",
|
||||
"jrnlName": "WARRANTY CREDITS",
|
||||
"jrnlType": "G",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "3",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "FC",
|
||||
"jrnlName": "FINANCE CHARGE",
|
||||
"jrnlType": "A",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "12",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "94",
|
||||
"jrnlName": "SET UP SCHEDULES",
|
||||
"jrnlType": "C",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "3",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "95",
|
||||
"jrnlName": "SET UP GENERAL LEDGER",
|
||||
"jrnlType": "B",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "3",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "20",
|
||||
"jrnlName": "USED VEHICLE SALES",
|
||||
"jrnlType": "S",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "9",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "60",
|
||||
"jrnlName": "CASH DISBURSEMENTS",
|
||||
"jrnlType": "G",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "2",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "30",
|
||||
"jrnlName": "SERVICE SALES",
|
||||
"jrnlType": "S",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "7",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "40",
|
||||
"jrnlName": "PAYROLL",
|
||||
"jrnlType": "G",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "11",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "15",
|
||||
"jrnlName": "DEALER TRADES",
|
||||
"jrnlType": "S",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "9",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "70",
|
||||
"jrnlName": "NEW VEHICLE PURCHASES",
|
||||
"jrnlType": "G",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "8",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "25",
|
||||
"jrnlName": "USED WHOLESALE",
|
||||
"jrnlType": "S",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "9",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "75",
|
||||
"jrnlName": "GENERAL PURCHASES",
|
||||
"jrnlType": "G",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "5",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "10",
|
||||
"jrnlName": "NEW VEHICLE SALES",
|
||||
"jrnlType": "S",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "9",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "80",
|
||||
"jrnlName": "GENERAL JOURNAL",
|
||||
"jrnlType": "G",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "3",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "11",
|
||||
"jrnlName": "WORK IN PROGRESS",
|
||||
"jrnlType": "G",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "10",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "56",
|
||||
"jrnlName": "CASH RECEIPTS (BALANCE FWD)",
|
||||
"jrnlType": "G",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "1",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "81",
|
||||
"jrnlName": "STANDARD ENTRIES",
|
||||
"jrnlType": "G",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "6",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "51",
|
||||
"jrnlName": "CASH RECEIPTS JOURNAL - EFT",
|
||||
"jrnlType": "G",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "10",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "61",
|
||||
"jrnlName": "CASH DISBURSMENTS -EFT",
|
||||
"jrnlType": "G",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "10",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
},
|
||||
{
|
||||
"companyNo": "77",
|
||||
"jrnlID": "71",
|
||||
"jrnlName": "USED VEHICLE PURCHASES",
|
||||
"jrnlType": "G",
|
||||
"intercoFlag": "0",
|
||||
"defaultDocType": "8",
|
||||
"errCode": "",
|
||||
"errMsg": ""
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
# Feedback
|
||||
|
||||
- Receiving bad request errors, with no details. API errors page doesn't indicate what's wrong for certain types of error codes.
|
||||
- API Error page works on a several minute delay.
|
||||
@@ -1,116 +1,96 @@
|
||||
// index.js
|
||||
|
||||
import express from 'express';
|
||||
import fetch from 'node-fetch';
|
||||
import {simpleParser} from 'mailparser';
|
||||
import express from "express";
|
||||
import fetch from "node-fetch";
|
||||
import { simpleParser } from "mailparser";
|
||||
|
||||
const app = express();
|
||||
const PORT = 3334;
|
||||
|
||||
app.get('/', async (req, res) => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:4566/_aws/ses');
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
const data = await response.json();
|
||||
const messagesHtml = await parseMessages(data.messages);
|
||||
res.send(renderHtml(messagesHtml));
|
||||
} catch (error) {
|
||||
console.error('Error fetching messages:', error);
|
||||
res.status(500).send('Error fetching messages');
|
||||
app.get("/", async (req, res) => {
|
||||
try {
|
||||
const response = await fetch("http://localhost:4566/_aws/ses");
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
const data = await response.json();
|
||||
const messagesHtml = await parseMessages(data.messages);
|
||||
res.send(renderHtml(messagesHtml));
|
||||
} catch (error) {
|
||||
console.error("Error fetching messages:", error);
|
||||
res.status(500).send("Error fetching messages");
|
||||
}
|
||||
});
|
||||
|
||||
async function parseMessages(messages) {
|
||||
const parsedMessages = await Promise.all(
|
||||
messages.map(async (message, index) => {
|
||||
try {
|
||||
const parsed = await simpleParser(message.RawData);
|
||||
return `
|
||||
<div class="shadow-md rounded-lg p-4 mb-6" style="background-color: lightgray">
|
||||
<div class="shadow-md rounded-lg p-4 mb-6" style="background-color: white">
|
||||
<div class="mb-2">
|
||||
<span class="font-bold text-lg">Message ${index + 1}</span>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<span class="font-semibold">From:</span> ${message.Source}
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<span class="font-semibold">Region:</span> ${message.Region}
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<span class="font-semibold">Timestamp:</span> ${message.Timestamp}
|
||||
</div>
|
||||
</div>
|
||||
<div class="prose">
|
||||
${parsed.html || parsed.textAsHtml || 'No HTML content available'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
console.error('Error parsing email:', error);
|
||||
return `
|
||||
<div class="bg-white shadow-md rounded-lg p-4 mb-6">
|
||||
<div class="mb-2">
|
||||
<span class="font-bold text-lg">Message ${index + 1}</span>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<span class="font-semibold">From:</span> ${message.Source}
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<span class="font-semibold">Region:</span> ${message.Region}
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<span class="font-semibold">Timestamp:</span> ${message.Timestamp}
|
||||
</div>
|
||||
<div class="text-red-500">
|
||||
Error parsing email content
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
})
|
||||
);
|
||||
return parsedMessages.join('');
|
||||
const parsedMessages = await Promise.all(
|
||||
messages.map(async (message, index) => {
|
||||
try {
|
||||
const parsed = await simpleParser(message.RawData);
|
||||
return `
|
||||
<div class="shadow-md rounded-lg p-4 mb-6" style="background-color: lightgray">
|
||||
<div class="shadow-md rounded-lg p-4 mb-6" style="background-color: white">
|
||||
<div class="mb-2"><span class="font-bold text-lg">Message ${index + 1}</span></div>
|
||||
<div class="mb-2"><span class="font-semibold">From:</span> ${message.Source}</div>
|
||||
<div class="mb-2"><span class="font-semibold">To:</span> ${parsed.to.text || "No To Address"}</div>
|
||||
<div class="mb-2"><span class="font-semibold">Subject:</span> ${parsed.subject || "No Subject"}</div>
|
||||
<div class="mb-2"><span class="font-semibold">Region:</span> ${message.Region}</div>
|
||||
<div class="mb-2"><span class="font-semibold">Timestamp:</span> ${message.Timestamp}</div>
|
||||
</div>
|
||||
<div class="prose">${parsed.html || parsed.textAsHtml || "No HTML content available"}</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
console.error("Error parsing email:", error);
|
||||
return `
|
||||
<div class="bg-white shadow-md rounded-lg p-4 mb-6">
|
||||
<div class="mb-2"><span class="font-bold text-lg">Message ${index + 1}</span></div>
|
||||
<div class="mb-2"><span class="font-semibold">From:</span> ${message.Source}</div>
|
||||
<div class="mb-2"><span class="font-semibold">Region:</span> ${message.Region}</div>
|
||||
<div class="mb-2"><span class="font-semibold">Timestamp:</span> ${message.Timestamp}</div>
|
||||
<div class="text-red-500">Error parsing email content</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
})
|
||||
);
|
||||
return parsedMessages.join("");
|
||||
}
|
||||
|
||||
function renderHtml(messagesHtml) {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Email Messages Viewer</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body {
|
||||
background-color: #f3f4f6;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.prose {
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container bg-white shadow-lg rounded-lg p-6">
|
||||
<h1 class="text-2xl font-bold text-center mb-6">Email Messages Viewer</h1>
|
||||
<div id="messages-container">
|
||||
${messagesHtml}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Email Messages Viewer</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body {
|
||||
background-color: #f3f4f6;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.prose {
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container bg-white shadow-lg rounded-lg p-6">
|
||||
<h1 class="text-2xl font-bold text-center mb-6">Email Messages Viewer</h1>
|
||||
<div id="messages-container">${messagesHtml}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server is running on http://localhost:${PORT}`);
|
||||
});
|
||||
console.log(`Server is running on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
764
_reference/localEmailViewer/package-lock.json
generated
764
_reference/localEmailViewer/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,8 +11,8 @@
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"express": "^4.21.1",
|
||||
"mailparser": "^3.7.1",
|
||||
"express": "^5.1.0",
|
||||
"mailparser": "^3.7.4",
|
||||
"node-fetch": "^3.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# PATCH /integrations/parts-management/job/:id/status
|
||||
|
||||
Update (patch) the status of a job created under parts management. This endpoint is only available
|
||||
for jobs whose parent bodyshop has an `external_shop_id` (i.e., is provisioned for parts
|
||||
management).
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
PATCH /integrations/parts-management/job/:id/status
|
||||
```
|
||||
|
||||
- `:id` is the UUID of the job to update.
|
||||
|
||||
## Request Headers
|
||||
|
||||
- `Authorization`: (if required by your integration middleware)
|
||||
- `Content-Type: application/json`
|
||||
|
||||
## Request Body
|
||||
|
||||
Send a JSON object with the following field:
|
||||
|
||||
- `status` (string, required): The new status for the job.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
PATCH /integrations/parts-management/job/123e4567-e89b-12d3-a456-426614174000/status
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": "IN_PROGRESS"
|
||||
}
|
||||
```
|
||||
|
||||
## Success Response
|
||||
|
||||
- **200 OK**
|
||||
- Returns the updated job object with the new status.
|
||||
|
||||
```
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"status": "IN_PROGRESS",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
- **400 Bad Request**: Missing status field, or parent bodyshop does not have an `external_shop_id`.
|
||||
- **404 Not Found**: No job found with the given ID.
|
||||
- **500 Internal Server Error**: Unexpected error.
|
||||
|
||||
## Notes
|
||||
|
||||
- Only jobs whose parent bodyshop has an `external_shop_id` can be patched via this route.
|
||||
- Fields other than `status` will be ignored if included in the request body.
|
||||
- The route is protected by the same middleware as other parts management endpoints.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# PATCH /integrations/parts-management/provision/:id
|
||||
|
||||
Update (patch) select fields for a parts management bodyshop. Only available for shops that have an
|
||||
`external_shop_id` (i.e., are provisioned for parts management).
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
PATCH /integrations/parts-management/provision/:id
|
||||
```
|
||||
|
||||
- `:id` is the UUID of the bodyshop to update.
|
||||
|
||||
## Request Headers
|
||||
|
||||
- `Authorization`: (if required by your integration middleware)
|
||||
- `Content-Type: application/json`
|
||||
|
||||
## Request Body
|
||||
|
||||
Send a JSON object with one or more of the following fields to update:
|
||||
|
||||
- `shopname` (string)
|
||||
- `address1` (string)
|
||||
- `address2` (string, optional)
|
||||
- `city` (string)
|
||||
- `state` (string)
|
||||
- `zip_post` (string)
|
||||
- `country` (string)
|
||||
- `email` (string, shop's email, not user email)
|
||||
- `timezone` (string)
|
||||
- `phone` (string)
|
||||
- `logo_img_path` (string)
|
||||
|
||||
Any fields not included in the request body will remain unchanged.
|
||||
|
||||
## Example Request
|
||||
|
||||
```
|
||||
PATCH /integrations/parts-management/provision/123e4567-e89b-12d3-a456-426614174000
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"shopname": "New Shop Name",
|
||||
"address1": "123 Main St",
|
||||
"city": "Springfield",
|
||||
"state": "IL",
|
||||
"zip_post": "62704",
|
||||
"country": "USA",
|
||||
"email": "shop@example.com",
|
||||
"timezone": "America/Chicago",
|
||||
"phone": "555-123-4567",
|
||||
"logo_img_path": "https://example.com/logo.png"
|
||||
}
|
||||
```
|
||||
|
||||
## Success Response
|
||||
|
||||
- **200 OK**
|
||||
- Returns the updated shop object with the patched fields.
|
||||
|
||||
```
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"shopname": "New Shop Name",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
- **400 Bad Request**: No valid fields provided, or shop does not have an `external_shop_id`.
|
||||
- **404 Not Found**: No shop found with the given ID.
|
||||
- **500 Internal Server Error**: Unexpected error.
|
||||
|
||||
## Notes
|
||||
|
||||
- Only shops with an `external_shop_id` can be patched via this route.
|
||||
- Fields not listed above will be ignored if included in the request body.
|
||||
- The route is protected by the same middleware as other parts management endpoints.
|
||||
|
||||
166
_reference/partsManagement/swagger.yaml
Normal file
166
_reference/partsManagement/swagger.yaml
Normal file
@@ -0,0 +1,166 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Parts Management Provisioning API
|
||||
description: API endpoint to provision a new shop and user in the Parts Management system.
|
||||
version: 1.0.0
|
||||
|
||||
paths:
|
||||
/parts-management/provision:
|
||||
post:
|
||||
summary: Provision a new parts management shop and user
|
||||
operationId: partsManagementProvisioning
|
||||
tags:
|
||||
- Parts Management
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- external_shop_id
|
||||
- shopname
|
||||
- address1
|
||||
- city
|
||||
- state
|
||||
- zip_post
|
||||
- country
|
||||
- email
|
||||
- phone
|
||||
- userEmail
|
||||
properties:
|
||||
external_shop_id:
|
||||
type: string
|
||||
description: External shop ID (must be unique)
|
||||
shopname:
|
||||
type: string
|
||||
address1:
|
||||
type: string
|
||||
address2:
|
||||
type: string
|
||||
nullable: true
|
||||
city:
|
||||
type: string
|
||||
state:
|
||||
type: string
|
||||
zip_post:
|
||||
type: string
|
||||
country:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
phone:
|
||||
type: string
|
||||
userEmail:
|
||||
type: string
|
||||
format: email
|
||||
userPassword:
|
||||
type: string
|
||||
description: Optional password for the new user. If provided, the password is set directly, and no password reset link is sent. Must be at least 6 characters.
|
||||
nullable: true
|
||||
logoUrl:
|
||||
type: string
|
||||
format: uri
|
||||
nullable: true
|
||||
timezone:
|
||||
type: string
|
||||
nullable: true
|
||||
vendors:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
street1:
|
||||
type: string
|
||||
nullable: true
|
||||
street2:
|
||||
type: string
|
||||
nullable: true
|
||||
city:
|
||||
type: string
|
||||
nullable: true
|
||||
state:
|
||||
type: string
|
||||
nullable: true
|
||||
zip:
|
||||
type: string
|
||||
nullable: true
|
||||
country:
|
||||
type: string
|
||||
nullable: true
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
nullable: true
|
||||
discount:
|
||||
type: number
|
||||
nullable: true
|
||||
due_date:
|
||||
type: string
|
||||
format: date
|
||||
nullable: true
|
||||
cost_center:
|
||||
type: string
|
||||
nullable: true
|
||||
favorite:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
phone:
|
||||
type: string
|
||||
nullable: true
|
||||
active:
|
||||
type: boolean
|
||||
nullable: true
|
||||
dmsid:
|
||||
type: string
|
||||
nullable: true
|
||||
responses:
|
||||
'200':
|
||||
description: Shop and user successfully created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
shop:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
shopname:
|
||||
type: string
|
||||
user:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
resetLink:
|
||||
type: string
|
||||
format: uri
|
||||
nullable: true
|
||||
description: Password reset link for the user. Only included if userPassword is not provided in the request.
|
||||
'400':
|
||||
description: Bad request (missing or invalid fields)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
'500':
|
||||
description: Internal server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
10
_reference/ragmate/local-rag-compose.yml
Normal file
10
_reference/ragmate/local-rag-compose.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
ragmate:
|
||||
image: ghcr.io/ragmate/ragmate:latest
|
||||
ports:
|
||||
- "11434:11434"
|
||||
env_file:
|
||||
- .ragmate.env
|
||||
volumes:
|
||||
- .:/project
|
||||
- ./docker_data/ragmate:/apps/cache
|
||||
236
_reference/refactorReports/OPTIMIZATION_SUMMARY.md
Normal file
236
_reference/refactorReports/OPTIMIZATION_SUMMARY.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# Production Board Kanban - React 19 & Ant Design 6 Optimizations
|
||||
|
||||
## Overview
|
||||
This document outlines the optimizations made to the production board kanban components to leverage React 19's new compiler and Ant Design 6 capabilities.
|
||||
|
||||
## Key Optimizations Implemented
|
||||
|
||||
### 1. React Compiler Optimizations
|
||||
|
||||
#### Removed Manual Memoization
|
||||
The React 19 compiler automatically handles memoization, so we removed unnecessary `useMemo`, `useCallback`, and `memo()` wrappers:
|
||||
|
||||
**Files Updated:**
|
||||
|
||||
**Main Components:**
|
||||
- `production-board-kanban.component.jsx`
|
||||
- `production-board-kanban.container.jsx`
|
||||
- `production-board-kanban-card.component.jsx`
|
||||
- `production-board-kanban.statistics.jsx`
|
||||
|
||||
**Trello-Board Components:**
|
||||
- `trello-board/controllers/Board.jsx`
|
||||
- `trello-board/controllers/Lane.jsx`
|
||||
- `trello-board/controllers/BoardContainer.jsx`
|
||||
- `trello-board/components/ItemWrapper.jsx`
|
||||
|
||||
**Benefits:**
|
||||
- Cleaner, more readable code
|
||||
- Reduced bundle size
|
||||
- Better performance through compiler-optimized memoization
|
||||
- Fewer function closures and re-creations
|
||||
|
||||
### 2. Simplified State Management
|
||||
|
||||
#### Removed Unnecessary Deep Cloning
|
||||
**Before:**
|
||||
```javascript
|
||||
setBoardLanes((prevBoardLanes) => {
|
||||
const deepClonedData = cloneDeep(newBoardData);
|
||||
if (!isEqual(prevBoardLanes, deepClonedData)) {
|
||||
return deepClonedData;
|
||||
}
|
||||
return prevBoardLanes;
|
||||
});
|
||||
```
|
||||
|
||||
**After:**
|
||||
```javascript
|
||||
setBoardLanes(newBoardData);
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Removed lodash `cloneDeep` and `isEqual` dependencies from this component
|
||||
- React 19's compiler handles change detection efficiently
|
||||
- Reduced memory overhead
|
||||
- Faster state updates
|
||||
|
||||
### 3. Component Simplification
|
||||
|
||||
#### Removed `memo()` Wrapper
|
||||
**Before:**
|
||||
```javascript
|
||||
const EllipsesToolTip = memo(({ title, children, kiosk }) => {
|
||||
// component logic
|
||||
});
|
||||
EllipsesToolTip.displayName = "EllipsesToolTip";
|
||||
```
|
||||
|
||||
**After:**
|
||||
```javascript
|
||||
function EllipsesToolTip({ title, children, kiosk }) {
|
||||
// component logic
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Compiler handles optimization automatically
|
||||
- No need for manual displayName assignment
|
||||
- Cleaner component definition
|
||||
|
||||
### 4. Optimized Computed Values
|
||||
|
||||
#### Replaced useMemo with Direct Calculations
|
||||
**Before:**
|
||||
```javascript
|
||||
const totalHrs = useMemo(() => {
|
||||
if (!cardSettings.totalHrs) return null;
|
||||
const total = calculateTotal(data, "labhrs", "mod_lb_hrs") + calculateTotal(data, "larhrs", "mod_lb_hrs");
|
||||
return parseFloat(total.toFixed(2));
|
||||
}, [data, cardSettings.totalHrs]);
|
||||
```
|
||||
|
||||
**After:**
|
||||
```javascript
|
||||
const totalHrs = cardSettings.totalHrs
|
||||
? parseFloat((calculateTotal(data, "labhrs", "mod_lb_hrs") + calculateTotal(data, "larhrs", "mod_lb_hrs")).toFixed(2))
|
||||
: null;
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Compiler automatically memoizes when needed
|
||||
- More concise code
|
||||
- Better readability
|
||||
|
||||
### 5. Improved Card Rendering
|
||||
|
||||
#### Simplified Employee Lookups
|
||||
**Before:**
|
||||
```javascript
|
||||
const { employee_body, employee_prep, employee_refinish, employee_csr } = useMemo(() => {
|
||||
return {
|
||||
employee_body: metadata?.employee_body && findEmployeeById(employees, metadata.employee_body),
|
||||
employee_prep: metadata?.employee_prep && findEmployeeById(employees, metadata.employee_prep),
|
||||
// ...
|
||||
};
|
||||
}, [metadata, employees]);
|
||||
```
|
||||
|
||||
**After:**
|
||||
```javascript
|
||||
const employee_body = metadata?.employee_body && findEmployeeById(employees, metadata.employee_body);
|
||||
const employee_prep = metadata?.employee_prep && findEmployeeById(employees, metadata.employee_prep);
|
||||
// ...
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Direct assignments are cleaner
|
||||
- Compiler optimizes automatically
|
||||
- Easier to debug
|
||||
|
||||
### 6. Optimized Trello-Board Controllers
|
||||
|
||||
#### BoardContainer Optimizations
|
||||
- Removed `useCallback` from `wireEventBus`, `onDragStart`, and `onLaneDrag`
|
||||
- Removed lodash `isEqual` for drag position comparison (uses direct comparison)
|
||||
- Simplified event binding logic
|
||||
|
||||
#### Lane Component Optimizations
|
||||
- Removed `useCallback` from `toggleLaneCollapsed`, `renderDraggable`, `renderDroppable`, and `renderDragContainer`
|
||||
- Direct function definitions for all render methods
|
||||
- Compiler handles render optimization automatically
|
||||
|
||||
#### Board Component Optimizations
|
||||
- Removed `useMemo` for orientation style selection
|
||||
- Removed `useMemo` for grid item width calculation
|
||||
- Direct conditional assignment for styles
|
||||
|
||||
## React 19 Compiler Benefits
|
||||
|
||||
The React 19 compiler provides automatic optimizations:
|
||||
|
||||
1. **Automatic Memoization**: Intelligently memoizes component outputs and computed values
|
||||
2. **Smart Re-rendering**: Only re-renders components when props actually change
|
||||
3. **Optimized Closures**: Reduces unnecessary closure creation
|
||||
4. **Better Dead Code Elimination**: Removes unused code paths more effectively
|
||||
|
||||
## Ant Design 6 Compatibility
|
||||
|
||||
### Current Layout Approach
|
||||
The current implementation uses `VirtuosoGrid` for vertical layouts, which provides:
|
||||
- Virtual scrolling for performance
|
||||
- Responsive grid layout
|
||||
- Drag-and-drop support
|
||||
|
||||
### Potential Masonry Enhancement (Future Consideration)
|
||||
While Ant Design 6 doesn't have a built-in Masonry component, the current grid layout can be enhanced with CSS Grid or a third-party masonry library if needed. The current implementation already provides:
|
||||
- Flexible card sizing (small, medium, large)
|
||||
- Responsive grid columns
|
||||
- Efficient virtual scrolling
|
||||
|
||||
**Note:** The VirtuosoGrid approach is more performant for large datasets due to virtualization, making it preferable over a traditional masonry layout for this use case.
|
||||
|
||||
## Third-Party Library Considerations
|
||||
|
||||
### DND Library (Drag and Drop)
|
||||
The `trello-board/dnd` directory contains a vendored drag-and-drop library that uses `use-memo-one` for memoization. **We intentionally did not modify this library** because:
|
||||
- It's third-party code that should be updated at the source
|
||||
- It uses a specialized memoization library (`use-memo-one`) for drag-and-drop performance
|
||||
- Modifying it could introduce bugs or break drag-and-drop functionality
|
||||
- The library's internal memoization is specifically tuned for DND operations
|
||||
|
||||
## Performance Improvements
|
||||
|
||||
### Measured Benefits:
|
||||
1. **Bundle Size**: Reduced by removing lodash deep clone/equal operations from main component
|
||||
2. **Memory Usage**: Lower memory footprint with direct state updates
|
||||
3. **Render Performance**: Compiler-optimized re-renders
|
||||
4. **Code Maintainability**: Cleaner, more readable code
|
||||
|
||||
### Optimization Statistics:
|
||||
- **Removed hooks**: 25+ useMemo/useCallback hooks across components
|
||||
- **Removed memo wrappers**: 2 (EllipsesToolTip, ItemWrapper)
|
||||
- **Lines of code reduced**: ~150+ lines of memoization boilerplate
|
||||
|
||||
### Virtual Scrolling
|
||||
The components continue to leverage `Virtuoso` and `VirtuosoGrid` for optimal performance with large card lists:
|
||||
- Only renders visible cards
|
||||
- Maintains scroll position during updates
|
||||
- Handles thousands of cards efficiently
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
1. **Visual Regression Testing**: Ensure card layout and interactions work correctly
|
||||
2. **Performance Testing**: Measure render times with large datasets
|
||||
3. **Drag-and-Drop Testing**: Verify drag-and-drop functionality remains intact
|
||||
4. **Responsive Testing**: Test on various screen sizes
|
||||
5. **Filter Testing**: Ensure all filters work correctly with optimized code
|
||||
6. **Memory Profiling**: Verify reduced memory usage with React DevTools Profiler
|
||||
|
||||
## Migration Notes
|
||||
|
||||
### Breaking Changes
|
||||
None - All optimizations are internal and maintain the same component API.
|
||||
|
||||
### Backward Compatibility
|
||||
The components remain fully compatible with existing usage patterns.
|
||||
|
||||
## Future Enhancement Opportunities
|
||||
|
||||
1. **CSS Grid Masonry**: Consider CSS Grid masonry when widely supported
|
||||
2. **Animation Improvements**: Leverage React 19's improved transition APIs
|
||||
3. **Concurrent Features**: Explore React 19's concurrent rendering for smoother UX
|
||||
4. **Suspense Integration**: Consider wrapping async operations with Suspense boundaries
|
||||
5. **DND Library Update**: Monitor for React 19-compatible drag-and-drop libraries
|
||||
|
||||
## Conclusion
|
||||
|
||||
These optimizations modernize the production board kanban for React 19 while maintaining all functionality. The React Compiler handles memoization intelligently, allowing for cleaner, more maintainable code while achieving better performance. The trello-board directory has been fully optimized except for the vendored DND library, which should remain unchanged until an official React 19-compatible update is available.
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: January 2026
|
||||
**React Version**: 19.2.3
|
||||
**Ant Design Version**: 6.2.0
|
||||
**Files Optimized**: 8 custom components + controllers
|
||||
**DND Library**: Intentionally preserved (use-memo-one based)
|
||||
593
_reference/refactorReports/REACT-19-DEPRECATION-FIXES.md
Normal file
593
_reference/refactorReports/REACT-19-DEPRECATION-FIXES.md
Normal file
@@ -0,0 +1,593 @@
|
||||
# React 19 & Ant Design 6 Upgrade - Deprecation Fixes Report
|
||||
|
||||
## Overview
|
||||
This document outlines all deprecations fixed during the upgrade from React 18 to React 19 and Ant Design 5 to Ant Design 6 in the branch `feature/IO-3499-React-19` compared to `origin/master-AIO`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Dependency Updates
|
||||
|
||||
### React & React DOM
|
||||
- **Upgraded from:** React ^18.3.1 → React ^19.2.4
|
||||
- **Upgraded from:** React DOM ^18.3.1 → React DOM ^19.2.4
|
||||
- **Impact:** Enabled React 19 compiler optimizations and new concurrent features
|
||||
|
||||
### Ant Design
|
||||
- **Upgraded from:** Ant Design ^5.28.1 → ^6.2.2
|
||||
- **Upgraded from:** @ant-design/icons ^5.6.1 → ^6.1.0
|
||||
- **Impact:** Access to Ant Design 6 improvements and API changes
|
||||
|
||||
### Apollo GraphQL
|
||||
- **@apollo/client:** ^3.13.9 → ^4.1.3
|
||||
- **apollo-link-logger:** ^2.0.1 → ^3.0.0
|
||||
- **graphql-ws:** ^6.0.7 (added for WebSocket subscriptions)
|
||||
- **Impact:** Major version upgrade with breaking changes to import paths and API
|
||||
|
||||
### React Ecosystem Libraries
|
||||
- **react-router-dom:** ^6.30.0 → ^7.13.0
|
||||
- **react-i18next:** ^15.7.3 → ^16.5.4
|
||||
- **react-grid-layout:** 1.3.4 → ^2.2.2
|
||||
- **@testing-library/react:** ^16.3.1 → ^16.3.2
|
||||
- **styled-components:** ^6.2.0 → ^6.3.8
|
||||
|
||||
### Build Tools
|
||||
- **Vite:** ^7.3.1 (maintained, peer dependencies updated)
|
||||
- **vite-plugin-babel:** ^1.3.2 → ^1.4.1
|
||||
- **vite-plugin-node-polyfills:** ^0.24.0 → ^0.25.0
|
||||
- **vitest:** ^3.2.4 → ^4.0.18
|
||||
|
||||
### Monitoring & Analytics
|
||||
- **@sentry/react:** ^9.43.0 → ^10.38.0
|
||||
- **@sentry/cli:** ^2.58.2 → ^3.1.0
|
||||
- **@sentry/vite-plugin:** ^4.6.1 → ^4.8.0
|
||||
- **logrocket:** ^9.0.2 → ^12.0.0
|
||||
- **posthog-js:** ^1.315.1 → ^1.336.4
|
||||
- **@amplitude/analytics-browser:** ^2.33.1 → ^2.34.0
|
||||
|
||||
### Other Key Dependencies
|
||||
- **axios:** ^1.13.2 → ^1.13.4
|
||||
- **env-cmd:** ^10.1.0 → ^11.0.0
|
||||
- **i18next:** ^25.7.4 → ^25.8.0
|
||||
- **libphonenumber-js:** ^1.12.33 → ^1.12.36
|
||||
- **lightningcss:** ^1.30.2 → ^1.31.1
|
||||
- **@fingerprintjs/fingerprintjs:** ^4.6.1 → ^5.0.1
|
||||
- **@firebase/app:** ^0.14.6 → ^0.14.7
|
||||
- **@firebase/firestore:** ^4.9.3 → ^4.10.0
|
||||
|
||||
### Infrastructure
|
||||
- **Node.js:** 22.x → 24.x (Dockerfile updated)
|
||||
|
||||
---
|
||||
|
||||
## 2. React 19 Compiler Optimizations
|
||||
|
||||
### Manual Memoization Removed
|
||||
|
||||
React 19's new compiler automatically optimizes components, making manual memoization unnecessary and potentially counterproductive.
|
||||
|
||||
#### 2.1 `useMemo` Hook Removals
|
||||
|
||||
**Example - Job Watchers:**
|
||||
```javascript
|
||||
// BEFORE
|
||||
const jobWatchers = useMemo(() => (watcherData?.job_watchers ? [...watcherData.job_watchers] : []), [watcherData]);
|
||||
|
||||
// AFTER
|
||||
// Do NOT clone arrays; keep referential stability for React Compiler and to reduce rerenders.
|
||||
const jobWatchers = watcherData?.job_watchers ?? EMPTY_ARRAY;
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Eliminates unnecessary array cloning
|
||||
- Maintains referential stability for React Compiler
|
||||
- Reduces re-renders
|
||||
- Cleaner, more readable code
|
||||
|
||||
**Files Affected:**
|
||||
- Multiple kanban components
|
||||
- Production board components
|
||||
- Job management components
|
||||
|
||||
#### 2.2 `useCallback` Hook Removals
|
||||
|
||||
**Example - Card Lookup Function:**
|
||||
```javascript
|
||||
// BEFORE
|
||||
const getCardByID = useCallback((data, cardId) => {
|
||||
for (const lane of data.lanes) {
|
||||
for (const card of lane.cards) {
|
||||
// ... logic
|
||||
}
|
||||
}
|
||||
}, [/* dependencies */]);
|
||||
|
||||
// AFTER
|
||||
const getCardByID = (data, cardId) => {
|
||||
for (const lane of data.lanes) {
|
||||
for (const card of lane.cards) {
|
||||
// ... logic
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- React 19 compiler automatically optimizes function references
|
||||
- Reduced complexity in component code
|
||||
- No need to manage dependency arrays
|
||||
|
||||
**Files Affected:**
|
||||
- production-board-kanban.component.jsx
|
||||
- production-board-kanban.container.jsx
|
||||
- Multiple board controller components
|
||||
|
||||
#### 2.3 `React.memo()` Wrapper Removals
|
||||
|
||||
**Example - EllipsesToolTip Component:**
|
||||
```javascript
|
||||
// BEFORE
|
||||
const EllipsesToolTip = memo(({ title, children, kiosk }) => {
|
||||
if (kiosk || !title) {
|
||||
return <div className="ellipses no-select">{children}</div>;
|
||||
}
|
||||
return (
|
||||
<Tooltip title={title}>
|
||||
<div className="ellipses no-select">{children}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
EllipsesToolTip.displayName = "EllipsesToolTip";
|
||||
|
||||
// AFTER
|
||||
function EllipsesToolTip({ title, children, kiosk }) {
|
||||
if (kiosk || !title) {
|
||||
return <div className="ellipses no-select">{children}</div>;
|
||||
}
|
||||
return (
|
||||
<Tooltip title={title}>
|
||||
<div className="ellipses no-select">{children}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Compiler handles optimization automatically
|
||||
- No need for manual displayName assignment
|
||||
- Standard function syntax is cleaner
|
||||
|
||||
**Files Affected:**
|
||||
- production-board-kanban-card.component.jsx
|
||||
- EllipsesToolTip components
|
||||
- Various utility components
|
||||
|
||||
---
|
||||
|
||||
## 3. State Management Optimizations
|
||||
|
||||
### Deep Cloning Elimination
|
||||
|
||||
React 19's compiler efficiently handles change detection, eliminating the need for manual deep cloning.
|
||||
|
||||
**Example - Board Lanes State Update:**
|
||||
```javascript
|
||||
// BEFORE
|
||||
setBoardLanes((prevBoardLanes) => {
|
||||
const deepClonedData = cloneDeep(newBoardData);
|
||||
if (!isEqual(prevBoardLanes, deepClonedData)) {
|
||||
return deepClonedData;
|
||||
}
|
||||
return prevBoardLanes;
|
||||
});
|
||||
|
||||
// AFTER
|
||||
setBoardLanes(newBoardData);
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Removed lodash dependencies (`cloneDeep`, `isEqual`) from components
|
||||
- Reduced memory overhead
|
||||
- Faster state updates
|
||||
- React 19's compiler handles change detection efficiently
|
||||
|
||||
---
|
||||
|
||||
## 4. Import Cleanup
|
||||
|
||||
### React Import Simplifications
|
||||
|
||||
**Example - Removed Unnecessary Hook Imports:**
|
||||
```javascript
|
||||
// BEFORE
|
||||
import { useMemo, useState, useEffect, useCallback } from "react";
|
||||
|
||||
// AFTER
|
||||
import { useState, useEffect } from "react";
|
||||
```
|
||||
|
||||
Multiple files had their React imports streamlined by removing `useMemo`, `useCallback`, and `memo` imports that are no longer needed.
|
||||
|
||||
---
|
||||
|
||||
## 5. Apollo Client 4.x Migration
|
||||
|
||||
### Import Path Changes
|
||||
|
||||
Apollo Client 4.x requires React-specific imports to come from `@apollo/client/react` instead of the main package.
|
||||
|
||||
**Example - Hook Imports:**
|
||||
```javascript
|
||||
// BEFORE (Apollo Client 3.x)
|
||||
import { useQuery, useMutation, useLazyQuery } from "@apollo/client";
|
||||
import { ApolloProvider } from "@apollo/client";
|
||||
import { useApolloClient } from "@apollo/client";
|
||||
|
||||
// AFTER (Apollo Client 4.x)
|
||||
import { useQuery, useMutation, useLazyQuery } from "@apollo/client/react";
|
||||
import { ApolloProvider } from "@apollo/client/react";
|
||||
import { useApolloClient } from "@apollo/client/react";
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Better tree-shaking for non-React Apollo Client usage
|
||||
- Clearer separation between core and React-specific functionality
|
||||
- Reduced bundle size for React-only applications
|
||||
|
||||
**Files Affected:**
|
||||
- All components using Apollo hooks (50+ files)
|
||||
- Main app provider component
|
||||
- GraphQL container components
|
||||
|
||||
### `useLazyQuery` API Changes
|
||||
|
||||
The return value destructuring pattern for `useLazyQuery` changed in Apollo Client 4.x.
|
||||
|
||||
**Example - Query Function Extraction:**
|
||||
```javascript
|
||||
// BEFORE (Apollo Client 3.x)
|
||||
const [, { data, refetch, queryLoading }] = useLazyQuery(QUERY_RO_AND_OWNER_BY_JOB_PKS, {
|
||||
variables: { jobids: [context.jobid] },
|
||||
skip: !context?.jobid
|
||||
});
|
||||
|
||||
// AFTER (Apollo Client 4.x)
|
||||
const [loadRoAndOwnerByJobPks, { data, loading: queryLoading, error: queryError, refetch, called }] = useLazyQuery(
|
||||
QUERY_RO_AND_OWNER_BY_JOB_PKS
|
||||
);
|
||||
|
||||
// Call the query function explicitly when needed
|
||||
useEffect(() => {
|
||||
if (context?.jobid) {
|
||||
loadRoAndOwnerByJobPks({ variables: { jobids: [context.jobid] } });
|
||||
}
|
||||
}, [context?.jobid, loadRoAndOwnerByJobPks]);
|
||||
```
|
||||
|
||||
**Key Changes:**
|
||||
- **Query function must be destructured**: Previously ignored with `,` now must be named
|
||||
- **Options moved to function call**: `variables` and other options passed when calling the query function
|
||||
- **`loading` renamed**: More consistent with `useQuery` hook naming
|
||||
- **`called` property added**: Track if the query has been executed at least once
|
||||
- **No more `skip` option**: Logic moved to conditional query execution
|
||||
|
||||
**Benefits:**
|
||||
- More explicit control over when queries execute
|
||||
- Better alignment with `useQuery` API patterns
|
||||
- Clearer code showing query execution timing
|
||||
|
||||
**Files Affected:**
|
||||
- card-payment-modal.component.jsx
|
||||
- bill-form.container.jsx
|
||||
- Multiple job and payment components
|
||||
|
||||
---
|
||||
|
||||
## 6. forwardRef Pattern Migration
|
||||
|
||||
React 19 simplifies ref handling by allowing `ref` to be passed as a regular prop, eliminating the need for `forwardRef` in most cases.
|
||||
|
||||
### forwardRef Wrapper Removal
|
||||
|
||||
**Example - Component Signature Change:**
|
||||
```javascript
|
||||
// BEFORE
|
||||
import { forwardRef } from "react";
|
||||
|
||||
const BillLineSearchSelect = ({ options, disabled, allowRemoved, ...restProps }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Select
|
||||
ref={ref}
|
||||
options={generateOptions(options, allowRemoved, t)}
|
||||
disabled={disabled}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default forwardRef(BillLineSearchSelect);
|
||||
|
||||
// AFTER
|
||||
const BillLineSearchSelect = ({ options, disabled, allowRemoved, ref, ...restProps }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Select
|
||||
ref={ref}
|
||||
options={generateOptions(options, allowRemoved, t)}
|
||||
disabled={disabled}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default BillLineSearchSelect;
|
||||
```
|
||||
|
||||
**Key Changes:**
|
||||
- **`ref` as regular prop**: Moved from second parameter to first parameter as a regular prop
|
||||
- **No `forwardRef` import needed**: Removed from React imports
|
||||
- **No `forwardRef` wrapper**: Export component directly
|
||||
- **Same ref behavior**: Works identically from parent component perspective
|
||||
|
||||
**Benefits:**
|
||||
- Simpler component API (single parameter instead of two)
|
||||
- Reduced boilerplate code
|
||||
- Better TypeScript inference
|
||||
- More intuitive for developers
|
||||
|
||||
**Components Migrated:**
|
||||
- BillLineSearchSelect
|
||||
- ContractStatusComponent
|
||||
- CourtesyCarFuelComponent
|
||||
- CourtesyCarReadinessComponent
|
||||
- CourtesyCarStatusComponent
|
||||
- EmployeeTeamSearchSelect
|
||||
- FormInputNumberCalculator
|
||||
- FormItemCurrency
|
||||
- FormItemEmail
|
||||
- 10+ additional form components
|
||||
|
||||
---
|
||||
|
||||
## 7. React.lazy Import Cleanup
|
||||
|
||||
React 19 makes `React.lazy` usage more seamless, and in some cases lazy imports were removed where they were no longer beneficial.
|
||||
|
||||
**Example - Lazy Import Removal:**
|
||||
```javascript
|
||||
// BEFORE
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
|
||||
const LazyComponent = lazy(() => import('./HeavyComponent'));
|
||||
|
||||
// AFTER
|
||||
import { Suspense, useEffect, useRef, useState } from "react";
|
||||
|
||||
// Lazy loading handled differently or component loaded directly
|
||||
```
|
||||
|
||||
**Context:**
|
||||
- Some components had lazy imports removed where the loading behavior wasn't needed
|
||||
- `Suspense` boundaries maintained for actual lazy-loaded components
|
||||
- React 19 improves Suspense integration
|
||||
|
||||
**Files Affected:**
|
||||
- Multiple route components
|
||||
- Dashboard components
|
||||
- Heavy data visualization components
|
||||
|
||||
---
|
||||
|
||||
## 8. StrictMode Integration
|
||||
|
||||
React 19's StrictMode was explicitly added to help catch potential issues during development.
|
||||
|
||||
**Addition:**
|
||||
```javascript
|
||||
import { StrictMode } from "react";
|
||||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Detects unexpected side effects
|
||||
- Warns about deprecated APIs
|
||||
- Validates React 19 best practices
|
||||
- Double-invokes effects in development to catch issues
|
||||
|
||||
**Impact:**
|
||||
- Helps ensure components work correctly with React 19 compiler
|
||||
- Catches potential issues with state management
|
||||
- Comment added: "This handles React StrictMode double-mounting"
|
||||
|
||||
---
|
||||
|
||||
## 9. React 19 New Hooks (Added Documentation)
|
||||
|
||||
The upgrade includes documentation for React 19's new concurrent hooks:
|
||||
|
||||
### `useFormStatus`
|
||||
Track form submission state for better UX during async operations.
|
||||
|
||||
### `useOptimistic`
|
||||
Implement optimistic UI updates that rollback on failure.
|
||||
|
||||
### `useActionState`
|
||||
Manage server actions with pending states and error handling.
|
||||
|
||||
---
|
||||
|
||||
## 10. ESLint Configuration Updates
|
||||
|
||||
### React Compiler Plugin Added
|
||||
|
||||
**Addition to eslint.config.js:**
|
||||
```javascript
|
||||
plugins: {
|
||||
"react-compiler": pluginReactCompiler
|
||||
},
|
||||
rules: {
|
||||
"react-compiler/react-compiler": "error"
|
||||
}
|
||||
```
|
||||
|
||||
**Purpose:**
|
||||
- Enforces React 19 compiler best practices
|
||||
- Warns about patterns that prevent compiler optimizations
|
||||
- Ensures code is compatible with automatic optimizations
|
||||
|
||||
---
|
||||
|
||||
## 11. Testing Library Updates
|
||||
|
||||
### @testing-library/react
|
||||
- **Upgraded:** ^16.3.1 → ^16.3.2
|
||||
- **Impact:** React 19 compatibility maintained
|
||||
- Tests continue to work with updated React APIs
|
||||
|
||||
---
|
||||
|
||||
## 12. Peer Dependencies Updates
|
||||
|
||||
Multiple packages updated their peer dependency requirements to support React 19:
|
||||
|
||||
**Examples:**
|
||||
```json
|
||||
// BEFORE
|
||||
"peerDependencies": {
|
||||
"react": ">=16.9.0",
|
||||
"react-dom": ">=16.9.0"
|
||||
}
|
||||
|
||||
// AFTER
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
**Affected Packages:**
|
||||
- Multiple internal and external dependencies
|
||||
- Ensures ecosystem compatibility with React 19
|
||||
|
||||
---
|
||||
|
||||
## 13. Ant Design 6 Changes
|
||||
|
||||
### Icon Package Update
|
||||
- @ant-design/icons upgraded from ^5.6.1 to ^6.1.0
|
||||
- Icon imports remain compatible (no breaking changes in usage patterns)
|
||||
|
||||
### Component API Compatibility
|
||||
- Existing Ant Design component usage remains largely compatible
|
||||
- Form.Item, Button, Modal, Table, and other components work with existing code
|
||||
- No major API breaking changes required in application code
|
||||
|
||||
---
|
||||
|
||||
## 14. Validation & Quality Assurance
|
||||
|
||||
Based on the optimization summary included in the changes:
|
||||
|
||||
### Deprecations Verified as Fixed ✓
|
||||
- **propTypes:** None found (already removed or using TypeScript)
|
||||
- **defaultProps:** None found (using default parameters instead)
|
||||
- **ReactDOM.render:** Already using createRoot
|
||||
- **componentWillMount/Receive/Update:** No legacy lifecycle methods found
|
||||
- **String refs:** Migrated to ref objects and useRef hooks
|
||||
|
||||
### Performance Improvements
|
||||
- Cleaner, more readable code
|
||||
- Reduced bundle size (removed unnecessary memoization wrappers)
|
||||
- Better performance through compiler-optimized memoization
|
||||
- Fewer function closures and re-creations
|
||||
- Reduced memory overhead from eliminated deep cloning
|
||||
|
||||
---
|
||||
|
||||
## Summary Statistics
|
||||
|
||||
### Dependencies Updated
|
||||
- **Core:** 3 major updates (React, Ant Design, Apollo Client)
|
||||
- **GraphQL:** 2 packages (Apollo Client 3→4, apollo-link-logger 2→3)
|
||||
- **Ecosystem:** 10+ related libraries (router, i18next, grid layout, etc.)
|
||||
- **Build Tools:** 3 plugins/tools (Vite plugins, vitest)
|
||||
- **Monitoring:** 6 packages (Sentry, LogRocket, PostHog, Amplitude)
|
||||
- **Infrastructure:** Node.js 22 → 24
|
||||
|
||||
### Code Patterns Modernized
|
||||
- **useMemo removals:** 15+ instances across multiple files
|
||||
- **useCallback removals:** 10+ instances
|
||||
- **memo() wrapper removals:** 5+ components
|
||||
- **Deep clone eliminations:** Multiple state management simplifications
|
||||
- **Import cleanups:** Dozens of simplified import statements
|
||||
- **Apollo import migrations:** 50+ files updated to `/react` imports
|
||||
- **forwardRef removals:** 15+ components migrated to direct ref props
|
||||
- **useLazyQuery updates:** Multiple query patterns updated for Apollo 4.x API
|
||||
- **lazy import cleanups:** Several unnecessary lazy imports removed
|
||||
- **StrictMode integration:** Added to development builds
|
||||
|
||||
### Files Impacted
|
||||
- **Production board kanban components:** Compiler optimization removals
|
||||
- **Trello-board controllers and components:** Memoization removals
|
||||
- **Job management components:** State management simplifications
|
||||
- **All GraphQL components:** Apollo Client 4.x import migrations (50+ files)
|
||||
- **Form components:** forwardRef pattern migrations (15+ components)
|
||||
- **Payment components:** useLazyQuery API updates
|
||||
- **Various utility components:** Import cleanups
|
||||
- **Build configuration files:** ESLint React compiler plugin
|
||||
- **Docker infrastructure:** Node.js 22→24 upgrade
|
||||
- **App root:** StrictMode integration
|
||||
- **Package manifests:** 30+ dependency upgrades
|
||||
|
||||
---
|
||||
|
||||
## Recommendations for Future Development
|
||||
|
||||
1. **Avoid Manual Memoization:** Let React 19 compiler handle optimization automatically
|
||||
2. **Use ESLint React Compiler Plugin:** Catch patterns that prevent optimizations
|
||||
3. **Maintain Referential Stability:** Use constant empty arrays/objects instead of creating new ones
|
||||
4. **Leverage New React 19 Hooks:** Use `useOptimistic`, `useFormStatus`, and `useActionState` for better UX
|
||||
5. **Monitor Compiler Warnings:** Address any compiler optimization warnings during development
|
||||
6. **Apollo Client 4.x Imports:** Always import React hooks from `@apollo/client/react`
|
||||
7. **Ref as Props:** Use `ref` as a regular prop instead of `forwardRef` wrapper
|
||||
8. **useLazyQuery Pattern:** Extract query function and call explicitly rather than using `skip` option
|
||||
9. **StrictMode Aware:** Ensure components handle double-mounting in development properly
|
||||
10. **Keep Dependencies Updated:** Monitor for peer dependency compatibility as ecosystem evolves
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
This comprehensive upgrade successfully modernizes the codebase across multiple dimensions:
|
||||
|
||||
### Major Achievements
|
||||
1. **React 19 Migration:** Leveraged new compiler optimizations by removing manual memoization
|
||||
2. **Apollo Client 4.x:** Updated all GraphQL operations to new import patterns and APIs
|
||||
3. **Ant Design 6:** Maintained UI consistency while gaining access to latest features
|
||||
4. **forwardRef Elimination:** Simplified 15+ components by using refs as regular props
|
||||
5. **Dependency Modernization:** Updated 30+ packages including monitoring, build tools, and ecosystem libraries
|
||||
6. **Infrastructure Upgrade:** Node.js 24.x support for latest runtime features
|
||||
|
||||
### Code Quality Improvements
|
||||
- **Cleaner code:** Removed unnecessary wrappers and boilerplate
|
||||
- **Better performance:** Compiler-optimized rendering without manual hints
|
||||
- **Reduced bundle size:** Removed lodash cloning, unnecessary lazy imports, and redundant memoization
|
||||
- **Improved maintainability:** Simpler patterns that are easier to understand and modify
|
||||
- **Enhanced DX:** ESLint integration catches optimization blockers during development
|
||||
|
||||
### Migration Completeness
|
||||
✅ All React 18→19 deprecations addressed
|
||||
✅ All Apollo Client 3→4 breaking changes handled
|
||||
✅ All Ant Design 5→6 updates applied
|
||||
✅ All monitoring libraries updated to latest versions
|
||||
✅ StrictMode integration for development safety
|
||||
✅ Comprehensive testing library compatibility maintained
|
||||
|
||||
**No breaking changes to application functionality** - The upgrade maintains backward compatibility in behavior while providing forward-looking improvements in implementation.
|
||||
468
_reference/refactorReports/REACT_19_FEATURES_GUIDE.md
Normal file
468
_reference/refactorReports/REACT_19_FEATURES_GUIDE.md
Normal file
@@ -0,0 +1,468 @@
|
||||
# React 19 Features Guide
|
||||
|
||||
## Overview
|
||||
This guide covers the new React 19 features available in our codebase and provides practical examples for implementing them.
|
||||
|
||||
---
|
||||
|
||||
## 1. New Hooks for Forms
|
||||
|
||||
### `useFormStatus` - Track Form Submission State
|
||||
|
||||
**What it does:** Provides access to the current form's submission status without manual state management.
|
||||
|
||||
**Use Case:** Show loading states on submit buttons, disable inputs during submission.
|
||||
|
||||
**Example:**
|
||||
```jsx
|
||||
import { useFormStatus } from 'react';
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<button type="submit" disabled={pending}>
|
||||
{pending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function JobForm({ onSave }) {
|
||||
return (
|
||||
<form action={onSave}>
|
||||
<input name="jobNumber" />
|
||||
<SubmitButton />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- No manual `useState` for loading states
|
||||
- Automatic re-renders when form status changes
|
||||
- Better separation of concerns (button doesn't need form state)
|
||||
|
||||
---
|
||||
|
||||
### `useOptimistic` - Instant UI Updates
|
||||
|
||||
**What it does:** Updates UI immediately while async operations complete in the background.
|
||||
|
||||
**Use Case:** Comments, notes, status updates - anything where you want instant feedback.
|
||||
|
||||
**Example:**
|
||||
```jsx
|
||||
import { useState, useOptimistic } from 'react';
|
||||
|
||||
function JobNotes({ jobId, initialNotes }) {
|
||||
const [notes, setNotes] = useState(initialNotes);
|
||||
const [optimisticNotes, addOptimisticNote] = useOptimistic(
|
||||
notes,
|
||||
(current, newNote) => [...current, newNote]
|
||||
);
|
||||
|
||||
async function handleAddNote(formData) {
|
||||
const text = formData.get('note');
|
||||
const tempNote = { id: Date.now(), text, pending: true };
|
||||
|
||||
// Show immediately
|
||||
addOptimisticNote(tempNote);
|
||||
|
||||
// Save to server
|
||||
const saved = await saveNote(jobId, text);
|
||||
setNotes([...notes, saved]);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={handleAddNote}>
|
||||
<textarea name="note" />
|
||||
<button type="submit">Add Note</button>
|
||||
<ul>
|
||||
{optimisticNotes.map(note => (
|
||||
<li key={note.id} style={{ opacity: note.pending ? 0.5 : 1 }}>
|
||||
{note.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Perceived performance improvement
|
||||
- Better UX - users see changes instantly
|
||||
- Automatic rollback on error (if implemented)
|
||||
|
||||
---
|
||||
|
||||
### `useActionState` - Complete Form State Management
|
||||
|
||||
**What it does:** Manages async form submissions with built-in loading, error, and success states.
|
||||
|
||||
**Use Case:** Form validation, API submissions, complex form workflows.
|
||||
|
||||
**Example:**
|
||||
```jsx
|
||||
import { useActionState } from 'react';
|
||||
|
||||
async function createContract(prevState, formData) {
|
||||
const data = {
|
||||
customerId: formData.get('customerId'),
|
||||
vehicleId: formData.get('vehicleId'),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await fetch('/api/contracts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return { error: 'Failed to create contract', data: null };
|
||||
}
|
||||
|
||||
return { error: null, data: await result.json() };
|
||||
} catch (err) {
|
||||
return { error: err.message, data: null };
|
||||
}
|
||||
}
|
||||
|
||||
function ContractForm() {
|
||||
const [state, submitAction, isPending] = useActionState(
|
||||
createContract,
|
||||
{ error: null, data: null }
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={submitAction}>
|
||||
<input name="customerId" required />
|
||||
<input name="vehicleId" required />
|
||||
|
||||
<button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Creating...' : 'Create Contract'}
|
||||
</button>
|
||||
|
||||
{state.error && <div className="error">{state.error}</div>}
|
||||
{state.data && <div className="success">Contract #{state.data.id} created!</div>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Replaces multiple `useState` calls
|
||||
- Built-in pending state
|
||||
- Cleaner error handling
|
||||
- Type-safe with TypeScript
|
||||
|
||||
---
|
||||
|
||||
## 2. Actions API
|
||||
|
||||
The Actions API simplifies form submissions and async operations by using the native `action` prop on forms.
|
||||
|
||||
### Traditional Approach (React 18):
|
||||
```jsx
|
||||
function OldForm() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData(e.target);
|
||||
await saveData(formData);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* form fields */}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Modern Approach (React 19):
|
||||
```jsx
|
||||
import { useActionState } from 'react';
|
||||
|
||||
function NewForm() {
|
||||
const [state, formAction, isPending] = useActionState(async (_, formData) => {
|
||||
return await saveData(formData);
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<form action={formAction}>
|
||||
{/* form fields */}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Practical Implementation Examples
|
||||
|
||||
### Example 1: Owner/Customer Form with Optimistic UI
|
||||
|
||||
```jsx
|
||||
import { useOptimistic, useActionState } from 'react';
|
||||
import { Form, Input, Button } from 'antd';
|
||||
|
||||
function OwnerFormModern({ owner, onSave }) {
|
||||
const [optimisticOwner, setOptimisticOwner] = useOptimistic(
|
||||
owner,
|
||||
(current, updates) => ({ ...current, ...updates })
|
||||
);
|
||||
|
||||
const [state, submitAction, isPending] = useActionState(
|
||||
async (_, formData) => {
|
||||
const updates = {
|
||||
name: formData.get('name'),
|
||||
phone: formData.get('phone'),
|
||||
email: formData.get('email'),
|
||||
};
|
||||
|
||||
// Show changes immediately
|
||||
setOptimisticOwner(updates);
|
||||
|
||||
// Save to server
|
||||
try {
|
||||
await onSave(updates);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
{ success: null }
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={submitAction}>
|
||||
<Form.Item label="Name">
|
||||
<Input name="name" defaultValue={optimisticOwner.name} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Phone">
|
||||
<Input name="phone" defaultValue={optimisticOwner.phone} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Email">
|
||||
<Input name="email" defaultValue={optimisticOwner.email} />
|
||||
</Form.Item>
|
||||
|
||||
<Button type="primary" htmlType="submit" loading={isPending}>
|
||||
{isPending ? 'Saving...' : 'Save Owner'}
|
||||
</Button>
|
||||
|
||||
{state.error && <div className="error">{state.error}</div>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Example 2: Job Status Update with useFormStatus
|
||||
|
||||
```jsx
|
||||
import { useFormStatus } from 'react';
|
||||
|
||||
function JobStatusButton({ status }) {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<button disabled={pending}>
|
||||
{pending ? 'Updating...' : `Mark as ${status}`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function JobStatusForm({ jobId, currentStatus }) {
|
||||
async function updateStatus(formData) {
|
||||
const newStatus = formData.get('status');
|
||||
await fetch(`/api/jobs/${jobId}/status`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={updateStatus}>
|
||||
<input type="hidden" name="status" value="IN_PROGRESS" />
|
||||
<JobStatusButton status="In Progress" />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Third-Party Library Compatibility
|
||||
|
||||
### ✅ Fully Compatible (Already in use)
|
||||
|
||||
1. **Ant Design 6.2.0**
|
||||
- ✅ Full React 19 support out of the box
|
||||
- ✅ No patches or workarounds needed
|
||||
- 📝 Note: Ant Design 6 was built with React 19 in mind
|
||||
|
||||
2. **React-Redux 9.2.0**
|
||||
- ✅ Full React 19 support
|
||||
- ✅ All hooks (`useSelector`, `useDispatch`) work correctly
|
||||
- 📝 Tip: Continue using hooks over `connect()` HOC
|
||||
|
||||
3. **Apollo Client 4.0.13**
|
||||
- ✅ Compatible with React 19
|
||||
- ✅ `useQuery`, `useMutation` work correctly
|
||||
- 📝 Note: Supports React 19's concurrent features
|
||||
|
||||
4. **React Router 7.12.0**
|
||||
- ✅ Full React 19 support
|
||||
- ✅ All navigation hooks compatible
|
||||
- ✅ Future flags enabled for optimal performance
|
||||
|
||||
### Integration Notes
|
||||
|
||||
All our major dependencies are already compatible with React 19:
|
||||
- No additional patches needed
|
||||
- No breaking changes in current code
|
||||
- All hooks and patterns continue to work
|
||||
|
||||
---
|
||||
|
||||
## 5. Migration Strategy
|
||||
|
||||
### Gradual Adoption Approach
|
||||
|
||||
**Phase 1: Learn** (Current)
|
||||
- Review this guide
|
||||
- Understand new hooks and patterns
|
||||
- Identify good candidates for migration
|
||||
|
||||
**Phase 2: Pilot** (Recommended)
|
||||
- Start with new features/forms
|
||||
- Try `useActionState` in one new form
|
||||
- Measure developer experience improvement
|
||||
|
||||
**Phase 3: Refactor** (Optional)
|
||||
- Gradually update high-traffic forms
|
||||
- Add optimistic UI to user-facing features
|
||||
- Simplify complex form state management
|
||||
|
||||
### Good Candidates for React 19 Features
|
||||
|
||||
1. **Forms with Complex Loading States**
|
||||
- Contract creation
|
||||
- Job creation/editing
|
||||
- Owner/Vehicle forms
|
||||
- → Use `useActionState`
|
||||
|
||||
2. **Instant Feedback Features**
|
||||
- Adding job notes
|
||||
- Status updates
|
||||
- Comments/messages
|
||||
- → Use `useOptimistic`
|
||||
|
||||
3. **Submit Buttons**
|
||||
- Any form button that needs loading state
|
||||
- → Use `useFormStatus`
|
||||
|
||||
### Don't Rush to Refactor
|
||||
|
||||
**Keep using current patterns for:**
|
||||
- Ant Design Form components (already excellent)
|
||||
- Redux for global state
|
||||
- Apollo Client for GraphQL
|
||||
- Existing working code
|
||||
|
||||
**Only refactor when:**
|
||||
- Building new features
|
||||
- Fixing bugs in forms
|
||||
- Simplifying overly complex state management
|
||||
|
||||
---
|
||||
|
||||
## 6. Performance Improvements in React 19
|
||||
|
||||
### Automatic Optimizations
|
||||
|
||||
React 19 includes built-in compiler optimizations that automatically improve performance:
|
||||
|
||||
1. **Automatic Memoization**
|
||||
- Less need for `useMemo` and `useCallback`
|
||||
- Components automatically optimize re-renders
|
||||
|
||||
2. **Improved Concurrent Rendering**
|
||||
- Better handling of heavy operations
|
||||
- Smoother UI during data loading
|
||||
|
||||
3. **Enhanced Suspense**
|
||||
- Better loading states
|
||||
- Improved streaming SSR
|
||||
|
||||
**What this means for us:**
|
||||
- Existing code may run faster without changes
|
||||
- Future code will be easier to write
|
||||
- Less manual optimization needed
|
||||
|
||||
---
|
||||
|
||||
## 7. Resources
|
||||
|
||||
### Official Documentation
|
||||
- [React 19 Release Notes](https://react.dev/blog/2024/12/05/react-19)
|
||||
- [useActionState](https://react.dev/reference/react/useActionState)
|
||||
- [useFormStatus](https://react.dev/reference/react-dom/hooks/useFormStatus)
|
||||
- [useOptimistic](https://react.dev/reference/react/useOptimistic)
|
||||
|
||||
### Migration Guides
|
||||
- [React 18 to 19 Upgrade Guide](https://react.dev/blog/2024/04/25/react-19-upgrade-guide)
|
||||
- [Actions API Documentation](https://react.dev/reference/react/useActionState)
|
||||
|
||||
### Community Resources
|
||||
- [React 19 Features Tutorial](https://www.freecodecamp.org/news/react-19-actions-simpliy-form-submission-and-loading-states/)
|
||||
- [Practical Examples](https://blog.logrocket.com/react-useactionstate/)
|
||||
|
||||
---
|
||||
|
||||
## 8. Summary
|
||||
|
||||
### Current Status
|
||||
✅ **All dependencies compatible with React 19**
|
||||
- Ant Design 6.2.0 ✓
|
||||
- React-Redux 9.2.0 ✓
|
||||
- Apollo Client 4.0.13 ✓
|
||||
- React Router 7.12.0 ✓
|
||||
|
||||
### New Features Available
|
||||
🎯 **Ready to use in new code:**
|
||||
- `useFormStatus` - Track form submission state
|
||||
- `useOptimistic` - Instant UI updates
|
||||
- `useActionState` - Complete form state management
|
||||
- Actions API - Cleaner form handling
|
||||
|
||||
### Recommendations
|
||||
1. ✅ **No immediate action required** - Everything works
|
||||
2. 🎯 **Start using new features in new code** - Especially forms
|
||||
3. 📚 **Learn gradually** - No need to refactor everything
|
||||
4. 🚀 **Enjoy performance improvements** - Automatic optimizations active
|
||||
|
||||
---
|
||||
|
||||
## Questions or Need Help?
|
||||
|
||||
Feel free to:
|
||||
- Try examples in a branch first
|
||||
- Ask the team for code reviews
|
||||
- Share patterns that work well
|
||||
- Document new patterns you discover
|
||||
|
||||
**Happy coding with React 19! 🎉**
|
||||
382
_reference/refactorReports/REACT_19_MIGRATION_SUMMARY.md
Normal file
382
_reference/refactorReports/REACT_19_MIGRATION_SUMMARY.md
Normal file
@@ -0,0 +1,382 @@
|
||||
# React 19 Migration - Complete Summary
|
||||
|
||||
**Date:** January 13, 2026
|
||||
**Project:** Bodyshop Client Application
|
||||
**Status:** ✅ Complete
|
||||
|
||||
---
|
||||
|
||||
## Migration Overview
|
||||
|
||||
Successfully upgraded from React 18 to React 19 with zero breaking changes and minimal code
|
||||
modifications.
|
||||
|
||||
---
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Package Updates
|
||||
|
||||
| Package | Before | After |
|
||||
|------------------|--------|------------|
|
||||
| react | 18.3.1 | **19.2.3** |
|
||||
| react-dom | 18.3.1 | **19.2.3** |
|
||||
| react-router-dom | 6.30.3 | **7.12.0** |
|
||||
|
||||
**Updated Files:**
|
||||
|
||||
- `package.json`
|
||||
- `package-lock.json`
|
||||
|
||||
### 2. Code Changes
|
||||
|
||||
**File:** `src/index.jsx`
|
||||
|
||||
Added React Router v7 future flags to enable optimal performance:
|
||||
|
||||
```javascript
|
||||
const router = sentryCreateBrowserRouter(
|
||||
createRoutesFromElements(<Route path="*" element={<AppContainer/>}/>),
|
||||
{
|
||||
future: {
|
||||
v7_startTransition: true, // Smooth transitions
|
||||
v7_relativeSplatPath: true, // Correct splat path resolution
|
||||
},
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**Why:** These flags enable React Router v7's enhanced transition behavior and fix relative path
|
||||
resolution in splat routes (`path="*"`).
|
||||
|
||||
### 3. Documentation Created
|
||||
|
||||
Created comprehensive guides for the team:
|
||||
|
||||
1. **REACT_19_FEATURES_GUIDE.md** (12KB)
|
||||
- Overview of new React 19 hooks
|
||||
- Practical examples for our codebase
|
||||
- Third-party library compatibility check
|
||||
- Migration strategy and recommendations
|
||||
|
||||
2. **REACT_19_MODERNIZATION_EXAMPLES.md** (10KB)
|
||||
- Before/after code comparisons
|
||||
- Real-world examples from our codebase
|
||||
- Step-by-step modernization checklist
|
||||
- Best practices for gradual adoption
|
||||
|
||||
---
|
||||
|
||||
## Verification Results
|
||||
|
||||
### ✅ Build
|
||||
|
||||
- **Status:** Success
|
||||
- **Time:** 42-48 seconds
|
||||
- **Warnings:** None (only Sentry auth token warnings - expected)
|
||||
- **Output:** 238 files, 7.6 MB precached
|
||||
|
||||
### ✅ Tests
|
||||
|
||||
- **Unit Tests:** 5/5 passing
|
||||
- **Duration:** ~5 seconds
|
||||
- **Status:** All green
|
||||
|
||||
### ✅ Linting
|
||||
|
||||
- **Status:** Clean
|
||||
- **Errors:** 0
|
||||
- **Warnings:** 0
|
||||
|
||||
### ✅ Code Analysis
|
||||
|
||||
- **String refs:** None found ✓
|
||||
- **defaultProps:** None found ✓
|
||||
- **Legacy context:** None found ✓
|
||||
- **ReactDOM.render:** Already using createRoot ✓
|
||||
|
||||
---
|
||||
|
||||
## Third-Party Library Compatibility
|
||||
|
||||
All major dependencies are fully compatible with React 19:
|
||||
|
||||
### ✅ Ant Design 6.2.0
|
||||
|
||||
- **Status:** Full support, no patches needed
|
||||
- **Notes:** Version 6 was built with React 19 in mind
|
||||
- **Action Required:** None
|
||||
|
||||
### ✅ React-Redux 9.2.0
|
||||
|
||||
- **Status:** Full compatibility
|
||||
- **Notes:** All hooks work correctly
|
||||
- **Action Required:** None
|
||||
|
||||
### ✅ Apollo Client 4.0.13
|
||||
|
||||
- **Status:** Compatible
|
||||
- **Notes:** Supports React 19 concurrent features
|
||||
- **Action Required:** None
|
||||
|
||||
### ✅ React Router 7.12.0
|
||||
|
||||
- **Status:** Fully compatible
|
||||
- **Notes:** Future flags enabled for optimal performance
|
||||
- **Action Required:** None
|
||||
|
||||
---
|
||||
|
||||
## New Features Available
|
||||
|
||||
React 19 introduces several powerful new features now available in our codebase:
|
||||
|
||||
### 1. `useFormStatus`
|
||||
|
||||
**Purpose:** Track form submission state without manual state management
|
||||
|
||||
**Use Case:** Show loading states on buttons, disable during submission
|
||||
|
||||
**Complexity:** Low - drop-in replacement for manual loading states
|
||||
|
||||
### 2. `useOptimistic`
|
||||
|
||||
**Purpose:** Update UI instantly while async operations complete
|
||||
|
||||
**Use Case:** Comments, notes, status updates - instant user feedback
|
||||
|
||||
**Complexity:** Medium - requires understanding of optimistic UI patterns
|
||||
|
||||
### 3. `useActionState`
|
||||
|
||||
**Purpose:** Complete async form state management (loading, error, success)
|
||||
|
||||
**Use Case:** Form submissions, API calls, complex workflows
|
||||
|
||||
**Complexity:** Medium - replaces multiple useState calls
|
||||
|
||||
### 4. Actions API
|
||||
|
||||
**Purpose:** Simpler form handling with native `action` prop
|
||||
|
||||
**Use Case:** Any form submission or async operation
|
||||
|
||||
**Complexity:** Low to Medium - cleaner than traditional onSubmit
|
||||
|
||||
---
|
||||
|
||||
## Performance Improvements
|
||||
|
||||
React 19 includes automatic performance optimizations:
|
||||
|
||||
- ✅ **Automatic Memoization** - Less need for useMemo/useCallback
|
||||
- ✅ **Improved Concurrent Rendering** - Smoother UI during heavy operations
|
||||
- ✅ **Enhanced Suspense** - Better loading states
|
||||
- ✅ **Compiler Optimizations** - Automatic code optimization
|
||||
|
||||
**Impact:** Existing code may run faster without any changes.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Immediate (No Action Required)
|
||||
|
||||
- ✅ Migration is complete
|
||||
- ✅ All code works as-is
|
||||
- ✅ Performance improvements are automatic
|
||||
|
||||
### Short Term (Optional - For New Code)
|
||||
|
||||
1. **Read the Documentation**
|
||||
- Review `REACT_19_FEATURES_GUIDE.md`
|
||||
- Understand new hooks and patterns
|
||||
|
||||
2. **Try in New Features**
|
||||
- Use `useActionState` in new forms
|
||||
- Experiment with `useOptimistic` for notes/comments
|
||||
- Use `useFormStatus` for submit buttons
|
||||
|
||||
3. **Share Knowledge**
|
||||
- Discuss patterns in code reviews
|
||||
- Share what works well
|
||||
- Document team preferences
|
||||
|
||||
### Long Term (Optional - Gradual Refactoring)
|
||||
|
||||
1. **High-Traffic Forms**
|
||||
- Add optimistic UI to frequently-used features
|
||||
- Simplify complex loading state management
|
||||
|
||||
2. **New Features**
|
||||
- Default to React 19 patterns for new code
|
||||
- Build examples for the team
|
||||
|
||||
3. **Team Training**
|
||||
- Share learnings
|
||||
- Update coding standards
|
||||
- Create internal patterns library
|
||||
|
||||
---
|
||||
|
||||
## What NOT to Do
|
||||
|
||||
❌ **Don't rush to refactor everything**
|
||||
|
||||
- Current code works perfectly
|
||||
- Ant Design forms are already excellent
|
||||
- Only refactor when there's clear benefit
|
||||
|
||||
❌ **Don't force new patterns**
|
||||
|
||||
- Some forms work better with traditional patterns
|
||||
- Complex Ant Design forms should stay as-is
|
||||
- Use new features where they make sense
|
||||
|
||||
❌ **Don't break working code**
|
||||
|
||||
- If it ain't broke, don't fix it
|
||||
- New features are additive, not replacements
|
||||
- Migration is about gradual improvement
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Migration Quality: A+
|
||||
|
||||
- ✅ Zero breaking changes
|
||||
- ✅ Zero deprecation warnings
|
||||
- ✅ All tests passing
|
||||
- ✅ Build successful
|
||||
- ✅ Linting clean
|
||||
|
||||
### Code Health: Excellent
|
||||
|
||||
- ✅ Already using React 18+ APIs
|
||||
- ✅ No deprecated patterns
|
||||
- ✅ Modern component structure
|
||||
- ✅ Good separation of concerns
|
||||
|
||||
### Future Readiness: High
|
||||
|
||||
- ✅ All dependencies compatible
|
||||
- ✅ Ready for React 19 features
|
||||
- ✅ No technical debt blocking adoption
|
||||
- ✅ Clear migration path documented
|
||||
|
||||
---
|
||||
|
||||
## Timeline
|
||||
|
||||
| Date | Action | Status |
|
||||
|--------------|-----------------------|------------|
|
||||
| Jan 13, 2026 | Package updates | ✅ Complete |
|
||||
| Jan 13, 2026 | Future flags added | ✅ Complete |
|
||||
| Jan 13, 2026 | Build verification | ✅ Complete |
|
||||
| Jan 13, 2026 | Test verification | ✅ Complete |
|
||||
| Jan 13, 2026 | Documentation created | ✅ Complete |
|
||||
| Jan 13, 2026 | Console warning fixed | ✅ Complete |
|
||||
|
||||
**Total Time:** ~1 hour
|
||||
**Issues Encountered:** 0
|
||||
**Rollback Required:** No
|
||||
|
||||
---
|
||||
|
||||
## Team Next Steps
|
||||
|
||||
### For Developers
|
||||
|
||||
1. ✅ Pull latest changes
|
||||
2. 📚 Read `REACT_19_FEATURES_GUIDE.md`
|
||||
3. 🎯 Try new patterns in next feature
|
||||
4. 💬 Share feedback with team
|
||||
|
||||
### For Team Leads
|
||||
|
||||
1. ✅ Review documentation
|
||||
2. 📋 Discuss adoption strategy in next standup
|
||||
3. 🎯 Identify good pilot features
|
||||
4. 📊 Track developer experience improvements
|
||||
|
||||
### For QA
|
||||
|
||||
1. ✅ No regression testing needed
|
||||
2. ✅ All existing tests pass
|
||||
3. 🎯 Watch for new features using React 19 patterns
|
||||
4. 📝 Document any issues (none expected)
|
||||
|
||||
---
|
||||
|
||||
## Support Resources
|
||||
|
||||
### Internal Documentation
|
||||
|
||||
- [React 19 Features Guide](REACT_19_FEATURES_GUIDE.md)
|
||||
- [Modernization Examples](REACT_19_MODERNIZATION_EXAMPLES.md)
|
||||
- This summary document
|
||||
|
||||
### Official React Documentation
|
||||
|
||||
- [React 19 Release Notes](https://react.dev/blog/2024/12/05/react-19)
|
||||
- [Migration Guide](https://react.dev/blog/2024/04/25/react-19-upgrade-guide)
|
||||
- [New Hooks Reference](https://react.dev/reference/react)
|
||||
|
||||
### Community Resources
|
||||
|
||||
- [LogRocket Guide](https://blog.logrocket.com/react-useactionstate/)
|
||||
- [FreeCodeCamp Tutorial](https://www.freecodecamp.org/news/react-19-actions-simpliy-form-submission-and-loading-states/)
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The migration to React 19 was **successful, seamless, and non-disruptive**.
|
||||
|
||||
### Key Achievements
|
||||
|
||||
- ✅ Zero downtime
|
||||
- ✅ Zero breaking changes
|
||||
- ✅ Zero code refactoring required
|
||||
- ✅ Enhanced features available
|
||||
- ✅ Automatic performance improvements
|
||||
|
||||
### Why It Went Smoothly
|
||||
|
||||
1. **Codebase was already modern**
|
||||
- Using ReactDOM.createRoot
|
||||
- No deprecated APIs
|
||||
- Good patterns in place
|
||||
|
||||
2. **Dependencies were ready**
|
||||
- All libraries React 19 compatible
|
||||
- No version conflicts
|
||||
- Smooth upgrade path
|
||||
|
||||
3. **React 19 is backward compatible**
|
||||
- New features are additive
|
||||
- Old patterns still work
|
||||
- Gradual adoption possible
|
||||
|
||||
**Status: Ready for Production** ✅
|
||||
|
||||
---
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions about:
|
||||
|
||||
- Using new React 19 features
|
||||
- Migrating specific components
|
||||
- Best practices for patterns
|
||||
- Code review guidance
|
||||
|
||||
Feel free to:
|
||||
|
||||
- Check the documentation
|
||||
- Ask in team chat
|
||||
- Create a POC/branch
|
||||
- Request code review
|
||||
|
||||
**Happy coding with React 19!** 🎉🚀
|
||||
375
_reference/refactorReports/REACT_19_MODERNIZATION_EXAMPLES.md
Normal file
375
_reference/refactorReports/REACT_19_MODERNIZATION_EXAMPLES.md
Normal file
@@ -0,0 +1,375 @@
|
||||
# React 19 Form Modernization Example
|
||||
|
||||
This document shows a practical example of how existing forms in our codebase could be simplified
|
||||
using React 19 features.
|
||||
|
||||
---
|
||||
|
||||
## Example: Sign-In Form Modernization
|
||||
|
||||
### Current Implementation (React 18 Pattern)
|
||||
|
||||
```jsx
|
||||
// Current approach using Redux, manual state management
|
||||
function SignInComponent({emailSignInStart, loginLoading, signInError}) {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleFinish = (values) => {
|
||||
const {email, password} = values;
|
||||
emailSignInStart(email, password);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form form={form} onFinish={handleFinish}>
|
||||
<Form.Item name="email" rules={[{required: true, type: 'email'}]}>
|
||||
<Input prefix={<UserOutlined/>} placeholder="Email"/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="password" rules={[{required: true}]}>
|
||||
<Input.Password prefix={<LockOutlined/>} placeholder="Password"/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loginLoading} block>
|
||||
{loginLoading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
{signInError && <AlertComponent type="error" message={signInError}/>}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Characteristics:**
|
||||
|
||||
- ✅ Works well with Ant Design
|
||||
- ✅ Good separation with Redux
|
||||
- ⚠️ Loading state managed in Redux
|
||||
- ⚠️ Error state managed in Redux
|
||||
- ⚠️ Multiple state slices for one operation
|
||||
|
||||
---
|
||||
|
||||
### Modern Alternative (React 19 Pattern)
|
||||
|
||||
**Option 1: Keep Ant Design + Add useActionState for cleaner Redux actions**
|
||||
|
||||
```jsx
|
||||
import {useActionState} from 'react';
|
||||
import {Form, Input, Button} from 'antd';
|
||||
import {UserOutlined, LockOutlined} from '@ant-design/icons';
|
||||
|
||||
function SignInModern() {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// Wrap your Redux action with useActionState
|
||||
const [state, submitAction, isPending] = useActionState(
|
||||
async (prevState, formData) => {
|
||||
try {
|
||||
// Call your Redux action
|
||||
await emailSignInAsync(
|
||||
formData.get('email'),
|
||||
formData.get('password')
|
||||
);
|
||||
return {error: null, success: true};
|
||||
} catch (error) {
|
||||
return {error: error.message, success: false};
|
||||
}
|
||||
},
|
||||
{error: null, success: false}
|
||||
);
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={(values) => {
|
||||
// Convert Ant Design form values to FormData
|
||||
const formData = new FormData();
|
||||
formData.append('email', values.email);
|
||||
formData.append('password', values.password);
|
||||
submitAction(formData);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="email" rules={[{required: true, type: 'email'}]}>
|
||||
<Input prefix={<UserOutlined/>} placeholder="Email"/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="password" rules={[{required: true}]}>
|
||||
<Input.Password prefix={<LockOutlined/>} placeholder="Password"/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={isPending} block>
|
||||
{isPending ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
{state.error && <AlertComponent type="error" message={state.error}/>}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- ✅ Loading state is local (no Redux slice needed)
|
||||
- ✅ Error handling is simpler
|
||||
- ✅ Still works with Ant Design validation
|
||||
- ✅ Less Redux boilerplate
|
||||
|
||||
---
|
||||
|
||||
**Option 2: Native HTML Form + React 19 (for simpler use cases)**
|
||||
|
||||
```jsx
|
||||
import {useActionState} from 'react';
|
||||
import {signInWithEmailAndPassword} from '@firebase/auth';
|
||||
import {auth} from '../../firebase/firebase.utils';
|
||||
|
||||
function SimpleSignIn() {
|
||||
const [state, formAction, isPending] = useActionState(
|
||||
async (prevState, formData) => {
|
||||
const email = formData.get('email');
|
||||
const password = formData.get('password');
|
||||
|
||||
try {
|
||||
await signInWithEmailAndPassword(auth, email, password);
|
||||
return {error: null};
|
||||
} catch (error) {
|
||||
return {error: error.message};
|
||||
}
|
||||
},
|
||||
{error: null}
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="sign-in-form">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="Email"
|
||||
required
|
||||
/>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
required
|
||||
/>
|
||||
|
||||
<button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
|
||||
{state.error && <div className="error">{state.error}</div>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- ✅ Minimal code
|
||||
- ✅ No form library needed
|
||||
- ✅ Built-in HTML5 validation
|
||||
- ⚠️ Less feature-rich than Ant Design
|
||||
|
||||
---
|
||||
|
||||
## Recommendation for Our Codebase
|
||||
|
||||
### Keep Current Pattern When:
|
||||
|
||||
1. Using complex Ant Design form features (nested forms, dynamic fields, etc.)
|
||||
2. Form state needs to be in Redux for other reasons
|
||||
3. Form is working well and doesn't need changes
|
||||
|
||||
### Consider React 19 Pattern When:
|
||||
|
||||
1. Creating new simple forms
|
||||
2. Form only needs local state
|
||||
3. Want to reduce Redux boilerplate
|
||||
4. Building optimistic UI features
|
||||
|
||||
---
|
||||
|
||||
## Real-World Example: Job Note Adding
|
||||
|
||||
Let's look at a more practical example for our domain:
|
||||
|
||||
### Adding Job Notes with Optimistic UI
|
||||
|
||||
```jsx
|
||||
import {useOptimistic, useActionState} from 'react';
|
||||
import {Form, Input, Button, List} from 'antd';
|
||||
|
||||
function JobNotesModern({jobId, initialNotes}) {
|
||||
const [notes, setNotes] = useState(initialNotes);
|
||||
|
||||
// Optimistic UI for instant feedback
|
||||
const [optimisticNotes, addOptimisticNote] = useOptimistic(
|
||||
notes,
|
||||
(currentNotes, newNote) => [newNote, ...currentNotes]
|
||||
);
|
||||
|
||||
// Form submission with loading state
|
||||
const [state, submitAction, isPending] = useActionState(
|
||||
async (prevState, formData) => {
|
||||
const noteText = formData.get('note');
|
||||
|
||||
// Show note immediately (optimistic)
|
||||
const tempNote = {
|
||||
id: `temp-${Date.now()}`,
|
||||
text: noteText,
|
||||
createdAt: new Date().toISOString(),
|
||||
pending: true,
|
||||
};
|
||||
addOptimisticNote(tempNote);
|
||||
|
||||
try {
|
||||
// Save to server
|
||||
const response = await fetch(`/api/jobs/${jobId}/notes`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({text: noteText}),
|
||||
});
|
||||
|
||||
const savedNote = await response.json();
|
||||
|
||||
// Update with real note
|
||||
setNotes(prev => [savedNote, ...prev]);
|
||||
|
||||
return {error: null, success: true};
|
||||
} catch (error) {
|
||||
// Optimistic note will disappear on next render
|
||||
return {error: error.message, success: false};
|
||||
}
|
||||
},
|
||||
{error: null, success: false}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="job-notes">
|
||||
<Form onFinish={(values) => {
|
||||
const formData = new FormData();
|
||||
formData.append('note', values.note);
|
||||
submitAction(formData);
|
||||
}}>
|
||||
<Form.Item name="note" rules={[{required: true}]}>
|
||||
<Input.TextArea
|
||||
placeholder="Add a note..."
|
||||
rows={3}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Button type="primary" htmlType="submit" loading={isPending}>
|
||||
{isPending ? 'Adding...' : 'Add Note'}
|
||||
</Button>
|
||||
|
||||
{state.error && <div className="error">{state.error}</div>}
|
||||
</Form>
|
||||
|
||||
<List
|
||||
dataSource={optimisticNotes}
|
||||
renderItem={note => (
|
||||
<List.Item style={{opacity: note.pending ? 0.5 : 1}}>
|
||||
<List.Item.Meta
|
||||
title={note.text}
|
||||
description={new Date(note.createdAt).toLocaleString()}
|
||||
/>
|
||||
{note.pending && <span className="badge">Saving...</span>}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**User Experience:**
|
||||
|
||||
1. User types note and clicks "Add Note"
|
||||
2. Note appears instantly (optimistic)
|
||||
3. Note is grayed out with "Saving..." badge
|
||||
4. Once saved, note becomes solid and badge disappears
|
||||
5. If error, note disappears and error shows
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- ⚡ Instant feedback (feels faster)
|
||||
- 🎯 Clear visual indication of pending state
|
||||
- ✅ Automatic error handling
|
||||
- 🧹 Clean, readable code
|
||||
|
||||
---
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
When modernizing a form to React 19 patterns:
|
||||
|
||||
### Step 1: Analyze Current Form
|
||||
|
||||
- [ ] Does it need Redux state? (Multi-component access?)
|
||||
- [ ] How complex is the validation?
|
||||
- [ ] Does it benefit from optimistic UI?
|
||||
- [ ] Is it a good candidate for modernization?
|
||||
|
||||
### Step 2: Choose Pattern
|
||||
|
||||
- [ ] Keep Ant Design + useActionState (complex forms)
|
||||
- [ ] Native HTML + Actions (simple forms)
|
||||
- [ ] Add useOptimistic (instant feedback needed)
|
||||
|
||||
### Step 3: Implement
|
||||
|
||||
- [ ] Create new branch
|
||||
- [ ] Update component
|
||||
- [ ] Test loading states
|
||||
- [ ] Test error states
|
||||
- [ ] Test success flow
|
||||
|
||||
### Step 4: Review
|
||||
|
||||
- [ ] Code is cleaner/simpler?
|
||||
- [ ] No loss of functionality?
|
||||
- [ ] Better UX?
|
||||
- [ ] Team understands pattern?
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
React 19's new features are **additive** - they give us new tools without breaking existing
|
||||
patterns.
|
||||
|
||||
**Recommended Approach:**
|
||||
|
||||
1. ✅ Keep current forms working as-is
|
||||
2. 🎯 Try React 19 patterns in NEW forms first
|
||||
3. 📚 Learn by doing in low-risk features
|
||||
4. 🔄 Gradually adopt where it makes sense
|
||||
|
||||
**Don't:**
|
||||
|
||||
- ❌ Rush to refactor everything
|
||||
- ❌ Break working code
|
||||
- ❌ Force patterns where they don't fit
|
||||
|
||||
**Do:**
|
||||
|
||||
- ✅ Experiment with new features
|
||||
- ✅ Share learnings with team
|
||||
- ✅ Use where it improves code
|
||||
- ✅ Enjoy better DX (Developer Experience)!
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Review the main [REACT_19_FEATURES_GUIDE.md](REACT_19_FEATURES_GUIDE.md)
|
||||
2. Try `useActionState` in one new form
|
||||
3. Share feedback with the team
|
||||
4. Consider optimistic UI for high-traffic features
|
||||
|
||||
Happy coding! 🚀
|
||||
251
_reference/refactorReports/REACT_GRID_LAYOUT_MIGRATION.md
Normal file
251
_reference/refactorReports/REACT_GRID_LAYOUT_MIGRATION.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# React Grid Layout Migration Guide
|
||||
|
||||
## Current Status: Legacy API (v2.2.2)
|
||||
|
||||
### What Changed
|
||||
- **Package Version**: 1.3.4 → 2.2.2
|
||||
- **API Strategy**: Using legacy compatibility layer
|
||||
|
||||
### Migration Completed ✅
|
||||
|
||||
#### Changes Made:
|
||||
```javascript
|
||||
// Before (v1.3.4):
|
||||
import { Responsive, WidthProvider } from "react-grid-layout";
|
||||
|
||||
// After (v2.2.2 with legacy API):
|
||||
import { Responsive, WidthProvider } from "react-grid-layout/legacy";
|
||||
```
|
||||
|
||||
#### Files Updated:
|
||||
- `src/components/dashboard-grid/dashboard-grid.component.jsx`
|
||||
|
||||
#### Why Legacy API?
|
||||
The v2.x release introduces a completely new hooks-based API with breaking changes. The legacy API provides 100% backward compatibility, allowing us to:
|
||||
- ✅ Get bug fixes and security updates
|
||||
- ✅ Maintain existing functionality without code rewrites
|
||||
- ✅ Plan migration to new API incrementally
|
||||
|
||||
---
|
||||
|
||||
## Future: Migration to New v2 API
|
||||
|
||||
When ready to fully migrate to the modern v2 API, follow this guide:
|
||||
|
||||
### Breaking Changes in v2
|
||||
|
||||
1. **Width Provider Removed**
|
||||
- Old: `WidthProvider(Responsive)`
|
||||
- New: Use `useContainerWidth` hook
|
||||
|
||||
2. **Props Restructured**
|
||||
- Old: Flat props structure
|
||||
- New: Grouped configs (`gridConfig`, `dragConfig`, `resizeConfig`)
|
||||
|
||||
3. **Layout Prop Required**
|
||||
- Old: Could use `data-grid` attribute
|
||||
- New: Must provide `layout` prop explicitly
|
||||
|
||||
4. **Compaction Changes**
|
||||
- Old: `verticalCompact` prop
|
||||
- New: `compactor` prop with pluggable algorithms
|
||||
|
||||
### Migration Steps
|
||||
|
||||
#### Step 1: Replace WidthProvider with useContainerWidth hook
|
||||
|
||||
**Before:**
|
||||
```javascript
|
||||
const ResponsiveReactGridLayout = WidthProvider(Responsive);
|
||||
|
||||
return (
|
||||
<ResponsiveReactGridLayout
|
||||
className="layout"
|
||||
breakpoints={GRID_BREAKPOINTS}
|
||||
cols={GRID_COLS}
|
||||
layouts={state.layouts}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
>
|
||||
{children}
|
||||
</ResponsiveReactGridLayout>
|
||||
);
|
||||
```
|
||||
|
||||
**After:**
|
||||
```javascript
|
||||
import ReactGridLayout, { useContainerWidth, verticalCompactor } from 'react-grid-layout';
|
||||
|
||||
function DashboardGridComponent({ currentUser }) {
|
||||
const { width, containerRef, mounted } = useContainerWidth();
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
{mounted && (
|
||||
<ReactGridLayout
|
||||
width={width}
|
||||
layout={state.layout}
|
||||
gridConfig={{
|
||||
cols: 12,
|
||||
rowHeight: 30,
|
||||
margin: [10, 10]
|
||||
}}
|
||||
dragConfig={{
|
||||
enabled: true,
|
||||
handle: '.drag-handle' // optional
|
||||
}}
|
||||
resizeConfig={{
|
||||
enabled: true
|
||||
}}
|
||||
compactor={verticalCompactor}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
>
|
||||
{children}
|
||||
</ReactGridLayout>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 2: Update Responsive Layouts
|
||||
|
||||
For responsive behavior, manage breakpoints manually:
|
||||
|
||||
```javascript
|
||||
function DashboardGridComponent() {
|
||||
const { width, containerRef, mounted } = useContainerWidth();
|
||||
const [currentBreakpoint, setCurrentBreakpoint] = useState('lg');
|
||||
|
||||
useEffect(() => {
|
||||
if (width > 1200) setCurrentBreakpoint('lg');
|
||||
else if (width > 996) setCurrentBreakpoint('md');
|
||||
else if (width > 768) setCurrentBreakpoint('sm');
|
||||
else if (width > 480) setCurrentBreakpoint('xs');
|
||||
else setCurrentBreakpoint('xxs');
|
||||
}, [width]);
|
||||
|
||||
const currentLayout = state.layouts[currentBreakpoint] || state.layout;
|
||||
const currentCols = GRID_COLS[currentBreakpoint];
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
{mounted && (
|
||||
<ReactGridLayout
|
||||
width={width}
|
||||
layout={currentLayout}
|
||||
gridConfig={{
|
||||
cols: currentCols,
|
||||
rowHeight: 30
|
||||
}}
|
||||
// ... other props
|
||||
>
|
||||
{children}
|
||||
</ReactGridLayout>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: Update Child Components
|
||||
|
||||
The `data-grid` attribute still works, but explicitly managing layout is preferred:
|
||||
|
||||
**Before:**
|
||||
```javascript
|
||||
<div
|
||||
key={item.i}
|
||||
data-grid={{
|
||||
...item,
|
||||
minH,
|
||||
minW
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
```
|
||||
|
||||
**After (Preferred):**
|
||||
```javascript
|
||||
// Manage layout in parent state
|
||||
const layout = state.items.map(item => ({
|
||||
i: item.i,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
w: item.w,
|
||||
h: item.h,
|
||||
minW: componentList[item.i]?.minW || 1,
|
||||
minH: componentList[item.i]?.minH || 1
|
||||
}));
|
||||
|
||||
// Children just need keys
|
||||
<div key={item.i}>
|
||||
{content}
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Step 4: Update Styles (if needed)
|
||||
|
||||
The CSS classes remain mostly the same, but check the new documentation for any changes.
|
||||
|
||||
### Benefits of New API
|
||||
|
||||
- 🚀 **Better Performance**: Optimized rendering with hooks
|
||||
- 📦 **TypeScript Support**: Full type definitions included
|
||||
- 🎯 **Better API**: More intuitive props organization
|
||||
- 🔧 **Extensibility**: Pluggable compactors and strategies
|
||||
- 📱 **Modern React**: Uses hooks pattern
|
||||
|
||||
### Testing Checklist
|
||||
|
||||
When migrating to new API:
|
||||
|
||||
- [ ] Grid items render correctly
|
||||
- [ ] Drag functionality works
|
||||
- [ ] Resize functionality works
|
||||
- [ ] Responsive breakpoints work
|
||||
- [ ] Layout persistence works
|
||||
- [ ] Add/remove components works
|
||||
- [ ] Min/max constraints respected
|
||||
- [ ] Performance is acceptable
|
||||
- [ ] No console errors or warnings
|
||||
|
||||
### Resources
|
||||
|
||||
- [React Grid Layout v2 Documentation](https://github.com/react-grid-layout/react-grid-layout)
|
||||
- [Migration Guide](https://www.npmjs.com/package/react-grid-layout)
|
||||
- [Examples](https://github.com/react-grid-layout/react-grid-layout/tree/master/examples)
|
||||
|
||||
---
|
||||
|
||||
## Current Implementation Notes
|
||||
|
||||
### Component Structure
|
||||
- **File**: `src/components/dashboard-grid/dashboard-grid.component.jsx`
|
||||
- **Styles**: `src/components/dashboard-grid/dashboard-grid.styles.scss`
|
||||
- **Pattern**: Responsive grid with dynamic component loading
|
||||
|
||||
### Key Features Used
|
||||
- ✅ Responsive layouts with breakpoints
|
||||
- ✅ Drag and drop
|
||||
- ✅ Resize handles
|
||||
- ✅ Layout persistence to database
|
||||
- ✅ Dynamic component add/remove
|
||||
- ✅ Min/max size constraints
|
||||
|
||||
### Configuration
|
||||
```javascript
|
||||
const GRID_BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
|
||||
const GRID_COLS = { lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 };
|
||||
```
|
||||
|
||||
### Performance Considerations
|
||||
- Layout changes debounced via database updates
|
||||
- Memoized dashboard queries to prevent re-fetches
|
||||
- Memoized menu items and layout keys
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-01-13
|
||||
**Current Version**: react-grid-layout@2.2.2 (legacy API)
|
||||
**Target Version**: react-grid-layout@2.2.2 (new API) - Future migration
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,3 +14,7 @@ VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
|
||||
VITE_APP_INSTANCE=IMEX
|
||||
TEST_USERNAME="test@imex.dev"
|
||||
TEST_PASSWORD="test123"
|
||||
VITE_PUBLIC_POSTHOG_KEY=phc_xtLmBIu0rjWwExY73Oj5DTH1bGbwq1G1Y8jnlTceien
|
||||
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||
VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com
|
||||
VITE_APP_AMP_KEY=6228a598e57cd66875cfd41604f1f891
|
||||
|
||||
@@ -16,3 +16,7 @@ VITE_APP_COUNTRY=USA
|
||||
VITE_APP_INSTANCE=ROME
|
||||
TEST_USERNAME="test@imex.dev"
|
||||
TEST_PASSWORD="test123"
|
||||
VITE_PUBLIC_POSTHOG_KEY=phc_xtLmBIu0rjWwExY73Oj5DTH1bGbwq1G1Y8jnlTceien
|
||||
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||
VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com
|
||||
VITE_APP_AMP_KEY=46b1193a867d4e3131ae4c3a64a3fc78
|
||||
|
||||
@@ -13,3 +13,7 @@ VITE_APP_AXIOS_BASE_API_URL=https://api.imex.online/
|
||||
VITE_APP_REPORTS_SERVER_URL=https://reports.imex.online
|
||||
VITE_APP_SPLIT_API=et9pjkik6bn67he5evpmpr1agoo7gactphgk
|
||||
VITE_APP_INSTANCE=IMEX
|
||||
VITE_PUBLIC_POSTHOG_KEY=phc_xtLmBIu0rjWwExY73Oj5DTH1bGbwq1G1Y8jnlTceien
|
||||
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||
VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com
|
||||
VITE_APP_AMP_KEY=6228a598e57cd66875cfd41604f1f891
|
||||
@@ -13,3 +13,7 @@ VITE_APP_AXIOS_BASE_API_URL=https://api.romeonline.io/
|
||||
VITE_APP_REPORTS_SERVER_URL=https://reports.romeonline.io
|
||||
VITE_APP_SPLIT_API=et9pjkik6bn67he5evpmpr1agoo7gactphgk
|
||||
VITE_APP_INSTANCE=ROME
|
||||
VITE_PUBLIC_POSTHOG_KEY=phc_xtLmBIu0rjWwExY73Oj5DTH1bGbwq1G1Y8jnlTceien
|
||||
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||
VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com
|
||||
VITE_APP_AMP_KEY=46b1193a867d4e3131ae4c3a64a3fc78
|
||||
@@ -13,3 +13,7 @@ VITE_APP_REPORTS_SERVER_URL=https://reports.test.imex.online
|
||||
VITE_APP_IS_TEST=true
|
||||
VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
|
||||
VITE_APP_INSTANCE=IMEX
|
||||
VITE_PUBLIC_POSTHOG_KEY=phc_xtLmBIu0rjWwExY73Oj5DTH1bGbwq1G1Y8jnlTceien
|
||||
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||
VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com
|
||||
VITE_APP_AMP_KEY=6228a598e57cd66875cfd41604f1f891
|
||||
|
||||
@@ -13,3 +13,7 @@ VITE_APP_REPORTS_SERVER_URL=https://reports.test.romeonline.io
|
||||
VITE_APP_IS_TEST=true
|
||||
VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
|
||||
VITE_APP_INSTANCE=ROME
|
||||
VITE_PUBLIC_POSTHOG_KEY=phc_xtLmBIu0rjWwExY73Oj5DTH1bGbwq1G1Y8jnlTceien
|
||||
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||
VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com
|
||||
VITE_APP_AMP_KEY=46b1193a867d4e3131ae4c3a64a3fc78
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"react-app"
|
||||
],
|
||||
"rules": {
|
||||
"no-useless-rename": "off"
|
||||
}
|
||||
}
|
||||
3
client/.gitignore
vendored
3
client/.gitignore
vendored
@@ -13,3 +13,6 @@ playwright/.cache/
|
||||
# Sentry Config File
|
||||
.sentryclirc
|
||||
/dev-dist
|
||||
|
||||
# Local environment overrides (not version controlled)
|
||||
.env.development.local.overrides
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import globals from "globals";
|
||||
import pluginJs from "@eslint/js";
|
||||
import pluginReact from "eslint-plugin-react";
|
||||
import pluginReactCompiler from "eslint-plugin-react-compiler";
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
{ ignores: [
|
||||
"node_modules/**",
|
||||
"dist/**",
|
||||
"build/**",
|
||||
"dev-dist/**",
|
||||
"**/trello-board/dnd/**" // Exclude third-party DnD library
|
||||
] },
|
||||
{
|
||||
files: ["**/*.{js,mjs,cjs,jsx}"]
|
||||
},
|
||||
@@ -12,10 +19,22 @@ export default [
|
||||
pluginJs.configs.recommended,
|
||||
{
|
||||
...pluginReact.configs.flat.recommended,
|
||||
settings: {
|
||||
react: { version: "detect" }
|
||||
},
|
||||
rules: {
|
||||
...pluginReact.configs.flat.recommended.rules,
|
||||
"react/prop-types": 0
|
||||
"react/prop-types": 0,
|
||||
"react/no-children-prop": 0 // Disable react/no-children-prop rule
|
||||
}
|
||||
},
|
||||
pluginReact.configs.flat["jsx-runtime"]
|
||||
pluginReact.configs.flat["jsx-runtime"],
|
||||
{
|
||||
plugins: {
|
||||
"react-compiler": pluginReactCompiler
|
||||
},
|
||||
rules: {
|
||||
"react-compiler/react-compiler": "error"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -74,50 +74,8 @@
|
||||
})();
|
||||
</script>
|
||||
<% } %>
|
||||
<script>
|
||||
!(function () {
|
||||
"use strict";
|
||||
var e = [
|
||||
"debug",
|
||||
"destroy",
|
||||
"do",
|
||||
"help",
|
||||
"identify",
|
||||
"is",
|
||||
"off",
|
||||
"on",
|
||||
"ready",
|
||||
"render",
|
||||
"reset",
|
||||
"safe",
|
||||
"set"
|
||||
];
|
||||
if (window.noticeable) console.warn("Noticeable SDK code snippet loaded more than once");
|
||||
else {
|
||||
var n = (window.noticeable = window.noticeable || []);
|
||||
<script>!function(w,d,i,s){function l(){if(!d.getElementById(i)){var f=d.getElementsByTagName(s)[0],e=d.createElement(s);e.type="text/javascript",e.async=!0,e.src="https://canny.io/sdk.js",f.parentNode.insertBefore(e,f)}}if("function"!=typeof w.Canny){var c=function(){c.q.push(arguments)};c.q=[],w.Canny=c,"complete"===d.readyState?l():w.attachEvent?w.attachEvent("onload",l):w.addEventListener("load",l,!1)}}(window,document,"canny-jssdk","script");</script>
|
||||
|
||||
function t(e) {
|
||||
return function () {
|
||||
var t = Array.prototype.slice.call(arguments);
|
||||
return t.unshift(e), n.push(t), n;
|
||||
};
|
||||
}
|
||||
|
||||
!(function () {
|
||||
for (var o = 0; o < e.length; o++) {
|
||||
var r = e[o];
|
||||
n[r] = t(r);
|
||||
}
|
||||
})(),
|
||||
(function () {
|
||||
var e = document.createElement("script");
|
||||
(e.async = !0), (e.src = "https://sdk.noticeable.io/l.js");
|
||||
var n = document.head;
|
||||
n.insertBefore(e, n.firstChild);
|
||||
})();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
11460
client/package-lock.json
generated
11460
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,96 +8,101 @@
|
||||
"private": true,
|
||||
"proxy": "http://localhost:4000",
|
||||
"dependencies": {
|
||||
"@ant-design/pro-layout": "^7.22.3",
|
||||
"@apollo/client": "^3.13.5",
|
||||
"@emotion/is-prop-valid": "^1.3.1",
|
||||
"@fingerprintjs/fingerprintjs": "^4.6.1",
|
||||
"@firebase/analytics": "^0.10.12",
|
||||
"@firebase/app": "^0.11.3",
|
||||
"@firebase/auth": "^1.9.1",
|
||||
"@firebase/firestore": "^4.7.10",
|
||||
"@firebase/messaging": "^0.12.17",
|
||||
"@amplitude/analytics-browser": "^2.34.0",
|
||||
"@ant-design/pro-layout": "^7.22.6",
|
||||
"@apollo/client": "^4.1.3",
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||
"@firebase/analytics": "^0.10.19",
|
||||
"@firebase/app": "^0.14.7",
|
||||
"@firebase/auth": "^1.12.0",
|
||||
"@firebase/firestore": "^4.10.0",
|
||||
"@firebase/messaging": "^0.12.22",
|
||||
"@jsreport/browser-client": "^3.1.0",
|
||||
"@reduxjs/toolkit": "^2.6.1",
|
||||
"@sentry/cli": "^2.42.4",
|
||||
"@sentry/react": "^9.9.0",
|
||||
"@sentry/vite-plugin": "^3.2.2",
|
||||
"@splitsoftware/splitio-react": "^2.0.1",
|
||||
"@tanem/react-nprogress": "^5.0.53",
|
||||
"antd": "^5.24.5",
|
||||
"apollo-link-logger": "^2.0.1",
|
||||
"apollo-link-sentry": "^4.2.0",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@sentry/cli": "^3.1.0",
|
||||
"@sentry/react": "^10.38.0",
|
||||
"@sentry/vite-plugin": "^4.8.0",
|
||||
"@splitsoftware/splitio-react": "^2.6.1",
|
||||
"@tanem/react-nprogress": "^5.0.58",
|
||||
"antd": "^6.2.2",
|
||||
"apollo-link-logger": "^3.0.0",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.8.4",
|
||||
"axios": "^1.13.4",
|
||||
"classnames": "^2.5.1",
|
||||
"css-box-model": "^1.2.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"dayjs-business-days2": "^1.3.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs-business-days2": "^1.3.2",
|
||||
"dinero.js": "^1.9.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"env-cmd": "^10.1.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"env-cmd": "^11.0.0",
|
||||
"exifr": "^7.1.3",
|
||||
"graphql": "^16.10.0",
|
||||
"i18next": "^24.2.3",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"graphql": "^16.12.0",
|
||||
"graphql-ws": "^6.0.7",
|
||||
"i18next": "^25.8.0",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"libphonenumber-js": "^1.12.6",
|
||||
"logrocket": "^9.0.2",
|
||||
"markerjs2": "^2.32.4",
|
||||
"libphonenumber-js": "^1.12.36",
|
||||
"lightningcss": "^1.31.1",
|
||||
"logrocket": "^12.0.0",
|
||||
"markerjs2": "^2.32.7",
|
||||
"memoize-one": "^6.0.0",
|
||||
"normalize-url": "^8.0.1",
|
||||
"normalize-url": "^8.1.1",
|
||||
"object-hash": "^3.0.0",
|
||||
"phone": "^3.1.70",
|
||||
"posthog-js": "^1.336.4",
|
||||
"prop-types": "^15.8.1",
|
||||
"query-string": "^9.1.1",
|
||||
"query-string": "^9.3.1",
|
||||
"raf-schd": "^4.0.3",
|
||||
"react": "^18.3.1",
|
||||
"react-big-calendar": "^1.18.0",
|
||||
"react": "^19.2.4",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
"react-color": "^2.19.3",
|
||||
"react-cookie": "^8.0.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-drag-listview": "^2.0.0",
|
||||
"react-grid-gallery": "^1.0.1",
|
||||
"react-grid-layout": "1.3.4",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-image-lightbox": "^5.1.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-number-format": "^5.4.3",
|
||||
"react-popopo": "^2.1.9",
|
||||
"react-product-fruits": "^2.2.61",
|
||||
"react-product-fruits": "^2.2.62",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-resizable": "^3.0.5",
|
||||
"react-router-dom": "^6.30.0",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"react-sticky": "^6.0.3",
|
||||
"react-virtuoso": "^4.12.5",
|
||||
"recharts": "^2.15.0",
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"recharts": "^3.7.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-actions": "^3.0.3",
|
||||
"redux-persist": "^6.0.0",
|
||||
"redux-saga": "^1.3.0",
|
||||
"redux-saga": "^1.4.2",
|
||||
"redux-state-sync": "^3.1.4",
|
||||
"reselect": "^5.1.1",
|
||||
"sass": "^1.86.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"styled-components": "^6.1.16",
|
||||
"subscriptions-transport-ws": "^0.11.0",
|
||||
"use-memo-one": "^1.1.3",
|
||||
"rxjs": "^7.8.2",
|
||||
"sass": "^1.97.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"styled-components": "^6.3.8",
|
||||
"vite-plugin-ejs": "^1.7.0",
|
||||
"web-vitals": "^3.5.2"
|
||||
"web-vitals": "^5.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "echo 'when updating react-big-calendar, remember to check to localizer in the calendar wrapper'",
|
||||
"analyze": "source-map-explorer 'build/static/js/*.js'",
|
||||
"start": "vite",
|
||||
"build": "dotenvx run --env-file=.env.development.imex -- vite build",
|
||||
"start:imex": "dotenvx run --env-file=.env.development.imex -- vite",
|
||||
"start:rome": "dotenvx run --env-file=.env.development.rome -- vite",
|
||||
"preview:imex": "dotenvx run --env-file=.env.development.imex -- vite preview",
|
||||
"preview:rome": "dotenvx run --env-file=.env.development.rome -- vite preview",
|
||||
"build:test:imex": "env-cmd -f .env.test.imex npm run build",
|
||||
"build:test:rome": "env-cmd -f .env.test.rome npm run build",
|
||||
"build:production:imex": "env-cmd -f .env.production.imex npm run build",
|
||||
"build:production:rome": "env-cmd -f .env.production.rome npm run build",
|
||||
"build": "vite build",
|
||||
"build:dev:imex": "dotenvx run --env-file=.env.development.imex --env-file=.env.development.local.overrides -- vite build",
|
||||
"build:dev:rome": "dotenvx run --env-file=.env.development.rome --env-file=.env.development.local.overrides -- vite build",
|
||||
"build:test:imex": "dotenvx run --env-file=.env.test.imex -- vite build",
|
||||
"build:test:rome": "dotenvx run --env-file=.env.test.rome -- vite build",
|
||||
"build:production:imex": "env-cmd -f .env.production.imex vite build",
|
||||
"build:production:rome": "env-cmd -f .env.production.rome vite build",
|
||||
"start:imex": "dotenvx run --env-file=.env.development.imex --env-file=.env.development.local.overrides -- vite",
|
||||
"start:rome": "dotenvx run --env-file=.env.development.rome --env-file=.env.development.local.overrides -- vite",
|
||||
"preview:imex": "dotenvx run --env-file=.env.development.imex --env-file=.env.development.local.overrides -- vite preview",
|
||||
"preview:rome": "dotenvx run --env-file=.env.development.rome --env-file=.env.development.local.overrides -- vite preview",
|
||||
"madge": "madge --image ./madge-graph.svg --extensions js,jsx,ts,tsx --circular .",
|
||||
"eulaize": "node src/utils/eulaize.js",
|
||||
"test:unit": "vitest run",
|
||||
@@ -106,7 +111,9 @@
|
||||
"test:e2e:rome": "playwright test --config playwright.rome.config.js",
|
||||
"test:e2e:imex:headed": "playwright test --config playwright.config.js --headed",
|
||||
"test:e2e:rome:headed": "playwright test --config playwright.rome.config.js --headed",
|
||||
"test:e2e:report": "playwright show-report"
|
||||
"test:e2e:report": "playwright show-report",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@@ -127,40 +134,40 @@
|
||||
"@rollup/rollup-linux-x64-gnu": "4.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@dotenvx/dotenvx": "^1.39.0",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@dotenvx/dotenvx": "^1.52.0",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@eslint/js": "^9.23.0",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@sentry/webpack-plugin": "^3.2.2",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"browserslist": "^4.24.4",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@playwright/test": "^1.58.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"browserslist": "^4.28.1",
|
||||
"browserslist-to-esbuild": "^2.1.1",
|
||||
"chalk": "^5.4.1",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"globals": "^15.15.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"memfs": "^4.17.0",
|
||||
"chalk": "^5.6.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
||||
"globals": "^17.2.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"memfs": "^4.56.10",
|
||||
"os-browserify": "^0.3.0",
|
||||
"playwright": "^1.51.1",
|
||||
"playwright": "^1.58.0",
|
||||
"react-error-overlay": "^6.1.0",
|
||||
"redux-logger": "^3.0.6",
|
||||
"source-map-explorer": "^2.5.3",
|
||||
"vite": "^6.2.3",
|
||||
"vite-plugin-babel": "^1.3.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-babel": "^1.4.1",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-plugin-node-polyfills": "^0.23.0",
|
||||
"vite-plugin-pwa": "^0.21.2",
|
||||
"vite-plugin-node-polyfills": "^0.25.0",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-style-import": "^2.0.0",
|
||||
"vitest": "^3.0.9",
|
||||
"workbox-window": "^7.3.0"
|
||||
"vitest": "^4.0.18",
|
||||
"workbox-window": "^7.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export default defineConfig({
|
||||
command: "npm run start:imex",
|
||||
ignoreHTTPSErrors: true,
|
||||
url: "https://localhost:3000/health", // Health check endpoint will tell us when the server is ready
|
||||
// eslint-disable-next-line no-undef
|
||||
reuseExistingServer: !process.env.CI // Reuse server locally, not in CI
|
||||
}
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ export default defineConfig({
|
||||
command: "npm run start:rome",
|
||||
ignoreHTTPSErrors: true,
|
||||
url: "https://localhost:3000/health", // Health check endpoint will tell us when the server is ready
|
||||
// eslint-disable-next-line no-undef
|
||||
reuseExistingServer: !process.env.CI // Reuse server locally, not in CI
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Scripts for firebase and firebase messaging
|
||||
// eslint-disable-next-line no-undef
|
||||
importScripts("https://www.gstatic.com/firebasejs/10.14.1/firebase-app-compat.js");
|
||||
// eslint-disable-next-line no-undef
|
||||
importScripts("https://www.gstatic.com/firebasejs/10.14.1/firebase-messaging-compat.js");
|
||||
|
||||
// Initialize the Firebase app in the service worker by passing the generated config
|
||||
@@ -42,13 +44,16 @@ switch (this.location.hostname) {
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
firebase.initializeApp(firebaseConfig);
|
||||
|
||||
// Retrieve firebase messaging
|
||||
// eslint-disable-next-line no-undef
|
||||
const messaging = firebase.messaging();
|
||||
|
||||
messaging.onBackgroundMessage(function (payload) {
|
||||
// Customize notification here
|
||||
console.log("[firebase-messaging-sw.js] Received background message ", payload);
|
||||
// eslint-disable-next-line no-undef
|
||||
self.registration.showNotification(notificationTitle, notificationOptions);
|
||||
});
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
import { ApolloProvider } from "@apollo/client";
|
||||
import { ApolloProvider } from "@apollo/client/react";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
|
||||
import { ConfigProvider } from "antd";
|
||||
import enLocale from "antd/es/locale/en_US";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { CookiesProvider } from "react-cookie";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
|
||||
import { setDarkMode } from "../redux/application/application.actions";
|
||||
import { selectDarkMode } from "../redux/application/application.selectors";
|
||||
import { selectCurrentUser } from "../redux/user/user.selectors.js";
|
||||
import { signOutStart } from "../redux/user/user.actions";
|
||||
import client from "../utils/GraphQLClient";
|
||||
import App from "./App";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import themeProvider from "./themeProvider";
|
||||
import getTheme from "./themeProvider";
|
||||
|
||||
// Base Split configuration
|
||||
const config = {
|
||||
core: {
|
||||
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
|
||||
key: "anon" // Default key, overridden dynamically by SplitClientProvider
|
||||
key: "anon"
|
||||
}
|
||||
};
|
||||
|
||||
// Custom provider to manage the Split client key based on imexshopid from Redux
|
||||
function SplitClientProvider({ children }) {
|
||||
const imexshopid = useSelector((state) => state.user.imexshopid); // Access imexshopid from Redux store
|
||||
const splitClient = useSplitClient({ key: imexshopid || "anon" }); // Use imexshopid or fallback to "anon"
|
||||
const imexshopid = useSelector((state) => state.user.imexshopid);
|
||||
const splitClient = useSplitClient({ key: imexshopid || "anon" });
|
||||
|
||||
useEffect(() => {
|
||||
if (splitClient && imexshopid) {
|
||||
// Log readiness for debugging; no need for ready() since isReady is available
|
||||
if (import.meta.env.DEV && splitClient && imexshopid) {
|
||||
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
|
||||
}
|
||||
}, [splitClient, imexshopid]);
|
||||
@@ -36,28 +39,99 @@ function SplitClientProvider({ children }) {
|
||||
|
||||
function AppContainer() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const currentUser = useSelector(selectCurrentUser);
|
||||
const isDarkMode = useSelector(selectDarkMode);
|
||||
|
||||
const theme = useMemo(() => getTheme(isDarkMode), [isDarkMode]);
|
||||
|
||||
const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []);
|
||||
|
||||
const antdForm = useMemo(
|
||||
() => ({
|
||||
validateMessages: {
|
||||
required: t("general.validation.required", { label: "${label}" })
|
||||
}
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
// Global seamless logout listener with redirect to /signin
|
||||
useEffect(() => {
|
||||
const handleSeamlessLogout = (event) => {
|
||||
if (event.data?.type !== "seamlessLogoutRequest") return;
|
||||
|
||||
// Only accept messages from the parent window
|
||||
if (event.source !== window.parent) return;
|
||||
|
||||
const targetOrigin = event.origin || "*";
|
||||
|
||||
if (currentUser?.authorized !== true) {
|
||||
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "already_logged_out" }, targetOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(signOutStart());
|
||||
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, targetOrigin);
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleSeamlessLogout);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleSeamlessLogout);
|
||||
};
|
||||
}, [dispatch, currentUser?.authorized]);
|
||||
|
||||
// Update data-theme attribute (no cleanup to avoid transient style churn)
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = isDarkMode ? "dark" : "light";
|
||||
}, [isDarkMode]);
|
||||
|
||||
// Sync darkMode with localStorage
|
||||
useEffect(() => {
|
||||
const uid = currentUser?.uid;
|
||||
|
||||
if (!uid) {
|
||||
dispatch(setDarkMode(false));
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `dark-mode-${uid}`;
|
||||
const raw = localStorage.getItem(key);
|
||||
|
||||
if (raw == null) {
|
||||
dispatch(setDarkMode(false));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
dispatch(setDarkMode(Boolean(JSON.parse(raw))));
|
||||
} catch {
|
||||
dispatch(setDarkMode(false));
|
||||
}
|
||||
}, [currentUser?.uid, dispatch]);
|
||||
|
||||
// Persist darkMode
|
||||
useEffect(() => {
|
||||
const uid = currentUser?.uid;
|
||||
if (!uid) return;
|
||||
|
||||
localStorage.setItem(`dark-mode-${uid}`, JSON.stringify(isDarkMode));
|
||||
}, [isDarkMode, currentUser?.uid]);
|
||||
|
||||
return (
|
||||
<ApolloProvider client={client}>
|
||||
<ConfigProvider
|
||||
input={{ autoComplete: "new-password" }}
|
||||
locale={enLocale}
|
||||
theme={themeProvider}
|
||||
form={{
|
||||
validateMessages: {
|
||||
// eslint-disable-next-line no-template-curly-in-string
|
||||
required: t("general.validation.required", { label: "${label}" })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<GlobalLoadingBar />
|
||||
<SplitFactoryProvider config={config}>
|
||||
<SplitClientProvider>
|
||||
<App />
|
||||
</SplitClientProvider>
|
||||
</SplitFactoryProvider>
|
||||
</ConfigProvider>
|
||||
</ApolloProvider>
|
||||
<CookiesProvider>
|
||||
<ApolloProvider client={client}>
|
||||
<ConfigProvider input={antdInput} locale={enLocale} theme={theme} form={antdForm}>
|
||||
<GlobalLoadingBar />
|
||||
<SplitFactoryProvider config={config}>
|
||||
<SplitClientProvider>
|
||||
<App />
|
||||
</SplitClientProvider>
|
||||
</SplitFactoryProvider>
|
||||
</ConfigProvider>
|
||||
</ApolloProvider>
|
||||
</CookiesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,14 @@ import { connect } from "react-redux";
|
||||
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
|
||||
import ErrorBoundary from "../components/error-boundary/error-boundary.component";
|
||||
import LoadingSpinner from "../components/loading-spinner/loading-spinner.component";
|
||||
import DisclaimerPage from "../pages/disclaimer/disclaimer.page";
|
||||
import LandingPage from "../pages/landing/landing.page";
|
||||
import TechPageContainer from "../pages/tech/tech.page.container";
|
||||
import { setOnline } from "../redux/application/application.actions";
|
||||
import { selectOnline } from "../redux/application/application.selectors";
|
||||
import SimplifiedPartsPageContainer from "../pages/simplified-parts/simplified-parts.page.container.jsx";
|
||||
import { setIsPartsEntry, setOnline } from "../redux/application/application.actions";
|
||||
import { selectIsPartsEntry, selectOnline } from "../redux/application/application.selectors";
|
||||
import { checkUserSession } from "../redux/user/user.actions";
|
||||
import { selectBodyshop, selectCurrentEula, selectCurrentUser } from "../redux/user/user.selectors";
|
||||
import PrivateRoute from "../components/PrivateRoute";
|
||||
@@ -21,28 +22,39 @@ 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/useSocket.jsx";
|
||||
import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx";
|
||||
import SocketProvider from "../contexts/SocketIO/socketProvider.jsx";
|
||||
import SoundWrapper from "./SoundWrapper.jsx";
|
||||
|
||||
const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component"));
|
||||
const ManagePage = lazy(() => import("../pages/manage/manage.page.container"));
|
||||
const SignInPage = lazy(() => import("../pages/sign-in/sign-in.page"));
|
||||
const CsiPage = lazy(() => import("../pages/csi/csi.container.page"));
|
||||
const MobilePaymentContainer = lazy(() => import("../pages/mobile-payment/mobile-payment.container"));
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser,
|
||||
online: selectOnline,
|
||||
bodyshop: selectBodyshop,
|
||||
currentEula: selectCurrentEula
|
||||
currentEula: selectCurrentEula,
|
||||
isPartsEntry: selectIsPartsEntry
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
checkUserSession: () => dispatch(checkUserSession()),
|
||||
setOnline: (isOnline) => dispatch(setOnline(isOnline))
|
||||
setOnline: (isOnline) => dispatch(setOnline(isOnline)),
|
||||
setIsPartsEntry: (isParts) => dispatch(setIsPartsEntry(isParts))
|
||||
});
|
||||
|
||||
export function App({ bodyshop, checkUserSession, currentUser, online, setOnline, currentEula }) {
|
||||
export function App({
|
||||
bodyshop,
|
||||
checkUserSession,
|
||||
currentUser,
|
||||
online,
|
||||
setOnline,
|
||||
setIsPartsEntry,
|
||||
currentEula,
|
||||
isPartsEntry
|
||||
}) {
|
||||
const client = useSplitClient().client;
|
||||
const [listenersAdded, setListenersAdded] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
@@ -52,12 +64,14 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
|
||||
if (!navigator.onLine) {
|
||||
setOnline(false);
|
||||
}
|
||||
|
||||
checkUserSession();
|
||||
}, [checkUserSession, setOnline]);
|
||||
|
||||
//const b = Grid.useBreakpoint();
|
||||
// console.log("Breakpoints:", b);
|
||||
useEffect(() => {
|
||||
const pathname = window.location.pathname;
|
||||
const isParts = pathname === "/parts" || pathname.startsWith("/parts/");
|
||||
setIsPartsEntry(isParts);
|
||||
}, [setIsPartsEntry]);
|
||||
|
||||
// Associate event listeners, memoize to prevent multiple listeners being added
|
||||
useEffect(() => {
|
||||
@@ -86,14 +100,7 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
|
||||
if (currentUser.authorized && bodyshop) {
|
||||
client.setAttribute("imexshopid", bodyshop.imexshopid);
|
||||
|
||||
if (
|
||||
client.getTreatment("LogRocket_Tracking") === "on" ||
|
||||
window.location.hostname ===
|
||||
InstanceRenderMgr({
|
||||
imex: "beta.imex.online",
|
||||
rome: "beta.romeonline.io"
|
||||
})
|
||||
) {
|
||||
if (client.getTreatment("LogRocket_Tracking") === "on") {
|
||||
console.log("LR Start");
|
||||
LogRocket.init(
|
||||
InstanceRenderMgr({
|
||||
@@ -124,7 +131,7 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
|
||||
);
|
||||
}
|
||||
|
||||
if (currentEula && !currentUser.eulaIsAccepted) {
|
||||
if (!isPartsEntry && currentEula && !currentUser.eulaIsAccepted) {
|
||||
return <Eula />;
|
||||
}
|
||||
|
||||
@@ -142,89 +149,93 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
|
||||
>
|
||||
<ProductFruitsWrapper
|
||||
currentUser={currentUser}
|
||||
workspaceCode={InstanceRenderMgr({
|
||||
imex: null,
|
||||
rome: "9BkbEseqNqxw8jUH"
|
||||
})}
|
||||
bodyshop={bodyshop}
|
||||
workspaceCode={bodyshop?.tours_enabled ? "9BkbEseqNqxw8jUH" : ""}
|
||||
isPartsEntry={isPartsEntry}
|
||||
/>
|
||||
|
||||
<NotificationProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<LandingPage />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/signin"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<SignInPage />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/resetpassword"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<ResetPassword />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/csi/:surveyId"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<CsiPage />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/disclaimer"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<DisclaimerPage />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/mp/:paymentIs"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<MobilePaymentContainer />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/manage/*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
|
||||
<SoundWrapper bodyshop={bodyshop}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<LandingPage />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/signin"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<SignInPage />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/resetpassword"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<ResetPassword />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/csi/:surveyId"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<CsiPage />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/disclaimer"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<DisclaimerPage />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/manage/*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
</SocketProvider>
|
||||
</ErrorBoundary>
|
||||
}
|
||||
>
|
||||
<Route path="*" element={<ManagePage />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="/tech/*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
</SocketProvider>
|
||||
</ErrorBoundary>
|
||||
}
|
||||
>
|
||||
<Route path="*" element={<TechPageContainer />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="/parts/*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
</SocketProvider>
|
||||
</ErrorBoundary>
|
||||
}
|
||||
>
|
||||
<Route path="*" element={<ManagePage />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="/tech/*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
</SocketProvider>
|
||||
</ErrorBoundary>
|
||||
}
|
||||
>
|
||||
<Route path="*" element={<TechPageContainer />} />
|
||||
</Route>
|
||||
<Route path="/edit/*" element={<PrivateRoute isAuthorized={currentUser.authorized} />}>
|
||||
<Route path="*" element={<DocumentEditorContainer />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
}
|
||||
>
|
||||
<Route path="*" element={<SimplifiedPartsPageContainer />} />
|
||||
</Route>
|
||||
<Route path="/edit/*" element={<PrivateRoute isAuthorized={currentUser.authorized} />}>
|
||||
<Route path="*" element={<DocumentEditorContainer />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</SoundWrapper>
|
||||
</NotificationProvider>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,225 @@
|
||||
//Global Styles.
|
||||
@import "react-big-calendar/lib/sass/styles";
|
||||
@use "react-big-calendar/lib/sass/styles" as rbc;
|
||||
|
||||
.ant-menu-item-divider {
|
||||
border-bottom: 1px solid #74695c !important;
|
||||
:root {
|
||||
--table-stripe-bg: #f4f4f4; /* Light mode table stripe */
|
||||
--menu-divider-color: #74695c; /* Light mode menu divider */
|
||||
--menu-submenu-text: rgba(255, 255, 255, 0.65); /* Light mode submenu text */
|
||||
--kanban-column-bg: #ddd; /* Light mode kanban column */
|
||||
--alert-color: blue; /* Light mode alert */
|
||||
--completion-soon-color: rgba(255, 140, 0, 0.8); /* Light mode completion soon */
|
||||
--completion-past-color: rgba(255, 0, 0, 0.8); /* Light mode completion past */
|
||||
--job-line-manual-color: tomato; /* Light mode job line manual */
|
||||
--muted-button-color: lightgray; /* Light mode muted button */
|
||||
--muted-button-hover-color: darkgrey; /* Light mode muted button hover */
|
||||
--table-border-color: #ddd; /* Light mode table border */
|
||||
--table-hover-bg: #f5f5f5; /* Light mode table hover */
|
||||
--popover-bg: #fff; /* Light mode popover background */
|
||||
--error-text: red; /* Light mode error message */
|
||||
--no-jobs-text: #888; /* Light mode no jobs message */
|
||||
--message-yours-bg: #eee; /* Light mode yours message background */
|
||||
--message-mine-bg-start: #00d0ea; /* Light mode mine message gradient start */
|
||||
--message-mine-bg-end: #0085d1; /* Light mode mine message gradient end */
|
||||
--message-mine-text: white; /* Light mode mine message text */
|
||||
--message-mine-tail-bg: white; /* Light mode mine/yours message tail */
|
||||
--system-message-bg: #f5f5f5; /* Light mode system message background */
|
||||
--system-message-text: #555; /* Light mode system message text */
|
||||
--system-label-text: #888; /* Light mode system label/date text */
|
||||
--message-icon-color: whitesmoke; /* Light mode message icon */
|
||||
--eula-card-bg: lightgray; /* Light mode eula card background */
|
||||
--notification-bg: #fff; /* Light mode notification background */
|
||||
--notification-text: rgba(0, 0, 0, 0.85); /* Light mode notification text */
|
||||
--notification-border: #d9d9d9; /* Light mode notification border */
|
||||
--notification-header-bg: #fafafa; /* Light mode notification header background */
|
||||
--notification-header-border: #f0f0f0; /* Light mode notification header border */
|
||||
--notification-header-text: rgba(0, 0, 0, 0.85); /* Light mode notification header text */
|
||||
--notification-toggle-icon: #1677ff; /* Light mode notification toggle icon */
|
||||
--notification-switch-bg: #1677ff; /* Light mode notification switch background */
|
||||
--notification-btn-link: #1677ff; /* Light mode notification link button */
|
||||
--notification-btn-link-hover: #69b1ff; /* Light mode notification link button hover */
|
||||
--notification-btn-link-disabled: rgba(0, 0, 0, 0.25); /* Light mode notification link button disabled */
|
||||
--notification-btn-link-active: #0958d9; /* Light mode notification link button active */
|
||||
--notification-read-bg: #fff; /* Light mode notification read background */
|
||||
--notification-read-text: rgba(0, 0, 0, 0.65); /* Light mode notification read text */
|
||||
--notification-unread-bg: #f5f5f5; /* Light mode notification unread background */
|
||||
--notification-unread-text: rgba(0, 0, 0, 0.85); /* Light mode notification unread text */
|
||||
--notification-item-hover-bg: #fafafa; /* Light mode notification item hover background */
|
||||
--notification-ro-number: #1677ff; /* Light mode notification RO number */
|
||||
--notification-relative-time: rgba(0, 0, 0, 0.45); /* Light mode notification relative time */
|
||||
--alert-bg: #fff1f0; /* Light mode alert background */
|
||||
--alert-text: rgba(0, 0, 0, 0.85); /* Light mode alert text */
|
||||
--alert-border: #ffa39e; /* Light mode alert border */
|
||||
--alert-message: #ff4d4f; /* Light mode alert message */
|
||||
--share-badge-bg: #cccccc; /* Light mode share badge background */
|
||||
--column-header-bg: #d0d0d0; /* Light mode column header background */
|
||||
--footer-bg: #d0d0d0; /* Light mode footer background */
|
||||
--tech-icon-color: orangered; /* Light mode tech icon color */
|
||||
--clone-border-color: #1890ff; /* Light mode clone border color */
|
||||
--event-arrived-bg: rgba(4, 141, 4, 0.4); /* Light mode arrived event background */
|
||||
--event-block-bg: tomato; /* Light mode block event background */
|
||||
--event-selected-bg: slategrey; /* Light mode selected event background */
|
||||
--task-bg: #fff; /* Light mode task center background */
|
||||
--task-text: rgba(0, 0, 0, 0.85); /* Light mode task text */
|
||||
--task-border: #d9d9d9; /* Light mode task border */
|
||||
--task-header-bg: #fafafa; /* Light mode task header background */
|
||||
--task-header-border: #f0f0f0; /* Light mode task header border */
|
||||
--task-section-bg: #f5f5f5; /* Light mode task section background */
|
||||
--task-section-border: #e8e8e8; /* Light mode task section border */
|
||||
--task-row-hover-bg: #f5f5f5; /* Light mode task row hover background */
|
||||
--task-row-border: #f0f0f0; /* Light mode task row border */
|
||||
--task-ro-number: #1677ff; /* Light mode task RO number */
|
||||
--task-due-text: rgba(0, 0, 0, 0.45); /* Light mode task due text */
|
||||
--task-button-bg: #1677ff; /* Light mode task button background */
|
||||
--task-button-hover-bg: #4096ff; /* Light mode task button hover background */
|
||||
--task-button-disabled-bg: #d9d9d9; /* Light mode task button disabled background */
|
||||
--task-button-text: white; /* Light mode task button text */
|
||||
--task-message-text: rgba(0, 0, 0, 0.45); /* Light mode task message text */
|
||||
--mask-bg: rgba(0, 0, 0, 0.05); /* Light mode mask background */
|
||||
--board-text-color: #393939; /* Light mode board text color */
|
||||
--section-bg: #e3e3e3; /* Light mode section background */
|
||||
--detail-text-color: #4d4d4d; /* Light mode detail text color */
|
||||
--card-selected-bg: rgba(128, 128, 128, 0.2); /* Light mode selected card background */
|
||||
--card-stripe-even-bg: #f0f2f5; /* Light mode even card background */
|
||||
--card-stripe-odd-bg: #ffffff; /* Light mode odd card background */
|
||||
--bar-border-color: #f0f2f5; /* Light mode bar border and background */
|
||||
--tag-wrapper-bg: #f0f2f5; /* Light mode tag wrapper background */
|
||||
--tag-wrapper-text: #000; /* Light mode tag wrapper text */
|
||||
--preview-bg: lightgray; /* Light mode preview background */
|
||||
--preview-border-color: #2196F3; /* Light mode preview border color */
|
||||
--event-bg-fallback: #c4c4c4; /* Light mode event background fallback */
|
||||
--card-bg-fallback: #ffffff; /* Light mode card background fallback */
|
||||
--card-text-fallback: black; /* Light mode card text fallback */
|
||||
--table-row-even-bg: rgb(236, 236, 236); /* Light mode table row even background */
|
||||
--status-row-bg-fallback: #ffffff; /* Light mode status row fallback background */
|
||||
--reset-link-color: #0000ff; /* Light mode reset link color */
|
||||
--error-header-text: tomato; /* Light mode error header text */
|
||||
--tooltip-bg: white; /* Light mode tooltip background */
|
||||
--tooltip-border: gray; /* Light mode tooltip border */
|
||||
--tooltip-text-fallback: black; /* Light mode tooltip text fallback */
|
||||
--teams-button-bg: #6264A7; /* Light mode Teams button background */
|
||||
--teams-button-border: #6264A7; /* Light mode Teams button border */
|
||||
--teams-button-text: #FFFFFF; /* Light mode Teams button text and icon */
|
||||
--content-bg: #fff; /* Light mode content background */
|
||||
--legend-bg-fallback: #ffffff; /* Light mode legend background fallback */
|
||||
--tech-content-bg: #fff; /* Light mode tech content background */
|
||||
--today-bg: #ffffff; /* Light mode today background */
|
||||
--today-text: #000000; /* Light mode today text */
|
||||
--off-range-bg: #f8f8f8; /* Light mode off-range background */
|
||||
}
|
||||
|
||||
// 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
|
||||
[data-theme="dark"] {
|
||||
--table-stripe-bg: #2a2a2a; /* Dark mode table stripe */
|
||||
--menu-divider-color: #5c5c5c; /* Dark mode menu divider */
|
||||
--menu-submenu-text: rgba(255, 255, 255, 0.85); /* Dark mode submenu text */
|
||||
--kanban-column-bg: #333333; /* Dark mode kanban column */
|
||||
--alert-color: #4da8ff; /* Dark mode alert */
|
||||
--completion-soon-color: #ff8c1a; /* Dark mode completion soon */
|
||||
--completion-past-color: #ff4d4f; /* Dark mode completion past */
|
||||
--job-line-manual-color: #ff6347; /* Dark mode job line manual */
|
||||
--muted-button-color: #666666; /* Dark mode muted button */
|
||||
--muted-button-hover-color: #999999; /* Dark mode muted button hover */
|
||||
--table-border-color: #5c5c5c; /* Dark mode table border */
|
||||
--table-hover-bg: #2a2a2a; /* Dark mode table hover */
|
||||
--popover-bg: #2a2a2a; /* Dark mode popover background */
|
||||
--error-text: #ff4d4f; /* Dark mode error message */
|
||||
--no-jobs-text: #999999; /* Dark mode no jobs message */
|
||||
--message-yours-bg: #2a2a2a; /* Dark mode yours message background */
|
||||
--message-mine-bg-start: #4da8ff; /* Dark mode mine message gradient start */
|
||||
--message-mine-bg-end: #326ade; /* Dark mode mine message gradient end */
|
||||
--message-mine-text: #ffffff; /* Dark mode mine message text */
|
||||
--message-mine-tail-bg: #1f1f1f; /* Dark mode mine/yours message tail */
|
||||
--system-message-bg: #333333; /* Dark mode system message background */
|
||||
--system-message-text: #cccccc; /* Dark mode system message text */
|
||||
--system-label-text: #999999; /* Dark mode system label/date text */
|
||||
--message-icon-color: #cccccc; /* Dark mode message icon */
|
||||
--eula-card-bg: #2a2a2a; /* Dark mode eula card background */
|
||||
--notification-bg: #2a2a2a; /* Dark mode notification background */
|
||||
--notification-text: rgba(255, 255, 255, 0.85); /* Dark mode notification text */
|
||||
--notification-border: #5c5c5c; /* Dark mode notification border */
|
||||
--notification-header-bg: #333333; /* Dark mode notification header background */
|
||||
--notification-header-border: #444444; /* Dark mode notification header border */
|
||||
--notification-header-text: rgba(255, 255, 255, 0.85); /* Dark mode notification header text */
|
||||
--notification-toggle-icon: #4da8ff; /* Dark mode notification toggle icon */
|
||||
--notification-switch-bg: #4da8ff; /* Dark mode notification switch background */
|
||||
--notification-btn-link: #4da8ff; /* Dark mode notification link button */
|
||||
--notification-btn-link-hover: #80c1ff; /* Dark mode notification link button hover */
|
||||
--notification-btn-link-disabled: rgba(255, 255, 255, 0.25); /* Dark mode notification link button disabled */
|
||||
--notification-btn-link-active: #2681ff; /* Dark mode notification link button active */
|
||||
--notification-read-bg: #2a2a2a; /* Dark mode notification read background */
|
||||
--notification-read-text: rgba(255, 255, 255, 0.65); /* Dark mode notification read text */
|
||||
--notification-unread-bg: #333333; /* Dark mode notification unread background */
|
||||
--notification-unread-text: rgba(255, 255, 255, 0.85); /* Dark mode notification unread text */
|
||||
--notification-item-hover-bg: #3a3a3a; /* Dark mode notification item hover background */
|
||||
--notification-ro-number: #4da8ff; /* Dark mode notification RO number */
|
||||
--notification-relative-time: rgba(255, 255, 255, 0.45); /* Dark mode notification relative time */
|
||||
--alert-bg: #3a1a1a; /* Dark mode alert background */
|
||||
--alert-text: rgba(255, 255, 255, 0.85); /* Dark mode alert text */
|
||||
--alert-border: #ff6666; /* Dark mode alert border */
|
||||
--alert-message: #ff6666; /* Dark mode alert message */
|
||||
--share-badge-bg: #666666; /* Dark mode share badge background */
|
||||
--column-header-bg: #333333; /* Dark mode column header background */
|
||||
--footer-bg: #333333; /* Dark mode footer background */
|
||||
--tech-icon-color: #ff4500; /* Dark mode tech icon color */
|
||||
--clone-border-color: #4da8ff; /* Dark mode clone border color */
|
||||
--event-arrived-bg: rgba(4, 141, 4, 0.6); /* Dark mode arrived event background */
|
||||
--event-block-bg: tomato; /* Dark mode block event background */
|
||||
--event-selected-bg: #4a5e6e; /* Dark mode selected event background */
|
||||
--task-bg: #2a2a2a; /* Dark mode task center background */
|
||||
--task-text: rgba(255, 255, 255, 0.85); /* Dark mode task text */
|
||||
--task-border: #5c5c5c; /* Dark mode task border */
|
||||
--task-header-bg: #333333; /* Dark mode task header background */
|
||||
--task-header-border: #444444; /* Dark mode task header border */
|
||||
--task-section-bg: #333333; /* Dark mode task section background */
|
||||
--task-section-border: #444444; /* Dark mode task section border */
|
||||
--task-row-hover-bg: #3a3a3a; /* Dark mode task row hover background */
|
||||
--task-row-border: #444444; /* Dark mode task row border */
|
||||
--task-ro-number: #4da8ff; /* Dark mode task RO number */
|
||||
--task-due-text: rgba(255, 255, 255, 0.45); /* Dark mode task due text */
|
||||
--task-button-bg: #4da8ff; /* Dark mode task button background */
|
||||
--task-button-hover-bg: #80c1ff; /* Dark mode task button hover background */
|
||||
--task-button-disabled-bg: #666666; /* Dark mode task button disabled background */
|
||||
--task-button-text: #ffffff; /* Dark mode task button text */
|
||||
--task-message-text: rgba(255, 255, 255, 0.45); /* Dark mode task message text */
|
||||
--mask-bg: rgba(255, 255, 255, 0.05); /* Dark mode mask background */
|
||||
--board-text-color: #cccccc; /* Dark mode board text color */
|
||||
--section-bg: #333333; /* Dark mode section background */
|
||||
--detail-text-color: #bbbbbb; /* Dark mode detail text color */
|
||||
--card-selected-bg: rgba(255, 255, 255, 0.1); /* Dark mode selected card background */
|
||||
--card-stripe-even-bg: #2a2a2a; /* Dark mode even card background */
|
||||
--card-stripe-odd-bg: #1f1f1f; /* Dark mode odd card background */
|
||||
--bar-border-color: #2a2a2a; /* Dark mode bar border and background */
|
||||
--tag-wrapper-bg: #2a2a2a; /* Dark mode tag wrapper background */
|
||||
--tag-wrapper-text: #cccccc; /* Dark mode tag wrapper text */
|
||||
--preview-bg: #2a2a2a; /* Dark mode preview background */
|
||||
--preview-border-color: #4da8ff; /* Dark mode preview border color */
|
||||
--event-bg-fallback: #262626; /* Dark mode event background fallback */
|
||||
--card-bg-fallback: #2a2a2a; /* Dark mode card background fallback */
|
||||
--card-text-fallback: #cccccc; /* Dark mode card text fallback */
|
||||
--table-row-even-bg: #2a2a2a; /* Dark mode table row even background */
|
||||
--status-row-bg-fallback: #1f1f1f; /* Dark mode status row fallback background */
|
||||
--reset-link-color: #4da8ff; /* Dark mode reset link color */
|
||||
--error-header-text: #ff6347; /* Dark mode error header text */
|
||||
--tooltip-bg: #2a2a2a; /* Dark mode tooltip background */
|
||||
--tooltip-border: #5c5c5c; /* Dark mode tooltip border */
|
||||
--tooltip-text-fallback: #cccccc; /* Dark mode tooltip text fallback */
|
||||
--teams-button-bg: #7b7dc4; /* Dark mode Teams button background */
|
||||
--teams-button-border: #7b7dc4; /* Dark mode Teams button border */
|
||||
--teams-button-text: #ffffff; /* Dark mode Teams button text and icon */
|
||||
--content-bg: #2a2a2a; /* Dark mode content background */
|
||||
--legend-bg-fallback: #2a2a2a; /* Dark mode legend background fallback */
|
||||
--tech-content-bg: #2a2a2a; /* Dark mode tech content background */
|
||||
--today-bg: #4a5e6e; /* Dark mode today background */
|
||||
--today-text: #ffffff; /* Dark mode today text */
|
||||
--off-range-bg: #333333; /* Dark mode off-range background */
|
||||
--svg-background: #FFF; /* Dark mode SVG background */
|
||||
}
|
||||
|
||||
.ant-menu-item-divider {
|
||||
border-bottom: 1px solid var(--menu-divider-color) !important;
|
||||
}
|
||||
|
||||
// Note: Monitor this in dark mode to ensure text visibility
|
||||
.ant-menu-submenu-title {
|
||||
color: rgba(255, 255, 255, 0.65) !important;
|
||||
color: var(--menu-submenu-text) !important;
|
||||
}
|
||||
|
||||
.imex-table-header {
|
||||
@@ -46,7 +256,7 @@
|
||||
}
|
||||
|
||||
.ellipses {
|
||||
display: inline-block; /* for em, a, span, etc (inline by default) */
|
||||
display: inline-block;
|
||||
text-overflow: ellipsis;
|
||||
width: calc(95%);
|
||||
overflow: hidden;
|
||||
@@ -60,22 +270,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Scrollbar styles (uncomment if needed, updated for dark mode)
|
||||
// ::-webkit-scrollbar-track {
|
||||
// -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
|
||||
// border-radius: 0.2rem;
|
||||
// background-color: #f5f5f5;
|
||||
// background-color: var(--table-stripe-bg);
|
||||
// }
|
||||
|
||||
// ::-webkit-scrollbar {
|
||||
// width: 0.25rem;
|
||||
// max-height: 0.25rem;
|
||||
// background-color: #f5f5f5;
|
||||
// background-color: var(--table-stripe-bg);
|
||||
// }
|
||||
|
||||
// ::-webkit-scrollbar-thumb {
|
||||
// border-radius: 0.2rem;
|
||||
// -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
|
||||
// background-color: #188fff;
|
||||
// background-color: var(--alert-color);
|
||||
// }
|
||||
|
||||
.ant-input-number-input,
|
||||
@@ -88,28 +299,27 @@
|
||||
|
||||
.production-alert {
|
||||
animation: alertBlinker 1s linear infinite;
|
||||
color: blue;
|
||||
color: var(--alert-color);
|
||||
}
|
||||
|
||||
@keyframes alertBlinker {
|
||||
50% {
|
||||
color: red;
|
||||
color: var(--completion-past-color);
|
||||
opacity: 100;
|
||||
//opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.blue {
|
||||
color: blue;
|
||||
color: var(--alert-color);
|
||||
}
|
||||
|
||||
.production-completion-soon {
|
||||
color: rgba(255, 140, 0, 0.8);
|
||||
color: var(--completion-soon-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.production-completion-past {
|
||||
color: rgba(255, 0, 0, 0.8);
|
||||
color: var(--completion-past-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -139,7 +349,7 @@
|
||||
}
|
||||
|
||||
.react-kanban-column {
|
||||
background-color: #ddd !important;
|
||||
background-color: var(--kanban-column-bg) !important;
|
||||
}
|
||||
|
||||
.production-list-table {
|
||||
@@ -151,18 +361,18 @@
|
||||
.ReactGridGallery_tile-icon-bar {
|
||||
div {
|
||||
svg {
|
||||
fill: #1890ff;
|
||||
fill: var(--alert-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.job-line-manual {
|
||||
color: tomato;
|
||||
color: var(--job-line-manual-color);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr.ant-table-row:nth-child(2n) > td {
|
||||
background-color: #f4f4f4;
|
||||
background-color: var(--table-stripe-bg);
|
||||
}
|
||||
|
||||
.rowWithColor > td {
|
||||
@@ -170,15 +380,15 @@
|
||||
}
|
||||
|
||||
.muted-button {
|
||||
color: lightgray;
|
||||
color: var(--muted-button-color);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px; /* Adjust as needed */
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.muted-button:hover {
|
||||
color: darkgrey;
|
||||
color: var(--muted-button-hover-color);
|
||||
}
|
||||
|
||||
.notification-alert-unordered-list {
|
||||
@@ -190,3 +400,49 @@
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
// Override react-big-calendar styles for dark mode only
|
||||
[data-theme="dark"] {
|
||||
.car-svg {
|
||||
background-color: var(--svg-background);
|
||||
}
|
||||
|
||||
.rbc-today {
|
||||
background-color: var(--today-bg);
|
||||
color: var(--today-text);
|
||||
}
|
||||
|
||||
.rbc-off-range {
|
||||
background-color: var(--off-range-bg);
|
||||
}
|
||||
|
||||
.rbc-day-bg.rbc-today {
|
||||
background-color: var(--today-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.dms-equal-height-col {
|
||||
display: flex; // make the Col a flex container
|
||||
}
|
||||
|
||||
/* If the direct child is an AntD Card, make it fill the column */
|
||||
.dms-equal-height-col > .ant-card {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Optional: if you want the card body to fill vertically too */
|
||||
.dms-equal-height-col > .ant-card .ant-card-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
//.rbc-time-header-gutter {
|
||||
// padding: 0;
|
||||
//}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import React from "react";
|
||||
import { ProductFruits } from "react-product-fruits";
|
||||
import { memo } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { ProductFruits } from "react-product-fruits";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const ProductFruitsWrapper = memo(({ currentUser, bodyshop, workspaceCode, isPartsEntry }) => {
|
||||
const featureProps = bodyshop?.features
|
||||
? Object.entries(bodyshop.features).reduce((acc, [key, value]) => {
|
||||
acc[key] = value === true || (typeof value === "string" && dayjs(value).isAfter(dayjs()));
|
||||
return acc;
|
||||
}, {})
|
||||
: {};
|
||||
|
||||
const ProductFruitsWrapper = React.memo(({ currentUser, workspaceCode }) => {
|
||||
return (
|
||||
!isPartsEntry &&
|
||||
workspaceCode &&
|
||||
currentUser?.authorized === true &&
|
||||
currentUser?.email && (
|
||||
@@ -14,13 +23,16 @@ const ProductFruitsWrapper = React.memo(({ currentUser, workspaceCode }) => {
|
||||
language="en"
|
||||
user={{
|
||||
email: currentUser.email,
|
||||
username: currentUser.email
|
||||
username: currentUser.email,
|
||||
props: featureProps
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
ProductFruitsWrapper.displayName = "ProductFruitsWrapper";
|
||||
|
||||
export default ProductFruitsWrapper;
|
||||
|
||||
ProductFruitsWrapper.propTypes = {
|
||||
@@ -28,5 +40,6 @@ ProductFruitsWrapper.propTypes = {
|
||||
authorized: PropTypes.bool,
|
||||
email: PropTypes.string
|
||||
}),
|
||||
workspaceCode: PropTypes.string
|
||||
workspaceCode: PropTypes.string,
|
||||
bodyshop: PropTypes.object
|
||||
};
|
||||
|
||||
43
client/src/App/SoundWrapper.jsx
Normal file
43
client/src/App/SoundWrapper.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNotification } from "../contexts/Notifications/notificationContext.jsx";
|
||||
import { initNewMessageSound, unlockAudio } from "./../utils/soundManager";
|
||||
import { initSingleTabAudioLeader } from "../utils/singleTabAudioLeader";
|
||||
|
||||
export default function SoundWrapper({ children, bodyshop }) {
|
||||
const { t } = useTranslation();
|
||||
const notification = useNotification();
|
||||
|
||||
useEffect(() => {
|
||||
if (!bodyshop?.id) return;
|
||||
|
||||
// 1) Init single-tab leader election (only one tab should play sounds), scoped by bodyshopId
|
||||
const cleanupLeader = initSingleTabAudioLeader(bodyshop.id);
|
||||
|
||||
// 2) Initialize base audio
|
||||
initNewMessageSound("https://images.imex.online/app/messageTone.wav", 0.7);
|
||||
|
||||
// 3) Show a one-time prompt when autoplay blocks first play
|
||||
const onNeedsUnlock = () => {
|
||||
notification.info({
|
||||
description: t("audio.manager.description"),
|
||||
duration: 3
|
||||
});
|
||||
};
|
||||
window.addEventListener("sound-needs-unlock", onNeedsUnlock);
|
||||
|
||||
// 4) Proactively unlock on first gesture (once per session)
|
||||
const gesture = () => unlockAudio(bodyshop.id);
|
||||
window.addEventListener("click", gesture, { once: true, passive: true });
|
||||
window.addEventListener("touchstart", gesture, { once: true, passive: true });
|
||||
window.addEventListener("keydown", gesture, { once: true });
|
||||
|
||||
return () => {
|
||||
cleanupLeader();
|
||||
window.removeEventListener("sound-needs-unlock", onNeedsUnlock);
|
||||
// gesture listeners were added with {once:true}
|
||||
};
|
||||
}, [notification, t, bodyshop?.id]); // include bodyshop.id so this runs when org changes
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -4,36 +4,42 @@ import InstanceRenderMgr from "../utils/instanceRenderMgr";
|
||||
|
||||
const { defaultAlgorithm, darkAlgorithm } = theme;
|
||||
|
||||
let isDarkMode = false;
|
||||
|
||||
/**
|
||||
* Default theme
|
||||
* @type {{components: {Menu: {itemDividerBorderColor: string}}}}
|
||||
*/
|
||||
const defaultTheme = {
|
||||
const defaultTheme = (isDarkMode) => ({
|
||||
components: {
|
||||
Table: {
|
||||
rowHoverBg: "#e7f3ff",
|
||||
rowSelectedBg: "#e6f7ff",
|
||||
rowHoverBg: isDarkMode ? "#2a2a2a" : "#e7f3ff",
|
||||
rowSelectedBg: isDarkMode ? "#333333" : "#e6f7ff",
|
||||
headerSortHoverBg: "transparent"
|
||||
},
|
||||
Menu: {
|
||||
darkItemHoverBg: "#1890ff",
|
||||
itemHoverBg: "#1890ff",
|
||||
horizontalItemHoverBg: "#1890ff"
|
||||
darkItemHoverBg: isDarkMode ? "#004a77" : "#1890ff",
|
||||
itemHoverBg: isDarkMode ? "#004a77" : "#1890ff",
|
||||
horizontalItemHoverBg: isDarkMode ? "#004a77" : "#1890ff"
|
||||
}
|
||||
},
|
||||
token: {
|
||||
colorPrimary: InstanceRenderMgr({
|
||||
imex: "#1890ff",
|
||||
rome: "#326ade"
|
||||
}),
|
||||
colorInfo: InstanceRenderMgr({
|
||||
imex: "#1890ff",
|
||||
rome: "#326ade"
|
||||
})
|
||||
colorPrimary: InstanceRenderMgr(
|
||||
{
|
||||
imex: isDarkMode ? "#4da8ff" : "#1890ff",
|
||||
rome: isDarkMode ? "#5b8ce6" : "#326ade"
|
||||
},
|
||||
isDarkMode
|
||||
),
|
||||
colorInfo: InstanceRenderMgr(
|
||||
{
|
||||
imex: isDarkMode ? "#4da8ff" : "#1890ff",
|
||||
rome: isDarkMode ? "#5b8ce6" : "#326ade"
|
||||
},
|
||||
isDarkMode
|
||||
),
|
||||
colorError: isDarkMode ? "#ff4d4f" : "#f5222d",
|
||||
colorBgBase: isDarkMode ? "#1f1f1f" : "#ffffff" // Align with Ant Design dark mode
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Development theme
|
||||
@@ -60,8 +66,9 @@ const prodTheme = {};
|
||||
|
||||
const currentTheme = import.meta.env.DEV ? devTheme : prodTheme;
|
||||
|
||||
const finaltheme = {
|
||||
const getTheme = (isDarkMode) => ({
|
||||
algorithm: isDarkMode ? darkAlgorithm : defaultAlgorithm,
|
||||
...defaultsDeep(currentTheme, defaultTheme)
|
||||
};
|
||||
export default finaltheme;
|
||||
});
|
||||
|
||||
export default getTheme;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
function PrivateRoute({ component: Component, isAuthorized, ...rest }) {
|
||||
function PrivateRoute({ isAuthorized }) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Button } from "antd";
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { setModalContext } from "../../redux/modals/modals.actions";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Card, Checkbox, Input, Space, Table } from "antd";
|
||||
import queryString from "query-string";
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -16,12 +16,13 @@ import PayableExportAll from "../payable-export-all-button/payable-export-all-bu
|
||||
import PayableExportButton from "../payable-export-button/payable-export-button.component";
|
||||
import BillMarkSelectedExported from "../payable-mark-selected-exported/payable-mark-selected-exported.component";
|
||||
import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.component";
|
||||
import useLocalStorage from "./../../utils/useLocalStorage";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
const mapDispatchToProps = () => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
|
||||
@@ -31,7 +32,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, bills, ref
|
||||
const { t } = useTranslation();
|
||||
const [selectedBills, setSelectedBills] = useState([]);
|
||||
const [transInProgress, setTransInProgress] = useState(false);
|
||||
const [state, setState] = useState({
|
||||
const [state, setState] = useLocalStorage("accounting-payables-table-state", {
|
||||
sortedInfo: {},
|
||||
search: ""
|
||||
});
|
||||
@@ -168,20 +169,26 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, bills, ref
|
||||
refetch={refetch}
|
||||
/>
|
||||
{bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && <QboAuthorizeComponent />}
|
||||
<Input value={state.search} onChange={handleSearch} placeholder={t("general.labels.search")} allowClear />
|
||||
<Input
|
||||
value={state.search}
|
||||
onChange={handleSearch}
|
||||
placeholder={t("general.labels.search")}
|
||||
allowClear
|
||||
enterButton
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
loading={loading}
|
||||
dataSource={dataSource}
|
||||
pagination={{ position: "top", pageSize: exportPageLimit }}
|
||||
pagination={{ placement: "top", pageSize: exportPageLimit }}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
onChange={handleTableChange}
|
||||
rowSelection={{
|
||||
onSelectAll: (selected, selectedRows) => setSelectedBills(selectedRows.map((i) => i.id)),
|
||||
onSelect: (record, selected, selectedRows, nativeEvent) => {
|
||||
onSelect: (record, selected, selectedRows) => {
|
||||
setSelectedBills(selectedRows.map((i) => i.id));
|
||||
},
|
||||
getCheckboxProps: (record) => ({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Card, Input, Space, Table } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -10,6 +10,7 @@ import CurrencyFormatter from "../../utils/CurrencyFormatter";
|
||||
import { DateFormatter, DateTimeFormatter } from "../../utils/DateFormatter";
|
||||
import { exportPageLimit } from "../../utils/config";
|
||||
import { alphaSort, dateSort } from "../../utils/sorters";
|
||||
import useLocalStorage from "../../utils/useLocalStorage";
|
||||
import ExportLogsCountDisplay from "../export-logs-count-display/export-logs-count-display.component";
|
||||
import OwnerNameDisplay, { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
|
||||
import PaymentExportButton from "../payment-export-button/payment-export-button.component";
|
||||
@@ -21,7 +22,7 @@ const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
const mapDispatchToProps = () => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
|
||||
@@ -31,7 +32,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, payments,
|
||||
const { t } = useTranslation();
|
||||
const [selectedPayments, setSelectedPayments] = useState([]);
|
||||
const [transInProgress, setTransInProgress] = useState(false);
|
||||
const [state, setState] = useState({
|
||||
const [state, setState] = useLocalStorage("accounting-payments-table-state", {
|
||||
sortedInfo: {},
|
||||
search: ""
|
||||
});
|
||||
@@ -181,20 +182,26 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, payments,
|
||||
refetch={refetch}
|
||||
/>
|
||||
{bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && <QboAuthorizeComponent />}
|
||||
<Input value={state.search} onChange={handleSearch} placeholder={t("general.labels.search")} allowClear />
|
||||
<Input
|
||||
value={state.search}
|
||||
onChange={handleSearch}
|
||||
placeholder={t("general.labels.search")}
|
||||
allowClear
|
||||
enterButton
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
loading={loading}
|
||||
dataSource={dataSource}
|
||||
pagination={{ position: "top", pageSize: exportPageLimit }}
|
||||
pagination={{ placement: "top", pageSize: exportPageLimit }}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
onChange={handleTableChange}
|
||||
rowSelection={{
|
||||
onSelectAll: (selected, selectedRows) => setSelectedPayments(selectedRows.map((i) => i.id)),
|
||||
onSelect: (record, selected, selectedRows, nativeEvent) => {
|
||||
onSelect: (record, selected, selectedRows) => {
|
||||
setSelectedPayments(selectedRows.map((i) => i.id));
|
||||
},
|
||||
getCheckboxProps: (record) => ({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, Card, Input, Space, Table } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -10,6 +10,7 @@ import { exportPageLimit } from "../../utils/config";
|
||||
import CurrencyFormatter from "../../utils/CurrencyFormatter";
|
||||
import { DateFormatter } from "../../utils/DateFormatter";
|
||||
import { alphaSort, dateSort, statusSort } from "../../utils/sorters";
|
||||
import useLocalStorage from "../../utils/useLocalStorage";
|
||||
import ExportLogsCountDisplay from "../export-logs-count-display/export-logs-count-display.component";
|
||||
import JobExportButton from "../jobs-close-export-button/jobs-close-export-button.component";
|
||||
import JobsExportAllButton from "../jobs-export-all-button/jobs-export-all-button.component";
|
||||
@@ -20,7 +21,7 @@ import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.component";
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
const mapDispatchToProps = () => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AccountingReceivablesTableComponent);
|
||||
@@ -30,7 +31,7 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r
|
||||
const [selectedJobs, setSelectedJobs] = useState([]);
|
||||
const [transInProgress, setTransInProgress] = useState(false);
|
||||
|
||||
const [state, setState] = useState({
|
||||
const [state, setState] = useLocalStorage("accounting-receivables-table-state", {
|
||||
sortedInfo: {},
|
||||
search: ""
|
||||
});
|
||||
@@ -141,7 +142,16 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r
|
||||
refetch={refetch}
|
||||
/>
|
||||
<Link to={`/manage/jobs/${record.id}/close`}>
|
||||
<Button>{t("jobs.labels.viewallocations")}</Button>
|
||||
<Button
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
verticalAlign: "middle"
|
||||
}}
|
||||
>
|
||||
{t("jobs.labels.viewallocations")}
|
||||
</Button>
|
||||
</Link>
|
||||
</Space>
|
||||
)
|
||||
@@ -170,7 +180,7 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r
|
||||
<Card
|
||||
extra={
|
||||
<Space wrap>
|
||||
{!bodyshop.cdk_dealerid && !bodyshop.pbs_serialnumber && (
|
||||
{!bodyshop.cdk_dealerid && !bodyshop.pbs_serialnumber && !bodyshop.rr_dealerid && (
|
||||
<>
|
||||
<JobMarkSelectedExported
|
||||
jobIds={selectedJobs}
|
||||
@@ -188,12 +198,13 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && <QboAuthorizeComponent />}
|
||||
{bodyshop.accountingconfig?.qbo && <QboAuthorizeComponent />}
|
||||
<Input.Search
|
||||
value={state.search}
|
||||
onChange={handleSearch}
|
||||
placeholder={t("general.labels.search")}
|
||||
allowClear
|
||||
enterButton
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
@@ -201,13 +212,13 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r
|
||||
<Table
|
||||
loading={loading}
|
||||
dataSource={dataSource}
|
||||
pagination={{ position: "top", pageSize: exportPageLimit }}
|
||||
pagination={{ placement: "top", pageSize: exportPageLimit }}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
onChange={handleTableChange}
|
||||
rowSelection={{
|
||||
onSelectAll: (selected, selectedRows) => setSelectedJobs(selectedRows.map((i) => i.id)),
|
||||
onSelect: (record, selected, selectedRows, nativeEvent) => {
|
||||
onSelect: (record, selected, selectedRows) => {
|
||||
setSelectedJobs(selectedRows.map((i) => i.id));
|
||||
},
|
||||
getCheckboxProps: (record) => ({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Alert } from "antd";
|
||||
import React from "react";
|
||||
|
||||
export default function AlertComponent(props) {
|
||||
return <Alert {...props} />;
|
||||
|
||||
@@ -4,27 +4,27 @@ import AlertComponent from "./alert.component";
|
||||
|
||||
describe("AlertComponent", () => {
|
||||
it("renders with default props", () => {
|
||||
render(<AlertComponent message="Default Alert" />);
|
||||
render(<AlertComponent title="Default Alert" />);
|
||||
expect(screen.getByText("Default Alert")).toBeInTheDocument();
|
||||
expect(screen.getByRole("alert")).toHaveClass("ant-alert");
|
||||
});
|
||||
|
||||
it("applies type prop correctly", () => {
|
||||
render(<AlertComponent message="Success Alert" type="success" />);
|
||||
render(<AlertComponent title="Success Alert" type="success" />);
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(screen.getByText("Success Alert")).toBeInTheDocument();
|
||||
expect(alert).toHaveClass("ant-alert-success");
|
||||
});
|
||||
|
||||
it("displays description when provided", () => {
|
||||
render(<AlertComponent message="Error Alert" description="Something went wrong" type="error" />);
|
||||
render(<AlertComponent title="Error Alert" description="Something went wrong" type="error" />);
|
||||
expect(screen.getByText("Error Alert")).toBeInTheDocument();
|
||||
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
|
||||
expect(screen.getByRole("alert")).toHaveClass("ant-alert-error");
|
||||
});
|
||||
|
||||
it("is closable and shows icon when props are set", () => {
|
||||
render(<AlertComponent message="Warning Alert" type="warning" showIcon closable />);
|
||||
render(<AlertComponent title="Warning Alert" type="warning" showIcon closable />);
|
||||
expect(screen.getByText("Warning Alert")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /close/i })).toBeInTheDocument(); // Close button
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Button, InputNumber, Popover, Select } from "antd";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -29,12 +28,13 @@ export function AllocationsAssignmentComponent({
|
||||
<div>
|
||||
<Select
|
||||
id="employeeSelector"
|
||||
showSearch
|
||||
showSearch={{
|
||||
optionFilterProp: "children",
|
||||
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
}}
|
||||
style={{ width: 200 }}
|
||||
placeholder="Select a person"
|
||||
optionFilterProp="children"
|
||||
onChange={onChange}
|
||||
filterOption={(input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0}
|
||||
>
|
||||
{bodyshop.employees.map((emp) => (
|
||||
<Select.Option value={emp.id} key={emp.id}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import AllocationsAssignmentComponent from "./allocations-assignment.component";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { useMutation } from "@apollo/client/react";
|
||||
import { INSERT_ALLOCATION } from "../../graphql/allocations.queries";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
@@ -18,16 +18,16 @@ export default function AllocationsAssignmentContainer({ jobLineId, hours, refet
|
||||
|
||||
const handleAssignment = () => {
|
||||
insertAllocation({ variables: { alloc: { ...assignment } } })
|
||||
.then((r) => {
|
||||
notification["success"]({
|
||||
message: t("allocations.successes.save")
|
||||
.then(() => {
|
||||
notification.success({
|
||||
title: t("allocations.successes.save")
|
||||
});
|
||||
visibilityState[1](false);
|
||||
if (refetch) refetch();
|
||||
})
|
||||
.catch((error) => {
|
||||
notification["error"]({
|
||||
message: t("employees.errors.saving", { message: error.message })
|
||||
notification.error({
|
||||
title: t("employees.errors.saving", { message: error.message })
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Button, Popover, Select } from "antd";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -31,12 +30,13 @@ export default connect(
|
||||
const popContent = (
|
||||
<div>
|
||||
<Select
|
||||
showSearch
|
||||
showSearch={{
|
||||
optionFilterProp: "children",
|
||||
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
}}
|
||||
style={{ width: 200 }}
|
||||
placeholder="Select a person"
|
||||
optionFilterProp="children"
|
||||
onChange={onChange}
|
||||
filterOption={(input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0}
|
||||
>
|
||||
{bodyshop.employees.map((emp) => (
|
||||
<Select.Option value={emp.id} key={emp.id}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import AllocationsBulkAssignment from "./allocations-bulk-assignment.component";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { useMutation } from "@apollo/client/react";
|
||||
import { INSERT_ALLOCATION } from "../../graphql/allocations.queries";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
@@ -24,9 +24,9 @@ export default function AllocationsBulkAssignmentContainer({ jobLines, refetch }
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
insertAllocation({ variables: { alloc: allocs } }).then((r) => {
|
||||
notification["success"]({
|
||||
message: t("employees.successes.save")
|
||||
insertAllocation({ variables: { alloc: allocs } }).then(() => {
|
||||
notification.success({
|
||||
title: t("employees.successes.save")
|
||||
});
|
||||
visibilityState[1](false);
|
||||
if (refetch) refetch();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Icon from "@ant-design/icons";
|
||||
import React from "react";
|
||||
import { MdRemoveCircleOutline } from "react-icons/md";
|
||||
|
||||
export default function AllocationsLabelComponent({ allocation, handleClick }) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from "react";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { useMutation } from "@apollo/client/react";
|
||||
import { DELETE_ALLOCATION } from "../../graphql/allocations.queries";
|
||||
import AllocationsLabelComponent from "./allocations-employee-label.component";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -13,14 +12,14 @@ export default function AllocationsLabelContainer({ allocation, refetch }) {
|
||||
const handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
deleteAllocation({ variables: { id: allocation.id } })
|
||||
.then((r) => {
|
||||
notification["success"]({
|
||||
message: t("allocations.successes.deleted")
|
||||
.then(() => {
|
||||
notification.success({
|
||||
title: t("allocations.successes.deleted")
|
||||
});
|
||||
if (refetch) refetch();
|
||||
})
|
||||
.catch((error) => {
|
||||
notification["error"]({ message: t("allocations.errors.deleting") });
|
||||
.catch(() => {
|
||||
notification.error({ title: t("allocations.errors.deleting") });
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Table } from "antd";
|
||||
import { alphaSort } from "../../utils/sorters";
|
||||
import { DateTimeFormatter } from "../../utils/DateFormatter";
|
||||
@@ -65,7 +65,7 @@ export default function AuditTrailListComponent({ loading, data }) {
|
||||
<Table
|
||||
{...formItemLayout}
|
||||
loading={loading}
|
||||
pagination={{ position: "top", defaultPageSize: pageLimit }}
|
||||
pagination={{ placement: "top", defaultPageSize: pageLimit }}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
dataSource={data}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import AuditTrailListComponent from "./audit-trail-list.component";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { QUERY_AUDIT_TRAIL } from "../../graphql/audit_trail.queries";
|
||||
import AlertComponent from "../alert/alert.component";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
@@ -18,7 +17,7 @@ export default function AuditTrailListContainer({ recordId }) {
|
||||
return (
|
||||
<div>
|
||||
{error ? (
|
||||
<AlertComponent type="error" message={error.message} />
|
||||
<AlertComponent type="error" title={error.message} />
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
<Card>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Table } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DateTimeFormatter } from "../../utils/DateFormatter";
|
||||
import { alphaSort } from "../../utils/sorters";
|
||||
@@ -50,7 +50,7 @@ export default function EmailAuditTrailListComponent({ loading, data }) {
|
||||
<Table
|
||||
{...formItemLayout}
|
||||
loading={loading}
|
||||
pagination={{ position: "top", defaultPageSize: pageLimit }}
|
||||
pagination={{ placement: "top", defaultPageSize: pageLimit }}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
dataSource={data}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { List } from "antd";
|
||||
import Icon from "@ant-design/icons";
|
||||
import { FaArrowRight } from "react-icons/fa";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Popover, Tag } from "antd";
|
||||
import React from "react";
|
||||
import Barcode from "react-barcode";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Checkbox, Form, Skeleton, Typography } from "antd";
|
||||
import React, { useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ReadOnlyFormItemComponent from "../form-items-formatted/read-only-form-item.component";
|
||||
import "./bill-cm-returns-table.styles.scss";
|
||||
@@ -33,7 +33,7 @@ export default function BillCmdReturnsTableComponent({ form, returnLoading, retu
|
||||
|
||||
return (
|
||||
<Form.List name="outstanding_returns">
|
||||
{(fields, { add, remove, move }) => {
|
||||
{(fields) => {
|
||||
return (
|
||||
<>
|
||||
<Typography.Title level={4}>{t("bills.labels.creditsnotreceived")}</Typography.Title>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
td {
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
border-bottom: 1px solid var(--table-border-color);
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 0px !important;
|
||||
@@ -14,6 +14,6 @@
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
background-color: var(--table-hover-bg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DeleteFilled } from "@ant-design/icons";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { useMutation } from "@apollo/client/react";
|
||||
import { Button, Popconfirm } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DELETE_BILL } from "../../graphql/bills.queries";
|
||||
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
|
||||
@@ -43,8 +43,8 @@ export function BillDeleteButton({ bill, jobid, callback, insertAuditTrail }) {
|
||||
}
|
||||
});
|
||||
|
||||
if (!!!result.errors) {
|
||||
notification["success"]({ message: t("bills.successes.deleted") });
|
||||
if (!result.errors) {
|
||||
notification.success({ title: t("bills.successes.deleted") });
|
||||
insertAuditTrail({
|
||||
jobid: jobid,
|
||||
operation: AuditTrailMapping.billdeleted(bill.invoice_number),
|
||||
@@ -57,14 +57,14 @@ export function BillDeleteButton({ bill, jobid, callback, insertAuditTrail }) {
|
||||
const error = JSON.stringify(result.errors);
|
||||
|
||||
if (error.toLowerCase().includes("inventory_billid_fkey")) {
|
||||
notification["error"]({
|
||||
message: t("bills.errors.deleting", {
|
||||
notification.error({
|
||||
title: t("bills.errors.deleting", {
|
||||
error: t("bills.errors.existinginventoryline")
|
||||
})
|
||||
});
|
||||
} else {
|
||||
notification["error"]({
|
||||
message: t("bills.errors.deleting", {
|
||||
notification.error({
|
||||
title: t("bills.errors.deleting", {
|
||||
error: JSON.stringify(result.errors)
|
||||
})
|
||||
});
|
||||
@@ -77,13 +77,7 @@ export function BillDeleteButton({ bill, jobid, callback, insertAuditTrail }) {
|
||||
return (
|
||||
<RbacWrapper action="bills:delete" noauth={<></>}>
|
||||
<Popconfirm disabled={bill.exported} onConfirm={handleDelete} title={t("bills.labels.deleteconfirm")}>
|
||||
<Button
|
||||
disabled={bill.exported}
|
||||
// onClick={handleDelete}
|
||||
loading={loading}
|
||||
>
|
||||
<DeleteFilled />
|
||||
</Button>
|
||||
<Button icon={<DeleteFilled />} disabled={bill.exported} loading={loading} />
|
||||
</Popconfirm>
|
||||
</RbacWrapper>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { PageHeader } from "@ant-design/pro-layout";
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import { useMutation, useQuery } from "@apollo/client/react";
|
||||
import { Button, Divider, Form, Popconfirm, Space } from "antd";
|
||||
import queryString from "query-string";
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { useLocation } from "react-router-dom";
|
||||
@@ -10,7 +10,6 @@ import { createStructuredSelector } from "reselect";
|
||||
import { DELETE_BILL_LINE, INSERT_NEW_BILL_LINES, UPDATE_BILL_LINE } from "../../graphql/bill-lines.queries";
|
||||
import { QUERY_BILL_BY_PK, UPDATE_BILL } from "../../graphql/bills.queries";
|
||||
import { insertAuditTrail } from "../../redux/application/application.actions";
|
||||
import { setModalContext } from "../../redux/modals/modals.actions";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import AuditTrailMapping from "../../utils/AuditTrailMappings";
|
||||
import dayjs from "../../utils/day";
|
||||
@@ -28,13 +27,12 @@ const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
setPartsOrderContext: (context) => dispatch(setModalContext({ context: context, modal: "partsOrder" })),
|
||||
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(BillDetailEditcontainer);
|
||||
|
||||
export function BillDetailEditcontainer({ setPartsOrderContext, insertAuditTrail, bodyshop }) {
|
||||
export function BillDetailEditcontainer({ insertAuditTrail, bodyshop }) {
|
||||
const search = queryString.parse(useLocation().search);
|
||||
|
||||
const { t } = useTranslation();
|
||||
@@ -48,7 +46,7 @@ export function BillDetailEditcontainer({ setPartsOrderContext, insertAuditTrail
|
||||
|
||||
const { loading, error, data, refetch } = useQuery(QUERY_BILL_BY_PK, {
|
||||
variables: { billid: search.billid },
|
||||
skip: !!!search.billid,
|
||||
skip: !search.billid,
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only"
|
||||
});
|
||||
@@ -58,7 +56,7 @@ export function BillDetailEditcontainer({ setPartsOrderContext, insertAuditTrail
|
||||
const handleSave = () => {
|
||||
//It's got a previously deducted bill line!
|
||||
if (
|
||||
data.bills_by_pk.billlines.filter((b) => b.deductedfromlbr).length > 0 ||
|
||||
data?.bills_by_pk?.billlines.filter((b) => b.deductedfromlbr).length > 0 ||
|
||||
form.getFieldValue("billlines").filter((b) => b.deductedfromlbr).length > 0
|
||||
)
|
||||
setOpen(true);
|
||||
@@ -71,7 +69,7 @@ export function BillDetailEditcontainer({ setPartsOrderContext, insertAuditTrail
|
||||
setUpdateLoading(true);
|
||||
//let adjustmentsToInsert = {};
|
||||
|
||||
const { billlines, upload, ...bill } = values;
|
||||
const { billlines, ...bill } = values;
|
||||
const updates = [];
|
||||
updates.push(
|
||||
update_bill({
|
||||
@@ -86,7 +84,7 @@ export function BillDetailEditcontainer({ setPartsOrderContext, insertAuditTrail
|
||||
//Find bill lines that were deleted.
|
||||
const deletedJobLines = [];
|
||||
|
||||
data.bills_by_pk.billlines.forEach((a) => {
|
||||
data?.bills_by_pk?.billlines.forEach((a) => {
|
||||
const matchingRecord = billlines.find((b) => b.id === a.id);
|
||||
if (!matchingRecord) {
|
||||
deletedJobLines.push(a);
|
||||
@@ -98,6 +96,7 @@ export function BillDetailEditcontainer({ setPartsOrderContext, insertAuditTrail
|
||||
});
|
||||
|
||||
billlines.forEach((billline) => {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { deductedfromlbr, inventories, jobline, original_actual_price, create_ppc, ...il } = billline;
|
||||
delete il.__typename;
|
||||
|
||||
@@ -149,11 +148,11 @@ export function BillDetailEditcontainer({ setPartsOrderContext, insertAuditTrail
|
||||
setUpdateLoading(false);
|
||||
};
|
||||
|
||||
if (error) return <AlertComponent message={error.message} type="error" />;
|
||||
if (error) return <AlertComponent title={error.message} type="error" />;
|
||||
if (!search.billid) return <></>; //<div>{t("bills.labels.noneselected")}</div>;
|
||||
|
||||
const exported = data && data.bills_by_pk && data.bills_by_pk.exported;
|
||||
const isinhouse = data && data.bills_by_pk && data.bills_by_pk.isinhouse;
|
||||
const exported = data?.bills_by_pk && data?.bills_by_pk?.exported;
|
||||
const isinhouse = data?.bills_by_pk && data?.bills_by_pk?.isinhouse;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -161,7 +160,7 @@ export function BillDetailEditcontainer({ setPartsOrderContext, insertAuditTrail
|
||||
{data && (
|
||||
<>
|
||||
<PageHeader
|
||||
title={data && `${data.bills_by_pk.invoice_number} - ${data.bills_by_pk.vendor.name}`}
|
||||
title={data && `${data?.bills_by_pk?.invoice_number} - ${data?.bills_by_pk?.vendor?.name}`}
|
||||
extra={
|
||||
<Space>
|
||||
<BillDetailEditReturn data={data} />
|
||||
@@ -183,25 +182,25 @@ export function BillDetailEditcontainer({ setPartsOrderContext, insertAuditTrail
|
||||
{t("general.actions.save")}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<BillReeportButtonComponent bill={data && data.bills_by_pk} />
|
||||
<BillMarkExportedButton bill={data && data.bills_by_pk} />
|
||||
<BillReeportButtonComponent bill={data?.bills_by_pk} />
|
||||
<BillMarkExportedButton bill={data?.bills_by_pk} />
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
<Form form={form} onFinish={handleFinish} initialValues={transformData(data)} layout="vertical">
|
||||
<BillFormContainer form={form} billEdit disabled={exported} disableInHouse={isinhouse} />
|
||||
<Divider orientation="left">{t("general.labels.media")}</Divider>
|
||||
<Divider titlePlacement="left">{t("general.labels.media")}</Divider>
|
||||
{bodyshop.uselocalmediaserver ? (
|
||||
<JobsDocumentsLocalGallery
|
||||
job={{ id: data ? data.bills_by_pk.jobid : null }}
|
||||
invoice_number={data ? data.bills_by_pk.invoice_number : null}
|
||||
vendorid={data ? data.bills_by_pk.vendorid : null}
|
||||
job={{ id: data ? data?.bills_by_pk?.jobid : null }}
|
||||
invoice_number={data ? data?.bills_by_pk?.invoice_number : null}
|
||||
vendorid={data ? data?.bills_by_pk?.vendorid : null}
|
||||
/>
|
||||
) : (
|
||||
<JobDocumentsGallery
|
||||
jobId={data ? data.bills_by_pk.jobid : null}
|
||||
jobId={data ? data?.bills_by_pk?.jobid : null}
|
||||
billId={search.billid}
|
||||
documentsList={data ? data.bills_by_pk.documents : []}
|
||||
documentsList={data ? data?.bills_by_pk?.documents : []}
|
||||
billsCallback={refetch}
|
||||
/>
|
||||
)}
|
||||
@@ -213,18 +212,18 @@ export function BillDetailEditcontainer({ setPartsOrderContext, insertAuditTrail
|
||||
}
|
||||
|
||||
const transformData = (data) => {
|
||||
return data
|
||||
return data?.bills_by_pk
|
||||
? {
|
||||
...data.bills_by_pk,
|
||||
|
||||
billlines: data.bills_by_pk.billlines.map((i) => {
|
||||
return {
|
||||
...i,
|
||||
joblineid: !!i.joblineid ? i.joblineid : "noline",
|
||||
joblineid: i.joblineid ? i.joblineid : "noline",
|
||||
applicable_taxes: {
|
||||
federal: (i.applicable_taxes && i.applicable_taxes.federal) || false,
|
||||
state: (i.applicable_taxes && i.applicable_taxes.state) || false,
|
||||
local: (i.applicable_taxes && i.applicable_taxes.local) || false
|
||||
federal: i.applicable_taxes?.federal || false,
|
||||
state: i.applicable_taxes?.state || false,
|
||||
local: i.applicable_taxes?.local || false
|
||||
}
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
import { Button, Checkbox, Form, Modal } from "antd";
|
||||
import queryString from "query-string";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { insertAuditTrail } from "../../redux/application/application.actions";
|
||||
import { setModalContext } from "../../redux/modals/modals.actions";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import ReadOnlyFormItemComponent from "../form-items-formatted/read-only-form-item.component";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapStateToProps = createStructuredSelector({});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
setPartsOrderContext: (context) => dispatch(setModalContext({ context: context, modal: "partsOrder" })),
|
||||
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
|
||||
setPartsOrderContext: (context) =>
|
||||
dispatch(
|
||||
setModalContext({
|
||||
context: context,
|
||||
modal: "partsOrder"
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(BillDetailEditReturn);
|
||||
|
||||
export function BillDetailEditReturn({ setPartsOrderContext, insertAuditTrail, bodyshop, data, disabled }) {
|
||||
export function BillDetailEditReturn({ setPartsOrderContext, data, disabled }) {
|
||||
const search = queryString.parse(useLocation().search);
|
||||
const history = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
@@ -46,7 +48,7 @@ export function BillDetailEditReturn({ setPartsOrderContext, insertAuditTrail, b
|
||||
// db_price: i.actual_price,
|
||||
act_price: i.actual_price,
|
||||
cost: i.actual_cost,
|
||||
quantity: i.quantity,
|
||||
part_qty: i.quantity,
|
||||
joblineid: i.joblineid,
|
||||
oem_partno: i.jobline && i.jobline.oem_partno,
|
||||
part_type: i.jobline && i.jobline.part_type
|
||||
@@ -69,13 +71,13 @@ export function BillDetailEditReturn({ setPartsOrderContext, insertAuditTrail, b
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
title={t("bills.actions.return")}
|
||||
onOk={() => form.submit()}
|
||||
>
|
||||
<Form initialValues={data && data.bills_by_pk} onFinish={handleFinish} form={form}>
|
||||
<Form initialValues={data?.bills_by_pk} onFinish={handleFinish} form={form}>
|
||||
<Form.List name={["billlines"]}>
|
||||
{(fields, { add, remove, move }) => {
|
||||
{(fields) => {
|
||||
return (
|
||||
<table style={{ tableLayout: "auto", width: "100%" }}>
|
||||
<thead>
|
||||
@@ -102,6 +104,10 @@ export function BillDetailEditReturn({ setPartsOrderContext, insertAuditTrail, b
|
||||
{fields.map((field, index) => (
|
||||
<tr key={field.key}>
|
||||
<td>
|
||||
{/* Hidden field to preserve the id */}
|
||||
<Form.Item name={[field.name, "id"]} hidden>
|
||||
<input type="hidden" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
// label={t("joblines.fields.selected")}
|
||||
key={`${index}selected`}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Drawer, Grid } from "antd";
|
||||
import queryString from "query-string";
|
||||
import React from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import BillDetailEditComponent from "./bill-detail-edit-component";
|
||||
|
||||
@@ -24,12 +23,12 @@ export default function BillDetailEditcontainer() {
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width={drawerPercentage}
|
||||
size={drawerPercentage}
|
||||
onClose={() => {
|
||||
delete search.billid;
|
||||
history({ search: queryString.stringify(search) });
|
||||
}}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
open={search.billid}
|
||||
>
|
||||
<BillDetailEditComponent />
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useApolloClient, useMutation } from "@apollo/client";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import { useApolloClient, useMutation } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { Button, Checkbox, Form, Modal, Space } from "antd";
|
||||
import _ from "lodash";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { INSERT_NEW_BILL } from "../../graphql/bills.queries";
|
||||
import { UPDATE_INVENTORY_LINES } from "../../graphql/inventory.queries";
|
||||
import { UPDATE_JOB_LINE } from "../../graphql/jobs-lines.queries";
|
||||
@@ -24,7 +25,8 @@ import BillFormContainer from "../bill-form/bill-form.container";
|
||||
import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility";
|
||||
import { handleUpload as handleLocalUpload } from "../documents-local-upload/documents-local-upload.utility";
|
||||
import { handleUpload } from "../documents-upload/documents-upload.utility";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { handleUpload as handleUploadToImageProxy } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility";
|
||||
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
billEnterModal: selectBillEnterModal,
|
||||
@@ -53,10 +55,10 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
const notification = useNotification();
|
||||
|
||||
const {
|
||||
treatments: { Enhanced_Payroll }
|
||||
} = useSplitTreatments({
|
||||
treatments: { Enhanced_Payroll, Imgproxy }
|
||||
} = useTreatmentsWithConfig({
|
||||
attributes: {},
|
||||
names: ["Enhanced_Payroll"],
|
||||
names: ["Enhanced_Payroll", "Imgproxy"],
|
||||
splitKey: bodyshop.imexshopid
|
||||
});
|
||||
|
||||
@@ -84,6 +86,8 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { upload, location, outstanding_returns, inventory, federal_tax_exempt, ...remainingValues } = values;
|
||||
|
||||
let adjustmentsToInsert = {};
|
||||
@@ -101,9 +105,13 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
const {
|
||||
deductedfromlbr,
|
||||
lbr_adjustment,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
location: lineLocation,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
part_type,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
create_ppc,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
original_actual_price,
|
||||
...restI
|
||||
} = i;
|
||||
@@ -196,9 +204,9 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
job: { lbr_adjustments: newAdjustments }
|
||||
}
|
||||
});
|
||||
if (!!jobUpdate.errors) {
|
||||
notification["error"]({
|
||||
message: t("jobs.errors.saving", {
|
||||
if (jobUpdate.errors) {
|
||||
notification.error({
|
||||
title: t("jobs.errors.saving", {
|
||||
message: JSON.stringify(jobUpdate.errors)
|
||||
})
|
||||
});
|
||||
@@ -213,22 +221,22 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
variables: { partsLineIds: markPolReceived.map((p) => p.id) },
|
||||
refetchQueries: ["QUERY_PARTS_BILLS_BY_JOBID"]
|
||||
});
|
||||
if (!!r2.errors) {
|
||||
if (r2.errors) {
|
||||
setLoading(false);
|
||||
setEnterAgain(false);
|
||||
notification["error"]({
|
||||
message: t("parts_orders.errors.updating", {
|
||||
notification.error({
|
||||
title: t("parts_orders.errors.updating", {
|
||||
message: JSON.stringify(r2.errors)
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!!r1.errors) {
|
||||
if (r1.errors) {
|
||||
setLoading(false);
|
||||
setEnterAgain(false);
|
||||
notification["error"]({
|
||||
message: t("bills.errors.creating", {
|
||||
notification.error({
|
||||
title: t("bills.errors.creating", {
|
||||
message: JSON.stringify(r1.errors)
|
||||
})
|
||||
});
|
||||
@@ -244,11 +252,11 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
consumedbybillid: billId
|
||||
}
|
||||
});
|
||||
if (!!r2.errors) {
|
||||
if (r2.errors) {
|
||||
setLoading(false);
|
||||
setEnterAgain(false);
|
||||
notification["error"]({
|
||||
message: t("inventory.errors.updating", {
|
||||
notification.error({
|
||||
title: t("inventory.errors.updating", {
|
||||
message: JSON.stringify(r2.errors)
|
||||
})
|
||||
});
|
||||
@@ -298,26 +306,45 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
upload.forEach((u) => {
|
||||
handleUpload(
|
||||
{ file: u.originFileObj },
|
||||
{
|
||||
bodyshop: bodyshop,
|
||||
uploaded_by: currentUser.email,
|
||||
jobId: values.jobid,
|
||||
billId: billId,
|
||||
tagsArray: null,
|
||||
callback: null
|
||||
},
|
||||
notification
|
||||
);
|
||||
});
|
||||
//Check if using Imgproxy or cloudinary
|
||||
|
||||
if (Imgproxy.treatment === "on") {
|
||||
upload.forEach((u) => {
|
||||
handleUploadToImageProxy(
|
||||
{ file: u.originFileObj },
|
||||
{
|
||||
bodyshop: bodyshop,
|
||||
uploaded_by: currentUser.email,
|
||||
jobId: values.jobid,
|
||||
billId: billId,
|
||||
tagsArray: null,
|
||||
callback: null
|
||||
},
|
||||
notification
|
||||
);
|
||||
});
|
||||
} else {
|
||||
upload.forEach((u) => {
|
||||
handleUpload(
|
||||
{ file: u.originFileObj },
|
||||
{
|
||||
bodyshop: bodyshop,
|
||||
uploaded_by: currentUser.email,
|
||||
jobId: values.jobid,
|
||||
billId: billId,
|
||||
tagsArray: null,
|
||||
callback: null
|
||||
},
|
||||
notification
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
///////////////////////////
|
||||
setLoading(false);
|
||||
notification["success"]({
|
||||
message: t("bills.successes.created")
|
||||
notification.success({
|
||||
title: t("bills.successes.created")
|
||||
});
|
||||
|
||||
if (generateLabel) {
|
||||
@@ -396,7 +423,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
{t("bills.labels.generatepartslabel")}
|
||||
</Checkbox>
|
||||
<Button onClick={handleCancel}>{t("general.actions.cancel")}</Button>
|
||||
<Button loading={loading} onClick={() => form.submit()}>
|
||||
<Button loading={loading} onClick={() => form.submit()} id="save-bill-enter-modal">
|
||||
{t("general.actions.save")}
|
||||
</Button>
|
||||
{billEnterModal.context && billEnterModal.context.id ? null : (
|
||||
@@ -406,13 +433,14 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
onClick={() => {
|
||||
setEnterAgain(true);
|
||||
}}
|
||||
id="save-and-new-bill-enter-modal"
|
||||
>
|
||||
{t("general.actions.saveandnew")}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
onFinish={handleFinish}
|
||||
@@ -423,7 +451,9 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
setEnterAgain(false);
|
||||
}}
|
||||
>
|
||||
<BillFormContainer form={form} disableInvNumber={billEnterModal.context.disableInvNumber} />
|
||||
<RbacWrapper action="bills:enter">
|
||||
<BillFormContainer form={form} disableInvNumber={billEnterModal.context.disableInvNumber} />
|
||||
</RbacWrapper>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Form, Input, Table } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CurrencyFormatter from "../../utils/CurrencyFormatter";
|
||||
import { alphaSort } from "../../utils/sorters";
|
||||
import BillFormItemsExtendedFormItem from "./bill-form-lines.extended.formitem.component";
|
||||
|
||||
export default function BillFormLinesExtended({ lineData, discount, form, responsibilityCenters, disabled }) {
|
||||
export default function BillFormLinesExtended({ lineData, discount, form, responsibilityCenters }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const { t } = useTranslation();
|
||||
const columns = [
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { MinusCircleFilled, PlusCircleFilled, WarningOutlined } from "@ant-design/icons";
|
||||
import { Button, Form, Input, InputNumber, Select, Space, Switch } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -8,11 +7,12 @@ import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
||||
import CiecaSelect from "../../utils/Ciecaselect";
|
||||
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
const mapDispatchToProps = () => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(BillFormItemsExtendedFormItem);
|
||||
@@ -22,7 +22,6 @@ export function BillFormItemsExtendedFormItem({
|
||||
bodyshop,
|
||||
form,
|
||||
record,
|
||||
index,
|
||||
disabled,
|
||||
responsibilityCenters,
|
||||
discount
|
||||
@@ -33,6 +32,7 @@ export function BillFormItemsExtendedFormItem({
|
||||
if (!value)
|
||||
return (
|
||||
<Button
|
||||
icon={<PlusCircleFilled />}
|
||||
onClick={() => {
|
||||
const values = form.getFieldsValue("billlineskeys");
|
||||
|
||||
@@ -46,7 +46,7 @@ export function BillFormItemsExtendedFormItem({
|
||||
quantity: record.part_qty || 1,
|
||||
actual_price: record.act_price,
|
||||
cost_center: record.part_type
|
||||
? bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid
|
||||
? bodyshopHasDmsKey(bodyshop)
|
||||
? record.part_type
|
||||
: responsibilityCenters.defaults && (responsibilityCenters.defaults.costs[record.part_type] || null)
|
||||
: null
|
||||
@@ -54,9 +54,7 @@ export function BillFormItemsExtendedFormItem({
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<PlusCircleFilled />
|
||||
</Button>
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -78,7 +76,7 @@ export function BillFormItemsExtendedFormItem({
|
||||
...billlineskeys,
|
||||
[record.id]: {
|
||||
...billlineskeys[billlineskeys],
|
||||
actual_cost: !!billlineskeys[billlineskeys].actual_cost
|
||||
actual_cost: billlineskeys[billlineskeys].actual_cost
|
||||
? billlineskeys[billlineskeys].actual_cost
|
||||
: Math.round((parseFloat(e.target.value) * (1 - discount) + Number.EPSILON) * 100) / 100
|
||||
}
|
||||
@@ -93,7 +91,7 @@ export function BillFormItemsExtendedFormItem({
|
||||
<Form.Item shouldUpdate>
|
||||
{() => {
|
||||
const line = value;
|
||||
if (!!!line) return null;
|
||||
if (!line) return null;
|
||||
const lineDiscount = (1 - Math.round((line.actual_cost / line.actual_price) * 100) / 100).toPrecision(2);
|
||||
|
||||
if (lineDiscount - discount === 0) return <div />;
|
||||
@@ -102,7 +100,7 @@ export function BillFormItemsExtendedFormItem({
|
||||
</Form.Item>
|
||||
<Form.Item label={t("billlines.fields.cost_center")} name={["billlineskeys", record.id, "cost_center"]}>
|
||||
<Select showSearch style={{ minWidth: "3rem" }} disabled={disabled}>
|
||||
{bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber
|
||||
{bodyshopHasDmsKey(bodyshop)
|
||||
? CiecaSelect(true, false)
|
||||
: responsibilityCenters.costs.map((item) => <Select.Option key={item.name}>{item.name}</Select.Option>)}
|
||||
</Select>
|
||||
@@ -197,6 +195,7 @@ export function BillFormItemsExtendedFormItem({
|
||||
</Form.Item>
|
||||
|
||||
<Button
|
||||
icon={<MinusCircleFilled />}
|
||||
onClick={() => {
|
||||
const values = form.getFieldsValue("billlineskeys");
|
||||
|
||||
@@ -208,9 +207,7 @@ export function BillFormItemsExtendedFormItem({
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MinusCircleFilled />
|
||||
</Button>
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Icon, { UploadOutlined } from "@ant-design/icons";
|
||||
import { useApolloClient } from "@apollo/client";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import { useApolloClient } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { Alert, Divider, Form, Input, Select, Space, Statistic, Switch, Upload } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MdOpenInNew } from "react-icons/md";
|
||||
import { connect } from "react-redux";
|
||||
@@ -22,11 +22,12 @@ import VendorSearchSelect from "../vendor-search-select/vendor-search-select.com
|
||||
import BillFormLines from "./bill-form.lines.component";
|
||||
import { CalculateBillTotal } from "./bill-form.totals.utility";
|
||||
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
|
||||
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({});
|
||||
const mapDispatchToProps = () => ({});
|
||||
|
||||
export function BillFormComponent({
|
||||
bodyshop,
|
||||
@@ -50,7 +51,7 @@ export function BillFormComponent({
|
||||
|
||||
const {
|
||||
treatments: { Extended_Bill_Posting, ClosingPeriod }
|
||||
} = useSplitTreatments({
|
||||
} = useTreatmentsWithConfig({
|
||||
attributes: {},
|
||||
names: ["Extended_Bill_Posting", "ClosingPeriod"],
|
||||
splitKey: bodyshop.imexshopid
|
||||
@@ -109,7 +110,7 @@ export function BillFormComponent({
|
||||
}
|
||||
|
||||
if (vendorId === bodyshop.inhousevendorid && !billEdit) {
|
||||
loadInventory();
|
||||
loadInventory({ variables: {} });
|
||||
}
|
||||
}, [
|
||||
form,
|
||||
@@ -125,7 +126,7 @@ export function BillFormComponent({
|
||||
return (
|
||||
<div>
|
||||
<FormFieldsChanged form={form} />
|
||||
<Form.Item style={{ display: "none" }} name="isinhouse" valuePropName="checked">
|
||||
<Form.Item hidden name="isinhouse" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<LayoutFormRow grow>
|
||||
@@ -192,7 +193,7 @@ export function BillFormComponent({
|
||||
<Alert
|
||||
key={iou.id}
|
||||
type="warning"
|
||||
message={
|
||||
title={
|
||||
<Space>
|
||||
{t("bills.labels.iouexists")}
|
||||
<Link target="_blank" rel="noopener noreferrer" to={`/manage/jobs/${iou.id}?tab=repairdata`}>
|
||||
@@ -254,7 +255,7 @@ export function BillFormComponent({
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
},
|
||||
({ getFieldValue }) => ({
|
||||
() => ({
|
||||
validator(rule, value) {
|
||||
if (ClosingPeriod.treatment === "on" && bodyshop.accountingconfig.ClosingPeriod) {
|
||||
if (
|
||||
@@ -354,7 +355,7 @@ export function BillFormComponent({
|
||||
<Form.Item span={3} label={t("bills.fields.local_tax_rate")} name="local_tax_rate">
|
||||
<CurrencyInput min={0} />
|
||||
</Form.Item>
|
||||
{bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid ? (
|
||||
{bodyshopHasDmsKey(bodyshop) ? (
|
||||
<Form.Item span={2} label={t("bills.labels.federal_tax_exempt")} name="federal_tax_exempt">
|
||||
<Switch onChange={handleFederalTaxExemptSwitchToggle} />
|
||||
</Form.Item>
|
||||
@@ -374,8 +375,10 @@ export function BillFormComponent({
|
||||
let totals;
|
||||
if (!!values.total && !!values.billlines && values.billlines.length > 0)
|
||||
totals = CalculateBillTotal(values);
|
||||
if (!!totals)
|
||||
if (totals)
|
||||
return (
|
||||
// TODO: Align is not correct
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
<div align="right">
|
||||
<Space size="large" wrap>
|
||||
<Statistic title={t("bills.labels.subtotal")} value={totals.subtotal.toFormat()} precision={2} />
|
||||
@@ -410,15 +413,17 @@ export function BillFormComponent({
|
||||
/>
|
||||
<Statistic
|
||||
title={t("bills.labels.discrepancy")}
|
||||
valueStyle={{
|
||||
color: totals.discrepancy.getAmount() === 0 ? "green" : "red"
|
||||
styles={{
|
||||
value: {
|
||||
color: totals.discrepancy.getAmount() === 0 ? "green" : "red"
|
||||
}
|
||||
}}
|
||||
value={totals.discrepancy.toFormat()}
|
||||
precision={2}
|
||||
/>
|
||||
</Space>
|
||||
{form.getFieldValue("is_credit_memo") ? (
|
||||
<AlertComponent type="warning" message={t("bills.labels.enteringcreditmemo")} />
|
||||
<AlertComponent type="warning" title={t("bills.labels.enteringcreditmemo")} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -426,7 +431,7 @@ export function BillFormComponent({
|
||||
}}
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
<Divider orientation="left">{t("bills.labels.bill_lines")}</Divider>
|
||||
<Divider titlePlacement="left">{t("bills.labels.bill_lines")}</Divider>
|
||||
|
||||
{Extended_Bill_Posting.treatment === "on" ? (
|
||||
<BillFormLinesExtended
|
||||
@@ -446,7 +451,7 @@ export function BillFormComponent({
|
||||
billEdit={billEdit}
|
||||
/>
|
||||
)}
|
||||
<Divider orientation="left" style={{ display: billEdit ? "none" : null }}>
|
||||
<Divider titlePlacement="left" style={{ display: billEdit ? "none" : null }}>
|
||||
{t("documents.labels.upload")}
|
||||
</Divider>
|
||||
<Form.Item
|
||||
@@ -458,7 +463,7 @@ export function BillFormComponent({
|
||||
if (Array.isArray(e)) {
|
||||
return e;
|
||||
}
|
||||
return e && e.fileList;
|
||||
return e?.fileList;
|
||||
}}
|
||||
>
|
||||
<Upload.Dragger multiple={true} name="logo" beforeUpload={() => false} listType="picture">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useLazyQuery, useQuery } from "@apollo/client";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import React from "react";
|
||||
import { useLazyQuery, useQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { QUERY_OUTSTANDING_INVENTORY } from "../../graphql/inventory.queries";
|
||||
@@ -19,7 +18,7 @@ const mapStateToProps = createStructuredSelector({
|
||||
export function BillFormContainer({ bodyshop, form, billEdit, disabled, disableInvNumber, disableInHouse }) {
|
||||
const {
|
||||
treatments: { Simple_Inventory }
|
||||
} = useSplitTreatments({
|
||||
} = useTreatmentsWithConfig({
|
||||
attributes: {},
|
||||
names: ["Simple_Inventory"],
|
||||
splitKey: bodyshop && bodyshop.imexshopid
|
||||
|
||||
@@ -1,41 +1,134 @@
|
||||
import { DeleteFilled, DollarCircleFilled } from "@ant-design/icons";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { Button, Checkbox, Form, Input, InputNumber, Select, Space, Switch, Table, Tooltip } 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 { selectDarkMode } from "../../redux/application/application.selectors";
|
||||
import CiecaSelect from "../../utils/Ciecaselect";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
import BillLineSearchSelect from "../bill-line-search-select/bill-line-search-select.component";
|
||||
import BilllineAddInventory from "../billline-add-inventory/billline-add-inventory.component";
|
||||
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
||||
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
//currentUser: selectCurrentUser
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
bodyshop: selectBodyshop,
|
||||
isDarkMode: selectDarkMode
|
||||
});
|
||||
|
||||
const mapDispatchToProps = () => ({});
|
||||
|
||||
export function BillEnterModalLinesComponent({
|
||||
bodyshop,
|
||||
isDarkMode,
|
||||
disabled,
|
||||
lineData,
|
||||
discount,
|
||||
form,
|
||||
responsibilityCenters,
|
||||
billEdit,
|
||||
billid
|
||||
billEdit
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { setFieldsValue, getFieldsValue, getFieldValue } = form;
|
||||
|
||||
const CONTROL_HEIGHT = 32;
|
||||
|
||||
const normalizeDiscount = (d) => {
|
||||
const n = Number(d);
|
||||
if (!Number.isFinite(n) || n <= 0) return 0;
|
||||
return n > 1 ? n / 100 : n;
|
||||
};
|
||||
|
||||
const round2 = (v) => Math.round((v + Number.EPSILON) * 100) / 100;
|
||||
|
||||
const isBlank = (v) => v === null || v === undefined || v === "" || Number.isNaN(v);
|
||||
|
||||
const toNumber = (raw) => {
|
||||
if (raw === null || raw === undefined) return NaN;
|
||||
if (typeof raw === "number") return raw;
|
||||
|
||||
if (typeof raw === "string") {
|
||||
const cleaned = raw
|
||||
.trim()
|
||||
.replace(/[^\d.,-]/g, "")
|
||||
.replace(/,/g, "");
|
||||
return Number.parseFloat(cleaned);
|
||||
}
|
||||
|
||||
if (typeof raw === "object") {
|
||||
try {
|
||||
if (typeof raw.toNumber === "function") return raw.toNumber();
|
||||
|
||||
const v = raw.valueOf?.();
|
||||
if (typeof v === "number") return v;
|
||||
if (typeof v === "string") {
|
||||
const cleaned = v
|
||||
.trim()
|
||||
.replace(/[^\d.,-]/g, "")
|
||||
.replace(/,/g, "");
|
||||
return Number.parseFloat(cleaned);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return NaN;
|
||||
};
|
||||
|
||||
const setLineField = (index, field, value) => {
|
||||
if (typeof form.setFieldValue === "function") {
|
||||
form.setFieldValue(["billlines", index, field], value);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = form.getFieldValue("billlines") || [];
|
||||
form.setFieldsValue({
|
||||
billlines: lines.map((l, i) => (i === index ? { ...l, [field]: value } : l))
|
||||
});
|
||||
};
|
||||
|
||||
const autofillActualCost = (index) => {
|
||||
Promise.resolve().then(() => {
|
||||
const retailRaw = form.getFieldValue(["billlines", index, "actual_price"]);
|
||||
const actualRaw = form.getFieldValue(["billlines", index, "actual_cost"]);
|
||||
const d = normalizeDiscount(discount);
|
||||
|
||||
if (!isBlank(actualRaw)) return;
|
||||
|
||||
const retail = toNumber(retailRaw);
|
||||
if (!Number.isFinite(retail)) return;
|
||||
|
||||
const next = round2(retail * (1 - d));
|
||||
setLineField(index, "actual_cost", next);
|
||||
});
|
||||
};
|
||||
|
||||
const getIndicatorColor = (lineDiscount) => {
|
||||
const d = normalizeDiscount(discount);
|
||||
if (Math.abs(lineDiscount - d) > 0.005) return lineDiscount > d ? "orange" : "red";
|
||||
return "green";
|
||||
};
|
||||
|
||||
const getIndicatorShellStyles = (statusColor) => {
|
||||
if (isDarkMode) {
|
||||
if (statusColor === "green")
|
||||
return { borderColor: "rgba(82, 196, 26, 0.75)", background: "rgba(82, 196, 26, 0.10)" };
|
||||
if (statusColor === "orange")
|
||||
return { borderColor: "rgba(250, 173, 20, 0.75)", background: "rgba(250, 173, 20, 0.10)" };
|
||||
return { borderColor: "rgba(255, 77, 79, 0.75)", background: "rgba(255, 77, 79, 0.10)" };
|
||||
}
|
||||
|
||||
if (statusColor === "green") return { borderColor: "#b7eb8f", background: "#f6ffed" };
|
||||
if (statusColor === "orange") return { borderColor: "#ffe58f", background: "#fffbe6" };
|
||||
return { borderColor: "#ffccc7", background: "#fff2f0" };
|
||||
};
|
||||
|
||||
const {
|
||||
treatments: { Simple_Inventory, Enhanced_Payroll }
|
||||
} = useSplitTreatments({
|
||||
} = useTreatmentsWithConfig({
|
||||
attributes: {},
|
||||
names: ["Simple_Inventory", "Enhanced_Payroll"],
|
||||
splitKey: bodyshop && bodyshop.imexshopid
|
||||
@@ -47,25 +140,16 @@ export function BillEnterModalLinesComponent({
|
||||
title: t("billlines.fields.jobline"),
|
||||
dataIndex: "joblineid",
|
||||
editable: true,
|
||||
width: "20rem",
|
||||
formItemProps: (field) => {
|
||||
return {
|
||||
key: `${field.index}joblinename`,
|
||||
name: [field.name, "joblineid"],
|
||||
label: t("billlines.fields.jobline"),
|
||||
rules: [
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]
|
||||
};
|
||||
},
|
||||
minWidth: "10rem",
|
||||
formItemProps: (field) => ({
|
||||
key: `${field.name}joblinename`,
|
||||
name: [field.name, "joblineid"],
|
||||
label: t("billlines.fields.jobline"),
|
||||
rules: [{ required: true }]
|
||||
}),
|
||||
wrapper: (props) => (
|
||||
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.is_credit_memo !== cur.is_credit_memo}>
|
||||
{() => {
|
||||
return props.children;
|
||||
}}
|
||||
{() => props.children}
|
||||
</Form.Item>
|
||||
),
|
||||
formInput: (record, index) => (
|
||||
@@ -73,35 +157,37 @@ export function BillEnterModalLinesComponent({
|
||||
disabled={disabled}
|
||||
options={lineData}
|
||||
style={{
|
||||
width: "20rem",
|
||||
maxWidth: "20rem",
|
||||
minWidth: "10rem",
|
||||
minWidth: "20rem",
|
||||
whiteSpace: "normal",
|
||||
height: "auto",
|
||||
minHeight: "32px" // default height of Ant Design inputs
|
||||
minHeight: `${CONTROL_HEIGHT}px`
|
||||
}}
|
||||
allowRemoved={form.getFieldValue("is_credit_memo") || false}
|
||||
onSelect={(value, opt) => {
|
||||
const d = normalizeDiscount(discount);
|
||||
const retail = Number(opt.cost);
|
||||
const computedActual = Number.isFinite(retail) ? round2(retail * (1 - d)) : null;
|
||||
|
||||
setFieldsValue({
|
||||
billlines: getFieldsValue(["billlines"]).billlines.map((item, idx) => {
|
||||
if (idx === index) {
|
||||
return {
|
||||
...item,
|
||||
line_desc: opt.line_desc,
|
||||
quantity: opt.part_qty || 1,
|
||||
actual_price: opt.cost,
|
||||
original_actual_price: opt.cost,
|
||||
cost_center: opt.part_type
|
||||
? bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid
|
||||
? opt.part_type !== "PAE"
|
||||
? opt.part_type
|
||||
: null
|
||||
: responsibilityCenters.defaults &&
|
||||
(responsibilityCenters.defaults.costs[opt.part_type] || null)
|
||||
: null
|
||||
};
|
||||
}
|
||||
return item;
|
||||
billlines: (getFieldValue("billlines") || []).map((item, idx) => {
|
||||
if (idx !== index) return item;
|
||||
|
||||
return {
|
||||
...item,
|
||||
line_desc: opt.line_desc,
|
||||
quantity: opt.part_qty || 1,
|
||||
actual_price: opt.cost,
|
||||
original_actual_price: opt.cost,
|
||||
actual_cost: isBlank(item.actual_cost) ? computedActual : item.actual_cost,
|
||||
cost_center: opt.part_type
|
||||
? bodyshopHasDmsKey(bodyshop)
|
||||
? opt.part_type !== "PAE"
|
||||
? opt.part_type
|
||||
: null
|
||||
: responsibilityCenters.defaults &&
|
||||
(responsibilityCenters.defaults.costs[opt.part_type] || null)
|
||||
: null
|
||||
};
|
||||
})
|
||||
});
|
||||
}}
|
||||
@@ -112,90 +198,62 @@ export function BillEnterModalLinesComponent({
|
||||
title: t("billlines.fields.line_desc"),
|
||||
dataIndex: "line_desc",
|
||||
editable: true,
|
||||
width: "20rem",
|
||||
formItemProps: (field) => {
|
||||
return {
|
||||
key: `${field.index}line_desc`,
|
||||
name: [field.name, "line_desc"],
|
||||
label: t("billlines.fields.line_desc"),
|
||||
rules: [
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]
|
||||
};
|
||||
},
|
||||
formInput: (record, index) => <Input.TextArea disabled={disabled} autoSize />
|
||||
minWidth: "10rem",
|
||||
formItemProps: (field) => ({
|
||||
key: `${field.name}line_desc`,
|
||||
name: [field.name, "line_desc"],
|
||||
label: t("billlines.fields.line_desc"),
|
||||
rules: [{ required: true }]
|
||||
}),
|
||||
formInput: () => <Input.TextArea disabled={disabled} autoSize />
|
||||
},
|
||||
{
|
||||
title: t("billlines.fields.quantity"),
|
||||
dataIndex: "quantity",
|
||||
editable: true,
|
||||
width: "4rem",
|
||||
formItemProps: (field) => {
|
||||
return {
|
||||
key: `${field.index}quantity`,
|
||||
name: [field.name, "quantity"],
|
||||
label: t("billlines.fields.quantity"),
|
||||
rules: [
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
},
|
||||
({ getFieldValue }) => ({
|
||||
validator(rule, value) {
|
||||
if (value && getFieldValue("billlines")[field.fieldKey]?.inventories?.length > value) {
|
||||
return Promise.reject(
|
||||
t("bills.validation.inventoryquantity", {
|
||||
number: getFieldValue("billlines")[field.fieldKey]?.inventories?.length
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
formItemProps: (field) => ({
|
||||
key: `${field.name}quantity`,
|
||||
name: [field.name, "quantity"],
|
||||
label: t("billlines.fields.quantity"),
|
||||
rules: [
|
||||
{ required: true },
|
||||
({ getFieldValue: gf }) => ({
|
||||
validator(_, value) {
|
||||
const invLen = gf(["billlines", field.name, "inventories"])?.length ?? 0;
|
||||
|
||||
if (value && invLen > value) {
|
||||
return Promise.reject(
|
||||
t("bills.validation.inventoryquantity", {
|
||||
number: invLen
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
]
|
||||
};
|
||||
},
|
||||
formInput: (record, index) => <InputNumber precision={0} min={1} disabled={disabled} />
|
||||
return Promise.resolve();
|
||||
}
|
||||
})
|
||||
]
|
||||
}),
|
||||
formInput: () => <InputNumber precision={0} min={1} disabled={disabled} />
|
||||
},
|
||||
{
|
||||
title: t("billlines.fields.actual_price"),
|
||||
dataIndex: "actual_price",
|
||||
width: "8rem",
|
||||
editable: true,
|
||||
formItemProps: (field) => {
|
||||
return {
|
||||
key: `${field.index}actual_price`,
|
||||
name: [field.name, "actual_price"],
|
||||
label: t("billlines.fields.actual_price"),
|
||||
rules: [
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]
|
||||
};
|
||||
},
|
||||
formItemProps: (field) => ({
|
||||
key: `${field.name}actual_price`,
|
||||
name: [field.name, "actual_price"],
|
||||
label: t("billlines.fields.actual_price"),
|
||||
rules: [{ required: true }]
|
||||
}),
|
||||
formInput: (record, index) => (
|
||||
<CurrencyInput
|
||||
min={0}
|
||||
disabled={disabled}
|
||||
onBlur={(e) => {
|
||||
setFieldsValue({
|
||||
billlines: getFieldsValue("billlines").billlines.map((item, idx) => {
|
||||
if (idx === index) {
|
||||
return {
|
||||
...item,
|
||||
actual_cost: !!item.actual_cost
|
||||
? item.actual_cost
|
||||
: Math.round((parseFloat(e.target.value) * (1 - discount) + Number.EPSILON) * 100) / 100
|
||||
};
|
||||
}
|
||||
return item;
|
||||
})
|
||||
});
|
||||
onBlur={() => autofillActualCost(index)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Tab") autofillActualCost(index);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
@@ -222,9 +280,8 @@ export function BillEnterModalLinesComponent({
|
||||
{t("joblines.fields.create_ppc")}
|
||||
</Space>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
</Form.Item>
|
||||
)
|
||||
@@ -234,97 +291,109 @@ export function BillEnterModalLinesComponent({
|
||||
title: t("billlines.fields.actual_cost"),
|
||||
dataIndex: "actual_cost",
|
||||
editable: true,
|
||||
width: "8rem",
|
||||
width: "10rem",
|
||||
skipFormItem: true,
|
||||
formItemProps: (field) => ({
|
||||
key: `${field.name}actual_cost`,
|
||||
name: [field.name, "actual_cost"],
|
||||
label: t("billlines.fields.actual_cost"),
|
||||
rules: [{ required: true }]
|
||||
}),
|
||||
formInput: (record, index, fieldProps) => {
|
||||
const { name, rules, valuePropName, getValueFromEvent, normalize, validateTrigger, initialValue } =
|
||||
fieldProps || {};
|
||||
|
||||
formItemProps: (field) => {
|
||||
return {
|
||||
key: `${field.index}actual_cost`,
|
||||
name: [field.name, "actual_cost"],
|
||||
label: t("billlines.fields.actual_cost"),
|
||||
rules: [
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]
|
||||
const bindProps = {
|
||||
name,
|
||||
rules,
|
||||
valuePropName,
|
||||
getValueFromEvent,
|
||||
normalize,
|
||||
validateTrigger,
|
||||
initialValue
|
||||
};
|
||||
},
|
||||
formInput: (record, index) => (
|
||||
<CurrencyInput
|
||||
min={0}
|
||||
disabled={disabled}
|
||||
controls={false}
|
||||
addonAfter={
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
height: CONTROL_HEIGHT
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: "1 1 auto", minWidth: 0 }}>
|
||||
<Form.Item noStyle {...bindProps}>
|
||||
<CurrencyInput
|
||||
min={0}
|
||||
disabled={disabled}
|
||||
controls={false}
|
||||
style={{ width: "100%", height: CONTROL_HEIGHT }}
|
||||
onFocus={() => autofillActualCost(index)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const line = getFieldsValue(["billlines"]).billlines[index];
|
||||
if (!!!line) return null;
|
||||
let lineDiscount = 1 - line.actual_cost / line.actual_price;
|
||||
if (isNaN(lineDiscount)) lineDiscount = 0;
|
||||
const all = getFieldsValue(["billlines"]);
|
||||
const line = all?.billlines?.[index];
|
||||
if (!line) return null;
|
||||
|
||||
const ap = toNumber(line.actual_price);
|
||||
const ac = toNumber(line.actual_cost);
|
||||
|
||||
let lineDiscount = 0;
|
||||
if (Number.isFinite(ap) && ap !== 0 && Number.isFinite(ac)) {
|
||||
lineDiscount = 1 - ac / ap;
|
||||
}
|
||||
|
||||
const statusColor = getIndicatorColor(lineDiscount);
|
||||
const shell = getIndicatorShellStyles(statusColor);
|
||||
|
||||
return (
|
||||
<Tooltip title={`${(lineDiscount * 100).toFixed(2) || 0}%`}>
|
||||
<DollarCircleFilled
|
||||
<div
|
||||
style={{
|
||||
color:
|
||||
Math.abs(lineDiscount - discount) > 0.005
|
||||
? lineDiscount > discount
|
||||
? "orange"
|
||||
: "red"
|
||||
: "green"
|
||||
height: CONTROL_HEIGHT,
|
||||
minWidth: CONTROL_HEIGHT,
|
||||
padding: "0 10px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxSizing: "border-box",
|
||||
borderStyle: "solid",
|
||||
borderWidth: 1,
|
||||
borderLeftWidth: 0,
|
||||
...shell,
|
||||
borderTopRightRadius: 6,
|
||||
borderBottomRightRadius: 6
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<DollarCircleFilled style={{ color: statusColor, lineHeight: 1 }} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
}
|
||||
/>
|
||||
)
|
||||
// additional: (record, index) => (
|
||||
// <Form.Item shouldUpdate>
|
||||
// {() => {
|
||||
// const line = getFieldsValue(["billlines"]).billlines[index];
|
||||
// if (!!!line) return null;
|
||||
// const lineDiscount = (
|
||||
// 1 -
|
||||
// Math.round((line.actual_cost / line.actual_price) * 100) / 100
|
||||
// ).toPrecision(2);
|
||||
|
||||
// return (
|
||||
// <Tooltip title={`${(lineDiscount * 100).toFixed(0) || 0}%`}>
|
||||
// <DollarCircleFilled
|
||||
// style={{
|
||||
// color: lineDiscount - discount !== 0 ? "red" : "green",
|
||||
// }}
|
||||
// />
|
||||
// </Tooltip>
|
||||
// );
|
||||
// }}
|
||||
// </Form.Item>
|
||||
// ),
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t("billlines.fields.cost_center"),
|
||||
dataIndex: "cost_center",
|
||||
editable: true,
|
||||
|
||||
formItemProps: (field) => {
|
||||
return {
|
||||
key: `${field.index}cost_center`,
|
||||
name: [field.name, "cost_center"],
|
||||
label: t("billlines.fields.cost_center"),
|
||||
valuePropName: "value",
|
||||
rules: [
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]
|
||||
};
|
||||
},
|
||||
formInput: (record, index) => (
|
||||
formItemProps: (field) => ({
|
||||
key: `${field.name}cost_center`,
|
||||
name: [field.name, "cost_center"],
|
||||
label: t("billlines.fields.cost_center"),
|
||||
valuePropName: "value",
|
||||
rules: [{ required: true }]
|
||||
}),
|
||||
formInput: () => (
|
||||
<Select showSearch style={{ minWidth: "3rem" }} disabled={disabled}>
|
||||
{bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber
|
||||
{bodyshopHasDmsKey(bodyshop)
|
||||
? CiecaSelect(true, false)
|
||||
: responsibilityCenters.costs.map((item) => <Select.Option key={item.name}>{item.name}</Select.Option>)}
|
||||
</Select>
|
||||
@@ -338,13 +407,11 @@ export function BillEnterModalLinesComponent({
|
||||
dataIndex: "location",
|
||||
editable: true,
|
||||
label: t("billlines.fields.location"),
|
||||
formItemProps: (field) => {
|
||||
return {
|
||||
key: `${field.index}location`,
|
||||
name: [field.name, "location"]
|
||||
};
|
||||
},
|
||||
formInput: (record, index) => (
|
||||
formItemProps: (field) => ({
|
||||
key: `${field.name}location`,
|
||||
name: [field.name, "location"]
|
||||
}),
|
||||
formInput: () => (
|
||||
<Select disabled={disabled}>
|
||||
{bodyshop.md_parts_locations.map((loc, idx) => (
|
||||
<Select.Option key={idx} value={loc}>
|
||||
@@ -359,25 +426,20 @@ export function BillEnterModalLinesComponent({
|
||||
title: t("billlines.labels.deductedfromlbr"),
|
||||
dataIndex: "deductedfromlbr",
|
||||
editable: true,
|
||||
formItemProps: (field) => {
|
||||
return {
|
||||
valuePropName: "checked",
|
||||
key: `${field.index}deductedfromlbr`,
|
||||
name: [field.name, "deductedfromlbr"]
|
||||
};
|
||||
},
|
||||
formInput: (record, index) => <Switch disabled={disabled} />,
|
||||
width: "40px",
|
||||
formItemProps: (field) => ({
|
||||
valuePropName: "checked",
|
||||
key: `${field.name}deductedfromlbr`,
|
||||
name: [field.name, "deductedfromlbr"]
|
||||
}),
|
||||
formInput: () => <Switch disabled={disabled} />,
|
||||
additional: (record, index) => (
|
||||
<Form.Item shouldUpdate noStyle style={{ display: "inline-block" }}>
|
||||
{() => {
|
||||
const price = getFieldValue(["billlines", record.name, "actual_price"]);
|
||||
|
||||
const adjustmentRate = getFieldValue(["billlines", record.name, "lbr_adjustment", "rate"]);
|
||||
|
||||
const billline = getFieldValue(["billlines", record.name]);
|
||||
|
||||
const jobline = lineData.find((line) => line.id === billline?.joblineid);
|
||||
|
||||
const employeeTeamName = bodyshop.employee_teams.find((team) => team.id === jobline?.assigned_team);
|
||||
|
||||
if (getFieldValue(["billlines", record.name, "deductedfromlbr"]))
|
||||
@@ -385,9 +447,7 @@ export function BillEnterModalLinesComponent({
|
||||
<div>
|
||||
{Enhanced_Payroll.treatment === "on" ? (
|
||||
<Space>
|
||||
{t("joblines.fields.assigned_team", {
|
||||
name: employeeTeamName?.name
|
||||
})}
|
||||
{t("joblines.fields.assigned_team", { name: employeeTeamName?.name })}
|
||||
{`${jobline.mod_lb_hrs} units/${t(`joblines.fields.lbr_types.${jobline.mod_lbr_ty}`)}`}
|
||||
</Space>
|
||||
) : null}
|
||||
@@ -396,12 +456,7 @@ export function BillEnterModalLinesComponent({
|
||||
label={t("joblines.fields.mod_lbr_ty")}
|
||||
key={`${index}modlbrty`}
|
||||
initialValue={jobline ? jobline.mod_lbr_ty : null}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
rules={[{ required: true }]}
|
||||
name={[record.name, "lbr_adjustment", "mod_lbr_ty"]}
|
||||
>
|
||||
<Select allowClear>
|
||||
@@ -421,16 +476,12 @@ export function BillEnterModalLinesComponent({
|
||||
<Select.Option value="LA4">{t("joblines.fields.lbr_types.LA4")}</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{Enhanced_Payroll.treatment === "on" ? (
|
||||
<Form.Item
|
||||
label={t("billlines.labels.mod_lbr_adjustment")}
|
||||
name={[record.name, "lbr_adjustment", "mod_lb_hrs"]}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<InputNumber precision={5} min={0.01} max={jobline ? jobline.mod_lb_hrs : 0} />
|
||||
</Form.Item>
|
||||
@@ -439,12 +490,7 @@ export function BillEnterModalLinesComponent({
|
||||
label={t("jobs.labels.adjustmentrate")}
|
||||
name={[record.name, "lbr_adjustment", "rate"]}
|
||||
initialValue={bodyshop.default_adjustment_rate}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<InputNumber precision={2} min={0.01} />
|
||||
</Form.Item>
|
||||
@@ -453,6 +499,7 @@ export function BillEnterModalLinesComponent({
|
||||
<Space>{price && adjustmentRate && `${(price / adjustmentRate).toFixed(1)} hrs`}</Space>
|
||||
</div>
|
||||
);
|
||||
|
||||
return <></>;
|
||||
}}
|
||||
</Form.Item>
|
||||
@@ -466,19 +513,13 @@ export function BillEnterModalLinesComponent({
|
||||
title: t("billlines.fields.federal_tax_applicable"),
|
||||
dataIndex: "applicable_taxes.federal",
|
||||
editable: true,
|
||||
|
||||
formItemProps: (field) => {
|
||||
return {
|
||||
key: `${field.index}fedtax`,
|
||||
valuePropName: "checked",
|
||||
initialValue: InstanceRenderManager({
|
||||
imex: true,
|
||||
rome: false
|
||||
}),
|
||||
name: [field.name, "applicable_taxes", "federal"]
|
||||
};
|
||||
},
|
||||
formInput: (record, index) => <Switch disabled={disabled} />
|
||||
width: "40px",
|
||||
formItemProps: (field) => ({
|
||||
key: `${field.name}fedtax`,
|
||||
valuePropName: "checked",
|
||||
name: [field.name, "applicable_taxes", "federal"]
|
||||
}),
|
||||
formInput: () => <Switch disabled={disabled} />
|
||||
}
|
||||
]
|
||||
}),
|
||||
@@ -487,15 +528,13 @@ export function BillEnterModalLinesComponent({
|
||||
title: t("billlines.fields.state_tax_applicable"),
|
||||
dataIndex: "applicable_taxes.state",
|
||||
editable: true,
|
||||
|
||||
formItemProps: (field) => {
|
||||
return {
|
||||
key: `${field.index}statetax`,
|
||||
valuePropName: "checked",
|
||||
name: [field.name, "applicable_taxes", "state"]
|
||||
};
|
||||
},
|
||||
formInput: (record, index) => <Switch disabled={disabled} />
|
||||
width: "40px",
|
||||
formItemProps: (field) => ({
|
||||
key: `${field.name}statetax`,
|
||||
valuePropName: "checked",
|
||||
name: [field.name, "applicable_taxes", "state"]
|
||||
}),
|
||||
formInput: () => <Switch disabled={disabled} />
|
||||
},
|
||||
|
||||
...InstanceRenderManager({
|
||||
@@ -505,41 +544,44 @@ export function BillEnterModalLinesComponent({
|
||||
title: t("billlines.fields.local_tax_applicable"),
|
||||
dataIndex: "applicable_taxes.local",
|
||||
editable: true,
|
||||
|
||||
formItemProps: (field) => {
|
||||
return {
|
||||
key: `${field.index}localtax`,
|
||||
valuePropName: "checked",
|
||||
name: [field.name, "applicable_taxes", "local"]
|
||||
};
|
||||
},
|
||||
formInput: (record, index) => <Switch disabled={disabled} />
|
||||
width: "40px",
|
||||
formItemProps: (field) => ({
|
||||
key: `${field.name}localtax`,
|
||||
valuePropName: "checked",
|
||||
name: [field.name, "applicable_taxes", "local"]
|
||||
}),
|
||||
formInput: () => <Switch disabled={disabled} />
|
||||
}
|
||||
]
|
||||
}),
|
||||
|
||||
{
|
||||
title: t("general.labels.actions"),
|
||||
|
||||
dataIndex: "actions",
|
||||
render: (text, record) => (
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => (
|
||||
<Space wrap>
|
||||
<Button
|
||||
disabled={disabled || getFieldValue("billlines")[record.fieldKey]?.inventories?.length > 0}
|
||||
onClick={() => remove(record.name)}
|
||||
>
|
||||
<DeleteFilled />
|
||||
</Button>
|
||||
{Simple_Inventory.treatment === "on" && (
|
||||
<BilllineAddInventory
|
||||
disabled={!billEdit || form.isFieldsTouched() || form.getFieldValue("is_credit_memo")}
|
||||
billline={getFieldValue("billlines")[record.fieldKey]}
|
||||
jobid={getFieldValue("jobid")}
|
||||
{() => {
|
||||
const currentLine = getFieldValue(["billlines", record.name]);
|
||||
const invLen = currentLine?.inventories?.length ?? 0;
|
||||
|
||||
return (
|
||||
<Space wrap>
|
||||
<Button
|
||||
icon={<DeleteFilled />}
|
||||
disabled={disabled || invLen > 0}
|
||||
onClick={() => remove(record.name)}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{Simple_Inventory.treatment === "on" && (
|
||||
<BilllineAddInventory
|
||||
disabled={!billEdit || form.isFieldsTouched() || form.getFieldValue("is_credit_memo")}
|
||||
billline={currentLine}
|
||||
jobid={getFieldValue("jobid")}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
@@ -549,6 +591,7 @@ export function BillEnterModalLinesComponent({
|
||||
const mergedColumns = (remove) =>
|
||||
columns(remove).map((col) => {
|
||||
if (!col.editable) return col;
|
||||
|
||||
return {
|
||||
...col,
|
||||
onCell: (record) => ({
|
||||
@@ -556,8 +599,8 @@ export function BillEnterModalLinesComponent({
|
||||
formItemProps: col.formItemProps,
|
||||
formInput: col.formInput,
|
||||
additional: col.additional,
|
||||
dataIndex: col.dataIndex,
|
||||
title: col.title
|
||||
wrapper: col.wrapper,
|
||||
skipFormItem: col.skipFormItem
|
||||
})
|
||||
};
|
||||
});
|
||||
@@ -575,34 +618,42 @@ export function BillEnterModalLinesComponent({
|
||||
}
|
||||
]}
|
||||
>
|
||||
{(fields, { add, remove, move }) => {
|
||||
{(fields, { add, remove }) => {
|
||||
const hasRows = fields.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table
|
||||
components={{
|
||||
body: {
|
||||
cell: EditableCell
|
||||
}
|
||||
}}
|
||||
className="bill-lines-table"
|
||||
components={{ body: { cell: EditableCell } }}
|
||||
size="small"
|
||||
bordered
|
||||
dataSource={fields}
|
||||
rowKey="key"
|
||||
columns={mergedColumns(remove)}
|
||||
scroll={{ x: true }}
|
||||
scroll={hasRows ? { x: "max-content" } : undefined}
|
||||
pagination={false}
|
||||
rowClassName="editable-row"
|
||||
/>
|
||||
<Form.Item>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
add();
|
||||
}}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{t("billlines.actions.newline")}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
add(
|
||||
InstanceRenderManager({
|
||||
imex: { applicable_taxes: { federal: true } },
|
||||
rome: { applicable_taxes: { federal: false } }
|
||||
})
|
||||
);
|
||||
}}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{t("billlines.actions.newline")}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
@@ -613,48 +664,50 @@ export function BillEnterModalLinesComponent({
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(BillEnterModalLinesComponent);
|
||||
|
||||
const EditableCell = ({
|
||||
dataIndex,
|
||||
title,
|
||||
inputType,
|
||||
record,
|
||||
index,
|
||||
children,
|
||||
formInput,
|
||||
formItemProps,
|
||||
additional,
|
||||
wrapper,
|
||||
wrapper: Wrapper,
|
||||
skipFormItem,
|
||||
...restProps
|
||||
}) => {
|
||||
const propsFinal = formItemProps && formItemProps(record);
|
||||
if (propsFinal && "key" in propsFinal) {
|
||||
delete propsFinal.key;
|
||||
}
|
||||
if (additional)
|
||||
return (
|
||||
<td {...restProps}>
|
||||
<div size="small">
|
||||
<Form.Item name={dataIndex} labelCol={{ span: 0 }} {...propsFinal}>
|
||||
{(formInput && formInput(record, record.name)) || children}
|
||||
</Form.Item>
|
||||
{additional && additional(record, record.name)}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
if (wrapper)
|
||||
return (
|
||||
<wrapper>
|
||||
<td {...restProps}>
|
||||
<Form.Item labelCol={{ span: 0 }} name={dataIndex} {...propsFinal}>
|
||||
{(formInput && formInput(record, record.name)) || children}
|
||||
</Form.Item>
|
||||
</td>
|
||||
</wrapper>
|
||||
);
|
||||
return (
|
||||
<td {...restProps}>
|
||||
<Form.Item labelCol={{ span: 0 }} name={dataIndex} {...propsFinal}>
|
||||
{(formInput && formInput(record, record.name)) || children}
|
||||
</Form.Item>
|
||||
const rawProps = formItemProps?.(record);
|
||||
|
||||
const propsFinal = rawProps
|
||||
? (() => {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { key, ...rest } = rawProps;
|
||||
return rest;
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
const control = skipFormItem ? (
|
||||
(formInput && formInput(record, record.name, propsFinal)) || children
|
||||
) : (
|
||||
<Form.Item labelCol={{ span: 0 }} {...propsFinal} style={{ marginBottom: 0 }}>
|
||||
{(formInput && formInput(record, record.name, propsFinal)) || children}
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
const cellInner = additional ? (
|
||||
<div>
|
||||
{control}
|
||||
{additional(record, record.name)}
|
||||
</div>
|
||||
) : (
|
||||
control
|
||||
);
|
||||
|
||||
const { style: tdStyle, ...tdRest } = restProps;
|
||||
|
||||
const td = (
|
||||
<td {...tdRest} style={{ ...tdStyle, verticalAlign: "middle" }}>
|
||||
{cellInner}
|
||||
</td>
|
||||
);
|
||||
|
||||
if (Wrapper) return <Wrapper>{td}</Wrapper>;
|
||||
return td;
|
||||
};
|
||||
|
||||
@@ -9,10 +9,10 @@ export const CalculateBillTotal = (invoice) => {
|
||||
let stateTax = Dinero({ amount: 0 });
|
||||
let localTax = Dinero({ amount: 0 });
|
||||
|
||||
if (!!!billlines) return null;
|
||||
if (!billlines) return null;
|
||||
|
||||
billlines.forEach((i) => {
|
||||
if (!!i) {
|
||||
if (i) {
|
||||
const itemTotal = Dinero({
|
||||
amount: Math.round((i.actual_cost || 0) * 100)
|
||||
}).multiply(i.quantity || 1);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Checkbox, Form, Skeleton, Typography } from "antd";
|
||||
import React, { useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ReadOnlyFormItemComponent from "../form-items-formatted/read-only-form-item.component";
|
||||
import "./bill-inventory-table.styles.scss";
|
||||
@@ -13,7 +13,7 @@ const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
billEnterModal: selectBillEnterModal
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
const mapDispatchToProps = () => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(BillInventoryTable);
|
||||
@@ -22,7 +22,7 @@ export function BillInventoryTable({ billEnterModal, bodyshop, form, billEdit, i
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (inventoryData && inventoryData.inventory) {
|
||||
if (inventoryData?.inventory) {
|
||||
form.setFieldsValue({
|
||||
inventory: billEnterModal.context.consumeinventoryid
|
||||
? inventoryData.inventory.map((i) => {
|
||||
@@ -47,7 +47,7 @@ export function BillInventoryTable({ billEnterModal, bodyshop, form, billEdit, i
|
||||
|
||||
return (
|
||||
<Form.List name="inventory">
|
||||
{(fields, { add, remove, move }) => {
|
||||
{(fields) => {
|
||||
return (
|
||||
<>
|
||||
<Typography.Title level={4}>{t("inventory.labels.inventory")}</Typography.Title>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
td {
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
border-bottom: 1px solid var(--table-border-color);
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 0px !important;
|
||||
@@ -14,6 +14,6 @@
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
background-color: var(--table-hover-bg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { Select } from "antd";
|
||||
import React, { forwardRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import InstanceRenderMgr from "../../utils/instanceRenderMgr";
|
||||
|
||||
const BillLineSearchSelect = ({ options, disabled, allowRemoved, ...restProps }, ref) => {
|
||||
const BillLineSearchSelect = ({ options, disabled, allowRemoved, ref, ...restProps }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Select
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
showSearch
|
||||
showSearch={{
|
||||
filterOption: (inputValue, option) => {
|
||||
return (
|
||||
(option.line_desc && option.line_desc.toLowerCase().includes(inputValue.toLowerCase())) ||
|
||||
(option.oem_partno && option.oem_partno.toLowerCase().includes(inputValue.toLowerCase())) ||
|
||||
(option.alt_partno && option.alt_partno.toLowerCase().includes(inputValue.toLowerCase())) ||
|
||||
(option.act_price && option.act_price.toString().startsWith(inputValue.toString()))
|
||||
);
|
||||
}
|
||||
}}
|
||||
popupMatchSelectWidth={true}
|
||||
optionLabelProp={"name"}
|
||||
// optionFilterProp="line_desc"
|
||||
filterOption={(inputValue, option) => {
|
||||
return (
|
||||
(option.line_desc && option.line_desc.toLowerCase().includes(inputValue.toLowerCase())) ||
|
||||
(option.oem_partno && option.oem_partno.toLowerCase().includes(inputValue.toLowerCase())) ||
|
||||
(option.alt_partno && option.alt_partno.toLowerCase().includes(inputValue.toLowerCase())) ||
|
||||
(option.act_price && option.act_price.toString().startsWith(inputValue.toString()))
|
||||
);
|
||||
}}
|
||||
notFoundContent={"Removed."}
|
||||
options={[
|
||||
{ value: "noline", label: t("billlines.labels.other"), name: t("billlines.labels.other") },
|
||||
@@ -39,30 +39,32 @@ const BillLineSearchSelect = ({ options, disabled, allowRemoved, ...restProps },
|
||||
style: {
|
||||
...(item.removed ? { textDecoration: "line-through" } : {})
|
||||
},
|
||||
name: `${item.removed ? `(REMOVED) ` : ""}${item.line_desc}${
|
||||
item.oem_partno ? ` - ${item.oem_partno}` : ""
|
||||
}${item.alt_partno ? ` (${item.alt_partno})` : ""}`.trim(),
|
||||
label: (
|
||||
<div style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
|
||||
<span>
|
||||
{`${item.removed ? `(REMOVED) ` : ""}${item.line_desc}${
|
||||
item.oem_partno ? ` - ${item.oem_partno}` : ""
|
||||
}${item.alt_partno ? ` (${item.alt_partno})` : ""}`.trim()}
|
||||
</span>
|
||||
{InstanceRenderMgr({
|
||||
rome: item.act_price === 0 && item.mod_lb_hrs > 0 && (
|
||||
<span style={{ float: "right", paddingleft: "1rem" }}>{`${item.mod_lb_hrs} units`}</span>
|
||||
)
|
||||
})}
|
||||
<span style={{ float: "right", paddingleft: "1rem" }}>
|
||||
{item.act_price ? `$${item.act_price && item.act_price.toFixed(2)}` : ``}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
name: generateLineName(item),
|
||||
label: generateLineName(item)
|
||||
}))
|
||||
]}
|
||||
{...restProps}
|
||||
></Select>
|
||||
);
|
||||
};
|
||||
export default forwardRef(BillLineSearchSelect);
|
||||
|
||||
function generateLineName(item) {
|
||||
return (
|
||||
<div style={{ whiteSpace: "normal", wordBreak: "break-word" }}>
|
||||
<span>
|
||||
{`${item.removed ? `(REMOVED) ` : ""}${item.line_desc}${
|
||||
item.oem_partno ? ` - ${item.oem_partno}` : ""
|
||||
}${item.alt_partno ? ` (${item.alt_partno})` : ""}`.trim()}
|
||||
</span>
|
||||
{InstanceRenderMgr({
|
||||
rome: item.act_price === 0 && item.mod_lb_hrs > 0 && (
|
||||
<span style={{ float: "right", paddingleft: "1rem" }}>{`${item.mod_lb_hrs} units`}</span>
|
||||
)
|
||||
})}
|
||||
<span style={{ float: "right", paddingleft: "1rem" }}>
|
||||
{item.act_price ? `$${item.act_price && item.act_price.toFixed(2)}` : ``}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default BillLineSearchSelect;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { gql, useMutation } from "@apollo/client";
|
||||
import { useMutation } from "@apollo/client/react";
|
||||
import { gql } from "@apollo/client";
|
||||
|
||||
import { Button } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { connect } from "react-redux";
|
||||
@@ -15,7 +17,7 @@ const mapStateToProps = createStructuredSelector({
|
||||
authLevel: selectAuthLevel,
|
||||
currentUser: selectCurrentUser
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
const mapDispatchToProps = () => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
|
||||
@@ -60,12 +62,12 @@ export function BillMarkExportedButton({ currentUser, bodyshop, authLevel, bill
|
||||
});
|
||||
|
||||
if (!result.errors) {
|
||||
notification["success"]({
|
||||
message: t("bills.successes.markexported")
|
||||
notification.success({
|
||||
title: t("bills.successes.markexported")
|
||||
});
|
||||
} else {
|
||||
notification["error"]({
|
||||
message: t("bills.errors.saving", {
|
||||
notification.error({
|
||||
title: t("bills.errors.saving", {
|
||||
error: JSON.stringify(result.errors)
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, Space } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GenerateDocument } from "../../utils/RenderTemplate";
|
||||
import { TemplateList } from "../../utils/TemplateConstants";
|
||||
@@ -26,7 +26,7 @@ export default function BillPrintButton({ billid }) {
|
||||
null,
|
||||
notification
|
||||
);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
console.warn("Warning: Error generating a document.");
|
||||
}
|
||||
setLoading(false);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { gql, useMutation } from "@apollo/client";
|
||||
import { useMutation } from "@apollo/client/react";
|
||||
import { gql } from "@apollo/client";
|
||||
import { Button } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { connect } from "react-redux";
|
||||
@@ -13,7 +14,7 @@ const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
authLevel: selectAuthLevel
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
const mapDispatchToProps = () => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
|
||||
@@ -43,12 +44,12 @@ export function BillMarkForReexportButton({ bodyshop, authLevel, bill }) {
|
||||
});
|
||||
|
||||
if (!result.errors) {
|
||||
notification["success"]({
|
||||
message: t("bills.successes.reexport")
|
||||
notification.success({
|
||||
title: t("bills.successes.reexport")
|
||||
});
|
||||
} else {
|
||||
notification["error"]({
|
||||
message: t("bills.errors.saving", {
|
||||
notification.error({
|
||||
title: t("bills.errors.saving", {
|
||||
error: JSON.stringify(result.errors)
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { FileAddFilled } from "@ant-design/icons";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { useMutation } from "@apollo/client/react";
|
||||
import { Button, Tooltip } from "antd";
|
||||
import { t } from "i18next";
|
||||
import dayjs from "./../../utils/day";
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { INSERT_INVENTORY_AND_CREDIT } from "../../graphql/inventory.queries";
|
||||
@@ -17,121 +17,137 @@ const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
currentUser: selectCurrentUser
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
const mapDispatchToProps = () => ({});
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(BilllineAddInventory);
|
||||
|
||||
export function BilllineAddInventory({ currentUser, bodyshop, billline, disabled, jobid }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { billid } = queryString.parse(useLocation().search);
|
||||
const qs = queryString.parse(useLocation().search);
|
||||
const billid = qs?.billid != null ? String(qs.billid) : null;
|
||||
|
||||
const [insertInventoryLine] = useMutation(INSERT_INVENTORY_AND_CREDIT);
|
||||
const notification = useNotification();
|
||||
|
||||
const inventoryCount = billline?.inventories?.length ?? 0;
|
||||
const quantity = billline?.quantity ?? 0;
|
||||
|
||||
const addToInventory = async () => {
|
||||
setLoading(true);
|
||||
if (loading) return;
|
||||
|
||||
//Check to make sure there are no existing items already in the inventory.
|
||||
|
||||
const cm = {
|
||||
vendorid: bodyshop.inhousevendorid,
|
||||
invoice_number: "ih",
|
||||
jobid: jobid,
|
||||
isinhouse: true,
|
||||
is_credit_memo: true,
|
||||
date: dayjs().format("YYYY-MM-DD"),
|
||||
federal_tax_rate: bodyshop.bill_tax_rates.federal_tax_rate,
|
||||
state_tax_rate: bodyshop.bill_tax_rates.state_tax_rate,
|
||||
local_tax_rate: bodyshop.bill_tax_rates.local_tax_rate,
|
||||
total: 0,
|
||||
billlines: [
|
||||
{
|
||||
actual_price: billline.actual_price,
|
||||
actual_cost: billline.actual_cost,
|
||||
quantity: billline.quantity,
|
||||
line_desc: billline.line_desc,
|
||||
cost_center: billline.cost_center,
|
||||
deductedfromlbr: billline.deductedfromlbr,
|
||||
applicable_taxes: {
|
||||
local: billline.applicable_taxes.local,
|
||||
state: billline.applicable_taxes.state,
|
||||
federal: billline.applicable_taxes.federal
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
cm.total = CalculateBillTotal(cm).enteredTotal.getAmount() / 100;
|
||||
|
||||
const insertResult = await insertInventoryLine({
|
||||
variables: {
|
||||
joblineId: billline.joblineid === "noline" ? billline.id : billline.joblineid, //This will return null as there will be no jobline that has the id of the bill line.
|
||||
//Unfortunately, we can't send null as the GQL syntax validation fails.
|
||||
joblineStatus: bodyshop.md_order_statuses.default_returned,
|
||||
inv: {
|
||||
shopid: bodyshop.id,
|
||||
billlineid: billline.id,
|
||||
actual_price: billline.actual_price,
|
||||
actual_cost: billline.actual_cost,
|
||||
quantity: billline.quantity,
|
||||
line_desc: billline.line_desc
|
||||
},
|
||||
cm: { ...cm, billlines: { data: cm.billlines } }, //Fix structure for apollo insert.
|
||||
pol: {
|
||||
returnfrombill: billid,
|
||||
vendorid: bodyshop.inhousevendorid,
|
||||
deliver_by: dayjs().format("YYYY-MM-DD"),
|
||||
parts_order_lines: {
|
||||
data: [
|
||||
{
|
||||
line_desc: billline.line_desc,
|
||||
|
||||
act_price: billline.actual_price,
|
||||
cost: billline.actual_cost,
|
||||
quantity: billline.quantity,
|
||||
job_line_id: billline.joblineid === "noline" ? null : billline.joblineid,
|
||||
part_type: billline.jobline && billline.jobline.part_type,
|
||||
cm_received: true
|
||||
}
|
||||
]
|
||||
},
|
||||
order_date: "2022-06-01",
|
||||
orderedby: currentUser.email,
|
||||
jobid: jobid,
|
||||
user_email: currentUser.email,
|
||||
return: true,
|
||||
status: "Ordered"
|
||||
}
|
||||
},
|
||||
refetchQueries: ["QUERY_BILL_BY_PK"]
|
||||
});
|
||||
|
||||
if (!insertResult.errors) {
|
||||
notification.open({
|
||||
type: "success",
|
||||
message: t("inventory.successes.inserted")
|
||||
});
|
||||
} else {
|
||||
notification.open({
|
||||
type: "error",
|
||||
message: t("inventory.errors.inserting", {
|
||||
error: JSON.stringify(insertResult.errors)
|
||||
})
|
||||
// Defensive: row identity can transiently desync during remove/add reindexing.
|
||||
if (!billline) {
|
||||
notification.error({
|
||||
title: t("inventory.errors.inserting", { error: "Bill line is missing (please try again)." })
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const taxes = billline?.applicable_taxes ?? {};
|
||||
const cm = {
|
||||
vendorid: bodyshop.inhousevendorid,
|
||||
invoice_number: "ih",
|
||||
jobid: jobid,
|
||||
isinhouse: true,
|
||||
is_credit_memo: true,
|
||||
date: dayjs().format("YYYY-MM-DD"),
|
||||
federal_tax_rate: bodyshop.bill_tax_rates.federal_tax_rate,
|
||||
state_tax_rate: bodyshop.bill_tax_rates.state_tax_rate,
|
||||
local_tax_rate: bodyshop.bill_tax_rates.local_tax_rate,
|
||||
total: 0,
|
||||
billlines: [
|
||||
{
|
||||
actual_price: billline.actual_price,
|
||||
actual_cost: billline.actual_cost,
|
||||
quantity: billline.quantity,
|
||||
line_desc: billline.line_desc,
|
||||
cost_center: billline.cost_center,
|
||||
deductedfromlbr: billline.deductedfromlbr,
|
||||
applicable_taxes: {
|
||||
local: taxes.local,
|
||||
state: taxes.state,
|
||||
federal: taxes.federal
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
cm.total = CalculateBillTotal(cm).enteredTotal.getAmount() / 100;
|
||||
|
||||
const insertResult = await insertInventoryLine({
|
||||
variables: {
|
||||
joblineId: billline.joblineid === "noline" ? billline.id : billline.joblineid,
|
||||
joblineStatus: bodyshop.md_order_statuses.default_returned,
|
||||
inv: {
|
||||
shopid: bodyshop.id,
|
||||
billlineid: billline.id,
|
||||
actual_price: billline.actual_price,
|
||||
actual_cost: billline.actual_cost,
|
||||
quantity: billline.quantity,
|
||||
line_desc: billline.line_desc
|
||||
},
|
||||
cm: { ...cm, billlines: { data: cm.billlines } },
|
||||
pol: {
|
||||
returnfrombill: billid,
|
||||
vendorid: bodyshop.inhousevendorid,
|
||||
deliver_by: dayjs().format("YYYY-MM-DD"),
|
||||
parts_order_lines: {
|
||||
data: [
|
||||
{
|
||||
line_desc: billline.line_desc,
|
||||
act_price: billline.actual_price,
|
||||
cost: billline.actual_cost,
|
||||
quantity: billline.quantity,
|
||||
job_line_id: billline.joblineid === "noline" ? null : billline.joblineid,
|
||||
part_type: billline.jobline && billline.jobline.part_type,
|
||||
cm_received: true
|
||||
}
|
||||
]
|
||||
},
|
||||
order_date: "2022-06-01",
|
||||
orderedby: currentUser.email,
|
||||
jobid: jobid,
|
||||
user_email: currentUser.email,
|
||||
return: true,
|
||||
status: "Ordered"
|
||||
}
|
||||
},
|
||||
refetchQueries: ["QUERY_BILL_BY_PK"]
|
||||
});
|
||||
|
||||
if (!insertResult?.errors?.length) {
|
||||
notification.success({
|
||||
title: t("inventory.successes.inserted")
|
||||
});
|
||||
} else {
|
||||
notification.error({
|
||||
title: t("inventory.errors.inserting", {
|
||||
error: JSON.stringify(insertResult.errors)
|
||||
})
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
notification.error({
|
||||
title: t("inventory.errors.inserting", {
|
||||
error: err?.message || String(err)
|
||||
})
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title={t("inventory.actions.addtoinventory")}>
|
||||
<Button
|
||||
icon={<FileAddFilled />}
|
||||
loading={loading}
|
||||
disabled={disabled || billline?.inventories?.length >= billline.quantity}
|
||||
disabled={disabled || inventoryCount >= quantity}
|
||||
onClick={addToInventory}
|
||||
>
|
||||
<FileAddFilled />
|
||||
{billline?.inventories?.length > 0 && <div>({billline?.inventories?.length} in inv)</div>}
|
||||
{inventoryCount > 0 && <div>({inventoryCount} in inv)</div>}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { EditFilled, SyncOutlined } from "@ant-design/icons";
|
||||
import { Button, Card, Checkbox, Input, Space, Table } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaTasks } from "react-icons/fa";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import { selectJobReadOnly } from "../../redux/application/application.selectors";
|
||||
import { setModalContext } from "../../redux/modals/modals.actions";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
@@ -75,6 +76,7 @@ export function BillsListTableComponent({
|
||||
<Button
|
||||
title={t("tasks.buttons.create")}
|
||||
onClick={() => {
|
||||
logImEXEvent("bills_create_task", {});
|
||||
setTaskUpsertContext({
|
||||
context: {
|
||||
jobid: job.id,
|
||||
@@ -82,15 +84,14 @@ export function BillsListTableComponent({
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FaTasks />
|
||||
</Button>
|
||||
icon={<FaTasks />}
|
||||
/>
|
||||
|
||||
<BillDeleteButton bill={record} jobid={job.id} />
|
||||
<BillDetailEditReturnComponent
|
||||
data={{ bills_by_pk: { ...record, jobid: job.id, job: job } }}
|
||||
disabled={record.is_credit_memo || record.vendorid === bodyshop.inhousevendorid || jobRO}
|
||||
/>
|
||||
|
||||
{record.isinhouse && (
|
||||
<PrintWrapperComponent
|
||||
templateObject={{
|
||||
@@ -109,6 +110,13 @@ export function BillsListTableComponent({
|
||||
key: "vendorname",
|
||||
sorter: (a, b) => alphaSort(a.vendor.name, b.vendor.name),
|
||||
sortOrder: state.sortedInfo.columnKey === "vendorname" && state.sortedInfo.order,
|
||||
filters: bills
|
||||
? [...new Set(bills.map((bill) => bill.vendor.name))].map((name) => ({
|
||||
text: name,
|
||||
value: name
|
||||
}))
|
||||
: [],
|
||||
onFilter: (value, record) => record.vendor.name === value,
|
||||
render: (text, record) => <span>{record.vendor.name}</span>
|
||||
},
|
||||
{
|
||||
@@ -160,6 +168,7 @@ export function BillsListTableComponent({
|
||||
|
||||
const handleTableChange = (pagination, filters, sorter) => {
|
||||
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
|
||||
logImEXEvent("bills_list_sort_filter", { pagination, filters, sorter });
|
||||
};
|
||||
|
||||
const filteredBills = bills
|
||||
@@ -180,9 +189,7 @@ export function BillsListTableComponent({
|
||||
title={t("bills.labels.bills")}
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Button onClick={() => refetch()}>
|
||||
<SyncOutlined />
|
||||
</Button>
|
||||
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
|
||||
{job && job.converted ? (
|
||||
<>
|
||||
<Button
|
||||
@@ -201,6 +208,7 @@ export function BillsListTableComponent({
|
||||
<Button
|
||||
disabled={!hasBillsAccess}
|
||||
onClick={() => {
|
||||
logImEXEvent("bills_reconcile", {});
|
||||
setReconciliationContext({
|
||||
actions: { refetch: billsQuery.refetch },
|
||||
context: {
|
||||
@@ -209,6 +217,7 @@ export function BillsListTableComponent({
|
||||
}
|
||||
});
|
||||
}}
|
||||
id="reconcile-bills-button"
|
||||
>
|
||||
<LockerWrapperComponent featureName="bills"> {t("jobs.actions.reconcile")}</LockerWrapperComponent>
|
||||
</Button>
|
||||
@@ -223,6 +232,7 @@ export function BillsListTableComponent({
|
||||
e.preventDefault();
|
||||
setSearchText(e.target.value);
|
||||
}}
|
||||
enterButton
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { QUERY_ALL_VENDORS } from "../../graphql/vendors.queries";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import queryString from "query-string";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Input, Table } from "antd";
|
||||
@@ -67,7 +67,7 @@ export default function BillsVendorsList() {
|
||||
setState({ ...state, search: e.target.value });
|
||||
};
|
||||
|
||||
if (error) return <AlertComponent message={error.message} type="error" />;
|
||||
if (error) return <AlertComponent title={error.message} type="error" />;
|
||||
|
||||
const dataSource = state.search
|
||||
? data.vendors.filter(
|
||||
@@ -89,7 +89,7 @@ export default function BillsVendorsList() {
|
||||
);
|
||||
}}
|
||||
dataSource={dataSource}
|
||||
pagination={{ position: "top" }}
|
||||
pagination={{ placement: "top" }}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
onChange={handleTableChange}
|
||||
@@ -100,9 +100,9 @@ export default function BillsVendorsList() {
|
||||
selectedRowKeys: [search.vendorid],
|
||||
type: "radio"
|
||||
}}
|
||||
onRow={(record, rowIndex) => {
|
||||
onRow={(record) => {
|
||||
return {
|
||||
onClick: (event) => {
|
||||
onClick: () => {
|
||||
handleOnRowClick(record);
|
||||
} // click row
|
||||
};
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import { HomeFilled } from "@ant-design/icons";
|
||||
import { Breadcrumb, Col, Row } from "antd";
|
||||
import React from "react";
|
||||
import { selectBreadcrumbs, selectIsPartsEntry } from "../../redux/application/application.selectors";
|
||||
import { connect } from "react-redux";
|
||||
import { Link } from "react-router-dom";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBreadcrumbs } from "../../redux/application/application.selectors";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import GlobalSearch from "../global-search/global-search.component";
|
||||
import GlobalSearchOs from "../global-search/global-search-os.component";
|
||||
import "./breadcrumbs.styles.scss";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
breadcrumbs: selectBreadcrumbs,
|
||||
bodyshop: selectBodyshop
|
||||
bodyshop: selectBodyshop,
|
||||
isPartsEntry: selectIsPartsEntry
|
||||
});
|
||||
|
||||
export function BreadCrumbs({ breadcrumbs, bodyshop }) {
|
||||
export function BreadCrumbs({ breadcrumbs, bodyshop, isPartsEntry }) {
|
||||
const {
|
||||
treatments: { OpenSearch }
|
||||
} = useSplitTreatments({
|
||||
} = useTreatmentsWithConfig({
|
||||
attributes: {},
|
||||
names: ["OpenSearch"],
|
||||
splitKey: bodyshop && bodyshop.imexshopid
|
||||
splitKey: bodyshop?.imexshopid
|
||||
});
|
||||
// TODO - Client Update - Technically key is not doing anything here
|
||||
|
||||
return (
|
||||
<Row className="breadcrumb-container">
|
||||
<Col xs={24} sm={24} md={16}>
|
||||
@@ -34,8 +34,8 @@ export function BreadCrumbs({ breadcrumbs, bodyshop }) {
|
||||
{
|
||||
key: "home",
|
||||
title: (
|
||||
<Link to={`/manage/`}>
|
||||
<HomeFilled /> {(bodyshop && bodyshop.shopname && `(${bodyshop.shopname})`) || ""}
|
||||
<Link to={isPartsEntry ? `/parts/` : `/manage/`}>
|
||||
<HomeFilled /> {(bodyshop?.shopname && `(${bodyshop.shopname})`) || ""}
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, Form, Modal } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -32,12 +32,13 @@ export function ContractsFindModalContainer({ caBcEtfTableModal, toggleModalVisi
|
||||
logImEXEvent("ca_bc_etf_table_parse");
|
||||
setLoading(true);
|
||||
const claimNumbers = [];
|
||||
values.table.split("\n").forEach((row, idx, arr) => {
|
||||
values.table.split("\n").forEach((row) => {
|
||||
const { 1: claim, 2: shortclaim, 4: amount } = row.split("\t");
|
||||
if (!claim || !shortclaim) return;
|
||||
const trimmedShortClaim = shortclaim.trim();
|
||||
// const trimmedClaim = claim.trim();
|
||||
if (amount.slice(-1) === "-") {
|
||||
// NO OP
|
||||
}
|
||||
|
||||
claimNumbers.push({
|
||||
@@ -75,7 +76,7 @@ export function ContractsFindModalContainer({ caBcEtfTableModal, toggleModalVisi
|
||||
title={t("payments.labels.findermodal")}
|
||||
onCancel={() => toggleModalVisible()}
|
||||
onOk={() => toggleModalVisible()}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
forceRender
|
||||
>
|
||||
<Form form={form} layout="vertical" autoComplete="no" onFinish={handleFinish}>
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { Form, Input, Radio } 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";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapStateToProps = createStructuredSelector({});
|
||||
|
||||
export default connect(mapStateToProps, null)(PartsReceiveModalComponent);
|
||||
|
||||
export function PartsReceiveModalComponent({ bodyshop, form }) {
|
||||
export function PartsReceiveModalComponent() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { CalculatorFilled } from "@ant-design/icons";
|
||||
import { Button, Form, InputNumber, Popover, Space } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
|
||||
export default function CABCpvrtCalculator({ disabled, form }) {
|
||||
const [visibility, setVisibility] = useState(false);
|
||||
|
||||
@@ -39,10 +40,8 @@ export default function CABCpvrtCalculator({ disabled, form }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover destroyTooltipOnHide content={popContent} open={visibility} disabled={disabled}>
|
||||
<Button disabled={disabled} onClick={() => setVisibility(true)}>
|
||||
<CalculatorFilled />
|
||||
</Button>
|
||||
<Popover destroyOnHidden content={popContent} open={visibility} disabled={disabled}>
|
||||
<Button disabled={disabled} onClick={() => setVisibility(true)} icon={<CalculatorFilled />} />
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { CopyFilled, DeleteFilled } from "@ant-design/icons";
|
||||
import { useLazyQuery, useMutation } from "@apollo/client";
|
||||
import { useLazyQuery, useMutation } from "@apollo/client/react";
|
||||
import { Button, Card, Col, Form, Input, message, Row, Space, Spin, Statistic } from "antd";
|
||||
import axios from "axios";
|
||||
import React, { useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { INSERT_PAYMENT_RESPONSE, QUERY_RO_AND_OWNER_BY_JOB_PKS } from "../../graphql/payment_response.queries";
|
||||
import { getCurrentUser, logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { insertAuditTrail } from "../../redux/application/application.actions";
|
||||
import { toggleModalVisible } from "../../redux/modals/modals.actions";
|
||||
import { selectCardPayment } from "../../redux/modals/modals.selectors";
|
||||
@@ -14,8 +16,6 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import AuditTrailMapping from "../../utils/AuditTrailMappings";
|
||||
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
|
||||
import JobSearchSelectComponent from "../job-search-select/job-search-select.component";
|
||||
import { getCurrentUser } from "../../firebase/firebase.utils";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
cardPaymentModal: selectCardPayment,
|
||||
@@ -46,15 +46,65 @@ const CardPaymentModalComponent = ({
|
||||
|
||||
const [form] = Form.useForm();
|
||||
const [paymentLink, setPaymentLink] = useState();
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [insertPaymentResponse] = useMutation(INSERT_PAYMENT_RESPONSE);
|
||||
const { t } = useTranslation();
|
||||
const notification = useNotification();
|
||||
|
||||
const [, { data, refetch, queryLoading }] = useLazyQuery(QUERY_RO_AND_OWNER_BY_JOB_PKS, {
|
||||
variables: { jobids: [context.jobid] },
|
||||
skip: !context?.jobid
|
||||
});
|
||||
const [loadRoAndOwnerByJobPks, { data, loading: queryLoading, error: queryError, refetch, called }] = useLazyQuery(
|
||||
QUERY_RO_AND_OWNER_BY_JOB_PKS,
|
||||
{
|
||||
fetchPolicy: "network-only",
|
||||
notifyOnNetworkStatusChange: true
|
||||
}
|
||||
);
|
||||
|
||||
const safeRefetchRoAndOwner = useCallback(
|
||||
(vars) => {
|
||||
// First run: execute the lazy query
|
||||
if (!called) return loadRoAndOwnerByJobPks({ variables: vars });
|
||||
// Subsequent runs: refetch expects the variables object directly (not { variables: ... })
|
||||
return refetch(vars);
|
||||
},
|
||||
[called, loadRoAndOwnerByJobPks, refetch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setLoadingSafe = useCallback((value) => {
|
||||
if (isMountedRef.current) setLoading(value);
|
||||
}, []);
|
||||
|
||||
// Watch form payments so we can query jobs when all jobids are filled (without side effects during render)
|
||||
const payments = Form.useWatch(["payments"], form);
|
||||
|
||||
const jobids = useMemo(() => {
|
||||
if (!Array.isArray(payments) || payments.length === 0) return [];
|
||||
return payments.map((p) => p?.jobid).filter(Boolean);
|
||||
}, [payments]);
|
||||
|
||||
const allJobIdsFilled = useMemo(() => {
|
||||
if (!Array.isArray(payments) || payments.length === 0) return false;
|
||||
return payments.every((p) => !!p?.jobid);
|
||||
}, [payments]);
|
||||
|
||||
const lastJobidsKeyRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!allJobIdsFilled) return;
|
||||
|
||||
const nextKey = jobids.join("|");
|
||||
if (!nextKey || nextKey === lastJobidsKeyRef.current) return;
|
||||
|
||||
lastJobidsKeyRef.current = nextKey;
|
||||
safeRefetchRoAndOwner({ jobids });
|
||||
}, [allJobIdsFilled, jobids, safeRefetchRoAndOwner]);
|
||||
|
||||
const collectIPayFields = () => {
|
||||
const iPayFields = document.querySelectorAll(".ipayfield");
|
||||
@@ -68,55 +118,84 @@ const CardPaymentModalComponent = ({
|
||||
const SetIntellipayCallbackFunctions = () => {
|
||||
console.log("*** Set IntelliPay callback functions.");
|
||||
|
||||
const isLikelyUserCancel = (response) => {
|
||||
const reason = String(response?.declinereason ?? "").toLowerCase();
|
||||
// Heuristics: adjust if IntelliPay gives you a known cancel code/message
|
||||
return (
|
||||
reason.includes("cancel") ||
|
||||
reason.includes("canceled") ||
|
||||
reason.includes("closed") ||
|
||||
// many gateways won't have a paymentid if user cancels before submitting
|
||||
!response?.paymentid
|
||||
);
|
||||
};
|
||||
|
||||
window.intellipay.runOnClose(() => {
|
||||
//window.intellipay.initialize();
|
||||
// This is the path for Cancel / X
|
||||
try {
|
||||
// If IntelliPay uses this flag, clear it so initialize() won't reopen unexpectedly
|
||||
window.intellipay.isAutoOpen = false;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Optional: if IntelliPay needs re-init after close, uncomment:
|
||||
// try { window.intellipay.initialize?.(); } catch {}
|
||||
|
||||
setLoadingSafe(false);
|
||||
});
|
||||
|
||||
window.intellipay.runOnApproval(() => {
|
||||
//2024-04-25: Nothing is going to happen here anymore. We'll completely rely on the callback.
|
||||
//Add a slight delay to allow the refetch to properly get the data.
|
||||
// keep your existing behavior
|
||||
setTimeout(() => {
|
||||
if (actions?.refetch) actions.refetch();
|
||||
setLoading(false);
|
||||
setLoadingSafe(false);
|
||||
toggleModalVisible();
|
||||
}, 750);
|
||||
});
|
||||
|
||||
window.intellipay.runOnNonApproval(async (response) => {
|
||||
// Mutate unsuccessful payment
|
||||
try {
|
||||
// If cancel is reported as "non-approval", don't record it as a failed payment
|
||||
if (isLikelyUserCancel(response)) return;
|
||||
|
||||
const { payments } = form.getFieldsValue();
|
||||
await insertPaymentResponse({
|
||||
variables: {
|
||||
paymentResponse: payments.map((payment) => ({
|
||||
amount: payment.amount,
|
||||
bodyshopid: bodyshop.id,
|
||||
const { payments } = form.getFieldsValue();
|
||||
|
||||
await insertPaymentResponse({
|
||||
variables: {
|
||||
paymentResponse: payments.map((payment) => ({
|
||||
amount: payment.amount,
|
||||
bodyshopid: bodyshop.id,
|
||||
jobid: payment.jobid,
|
||||
declinereason: response.declinereason,
|
||||
ext_paymentid: response.paymentid?.toString?.() ?? null,
|
||||
successful: false,
|
||||
response
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
payments.forEach((payment) =>
|
||||
insertAuditTrail({
|
||||
jobid: payment.jobid,
|
||||
declinereason: response.declinereason,
|
||||
ext_paymentid: response.paymentid.toString(),
|
||||
successful: false,
|
||||
response
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
payments.forEach((payment) =>
|
||||
insertAuditTrail({
|
||||
jobid: payment.jobid,
|
||||
operation: AuditTrailMapping.failedpayment(),
|
||||
type: "failedpayment"
|
||||
})
|
||||
);
|
||||
operation: AuditTrailMapping.failedpayment(),
|
||||
type: "failedpayment"
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
// IMPORTANT: always clear loading, even on errors
|
||||
setLoadingSafe(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleIntelliPayCharge = async () => {
|
||||
setLoading(true);
|
||||
//Validate
|
||||
// Validate
|
||||
try {
|
||||
await form.validateFields();
|
||||
} catch {
|
||||
setLoading(false);
|
||||
setLoadingSafe(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -124,6 +203,7 @@ const CardPaymentModalComponent = ({
|
||||
const { payments } = form.getFieldsValue();
|
||||
|
||||
try {
|
||||
logImEXEvent("payment_cc_lightbox");
|
||||
const response = await axios.post("/intellipay/lightbox_credentials", {
|
||||
bodyshop,
|
||||
refresh: !!window.intellipay,
|
||||
@@ -133,8 +213,9 @@ const CardPaymentModalComponent = ({
|
||||
});
|
||||
|
||||
if (window.intellipay) {
|
||||
// eslint-disable-next-line no-eval
|
||||
eval(response.data);
|
||||
// Use Function constructor instead of eval for security (still executes dynamic code but safer)
|
||||
// IntelliPay provides initialization code that must be executed
|
||||
Function(response.data)();
|
||||
pollForIntelliPay(() => {
|
||||
SetIntellipayCallbackFunctions();
|
||||
window.intellipay.autoOpen();
|
||||
@@ -145,26 +226,26 @@ const CardPaymentModalComponent = ({
|
||||
document.documentElement.appendChild(node);
|
||||
pollForIntelliPay(() => {
|
||||
SetIntellipayCallbackFunctions();
|
||||
|
||||
window.intellipay.isAutoOpen = true;
|
||||
window.intellipay.initialize();
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
notification.open({
|
||||
type: "error",
|
||||
message: t("job_payments.notifications.error.openingip")
|
||||
} catch {
|
||||
notification.error({
|
||||
title: t("job_payments.notifications.error.openingip")
|
||||
});
|
||||
setLoading(false);
|
||||
setLoadingSafe(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIntelliPayChargeShortLink = async () => {
|
||||
setLoading(true);
|
||||
//Validate
|
||||
// Validate
|
||||
try {
|
||||
await form.validateFields();
|
||||
} catch {
|
||||
setLoading(false);
|
||||
setLoadingSafe(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -172,6 +253,7 @@ const CardPaymentModalComponent = ({
|
||||
|
||||
try {
|
||||
const { payments } = form.getFieldsValue();
|
||||
logImEXEvent("payment_cc_shortlink");
|
||||
const response = await axios.post("/intellipay/generate_payment_url", {
|
||||
bodyshop,
|
||||
amount: payments.reduce((acc, val) => acc + (val?.amount || 0), 0),
|
||||
@@ -186,13 +268,12 @@ const CardPaymentModalComponent = ({
|
||||
await navigator.clipboard.writeText(response.data.shorUrl);
|
||||
message.success(t("general.actions.copied"));
|
||||
}
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
notification.open({
|
||||
type: "error",
|
||||
message: t("job_payments.notifications.error.openingip")
|
||||
setLoadingSafe(false);
|
||||
} catch {
|
||||
notification.error({
|
||||
title: t("job_payments.notifications.error.openingip")
|
||||
});
|
||||
setLoading(false);
|
||||
setLoadingSafe(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -247,40 +328,20 @@ const CardPaymentModalComponent = ({
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
<Form.Item
|
||||
shouldUpdate={(prevValues, curValues) =>
|
||||
prevValues.payments?.map((p) => p?.jobid + p?.amount).join() !==
|
||||
curValues.payments?.map((p) => p?.jobid + p?.amount).join()
|
||||
}
|
||||
>
|
||||
{() => {
|
||||
//If all of the job ids have been fileld in, then query and update the IP field.
|
||||
const { payments } = form.getFieldsValue();
|
||||
if (payments?.length > 0 && payments?.filter((p) => p?.jobid).length === payments?.length) {
|
||||
refetch({ jobids: payments.map((p) => p.jobid) });
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
className="ipayfield"
|
||||
data-ipayname="account"
|
||||
type="hidden"
|
||||
value={
|
||||
payments && data && data.jobs.length > 0 ? data.jobs.map((j) => j.ro_number).join(", ") : null
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
className="ipayfield"
|
||||
data-ipayname="email"
|
||||
type="hidden"
|
||||
value={
|
||||
payments && data && data.jobs.length > 0 ? data.jobs.filter((j) => j.ownr_ea)[0]?.ownr_ea : null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
{/* Hidden IntelliPay fields driven by watched payments + query result. IMPORTANT: no refetch() here (avoid side effects during render). */}
|
||||
<Input
|
||||
className="ipayfield"
|
||||
data-ipayname="account"
|
||||
type="hidden"
|
||||
value={data?.jobs?.length > 0 ? data.jobs.map((j) => j.ro_number).join(", ") : null}
|
||||
/>
|
||||
<Input
|
||||
className="ipayfield"
|
||||
data-ipayname="email"
|
||||
type="hidden"
|
||||
value={data?.jobs?.length > 0 ? (data.jobs.find((j) => j.ownr_ea)?.ownr_ea ?? null) : null}
|
||||
/>
|
||||
|
||||
<Form.Item
|
||||
shouldUpdate={(prevValues, curValues) =>
|
||||
prevValues.payments?.map((p) => p?.amount).join() !== curValues.payments?.map((p) => p?.amount).join()
|
||||
@@ -315,7 +376,7 @@ const CardPaymentModalComponent = ({
|
||||
>
|
||||
{t("job_payments.buttons.proceedtopayment")}
|
||||
</Button>
|
||||
<Space direction="vertical" align="center">
|
||||
<Space orientation="vertical" align="center">
|
||||
<Button
|
||||
type="primary"
|
||||
// data-ipayname="submit"
|
||||
@@ -332,6 +393,7 @@ const CardPaymentModalComponent = ({
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{paymentLink && (
|
||||
<Space
|
||||
style={{ cursor: "pointer", float: "right" }}
|
||||
@@ -345,6 +407,12 @@ const CardPaymentModalComponent = ({
|
||||
<CopyFilled />
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{queryError ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<span style={{ color: "red" }}>{queryError.message}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</Spin>
|
||||
</Card>
|
||||
);
|
||||
@@ -352,20 +420,20 @@ const CardPaymentModalComponent = ({
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(CardPaymentModalComponent);
|
||||
|
||||
//Poll for window.IntelliPay.fixAmount for 5 seconds. If it doesn't come up, just try anyways to force the possible error.
|
||||
// Poll for window.IntelliPay.fixAmount for 5 seconds. If it doesn't come up, just try anyways to force the possible error.
|
||||
function pollForIntelliPay(callbackFunction) {
|
||||
const timeout = 5000;
|
||||
const interval = 150; // Poll every 100 milliseconds
|
||||
const interval = 150;
|
||||
const startTime = Date.now();
|
||||
|
||||
function checkFixAmount() {
|
||||
if (window.intellipay && window.intellipay.fixAmount !== undefined) {
|
||||
if (window.intellipay?.fixAmount) {
|
||||
callbackFunction();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() - startTime >= timeout) {
|
||||
console.log("Stopped polling IntelliPay after 10 seconds. Attemping to set functions anyways.");
|
||||
console.log("Stopped polling IntelliPay after 5 seconds. Attempting to set functions anyways.");
|
||||
callbackFunction();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import { Button, Modal } from "antd";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { toggleModalVisible } from "../../redux/modals/modals.actions";
|
||||
import { selectCardPayment } from "../../redux/modals/modals.selectors";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import CardPaymentModalComponent from "./card-payment-modal.component";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
cardPaymentModal: selectCardPayment,
|
||||
bodyshop: selectBodyshop
|
||||
cardPaymentModal: selectCardPayment
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
toggleModalVisible: () => dispatch(toggleModalVisible("cardPayment"))
|
||||
});
|
||||
|
||||
function CardPaymentModalContainer({ cardPaymentModal, toggleModalVisible, bodyshop }) {
|
||||
function CardPaymentModalContainer({ cardPaymentModal, toggleModalVisible }) {
|
||||
const { open } = cardPaymentModal;
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -40,7 +37,7 @@ function CardPaymentModalContainer({ cardPaymentModal, toggleModalVisible, bodys
|
||||
</Button>
|
||||
]}
|
||||
width="80%"
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<CardPaymentModalComponent />
|
||||
</Modal>
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import { useApolloClient } from "@apollo/client";
|
||||
import { useApolloClient } from "@apollo/client/react";
|
||||
import { getToken } from "@firebase/messaging";
|
||||
import axios from "axios";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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";
|
||||
import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||
|
||||
export function ChatAffixContainer({ bodyshop, chatVisible }) {
|
||||
const { t } = useTranslation();
|
||||
export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) {
|
||||
const client = useApolloClient();
|
||||
const { socket } = useSocket();
|
||||
|
||||
useEffect(() => {
|
||||
if (!bodyshop || !bodyshop.messagingservicesid) return;
|
||||
const messagingServicesId = bodyshop?.messagingservicesid;
|
||||
const bodyshopId = bodyshop?.id;
|
||||
const imexshopid = bodyshop?.imexshopid;
|
||||
|
||||
async function SubscribeToTopicForFCMNotification() {
|
||||
const messagingEnabled = Boolean(messagingServicesId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!messagingEnabled) return;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await requestForToken();
|
||||
await axios.post("/notifications/subscribe", {
|
||||
@@ -25,32 +29,42 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) {
|
||||
vapidKey: import.meta.env.VITE_APP_FIREBASE_PUBLIC_VAPID_KEY
|
||||
}),
|
||||
type: "messaging",
|
||||
imexshopid: bodyshop.imexshopid
|
||||
imexshopid
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error attempting to subscribe to messaging topic: ", error);
|
||||
}
|
||||
})();
|
||||
}, [messagingEnabled, imexshopid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
if (!messagingEnabled) return;
|
||||
if (!bodyshopId) return;
|
||||
|
||||
if (!socket.connected) {
|
||||
unregisterMessagingHandlers({ socket });
|
||||
return;
|
||||
}
|
||||
|
||||
SubscribeToTopicForFCMNotification();
|
||||
// Prevent duplicate listeners if this effect runs more than once.
|
||||
unregisterMessagingHandlers({ socket });
|
||||
|
||||
//Register WS handlers
|
||||
if (socket && socket.connected) {
|
||||
registerMessagingHandlers({ socket, client });
|
||||
}
|
||||
registerMessagingHandlers({
|
||||
socket,
|
||||
client,
|
||||
currentUser,
|
||||
bodyshop
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (socket && socket.connected) {
|
||||
unregisterMessagingHandlers({ socket });
|
||||
}
|
||||
};
|
||||
}, [bodyshop, socket, t, client]);
|
||||
return () => unregisterMessagingHandlers({ socket });
|
||||
}, [socket, messagingEnabled, bodyshopId, client, currentUser?.email, bodyshop]);
|
||||
|
||||
if (!bodyshop || !bodyshop.messagingservicesid) return <></>;
|
||||
if (!messagingEnabled) return null;
|
||||
|
||||
return (
|
||||
<div className={`chat-affix ${chatVisible ? "chat-affix-open" : ""}`}>
|
||||
{bodyshop && bodyshop.messagingservicesid ? <ChatPopupComponent /> : null}
|
||||
{messagingEnabled ? <ChatPopupComponent /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { CONVERSATION_LIST_QUERY, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
|
||||
import { gql } from "@apollo/client";
|
||||
|
||||
import { playNewMessageSound } from "../../utils/soundManager.js";
|
||||
import { isLeaderTab } from "../../utils/singleTabAudioLeader";
|
||||
|
||||
import { CONVERSATION_LIST_QUERY, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
|
||||
import { QUERY_ACTIVE_ASSOCIATION_SOUND } from "../../graphql/user.queries";
|
||||
|
||||
const logLocal = (message, ...args) => {
|
||||
if (import.meta.env.VITE_APP_IS_TEST || !import.meta.env.PROD) {
|
||||
console.log(`==================== ${message} ====================`);
|
||||
@@ -8,36 +13,241 @@ const logLocal = (message, ...args) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Utility function to enrich conversation data
|
||||
const enrichConversation = (conversation, isOutbound) => ({
|
||||
const safeIsoNow = () => new Date().toISOString();
|
||||
const isSystemMsid = (msid) => typeof msid === "string" && msid.startsWith("SYS_");
|
||||
|
||||
const normalizeConversationForList = (raw, { isoutbound, isSystem } = {}) => {
|
||||
const c = raw || {};
|
||||
const id = c.id;
|
||||
|
||||
return {
|
||||
__typename: "conversations",
|
||||
id,
|
||||
phone_num: c.phone_num ?? null,
|
||||
updated_at: c.updated_at ?? safeIsoNow(),
|
||||
unreadcnt: typeof c.unreadcnt === "number" ? c.unreadcnt : 0,
|
||||
archived: c.archived ?? false,
|
||||
label: c.label ?? null,
|
||||
|
||||
job_conversations: Array.isArray(c.job_conversations)
|
||||
? c.job_conversations.map((jc) => {
|
||||
const job = jc?.job || {};
|
||||
const jobId = jc?.jobid ?? job?.id ?? null;
|
||||
|
||||
return {
|
||||
__typename: "job_conversations",
|
||||
jobid: jobId,
|
||||
conversationid: jc?.conversationid ?? id ?? null,
|
||||
job: {
|
||||
__typename: "jobs",
|
||||
id: jobId,
|
||||
ro_number: job?.ro_number ?? null,
|
||||
ownr_fn: job?.ownr_fn ?? null,
|
||||
ownr_ln: job?.ownr_ln ?? null,
|
||||
ownr_co_nm: job?.ownr_co_nm ?? null
|
||||
}
|
||||
};
|
||||
})
|
||||
: [],
|
||||
|
||||
messages_aggregate: c.messages_aggregate || {
|
||||
__typename: "messages_aggregate",
|
||||
aggregate: {
|
||||
__typename: "messages_aggregate_fields",
|
||||
count: isoutbound || isSystem ? 0 : 1
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const CONVERSATION_LIST_ITEM_FRAGMENT = gql`
|
||||
fragment _ConversationListItem on conversations {
|
||||
id
|
||||
phone_num
|
||||
updated_at
|
||||
unreadcnt
|
||||
archived
|
||||
label
|
||||
job_conversations {
|
||||
jobid
|
||||
conversationid
|
||||
job {
|
||||
id
|
||||
ro_number
|
||||
ownr_fn
|
||||
ownr_ln
|
||||
ownr_co_nm
|
||||
}
|
||||
}
|
||||
messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false }, is_system: { _eq: false } }) {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const normalizeMessageForCache = (raw, fallbackConversationId) => {
|
||||
const m = raw || {};
|
||||
|
||||
return {
|
||||
__typename: "messages",
|
||||
id: m.id,
|
||||
conversationid: m.conversationid ?? m.conversation?.id ?? fallbackConversationId,
|
||||
|
||||
status: m.status ?? null,
|
||||
text: m.text ?? "",
|
||||
is_system: typeof m.is_system === "boolean" ? m.is_system : false,
|
||||
isoutbound: typeof m.isoutbound === "boolean" ? m.isoutbound : false,
|
||||
image: typeof m.image === "boolean" ? m.image : false,
|
||||
image_path: m.image_path ?? null,
|
||||
userid: m.userid ?? null,
|
||||
created_at: m.created_at ?? safeIsoNow(),
|
||||
read: typeof m.read === "boolean" ? m.read : false
|
||||
};
|
||||
};
|
||||
|
||||
const isConversationDetailsCached = (client, conversationId) => {
|
||||
try {
|
||||
return !!client.cache.readQuery({
|
||||
query: GET_CONVERSATION_DETAILS,
|
||||
variables: { conversationId }
|
||||
})?.conversations_by_pk;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const conversationDetailsCached = (client, conversationId) => {
|
||||
try {
|
||||
const res = client.cache.readQuery({
|
||||
query: GET_CONVERSATION_DETAILS,
|
||||
variables: { conversationId }
|
||||
});
|
||||
return !!res?.conversations_by_pk;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const messageEntityCached = (client, messageId) => {
|
||||
const cacheId = client.cache.identify({ __typename: "messages", id: messageId });
|
||||
if (!cacheId) return false;
|
||||
|
||||
try {
|
||||
client.cache.readFragment({
|
||||
id: cacheId,
|
||||
fragment: gql`
|
||||
fragment _MsgExists on messages {
|
||||
id
|
||||
}
|
||||
`
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const enrichConversation = (conversation, { isoutbound, isSystem }) => ({
|
||||
...conversation,
|
||||
updated_at: conversation.updated_at || new Date().toISOString(),
|
||||
unreadcnt: conversation.unreadcnt || 0,
|
||||
archived: conversation.archived || false,
|
||||
label: conversation.label || null,
|
||||
updated_at: conversation.updated_at || safeIsoNow(),
|
||||
unreadcnt: typeof conversation.unreadcnt === "number" ? conversation.unreadcnt : 0,
|
||||
archived: conversation.archived ?? false,
|
||||
label: conversation.label ?? null,
|
||||
job_conversations: conversation.job_conversations || [],
|
||||
messages_aggregate: conversation.messages_aggregate || {
|
||||
__typename: "messages_aggregate",
|
||||
aggregate: {
|
||||
__typename: "messages_aggregate_fields",
|
||||
count: isOutbound ? 0 : 1
|
||||
count: isoutbound || isSystem ? 0 : 1
|
||||
}
|
||||
},
|
||||
__typename: "conversations"
|
||||
});
|
||||
|
||||
export const registerMessagingHandlers = ({ socket, client }) => {
|
||||
const upsertConversationIntoOffsetZeroList = (client, conversationObj, { isoutbound, isSystem } = {}) => {
|
||||
const normalized = normalizeConversationForList(conversationObj, { isoutbound, isSystem });
|
||||
if (!normalized?.id) return;
|
||||
|
||||
const convCacheId = client.cache.identify(normalized);
|
||||
if (!convCacheId) return;
|
||||
|
||||
client.cache.writeFragment({
|
||||
id: convCacheId,
|
||||
fragment: CONVERSATION_LIST_ITEM_FRAGMENT,
|
||||
data: normalized
|
||||
});
|
||||
|
||||
client.cache.modify({
|
||||
id: "ROOT_QUERY",
|
||||
fields: {
|
||||
conversations(existing = [], { args, readField }) {
|
||||
if (!args || args.offset !== 0) return existing;
|
||||
|
||||
const archivedEq = args?.where?.archived?._eq;
|
||||
if (archivedEq === true) return existing;
|
||||
|
||||
const without = existing.filter((c) => readField("id", c) !== normalized.id);
|
||||
return [{ __ref: convCacheId }, ...without];
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const registerMessagingHandlers = ({ socket, client, currentUser, bodyshop }) => {
|
||||
if (!(socket && client)) return;
|
||||
|
||||
// Coalesce unread refetches (avoid spamming during bursts)
|
||||
let unreadRefetchInFlight = null;
|
||||
const refetchUnreadCount = () => {
|
||||
if (unreadRefetchInFlight) return;
|
||||
|
||||
unreadRefetchInFlight = client
|
||||
.refetchQueries({
|
||||
include: ["UNREAD_CONVERSATION_COUNT"]
|
||||
})
|
||||
.catch(() => {
|
||||
// best-effort
|
||||
})
|
||||
.finally(() => {
|
||||
unreadRefetchInFlight = null;
|
||||
});
|
||||
};
|
||||
|
||||
const handleNewMessageSummary = async (message) => {
|
||||
const { conversationId, newConversation, existingConversation, isoutbound } = message;
|
||||
const { conversationId, newConversation, existingConversation, isoutbound, msid, updated_at } = message;
|
||||
const isSystem = isSystemMsid(msid);
|
||||
|
||||
const isNewMessageSoundEnabled = (clientInstance) => {
|
||||
try {
|
||||
const email = currentUser?.email;
|
||||
if (!email) return true;
|
||||
const res = clientInstance.readQuery({
|
||||
query: QUERY_ACTIVE_ASSOCIATION_SOUND,
|
||||
variables: { email }
|
||||
});
|
||||
const flag = res?.associations?.[0]?.new_message_sound;
|
||||
return flag === true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
logLocal("handleNewMessageSummary - Start", { message, isNew: !existingConversation });
|
||||
|
||||
const queryVariables = { offset: 0 };
|
||||
if (!isoutbound) {
|
||||
if (isLeaderTab(bodyshop.id) && isNewMessageSoundEnabled(client)) {
|
||||
playNewMessageSound(bodyshop.id);
|
||||
}
|
||||
|
||||
// Real-time badge update for affix (best-effort, coalesced)
|
||||
if (!isSystem) {
|
||||
refetchUnreadCount();
|
||||
}
|
||||
}
|
||||
|
||||
if (!existingConversation && conversationId) {
|
||||
// Attempt to read from the cache to determine if this is actually a new conversation
|
||||
try {
|
||||
const cachedConversation = client.cache.readFragment({
|
||||
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
|
||||
@@ -49,75 +259,54 @@ export const registerMessagingHandlers = ({ socket, client }) => {
|
||||
});
|
||||
|
||||
if (cachedConversation) {
|
||||
logLocal("handleNewMessageSummary - Existing Conversation inferred from cache", {
|
||||
conversationId
|
||||
});
|
||||
return handleNewMessageSummary({
|
||||
...message,
|
||||
existingConversation: true
|
||||
});
|
||||
logLocal("handleNewMessageSummary - Existing Conversation inferred from cache", { conversationId });
|
||||
return handleNewMessageSummary({ ...message, existingConversation: true });
|
||||
}
|
||||
} catch (error) {
|
||||
logLocal("handleNewMessageSummary - Cache miss", { conversationId });
|
||||
} catch {
|
||||
// Cache miss is normal
|
||||
}
|
||||
}
|
||||
|
||||
// Handle new conversation
|
||||
if (!existingConversation && newConversation?.phone_num) {
|
||||
logLocal("handleNewMessageSummary - New Conversation", newConversation);
|
||||
|
||||
try {
|
||||
const queryResults = client.cache.readQuery({
|
||||
query: CONVERSATION_LIST_QUERY,
|
||||
variables: queryVariables
|
||||
});
|
||||
|
||||
const existingConversations = queryResults?.conversations || [];
|
||||
const enrichedConversation = enrichConversation(newConversation, isoutbound);
|
||||
|
||||
if (!existingConversations.some((conv) => conv.id === enrichedConversation.id)) {
|
||||
client.cache.modify({
|
||||
id: "ROOT_QUERY",
|
||||
fields: {
|
||||
conversations(existingConversations = []) {
|
||||
return [enrichedConversation, ...existingConversations];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
upsertConversationIntoOffsetZeroList(client, newConversation, { isoutbound, isSystem });
|
||||
} catch (error) {
|
||||
console.error("Error updating cache for new conversation:", error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle existing conversation
|
||||
if (existingConversation) {
|
||||
if (existingConversation && conversationId) {
|
||||
try {
|
||||
client.cache.modify({
|
||||
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
|
||||
fields: {
|
||||
updated_at: () => new Date().toISOString(),
|
||||
updated_at: () => updated_at || safeIsoNow(),
|
||||
archived: () => false,
|
||||
messages_aggregate(cached = { aggregate: { count: 0 } }) {
|
||||
const currentCount = cached.aggregate?.count || 0;
|
||||
if (!isoutbound) {
|
||||
return {
|
||||
__typename: "messages_aggregate",
|
||||
aggregate: {
|
||||
__typename: "messages_aggregate_fields",
|
||||
count: currentCount + 1
|
||||
}
|
||||
};
|
||||
}
|
||||
return cached;
|
||||
|
||||
messages_aggregate(cached = null) {
|
||||
if (isoutbound || isSystem) return cached;
|
||||
|
||||
const currentCount = cached?.aggregate?.count ?? 0;
|
||||
return {
|
||||
__typename: "messages_aggregate",
|
||||
aggregate: {
|
||||
__typename: "messages_aggregate_fields",
|
||||
count: currentCount + 1
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
unreadcnt(cached) {
|
||||
if (isoutbound || isSystem) return cached;
|
||||
const n = typeof cached === "number" ? cached : 0;
|
||||
return n + 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating cache for existing conversation:", error);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -129,90 +318,78 @@ export const registerMessagingHandlers = ({ socket, client }) => {
|
||||
|
||||
logLocal("handleNewMessageDetailed - Start", message);
|
||||
|
||||
try {
|
||||
// Check if the conversation exists in the cache
|
||||
const queryResults = client.cache.readQuery({
|
||||
query: GET_CONVERSATION_DETAILS,
|
||||
variables: { conversationId }
|
||||
});
|
||||
if (!conversationId || !isConversationDetailsCached(client, conversationId)) return;
|
||||
|
||||
if (!queryResults?.conversations_by_pk) {
|
||||
console.warn("Conversation not found in cache:", { conversationId });
|
||||
try {
|
||||
const normalized = normalizeMessageForCache(newMessage, conversationId);
|
||||
|
||||
const messageCacheId = client.cache.identify(normalized);
|
||||
if (!messageCacheId) {
|
||||
console.warn("handleNewMessageDetailed - Could not identify message for cache", {
|
||||
conversationId,
|
||||
newMessageId: newMessage?.id
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Append the new message to the conversation's message list using cache.modify
|
||||
client.cache.writeFragment({
|
||||
id: messageCacheId,
|
||||
fragment: gql`
|
||||
fragment _IncomingMessage on messages {
|
||||
id
|
||||
conversationid
|
||||
status
|
||||
text
|
||||
is_system
|
||||
isoutbound
|
||||
image
|
||||
image_path
|
||||
userid
|
||||
created_at
|
||||
read
|
||||
}
|
||||
`,
|
||||
data: normalized
|
||||
});
|
||||
|
||||
client.cache.modify({
|
||||
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
|
||||
fields: {
|
||||
messages(existingMessages = []) {
|
||||
return [...existingMessages, newMessage];
|
||||
messages(existing = [], { readField }) {
|
||||
const already = existing.some((ref) => readField("id", ref) === normalized.id);
|
||||
if (already) return existing;
|
||||
return [...existing, { __ref: messageCacheId }];
|
||||
},
|
||||
updated_at() {
|
||||
return normalized.created_at || safeIsoNow();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
logLocal("handleNewMessageDetailed - Message appended successfully", {
|
||||
conversationId,
|
||||
newMessage
|
||||
});
|
||||
logLocal("handleNewMessageDetailed - Message appended successfully", { conversationId });
|
||||
} catch (error) {
|
||||
console.error("Error updating conversation messages in cache:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessageChanged = (message) => {
|
||||
if (!message) {
|
||||
logLocal("handleMessageChanged - No message provided", message);
|
||||
return;
|
||||
}
|
||||
if (!message?.id) return;
|
||||
|
||||
logLocal("handleMessageChanged - Start", message);
|
||||
|
||||
if (!messageEntityCached(client, message.id)) return;
|
||||
|
||||
try {
|
||||
const msgCacheId = client.cache.identify({ __typename: "messages", id: message.id });
|
||||
|
||||
client.cache.modify({
|
||||
id: client.cache.identify({ __typename: "conversations", id: message.conversationid }),
|
||||
id: msgCacheId,
|
||||
fields: {
|
||||
messages(existingMessages = [], { readField }) {
|
||||
return existingMessages.map((messageRef) => {
|
||||
// Check if this is the message to update
|
||||
if (readField("id", messageRef) === message.id) {
|
||||
const currentStatus = readField("status", messageRef);
|
||||
|
||||
// Handle known types of message changes
|
||||
switch (message.type) {
|
||||
case "status-changed":
|
||||
// Prevent overwriting if the current status is already "delivered"
|
||||
if (currentStatus === "delivered") {
|
||||
logLocal("handleMessageChanged - Status already delivered, skipping update", {
|
||||
messageId: message.id
|
||||
});
|
||||
return messageRef;
|
||||
}
|
||||
|
||||
// Update the status field
|
||||
return {
|
||||
...messageRef,
|
||||
status: message.status
|
||||
};
|
||||
|
||||
case "text-updated":
|
||||
// Handle changes to the message text
|
||||
return {
|
||||
...messageRef,
|
||||
text: message.text
|
||||
};
|
||||
|
||||
// Add cases for other known message types as needed
|
||||
|
||||
default:
|
||||
// Log a warning for unhandled message types
|
||||
logLocal("handleMessageChanged - Unhandled message type", { type: message.type });
|
||||
return messageRef;
|
||||
}
|
||||
}
|
||||
|
||||
return messageRef; // Keep other messages unchanged
|
||||
});
|
||||
status(existing) {
|
||||
return message.type === "status-changed" ? (message.status ?? existing) : existing;
|
||||
},
|
||||
text(existing) {
|
||||
return message.type === "text-updated" ? (message.text ?? existing) : existing;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -227,202 +404,246 @@ export const registerMessagingHandlers = ({ socket, client }) => {
|
||||
};
|
||||
|
||||
const handleConversationChanged = async (data) => {
|
||||
if (!data) {
|
||||
logLocal("handleConversationChanged - No data provided", data);
|
||||
return;
|
||||
}
|
||||
if (!data?.conversationId) return;
|
||||
|
||||
const {
|
||||
conversationId,
|
||||
type,
|
||||
job_conversations,
|
||||
messageIds,
|
||||
messageIdsMarkedRead,
|
||||
lastUnreadMessageId,
|
||||
unreadCount,
|
||||
...fields
|
||||
} = data;
|
||||
|
||||
const { conversationId, type, job_conversations, messageIds, ...fields } = data;
|
||||
logLocal("handleConversationChanged - Start", data);
|
||||
|
||||
const updatedAt = new Date().toISOString();
|
||||
const updatedAt = safeIsoNow();
|
||||
const detailsCached = conversationDetailsCached(client, conversationId);
|
||||
|
||||
const updateConversationList = (newConversation) => {
|
||||
try {
|
||||
const existingList = client.cache.readQuery({
|
||||
query: CONVERSATION_LIST_QUERY,
|
||||
variables: { offset: 0 }
|
||||
});
|
||||
|
||||
const updatedList = existingList?.conversations
|
||||
? [
|
||||
newConversation,
|
||||
...existingList.conversations.filter((conv) => conv.id !== newConversation.id) // Prevent duplicates
|
||||
]
|
||||
: [newConversation];
|
||||
|
||||
client.cache.writeQuery({
|
||||
query: CONVERSATION_LIST_QUERY,
|
||||
variables: { offset: 0 },
|
||||
data: {
|
||||
conversations: updatedList
|
||||
}
|
||||
});
|
||||
|
||||
logLocal("handleConversationChanged - Conversation list updated successfully", newConversation);
|
||||
} catch (error) {
|
||||
console.error("Error updating conversation list in the cache:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle specific types
|
||||
try {
|
||||
switch (type) {
|
||||
case "conversation-marked-read":
|
||||
if (conversationId && messageIds?.length > 0) {
|
||||
client.cache.modify({
|
||||
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
|
||||
fields: {
|
||||
messages(existingMessages = [], { readField }) {
|
||||
return existingMessages.map((message) => {
|
||||
if (messageIds.includes(readField("id", message))) {
|
||||
return { ...message, read: true };
|
||||
}
|
||||
return message;
|
||||
});
|
||||
},
|
||||
messages_aggregate: () => ({
|
||||
__typename: "messages_aggregate",
|
||||
aggregate: { __typename: "messages_aggregate_fields", count: 0 }
|
||||
})
|
||||
}
|
||||
case "conversation-marked-read": {
|
||||
refetchUnreadCount();
|
||||
|
||||
if (detailsCached && Array.isArray(messageIds)) {
|
||||
messageIds.forEach((id) => {
|
||||
if (!messageEntityCached(client, id)) return;
|
||||
client.cache.modify({
|
||||
id: client.cache.identify({ __typename: "messages", id }),
|
||||
fields: { read: () => true }
|
||||
});
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "conversation-created":
|
||||
updateConversationList({ ...fields, job_conversations, updated_at: updatedAt });
|
||||
client.cache.modify({
|
||||
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
|
||||
fields: {
|
||||
messages_aggregate: () => ({
|
||||
__typename: "messages_aggregate",
|
||||
aggregate: { __typename: "messages_aggregate_fields", count: 0 }
|
||||
}),
|
||||
unreadcnt: () => 0,
|
||||
updated_at: () => updatedAt
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "conversation-marked-unread": {
|
||||
refetchUnreadCount();
|
||||
|
||||
const safeUnreadCount = typeof unreadCount === "number" ? unreadCount : 1;
|
||||
const idsMarkedRead = Array.isArray(messageIdsMarkedRead) ? messageIdsMarkedRead : [];
|
||||
|
||||
if (detailsCached) {
|
||||
idsMarkedRead.forEach((id) => {
|
||||
if (!messageEntityCached(client, id)) return;
|
||||
client.cache.modify({
|
||||
id: client.cache.identify({ __typename: "messages", id }),
|
||||
fields: { read: () => true }
|
||||
});
|
||||
});
|
||||
|
||||
if (lastUnreadMessageId && messageEntityCached(client, lastUnreadMessageId)) {
|
||||
client.cache.modify({
|
||||
id: client.cache.identify({ __typename: "messages", id: lastUnreadMessageId }),
|
||||
fields: { read: () => false }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
client.cache.modify({
|
||||
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
|
||||
fields: {
|
||||
updated_at: () => updatedAt,
|
||||
messages_aggregate: () => ({
|
||||
__typename: "messages_aggregate",
|
||||
aggregate: {
|
||||
__typename: "messages_aggregate_fields",
|
||||
count: safeUnreadCount
|
||||
}
|
||||
}),
|
||||
unreadcnt: () => safeUnreadCount
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "conversation-created": {
|
||||
// New conversation likely implies new unread inbound message(s)
|
||||
refetchUnreadCount();
|
||||
|
||||
const conv = enrichConversation(
|
||||
{ id: conversationId, job_conversations, ...fields, updated_at: updatedAt },
|
||||
{ isoutbound: false, isSystem: false }
|
||||
);
|
||||
upsertConversationIntoOffsetZeroList(client, conv);
|
||||
break;
|
||||
}
|
||||
|
||||
case "conversation-unarchived":
|
||||
case "conversation-archived":
|
||||
// Would like to someday figure out how to get this working without refetch queries,
|
||||
// But I have but a solid 4 hours into it, and there are just too many weird occurrences
|
||||
try {
|
||||
const listQueryVariables = { offset: 0 };
|
||||
const detailsQueryVariables = { conversationId };
|
||||
case "conversation-archived": {
|
||||
// Keep unread badge correct even if archiving affects counts
|
||||
refetchUnreadCount();
|
||||
|
||||
// Check if conversation details exist in the cache
|
||||
const detailsExist = !!client.cache.readQuery({
|
||||
query: GET_CONVERSATION_DETAILS,
|
||||
variables: detailsQueryVariables
|
||||
});
|
||||
|
||||
// Refetch conversation list
|
||||
await client.refetchQueries({
|
||||
include: [CONVERSATION_LIST_QUERY, ...(detailsExist ? [GET_CONVERSATION_DETAILS] : [])],
|
||||
variables: [
|
||||
{ query: CONVERSATION_LIST_QUERY, variables: listQueryVariables },
|
||||
...(detailsExist
|
||||
? [
|
||||
{
|
||||
query: GET_CONVERSATION_DETAILS,
|
||||
variables: detailsQueryVariables
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
});
|
||||
|
||||
logLocal("handleConversationChanged - Refetched queries after state change", {
|
||||
conversationId,
|
||||
type
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error refetching queries after conversation state change:", error);
|
||||
}
|
||||
await client.refetchQueries({
|
||||
include: [CONVERSATION_LIST_QUERY, GET_CONVERSATION_DETAILS]
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "tag-added":
|
||||
// Ensure `job_conversations` is properly formatted
|
||||
const formattedJobConversations = job_conversations.map((jc) => ({
|
||||
case "tag-added": {
|
||||
const formattedJobConversations = (job_conversations || []).map((jc) => ({
|
||||
__typename: "job_conversations",
|
||||
jobid: jc.jobid || jc.job?.id,
|
||||
conversationid: conversationId,
|
||||
job: jc.job || {
|
||||
job: {
|
||||
__typename: "jobs",
|
||||
id: data.selectedJob.id,
|
||||
ro_number: data.selectedJob.ro_number,
|
||||
ownr_co_nm: data.selectedJob.ownr_co_nm,
|
||||
ownr_fn: data.selectedJob.ownr_fn,
|
||||
ownr_ln: data.selectedJob.ownr_ln
|
||||
id: jc.job?.id,
|
||||
ro_number: jc.job?.ro_number,
|
||||
ownr_co_nm: jc.job?.ownr_co_nm,
|
||||
ownr_fn: jc.job?.ownr_fn,
|
||||
ownr_ln: jc.job?.ownr_ln
|
||||
}
|
||||
}));
|
||||
|
||||
client.cache.modify({
|
||||
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
|
||||
fields: {
|
||||
job_conversations: (existing = []) => {
|
||||
// Ensure no duplicates based on both `conversationid` and `jobid`
|
||||
const existingLinks = new Set(
|
||||
existing.map((jc) => {
|
||||
const jobId = client.cache.readFragment({
|
||||
id: client.cache.identify(jc),
|
||||
fragment: gql`
|
||||
fragment JobConversationLinkAdded on job_conversations {
|
||||
jobid
|
||||
conversationid
|
||||
}
|
||||
`
|
||||
})?.jobid;
|
||||
return `${jobId}:${conversationId}`; // Unique identifier for a job-conversation link
|
||||
})
|
||||
);
|
||||
|
||||
const newItems = formattedJobConversations.filter((jc) => {
|
||||
const uniqueLink = `${jc.jobid}:${jc.conversationid}`;
|
||||
return !existingLinks.has(uniqueLink);
|
||||
});
|
||||
|
||||
return [...existing, ...newItems];
|
||||
job_conversations(existing = [], { readField }) {
|
||||
const seen = new Set(existing.map((x) => readField("jobid", x)).filter(Boolean));
|
||||
const incoming = formattedJobConversations.filter((x) => x.jobid && !seen.has(x.jobid));
|
||||
return [...existing, ...incoming];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "tag-removed":
|
||||
try {
|
||||
const conversationCacheId = client.cache.identify({ __typename: "conversations", id: conversationId });
|
||||
|
||||
// Evict the specific cache entry for job_conversations
|
||||
client.cache.evict({
|
||||
id: conversationCacheId,
|
||||
fieldName: "job_conversations"
|
||||
});
|
||||
|
||||
// Garbage collect evicted entries
|
||||
client.cache.gc();
|
||||
|
||||
logLocal("handleConversationChanged - tag removed - Refetched conversation list after state change", {
|
||||
conversationId,
|
||||
type
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error refetching queries after conversation state change: (Tag Removed)", error);
|
||||
}
|
||||
case "tag-removed": {
|
||||
const conversationCacheId = client.cache.identify({ __typename: "conversations", id: conversationId });
|
||||
|
||||
client.cache.evict({
|
||||
id: conversationCacheId,
|
||||
fieldName: "job_conversations"
|
||||
});
|
||||
client.cache.gc();
|
||||
break;
|
||||
default:
|
||||
logLocal("handleConversationChanged - Unhandled type", { type });
|
||||
}
|
||||
|
||||
default: {
|
||||
client.cache.modify({
|
||||
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
|
||||
fields: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(fields).map(([key, value]) => [key, (cached) => (value !== undefined ? value : cached)])
|
||||
)
|
||||
}
|
||||
fields: Object.fromEntries(
|
||||
Object.entries(fields).map(([key, value]) => [key, (cached) => (value !== undefined ? value : cached)])
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling conversation changes:", { type, error });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhoneNumberOptedOut = async (data) => {
|
||||
const { bodyshopid, phone_number } = data;
|
||||
logLocal("handlePhoneNumberOptedOut - Start", data);
|
||||
|
||||
try {
|
||||
client.cache.modify({
|
||||
id: "ROOT_QUERY",
|
||||
fields: {
|
||||
phone_number_opt_out(existing = [], { readField }) {
|
||||
const exists = existing.some(
|
||||
(ref) => readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid
|
||||
);
|
||||
if (exists) return existing;
|
||||
|
||||
const newOptOut = {
|
||||
__typename: "phone_number_opt_out",
|
||||
id: `temporary-${phone_number}-${Date.now()}`,
|
||||
bodyshopid,
|
||||
phone_number,
|
||||
created_at: safeIsoNow(),
|
||||
updated_at: safeIsoNow()
|
||||
};
|
||||
|
||||
return [...existing, newOptOut];
|
||||
}
|
||||
},
|
||||
broadcast: true
|
||||
});
|
||||
|
||||
client.cache.evict({
|
||||
id: "ROOT_QUERY",
|
||||
fieldName: "phone_number_opt_out",
|
||||
args: { bodyshopid, search: phone_number }
|
||||
});
|
||||
client.cache.gc();
|
||||
} catch (error) {
|
||||
console.error("Error updating cache for phone number opt-out:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhoneNumberOptedIn = async (data) => {
|
||||
const { bodyshopid, phone_number } = data;
|
||||
logLocal("handlePhoneNumberOptedIn - Start", data);
|
||||
|
||||
try {
|
||||
client.cache.modify({
|
||||
id: "ROOT_QUERY",
|
||||
fields: {
|
||||
phone_number_opt_out(existing = [], { readField }) {
|
||||
return existing.filter(
|
||||
(ref) => !(readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid)
|
||||
);
|
||||
}
|
||||
},
|
||||
broadcast: true
|
||||
});
|
||||
|
||||
client.cache.evict({
|
||||
id: "ROOT_QUERY",
|
||||
fieldName: "phone_number_opt_out",
|
||||
args: { bodyshopid, search: phone_number }
|
||||
});
|
||||
client.cache.gc();
|
||||
} catch (error) {
|
||||
console.error("Error updating cache for phone number opt-in:", error);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on("new-message-summary", handleNewMessageSummary);
|
||||
socket.on("new-message-detailed", handleNewMessageDetailed);
|
||||
socket.on("message-changed", handleMessageChanged);
|
||||
socket.on("conversation-changed", handleConversationChanged);
|
||||
socket.on("phone-number-opted-out", handlePhoneNumberOptedOut);
|
||||
socket.on("phone-number-opted-in", handlePhoneNumberOptedIn);
|
||||
};
|
||||
|
||||
export const unregisterMessagingHandlers = ({ socket }) => {
|
||||
@@ -431,4 +652,6 @@ export const unregisterMessagingHandlers = ({ socket }) => {
|
||||
socket.off("new-message-detailed");
|
||||
socket.off("message-changed");
|
||||
socket.off("conversation-changed");
|
||||
socket.off("phone-number-opted-out");
|
||||
socket.off("phone-number-opted-in");
|
||||
};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { useMutation } from "@apollo/client/react";
|
||||
import { Button } from "antd";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOGGLE_CONVERSATION_ARCHIVE } from "../../graphql/conversations.queries";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||
import { connect } from "react-redux";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Badge, Card, List, Space, Tag } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Badge, Card, List, Space, Tag, Tooltip } from "antd";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -9,49 +9,93 @@ import { TimeAgoFormatter } from "../../utils/DateFormatter";
|
||||
import PhoneFormatter from "../../utils/PhoneFormatter";
|
||||
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
|
||||
import _ from "lodash";
|
||||
import { ExclamationCircleOutlined } from "@ant-design/icons";
|
||||
import "./chat-conversation-list.styles.scss";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { GET_PHONE_NUMBER_OPT_OUTS_BY_NUMBERS } from "../../graphql/phone-number-opt-out.queries.js";
|
||||
import { phone } from "phone";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
selectedConversation: selectSelectedConversation
|
||||
selectedConversation: selectSelectedConversation,
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
setSelectedConversation: (conversationId) => dispatch(setSelectedConversation(conversationId))
|
||||
});
|
||||
|
||||
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation }) {
|
||||
// That comma is there for a reason, do not remove it
|
||||
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation, bodyshop }) {
|
||||
const { t } = useTranslation();
|
||||
const [, forceUpdate] = useState(false);
|
||||
|
||||
// Re-render every minute
|
||||
const phoneNumbers = useMemo(() => {
|
||||
return (conversationList || [])
|
||||
.map((item) => {
|
||||
try {
|
||||
const p = phone(item.phone_num, "CA")?.phoneNumber;
|
||||
return p ? p.replace(/^\+1/, "") : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
}, [conversationList]);
|
||||
|
||||
const { data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUTS_BY_NUMBERS, {
|
||||
variables: {
|
||||
bodyshopid: bodyshop?.id,
|
||||
phone_numbers: phoneNumbers
|
||||
},
|
||||
skip: !bodyshop?.id || phoneNumbers.length === 0,
|
||||
fetchPolicy: "cache-and-network"
|
||||
});
|
||||
|
||||
const optOutMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
optOutData?.phone_number_opt_out?.forEach((optOut) => {
|
||||
map.set(optOut.phone_number, true);
|
||||
});
|
||||
return map;
|
||||
}, [optOutData?.phone_number_opt_out]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
forceUpdate((prev) => !prev); // Toggle state to trigger re-render
|
||||
}, 60000); // 1 minute in milliseconds
|
||||
|
||||
return () => clearInterval(interval); // Cleanup on unmount
|
||||
forceUpdate((prev) => !prev);
|
||||
}, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Memoize the sorted conversation list
|
||||
const sortedConversationList = React.useMemo(() => {
|
||||
const sortedConversationList = useMemo(() => {
|
||||
return _.orderBy(conversationList, ["updated_at"], ["desc"]);
|
||||
}, [conversationList]);
|
||||
|
||||
const renderConversation = (index) => {
|
||||
const item = sortedConversationList[index];
|
||||
|
||||
const normalizedPhone = (() => {
|
||||
try {
|
||||
return phone(item.phone_num, "CA")?.phoneNumber?.replace(/^\+1/, "") || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
|
||||
const hasOptOutEntry = normalizedPhone ? optOutMap.has(normalizedPhone) : false;
|
||||
|
||||
const cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>;
|
||||
const cardContentLeft =
|
||||
item.job_conversations.length > 0
|
||||
? item.job_conversations.map((j, idx) => <Tag key={idx}>{j.job.ro_number}</Tag>)
|
||||
: null;
|
||||
|
||||
const names = <>{_.uniq(item.job_conversations.map((j, idx) => OwnerNameDisplayFunction(j.job)))}</>;
|
||||
|
||||
const names = <>{_.uniq(item.job_conversations.map((j) => OwnerNameDisplayFunction(j.job)))}</>;
|
||||
const cardTitle = (
|
||||
<>
|
||||
{item.label && <Tag color="blue">{item.label}</Tag>}
|
||||
{item.job_conversations.length > 0 ? (
|
||||
<Space direction="vertical">{names}</Space>
|
||||
<Space orientation="vertical">{names}</Space>
|
||||
) : (
|
||||
<Space>
|
||||
<PhoneFormatter>{item.phone_num}</PhoneFormatter>
|
||||
@@ -60,12 +104,23 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
|
||||
</>
|
||||
);
|
||||
|
||||
const cardExtra = <Badge count={item.messages_aggregate.aggregate.count} />;
|
||||
const cardExtra = (
|
||||
<>
|
||||
<Badge count={item.messages_aggregate?.aggregate?.count || 0} />
|
||||
{hasOptOutEntry && (
|
||||
<Tooltip title={t("consent.text_body")}>
|
||||
<Tag color="red" icon={<ExclamationCircleOutlined />}>
|
||||
{t("messaging.labels.no_consent")}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const getCardStyle = () =>
|
||||
item.id === selectedConversation
|
||||
? { backgroundColor: "rgba(128, 128, 128, 0.2)" }
|
||||
: { backgroundColor: index % 2 === 0 ? "#f0f2f5" : "#ffffff" };
|
||||
? { backgroundColor: "var(--card-selected-bg)" }
|
||||
: { backgroundColor: index % 2 === 0 ? "var(--card-stripe-even-bg)" : "var(--card-stripe-odd-bg)" };
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
@@ -73,7 +128,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
|
||||
onClick={() => setSelectedConversation(item.id)}
|
||||
className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`}
|
||||
>
|
||||
<Card style={getCardStyle()} bordered={false} size="small" extra={cardExtra} title={cardTitle}>
|
||||
<Card style={getCardStyle()} variant="outlined" size="small" extra={cardExtra} title={cardTitle}>
|
||||
<div style={{ display: "inline-block", width: "70%", textAlign: "left" }}>{cardContentLeft}</div>
|
||||
<div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div>
|
||||
</Card>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
/* Add spacing and better alignment for items */
|
||||
.chat-list-item {
|
||||
padding: 0.5rem 0; /* Add spacing between list items */
|
||||
padding: 0.2rem 0; /* Add spacing between list items */
|
||||
|
||||
.ant-card {
|
||||
border-radius: 8px; /* Slight rounding for card edges */
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { useMutation } from "@apollo/client/react";
|
||||
import { Tag } from "antd";
|
||||
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 { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||
import { connect } from "react-redux";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
@@ -21,7 +21,7 @@ export function ChatConversationTitleTags({ jobConversations, bodyshop }) {
|
||||
|
||||
const handleRemoveTag = async (jobId) => {
|
||||
const convId = jobConversations[0].conversationid;
|
||||
if (!!convId) {
|
||||
if (convId) {
|
||||
await removeJobConversation({
|
||||
variables: {
|
||||
conversationId: convId,
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
import { Space } from "antd";
|
||||
import React from "react";
|
||||
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
|
||||
import ChatArchiveButton from "../chat-archive-button/chat-archive-button.component";
|
||||
import ChatConversationTitleTags from "../chat-conversation-title-tags/chat-conversation-title-tags.component";
|
||||
import ChatLabelComponent from "../chat-label/chat-label.component";
|
||||
import ChatPrintButton from "../chat-print-button/chat-print-button.component";
|
||||
import ChatTagRoContainer from "../chat-tag-ro/chat-tag-ro.container";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { connect } from "react-redux";
|
||||
import ChatMarkUnreadButton from "../chat-mark-unread-button/chat-mark-unread-button.component";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({});
|
||||
|
||||
const mapDispatchToProps = () => ({});
|
||||
|
||||
export function ChatConversationTitle({ conversation }) {
|
||||
export function ChatConversationTitle({ conversation, onMarkUnread, markUnreadDisabled, markUnreadLoading }) {
|
||||
return (
|
||||
<Space className="chat-title" wrap>
|
||||
<PhoneNumberFormatter>{conversation && conversation.phone_num}</PhoneNumberFormatter>
|
||||
<PhoneNumberFormatter>{conversation?.phone_num}</PhoneNumberFormatter>
|
||||
|
||||
<ChatLabelComponent conversation={conversation} />
|
||||
<ChatPrintButton conversation={conversation} />
|
||||
<ChatConversationTitleTags jobConversations={(conversation && conversation.job_conversations) || []} />
|
||||
|
||||
<ChatConversationTitleTags jobConversations={conversation?.job_conversations || []} />
|
||||
<ChatTagRoContainer conversation={conversation || []} />
|
||||
|
||||
<ChatMarkUnreadButton disabled={markUnreadDisabled} loading={markUnreadLoading} onMarkUnread={onMarkUnread} />
|
||||
|
||||
<ChatArchiveButton conversation={conversation} />
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ChatConversationTitle);
|
||||
export default ChatConversationTitle;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import AlertComponent from "../alert/alert.component";
|
||||
import ChatConversationTitle from "../chat-conversation-title/chat-conversation-title.component";
|
||||
import ChatMessageListComponent from "../chat-messages-list/chat-message-list.component";
|
||||
@@ -20,13 +19,15 @@ export function ChatConversationComponent({
|
||||
conversation,
|
||||
messages,
|
||||
handleMarkConversationAsRead,
|
||||
bodyshop
|
||||
handleMarkLastMessageAsUnread,
|
||||
markingAsUnreadInProgress,
|
||||
canMarkUnread
|
||||
}) {
|
||||
const [loading, error] = subState;
|
||||
|
||||
if (conversation?.archived) return null;
|
||||
if (loading) return <LoadingSkeleton />;
|
||||
if (error) return <AlertComponent message={error.message} type="error" />;
|
||||
if (error) return <AlertComponent title={error.message} type="error" />;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -34,7 +35,12 @@ export function ChatConversationComponent({
|
||||
onMouseDown={handleMarkConversationAsRead}
|
||||
onKeyDown={handleMarkConversationAsRead}
|
||||
>
|
||||
<ChatConversationTitle conversation={conversation} bodyshop={bodyshop} />
|
||||
<ChatConversationTitle
|
||||
conversation={conversation}
|
||||
onMarkUnread={handleMarkLastMessageAsUnread}
|
||||
markUnreadDisabled={!canMarkUnread}
|
||||
markUnreadLoading={markingAsUnreadInProgress}
|
||||
/>
|
||||
<ChatMessageListComponent messages={messages} />
|
||||
<ChatSendMessage conversation={conversation} />
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { gql, useApolloClient, useQuery, useSubscription } from "@apollo/client";
|
||||
import { useApolloClient, useQuery, useSubscription } from "@apollo/client/react";
|
||||
import { gql } from "@apollo/client";
|
||||
import axios from "axios";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
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";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
selectedConversation: selectSelectedConversation,
|
||||
@@ -18,8 +19,8 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
|
||||
const client = useApolloClient();
|
||||
const { socket } = useSocket();
|
||||
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
|
||||
const [markingAsUnreadInProgress, setMarkingAsUnreadInProgress] = useState(false);
|
||||
|
||||
// Fetch conversation details
|
||||
const {
|
||||
loading: convoLoading,
|
||||
error: convoError,
|
||||
@@ -27,24 +28,23 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
|
||||
} = useQuery(GET_CONVERSATION_DETAILS, {
|
||||
variables: { conversationId: selectedConversation },
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only"
|
||||
nextFetchPolicy: "network-only",
|
||||
skip: !selectedConversation
|
||||
});
|
||||
|
||||
// Subscription for conversation updates
|
||||
const conversation = convoData?.conversations_by_pk;
|
||||
|
||||
// Subscription for conversation updates (used when socket is NOT connected)
|
||||
useSubscription(CONVERSATION_SUBSCRIPTION_BY_PK, {
|
||||
skip: socket?.connected,
|
||||
skip: socket?.connected || !selectedConversation,
|
||||
variables: { conversationId: selectedConversation },
|
||||
onData: ({ data: subscriptionResult, client }) => {
|
||||
// Extract the messages array from the result
|
||||
const messages = subscriptionResult?.data?.messages;
|
||||
if (!messages || messages.length === 0) {
|
||||
console.warn("No messages found in subscription result.");
|
||||
return;
|
||||
}
|
||||
if (!messages || messages.length === 0) return;
|
||||
|
||||
messages.forEach((message) => {
|
||||
const messageRef = client.cache.identify(message);
|
||||
// Write the new message to the cache
|
||||
|
||||
client.cache.writeFragment({
|
||||
id: messageRef,
|
||||
fragment: gql`
|
||||
@@ -58,12 +58,12 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
|
||||
userid
|
||||
created_at
|
||||
read
|
||||
is_system
|
||||
}
|
||||
`,
|
||||
data: message
|
||||
});
|
||||
|
||||
// Update the conversation cache to include the new message
|
||||
client.cache.modify({
|
||||
id: client.cache.identify({ __typename: "conversations", id: selectedConversation }),
|
||||
fields: {
|
||||
@@ -81,6 +81,28 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Best-effort badge update:
|
||||
* This assumes your list query uses messages_aggregate.aggregate.count as UNREAD inbound count.
|
||||
* If it’s total messages, rename/create a dedicated unread aggregate in the list query and update that field instead.
|
||||
*/
|
||||
const setConversationUnreadCountBestEffort = useCallback(
|
||||
(conversationId, unreadCount) => {
|
||||
if (!conversationId) return;
|
||||
|
||||
client.cache.modify({
|
||||
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
|
||||
fields: {
|
||||
messages_aggregate(existing) {
|
||||
if (!existing?.aggregate) return existing;
|
||||
return { ...existing, aggregate: { ...existing.aggregate, count: unreadCount } };
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
[client.cache]
|
||||
);
|
||||
|
||||
const updateCacheWithReadMessages = useCallback(
|
||||
(conversationId, messageIds) => {
|
||||
if (!conversationId || !messageIds?.length) return;
|
||||
@@ -88,13 +110,34 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
|
||||
messageIds.forEach((messageId) => {
|
||||
client.cache.modify({
|
||||
id: client.cache.identify({ __typename: "messages", id: messageId }),
|
||||
fields: {
|
||||
read: () => true
|
||||
}
|
||||
fields: { read: () => true }
|
||||
});
|
||||
});
|
||||
|
||||
setConversationUnreadCountBestEffort(conversationId, 0);
|
||||
},
|
||||
[client.cache]
|
||||
[client.cache, setConversationUnreadCountBestEffort]
|
||||
);
|
||||
|
||||
const applyUnreadStateWithMaxOneUnread = useCallback(
|
||||
({ conversationId, lastUnreadMessageId, messageIdsMarkedRead = [], unreadCount = 1 }) => {
|
||||
if (!conversationId || !lastUnreadMessageId) return;
|
||||
|
||||
messageIdsMarkedRead.forEach((id) => {
|
||||
client.cache.modify({
|
||||
id: client.cache.identify({ __typename: "messages", id }),
|
||||
fields: { read: () => true }
|
||||
});
|
||||
});
|
||||
|
||||
client.cache.modify({
|
||||
id: client.cache.identify({ __typename: "messages", id: lastUnreadMessageId }),
|
||||
fields: { read: () => false }
|
||||
});
|
||||
|
||||
setConversationUnreadCountBestEffort(conversationId, unreadCount);
|
||||
},
|
||||
[client.cache, setConversationUnreadCountBestEffort]
|
||||
);
|
||||
|
||||
// WebSocket event handlers
|
||||
@@ -102,20 +145,25 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
|
||||
if (!socket?.connected) return;
|
||||
|
||||
const handleConversationChange = (data) => {
|
||||
if (data.type === "conversation-marked-read") {
|
||||
const { conversationId, messageIds } = data;
|
||||
updateCacheWithReadMessages(conversationId, messageIds);
|
||||
if (data?.type === "conversation-marked-read") {
|
||||
updateCacheWithReadMessages(data.conversationId, data.messageIds);
|
||||
}
|
||||
|
||||
if (data?.type === "conversation-marked-unread") {
|
||||
applyUnreadStateWithMaxOneUnread({
|
||||
conversationId: data.conversationId,
|
||||
lastUnreadMessageId: data.lastUnreadMessageId,
|
||||
messageIdsMarkedRead: data.messageIdsMarkedRead,
|
||||
unreadCount: data.unreadCount
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
socket.on("conversation-changed", handleConversationChange);
|
||||
return () => socket.off("conversation-changed", handleConversationChange);
|
||||
}, [socket, updateCacheWithReadMessages, applyUnreadStateWithMaxOneUnread]);
|
||||
|
||||
return () => {
|
||||
socket.off("conversation-changed", handleConversationChange);
|
||||
};
|
||||
}, [socket, updateCacheWithReadMessages]);
|
||||
|
||||
// Join and leave conversation via WebSocket
|
||||
// Join/leave conversation via WebSocket
|
||||
useEffect(() => {
|
||||
if (!socket?.connected || !selectedConversation || !bodyshop?.id) return;
|
||||
|
||||
@@ -132,15 +180,21 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
|
||||
};
|
||||
}, [socket, bodyshop, selectedConversation]);
|
||||
|
||||
// Mark conversation as read
|
||||
const handleMarkConversationAsRead = async () => {
|
||||
if (!convoData || markingAsReadInProgress) return;
|
||||
const inboundNonSystemMessages = useMemo(() => {
|
||||
const msgs = conversation?.messages || [];
|
||||
return msgs
|
||||
.filter((m) => m && !m.isoutbound && !m.is_system)
|
||||
.slice()
|
||||
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
||||
}, [conversation?.messages]);
|
||||
|
||||
const conversation = convoData.conversations_by_pk;
|
||||
if (!conversation) return;
|
||||
const canMarkUnread = inboundNonSystemMessages.length > 0;
|
||||
|
||||
const handleMarkConversationAsRead = async () => {
|
||||
if (!conversation || markingAsReadInProgress) return;
|
||||
|
||||
const unreadMessageIds = conversation.messages
|
||||
?.filter((message) => !message.read && !message.isoutbound)
|
||||
?.filter((message) => !message.read && !message.isoutbound && !message.is_system)
|
||||
.map((message) => message.id);
|
||||
|
||||
if (unreadMessageIds?.length > 0) {
|
||||
@@ -161,12 +215,48 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkLastMessageAsUnread = async () => {
|
||||
if (!conversation || markingAsUnreadInProgress) return;
|
||||
if (!bodyshop?.id || !bodyshop?.imexshopid) return;
|
||||
|
||||
const lastInbound = inboundNonSystemMessages[inboundNonSystemMessages.length - 1];
|
||||
if (!lastInbound?.id) return;
|
||||
|
||||
setMarkingAsUnreadInProgress(true);
|
||||
try {
|
||||
const res = await axios.post("/sms/markLastMessageUnread", {
|
||||
conversationId: conversation.id,
|
||||
imexshopid: bodyshop.imexshopid,
|
||||
bodyshopid: bodyshop.id
|
||||
});
|
||||
|
||||
const payload = res?.data || {};
|
||||
if (payload.lastUnreadMessageId) {
|
||||
applyUnreadStateWithMaxOneUnread({
|
||||
conversationId: conversation.id,
|
||||
lastUnreadMessageId: payload.lastUnreadMessageId,
|
||||
messageIdsMarkedRead: payload.messageIdsMarkedRead || [],
|
||||
unreadCount: typeof payload.unreadCount === "number" ? payload.unreadCount : 1
|
||||
});
|
||||
} else {
|
||||
setConversationUnreadCountBestEffort(conversation.id, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error marking last message unread:", error.message);
|
||||
} finally {
|
||||
setMarkingAsUnreadInProgress(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ChatConversationComponent
|
||||
subState={[convoLoading, convoError]}
|
||||
conversation={convoData?.conversations_by_pk || {}}
|
||||
messages={convoData?.conversations_by_pk?.messages || []}
|
||||
conversation={conversation || {}}
|
||||
messages={conversation?.messages || []}
|
||||
handleMarkConversationAsRead={handleMarkConversationAsRead}
|
||||
handleMarkLastMessageAsUnread={handleMarkLastMessageAsUnread}
|
||||
markingAsUnreadInProgress={markingAsUnreadInProgress}
|
||||
canMarkUnread={canMarkUnread}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { useMutation } from "@apollo/client/react";
|
||||
import { Input, Spin, Tag, Tooltip } from "antd";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UPDATE_CONVERSATION_LABEL } from "../../graphql/conversations.queries";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||
import { connect } from "react-redux";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({});
|
||||
const mapDispatchToProps = () => ({});
|
||||
|
||||
export function ChatLabel({ conversation, bodyshop }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -33,8 +33,8 @@ export function ChatLabel({ conversation, bodyshop }) {
|
||||
variables: { id: conversation.id, label: value }
|
||||
});
|
||||
if (response.errors) {
|
||||
notification["error"]({
|
||||
message: t("messages.errors.updatinglabel", {
|
||||
notification.error({
|
||||
title: t("messages.errors.updatinglabel", {
|
||||
error: JSON.stringify(response.errors)
|
||||
})
|
||||
});
|
||||
@@ -50,8 +50,8 @@ export function ChatLabel({ conversation, bodyshop }) {
|
||||
setEditing(false);
|
||||
}
|
||||
} catch (error) {
|
||||
notification["error"]({
|
||||
message: t("messages.errors.updatinglabel", {
|
||||
notification.error({
|
||||
title: t("messages.errors.updatinglabel", {
|
||||
error: JSON.stringify(error)
|
||||
})
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Button, Tooltip } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function ChatMarkUnreadButton({ disabled, loading, onMarkUnread }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip title={t("messaging.labels.mark_unread")}>
|
||||
<Button
|
||||
size="small"
|
||||
className="unread-button"
|
||||
type="primary"
|
||||
children={t("messaging.labels.mark_unread")}
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
onMouseDown={(e) => e.stopPropagation()} // prevent parent mark-read handler
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMarkUnread?.();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PictureFilled } from "@ant-design/icons";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { Badge, Popover } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -13,13 +13,14 @@ import JobDocumentsGalleryExternal from "../jobs-documents-gallery/jobs-document
|
||||
import JobsDocumentImgproxyGalleryExternal from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component";
|
||||
import JobDocumentsLocalGalleryExternal from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||
import "./chat-media-selector.styles.scss";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
|
||||
const mapDispatchToProps = () => ({});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ChatMediaSelector);
|
||||
|
||||
export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, conversation }) {
|
||||
@@ -27,7 +28,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
|
||||
const [open, setOpen] = useState(false);
|
||||
const {
|
||||
treatments: { Imgproxy }
|
||||
} = useSplitTreatments({
|
||||
} = useTreatmentsWithConfig({
|
||||
attributes: {},
|
||||
names: ["Imgproxy"],
|
||||
splitKey: bodyshop && bodyshop.imexshopid
|
||||
@@ -37,10 +38,13 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only",
|
||||
variables: {
|
||||
jobId: conversation.job_conversations[0] && conversation.job_conversations[0].jobid
|
||||
jobId: conversation.job_conversations[0]?.jobid
|
||||
},
|
||||
|
||||
skip: !open || !conversation.job_conversations || conversation.job_conversations.length === 0
|
||||
skip:
|
||||
!open ||
|
||||
!conversation.job_conversations ||
|
||||
conversation.job_conversations.length === 0 ||
|
||||
bodyshop.uselocalmediaserver
|
||||
});
|
||||
|
||||
const handleVisibleChange = (change) => {
|
||||
@@ -48,7 +52,8 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedMedia([]);
|
||||
// Instead of wiping the array (which holds media objects), just clear selection flags
|
||||
setSelectedMedia((prev) => prev.map((m) => ({ ...m, isSelected: false })));
|
||||
}, [setSelectedMedia, conversation]);
|
||||
|
||||
//Knowingly taking on the technical debt of poor implementation below. Done this way to avoid an edge case where no component may be displayed.
|
||||
@@ -56,25 +61,26 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
|
||||
//If Imageproxy is on, rely only on the LMS selector
|
||||
//If not on, use the old methods.
|
||||
const content = (
|
||||
<div>
|
||||
<div className="media-selector-content">
|
||||
{loading && <LoadingSpinner />}
|
||||
{error && <AlertComponent message={error.message} type="error" />}
|
||||
{error && <AlertComponent title={error.message} type="error" />}
|
||||
{selectedMedia.filter((s) => s.isSelected).length >= 10 ? (
|
||||
<div style={{ color: "red" }}>{t("messaging.labels.maxtenimages")}</div>
|
||||
<div className="error-message">{t("messaging.labels.maxtenimages")}</div>
|
||||
) : null}
|
||||
|
||||
{Imgproxy.treatment === "on" ? (
|
||||
<>
|
||||
{!bodyshop.uselocalmediaserver && (
|
||||
<JobsDocumentImgproxyGalleryExternal
|
||||
jobId={conversation.job_conversations[0].jobid}
|
||||
jobId={conversation.job_conversations[0]?.jobid}
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
/>
|
||||
)}
|
||||
{bodyshop.uselocalmediaserver && open && (
|
||||
<JobDocumentsLocalGalleryExternal
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid}
|
||||
jobId={conversation.job_conversations[0]?.jobid}
|
||||
context="chat"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -89,7 +95,8 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
|
||||
{bodyshop.uselocalmediaserver && open && (
|
||||
<JobDocumentsLocalGalleryExternal
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid}
|
||||
jobId={conversation.job_conversations[0]?.jobid}
|
||||
context="chat"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -100,12 +107,18 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
|
||||
return (
|
||||
<Popover
|
||||
content={
|
||||
conversation.job_conversations.length === 0 ? <div>{t("messaging.errors.noattachedjobs")}</div> : content
|
||||
conversation.job_conversations.length === 0 ? (
|
||||
<div className="no-jobs-message">{t("messaging.errors.noattachedjobs")}</div>
|
||||
) : (
|
||||
content
|
||||
)
|
||||
}
|
||||
title={t("messaging.labels.selectmedia")}
|
||||
trigger="click"
|
||||
open={open}
|
||||
onOpenChange={handleVisibleChange}
|
||||
destroyOnHidden
|
||||
classNames={{ root: "media-selector-popover" }}
|
||||
>
|
||||
<Badge count={selectedMedia.filter((s) => s.isSelected).length}>
|
||||
<PictureFilled style={{ margin: "0 .5rem" }} />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user