Merged in development (pull request #9)

Bring Master up to date.
This commit is contained in:
Snapt Software
2020-08-25 23:57:04 +00:00
1407 changed files with 107037 additions and 14354 deletions

View File

@@ -0,0 +1,5 @@
commands:
10_cleanup:
command: |
sudo rm -f /opt/elasticbeanstalk/hooks/configdeploy/post/*
sudo rm -f /etc/nginx/conf.d/*

View File

@@ -0,0 +1,13 @@
Resources:
sslSecurityGroupIngress:
Type: AWS::EC2::SecurityGroupIngress
Properties:
GroupId: {"Fn::GetAtt" : ["AWSEBSecurityGroup", "GroupId"]}
IpProtocol: tcp
ToPort: 443
FromPort: 443
CidrIp: 0.0.0.0/0
packages:
yum:
epel-release: []

View File

@@ -0,0 +1,105 @@
files:
"/etc/nginx/nginx.pre":
mode: "000644"
owner: root
group: root
content: |
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
port_in_redirect off;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
log_format healthd '$msec"$uri"$status"$request_time"$upstream_response_time"$http_x_forwarded_for';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
include /etc/nginx/mime.types;
include /etc/nginx/conf.d/*.conf;
}
"/etc/nginx/conf.d/http_custom.conf":
mode: "000644"
owner: root
group: root
content: |
server {
listen 8080;
location ~ /.well-known/ {
root /var/www/letsencrypt/;
}
location / {
return 301 https://$host$request_uri;
}
}
"/etc/nginx/conf.d/https_custom.pre":
mode: "000644"
owner: root
group: root
content: |
upstream nodejs {
server 127.0.0.1:5000;
keepalive 256;
}
server {
listen 443 ssl default;
server_name localhost;
error_page 497 https://$host$request_uri;
if ($time_iso8601 ~ "^(\d{4})-(\d{2})-(\d{2})T(\d{2})") {
set $year $1;
set $month $2;
set $day $3;
set $hour $4;
}
access_log /var/log/nginx/healthd/application.log.$year-$month-$day-$hour healthd;
access_log /var/log/nginx/access.log main;
location / {
proxy_pass http://nodejs;
proxy_set_header Connection "";
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
gzip on;
gzip_comp_level 4;
gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript;
ssl_certificate /etc/letsencrypt/live/ebcert/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ebcert/privkey.pem;
ssl_session_timeout 5m;
ssl_protocols TLSv1.1 TLSv1.2;
ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH";
ssl_prefer_server_ciphers on;
if ($host ~* ^www\.(.*)) {
set $host_without_www $1;
rewrite ^(.*) https://$host_without_www$1 permanent;
}
if ($ssl_protocol = "") {
rewrite ^ https://$host$request_uri? permanent;
}
}

View File

@@ -0,0 +1,45 @@
container_commands:
10_setup_nginx:
command: |
sudo rm -f /tmp/deployment/config/#etc#nginx#conf.d#00_elastic_beanstalk_proxy.conf
sudo rm -f /etc/nginx/conf.d/00_elastic_beanstalk_proxy.conf
sudo rm -f /tmp/deployment/config/#etc#nginx#nginx.conf
sudo rm -f /etc/nginx/nginx.conf
sudo mv /etc/nginx/nginx.pre /etc/nginx/nginx.conf
sudo service nginx stop
sudo service nginx start
20_install_certbot:
command: |
wget https://dl.eff.org/certbot-auto
mv certbot-auto /usr/local/bin/certbot-auto
chown root /usr/local/bin/certbot-auto
chmod 0755 /usr/local/bin/certbot-auto
30_create_webroot_path:
command: |
sudo rm -rf /var/www/letsencrypt/
sudo mkdir /var/www/letsencrypt/
40_configure_cert:
command: |
certbot_command="/usr/local/bin/certbot-auto certonly --webroot --webroot-path /var/www/letsencrypt --debug --non-interactive --email ${LETSENCRYPT_EMAIL} --agree-tos --expand --keep-until-expiring"
for domain in $(echo ${LETSENCRYPT_DOMAIN} | sed "s/,/ /g")
do
certbot_command="$certbot_command --domains $domain"
done
eval $certbot_command
50_link_cert:
command: |
domain="$( cut -d ',' -f 1 <<< "${LETSENCRYPT_DOMAIN}" )";
if [ -d /etc/letsencrypt/live ]; then
domain_folder_name="$(ls /etc/letsencrypt/live | sort -n | grep $domain | head -1)";
if [ -d /etc/letsencrypt/live/${domain_folder_name} ]; then
ln -sfn /etc/letsencrypt/live/${domain_folder_name} /etc/letsencrypt/live/ebcert
fi
fi
60_enable_https_config:
command: |
sudo mv /etc/nginx/conf.d/https_custom.pre /etc/nginx/conf.d/https_custom.conf
sudo service nginx stop
sudo service nginx start

View File

@@ -0,0 +1,11 @@
files:
# Elastic Beanstalk recreates the default configuration during every configuration deployment
"/opt/elasticbeanstalk/hooks/configdeploy/post/99_kill_default_nginx.sh":
mode: "000755"
owner: root
group: root
content: |
#!/bin/bash -xe
rm -f /etc/nginx/conf.d/00_elastic_beanstalk_proxy.conf
service nginx stop
service nginx start

View File

@@ -0,0 +1,8 @@
files:
# Cron to renew cert
"/etc/cron.d/certbot_renew":
mode: "000644"
owner: root
group: root
content: |
@weekly root /usr/local/bin/certbot-auto renew

13
.gitignore vendored
View File

@@ -7,12 +7,18 @@
client/node_modules
client/.pnp
client.pnp.js
admin/node_modules
admin/.pnp
admin.pnp.js
# testing
/coverage
client/coverage
admin/coverage
# production
/build
client/build
admin/build
# misc
.DS_Store
.env
@@ -25,7 +31,12 @@ client/.env
npm-debug.log*
yarn-debug.log*
yarn-error.log*
client/npm-debug.log*
client/yarn-debug.log*
client/yarn-error.log*
admin/npm-debug.log*
admin/yarn-debug.log*
admin/yarn-error.log*
#Firebase Ignore
# Logs

64
.vscode/bodyshopsnippets.code-snippets vendored Normal file
View File

@@ -0,0 +1,64 @@
{
// Place your bodyshop workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
// description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
// is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
// used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
// $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
// Placeholders with the same ids are connected.
// Example:
// "Print to console": {
// "scope": "javascript,typescript",
// "prefix": "log",
// "body": [
// "console.log('$1');",
// "$2"
// ],
// "description": "Log output to console"
// }
"Const T useTranslation": {
"prefix": "ttt",
"body": ["const { t } = useTranslation();"],
"description": "Use Translation Destructing."
},
" useTranslation import": {
"prefix": "tti",
"body": ["import { useTranslation } from \"react-i18next\";"],
"description": "Use Translation import."
},
"Redux Setup": {
"prefix": "rdx",
"body": [
"import { connect } from \"react-redux\";",
"import { createStructuredSelector } from \"reselect\";",
"const mapStateToProps = createStructuredSelector({",
" //currentUser: selectCurrentUser",
"});",
"const mapDispatchToProps = dispatch => ({",
" //setUserLanguage: language => dispatch(setUserLanguage(language))",
"});",
"export default connect (mapStateToProps,mapDispatchToProps)();"
],
"description": "General Redux."
},
" Apollo Loading Error Handling import": {
"prefix": "ale",
"body": [
"if (loading) return <LoadingSpinner />;",
"if (error) return <AlertComponent message={error.message} type=\"error\" />;"
],
"description": "Apollo Loading Error Handling import."
},
"Log IMEX EVent Import": {
"prefix": "liei",
"body": [
"import { logImEXEvent } from \"../../firebase/firebase.utils\"; "
],
"description": "Apollo Loading Error Handling import."
},
"Log IMEX EVent": {
"prefix": "lie",
"body": ["logImEXEvent(\"EventName\", { prop: \"value\" });"],
"description": ""
}
}

29
.vscode/launch.json vendored
View File

@@ -1,13 +1,20 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Chrome",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000",
"webRoot": "${workspaceRoot}/src"
"version": "0.2.0",
"configurations": [
{
"name": "Attach to Chrome",
"port": 9222,
"request": "attach",
"type": "pwa-chrome",
"webRoot": "${workspaceFolder}/client/src"
},
}
]
}
{
"name": "Chrome",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000",
"webRoot": "${workspaceRoot}/client/src"
}
]
}

View File

@@ -1,7 +1,8 @@
React App:
Yarn Dependency Management:
To force upgrades for some packages: yarn upgrade-interactive --latest
To force upgrades for some packages:
yarn upgrade-interactive --latest
GraphQL API:
Hasura is hosted on another dyno. Several environmental variables are required, including disabling the console.
@@ -12,3 +13,13 @@ npx hasura console --admin-secret Dev-BodyShopAppBySnaptSoftware!
Migrating to Staging:
npx hasura migrate apply --up 10 --endpoint https://bodyshop-staging-db.herokuapp.com/ --admin-secret Staging-BodyShopAppBySnaptSoftware!
NGROK TEsting:
./ngrok.exe http https://localhost:5000 -host-header="localhost:5000"
Finding deadfiles - run from client directory
npx deadfile ./src/index.js --exclude build templates
cd client && yarn build && cd build && scp -r ** imex@prod-tor1.imex.online:~/bodyshop/client/build && cd .. &&cd ..

View File

@@ -49,3 +49,6 @@
--\* Set the region for the shop.
-Counter Record - type: ronum
Create an in house vendor record and add it to the bodyshop record.

View File

@@ -1,11 +0,0 @@
server.env
AWSAccessKeyId=AKIAJYNXY5KCA25PB2JA
AWSSecretKey=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
Bucket=bodyshop-app-dev
client.env
REACT_APP_GRAPHQL_ENDPOINT=https://bodyshop-dev-db.herokuapp.com/v1/graphql
REACT_APP_GRAPHQL_ENDPOINT_WS=wss://bodyshop-dev-db.herokuapp.com/v1/graphql
REACT_APP_GA_CODE=217352234

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,75 @@
**Create an SSH key for local computer**
ssh-keygen -t rsa -C "your_email@example.com"
Copy the new key to clipboard:
* Windows: clip < id_rsa.pub
* Linux: sudo apt-get install xclip
xclip -sel clip < ~/.ssh/id_rsa.pub
* Mac: pbcopy < ~/.ssh/id_rsa.pub
* Manual Copy: cat ~/.ssh/id_rsa.pub
Add the SSH key to the drop creation screen.
1. Create a new user to replace root user
1. # adduser imex
2. # usermod -aG sudo imex
3. # su - imex
4. $ mkdir ~/.ssh
5. $ chmod 700 ~/.ssh
6. $ nano ~/.ssh/authorized_keys
7. Add the copied SSH key and save.
8. $ chmod 600 ~/.ssh/authorized_keys #Restrict access to authorized keys.
2. Setup the Firewall
1. $ sudo ufw allow OpenSSH.
2. $ sudo ufw enable
3. Add Nginx & Configure
1. $ sudo apt-get update
2. $ sudo apt-get install nginx
3. $ sudo ufw allow 'Nginx Full'
4. $ sudo ufw app list
1. Nginx Full: Opens both port 80 (normal, unencrypted web traffic) and port 443 (TLS/SSL encrypted traffic)
2. Nginx Http: Opens only port 80 (normal, unencrypted web traffic)
3. Nginx Https: Opens only port 443 (TLS/SSL encrypted traffic)
5. Should now be able to go to IP and see nginx responding with a blank page.
6. Install NodeJs
1. $ curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash -
2. $ sudo apt install nodejs
3. $ node --version
7. Clone Source Code
1. $ git clone git@bitbucket.org:snaptsoft/bodyshop.git //Requires SSH setup.
2. $ cd bodyshop && npm install //Install all server dependencies.
8. Setup PM2
1. $ npm install pm2 -g //Had to be run as root.
2. $ pm2 start ecosystem.config.js
3. $ pm2 startup ubuntu //Ensure it starts when server does.
9. Alter Nginx config
1. sudo nano /etc/nginx/sites-available/default
2. //Add Appropriate server names to the file. www. and non-www.
3. Add the following inside the location of the server block:
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
10. Install Certbot
4. $ sudo add-apt-repository ppa:certbot/certbot //Potential issue on ubuntu 20.04
5. $ sudo apt-get update
6. $ sudo apt install python-certbot-nginx
7. $ sudo nano /etc/nginx/sites-available/default
8. Find the existing server_name line and replace the underscore with your domain name:
...
server_name example.com www.example.com;
...
9. $ sudo nginx -t //Verify syntax.
10. $ sudo systemctl reload nginx
11. Generate Certificate
11. $ sudo certbot --nginx -d example.com -d www.example.com //Follow prompts.
12. $ sudo certbot renew --dry-run //Dry run to test auto renewal.
ADding Yarn
curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
sudo apt-get update && sudo apt-get install yarn

16
_reference/firebase.md Normal file
View File

@@ -0,0 +1,16 @@
1. Create a new project
2. Setup sign in methods to be user and email only.
3. Update .env to include config.
4. Setup the Firebase CLI
1. cd to client firebase at server directory.
2. ensure all dependencies installed
1. $ npm install firebase-functions@latest firebase-admin@latest --save
2. $ npm install -g firebase-tools
3. $ firebase login //Login as needed.
5. Set the current projct
1. firebase use <projectname>
6. Deploy the function
1. $ firebase deploy --only functions
7. Add the allowed domains.
8. Update server variables including FIREBASE_ADMINSDK_JSON, FIREBASE_DATABASE_URL
9. Create the firestore and copy the rules from dev for userinstances.

68
admin/README.md Normal file
View File

@@ -0,0 +1,68 @@
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `yarn start`
Runs the app in the development mode.<br />
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br />
You will also see any lint errors in the console.
### `yarn test`
Launches the test runner in the interactive watch mode.<br />
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `yarn build`
Builds the app for production to the `build` folder.<br />
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br />
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `yarn eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting
### Analyzing the Bundle Size
This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size
### Making a Progressive Web App
This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app
### Advanced Configuration
This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration
### Deployment
This section has moved here: https://facebook.github.io/create-react-app/docs/deployment
### `yarn build` fails to minify
This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify

46
admin/package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "admin",
"version": "0.1.0",
"private": true,
"dependencies": {
"@apollo/client": "^3.1.2",
"@testing-library/jest-dom": "^5.11.2",
"@testing-library/react": "^10.4.8",
"@testing-library/user-event": "^12.1.0",
"@types/prop-types": "^15.7.3",
"apollo-boost": "^0.4.9",
"apollo-link-context": "^1.0.20",
"apollo-link-logger": "^1.2.3",
"dotenv": "^8.2.0",
"firebase": "^7.17.1",
"graphql": "^15.3.0",
"prop-types": "^15.7.2",
"ra-data-hasura-graphql": "^0.1.12",
"react": "^16.13.1",
"react-admin": "^3.7.2",
"react-dom": "^16.13.1",
"react-icons": "^3.10.0",
"react-scripts": "3.4.1"
},
"scripts": {
"start": "set PORT=3001 && react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

BIN
admin/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

43
admin/public/index.html Normal file
View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>ImEX Online - ADMIN</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
admin/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
admin/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
admin/public/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

38
admin/src/App/App.css Normal file
View File

@@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

12
admin/src/App/App.js Normal file
View File

@@ -0,0 +1,12 @@
import React from "react";
import AdminRoot from "../components/admin-root/admin-root.component";
import "./App.css";
function App() {
return (
<div className="App">
<AdminRoot />
</div>
);
}
export default App;

View File

@@ -0,0 +1,9 @@
import React from 'react';
import { render } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
const { getByText } = render(<App />);
const linkElement = getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,277 @@
import { ApolloClient, ApolloProvider, InMemoryCache } from "@apollo/client";
import { ApolloLink } from "apollo-boost";
import { setContext } from "apollo-link-context";
import { HttpLink } from "apollo-link-http";
import apolloLogger from "apollo-link-logger";
import buildHasuraProvider from "ra-data-hasura-graphql";
import React, { Component } from "react";
import {
Admin,
EditGuesser,
ListGuesser,
Resource,
ShowGuesser,
} from "react-admin";
import { FaFileInvoiceDollar } from "react-icons/fa";
import { auth } from "../../firebase/admin-firebase-utils";
import authProvider from "../auth-provider/auth-provider";
import JoblinesCreate from "../joblines/joblines.create";
import JoblinesEdit from "../joblines/joblines.edit";
import JoblinesList from "../joblines/joblines.list";
import JoblinesShow from "../joblines/joblines.show";
import JobsCreate from "../jobs/jobs.create";
import JobsEdit from "../jobs/jobs.edit";
import JobsList from "../jobs/jobs.list";
import JobsShow from "../jobs/jobs.show";
const httpLink = new HttpLink({
uri: process.env.REACT_APP_GRAPHQL_ENDPOINT,
headers: {
"x-hasura-admin-secret": `Dev-BodyShopAppBySnaptSoftware!`,
// 'Authorization': `Bearer xxxx`,
},
});
const authLink = setContext((_, { headers }) => {
return (
auth.currentUser &&
auth.currentUser.getIdToken().then((token) => {
if (token) {
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : "",
},
};
} else {
return { headers };
}
})
);
});
const middlewares = [];
if (process.env.NODE_ENV === "development") {
middlewares.push(apolloLogger);
}
middlewares.push(authLink.concat(httpLink));
const client = new ApolloClient({
link: ApolloLink.from(middlewares),
cache: new InMemoryCache(),
});
// const client = new ApolloClient({
// uri: process.env.REACT_APP_GRAPHQL_ENDPOINT,
// cache: new InMemoryCache(),
// headers: {
// "x-hasura-admin-secret": `Dev-BodyShopAppBySnaptSoftware!`,
// // 'Authorization': `Bearer xxxx`,
// },
// });
class AdminRoot extends Component {
constructor() {
super();
this.state = { dataProvider: null };
}
componentDidMount() {
buildHasuraProvider({
client,
}).then((dataProvider) => this.setState({ dataProvider }));
}
render() {
const { dataProvider } = this.state;
if (!dataProvider) {
return <div>Loading</div>;
}
return (
<ApolloProvider client={client}>
<Admin dataProvider={dataProvider} authProvider={authProvider}>
<Resource
icon={FaFileInvoiceDollar}
name="jobs"
list={JobsList}
edit={JobsEdit}
create={JobsCreate}
show={JobsShow}
/>
<Resource
name="joblines"
list={JoblinesList}
edit={JoblinesEdit}
create={JoblinesCreate}
show={JoblinesShow}
/>
<Resource
name="bodyshops"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="owners"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="vehicles"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="appointments"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="available_jobs"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="cccontracts"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="conversations"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="counters"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="courtesycars"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="csi"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="csiquestions"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="documents"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="employees"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="invoicelines"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="invoices"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="job_conversations"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="masterdata"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="messages"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="notes"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="parts_order_lines"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="parts_orders"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="payments"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="scoreboard"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="templates"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="timetickets"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="users"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
<Resource
name="vendors"
list={ListGuesser}
edit={EditGuesser}
show={ShowGuesser}
/>
</Admin>
</ApolloProvider>
);
}
}
export default AdminRoot;

View File

@@ -0,0 +1,39 @@
import { auth, getCurrentUser } from "../../firebase/admin-firebase-utils";
const authProvider = {
login: async ({ username, password }) => {
console.log(username, password);
try {
const { user } = await auth.signInWithEmailAndPassword(
username,
password
);
const token = await user.getIdToken(true);
localStorage.setItem("token", token);
return Promise.resolve();
} catch (error) {
console.log("error", error);
return Promise.reject();
}
},
logout: async (params) => {
await auth.signOut();
localStorage.removeItem("token");
return Promise.resolve();
},
checkAuth: async (params) => {
const user = await getCurrentUser();
if (!!user) {
return Promise.resolve();
} else {
return Promise.reject();
}
},
checkError: (error) => {
return Promise.resolve();
},
getPermissions: (params) => {
return Promise.resolve();
},
};
export default authProvider;

View File

@@ -0,0 +1,26 @@
import React from "react";
import {
Create,
NumberInput, SimpleForm,
TextInput
} from "react-admin";
const JoblinesCreate = (props) => (
<Create {...props}>
<SimpleForm>
<TextInput source="line_ref" />
<TextInput source="line_ind" />
<NumberInput source="db_price" />
<NumberInput source="act_price" />
<NumberInput source="part_qty" />
<NumberInput source="mod_lb_hrs" />
<TextInput source="mod_lbr_type" />
<TextInput source="lbr_op" />
</SimpleForm>
</Create>
);
export default JoblinesCreate;

View File

@@ -0,0 +1,70 @@
import React from "react";
import {
BooleanInput,
DateField,
Edit,
NumberInput,
SimpleForm,
TextInput,
} from "react-admin";
const JoblinesEdit = (props) => (
<Edit {...props}>
<SimpleForm>
<TextInput source="id" />
<DateField showTime source="created_at" />
<DateField showTime source="updated_at" />
<TextInput source="jobid" />
<NumberInput source="unq_seq" />
<NumberInput source="line_ind" />
<TextInput source="line_desc" />
<TextInput source="part_type" />
<TextInput source="oem_partno" />
<TextInput source="est_seq" />
<TextInput source="db_ref" />
<TextInput source="line_ref" />
<BooleanInput source="tax_part" />
<NumberInput source="db_price" />
<NumberInput source="act_price" />
<NumberInput source="part_qty" />
<TextInput source="alt_partno" />
<TextInput source="mod_lbr_ty" />
<NumberInput source="db_hrs" />
<NumberInput source="mod_lb_hrs" />
<TextInput source="lbr_op" />
<NumberInput source="lbr_amt" />
<BooleanInput source="glass_flag" />
<TextInput source="price_inc" />
<TextInput source="alt_part_i" />
<TextInput source="price_j" />
<TextInput source="cert_part" />
<TextInput source="alt_co_id" />
<TextInput source="alt_overrd" />
<TextInput source="alt_partm" />
<TextInput source="prt_dsmk_p" />
<TextInput source="prt_dsmk_m" />
<TextInput source="lbr_inc" />
<TextInput source="lbr_hrs_j" />
<TextInput source="lbr_typ_j" />
<TextInput source="lbr_op_j" />
<TextInput source="paint_stg" />
<TextInput source="paint_tone" />
<TextInput source="lbr_tax" />
<NumberInput source="misc_amt" />
<TextInput source="misc_sublt" />
<TextInput source="misc_tax" />
<TextInput source="bett_type" />
<NumberInput source="bett_pctg" />
<NumberInput source="bett_amt" />
<TextInput source="bett_tax" />
<TextInput source="op_code_desc" />
<TextInput source="status" />
<TextInput source="removed" />
<NumberInput source="line_no" />
<TextInput source="notes" />
<TextInput source='"location"' />
</SimpleForm>
</Edit>
);
export default JoblinesEdit;

View File

@@ -0,0 +1,29 @@
import React from "react";
import {
Datagrid, List,
NumberField,
ReferenceField, TextField
} from "react-admin";
const JoblinesList = (props) => (
<List {...props}>
<Datagrid rowClick="edit">
<ReferenceField source="jobid" reference="jobs">
<TextField source="ro_number" />
</ReferenceField>
<TextField source="line_ref" />
<TextField source="line_ind" />
<NumberField source="db_price" />
<NumberField source="act_price" />
<NumberField source="part_qty" />
<NumberField source="mod_lb_hrs" />
<TextField source="mod_lbr_type" />
<TextField source="lbr_op" />
</Datagrid>
</List>
);
export default JoblinesList;

View File

@@ -0,0 +1,24 @@
import React from "react";
import {
NumberInput, Show,
SimpleShowLayout,
TextInput
} from "react-admin";
const JoblinesShow = (props) => (
<Show {...props}>
<SimpleShowLayout>
<TextInput source="line_ref" />
<TextInput source="line_ind" />
<NumberInput source="db_price" />
<NumberInput source="act_price" />
<NumberInput source="part_qty" />
<NumberInput source="mod_lb_hrs" />
<TextInput source="mod_lbr_type" />
<TextInput source="lbr_op" />
</SimpleShowLayout>
</Show>
);
export default JoblinesShow;

View File

@@ -0,0 +1,17 @@
import React from "react";
import { Create, EmailField, SimpleForm, TextInput } from "react-admin";
const JobsCreate = (props) => (
<Create {...props}>
<SimpleForm>
<TextInput source="ro_number" />
<TextInput source="est_number" />
<TextInput source="ownr_fn" />
<TextInput source="ownr_ln" />
<TextInput source="converted" />
<EmailField source="ownr_ea" />
</SimpleForm>
</Create>
);
export default JobsCreate;

View File

@@ -0,0 +1,343 @@
import React from "react";
//@ts-ignore
import {
AutocompleteInput,
Edit,
FormTab,
NumberInput,
ReferenceInput,
SelectInput,
TabbedForm,
TextInput,
} from "react-admin";
const JobsEdit = (props) => (
<Edit {...props}>
<TabbedForm margin="normal" variant="standard">
<FormTab label="Record Info">
<div
style={{
columns: "3 auto",
// display: "flex",
width: "100%",
// justifyContent: "space-around",
}}
>
<TextInput disabled fullWidth source="id" />
<TextInput disabled fullWidth source="created_at" />
<TextInput disabled fullWidth source="updated_at" />
</div>
</FormTab>
<FormTab label="Job Info">
<div
style={{
columns: "3 auto",
// display: "flex",
width: "100%",
// justifyContent: "space-around",
}}
>
<ReferenceInput label="Shopid" source="shopid" reference="bodyshops">
<SelectInput disabled optionText="shopname" />
</ReferenceInput>
<TextInput fullWidth source="ro_number" />
<ReferenceInput label="Owner ID" source="ownerid" reference="owners">
<AutocompleteInput
matchSuggestion={(filter, choice) =>
choice.ownr_fn &&
choice.ownr_fn.toLowerCase().includes(filter.toLowerCase())
}
optionText={(record) =>
`${record.ownr_fn || ""} ${record.ownr_ln || ""} ${
record.ownr_co_nm || ""
} (${record.ownr_ph1 || ""})`
}
/>
</ReferenceInput>
<ReferenceInput
label="Vehicle Id"
source="vehicleid"
reference="vehicles"
>
<SelectInput optionText="v_vin" />
</ReferenceInput>
<ReferenceInput label="Shopid" source="shopid" reference="bodyshops">
<SelectInput disabled optionText="shopname" />
</ReferenceInput>
<TextInput fullWidth source="ro_number" />
<ReferenceInput label="Owner ID" source="ownerid" reference="owners">
<AutocompleteInput
matchSuggestion={(filter, choice) =>
choice.ownr_fn &&
choice.ownr_fn.toLowerCase().includes(filter.toLowerCase())
}
optionText={(record) =>
`${record.ownr_fn || ""} ${record.ownr_ln || ""} ${
record.ownr_co_nm || ""
} (${record.ownr_ph1 || ""})`
}
/>
</ReferenceInput>
<ReferenceInput
label="Vehicle Id"
source="vehicleid"
reference="vehicles"
>
<SelectInput optionText="v_vin" />
</ReferenceInput>
<TextInput fullWidth source="inproduction" />
<TextInput fullWidth source="converted" />
</div>
</FormTab>
<FormTab label="Labor Rates">
<div
style={{
columns: "3 auto",
// display: "flex",
width: "100%",
// justifyContent: "space-around",
}}
>
<NumberInput fullWidth source="labor_rate_id" />
<NumberInput fullWidth source="labor_rate_desc" />
<NumberInput fullWidth source="rate_lab" />
<NumberInput fullWidth source="rate_lad" />
<NumberInput fullWidth source="rate_lae" />
<NumberInput fullWidth source="rate_lar" />
<NumberInput fullWidth source="rate_las" />
<NumberInput fullWidth source="rate_laf" />
<NumberInput fullWidth source="rate_lam" />
<NumberInput fullWidth source="rate_lag" />
<NumberInput fullWidth source="rate_atp" />
<NumberInput fullWidth source="rate_lau" />
<NumberInput fullWidth source="rate_la1" />
<NumberInput fullWidth source="rate_la2" />
<NumberInput fullWidth source="rate_la3" />
<NumberInput fullWidth source="rate_la4" />
<NumberInput fullWidth source="rate_mapa" />
<NumberInput fullWidth source="rate_mash" />
<NumberInput fullWidth source="rate_mahw" />
<NumberInput fullWidth source="rate_ma2s" />
<NumberInput fullWidth source="rate_ma3s" />
<NumberInput fullWidth source="rate_ma2t" />
<NumberInput fullWidth source="rate_mabl" />
<NumberInput fullWidth source="rate_macs" />
<NumberInput fullWidth source="rate_matd" />
<NumberInput fullWidth source="federal_tax_rate" />
<NumberInput fullWidth source="state_tax_rate" />
<NumberInput fullWidth source="local_tax_rate" />
</div>
</FormTab>
<FormTab label="Dates">
<TextInput fullWidth source="scheduled_in" />
<TextInput fullWidth source="actual_in" />
<TextInput fullWidth source="scheduled_completion" />
<TextInput fullWidth source="actual_completion" />
<TextInput fullWidth source="scheduled_delivery" />
<TextInput fullWidth source="actual_delivery" />
<TextInput fullWidth source="invoice_date" />
<TextInput fullWidth source="date_estimated" />
<TextInput fullWidth source="date_open" />
<TextInput fullWidth source="date_scheduled" />
<TextInput fullWidth source="date_invoiced" />
<TextInput fullWidth source="date_closed" />
<TextInput fullWidth source="date_exported" />
</FormTab>
<FormTab label="Insurance info">
<TextInput fullWidth source="est_co_nm" />
<TextInput fullWidth source="est_addr1" />
<TextInput fullWidth source="est_addr2" />
<TextInput fullWidth source="est_city" />
<TextInput fullWidth source="est_st" />
<TextInput fullWidth source="est_zip" />
<TextInput fullWidth source="est_ctry" />
<TextInput fullWidth source="est_ph1" />
<TextInput fullWidth source="est_ea" />
<TextInput fullWidth source="est_ct_ln" />
<TextInput fullWidth source="est_ct_fn" />
<TextInput fullWidth source="regie_number" />
<TextInput fullWidth source="statusid" />
<TextInput fullWidth source="ins_co_id" />
<TextInput fullWidth source="ins_co_nm" />
<TextInput fullWidth source="ins_addr1" />
<TextInput fullWidth source="ins_addr2" />
<TextInput fullWidth source="ins_city" />
<TextInput fullWidth source="ins_st" />
<TextInput fullWidth source="ins_zip" />
<TextInput fullWidth source="ins_ctry" />
<TextInput fullWidth source="ins_ph1" />
<TextInput fullWidth source="ins_ph1x" />
<TextInput fullWidth source="ins_ph2" />
<TextInput fullWidth source="ins_ph2x" />
<TextInput fullWidth source="ins_fax" />
<TextInput fullWidth source="ins_faxx" />
<TextInput fullWidth source="ins_ct_ln" />
<TextInput fullWidth source="ins_ct_fn" />
<TextInput fullWidth source="ins_title" />
<TextInput fullWidth source="ins_ct_ph" />
<TextInput fullWidth source="ins_ct_phx" />
<TextInput fullWidth source="ins_ea" />
<TextInput fullWidth source="ins_memo" />
<TextInput fullWidth source="policy_no" />
<TextInput fullWidth source="ded_amt" />
<TextInput fullWidth source="ded_status" />
<TextInput fullWidth source="asgn_no" />
<TextInput fullWidth source="asgn_date" />
<TextInput fullWidth source="asgn_type" />
<TextInput fullWidth source="clm_no" />
<TextInput fullWidth source="clm_ofc_id" />
<TextInput fullWidth source="agt_co_id" />
<TextInput fullWidth source="agt_co_nm" />
<TextInput fullWidth source="agt_addr1" />
<TextInput fullWidth source="agt_addr2" />
<TextInput fullWidth source="agt_city" />
<TextInput fullWidth source="agt_st" />
<TextInput fullWidth source="agt_zip" />
<TextInput fullWidth source="agt_ctry" />
<TextInput fullWidth source="agt_ph1" />
<TextInput fullWidth source="agt_ph1x" />
<TextInput fullWidth source="agt_ph2" />
<TextInput fullWidth source="agt_ph2x" />
<TextInput fullWidth source="agt_fax" />
<TextInput fullWidth source="agt_faxx" />
<TextInput fullWidth source="agt_ct_ln" />
<TextInput fullWidth source="agt_ct_fn" />
<TextInput fullWidth source="agt_ct_ph" />
<TextInput fullWidth source="agt_ct_phx" />
<TextInput fullWidth source="agt_ea" />
<TextInput fullWidth source="agt_lic_no" />
<TextInput fullWidth source="loss_type" />
<TextInput fullWidth source="loss_desc" />
<TextInput fullWidth source="theft_ind" />
<TextInput fullWidth source="cat_no" />
<TextInput fullWidth source="tlos_ind" />
<TextInput fullWidth source="ciecaid" />
<TextInput fullWidth source="loss_date" />
<TextInput fullWidth source="clm_ofc_nm" />
<TextInput fullWidth source="clm_addr1" />
<TextInput fullWidth source="clm_addr2" />
<TextInput fullWidth source="clm_city" />
<TextInput fullWidth source="clm_st" />
<TextInput fullWidth source="clm_zip" />
<TextInput fullWidth source="clm_ctry" />
<TextInput fullWidth source="clm_ph1" />
<TextInput fullWidth source="clm_ph1x" />
<TextInput fullWidth source="clm_ph2" />
<TextInput fullWidth source="clm_ph2x" />
<TextInput fullWidth source="clm_fax" />
<TextInput fullWidth source="clm_faxx" />
<TextInput fullWidth source="clm_ct_ln" />
<TextInput fullWidth source="clm_ct_fn" />
<TextInput fullWidth source="clm_title" />
<TextInput fullWidth source="clm_ct_ph" />
<TextInput fullWidth source="clm_ct_phx" />
<TextInput fullWidth source="clm_ea" />
<TextInput fullWidth source="payee_nms" />
<TextInput fullWidth source="pay_type" />
<TextInput fullWidth source="pay_date" />
<TextInput fullWidth source="pay_chknm" />
<TextInput fullWidth source="pay_amt" />
</FormTab>
<FormTab label="Owner Data on Job">
<TextInput fullWidth source="cust_pr" />
<TextInput fullWidth source="insd_ln" />
<TextInput fullWidth source="insd_fn" />
<TextInput fullWidth source="insd_title" />
<TextInput fullWidth source="insd_co_nm" />
<TextInput fullWidth source="insd_addr1" />
<TextInput fullWidth source="insd_addr2" />
<TextInput fullWidth source="insd_city" />
<TextInput fullWidth source="insd_st" />
<TextInput fullWidth source="insd_zip" />
<TextInput fullWidth source="insd_ctry" />
<TextInput fullWidth source="insd_ph1" />
<TextInput fullWidth source="insd_ph1x" />
<TextInput fullWidth source="insd_ph2" />
<TextInput fullWidth source="insd_ph2x" />
<TextInput fullWidth source="insd_fax" />
<TextInput fullWidth source="insd_faxx" />
<TextInput fullWidth source="insd_ea" />
<TextInput fullWidth source="ownr_ln" />
<TextInput fullWidth source="ownr_fn" />
<TextInput fullWidth source="ownr_title" />
<TextInput fullWidth source="ownr_co_nm" />
<TextInput fullWidth source="ownr_addr1" />
<TextInput fullWidth source="ownr_addr2" />
<TextInput fullWidth source="ownr_city" />
<TextInput fullWidth source="ownr_st" />
<TextInput fullWidth source="ownr_zip" />
<TextInput fullWidth source="ownr_ctry" />
<TextInput fullWidth source="ownr_ph1" />
<TextInput fullWidth source="ownr_ph1x" />
<TextInput fullWidth source="ownr_ph2" />
<TextInput fullWidth source="ownr_ph2x" />
<TextInput fullWidth source="ownr_fax" />
<TextInput fullWidth source="ownr_faxx" />
<TextInput fullWidth source="ownr_ea" />
</FormTab>
<FormTab label="Financial">
<TextInput fullWidth source="clm_total" />
<TextInput fullWidth source="owner_owing" />
</FormTab>
<FormTab label="Other">
<TextInput fullWidth source="area_of_damage" />
<TextInput fullWidth source="loss_cat" />
<TextInput fullWidth source="est_number" />
<TextInput fullWidth source="special_coverage_policy" />
<TextInput fullWidth source="csr" />
<TextInput fullWidth source="po_number" />
<TextInput fullWidth source="unit_number" />
<TextInput fullWidth source="kmin" />
<TextInput fullWidth source="kmout" />
<TextInput fullWidth source="referral_source" />
<TextInput fullWidth source="selling_dealer" />
<TextInput fullWidth source="servicing_dealer" />
<TextInput fullWidth source="servicing_dealer_contact" />
<TextInput fullWidth source="selling_dealer_contact" />
<TextInput fullWidth source="depreciation_taxes" />
<TextInput fullWidth source="federal_tax_payable" />
<TextInput fullWidth source="other_amount_payable" />
<TextInput fullWidth source="towing_payable" />
<TextInput fullWidth source="storage_payable" />
<TextInput fullWidth source="adjustment_bottom_line" />
<TextInput fullWidth source="tax_pstthr" />
<TextInput fullWidth source="tax_tow_rt" />
<TextInput fullWidth source="tax_sub_rt" />
<TextInput fullWidth source="tax_paint_mat_rt" />
<TextInput fullWidth source="tax_levies_rt" />
<TextInput fullWidth source="tax_prethr" />
<TextInput fullWidth source="tax_thramt" />
<TextInput fullWidth source="tax_str_rt" />
<TextInput fullWidth source="tax_lbr_rt" />
<TextInput fullWidth source="adj_g_disc" />
<TextInput fullWidth source="adj_towdis" />
<TextInput fullWidth source="adj_strdis" />
<TextInput fullWidth source="tax_predis" />
<TextInput fullWidth source="rate_laa" />
<TextInput fullWidth source="status" />
<TextInput fullWidth source="cieca_stl" />
<TextInput fullWidth source="g_bett_amt" />
<TextInput fullWidth source="cieca_ttl" />
<TextInput fullWidth source="plate_no" />
<TextInput fullWidth source="plate_st" />
<TextInput fullWidth source="v_vin" />
<TextInput fullWidth source="v_model_yr" />
<TextInput fullWidth source="v_model_desc" />
<TextInput fullWidth source="v_make_desc" />
<TextInput fullWidth source="v_color" />
<TextInput fullWidth source="parts_tax_rates" />
<TextInput fullWidth source="job_totals" />
<TextInput fullWidth source="production_vars" />
<TextInput fullWidth source="intakechecklist" />
<TextInput fullWidth source="invoice_allocation" />
<TextInput fullWidth source="kanbanparent" />
<TextInput fullWidth source="employee_body" />
<TextInput fullWidth source="employee_refinish" />
<TextInput fullWidth source="employee_prep" />
</FormTab>
</TabbedForm>
</Edit>
);
export default JobsEdit;

View File

@@ -0,0 +1,62 @@
import React from "react";
import {
Datagrid,
Filter,
List,
ReferenceField,
TextField,
SelectInput,
TextInput,
} from "react-admin";
import { useQuery } from "@apollo/client";
import { QUERY_ALL_SHOPS } from "../../graphql/admin.shop.queries";
import CircularProgress from "@material-ui/core/CircularProgress";
const JobsList = (props) => (
<List filters={<JobsFilter />} {...props}>
<Datagrid rowClick="edit">
<TextField source="id" />
<ReferenceField source="shopid" reference="bodyshops">
<TextField source="shopname" />
</ReferenceField>
<TextField source="ro_number" />
<TextField source="est_number" />
<TextField source="ownr_fn" />
<TextField source="ownr_ln" />
<TextField source="ownr_co_nm" />
<ReferenceField source="ownerid" reference="owners">
<TextField source="id" />
</ReferenceField>
<TextField source="v_model_yr" />
<TextField source="v_make_desc" />
<TextField source="v_model_desc" />
<ReferenceField source="vehicleid" reference="vehicles">
<TextField source="id" />
</ReferenceField>
</Datagrid>
</List>
);
const JobsFilter = (props) => {
const { loading, error, data } = useQuery(QUERY_ALL_SHOPS);
if (loading) return <CircularProgress />;
if (error) return JSON.stringify(error);
return (
<Filter {...props}>
<TextInput label="RO Number" source="ro_number" />
<SelectInput
source="shopid"
choices={data.bodyshops.map((b) => {
return { id: b.id, name: b.shopname };
})}
alwaysOn
allowEmpty={false}
/>
</Filter>
);
};
export default JobsList;

View File

@@ -0,0 +1,281 @@
import React from "react";
import {
Datagrid,
EditButton,
NumberField,
ReferenceManyField,
Show,
Tab,
TabbedShowLayout,
TextField,
} from "react-admin";
const JobsShow = (props) => (
<Show {...props}>
<TabbedShowLayout>
<Tab label="summary">
<TextField source="id" />
<TextField source="created_at" />
<TextField source="updated_at" />
<TextField source="shopid" />
<TextField source="ro_number" />
<TextField source="ownerid" />
<TextField source="vehicleid" />
</Tab>
<Tab label="Job Lines">
<ReferenceManyField
reference="joblines"
target="jobid"
label="Job Lines"
>
<Datagrid>
<TextField source="id" />
<TextField source="line_ref" />
<TextField source="line_desc" />
<TextField source="line_ind" />
<NumberField source="db_price" />
<NumberField source="act_price" />
<NumberField source="part_qty" />
<NumberField source="mod_lb_hrs" />
<TextField source="mod_lbr_type" />
<TextField source="lbr_op" />
<EditButton />
</Datagrid>
</ReferenceManyField>
</Tab>
<Tab label="other">
<TextField source="labor_rate_id" />
<TextField source="labor_rate_desc" />
<TextField source="rate_lab" />
<TextField source="rate_lad" />
<TextField source="rate_lae" />
<TextField source="rate_lar" />
<TextField source="rate_las" />
<TextField source="rate_laf" />
<TextField source="rate_lam" />
<TextField source="rate_lag" />
<TextField source="rate_atp" />
<TextField source="rate_lau" />
<TextField source="rate_la1" />
<TextField source="rate_la2" />
<TextField source="rate_la3" />
<TextField source="rate_la4" />
<TextField source="rate_mapa" />
<TextField source="rate_mash" />
<TextField source="rate_mahw" />
<TextField source="rate_ma2s" />
<TextField source="rate_ma3s" />
<TextField source="rate_ma2t" />
<TextField source="rate_mabl" />
<TextField source="rate_macs" />
<TextField source="rate_matd" />
<TextField source="federal_tax_rate" />
<TextField source="state_tax_rate" />
<TextField source="local_tax_rate" />
<TextField source="est_co_nm" />
<TextField source="est_addr1" />
<TextField source="est_addr2" />
<TextField source="est_city" />
<TextField source="est_st" />
<TextField source="est_zip" />
<TextField source="est_ctry" />
<TextField source="est_ph1" />
<TextField source="est_ea" />
<TextField source="est_ct_ln" />
<TextField source="est_ct_fn" />
<TextField source="scheduled_in" />
<TextField source="actual_in" />
<TextField source="scheduled_completion" />
<TextField source="actual_completion" />
<TextField source="scheduled_delivery" />
<TextField source="actual_delivery" />
<TextField source="regie_number" />
<TextField source="invoice_date" />
<TextField source="inproduction" />
<TextField source="statusid" />
<TextField source="ins_co_id" />
<TextField source="ins_co_nm" />
<TextField source="ins_addr1" />
<TextField source="ins_addr2" />
<TextField source="ins_city" />
<TextField source="ins_st" />
<TextField source="ins_zip" />
<TextField source="ins_ctry" />
<TextField source="ins_ph1" />
<TextField source="ins_ph1x" />
<TextField source="ins_ph2" />
<TextField source="ins_ph2x" />
<TextField source="ins_fax" />
<TextField source="ins_faxx" />
<TextField source="ins_ct_ln" />
<TextField source="ins_ct_fn" />
<TextField source="ins_title" />
<TextField source="ins_ct_ph" />
<TextField source="ins_ct_phx" />
<TextField source="ins_ea" />
<TextField source="ins_memo" />
<TextField source="policy_no" />
<TextField source="ded_amt" />
<TextField source="ded_status" />
<TextField source="asgn_no" />
<TextField source="asgn_date" />
<TextField source="asgn_type" />
<TextField source="clm_no" />
<TextField source="clm_ofc_id" />
<TextField source="date_estimated" />
<TextField source="date_open" />
<TextField source="date_scheduled" />
<TextField source="date_invoiced" />
<TextField source="date_closed" />
<TextField source="date_exported" />
<TextField source="clm_total" />
<TextField source="owner_owing" />
<TextField source="converted" />
<TextField source="ciecaid" />
<TextField source="loss_date" />
<TextField source="clm_ofc_nm" />
<TextField source="clm_addr1" />
<TextField source="clm_addr2" />
<TextField source="clm_city" />
<TextField source="clm_st" />
<TextField source="clm_zip" />
<TextField source="clm_ctry" />
<TextField source="clm_ph1" />
<TextField source="clm_ph1x" />
<TextField source="clm_ph2" />
<TextField source="clm_ph2x" />
<TextField source="clm_fax" />
<TextField source="clm_faxx" />
<TextField source="clm_ct_ln" />
<TextField source="clm_ct_fn" />
<TextField source="clm_title" />
<TextField source="clm_ct_ph" />
<TextField source="clm_ct_phx" />
<TextField source="clm_ea" />
<TextField source="payee_nms" />
<TextField source="pay_type" />
<TextField source="pay_date" />
<TextField source="pay_chknm" />
<TextField source="pay_amt" />
<TextField source="agt_co_id" />
<TextField source="agt_co_nm" />
<TextField source="agt_addr1" />
<TextField source="agt_addr2" />
<TextField source="agt_city" />
<TextField source="agt_st" />
<TextField source="agt_zip" />
<TextField source="agt_ctry" />
<TextField source="agt_ph1" />
<TextField source="agt_ph1x" />
<TextField source="agt_ph2" />
<TextField source="agt_ph2x" />
<TextField source="agt_fax" />
<TextField source="agt_faxx" />
<TextField source="agt_ct_ln" />
<TextField source="agt_ct_fn" />
<TextField source="agt_ct_ph" />
<TextField source="agt_ct_phx" />
<TextField source="agt_ea" />
<TextField source="agt_lic_no" />
<TextField source="loss_type" />
<TextField source="loss_desc" />
<TextField source="theft_ind" />
<TextField source="cat_no" />
<TextField source="tlos_ind" />
<TextField source="cust_pr" />
<TextField source="insd_ln" />
<TextField source="insd_fn" />
<TextField source="insd_title" />
<TextField source="insd_co_nm" />
<TextField source="insd_addr1" />
<TextField source="insd_addr2" />
<TextField source="insd_city" />
<TextField source="insd_st" />
<TextField source="insd_zip" />
<TextField source="insd_ctry" />
<TextField source="insd_ph1" />
<TextField source="insd_ph1x" />
<TextField source="insd_ph2" />
<TextField source="insd_ph2x" />
<TextField source="insd_fax" />
<TextField source="insd_faxx" />
<TextField source="insd_ea" />
<TextField source="ownr_ln" />
<TextField source="ownr_fn" />
<TextField source="ownr_title" />
<TextField source="ownr_co_nm" />
<TextField source="ownr_addr1" />
<TextField source="ownr_addr2" />
<TextField source="ownr_city" />
<TextField source="ownr_st" />
<TextField source="ownr_zip" />
<TextField source="ownr_ctry" />
<TextField source="ownr_ph1" />
<TextField source="ownr_ph1x" />
<TextField source="ownr_ph2" />
<TextField source="ownr_ph2x" />
<TextField source="ownr_fax" />
<TextField source="ownr_faxx" />
<TextField source="ownr_ea" />
<TextField source="area_of_damage" />
<TextField source="loss_cat" />
<TextField source="est_number" />
<TextField source="special_coverage_policy" />
<TextField source="csr" />
<TextField source="po_number" />
<TextField source="unit_number" />
<TextField source="kmin" />
<TextField source="kmout" />
<TextField source="referral_source" />
<TextField source="selling_dealer" />
<TextField source="servicing_dealer" />
<TextField source="servicing_dealer_contact" />
<TextField source="selling_dealer_contact" />
<TextField source="depreciation_taxes" />
<TextField source="federal_tax_payable" />
<TextField source="other_amount_payable" />
<TextField source="towing_payable" />
<TextField source="storage_payable" />
<TextField source="adjustment_bottom_line" />
<TextField source="tax_pstthr" />
<TextField source="tax_tow_rt" />
<TextField source="tax_sub_rt" />
<TextField source="tax_paint_mat_rt" />
<TextField source="tax_levies_rt" />
<TextField source="tax_prethr" />
<TextField source="tax_thramt" />
<TextField source="tax_str_rt" />
<TextField source="tax_lbr_rt" />
<TextField source="adj_g_disc" />
<TextField source="adj_towdis" />
<TextField source="adj_strdis" />
<TextField source="tax_predis" />
<TextField source="rate_laa" />
<TextField source="status" />
<TextField source="cieca_stl" />
<TextField source="g_bett_amt" />
<TextField source="cieca_ttl" />
<TextField source="plate_no" />
<TextField source="plate_st" />
<TextField source="v_vin" />
<TextField source="v_model_yr" />
<TextField source="v_model_desc" />
<TextField source="v_make_desc" />
<TextField source="v_color" />
<TextField source="parts_tax_rates" />
<TextField source="job_totals" />
<TextField source="production_vars" />
<TextField source="intakechecklist" />
<TextField source="invoice_allocation" />
<TextField source="kanbanparent" />
<TextField source="employee_body" />
<TextField source="employee_refinish" />
<TextField source="employee_prep" />
</Tab>
</TabbedShowLayout>
</Show>
);
export default JobsShow;

View File

@@ -0,0 +1,31 @@
import firebase from "firebase/app";
import "firebase/firestore";
import "firebase/auth";
const config = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
firebase.initializeApp(config);
export const auth = firebase.auth();
export const firestore = firebase.firestore();
export default firebase;
export const getCurrentUser = () => {
return new Promise((resolve, reject) => {
const unsubscribe = auth.onAuthStateChanged((userAuth) => {
unsubscribe();
resolve(userAuth);
}, reject);
});
};
export const updateCurrentUser = (userDetails) => {
return new Promise((resolve, reject) => {
const unsubscribe = auth.onAuthStateChanged((userAuth) => {
userAuth.updateProfile(userDetails).then((r) => {
unsubscribe();
resolve(userAuth);
});
}, reject);
});
};

View File

@@ -0,0 +1,10 @@
import gql from "graphql-tag";
export const QUERY_ALL_SHOPS = gql`
query QUERY_ALL_SHOPS {
bodyshops {
id
shopname
}
}
`;

13
admin/src/index.css Normal file
View File

@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

17
admin/src/index.js Normal file
View File

@@ -0,0 +1,17 @@
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App/App";
import * as serviceWorker from "./serviceWorker";
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById("root")
);
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

141
admin/src/serviceWorker.js Normal file
View File

@@ -0,0 +1,141 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export function register(config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl, config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' },
})
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then(registration => {
registration.unregister();
})
.catch(error => {
console.error(error.message);
});
}
}

5
admin/src/setupTests.js Normal file
View File

@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom/extend-expect';

12271
admin/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +0,0 @@
module.exports = {
client: {
service: {
name: "Dev",
url: "https://bodyshop-dev-db.herokuapp.com/v1/graphql",
headers: {
"x-hasura-admin-secret": "Dev-BodyShopAppBySnaptSoftware!"
}
}
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +0,0 @@
[0309/123120.472:ERROR:process_reader_win.cc(108)] process 40916 not found
[0309/123120.472:ERROR:exception_snapshot_win.cc(98)] thread ID 50448 not found in process
[0309/123120.472:ERROR:scoped_process_suspend.cc(40)] NtResumeProcess: An attempt was made to access an exiting process. (0xc000010a)

View File

@@ -1,56 +1,70 @@
{
"name": "bodyshop",
"version": "0.1.0",
"version": "0.1.0001",
"private": true,
"proxy": "https://localhost:5000",
"proxy": "http://localhost:5000",
"dependencies": {
"@ckeditor/ckeditor5-build-classic": "^18.0.0",
"@ckeditor/ckeditor5-react": "^2.1.0",
"@nivo/pie": "^0.61.1",
"@tanem/react-nprogress": "^3.0.20",
"aamva": "^1.2.0",
"antd": "^4.1.0",
"apollo-boost": "^0.4.4",
"apollo-link-context": "^1.0.19",
"apollo-link-error": "^1.1.12",
"@lourenci/react-kanban": "^2.0.0",
"@stripe/react-stripe-js": "^1.1.2",
"@stripe/stripe-js": "^1.9.0",
"@tanem/react-nprogress": "^3.0.40",
"@tinymce/tinymce-react": "^3.6.0",
"antd": "^4.6.1",
"apollo-boost": "^0.4.9",
"apollo-link-context": "^1.0.20",
"apollo-link-error": "^1.1.13",
"apollo-link-logger": "^1.2.3",
"apollo-link-retry": "^2.2.15",
"apollo-link-ws": "^1.0.19",
"axios": "^0.19.2",
"apollo-link-retry": "^2.2.16",
"apollo-link-ws": "^1.0.20",
"axios": "^0.20.0",
"dinero.js": "^1.8.1",
"dotenv": "^8.2.0",
"firebase": "^7.13.1",
"graphql": "^14.6.0",
"i18next": "^19.3.4",
"node-sass": "^4.13.1",
"fingerprintjs2": "^2.1.2",
"firebase": "^7.19.0",
"graphql": "^15.3.0",
"i18next": "^19.7.0",
"i18next-browser-languagedetector": "^6.0.1",
"inline-css": "^2.6.3",
"logrocket": "^1.0.11",
"moment-business-days": "^1.2.0",
"node-sass": "^4.14.1",
"phone": "^2.4.15",
"prop-types": "^15.7.2",
"query-string": "^6.13.1",
"react": "^16.13.1",
"react-apollo": "^3.1.3",
"react-apollo": "^3.1.5",
"react-barcode": "^1.4.0",
"react-big-calendar": "^0.24.1",
"react-big-calendar": "^0.26.1",
"react-dom": "^16.13.1",
"react-drag-listview": "^0.1.7",
"react-email-editor": "^1.1.1",
"react-ga": "^3.1.2",
"react-grid-gallery": "^0.5.5",
"react-grid-layout": "^0.18.3",
"react-html-email": "^3.0.0",
"react-i18next": "^11.3.4",
"react-icons": "^3.9.0",
"react-image-file-resizer": "^0.2.1",
"react-grid-layout": "^1.0.0",
"react-i18next": "^11.7.1",
"react-icons": "^3.11.0",
"react-image-file-resizer": "^0.3.6",
"react-moment": "^0.9.7",
"react-number-format": "^4.4.1",
"react-pdf": "^4.1.0",
"react-redux": "^7.2.0",
"react-router-dom": "^5.1.2",
"react-scripts": "3.4.1",
"react-redux": "^7.2.1",
"react-resizable": "^1.10.1",
"react-router-dom": "^5.2.0",
"react-scripts": "3.4.3",
"react-trello": "^2.2.8",
"react-virtualized": "^9.22.2",
"recharts": "^1.8.5",
"redux": "^4.0.5",
"redux-logger": "^3.0.6",
"redux-persist": "^6.0.0",
"redux-saga": "^1.1.3",
"redux-state-sync": "^3.1.2",
"reselect": "^4.0.0",
"styled-components": "^5.0.1",
"subscriptions-transport-ws": "^0.9.16"
"styled-components": "^5.1.1",
"subscriptions-transport-ws": "^0.9.18"
},
"scripts": {
"analyze": "source-map-explorer 'build/static/js/*.js'",
"start": "react-scripts start",
"build": "react-scripts build",
"build": "REACT_APP_GIT_SHA=`git rev-parse --short HEAD` react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
@@ -70,9 +84,10 @@
]
},
"devDependencies": {
"@apollo/react-testing": "^3.1.3",
"@apollo/react-testing": "^4.0.0",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2",
"source-map-explorer": "^2.4.2"
"enzyme-adapter-react-16": "^1.15.3",
"redux-logger": "^3.0.6",
"source-map-explorer": "^2.5.0"
}
}

95
client/public/editor.js Normal file
View File

@@ -0,0 +1,95 @@
// unlayer.registerPropertyEditor({
// name: "field_name",
// layout: "bottom",
// Widget: unlayer.createWidget({
// render(value) {
// return `
// <input class="field" value=${value} />
// `;
// },
// mount(node, value, updateValue) {
// var input = node.getElementsByClassName("field")[0];
// input.onchange = function (event) {
// updateValue(event.target.value);
// };
// },
// }),
// });
// unlayer.registerTool({
// type: "whatever",
// category: "contents",
// label: "Begin Repeat",
// icon: "fa-smile",
// values: {},
// options: {
// default: {
// title: null,
// },
// text: {
// title: "Field",
// position: 1,
// options: {
// field: {
// label: "Field",
// defaultValue: "",
// widget: "field_name",
// },
// },
// },
// },
// renderer: {
// Viewer: unlayer.createViewer({
// render(values) {
// console.log(values);
// return `
// <div style="display: none;">{{#each ${values.field}}}</div>
// `;
// },
// }),
// exporters: {
// web: function () {},
// email: function () {},
// },
// },
// });
// unlayer.registerTool({
// type: "whatever",
// category: "contents",
// label: "End Repeat",
// icon: "fa-smile",
// values: {},
// options: {
// default: {
// title: null,
// },
// text: {
// title: "Field",
// position: 1,
// options: {
// field: {
// label: "Field",
// defaultValue: "",
// widget: "field_name",
// },
// },
// },
// },
// renderer: {
// Viewer: unlayer.createViewer({
// render(values) {
// return `
// <div style="display: none;">{{ /each }}</div>
// `;
// },
// }),
// exporters: {
// web: function () {},
// email: function () {},
// },
// },
// });
unlayer.registerColumns([2, 2, 2, 2, 2, 2]);
unlayer.registerColumns([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]);

View File

@@ -0,0 +1,53 @@
importScripts("https://www.gstatic.com/firebasejs/7.14.2/firebase-app.js");
importScripts(
"https://www.gstatic.com/firebasejs/7.14.2/firebase-messaging.js"
);
firebase.initializeApp({
apiKey: "AIzaSyDSezy-jGJreo7ulgpLdlpOwAOrgcaEkhU",
authDomain: "imex-prod.firebaseapp.com",
databaseURL: "https://imex-prod.firebaseio.com",
projectId: "imex-prod",
storageBucket: "imex-prod.appspot.com",
messagingSenderId: "253497221485",
appId: "1:253497221485:web:3c81c483b94db84b227a64",
measurementId: "G-NTWBKG2L0M",
});
// firebase.initializeApp({
// apiKey: "AIzaSyDPLT8GiDHDR1R4nI66Qi0BY1aYviDPioc",
// authDomain: "imex-dev.firebaseapp.com",
// databaseURL: "https://imex-dev.firebaseio.com",
// projectId: "imex-dev",
// storageBucket: "imex-dev.appspot.com",
// messagingSenderId: "759548147434",
// appId: "1:759548147434:web:e8239868a48ceb36700993",
// measurementId: "G-K5XRBVVB4S",
// });
const messaging = firebase.messaging();
self.addEventListener("fetch", (fetch) => {
//required for installation as a PWA. Can ignore for now.
//console.log("fetch", fetch);
});
messaging.setBackgroundMessageHandler(function (payload) {
return self.registration.showNotification(
"[SW]" + payload.notification.title,
payload.notification
);
});
//Handles the notification getting clicked.
self.addEventListener("notificationclick", function (event) {
console.log("SW notificationclick", event);
// event.notification.close();
if (event.action === "archive") {
// Archive action was clicked
archiveEmail();
} else {
// Main body of notification was clicked
clients.openWindow("/inbox");
}
});

View File

@@ -26,7 +26,7 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>BodyShop | ImEX Systems Inc.</title>
<title>ImEX Online</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@@ -1,7 +1,7 @@
{
"short_name": "Bodyshop.app",
"name": "Bodyshop Management System",
"description": "The ultimate bodyshop management system",
"short_name": "ImEX Online",
"name": "ImEX Online",
"description": "The ultimate bodyshop management system.",
"icons": [
{
"src": "favicon.ico",
@@ -22,5 +22,6 @@
"start_url": ".",
"display": "standalone",
"theme_color": "#fff",
"background_color": "#fff"
"background_color": "#fff",
"gcm_sender_id": "103953800507"
}

View File

@@ -0,0 +1,85 @@
/* body {
font-family: "Open Sans", sans-serif;
line-height: 1.25;
} */
table {
border: 1px solid #ccc;
border-collapse: collapse;
margin: 0;
padding: 0;
width: 100%;
table-layout: fixed;
}
table caption {
font-size: 1.5em;
margin: 0.5em 0 0.75em;
}
table tr {
background-color: #f8f8f8;
border: 1px solid #ddd;
padding: 0.35em;
}
table th,
table td {
padding: 0.625em;
text-align: center;
}
table th {
font-size: 0.85em;
letter-spacing: 0.1em;
text-transform: uppercase;
}
@media screen and (max-width: 600px) {
table {
border: 0;
}
table caption {
font-size: 1.3em;
}
table thead {
border: none;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
table tr {
border-bottom: 3px solid #ddd;
display: block;
margin-bottom: 0.625em;
}
table td {
border-bottom: 1px solid #ddd;
display: block;
font-size: 0.8em;
text-align: right;
}
table td::before {
/*
* aria-label has no advantage, it won't be read inside a table
content: attr(aria-label);
*/
content: attr(data-label);
float: left;
font-weight: bold;
text-transform: uppercase;
}
table td:last-child {
border-bottom: 0;
}
}

View File

@@ -1,4 +1,6 @@
import { ApolloProvider } from "@apollo/react-common";
import { ConfigProvider } from "antd";
import enLocale from "antd/es/locale/en_US";
import { ApolloLink } from "apollo-boost";
import { InMemoryCache } from "apollo-cache-inmemory";
import ApolloClient from "apollo-client";
@@ -9,117 +11,148 @@ import apolloLogger from "apollo-link-logger";
import { RetryLink } from "apollo-link-retry";
import { WebSocketLink } from "apollo-link-ws";
import { getMainDefinition } from "apollo-utilities";
import React, { Component } from "react";
import axios from "axios";
import LogRocket from "logrocket";
import moment from "moment";
import React from "react";
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
import { auth } from "../firebase/firebase.utils";
import errorLink from "../graphql/apollo-error-handling";
import App from "./App";
export default class AppContainer extends Component {
constructor() {
super();
const httpLink = new HttpLink({
uri: process.env.REACT_APP_GRAPHQL_ENDPOINT
});
const wsLink = new WebSocketLink({
uri: process.env.REACT_APP_GRAPHQL_ENDPOINT_WS,
options: {
lazy: true,
reconnect: true,
connectionParams: async () => {
//const token = localStorage.getItem("token");
const token = await auth.currentUser.getIdToken(true);
if (token) {
return {
headers: {
authorization: token ? `Bearer ${token}` : ""
}
};
}
}
moment.locale("en-US");
axios.interceptors.request.use(
async (config) => {
if (!config.headers.Authorization) {
const token =
auth.currentUser && (await auth.currentUser.getIdToken(true));
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
});
const subscriptionMiddleware = {
applyMiddleware: async (options, next) => {
options.authToken = await auth.currentUser.getIdToken(true);
next();
}
};
wsLink.subscriptionClient.use([subscriptionMiddleware]);
const link = split(
// split based on operation type
({ query }) => {
const definition = getMainDefinition(query);
// console.log(
// "##Intercepted GQL Transaction : " +
// definition.operation +
// "|" +
// definition.name.value +
// "##",
// query
// );
return (
definition.kind === "OperationDefinition" &&
definition.operation === "subscription"
);
},
wsLink,
httpLink
);
const authLink = setContext((_, { headers }) => {
return auth.currentUser.getIdToken().then(token => {
if (token) {
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : ""
}
};
} else {
return { headers };
}
});
});
const retryLink = new RetryLink({
delay: {
initial: 300,
max: 5,
jitter: true
},
attempts: {
max: 5,
retryIf: (error, _operation) => !!error
}
});
const middlewares = [];
if (process.env.NODE_ENV === "development") {
middlewares.push(apolloLogger);
}
middlewares.push(retryLink.concat(errorLink.concat(authLink.concat(link))));
return config;
},
(error) => Promise.reject(error)
);
const cache = new InMemoryCache();
const client = new ApolloClient({
link: ApolloLink.from(middlewares),
cache,
connectToDevTools: true
});
if (process.env.NODE_ENV === "production") LogRocket.init("gvfvfw/bodyshopapp");
this.state = { client };
}
const httpLink = new HttpLink({
uri: process.env.REACT_APP_GRAPHQL_ENDPOINT,
});
render() {
const { client } = this.state;
const wsLink = new WebSocketLink({
uri: process.env.REACT_APP_GRAPHQL_ENDPOINT_WS,
options: {
lazy: true,
reconnect: true,
connectionParams: async () => {
const token =
auth.currentUser && (await auth.currentUser.getIdToken(true));
if (token) {
return {
headers: {
authorization: token ? `Bearer ${token}` : "",
},
};
}
},
},
});
const subscriptionMiddleware = {
applyMiddleware: async (options, next) => {
options.authToken =
auth.currentUser && (await auth.currentUser.getIdToken(true));
next();
},
};
wsLink.subscriptionClient.use([subscriptionMiddleware]);
const link = split(
// split based on operation type
({ query }) => {
const definition = getMainDefinition(query);
// console.log(
// "##Intercepted GQL Transaction : " +
// definition.operation +
// "|" +
// definition.name.value +
// "##",
// query
// );
return (
<ApolloProvider client={client}>
definition.kind === "OperationDefinition" &&
definition.operation === "subscription"
);
},
wsLink,
httpLink
);
const authLink = setContext((_, { headers }) => {
return (
auth.currentUser &&
auth.currentUser.getIdToken().then((token) => {
if (token) {
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : "",
},
};
} else {
return { headers };
}
})
);
});
const retryLink = new RetryLink({
delay: {
initial: 500,
max: 5,
jitter: true,
},
attempts: {
max: 5,
retryIf: (error, _operation) => !!error,
},
});
const middlewares = [];
if (process.env.NODE_ENV === "development") {
middlewares.push(apolloLogger);
}
middlewares.push(retryLink.concat(errorLink.concat(authLink.concat(link))));
const cache = new InMemoryCache({});
export const client = new ApolloClient({
link: ApolloLink.from(middlewares),
cache,
connectToDevTools: process.env.NODE_ENV !== "production",
defaultOptions: {
watchQuery: {
fetchPolicy: "cache-and-network",
},
},
});
export default function AppContainer() {
return (
<ApolloProvider client={client}>
<ConfigProvider
componentSize="small"
input={{ autoComplete: "new-password" }}
locale={enLocale}
>
<GlobalLoadingBar />
<App />
</ApolloProvider>
);
}
</ConfigProvider>
</ApolloProvider>
);
}

View File

@@ -1 +0,0 @@
@import "~antd/dist/antd.css";

View File

@@ -1,70 +0,0 @@
import i18next from "i18next";
import React, { lazy, Suspense, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Route, Switch } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import ErrorBoundary from "../components/error-boundary/error-boundary.component";
//Component Imports
import LoadingSpinner from "../components/loading-spinner/loading-spinner.component";
import { checkUserSession } from "../redux/user/user.actions";
import { selectCurrentUser } from "../redux/user/user.selectors";
// import { QUERY_BODYSHOP } from "../graphql/bodyshop.queries";
import PrivateRoute from "../utils/private-route";
import "./App.css";
const LandingPage = lazy(() => import("../pages/landing/landing.page"));
const ManagePage = lazy(() => import("../pages/manage/manage.page.container"));
const SignInPage = lazy(() => import("../pages/sign-in/sign-in.page"));
const Unauthorized = lazy(() =>
import("../pages/unauthorized/unauthorized.component")
);
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser
});
const mapDispatchToProps = dispatch => ({
checkUserSession: () => dispatch(checkUserSession())
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(({ checkUserSession, currentUser }) => {
useEffect(() => {
checkUserSession();
return () => {};
}, [checkUserSession]);
const { t } = useTranslation();
if (currentUser && currentUser.language)
i18next.changeLanguage(currentUser.language, err => {
if (err)
return console.log("Error encountered when changing languages.", err);
});
if (currentUser.authorized === null) {
return <LoadingSpinner message={t("general.labels.loggingin")} />;
}
return (
<div>
<Switch>
<ErrorBoundary>
<Suspense fallback={<LoadingSpinner message='App.Js Suspense' />}>
<Route exact path='/' component={LandingPage} />
<Route exact path='/unauthorized' component={Unauthorized} />
<Route exact path='/signin' component={SignInPage} />
<PrivateRoute
isAuthorized={currentUser.authorized}
path='/manage'
component={ManagePage}
/>
</Suspense>
</ErrorBoundary>
</Switch>
</div>
);
});

92
client/src/App/App.jsx Normal file
View File

@@ -0,0 +1,92 @@
import "antd/dist/antd.css";
import React, { lazy, Suspense, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Route, Switch } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import ErrorBoundary from "../components/error-boundary/error-boundary.component";
//Component Imports
import LoadingSpinner from "../components/loading-spinner/loading-spinner.component";
import TechPageContainer from "../pages/tech/tech.page.container";
import { checkUserSession } from "../redux/user/user.actions";
import { selectCurrentUser } from "../redux/user/user.selectors";
import PrivateRoute from "../utils/private-route";
import "./App.styles.scss";
const LandingPage = lazy(() => import("../pages/landing/landing.page"));
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,
});
const mapDispatchToProps = (dispatch) => ({
checkUserSession: () => dispatch(checkUserSession()),
});
export function App({ checkUserSession, currentUser }) {
useEffect(() => {
checkUserSession();
}, [checkUserSession]);
//const b = Grid.useBreakpoint();
// console.log("Breakpoints:", b);
const { t } = useTranslation();
if (currentUser.authorized === null) {
return <LoadingSpinner message={t("general.labels.loggingin")} />;
}
return (
<div>
<Switch>
<Suspense fallback={<LoadingSpinner message="App.Js Suspense" />}>
<ErrorBoundary>
<Route exact path="/" component={LandingPage} />
</ErrorBoundary>
<ErrorBoundary>
<Route exact path="/signin" component={SignInPage} />
</ErrorBoundary>
<ErrorBoundary>
<Route exact path="/resetpassword" component={ResetPassword} />
</ErrorBoundary>
<ErrorBoundary>
<Route exact path="/csi/:surveyId" component={CsiPage} />
</ErrorBoundary>
<ErrorBoundary>
<Route
exact
path="/mp/:paymentIs"
component={MobilePaymentContainer}
/>
</ErrorBoundary>
<ErrorBoundary>
<PrivateRoute
isAuthorized={currentUser.authorized}
path="/manage"
component={ManagePage}
/>
</ErrorBoundary>
<ErrorBoundary>
<PrivateRoute
isAuthorized={currentUser.authorized}
path="/tech"
component={TechPageContainer}
/>
</ErrorBoundary>
</Suspense>
</Switch>
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(App);

View File

@@ -0,0 +1,72 @@
//Global Styles.
.imex-table-header {
display: flex;
flex-wrap: wrap;
justify-content: center;
&__search {
flex: 1;
}
}
.imex-flex-row {
display: flex;
justify-content: flex-start;
flex-wrap: wrap;
align-items: center;
&__grow {
flex: 1;
}
&__margin {
margin: 0.2rem 0.2rem;
}
&__margin-large {
margin: 0.5rem 0.5rem;
}
&__flex-space-around {
justify-content: space-around;
}
}
.ellipses {
display: inline-block; /* for em, a, span, etc (inline by default) */
text-overflow: ellipsis;
width: calc(95%);
overflow: hidden;
white-space: nowrap;
}
.tight-antd-rows {
.ant-row {
margin: 0rem;
line-height: 1rem;
}
}
::-webkit-scrollbar-track {
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
border-radius: 0.2rem;
background-color: #f5f5f5;
}
::-webkit-scrollbar {
width: 0.25rem;
max-height: 0.25rem;
background-color: #f5f5f5;
}
::-webkit-scrollbar-thumb {
border-radius: 0.2rem;
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
background-color: #188fff;
}
.ant-table-cell {
// background-color: red;
padding: 0.2rem !important;
}

View File

@@ -0,0 +1,42 @@
import { AlertOutlined } from "@ant-design/icons";
import { Button, notification } from "antd";
import i18n from "i18next";
import React from "react";
import * as serviceWorker from "../serviceWorker";
const onServiceWorkerUpdate = (registration) => {
console.log("[RSW] onServiceWorkerUpdate", registration);
const key = `open${Date.now()}`;
const btn = (
<Button
type="primary"
onClick={async () => {
if (registration && registration.waiting) {
await registration.unregister();
// Makes Workbox call skipWaiting()
registration.waiting.postMessage({ type: "SKIP_WAITING" });
// Once the service worker is unregistered, we can reload the page to let
// the browser download a fresh copy of our app (invalidating the cache)
window.location.reload();
}
}}
>
{i18n.t("general.actions.refresh")}
</Button>
);
notification.open({
icon: <AlertOutlined />,
message: i18n.t("general.messages.newversiontitle"),
description: i18n.t("general.messages.newversionmessage"),
duration: 0,
btn,
key,
});
};
// if (process.env.NODE_ENV === "production") {
// console.log("SWR Registering SW...");
console.log("Registering Service Worker...");
serviceWorker.register({ onUpdate: onServiceWorkerUpdate });
// }

View File

@@ -0,0 +1,96 @@
import {
PaymentRequestButtonElement,
useStripe
} from "@stripe/react-stripe-js";
import React, { useEffect, useState } from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { setEmailOptions } from "../../redux/email/email.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { TemplateList } from "../../utils/TemplateConstants";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
});
const mapDispatchToProps = (dispatch) => ({
setEmailOptions: (e) => dispatch(setEmailOptions(e)),
});
function Test({ bodyshop, setEmailOptions }) {
const stripe = useStripe();
const [paymentRequest, setPaymentRequest] = useState(null);
useEffect(() => {
if (stripe) {
const pr = stripe.paymentRequest({
country: "CA",
displayItems: [{ label: "Deductible", amount: 1099 }],
currency: "cad",
total: {
label: "Demo total",
amount: 1099,
},
requestPayerName: true,
requestPayerEmail: true,
});
// Check the availability of the Payment Request API.
pr.canMakePayment().then((result) => {
if (result) {
setPaymentRequest(pr);
} else {
// var details = {
// total: { label: "", amount: { currency: "CAD", value: "0.00" } },
// };
new PaymentRequest(
[{ supportedMethods: ["basic-card"] }],
{}
// details
).show();
}
});
}
}, [stripe]);
if (paymentRequest) {
return (
<div style={{ height: "300px" }}>
<PaymentRequestButtonElement options={{ paymentRequest }} />
</div>
);
}
return (
<div>
<button
onClick={() => {
setEmailOptions({
messageOptions: {
to: ["patrickwf@gmail.com"],
replyTo: bodyshop.email,
},
template: {
name: TemplateList.parts_order_confirmation.key,
variables: {
id: "a7c2d4e1-f519-42a9-a071-c48cf0f22979",
},
},
});
}}
>
send email
</button>
<button
onClick={() => {
logImEXEvent("IMEXEVENT", { somethignArThare: 5 });
}}
>
Log an ImEX Event.
</button>
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(Test);

View File

@@ -1,51 +1,25 @@
import Axios from "axios";
import React from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import {
startLoading,
endLoading
} from "../../redux/application/application.actions";
import { setEmailOptions } from "../../redux/email/email.actions";
import T, {
Subject
} from "../../emails/templates/appointment-confirmation/appointment-confirmation.template";
import { EMAIL_APPOINTMENT_CONFIRMATION } from "../../emails/templates/appointment-confirmation/appointment-confirmation.query";
export default function Test() {
const handleQbSignIn = async () => {
const result = await Axios.post("/qbo/authorize", { userId: "1234" });
console.log("handleQbSignIn -> result", result.data);
// window.open(result.data, "_blank", "toolbar=0,location=0,menubar=0");
var parameters = "location=1,width=800,height=650";
parameters +=
",left=" +
(window.screen.width - 800) / 2 +
",top=" +
(window.screen.height - 650) / 2;
// Launch Popup
window.open(result.data, "connectPopup", parameters);
};
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
});
const mapDispatchToProps = dispatch => ({
setEmailOptions: e => dispatch(setEmailOptions(e)),
load: () => dispatch(startLoading()),
endload: () => dispatch(endLoading())
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(function Test({ setEmailOptions, load, endload }) {
return (
<div>
<button
onClick={() =>
setEmailOptions({
messageOptions: {
from: { name: "Kavia Autobody", address: "noreply@bodyshop.app" },
to: "patrickwf@gmail.com",
replyTo: "snaptsoft@gmail.com",
subject: Subject
},
template: T,
queryConfig: [
EMAIL_APPOINTMENT_CONFIRMATION,
{ variables: { id: "91bb31dd-ea87-4cfc-bbe2-2ec754dcb861" } }
]
})
}
>
Set email config.
</button>
<button onClick={() => load()}>Load</button>
<button onClick={() => endload()}>Stop</button>
<button onClick={handleQbSignIn}>Sign Into Qb.</button>
</div>
);
});
}

View File

@@ -0,0 +1,193 @@
import { Input, Table, Checkbox } from "antd";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { alphaSort } from "../../utils/sorters";
import InvoiceExportButton from "../invoice-export-button/invoice-export-button.component";
import InvoiceExportAllButton from "../invoice-export-all-button/invoice-export-all-button.component";
import { DateFormatter } from "../../utils/DateFormatter";
import queryString from "query-string";
import { logImEXEvent } from "../../firebase/firebase.utils";
export default function AccountingPayablesTableComponent({
loading,
invoices,
}) {
const { t } = useTranslation();
const [selectedInvoices, setSelectedInvoices] = useState([]);
const [transInProgress, setTransInProgress] = useState(false);
const [state, setState] = useState({
sortedInfo: {},
search: "",
});
const handleTableChange = (pagination, filters, sorter) => {
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
};
const columns = [
{
title: t("invoices.fields.vendorname"),
dataIndex: "vendorname",
key: "vendorname",
sorter: (a, b) => alphaSort(a.vendor.name, b.vendor.name),
sortOrder:
state.sortedInfo.columnKey === "vendorname" && state.sortedInfo.order,
render: (text, record) => (
<Link
to={{
pathname: `/manage/shop/vendors`,
search: queryString.stringify({ selectedvendor: record.vendor.id }),
}}
>
{record.vendor.name}
</Link>
),
},
{
title: t("invoices.fields.invoice_number"),
dataIndex: "invoice_number",
key: "invoice_number",
sorter: (a, b) => alphaSort(a.invoice_number, b.invoice_number),
sortOrder:
state.sortedInfo.columnKey === "invoice_number" &&
state.sortedInfo.order,
render: (text, record) => (
<Link
to={{
pathname: `/manage/invoices`,
search: queryString.stringify({
invoiceid: record.id,
vendorid: record.vendor.id,
}),
}}
>
{record.invoice_number}
</Link>
),
},
{
title: t("jobs.fields.ro_number"),
dataIndex: "ro_number",
key: "ro_number",
sorter: (a, b) => alphaSort(a.job.ro_number, b.job.ro_number),
sortOrder:
state.sortedInfo.columnKey === "ro_number" && state.sortedInfo.order,
render: (text, record) => (
<Link to={`/manage/jobs/${record.job.id}`}>{record.job.ro_number}</Link>
),
},
{
title: t("invoices.fields.date"),
dataIndex: "date",
key: "date",
sorter: (a, b) => a.date - b.date,
sortOrder:
state.sortedInfo.columnKey === "date" && state.sortedInfo.order,
render: (text, record) => <DateFormatter>{record.date}</DateFormatter>,
},
{
title: t("invoices.fields.total"),
dataIndex: "total",
key: "total",
sorter: (a, b) => a.total - b.total,
sortOrder:
state.sortedInfo.columnKey === "total" && state.sortedInfo.order,
render: (text, record) => (
<CurrencyFormatter>{record.total}</CurrencyFormatter>
),
},
{
title: t("invoices.fields.is_credit_memo"),
dataIndex: "is_credit_memo",
key: "is_credit_memo",
sorter: (a, b) => a.is_credit_memo - b.is_credit_memo,
sortOrder:
state.sortedInfo.columnKey === "is_credit_memo" &&
state.sortedInfo.order,
render: (text, record) => (
<Checkbox disabled checked={record.is_credit_memo} />
),
},
{
title: t("general.labels.actions"),
dataIndex: "actions",
key: "actions",
sorter: (a, b) => a.clm_total - b.clm_total,
render: (text, record) => (
<div>
<InvoiceExportButton
invoiceId={record.id}
disabled={transInProgress || !!record.exported}
loadingCallback={setTransInProgress}
/>
</div>
),
},
];
const handleSearch = (e) => {
setState({ ...state, search: e.target.value });
logImEXEvent("accounting_payables_table_search");
};
const dataSource = state.search
? invoices.filter(
(v) =>
(v.vendor.name || "")
.toLowerCase()
.includes(state.search.toLowerCase()) ||
(v.invoice_number || "")
.toLowerCase()
.includes(state.search.toLowerCase())
)
: invoices;
return (
<div>
<Table
loading={loading}
title={() => {
return (
<div>
<Input
value={state.search}
onChange={handleSearch}
placeholder={t("general.labels.search")}
allowClear
/>
<InvoiceExportAllButton
invoiceIds={selectedInvoices}
disabled={transInProgress || selectedInvoices.length === 0}
loadingCallback={setTransInProgress}
completedCallback={setSelectedInvoices}
/>
</div>
);
}}
dataSource={dataSource}
size="small"
pagination={{ position: "top", pageSize: 50 }}
columns={columns}
rowKey="id"
onChange={handleTableChange}
rowSelection={{
onSelectAll: (selected, selectedRows) =>
setSelectedInvoices(selectedRows.map((i) => i.id)),
onSelect: (record, selected, selectedRows, nativeEvent) => {
setSelectedInvoices(selectedRows.map((i) => i.id));
},
getCheckboxProps: (record) => ({
disabled: record.exported,
}),
selectedRowKeys: selectedInvoices,
type: "checkbox",
}}
/>
</div>
);
}

View File

@@ -0,0 +1,193 @@
import { Input, Table } from "antd";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateTimeFormatter } from "../../utils/DateFormatter";
import { alphaSort } from "../../utils/sorters";
import PaymentExportButton from "../payment-export-button/payment-export-button.component";
import { PaymentsExportAllButton } from "../payments-export-all-button/payments-export-all-button.component";
import { logImEXEvent } from "../../firebase/firebase.utils";
export default function AccountingPayablesTableComponent({
loading,
payments,
}) {
const { t } = useTranslation();
const [selectedPayments, setSelectedPayments] = useState([]);
const [transInProgress, setTransInProgress] = useState(false);
const [state, setState] = useState({
sortedInfo: {},
search: "",
});
const handleTableChange = (pagination, filters, sorter) => {
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
};
const columns = [
{
title: t("jobs.fields.ro_number"),
dataIndex: "ro_number",
key: "ro_number",
sorter: (a, b) => alphaSort(a.job.ro_number, b.job.ro_number),
sortOrder:
state.sortedInfo.columnKey === "ro_number" && state.sortedInfo.order,
render: (text, record) => (
<Link to={"/manage/jobs/" + record.job.id}>{record.job.ro_number}</Link>
),
},
{
title: t("jobs.fields.est_number"),
dataIndex: "est_number",
key: "est_number",
sorter: (a, b) => a.job.est_number - b.job.est_number,
sortOrder:
state.sortedInfo.columnKey === "est_number" && state.sortedInfo.order,
render: (text, record) => (
<Link to={"/manage/jobs/" + record.job.id}>
{record.job.est_number}
</Link>
),
},
{
title: t("jobs.fields.owner"),
dataIndex: "owner",
key: "owner",
ellipsis: true,
sorter: (a, b) => alphaSort(a.job.ownr_ln, b.job.ownr_ln),
sortOrder:
state.sortedInfo.columnKey === "ownr_ln" && state.sortedInfo.order,
render: (text, record) => {
return record.job.owner ? (
<Link to={"/manage/owners/" + record.job.owner.id}>
{`${record.job.ownr_fn || ""} ${record.job.ownr_ln || ""} ${
record.job.ownr_co_nm
}`}
</Link>
) : (
<span>{`${record.job.ownr_fn || ""} ${record.job.ownr_ln || ""} ${
record.job.ownr_co_nm
}`}</span>
);
},
},
{
title: t("payments.fields.amount"),
dataIndex: "amount",
key: "amount",
render: (text, record) => (
<CurrencyFormatter>{record.amount}</CurrencyFormatter>
),
},
{
title: t("payments.fields.memo"),
dataIndex: "memo",
key: "memo",
},
{
title: t("payments.fields.transactionid"),
dataIndex: "transactionid",
key: "transactionid",
},
{
title: t("payments.fields.stripeid"),
dataIndex: "stripeid",
key: "stripeid",
},
{
title: t("payments.fields.created_at"),
dataIndex: "created_at",
key: "created_at",
render: (text, record) => (
<DateTimeFormatter>{record.created_at}</DateTimeFormatter>
),
},
{
title: t("payments.fields.exportedat"),
dataIndex: "exportedat",
key: "exportedat",
render: (text, record) => (
<DateTimeFormatter>{record.exportedat}</DateTimeFormatter>
),
},
{
title: t("general.labels.actions"),
dataIndex: "actions",
key: "actions",
sorter: (a, b) => a.clm_total - b.clm_total,
render: (text, record) => (
<div>
<PaymentExportButton
paymentId={record.id}
disabled={transInProgress || !!record.exportedat}
loadingCallback={setTransInProgress}
/>
</div>
),
},
];
const handleSearch = (e) => {
setState({ ...state, search: e.target.value });
logImEXEvent("account_payments_table_search");
};
const dataSource = state.search
? payments.filter(
(v) =>
(v.vendor.name || "")
.toLowerCase()
.includes(state.search.toLowerCase()) ||
(v.invoice_number || "")
.toLowerCase()
.includes(state.search.toLowerCase())
)
: payments;
return (
<div>
<Table
loading={loading}
title={() => {
return (
<div>
<Input
value={state.search}
onChange={handleSearch}
placeholder={t("general.labels.search")}
allowClear
/>
<PaymentsExportAllButton
paymentIds={selectedPayments}
disabled={transInProgress}
loadingCallback={setTransInProgress}
completedCallback={setSelectedPayments}
/>
</div>
);
}}
dataSource={dataSource}
size="small"
pagination={{ position: "top", pageSize: 50 }}
columns={columns}
rowKey="id"
onChange={handleTableChange}
rowSelection={{
onSelectAll: (selected, selectedRows) =>
setSelectedPayments(selectedRows.map((i) => i.id)),
onSelect: (record, selected, selectedRows, nativeEvent) => {
setSelectedPayments(selectedRows.map((i) => i.id));
},
getCheckboxProps: (record) => ({
disabled: record.exported,
}),
selectedRowKeys: selectedPayments,
type: "checkbox",
}}
/>
</div>
);
}

View File

@@ -0,0 +1,226 @@
import { Input, Table, Button } from "antd";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { alphaSort } from "../../utils/sorters";
import JobExportButton from "../jobs-close-export-button/jobs-close-export-button.component";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { JobsExportAllButton } from "../jobs-export-all-button/jobs-export-all-button.component";
export default function AccountingReceivablesTableComponent({ loading, jobs }) {
const { t } = useTranslation();
const [selectedJobs, setSelectedJobs] = useState([]);
const [transInProgress, setTransInProgress] = useState(false);
const [state, setState] = useState({
sortedInfo: {},
search: "",
});
const handleTableChange = (pagination, filters, sorter) => {
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
};
const columns = [
{
title: t("jobs.fields.ro_number"),
dataIndex: "ro_number",
key: "ro_number",
sorter: (a, b) => alphaSort(a.ro_number, b.ro_number),
sortOrder:
state.sortedInfo.columnKey === "ro_number" && state.sortedInfo.order,
render: (text, record) => (
<Link to={"/manage/jobs/" + record.id}>{record.ro_number}</Link>
),
},
{
title: t("jobs.fields.est_number"),
dataIndex: "est_number",
key: "est_number",
sorter: (a, b) => a.est_number - b.est_number,
sortOrder:
state.sortedInfo.columnKey === "est_number" && state.sortedInfo.order,
render: (text, record) => (
<Link to={"/manage/jobs/" + record.id}>{record.est_number}</Link>
),
},
{
title: t("jobs.fields.status"),
dataIndex: "status",
key: "status",
sorter: (a, b) => a.status - b.status,
sortOrder:
state.sortedInfo.columnKey === "status" && state.sortedInfo.order,
},
{
title: t("jobs.fields.owner"),
dataIndex: "owner",
key: "owner",
sorter: (a, b) => alphaSort(a.ownr_ln, b.ownr_ln),
sortOrder:
state.sortedInfo.columnKey === "owner" && state.sortedInfo.order,
render: (text, record) => {
return record.owner ? (
<Link to={"/manage/owners/" + record.owner.id}>
{`${record.ownr_fn || ""} ${record.ownr_ln || ""} ${
record.ownr_co_nm || ""
}`}
</Link>
) : (
<span>{`${record.ownr_fn || ""} ${record.ownr_ln || ""} ${
record.ownr_co_nm || ""
}`}</span>
);
},
},
{
title: t("jobs.fields.vehicle"),
dataIndex: "vehicle",
key: "vehicle",
ellipsis: true,
render: (text, record) => {
return record.vehicleid ? (
<Link to={"/manage/vehicles/" + record.vehicleid}>
{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${
record.v_model_desc || ""
}`}
</Link>
) : (
<span>{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${
record.v_model_desc || ""
}`}</span>
);
},
},
{
title: t("jobs.fields.clm_no"),
dataIndex: "clm_no",
key: "clm_no",
ellipsis: true,
sorter: (a, b) => alphaSort(a.clm_no, b.clm_no),
sortOrder:
state.sortedInfo.columnKey === "clm_no" && state.sortedInfo.order,
render: (text, record) => {
return record.clm_no ? (
<span>{record.clm_no}</span>
) : (
t("general.labels.unknown")
);
},
},
{
title: t("jobs.fields.clm_total"),
dataIndex: "clm_total",
key: "clm_total",
sorter: (a, b) => a.clm_total - b.clm_total,
sortOrder:
state.sortedInfo.columnKey === "clm_total" && state.sortedInfo.order,
render: (text, record) => {
return record.clm_total ? (
<CurrencyFormatter>{record.clm_total}</CurrencyFormatter>
) : (
t("general.labels.unknown")
);
},
},
{
title: t("general.labels.actions"),
dataIndex: "actions",
key: "actions",
sorter: (a, b) => a.clm_total - b.clm_total,
render: (text, record) => (
<div style={{ display: "flex" }}>
<JobExportButton
jobId={record.id}
disabled={!!record.date_exported}
/>
<Link to={`/manage/jobs/${record.id}/close`}>
<Button>{t("jobs.labels.viewallocations")}</Button>
</Link>
</div>
),
},
];
const handleSearch = (e) => {
setState({ ...state, search: e.target.value });
logImEXEvent("accounting_receivables_search");
};
const dataSource = state.search
? jobs.filter(
(v) =>
(v.ro_number || "")
.toString()
.toLowerCase()
.includes(state.search.toLowerCase()) ||
(v.est_number || "")
.toString()
.toLowerCase()
.includes(state.search.toLowerCase()) ||
(v.ownr_fn || "")
.toLowerCase()
.includes(state.search.toLowerCase()) ||
(v.ownr_ln || "")
.toLowerCase()
.includes(state.search.toLowerCase()) ||
(v.ownr_co_nm || "")
.toLowerCase()
.includes(state.search.toLowerCase()) ||
(v.v_model_desc || "")
.toLowerCase()
.includes(state.search.toLowerCase()) ||
(v.v_make_desc || "")
.toLowerCase()
.includes(state.search.toLowerCase()) ||
(v.clm_no || "").toLowerCase().includes(state.search.toLowerCase())
)
: jobs;
return (
<div>
<Table
loading={loading}
title={() => {
return (
<div className="imex-table-header">
<JobsExportAllButton
jobIds={selectedJobs}
disabled={transInProgress || selectedJobs.length === 0}
loadingCallback={setTransInProgress}
completedCallback={setSelectedJobs}
/>
<Input.Search
className="imex-table-header__search"
value={state.search}
onChange={handleSearch}
placeholder={t("general.labels.search")}
allowClear
/>
</div>
);
}}
dataSource={dataSource}
size="small"
pagination={{ position: "top" }}
columns={columns}
rowKey="id"
onChange={handleTableChange}
rowSelection={{
onSelectAll: (selected, selectedRows) =>
setSelectedJobs(selectedRows.map((i) => i.id)),
onSelect: (record, selected, selectedRows, nativeEvent) => {
setSelectedJobs(selectedRows.map((i) => i.id));
},
getCheckboxProps: (record) => ({
disabled: record.exported,
}),
selectedRowKeys: selectedJobs,
type: "checkbox",
}}
/>
</div>
);
}

View File

@@ -1,7 +1,8 @@
import { Alert } from "antd";
import { logImEXEvent } from "../../firebase/firebase.utils";
import React from "react";
export default function AlertComponent(props) {
if (props.type === "error") logImEXEvent("alert_render", { ...props });
return <Alert {...props} />;
}

View File

@@ -15,7 +15,7 @@ describe("AllocationsAssignmentComponent component", () => {
assignment: {},
setAssignment: jest.fn(),
visibilityState: [false, jest.fn()],
maxHours: 4
maxHours: 4,
};
wrapper = mount(<AllocationsAssignmentComponent {...mockProps} />);
@@ -27,7 +27,6 @@ describe("AllocationsAssignmentComponent component", () => {
it("should render a list of employees", () => {
const empList = wrapper.find("#employeeSelector");
console.log(empList.debug());
expect(empList.children()).to.have.lengthOf(2);
});

View File

@@ -6,7 +6,7 @@ import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
bodyshop: selectBodyshop,
});
export default connect(
@@ -18,12 +18,11 @@ export default connect(
handleAssignment,
assignment,
setAssignment,
visibilityState
visibilityState,
}) {
const { t } = useTranslation();
const onChange = e => {
console.log("e", e);
const onChange = (e) => {
setAssignment({ ...assignment, employeeid: e });
};
@@ -34,13 +33,14 @@ export default connect(
<Select
showSearch
style={{ width: 200 }}
placeholder='Select a person'
optionFilterProp='children'
placeholder="Select a person"
optionFilterProp="children"
onChange={onChange}
filterOption={(input, option) =>
option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}>
{bodyshop.employees.map(emp => (
}
>
{bodyshop.employees.map((emp) => (
<Select.Option value={emp.id} key={emp.id}>
{`${emp.first_name} ${emp.last_name}`}
</Select.Option>
@@ -48,9 +48,10 @@ export default connect(
</Select>
<Button
type='primary'
type="primary"
disabled={!assignment.employeeid}
onClick={handleAssignment}>
onClick={handleAssignment}
>
Assign
</Button>
<Button onClick={() => setVisibility(false)}>Close</Button>

View File

@@ -4,10 +4,11 @@ import { MdRemoveCircleOutline } from "react-icons/md";
export default function AllocationsLabelComponent({ allocation, handleClick }) {
return (
<div style={{ display: "flex" }}>
<div style={{ display: "flex", alignItems: "center" }}>
<span>
{`${allocation.employee.first_name || ""} ${allocation.employee
.last_name || ""} (${allocation.hours || ""})`}
{`${allocation.employee.first_name || ""} ${
allocation.employee.last_name || ""
} (${allocation.hours || ""})`}
</span>
<Icon
style={{ color: "red", padding: "0px 4px" }}

View File

@@ -8,19 +8,21 @@ import { useTranslation } from "react-i18next";
export default function AllocationsLabelContainer({ allocation, refetch }) {
const [deleteAllocation] = useMutation(DELETE_ALLOCATION);
const { t } = useTranslation();
const handleClick = e => {
const handleClick = (e) => {
e.preventDefault();
deleteAllocation({ variables: { id: allocation.id } })
.then(r => {
.then((r) => {
notification["success"]({
message: t("allocations.successes.deleted")
message: t("allocations.successes.deleted"),
});
if (refetch) refetch();
})
.catch(error => {
.catch((error) => {
notification["error"]({ message: t("allocations.errors.deleting") });
});
};
return (
<AllocationsLabelComponent
allocation={allocation}

View File

@@ -3,16 +3,19 @@ import AuditTrailListComponent from "./audit-trail-list.component";
import { useQuery } from "@apollo/react-hooks";
import { QUERY_AUDIT_TRAIL } from "../../graphql/audit_trail.queries";
import AlertComponent from "../alert/alert.component";
import { logImEXEvent } from "../../firebase/firebase.utils";
export default function AuditTrailListContainer({ recordId }) {
const { loading, error, data } = useQuery(QUERY_AUDIT_TRAIL, {
variables: { id: recordId },
fetchPolicy: "network-only"
fetchPolicy: "network-only",
});
logImEXEvent("audittrail_view", { recordId });
return (
<div>
{error ? (
<AlertComponent type="error" message={error.message} />
<AlertComponent type='error' message={error.message} />
) : (
<AuditTrailListComponent
loading={loading}

View File

@@ -3,13 +3,11 @@ import { List } from "antd";
import Icon from "@ant-design/icons";
import { FaArrowRight } from "react-icons/fa";
export default function AuditTrailValuesComponent({ oldV, newV }) {
console.log("(!oldV & !newV)", !oldV && !newV);
console.log("(!oldV & newV)", !oldV && newV);
if (!oldV && !newV) return <div></div>;
if (!oldV && newV)
return (
<List style={{ width: "800px" }} bordered size="small">
<List style={{ width: "800px" }} bordered size='small'>
{Object.keys(newV).map((key, idx) => (
<List.Item key={idx} value={key}>
{key}: {JSON.stringify(newV[key])}
@@ -19,7 +17,7 @@ export default function AuditTrailValuesComponent({ oldV, newV }) {
);
return (
<List style={{ width: "800px" }} bordered size="small">
<List style={{ width: "800px" }} bordered size='small'>
{Object.keys(oldV).map((key, idx) => (
<List.Item key={idx}>
{key}: {oldV[key]} <Icon component={FaArrowRight} />

View File

@@ -2,7 +2,7 @@ import { Tag, Popover } from "antd";
import React from "react";
import Barcode from "react-barcode";
import { useTranslation } from "react-i18next";
export default function BarcodePopupComponent({ value }) {
export default function BarcodePopupComponent({ value, children }) {
const { t } = useTranslation();
return (
<div>
@@ -10,12 +10,11 @@ export default function BarcodePopupComponent({ value }) {
content={
<Barcode
value={value}
background="transparent"
background='transparent'
displayValue={false}
/>
}
>
<Tag>{t("general.labels.barcode")}</Tag>
}>
{children ? children : <Tag>{t("general.labels.barcode")}</Tag>}
</Popover>
</div>
);

View File

@@ -0,0 +1,36 @@
import { HomeFilled } from "@ant-design/icons";
import { Breadcrumb } from "antd";
import React from "react";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { selectBreadcrumbs } from "../../redux/application/application.selectors";
import "./breadcrumbs.styles.scss";
const mapStateToProps = createStructuredSelector({
breadcrumbs: selectBreadcrumbs,
});
export function BreadCrumbs({ breadcrumbs }) {
return (
<div className="breadcrumb-container imex-flex-row">
<Breadcrumb separator=">">
<Breadcrumb.Item>
<Link to={`/manage`}>
<HomeFilled />
</Link>
</Breadcrumb.Item>
{breadcrumbs.map((item) =>
item.link ? (
<Breadcrumb.Item key={item.label}>
<Link to={item.link}>{item.label} </Link>
</Breadcrumb.Item>
) : (
<Breadcrumb.Item key={item.label}>{item.label}</Breadcrumb.Item>
)
)}
</Breadcrumb>
</div>
);
}
export default connect(mapStateToProps, null)(BreadCrumbs);

View File

@@ -0,0 +1,3 @@
.breadcrumb-container {
margin: 0.5rem 4rem;
}

View File

@@ -0,0 +1,43 @@
import { MessageOutlined } from "@ant-design/icons";
import { Badge, Card } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { toggleChatVisible } from "../../redux/messaging/messaging.actions";
import { selectChatVisible } from "../../redux/messaging/messaging.selectors";
import ChatPopupComponent from '../chat-popup/chat-popup.component'
const mapStateToProps = createStructuredSelector({
chatVisible: selectChatVisible,
});
const mapDispatchToProps = (dispatch) => ({
toggleChatVisible: () => dispatch(toggleChatVisible()),
});
export function ChatAffixComponent({
chatVisible,
toggleChatVisible,
conversationList,
unreadCount,
}) {
const { t } = useTranslation();
return (
<Badge count={unreadCount}>
<Card size='small'>
{chatVisible ? (
<ChatPopupComponent conversationList={conversationList} />
) : (
<div
onClick={() => toggleChatVisible()}
style={{ cursor: "pointer" }}>
<MessageOutlined />
<strong>{t("messaging.labels.messaging")}</strong>
</div>
)}
</Card>
</Badge>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatAffixComponent);

View File

@@ -0,0 +1,32 @@
import { useSubscription } from "@apollo/react-hooks";
import React from "react";
import { CONVERSATION_LIST_SUBSCRIPTION } from "../../graphql/conversations.queries";
import AlertComponent from "../alert/alert.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import ChatAffixComponent from "./chat-affix.component";
import { Affix } from "antd";
import "./chat-affix.styles.scss";
export default function ChatAffixContainer() {
const { loading, error, data } = useSubscription(
CONVERSATION_LIST_SUBSCRIPTION
);
if (loading) return <LoadingSpinner />;
if (error) return <AlertComponent message={error.message} type='error' />;
return (
<Affix className='chat-affix'>
<div>
<ChatAffixComponent
conversationList={(data && data.conversations) || []}
unreadCount={
(data &&
data.conversations.reduce((acc, val) => {
return (acc = acc + val.messages_aggregate.aggregate.count);
}, 0)) ||
0
}
/>
</div>
</Affix>
);
}

View File

@@ -0,0 +1,4 @@
.chat-affix {
position: absolute;
bottom: 2vh;
}

View File

@@ -1,41 +1,67 @@
import { ShrinkOutlined } from "@ant-design/icons";
import { Badge } from "antd";
import { Badge, List } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import {
openConversation,
toggleChatVisible
} from "../../redux/messaging/messaging.actions";
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import { createStructuredSelector } from "reselect";
import { setSelectedConversation } from "../../redux/messaging/messaging.actions";
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
import PhoneFormatter from "../../utils/PhoneFormatter";
import "./chat-conversation-list.styles.scss";
const mapDispatchToProps = dispatch => ({
toggleChatVisible: () => dispatch(toggleChatVisible()),
openConversation: number => dispatch(openConversation(number))
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,
});
const mapDispatchToProps = (dispatch) => ({
setSelectedConversation: (conversationId) =>
dispatch(setSelectedConversation(conversationId)),
});
export function ChatConversationListComponent({
toggleChatVisible,
conversationList,
openConversation
selectedConversation,
setSelectedConversation,
}) {
const { t } = useTranslation();
return (
<div className='chat-overlay-open'>
<ShrinkOutlined onClick={() => toggleChatVisible()} />
{conversationList.map(item => (
<Badge count={item.messages_aggregate.aggregate.count || 0}>
<div
key={item.id}
style={{ cursor: "pointer", display: "block" }}
onClick={() =>
openConversation({ phone_num: item.phone_num, id: item.id })
}>
<div>
<PhoneNumberFormatter>{item.phone_num}</PhoneNumberFormatter>
</div>
</div>
</Badge>
))}
</div>
<List
bordered
size="small"
dataSource={conversationList}
renderItem={(item) => (
<List.Item
onClick={() => setSelectedConversation(item.id)}
className={`chat-list-item ${
item.id === selectedConversation
? "chat-list-selected-conversation"
: null
}`}
>
<List.Item.Meta
title={<PhoneFormatter>{item.phone_num}</PhoneFormatter>}
description={
item.job_conversations.length > 0 ? (
<div>
{item.job_conversations.map(
(j) =>
`${j.job.ownr_fn || ""} ${j.job.ownr_ln || ""} ${
j.job.ownr_co_nm || ""
}`
)}
</div>
) : (
t("messaging.labels.nojobs")
)
}
/>
<Badge count={item.messages_aggregate.aggregate.count || 0} />
</List.Item>
)}
/>
);
}
export default connect(null, mapDispatchToProps)(ChatConversationListComponent);
export default connect(
mapStateToProps,
mapDispatchToProps
)(ChatConversationListComponent);

View File

@@ -0,0 +1,10 @@
.chat-list-selected-conversation {
background-color: rgba(128, 128, 128, 0.2);
}
.chat-list-item {
&:hover {
cursor: pointer;
color: #ff7a00;
}
}

View File

@@ -0,0 +1,42 @@
import React from "react";
import { Tag } from "antd";
import { Link } from "react-router-dom";
import { useMutation } from "@apollo/react-hooks";
import { REMOVE_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import { logImEXEvent } from "../../firebase/firebase.utils";
export default function ChatConversationTitleTags({ jobConversations }) {
const [removeJobConversation] = useMutation(REMOVE_CONVERSATION_TAG);
const handleRemoveTag = (jobId) => {
const convId = jobConversations[0].conversationid;
if (!!convId) {
removeJobConversation({
variables: {
conversationId: convId,
jobId: jobId,
},
});
logImEXEvent("messaging_remove_job_tag", {
conversationId: convId,
jobId: jobId,
});
}
};
return (
<div>
{jobConversations.map((item) => (
<Tag
key={item.job.id}
closable
color='blue'
style={{ cursor: "pointer" }}
onClose={() => handleRemoveTag(item.job.id)}>
<Link to={`/manage/jobs/${item.job.id}`}>
{item.job.ro_number || "?"}
</Link>
</Tag>
))}
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { Space } from "antd";
import React from "react";
import ChatConversationTitleTags from "../chat-conversation-title-tags/chat-conversation-title-tags.component";
import ChatTagRoContainer from "../chat-tag-ro/chat-tag-ro.container";
import ChatPresetsComponent from "../chat-presets/chat-presets.component";
export default function ChatConversationTitle({ conversation }) {
return (
<div>
<Space>
<strong>{conversation && conversation.phone_num}</strong>
<span>
{conversation.job_conversations.map(
(j) =>
`${j.job.ownr_fn || ""} ${j.job.ownr_ln || ""} ${
j.job.ownr_co_nm || ""
} | `
)}
</span>
</Space>
<div className="imex-flex-row imex-flex-row__margin">
<ChatConversationTitleTags
jobConversations={
(conversation && conversation.job_conversations) || []
}
/>
<ChatTagRoContainer conversation={conversation || []} />
<ChatPresetsComponent />
</div>
</div>
);
}

View File

@@ -1,38 +0,0 @@
import { CloseCircleFilled } from "@ant-design/icons";
import React from "react";
import { connect } from "react-redux";
import {
closeConversation,
sendMessage,
toggleConversationVisible
} from "../../redux/messaging/messaging.actions";
import PhoneFormatter from "../../utils/PhoneFormatter";
const mapDispatchToProps = dispatch => ({
toggleConversationVisible: conversationId =>
dispatch(toggleConversationVisible(conversationId)),
closeConversation: phone => dispatch(closeConversation(phone)),
sendMessage: message => dispatch(sendMessage(message))
});
function ChatConversationClosedComponent({
conversation,
toggleConversationVisible,
closeConversation
}) {
return (
<div
className='chat-conversation-closed'
onClick={() => toggleConversationVisible(conversation.id)}>
<PhoneFormatter>{conversation.phone_num}</PhoneFormatter>
<CloseCircleFilled
onClick={() => closeConversation(conversation.phone_num)}
/>
</div>
);
}
export default connect(
null,
mapDispatchToProps
)(ChatConversationClosedComponent);

View File

@@ -1,29 +1,32 @@
import { Badge, Card } from "antd";
import React from "react";
import ChatConversationClosedComponent from "./chat-conversation.closed.component";
import ChatConversationOpenComponent from "./chat-conversation.open.component";
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";
import ChatSendMessage from "../chat-send-message/chat-send-message.component";
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component.jsx";
import "./chat-conversation.styles.scss";
export default function ChatConversationComponent({
conversation,
messages,
subState,
unreadCount
conversation,
handleMarkConversationAsRead,
}) {
const [loading, error] = subState;
if (loading) return <LoadingSkeleton />;
if (error) return <AlertComponent message={error.message} type="error" />;
const messages = (conversation && conversation.messages) || [];
return (
<div className='chat-conversation'>
<Badge count={unreadCount}>
<Card size='small'>
{conversation.open ? (
<ChatConversationOpenComponent
messages={messages}
conversation={conversation}
subState={subState}
/>
) : (
<ChatConversationClosedComponent conversation={conversation} />
)}
</Card>
</Badge>
<div
className="chat-conversation"
onMouseDown={handleMarkConversationAsRead}
onKeyDown={handleMarkConversationAsRead}
>
<ChatConversationTitle conversation={conversation} />
<ChatMessageListComponent messages={messages} />
<ChatSendMessage conversation={conversation} />
</div>
);
}

View File

@@ -1,34 +1,56 @@
import { useSubscription } from "@apollo/react-hooks";
import React from "react";
import { useMutation, useSubscription } from "@apollo/react-hooks";
import React, { useState } from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { CONVERSATION_SUBSCRIPTION_BY_PK } from "../../graphql/conversations.queries";
import { MARK_MESSAGES_AS_READ_BY_CONVERSATION } from "../../graphql/messages.queries";
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
import ChatConversationComponent from "./chat-conversation.component";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,
});
export default function ChatConversationContainer({ conversation }) {
export default connect(mapStateToProps, null)(ChatConversationContainer);
export function ChatConversationContainer({ selectedConversation }) {
const { loading, error, data } = useSubscription(
CONVERSATION_SUBSCRIPTION_BY_PK,
{
variables: { conversationId: conversation.id }
variables: { conversationId: selectedConversation },
}
);
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
const [markConversationRead] = useMutation(
MARK_MESSAGES_AS_READ_BY_CONVERSATION,
{
variables: { conversationId: selectedConversation },
}
);
const unreadCount =
(data &&
data.conversations_by_pk &&
data.conversations_by_pk &&
data.conversations_by_pk.messages_aggregate &&
data.conversations_by_pk.messages_aggregate.aggregate &&
data.conversations_by_pk.messages_aggregate.aggregate.count) ||
0;
const handleMarkConversationAsRead = async () => {
if (unreadCount > 0 && !!selectedConversation && !markingAsReadInProgress) {
setMarkingAsReadInProgress(true);
await markConversationRead();
setMarkingAsReadInProgress(false);
}
};
return (
<ChatConversationComponent
subState={[loading, error]}
conversation={conversation}
unreadCount={
(data &&
data.conversations_by_pk &&
data.conversations_by_pk.messages_aggregate &&
data.conversations_by_pk.messages_aggregate.aggregate &&
data.conversations_by_pk.messages_aggregate.aggregate.count) ||
0
}
messages={
(data &&
data.conversations_by_pk &&
data.conversations_by_pk.messages) ||
[]
}
conversation={data ? data.conversations_by_pk : {}}
handleMarkConversationAsRead={handleMarkConversationAsRead}
/>
);
}

View File

@@ -1,36 +0,0 @@
import React from "react";
import { connect } from "react-redux";
import { toggleConversationVisible } from "../../redux/messaging/messaging.actions";
import AlertComponent from "../alert/alert.component";
import ChatMessageListComponent from "../chat-messages-list/chat-message-list.component";
import ChatSendMessage from "../chat-send-message/chat-send-message.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { ShrinkOutlined } from "@ant-design/icons";
const mapDispatchToProps = dispatch => ({
toggleConversationVisible: conversation =>
dispatch(toggleConversationVisible(conversation))
});
export function ChatConversationOpenComponent({
conversation,
messages,
subState,
toggleConversationVisible
}) {
const [loading, error] = subState;
if (loading) return <LoadingSpinner />;
if (error) return <AlertComponent message={error.message} type='error' />;
return (
<div className='chat-conversation-open'>
<ShrinkOutlined
onClick={() => toggleConversationVisible(conversation.id)}
/>
<ChatMessageListComponent messages={messages} />
<ChatSendMessage conversation={conversation} />
</div>
);
}
export default connect(null, mapDispatchToProps)(ChatConversationOpenComponent);

View File

@@ -0,0 +1,6 @@
.chat-conversation {
display: flex;
height: 100%;
margin: 0rem 0.5rem;
flex-direction: column;
}

View File

@@ -1,32 +0,0 @@
import { Affix } from "antd";
import React from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectConversations } from "../../redux/messaging/messaging.selectors";
import ChatConversationContainer from "../chat-conversation/chat-conversation.container";
import ChatMessagesButtonContainer from "../chat-messages-button/chat-messages-button.container";
import "./chat-dock.styles.scss";
const mapStateToProps = createStructuredSelector({
activeConversations: selectConversations
});
export function ChatOverlayContainer({ activeConversations }) {
return (
<Affix offsetBottom={0}>
<div className='chat-dock'>
<ChatMessagesButtonContainer />
{activeConversations
? activeConversations.map(conversation => (
<ChatConversationContainer
conversation={conversation}
key={conversation.id}
/>
))
: null}
</div>
</Affix>
);
}
export default connect(mapStateToProps, null)(ChatOverlayContainer);

View File

@@ -1,188 +0,0 @@
.chat-dock {
z-index: 5;
//overflow-x: scroll;
// overflow-y: hidden;
width: 100%;
display: flex;
align-items: baseline;
}
.chat-conversation {
margin: 2em 1em 0em 1em;
}
.chat-conversation-open {
height: 500px;
}
// .chat-messages {
// height: 80%;
// overflow-x: hidden;
// overflow-y: scroll;
// flex-grow: 1;
// ul {
// list-style: none;
// margin: 0;
// padding: 0;
// }
// ul li {
// display: inline-block;
// clear: both;
// padding: 3px 10px;
// border-radius: 30px;
// margin-bottom: 2px;
// }
// .inbound {
// background: #eee;
// float: left;
// }
// .outbound {
// float: right;
// background: #0084ff;
// color: #fff;
// }
// .inbound + .outbound {
// border-bottom-right-radius: 5px;
// }
// .outbound + .outbound {
// border-top-right-radius: 5px;
// border-bottom-right-radius: 5px;
// }
// .outbound:last-of-type {
// border-bottom-right-radius: 30px;
// }
// }
.messages {
height: auto;
min-height: calc(100% - 10px);
max-height: calc(100% - 93px);
overflow-y: scroll;
overflow-x: hidden;
}
@media screen and (max-width: 735px) {
.messages {
max-height: calc(100% - 105px);
}
}
.messages::-webkit-scrollbar {
width: 8px;
background: transparent;
}
.messages::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.3);
}
.messages ul li {
display: inline-block;
clear: both;
//float: left;
margin: 5px;
width: calc(100% - 25px);
font-size: 0.9em;
}
.messages ul li:nth-last-child(1) {
margin-bottom: 20px;
}
.messages ul li.sent img {
margin: 6px 8px 0 0;
}
.messages ul li.sent p {
background: #435f7a;
color: #f5f5f5;
}
.messages ul li.replies img {
float: right;
margin: 6px 0 0 8px;
}
.messages ul li.replies p {
background: #f5f5f5;
float: right;
}
.messages ul li img {
width: 22px;
border-radius: 50%;
float: left;
}
.messages ul li p {
display: inline-block;
padding: 10px 15px;
border-radius: 20px;
max-width: 205px;
line-height: 130%;
}
@media screen and (min-width: 735px) {
.messages ul li p {
max-width: 300px;
}
}
.message-input {
position: absolute;
bottom: 0;
width: 100%;
z-index: 99;
}
.message-input .wrap {
position: relative;
}
.message-input .wrap input {
font-family: "proxima-nova", "Source Sans Pro", sans-serif;
float: left;
border: none;
width: calc(100% - 90px);
padding: 11px 32px 10px 8px;
font-size: 0.8em;
color: #32465a;
}
@media screen and (max-width: 735px) {
.message-input .wrap input {
padding: 15px 32px 16px 8px;
}
}
.message-input .wrap input:focus {
outline: none;
}
.message-input .wrap .attachment {
position: absolute;
right: 60px;
z-index: 4;
margin-top: 10px;
font-size: 1.1em;
color: #435f7a;
opacity: 0.5;
cursor: pointer;
}
@media screen and (max-width: 735px) {
.message-input .wrap .attachment {
margin-top: 17px;
right: 65px;
}
}
.message-input .wrap .attachment:hover {
opacity: 1;
}
.message-input .wrap button {
float: right;
border: none;
width: 50px;
padding: 12px 0;
cursor: pointer;
background: #32465a;
color: #f5f5f5;
}
@media screen and (max-width: 735px) {
.message-input .wrap button {
padding: 16px 0;
}
}
.message-input .wrap button:hover {
background: #435f7a;
}
.message-input .wrap button:focus {
outline: none;
}

View File

@@ -1,47 +0,0 @@
import { MessageFilled } from "@ant-design/icons";
import { Badge, Card } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { toggleChatVisible } from "../../redux/messaging/messaging.actions";
import { selectChatVisible } from "../../redux/messaging/messaging.selectors";
import ChatConversationListComponent from "../chat-conversation-list/chat-conversation-list.component";
const mapStateToProps = createStructuredSelector({
chatVisible: selectChatVisible
});
const mapDispatchToProps = dispatch => ({
toggleChatVisible: () => dispatch(toggleChatVisible())
});
export function ChatWindowComponent({
chatVisible,
toggleChatVisible,
conversationList,
unreadCount
}) {
const { t } = useTranslation();
return (
<div className='chat-conversation'>
<Badge count={unreadCount}>
<Card size='small'>
{chatVisible ? (
<ChatConversationListComponent
conversationList={conversationList}
/>
) : (
<div onClick={() => toggleChatVisible()}>
<MessageFilled />
<strong>{t("messaging.labels.messaging")}</strong>
</div>
)}
</Card>
</Badge>
</div>
);
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(ChatWindowComponent);

View File

@@ -1,27 +0,0 @@
import { useSubscription } from "@apollo/react-hooks";
import React from "react";
import { CONVERSATION_LIST_SUBSCRIPTION } from "../../graphql/conversations.queries";
import AlertComponent from "../alert/alert.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import ChatMessagesButtonComponent from "./chat-messages-button.component";
export default function ChatMessagesButtonContainer() {
const { loading, error, data } = useSubscription(
CONVERSATION_LIST_SUBSCRIPTION
);
if (loading) return <LoadingSpinner />;
if (error) return <AlertComponent message={error.message} type='error' />;
return (
<ChatMessagesButtonComponent
conversationList={(data && data.conversations) || []}
unreadCount={
(data &&
data.conversations.reduce((acc, val) => {
return (acc = acc + val.messages_aggregate.aggregate.count);
}, 0)) ||
0
}
/>
);
}

View File

@@ -1,44 +1,95 @@
import { CheckCircleOutlined, CheckOutlined } from "@ant-design/icons";
import Icon from "@ant-design/icons";
import React, { useEffect, useRef } from "react";
import { MdDone, MdDoneAll } from "react-icons/md";
import {
AutoSizer,
CellMeasurer,
CellMeasurerCache,
List,
} from "react-virtualized";
import "./chat-message-list.styles.scss";
export default function ChatMessageListComponent({ messages }) {
const messagesEndRef = useRef(null);
const virtualizedListRef = useRef(null);
const scrollToBottom = () => {
console.log("use");
!!messagesEndRef.current &&
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
const _cache = new CellMeasurerCache({
fixedWidth: true,
// minHeight: 50,
defaultHeight: 100,
});
const scrollToBottom = (renderedrows) => {
//console.log("Scrolling to", messages.length);
// !!virtualizedListRef.current &&
// virtualizedListRef.current.scrollToRow(messages.length);
// Outstanding isue on virtualization: https://github.com/bvaughn/react-virtualized/issues/1179
//Scrolling does not work on this version of React.
};
useEffect(scrollToBottom, [messages]);
const StatusRender = status => {
switch (status) {
case "sent":
return <CheckOutlined style={{ margin: "2px", float: "right" }} />;
case "delivered":
return (
<CheckCircleOutlined style={{ margin: "2px", float: "right" }} />
);
default:
return null;
}
const _rowRenderer = ({ index, key, parent, style }) => {
return (
<CellMeasurer cache={_cache} key={key} rowIndex={index} parent={parent}>
{({ measure, registerChild }) => (
<div
ref={registerChild}
onLoad={measure}
style={style}
className={`${
messages[index].isoutbound ? "mine messages" : "yours messages"
}`}
>
<div className="message msgmargin">
{MessageRender(messages[index])}
{StatusRender(messages[index].status)}
</div>
</div>
)}
</CellMeasurer>
);
};
return (
<div className='messages'>
<ul>
{messages.map(item => (
<li
key={item.id}
className={`${item.isoutbound ? "replies" : "sent"}`}>
<p>
{item.text}
{StatusRender(item.status)}
</p>
</li>
))}
<li ref={messagesEndRef} />
</ul>
<div className="chat">
<AutoSizer>
{({ height, width }) => (
<List
ref={virtualizedListRef}
width={width}
height={height}
rowHeight={_cache.rowHeight}
rowRenderer={_rowRenderer}
rowCount={messages.length}
overscanRowCount={10}
estimatedRowSize={150}
scrollToIndex={messages.length}
/>
)}
</AutoSizer>
</div>
);
}
const MessageRender = (message) => {
if (message.image) {
return (
<a href={message.image_path} target="__blank">
<img alt="Received" className="message-img" src={message.image_path} />
</a>
);
} else {
return <span>{message.text}</span>;
}
};
const StatusRender = (status) => {
switch (status) {
case "sent":
return <Icon component={MdDone} className="message-icon" />;
case "delivered":
return <Icon component={MdDoneAll} className="message-icon" />;
default:
return null;
}
};

View File

@@ -0,0 +1,112 @@
.message-icon {
//position: absolute;
// bottom: 0rem;
color: whitesmoke;
border: #000000;
margin-left: 0.2rem;
margin-right: 0rem;
// z-index: 5;
}
.chat {
flex: 1;
//width: 300px;
//border: solid 1px #eee;
display: flex;
flex-direction: column;
margin: 0.8rem 0rem;
}
.messages {
//margin-top: 30px;
display: flex;
flex-direction: column;
}
.message {
border-radius: 20px;
padding: 0.25rem 0.8rem;
//margin-top: 5px;
// margin-bottom: 5px;
//display: inline-block;
.message-img {
max-width: 3rem;
max-height: 3rem;
object-fit: contain;
}
}
.yours {
align-items: flex-start;
}
.msgmargin {
margin-top: 0.1rem;
margin-bottom: 0.1rem;
}
.yours .message {
margin-right: 20%;
background-color: #eee;
position: relative;
}
.yours .message.last:before {
content: "";
position: absolute;
z-index: 0;
bottom: 0;
left: -7px;
height: 20px;
width: 20px;
background: #eee;
border-bottom-right-radius: 15px;
}
.yours .message.last:after {
content: "";
position: absolute;
z-index: 1;
bottom: 0;
left: -10px;
width: 10px;
height: 20px;
background: white;
border-bottom-right-radius: 10px;
}
.mine {
align-items: flex-end;
}
.mine .message {
color: white;
margin-left: 25%;
background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%);
background-attachment: fixed;
position: relative;
}
.mine .message.last:before {
content: "";
position: absolute;
z-index: 0;
bottom: 0;
right: -8px;
height: 20px;
width: 20px;
background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%);
background-attachment: fixed;
border-bottom-left-radius: 15px;
}
.mine .message.last:after {
content: "";
position: absolute;
z-index: 1;
bottom: 0;
right: -10px;
width: 10px;
height: 20px;
background: white;
border-bottom-left-radius: 10px;
}

View File

@@ -1,22 +1,16 @@
import { MessageFilled } from "@ant-design/icons";
import React from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { openConversation } from "../../redux/messaging/messaging.actions";
import { MessageFilled } from "@ant-design/icons";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
const mapDispatchToProps = (dispatch) => ({
openChatByPhone: (phone) => dispatch(openChatByPhone(phone)),
});
const mapDispatchToProps = dispatch => ({
openConversation: phone => dispatch(openConversation(phone))
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(function ChatOpenButton({ openConversation, phone }) {
export function ChatOpenButton({ phone, jobid, openChatByPhone }) {
return (
<MessageFilled
style={{ margin: 4 }}
onClick={() => openConversation(phone)}
onClick={() => openChatByPhone({ phone_num: phone, jobid: jobid })}
/>
);
});
}
export default connect(null, mapDispatchToProps)(ChatOpenButton);

View File

@@ -0,0 +1,47 @@
import { ShrinkOutlined } from "@ant-design/icons";
import { Col, Row, Typography } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { toggleChatVisible } from "../../redux/messaging/messaging.actions";
import ChatConversationListComponent from "../chat-conversation-list/chat-conversation-list.component";
import ChatConversationContainer from "../chat-conversation/chat-conversation.container";
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
import "./chat-popup.styles.scss";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,
});
const mapDispatchToProps = (dispatch) => ({
toggleChatVisible: () => dispatch(toggleChatVisible()),
});
export function ChatPopupComponent({
conversationList,
selectedConversation,
toggleChatVisible,
}) {
const { t } = useTranslation();
return (
<div className="chat-popup">
<Typography.Title level={4}>
{t("messaging.labels.messaging")}
</Typography.Title>
<ShrinkOutlined
onClick={() => toggleChatVisible()}
style={{ position: "absolute", right: ".5rem", top: ".5rem" }}
/>
<Row className="chat-popup-content">
<Col span={8}>
<ChatConversationListComponent conversationList={conversationList} />
</Col>
<Col span={16}>
{selectedConversation ? <ChatConversationContainer /> : null}
</Col>
</Row>
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatPopupComponent);

View File

@@ -0,0 +1,20 @@
.chat-popup {
width: 90vw;
height: 95vh;
display: flex;
flex-direction: column;
}
.chat-popup-content {
//height: 50vh;
flex: 1;
}
@media only screen and (min-width: 992px) {
.chat-popup {
width: 60vw;
height: 55vh;
display: flex;
flex-direction: column;
}
}

View File

@@ -0,0 +1,49 @@
import { DownOutlined } from "@ant-design/icons";
import { Dropdown, Menu } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { setMessage } from "../../redux/messaging/messaging.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
bodyshop: selectBodyshop,
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
setMessage: (message) => dispatch(setMessage(message)),
});
export function ChatPresetsComponent({ bodyshop, setMessage }) {
const { t } = useTranslation();
const menu = (
<Menu>
{bodyshop.md_messaging_presets.map((i, idx) => (
<Menu.Item onClick={() => setMessage(i.text)} onItemHover key={idx}>
{i.label}
</Menu.Item>
))}
</Menu>
);
return (
<div>
<Dropdown trigger={["click"]} overlay={menu}>
<a
className="ant-dropdown-link"
href="# "
onClick={(e) => e.preventDefault()}
>
{t("messaging.labels.presets")} <DownOutlined />
</a>
</Dropdown>
</div>
);
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(ChatPresetsComponent);

View File

@@ -1,60 +1,80 @@
import { LoadingOutlined, SendOutlined } from "@ant-design/icons";
import { Input, Spin } from "antd";
import { LoadingOutlined } from "@ant-design/icons";
import React, { useState, useEffect } from "react";
import React, { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { sendMessage } from "../../redux/messaging/messaging.actions";
import { logImEXEvent } from "../../firebase/firebase.utils";
import {
sendMessage,
setMessage,
} from "../../redux/messaging/messaging.actions";
import {
selectIsSending,
selectMessage,
} from "../../redux/messaging/messaging.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = dispatch => ({
sendMessage: message => dispatch(sendMessage(message))
bodyshop: selectBodyshop,
isSending: selectIsSending,
message: selectMessage,
});
function ChatSendMessageComponent({ conversation, bodyshop, sendMessage }) {
const [message, setMessage] = useState("");
const mapDispatchToProps = (dispatch) => ({
sendMessage: (message) => dispatch(sendMessage(message)),
setMessage: (message) => dispatch(setMessage(message)),
});
function ChatSendMessageComponent({
conversation,
bodyshop,
sendMessage,
isSending,
message,
setMessage,
}) {
const inputArea = useRef(null);
useEffect(() => {
if (conversation.isSending === false) {
setMessage("");
}
}, [conversation, setMessage]);
inputArea.current.focus();
}, [isSending, setMessage]);
const { t } = useTranslation();
const handleEnter = () => {
logImEXEvent("messaging_send_message");
sendMessage({
to: conversation.phone_num,
body: message,
messagingServiceSid: bodyshop.messagingservicesid,
conversationid: conversation.id
conversationid: conversation.id,
});
};
return (
<div style={{ display: "flex " }}>
<div className="imex-flex-row">
<Input.TextArea
className="imex-flex-row__margin imex-flex-row__grow"
allowClear
autoFocus
suffix={<span>a</span>}
ref={inputArea}
autoSize={{ minRows: 1, maxRows: 4 }}
value={message}
disabled={conversation.isSending}
disabled={isSending}
placeholder={t("messaging.labels.typeamessage")}
onChange={e => setMessage(e.target.value)}
onPressEnter={event => {
onChange={(e) => setMessage(e.target.value)}
onPressEnter={(event) => {
event.preventDefault();
if (!!!event.shiftKey) handleEnter();
}}
/>
<SendOutlined className="imex-flex-row__margin" onClick={handleEnter} />
<Spin
style={{ display: `${conversation.isSending ? "" : "none"}` }}
style={{ display: `${isSending ? "" : "none"}` }}
indicator={
<LoadingOutlined
style={{
fontSize: 24
fontSize: 24,
}}
spin
/>

View File

@@ -0,0 +1,51 @@
import React from "react";
import { AutoComplete } from "antd";
import { LoadingOutlined, CloseCircleOutlined } from "@ant-design/icons";
import { useTranslation } from "react-i18next";
export default function ChatTagRoComponent({
searchQueryState,
roOptions,
loading,
executeSearch,
handleInsertTag,
setVisible,
}) {
const { t } = useTranslation();
const setSearchQuery = searchQueryState[1];
const handleSearchQuery = (value) => {
setSearchQuery(value);
};
const handleKeyDown = (event) => {
if (event.key === "Enter") {
executeSearch();
}
};
return (
<span>
<AutoComplete
style={{ width: 100 }}
onSearch={handleSearchQuery}
onSelect={handleInsertTag}
placeholder={t("general.labels.search")}
onKeyDown={handleKeyDown}
>
{roOptions.map((item, idx) => (
<AutoComplete.Option key={item.id || idx}>
{` ${item.ro_number || ""} | ${item.ownr_fn || ""} ${
item.ownr_ln || ""
} ${item.ownr_co_nm || ""}`}
</AutoComplete.Option>
))}
</AutoComplete>
{loading ? (
<LoadingOutlined />
) : (
<CloseCircleOutlined onClick={() => setVisible(false)} />
)}
</span>
);
}

View File

@@ -0,0 +1,67 @@
import React, { useState } from "react";
import ChatTagRo from "./chat-tag-ro.component";
import { useLazyQuery, useMutation } from "@apollo/react-hooks";
import { SEARCH_FOR_JOBS } from "../../graphql/jobs.queries";
import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import { Tag } from "antd";
import { useTranslation } from "react-i18next";
import { PlusOutlined } from "@ant-design/icons";
import { logImEXEvent } from "../../firebase/firebase.utils";
export default function ChatTagRoContainer({ conversation }) {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const searchQueryState = useState("");
const searchText = searchQueryState[0];
const [loadRo, { called, loading, data, refetch }] = useLazyQuery(
SEARCH_FOR_JOBS,
{
variables: { search: `%${searchText}%` },
}
);
const executeSearch = () => {
logImEXEvent("messaging_search_job_tag", { searchTerm: searchText });
if (called) refetch();
else {
loadRo();
}
};
const [insertTag] = useMutation(INSERT_CONVERSATION_TAG, {
variables: { conversationId: conversation.id },
});
const handleInsertTag = (value, option) => {
logImEXEvent("messaging_add_job_tag");
insertTag({ variables: { jobId: option.key } });
setVisible(false);
};
const existingJobTags = conversation.job_conversations.map((i) => i.jobid);
const roOptions = data
? data.jobs.filter((job) => !existingJobTags.includes(job.id))
: [];
return (
<div>
{visible ? (
<ChatTagRo
loading={loading}
searchQueryState={searchQueryState}
roOptions={roOptions}
executeSearch={executeSearch}
handleInsertTag={handleInsertTag}
setVisible={setVisible}
/>
) : (
<Tag onClick={() => setVisible(true)}>
<PlusOutlined />
{t("messaging.actions.link")}
</Tag>
)}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import React from "react";
import { Form, Checkbox } from "antd";
import { useTranslation } from "react-i18next";
export default function JobIntakeFormCheckboxComponent({ formItem, readOnly }) {
const { name, label, required } = formItem;
const { t } = useTranslation();
return (
<Form.Item
name={name}
label={label}
valuePropName="checked"
rules={[
{
required: required,
message: t("general.validation.required"),
},
]}
>
<Checkbox disabled={readOnly} />
</Form.Item>
);
}

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