Merged in development (pull request #11)

Prepping for initial AWS deployment.
This commit is contained in:
Patrick Fic
2020-10-07 03:43:58 +00:00
511 changed files with 85487 additions and 8055 deletions

View File

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

View File

@@ -1,13 +0,0 @@
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

@@ -1,105 +0,0 @@
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

@@ -1,45 +0,0 @@
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

@@ -1,11 +0,0 @@
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

@@ -1,8 +0,0 @@
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

View File

@@ -1,9 +0,0 @@
branch-defaults:
master:
environment: Bodyshop-prod
global:
application_name: bodyshop
default_ec2_keyname: e-yqpq3yupbk
default_platform: Node.js running on 64bit Amazon Linux/4.14.1
default_region: ca-central-1
sc: git

View File

@@ -1,7 +1,7 @@
React App: React App:
Yarn Dependency Management: Yarn Dependency Management:
To force upgrades for some packages: To force upgrades for some packages:
yarn upgrade-interactive --latest yarn upgrade-interactive --latest
GraphQL API: GraphQL API:
@@ -14,12 +14,13 @@ npx hasura console --admin-secret Dev-BodyShopAppBySnaptSoftware!
Migrating to Staging: Migrating to Staging:
npx hasura migrate apply --up 10 --endpoint https://bodyshop-staging-db.herokuapp.com/ --admin-secret Staging-BodyShopAppBySnaptSoftware! npx hasura migrate apply --up 10 --endpoint https://bodyshop-staging-db.herokuapp.com/ --admin-secret Staging-BodyShopAppBySnaptSoftware!
NGROK TEsting: NGROK TEsting:
./ngrok.exe http https://localhost:5000 -host-header="localhost:5000" ./ngrok.exe http https://localhost:5000 -host-header="localhost:5000"
Finding deadfiles - run from client directory
npx deadfile ./src/index.js --exclude build templates
Finding deadfiles - run from client directory cd client && yarn build && cd build && scp -r ** imex@prod-tor1.imex.online:~/bodyshop/client/build && cd .. &&cd ..
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 .. gq https://bodyshop-dev-db.herokuapp.com/v1/graphql -H "X-Hasura-Admin-Secret: Dev-BodyShopAppBySnaptSoftware\!" --introspect > schema.graphql

View File

@@ -3,24 +3,24 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@apollo/client": "^3.1.2", "@apollo/client": "^3.2.1",
"@testing-library/jest-dom": "^5.11.2", "@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^10.4.8", "@testing-library/react": "^11.0.4",
"@testing-library/user-event": "^12.1.0", "@testing-library/user-event": "^12.1.6",
"@types/prop-types": "^15.7.3", "@types/prop-types": "^15.7.3",
"apollo-boost": "^0.4.9", "apollo-boost": "^0.4.9",
"apollo-link-context": "^1.0.20", "apollo-link-context": "^1.0.20",
"apollo-link-logger": "^1.2.3", "apollo-link-logger": "^1.2.3",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"firebase": "^7.17.1", "firebase": "^7.21.0",
"graphql": "^15.3.0", "graphql": "^15.3.0",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"ra-data-hasura-graphql": "^0.1.12", "ra-data-hasura-graphql": "^0.1.12",
"react": "^16.13.1", "react": "^16.13.1",
"react-admin": "^3.7.2", "react-admin": "^3.8.5",
"react-dom": "^16.13.1", "react-dom": "^16.13.1",
"react-icons": "^3.10.0", "react-icons": "^3.11.0",
"react-scripts": "3.4.1" "react-scripts": "3.4.3"
}, },
"scripts": { "scripts": {
"start": "set PORT=3001 && react-scripts start", "start": "set PORT=3001 && react-scripts start",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIGJzCCBA+gAwIBAgIUbtAGUWRU8Q+YGMqPt+ETB8uGnA4wDQYJKoZIhvcNAQEL
BQAwgaIxCzAJBgNVBAYTAkNBMRkwFwYDVQQIDBBCcml0aXNoIENvbHVtYmlhMRIw
EAYDVQQHDAlWYW5jb3V2ZXIxHDAaBgNVBAoME1NuYXB0IFNvZnR3YXJlIEluYy4x
DjAMBgNVBAsMBVNuYXB0MRIwEAYDVQQDDAlsb2NhbGhvc3QxIjAgBgkqhkiG9w0B
CQEWE3NuYXB0c29mdEBnbWFpbC5jb20wHhcNMjAwMTE1MDUwMDU4WhcNMjEwMTE0
MDUwMDU4WjCBojELMAkGA1UEBhMCQ0ExGTAXBgNVBAgMEEJyaXRpc2ggQ29sdW1i
aWExEjAQBgNVBAcMCVZhbmNvdXZlcjEcMBoGA1UECgwTU25hcHQgU29mdHdhcmUg
SW5jLjEOMAwGA1UECwwFU25hcHQxEjAQBgNVBAMMCWxvY2FsaG9zdDEiMCAGCSqG
SIb3DQEJARYTc25hcHRzb2Z0QGdtYWlsLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQAD
ggIPADCCAgoCggIBAKIo9ImyCPk1+3jWiO8wC9zg3q/C9ZMUeGfoHVCf4N1WM5H6
ld/isIQfnEiGIpf7cxpHIazoGkMnBTC4vdOp4JVH8C3ObOZXZiMUg6EDxRJCdR7B
ooOnYORV8MWYY0jCpgJ1fRVOs7c3CxzKD9q76r6/+UI2byIb1V51FXl80WuSmnsY
+E2DSLViG7lzG/bWL/GQoqhUuDt3UtjFJiBEV89AvHunETGOnZ3TkGfAygum7cid
pGHh1O1w4TeuCYGukIOYeY0EgK8LXOl1ILFwVom7/uN/ekp8KfRWPBbj2Oy54SSp
/oYSLCOJCGEnOpBFAe+hulQE1CVynMpzl7WID8uS6HxYszCfzPe0FZUTWn0hbULH
4z6EabdGskK0mb0ySHZq+fjI119BUoMKoLERC+HZw7nrD2TBVb9LLwwSm7+lneVH
wcG3tml+XUOSi7dV5gdMvW72ympnWD5jPWI0PkrH1e7veww/UlixDwzQ8QwduQNc
qTRoHUCmGNljUSHavTcN61hiLG6NyFoeErOPoR30LNixA014W/FORXDcDCzvfsHd
XhXUFAAODLKs4XTRV/b0sTQL+xi14FlLdQhNYQH8LVTpDZNFmsdlYih2iunsd1gU
JGmdwyA5pFyxAp8veJiA/KU2mugfUBoVk8BPz7rzLZSGSvzBV9ZuoIFcrBFzAgMB
AAGjUzBRMB0GA1UdDgQWBBRW+mW0lAVmhaCbX3SZbQPQhdTgTjAfBgNVHSMEGDAW
gBRW+mW0lAVmhaCbX3SZbQPQhdTgTjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
DQEBCwUAA4ICAQA92XAb3LiUpRYWI6w3E6Non95b4pR5sNypkHedCTP2TZyPN16H
CPtxJYV6MC3HVbpDcjo4KHrq5QIuZF+w+U3rp06DVgjBMghtoOvOBWTMtJJW8KWS
OzJFtdI2+WrJL4n9RcUQ0TFKja82y7HSinCwPHcoy+tIk5pdjDVC8w4N/5/slVJi
k418QqwM6CIWtZC5Hf1EbCKwzuR+0x5Q0UY5YeqnLSyR7qjqjp2ZgI/apdNmlQWq
M47iPGQtvyK8m0Z7Tw7mScaSd2ZIhQY5E390cGbGnnebOeKa2Ev+kfbG32dMhzQC
T9a+7k8IEX7Nak4T2FVKyCzfTFd0n7E6+R1Z0+r20e4M1DGScZIX8iMjs8Gc4Dlc
pStTulz+OjsvzRvfYhuKQimGA+z62jehmhd9gDr2IMEpBk7T2OoYsFq+9ulb9z+X
PPIOR4vYB4TUDgNbELypvrzHZvQjIyPT3sSq7J5ri151uhWiVoM4/IfnpQ0R0ysB
WR3b6Kz8qaQyXO/KAvWLUjnHbW2I8PxfUWmqigc9fb9TjLIAeSl94UR4NvloPIcS
FOq2pFMqYHBPi1SEET7WIj0BwDGpMh8OVc6iZ4Rgm43dTHneU2mcyQf5kn6IE0Bd
bE0NLRGxW6I/L4kwtQFynkZxxvpeGq9d0gtfe5Qd8AscGL+da40/S95pTQ==
-----END CERTIFICATE-----

View File

@@ -7,47 +7,51 @@
"@lourenci/react-kanban": "^2.0.0", "@lourenci/react-kanban": "^2.0.0",
"@stripe/react-stripe-js": "^1.1.2", "@stripe/react-stripe-js": "^1.1.2",
"@stripe/stripe-js": "^1.9.0", "@stripe/stripe-js": "^1.9.0",
"@tanem/react-nprogress": "^3.0.40", "@tanem/react-nprogress": "^3.0.46",
"@tinymce/tinymce-react": "^3.6.0", "@tinymce/tinymce-react": "^3.7.0",
"antd": "^4.6.1", "antd": "^4.6.6",
"apollo-boost": "^0.4.9", "apollo-boost": "^0.4.9",
"apollo-link-context": "^1.0.20", "apollo-link-context": "^1.0.20",
"apollo-link-error": "^1.1.13", "apollo-link-error": "^1.1.13",
"apollo-link-logger": "^1.2.3", "apollo-link-logger": "^2.0.0",
"apollo-link-retry": "^2.2.16", "apollo-link-retry": "^2.2.16",
"apollo-link-ws": "^1.0.20", "apollo-link-ws": "^1.0.20",
"axios": "^0.20.0", "axios": "^0.20.0",
"codemirror": "^5.58.1",
"codemirror-graphql": "^0.12.2",
"dinero.js": "^1.8.1", "dinero.js": "^1.8.1",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"fingerprintjs2": "^2.1.2", "fingerprintjs2": "^2.1.2",
"firebase": "^7.19.0", "firebase": "^7.22.1",
"graphql": "^15.3.0", "graphql": "^15.3.0",
"i18next": "^19.7.0", "i18next": "^19.8.2",
"i18next-browser-languagedetector": "^6.0.1", "i18next-browser-languagedetector": "^6.0.1",
"inline-css": "^2.6.3", "inline-css": "^2.6.3",
"logrocket": "^1.0.11", "jsoneditor": "^9.1.1",
"jsoneditor-react": "^3.0.1",
"logrocket": "^1.0.13",
"moment-business-days": "^1.2.0", "moment-business-days": "^1.2.0",
"node-sass": "^4.14.1", "node-sass": "^4.14.1",
"phone": "^2.4.15", "phone": "^2.4.16",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"query-string": "^6.13.1", "query-string": "^6.13.5",
"react": "^16.13.1", "react": "^16.13.1",
"react-apollo": "^3.1.5", "react-apollo": "^3.1.5",
"react-barcode": "^1.4.0", "react-big-calendar": "^0.28.0",
"react-big-calendar": "^0.26.1", "react-codemirror2": "^7.2.1",
"react-color": "^2.18.1",
"react-dom": "^16.13.1", "react-dom": "^16.13.1",
"react-drag-listview": "^0.1.7", "react-drag-listview": "^0.1.7",
"react-email-editor": "^1.1.1", "react-email-editor": "^1.1.1",
"react-ga": "^3.1.2", "react-ga": "^3.1.2",
"react-grid-gallery": "^0.5.5", "react-grid-gallery": "^0.5.5",
"react-grid-layout": "^1.0.0", "react-grid-layout": "^1.1.1",
"react-i18next": "^11.7.1", "react-i18next": "^11.7.3",
"react-icons": "^3.11.0", "react-icons": "^3.11.0",
"react-image-file-resizer": "^0.3.6", "react-moment": "^1.0.0",
"react-moment": "^0.9.7",
"react-number-format": "^4.4.1", "react-number-format": "^4.4.1",
"react-redux": "^7.2.1", "react-redux": "^7.2.1",
"react-resizable": "^1.10.1", "react-resizable": "^1.11.0",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-scripts": "3.4.3", "react-scripts": "3.4.3",
"react-trello": "^2.2.8", "react-trello": "^2.2.8",
@@ -58,7 +62,7 @@
"redux-saga": "^1.1.3", "redux-saga": "^1.1.3",
"redux-state-sync": "^3.1.2", "redux-state-sync": "^3.1.2",
"reselect": "^4.0.0", "reselect": "^4.0.0",
"styled-components": "^5.1.1", "styled-components": "^5.2.0",
"subscriptions-transport-ws": "^0.9.18" "subscriptions-transport-ws": "^0.9.18"
}, },
"scripts": { "scripts": {
@@ -86,7 +90,7 @@
"devDependencies": { "devDependencies": {
"@apollo/react-testing": "^4.0.0", "@apollo/react-testing": "^4.0.0",
"enzyme": "^3.11.0", "enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.3", "enzyme-adapter-react-16": "^1.15.5",
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",
"source-map-explorer": "^2.5.0" "source-map-explorer": "^2.5.0"
} }

BIN
client/public/kavia.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -1,85 +1,58 @@
/* body { body {
font-family: "Open Sans", sans-serif; font-family: "Open Sans", sans-serif;
line-height: 1.25; line-height: 1.25;
} */ padding: 10mm 10mm 10mm 10mm !important;
}
table { @page {
margin: 50px;
}
table.imex-table {
border: 1px solid #ccc; border: 1px solid #ccc;
border-collapse: collapse; border-collapse: collapse;
margin: 0; margin: 0;
padding: 0; padding: 0;
width: 100%; width: 100%;
table-layout: fixed; table-layout: fixed;
font-size: inherit;
page-break-inside: auto;
} }
table caption { table.imex-table caption {
font-size: 1.5em; /* font-size: 1.5em; */
margin: 0.5em 0 0.75em; margin: 0.5em 0 0.75em;
font-size: inherit;
} }
table tr { table.imex-table tr {
background-color: #f8f8f8; /* background-color: #f8f8f8; */
border: 1px solid #ddd; border: 1px solid #ddd;
padding: 0.35em; padding: 0.2rem;
font-size: inherit;
page-break-inside: avoid;
page-break-after: auto;
} }
table th, table.imex-table th,
table td { table.imex-table td {
padding: 0.625em; padding: 0.3rem;
text-align: center; text-align: center;
font-size: inherit;
}
table.imex-table th.left,
table.imex-table td.left {
padding: 0.3rem;
text-align: left;
font-size: inherit;
} }
table th { table.imex-table th {
font-size: 0.85em; /* font-size: 0.85em; */
letter-spacing: 0.1em; letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
}
/* display: table-header-group; */
@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;
}
} }

BIN
client/public/vorfahrt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -22,7 +22,7 @@ import App from "./App";
moment.locale("en-US"); moment.locale("en-US");
axios.interceptors.request.use( export const axiosAuthInterceptorId = axios.interceptors.request.use(
async (config) => { async (config) => {
if (!config.headers.Authorization) { if (!config.headers.Authorization) {
const token = const token =
@@ -37,6 +37,9 @@ axios.interceptors.request.use(
(error) => Promise.reject(error) (error) => Promise.reject(error)
); );
export const cleanAxios = axios.create();
cleanAxios.interceptors.request.eject(axiosAuthInterceptorId);
if (process.env.NODE_ENV === "production") LogRocket.init("gvfvfw/bodyshopapp"); if (process.env.NODE_ENV === "production") LogRocket.init("gvfvfw/bodyshopapp");
const httpLink = new HttpLink({ const httpLink = new HttpLink({
@@ -136,8 +139,11 @@ export const client = new ApolloClient({
cache, cache,
connectToDevTools: process.env.NODE_ENV !== "production", connectToDevTools: process.env.NODE_ENV !== "production",
defaultOptions: { defaultOptions: {
query: {
fetchPolicy: "network-only",
},
watchQuery: { watchQuery: {
fetchPolicy: "cache-and-network", fetchPolicy: "network-only",
}, },
}, },
}); });

View File

@@ -68,5 +68,5 @@
.ant-table-cell { .ant-table-cell {
// background-color: red; // background-color: red;
padding: 0.2rem !important; //padding: 0.2rem !important;
} }

View File

@@ -1,6 +1,6 @@
import { import {
PaymentRequestButtonElement, PaymentRequestButtonElement,
useStripe useStripe,
} from "@stripe/react-stripe-js"; } from "@stripe/react-stripe-js";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
@@ -72,7 +72,7 @@ function Test({ bodyshop, setEmailOptions }) {
replyTo: bodyshop.email, replyTo: bodyshop.email,
}, },
template: { template: {
name: TemplateList.parts_order_confirmation.key, name: TemplateList().parts_order_confirmation.key,
variables: { variables: {
id: "a7c2d4e1-f519-42a9-a071-c48cf0f22979", id: "a7c2d4e1-f519-42a9-a071-c48cf0f22979",
}, },

View File

@@ -4,18 +4,15 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import CurrencyFormatter from "../../utils/CurrencyFormatter"; import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { alphaSort } from "../../utils/sorters"; import { alphaSort } from "../../utils/sorters";
import InvoiceExportButton from "../invoice-export-button/invoice-export-button.component"; import PayableExportButton from "../payable-export-button/payable-export-button.component";
import InvoiceExportAllButton from "../invoice-export-all-button/invoice-export-all-button.component"; import PayableExportAll from "../payable-export-all-button/payable-export-all-button.component";
import { DateFormatter } from "../../utils/DateFormatter"; import { DateFormatter } from "../../utils/DateFormatter";
import queryString from "query-string"; import queryString from "query-string";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
export default function AccountingPayablesTableComponent({ export default function AccountingPayablesTableComponent({ loading, bills }) {
loading,
invoices,
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const [selectedInvoices, setSelectedInvoices] = useState([]); const [selectedBills, setSelectedBills] = useState([]);
const [transInProgress, setTransInProgress] = useState(false); const [transInProgress, setTransInProgress] = useState(false);
const [state, setState] = useState({ const [state, setState] = useState({
sortedInfo: {}, sortedInfo: {},
@@ -28,7 +25,7 @@ export default function AccountingPayablesTableComponent({
const columns = [ const columns = [
{ {
title: t("invoices.fields.vendorname"), title: t("bills.fields.vendorname"),
dataIndex: "vendorname", dataIndex: "vendorname",
key: "vendorname", key: "vendorname",
sorter: (a, b) => alphaSort(a.vendor.name, b.vendor.name), sorter: (a, b) => alphaSort(a.vendor.name, b.vendor.name),
@@ -46,7 +43,7 @@ export default function AccountingPayablesTableComponent({
), ),
}, },
{ {
title: t("invoices.fields.invoice_number"), title: t("bills.fields.invoice_number"),
dataIndex: "invoice_number", dataIndex: "invoice_number",
key: "invoice_number", key: "invoice_number",
sorter: (a, b) => alphaSort(a.invoice_number, b.invoice_number), sorter: (a, b) => alphaSort(a.invoice_number, b.invoice_number),
@@ -56,9 +53,9 @@ export default function AccountingPayablesTableComponent({
render: (text, record) => ( render: (text, record) => (
<Link <Link
to={{ to={{
pathname: `/manage/invoices`, pathname: `/manage/bills`,
search: queryString.stringify({ search: queryString.stringify({
invoiceid: record.id, billid: record.id,
vendorid: record.vendor.id, vendorid: record.vendor.id,
}), }),
}} }}
@@ -79,7 +76,7 @@ export default function AccountingPayablesTableComponent({
), ),
}, },
{ {
title: t("invoices.fields.date"), title: t("bills.fields.date"),
dataIndex: "date", dataIndex: "date",
key: "date", key: "date",
@@ -89,7 +86,7 @@ export default function AccountingPayablesTableComponent({
render: (text, record) => <DateFormatter>{record.date}</DateFormatter>, render: (text, record) => <DateFormatter>{record.date}</DateFormatter>,
}, },
{ {
title: t("invoices.fields.total"), title: t("bills.fields.total"),
dataIndex: "total", dataIndex: "total",
key: "total", key: "total",
@@ -101,7 +98,7 @@ export default function AccountingPayablesTableComponent({
), ),
}, },
{ {
title: t("invoices.fields.is_credit_memo"), title: t("bills.fields.is_credit_memo"),
dataIndex: "is_credit_memo", dataIndex: "is_credit_memo",
key: "is_credit_memo", key: "is_credit_memo",
sorter: (a, b) => a.is_credit_memo - b.is_credit_memo, sorter: (a, b) => a.is_credit_memo - b.is_credit_memo,
@@ -120,8 +117,8 @@ export default function AccountingPayablesTableComponent({
render: (text, record) => ( render: (text, record) => (
<div> <div>
<InvoiceExportButton <PayableExportButton
invoiceId={record.id} billId={record.id}
disabled={transInProgress || !!record.exported} disabled={transInProgress || !!record.exported}
loadingCallback={setTransInProgress} loadingCallback={setTransInProgress}
/> />
@@ -136,7 +133,7 @@ export default function AccountingPayablesTableComponent({
}; };
const dataSource = state.search const dataSource = state.search
? invoices.filter( ? bills.filter(
(v) => (v) =>
(v.vendor.name || "") (v.vendor.name || "")
.toLowerCase() .toLowerCase()
@@ -145,7 +142,7 @@ export default function AccountingPayablesTableComponent({
.toLowerCase() .toLowerCase()
.includes(state.search.toLowerCase()) .includes(state.search.toLowerCase())
) )
: invoices; : bills;
return ( return (
<div> <div>
@@ -160,11 +157,11 @@ export default function AccountingPayablesTableComponent({
placeholder={t("general.labels.search")} placeholder={t("general.labels.search")}
allowClear allowClear
/> />
<InvoiceExportAllButton <PayableExportAll
invoiceIds={selectedInvoices} billIds={selectedBills}
disabled={transInProgress || selectedInvoices.length === 0} disabled={transInProgress || selectedBills.length === 0}
loadingCallback={setTransInProgress} loadingCallback={setTransInProgress}
completedCallback={setSelectedInvoices} completedCallback={setSelectedBills}
/> />
</div> </div>
); );
@@ -177,14 +174,14 @@ export default function AccountingPayablesTableComponent({
onChange={handleTableChange} onChange={handleTableChange}
rowSelection={{ rowSelection={{
onSelectAll: (selected, selectedRows) => onSelectAll: (selected, selectedRows) =>
setSelectedInvoices(selectedRows.map((i) => i.id)), setSelectedBills(selectedRows.map((i) => i.id)),
onSelect: (record, selected, selectedRows, nativeEvent) => { onSelect: (record, selected, selectedRows, nativeEvent) => {
setSelectedInvoices(selectedRows.map((i) => i.id)); setSelectedBills(selectedRows.map((i) => i.id));
}, },
getCheckboxProps: (record) => ({ getCheckboxProps: (record) => ({
disabled: record.exported, disabled: record.exported,
}), }),
selectedRowKeys: selectedInvoices, selectedRowKeys: selectedBills,
type: "checkbox", type: "checkbox",
}} }}
/> />

View File

@@ -153,19 +153,20 @@ export default function AccountingPayablesTableComponent({
loading={loading} loading={loading}
title={() => { title={() => {
return ( return (
<div> <div className="imex-table-header">
<PaymentsExportAllButton
paymentIds={selectedPayments}
disabled={transInProgress || selectedPayments.length === 0}
loadingCallback={setTransInProgress}
completedCallback={setSelectedPayments}
/>
<Input <Input
className="imex-table-header__search"
value={state.search} value={state.search}
onChange={handleSearch} onChange={handleSearch}
placeholder={t("general.labels.search")} placeholder={t("general.labels.search")}
allowClear allowClear
/> />
<PaymentsExportAllButton
paymentIds={selectedPayments}
disabled={transInProgress}
loadingCallback={setTransInProgress}
completedCallback={setSelectedPayments}
/>
</div> </div>
); );
}} }}

View File

@@ -9,11 +9,12 @@ export default function BarcodePopupComponent({ value, children }) {
<Popover <Popover
content={ content={
<Barcode <Barcode
value={value} value={value || ""}
background='transparent' background="transparent"
displayValue={false} displayValue={false}
/> />
}> }
>
{children ? children : <Tag>{t("general.labels.barcode")}</Tag>} {children ? children : <Tag>{t("general.labels.barcode")}</Tag>}
</Popover> </Popover>
</div> </div>

View File

@@ -0,0 +1,140 @@
import { useMutation, useQuery } from "@apollo/react-hooks";
import { Button, Form } from "antd";
import moment from "moment";
import queryString from "query-string";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import {
INSERT_NEW_BILL_LINES,
UPDATE_BILL_LINE,
} from "../../graphql/bill-lines.queries";
import { QUERY_BILL_BY_PK, UPDATE_BILL } from "../../graphql/bills.queries";
import AlertComponent from "../alert/alert.component";
import BillFormContainer from "../bill-form/bill-form.container";
import JobDocumentsGallery from "../jobs-documents-gallery/jobs-documents-gallery.container";
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
export default function BillDetailEditcontainer() {
const search = queryString.parse(useLocation().search);
const { t } = useTranslation();
const [form] = Form.useForm();
const [updateLoading, setUpdateLoading] = useState(false);
const [update_bill] = useMutation(UPDATE_BILL);
const [insertBillLine] = useMutation(INSERT_NEW_BILL_LINES);
const [updateBillLine] = useMutation(UPDATE_BILL_LINE);
const { loading, error, data, refetch } = useQuery(QUERY_BILL_BY_PK, {
variables: { billid: search.billid },
skip: !!!search.billid,
});
const handleFinish = async (values) => {
setUpdateLoading(true);
const { billlines, upload, ...bill } = values;
const updates = [];
updates.push(
update_bill({
variables: { billId: search.billid, bill: bill },
})
);
billlines.forEach((il) => {
delete il.__typename;
if (il.id) {
updates.push(
updateBillLine({
variables: {
billLineId: il.id,
billLine: {
...il,
joblineid: il.joblineid === "noline" ? null : il.joblineid,
},
},
})
);
} else {
//It's a new line, have to insert it.
updates.push(
insertBillLine({
variables: {
billLines: [
{
...il,
billid: search.billid,
joblineid: il.joblineid === "noline" ? null : il.joblineid,
},
],
},
})
);
}
});
await Promise.all(updates);
setUpdateLoading(false);
};
useEffect(() => {
if (search.billid) {
form.resetFields();
}
}, [form, search.billid]);
if (error) return <AlertComponent message={error.message} type="error" />;
if (!!!search.billid) return <div>{t("bills.labels.noneselected")}</div>;
const exported = data && data.bills_by_pk && data.bills_by_pk.exported;
return (
<LoadingSkeleton loading={loading}>
<Form
form={form}
onFinish={handleFinish}
initialValues={
data
? {
...data.bills_by_pk,
billlines: data.bills_by_pk.billlines.map((i) => {
return {
...i,
joblineid: !!i.joblineid ? i.joblineid : "noline",
applicable_taxes: {
federal:
(i.applicable_taxes && i.applicable_taxes.federal) ||
false,
state:
(i.applicable_taxes && i.applicable_taxes.state) ||
false,
local:
(i.applicable_taxes && i.applicable_taxes.local) ||
false,
},
};
}),
date: data.bills_by_pk ? moment(data.bills_by_pk.date) : null,
}
: {}
}
>
<Button
htmlType="submit"
disabled={exported}
loading={updateLoading}
type="primary"
>
{t("general.actions.save")}
</Button>
<BillFormContainer form={form} billEdit disabled={exported} />
<JobDocumentsGallery
jobId={data ? data.bills_by_pk.jobid : null}
billId={search.billid}
documentsList={data ? data.bills_by_pk.documents : []}
billsCallback={refetch}
/>
</Form>
</LoadingSkeleton>
);
}

View File

@@ -4,28 +4,28 @@ import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { INSERT_NEW_INVOICE } from "../../graphql/invoices.queries"; import { INSERT_NEW_BILL } from "../../graphql/bills.queries";
import { toggleModalVisible } from "../../redux/modals/modals.actions"; import { toggleModalVisible } from "../../redux/modals/modals.actions";
import { selectInvoiceEnterModal } from "../../redux/modals/modals.selectors"; import { selectBillEnterModal } from "../../redux/modals/modals.selectors";
import { import {
selectBodyshop, selectBodyshop,
selectCurrentUser, selectCurrentUser,
} from "../../redux/user/user.selectors"; } from "../../redux/user/user.selectors";
import { handleUpload } from "../documents-upload/documents-upload.utility"; import { handleUpload } from "../documents-upload/documents-upload.utility";
import InvoiceFormContainer from "../invoice-form/invoice-form.container"; import BillFormContainer from "../bill-form/bill-form.container";
import { UPDATE_JOB_LINE_STATUS } from "../../graphql/jobs-lines.queries"; import { UPDATE_JOB_LINE_STATUS } from "../../graphql/jobs-lines.queries";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
invoiceEnterModal: selectInvoiceEnterModal, billEnterModal: selectBillEnterModal,
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
currentUser: selectCurrentUser, currentUser: selectCurrentUser,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("invoiceEnter")), toggleModalVisible: () => dispatch(toggleModalVisible("billEnter")),
}); });
function InvoiceEnterModalContainer({ function BillEnterModalContainer({
invoiceEnterModal, billEnterModal,
toggleModalVisible, toggleModalVisible,
bodyshop, bodyshop,
currentUser, currentUser,
@@ -33,38 +33,41 @@ function InvoiceEnterModalContainer({
const [form] = Form.useForm(); const [form] = Form.useForm();
const { t } = useTranslation(); const { t } = useTranslation();
const [enterAgain, setEnterAgain] = useState(false); const [enterAgain, setEnterAgain] = useState(false);
const [insertInvoice] = useMutation(INSERT_NEW_INVOICE); const [insertBill] = useMutation(INSERT_NEW_BILL);
const [updateJobLines] = useMutation(UPDATE_JOB_LINE_STATUS); const [updateJobLines] = useMutation(UPDATE_JOB_LINE_STATUS);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const handleFinish = (values) => { const handleFinish = (values) => {
setLoading(true); setLoading(true);
const { upload, ...remainingValues } = values; const { upload, location, ...remainingValues } = values;
insertInvoice({ insertBill({
variables: { variables: {
invoice: [ bill: [
Object.assign({}, remainingValues, { Object.assign({}, remainingValues, {
invoicelines: { billlines: {
data: remainingValues.invoicelines.map((i) => { data:
return { remainingValues.billlines &&
...i, remainingValues.billlines.map((i) => {
joblineid: i.joblineid === "noline" ? null : i.joblineid, return {
}; ...i,
}), joblineid: i.joblineid === "noline" ? null : i.joblineid,
};
}),
}, },
}), }),
], ],
}, },
}) })
.then((r) => { .then((r) => {
const invoiceId = r.data.insert_invoices.returning[0].id; const billId = r.data.insert_bills.returning[0].id;
updateJobLines({ updateJobLines({
variables: { variables: {
ids: remainingValues.invoicelines ids: remainingValues.billlines
.filter((il) => il.joblineid !== "noline") .filter((il) => il.joblineid !== "noline")
.map((li) => li.joblineid), .map((li) => li.joblineid),
status: bodyshop.md_order_statuses.default_received || "Received*", status: bodyshop.md_order_statuses.default_received || "Received*",
location: location,
}, },
}).then((joblineresult) => { }).then((joblineresult) => {
///////////////////////// /////////////////////////
@@ -77,7 +80,7 @@ function InvoiceEnterModalContainer({
bodyshop: bodyshop, bodyshop: bodyshop,
uploaded_by: currentUser.email, uploaded_by: currentUser.email,
jobId: values.jobid, jobId: values.jobid,
invoiceId: invoiceId, billId: billId,
tagsArray: null, tagsArray: null,
callback: null, callback: null,
} }
@@ -87,14 +90,13 @@ function InvoiceEnterModalContainer({
/////////////////////////// ///////////////////////////
setLoading(false); setLoading(false);
notification["success"]({ notification["success"]({
message: t("invoices.successes.created"), message: t("bills.successes.created"),
}); });
if (invoiceEnterModal.actions.refetch) if (billEnterModal.actions.refetch) billEnterModal.actions.refetch();
invoiceEnterModal.actions.refetch();
if (enterAgain) { if (enterAgain) {
form.resetFields(); form.resetFields();
form.setFieldsValue({ invoicelines: [] }); form.setFieldsValue({ billlines: [] });
} else { } else {
toggleModalVisible(); toggleModalVisible();
} }
@@ -105,7 +107,7 @@ function InvoiceEnterModalContainer({
setLoading(false); setLoading(false);
setEnterAgain(false); setEnterAgain(false);
notification["error"]({ notification["error"]({
message: t("invoices.errors.creating", { message: t("bills.errors.creating", {
message: JSON.stringify(error), message: JSON.stringify(error),
}), }),
}); });
@@ -121,14 +123,14 @@ function InvoiceEnterModalContainer({
}, [enterAgain, form]); }, [enterAgain, form]);
useEffect(() => { useEffect(() => {
if (invoiceEnterModal.visible) form.resetFields(); if (billEnterModal.visible) form.resetFields();
}, [invoiceEnterModal.visible, form]); }, [billEnterModal.visible, form]);
return ( return (
<Modal <Modal
title={t("invoices.labels.new")} title={t("bills.labels.new")}
width={"90%"} width={"90%"}
visible={invoiceEnterModal.visible} visible={billEnterModal.visible}
okText={t("general.actions.save")} okText={t("general.actions.save")}
onOk={() => form.submit()} onOk={() => form.submit()}
onCancel={handleCancel} onCancel={handleCancel}
@@ -139,7 +141,7 @@ function InvoiceEnterModalContainer({
<Button loading={loading} onClick={() => form.submit()}> <Button loading={loading} onClick={() => form.submit()}>
{t("general.actions.save")} {t("general.actions.save")}
</Button> </Button>
{invoiceEnterModal.context && invoiceEnterModal.context.id ? null : ( {billEnterModal.context && billEnterModal.context.id ? null : (
<Button <Button
type="primary" type="primary"
loading={loading} loading={loading}
@@ -157,22 +159,31 @@ function InvoiceEnterModalContainer({
<Form <Form
onFinish={handleFinish} onFinish={handleFinish}
autoComplete={"off"} autoComplete={"off"}
layout="vertical"
form={form} form={form}
onFinishFailed={() => { onFinishFailed={() => {
setEnterAgain(false); setEnterAgain(false);
}} }}
initialValues={{ initialValues={{
...invoiceEnterModal.context.invoice, ...billEnterModal.context.bill,
jobid: jobid:
(invoiceEnterModal.context.job && (billEnterModal.context.job && billEnterModal.context.job.id) ||
invoiceEnterModal.context.job.id) ||
null, null,
federal_tax_rate: bodyshop.invoice_tax_rates.federal_tax_rate || 0, federal_tax_rate:
state_tax_rate: bodyshop.invoice_tax_rates.state_tax_rate || 0, (bodyshop.bill_tax_rates &&
local_tax_rate: bodyshop.invoice_tax_rates.local_tax_rate || 0, bodyshop.bill_tax_rates.federal_tax_rate) ||
0,
state_tax_rate:
(bodyshop.bill_tax_rates &&
bodyshop.bill_tax_rates.state_tax_rate) ||
0,
local_tax_rate:
(bodyshop.bill_tax_rates &&
bodyshop.bill_tax_rates.local_tax_rate) ||
0,
}} }}
> >
<InvoiceFormContainer form={form} /> <BillFormContainer form={form} />
</Form> </Form>
</Modal> </Modal>
); );
@@ -181,4 +192,4 @@ function InvoiceEnterModalContainer({
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(InvoiceEnterModalContainer); )(BillEnterModalContainer);

View File

@@ -1,26 +1,46 @@
import { Button, Form, Input, Statistic, Switch, Upload } from "antd"; import {
Button,
Form,
Input,
Select,
Space,
Statistic,
Switch,
Typography,
Upload,
} from "antd";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useApolloClient } from "react-apollo";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { CHECK_BILL_INVOICE_NUMBER } from "../../graphql/bills.queries";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import FormDatePicker from "../form-date-picker/form-date-picker.component"; import FormDatePicker from "../form-date-picker/form-date-picker.component";
import CurrencyInput from "../form-items-formatted/currency-form-item.component"; import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import JobSearchSelect from "../job-search-select/job-search-select.component"; import JobSearchSelect from "../job-search-select/job-search-select.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component"; import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
import InvoiceFormLines from "./invoice-form.lines.component"; import BillFormLines from "./bill-form.lines.component";
import "./invoice-form.styles.scss"; import { CalculateBillTotal } from "./bill-form.totals.utility";
import { CalculateInvoiceTotal } from "./invoice-form.totals.utility"; const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
});
const mapDispatchToProps = (dispatch) => ({});
export default function InvoiceFormComponent({ export function BillFormComponent({
bodyshop,
disabled,
form, form,
roAutoCompleteOptions,
vendorAutoCompleteOptions, vendorAutoCompleteOptions,
lineData, lineData,
responsibilityCenters, responsibilityCenters,
loadLines, loadLines,
invoiceEdit, billEdit,
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const client = useApolloClient();
const [discount, setDiscount] = useState(0); const [discount, setDiscount] = useState(0);
const handleVendorSelect = (props, opt) => { const handleVendorSelect = (props, opt) => {
@@ -44,11 +64,11 @@ export default function InvoiceFormComponent({
}, [form, setDiscount, vendorAutoCompleteOptions, loadLines]); }, [form, setDiscount, vendorAutoCompleteOptions, loadLines]);
return ( return (
<div className="invoice-form-wrapper"> <div>
<div className="invoice-form-invoice-details"> <LayoutFormRow>
<Form.Item <Form.Item
name="jobid" name="jobid"
label={t("invoices.fields.ro_number")} label={t("bills.fields.ro_number")}
rules={[ rules={[
{ {
required: true, required: true,
@@ -57,8 +77,7 @@ export default function InvoiceFormComponent({
]} ]}
> >
<JobSearchSelect <JobSearchSelect
options={roAutoCompleteOptions} disabled={billEdit || disabled}
disabled={invoiceEdit}
onBlur={() => { onBlur={() => {
if (form.getFieldValue("jobid") !== null) { if (form.getFieldValue("jobid") !== null) {
loadLines({ variables: { id: form.getFieldValue("jobid") } }); loadLines({ variables: { id: form.getFieldValue("jobid") } });
@@ -67,9 +86,9 @@ export default function InvoiceFormComponent({
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("invoices.fields.vendor")} label={t("bills.fields.vendor")}
name="vendorid" name="vendorid"
style={{ display: invoiceEdit ? "none" : null }} style={{ display: billEdit ? "none" : null }}
rules={[ rules={[
{ {
required: true, required: true,
@@ -78,26 +97,53 @@ export default function InvoiceFormComponent({
]} ]}
> >
<VendorSearchSelect <VendorSearchSelect
disabled={disabled}
options={vendorAutoCompleteOptions} options={vendorAutoCompleteOptions}
onSelect={handleVendorSelect} onSelect={handleVendorSelect}
/> />
</Form.Item> </Form.Item>
</div> </LayoutFormRow>
<div className="invoice-form-invoice-details">
<LayoutFormRow>
<Form.Item <Form.Item
label={t("invoices.fields.invoice_number")} label={t("bills.fields.invoice_number")}
name="invoice_number" name="invoice_number"
validateTrigger="onBlur"
hasFeedback
rules={[ rules={[
{ {
required: true, required: true,
message: t("general.validation.required"), message: t("general.validation.required"),
}, },
({ getFieldValue }) => ({
async validator(rule, value) {
const vendorid = getFieldValue("vendorid");
if (vendorid) {
const response = await client.query({
query: CHECK_BILL_INVOICE_NUMBER,
variables: {
invoice_number: value,
vendorid: vendorid,
},
});
if (response.data.bills_aggregate.aggregate.count === 0) {
return Promise.resolve();
}
return Promise.reject(
t("bills.validation.unique_invoice_number")
);
} else {
return Promise.resolve();
}
},
}),
]} ]}
> >
<Input /> <Input disabled={disabled} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("invoices.fields.date")} label={t("bills.fields.date")}
name="date" name="date"
rules={[ rules={[
{ {
@@ -106,17 +152,17 @@ export default function InvoiceFormComponent({
}, },
]} ]}
> >
<FormDatePicker /> <FormDatePicker disabled={disabled} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("invoices.fields.is_credit_memo")} label={t("bills.fields.is_credit_memo")}
name="is_credit_memo" name="is_credit_memo"
valuePropName="checked" valuePropName="checked"
> >
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("invoices.fields.total")} label={t("bills.fields.total")}
name="total" name="total"
rules={[ rules={[
{ {
@@ -125,38 +171,52 @@ export default function InvoiceFormComponent({
}, },
]} ]}
> >
<CurrencyInput min={0} /> <CurrencyInput min={0} disabled={disabled} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("invoices.fields.federal_tax_rate")} label={t("bills.fields.federal_tax_rate")}
name="federal_tax_rate" name="federal_tax_rate"
> >
<CurrencyInput min={0} /> <CurrencyInput min={0} disabled={disabled} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("invoices.fields.state_tax_rate")} label={t("bills.fields.state_tax_rate")}
name="state_tax_rate" name="state_tax_rate"
> >
<CurrencyInput min={0} /> <CurrencyInput min={0} disabled={disabled} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("invoices.fields.local_tax_rate")} label={t("bills.fields.local_tax_rate")}
name="local_tax_rate" name="local_tax_rate"
> >
<CurrencyInput min={0} /> <CurrencyInput min={0} />
</Form.Item> </Form.Item>
</div>
<InvoiceFormLines <Form.Item label={t("bills.fields.allpartslocation")} name="location">
<Select style={{ width: "10rem" }} disabled={disabled}>
{bodyshop.md_parts_locations.map((loc, idx) => (
<Select.Option key={idx} value={loc}>
{loc}
</Select.Option>
))}
</Select>
</Form.Item>
</LayoutFormRow>
<Typography.Title level={4}>
{t("bills.labels.bill_lines")}
</Typography.Title>
<BillFormLines
lineData={lineData} lineData={lineData}
discount={discount} discount={discount}
form={form} form={form}
responsibilityCenters={responsibilityCenters} responsibilityCenters={responsibilityCenters}
disabled={disabled}
/> />
<Form.Item <Form.Item
name="upload" name="upload"
label="Upload" label="Upload"
style={{ display: invoiceEdit ? "none" : null }} style={{ display: billEdit ? "none" : null }}
valuePropName="fileList" valuePropName="fileList"
getValueFromEvent={(e) => { getValueFromEvent={(e) => {
if (Array.isArray(e)) { if (Array.isArray(e)) {
@@ -173,7 +233,7 @@ export default function InvoiceFormComponent({
<Form.Item shouldUpdate> <Form.Item shouldUpdate>
{() => { {() => {
const values = form.getFieldsValue([ const values = form.getFieldsValue([
"invoicelines", "billlines",
"total", "total",
"federal_tax_rate", "federal_tax_rate",
"state_tax_rate", "state_tax_rate",
@@ -182,46 +242,46 @@ export default function InvoiceFormComponent({
let totals; let totals;
if ( if (
!!values.total && !!values.total &&
!!values.invoicelines && !!values.billlines &&
values.invoicelines.length > 0 values.billlines.length > 0
) )
totals = CalculateInvoiceTotal(values); totals = CalculateBillTotal(values);
if (!!totals) if (!!totals)
return ( return (
<div> <div>
<div className="invoice-form-totals"> <Space>
<Statistic <Statistic
title={t("invoices.labels.subtotal")} title={t("bills.labels.subtotal")}
value={totals.subtotal.toFormat()} value={totals.subtotal.toFormat()}
precision={2} precision={2}
/> />
<Statistic <Statistic
title={t("invoices.labels.federal_tax")} title={t("bills.labels.federal_tax")}
value={totals.federalTax.toFormat()} value={totals.federalTax.toFormat()}
precision={2} precision={2}
/> />
<Statistic <Statistic
title={t("invoices.labels.state_tax")} title={t("bills.labels.state_tax")}
value={totals.stateTax.toFormat()} value={totals.stateTax.toFormat()}
precision={2} precision={2}
/> />
<Statistic <Statistic
title={t("invoices.labels.local_tax")} title={t("bills.labels.local_tax")}
value={totals.localTax.toFormat()} value={totals.localTax.toFormat()}
precision={2} precision={2}
/> />
<Statistic <Statistic
title={t("invoices.labels.entered_total")} title={t("bills.labels.entered_total")}
value={totals.enteredTotal.toFormat()} value={totals.enteredTotal.toFormat()}
precision={2} precision={2}
/> />
<Statistic <Statistic
title={t("invoices.labels.invoice_total")} title={t("bills.labels.bill_total")}
value={totals.invoiceTotal.toFormat()} value={totals.invoiceTotal.toFormat()}
precision={2} precision={2}
/> />
<Statistic <Statistic
title={t("invoices.labels.discrepancy")} title={t("bills.labels.discrepancy")}
valueStyle={{ valueStyle={{
color: color:
totals.discrepancy.getAmount() === 0 ? "green" : "red", totals.discrepancy.getAmount() === 0 ? "green" : "red",
@@ -229,11 +289,11 @@ export default function InvoiceFormComponent({
value={totals.discrepancy.toFormat()} value={totals.discrepancy.toFormat()}
precision={2} precision={2}
/> />
</div> </Space>
{form.getFieldValue("is_credit_memo") ? ( {form.getFieldValue("is_credit_memo") ? (
<AlertComponent <AlertComponent
type="warning" type="warning"
message={t("invoices.labels.enteringcreditmemo")} message={t("bills.labels.enteringcreditmemo")}
/> />
) : null} ) : null}
</div> </div>
@@ -244,3 +304,5 @@ export default function InvoiceFormComponent({
</div> </div>
); );
} }
export default connect(mapStateToProps, mapDispatchToProps)(BillFormComponent);

View File

@@ -0,0 +1,35 @@
import { useLazyQuery, useQuery } from "@apollo/react-hooks";
import React from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { GET_JOB_LINES_TO_ENTER_BILL } from "../../graphql/jobs-lines.queries";
import { SEARCH_VENDOR_AUTOCOMPLETE } from "../../graphql/vendors.queries";
import { selectBodyshop } from "../../redux/user/user.selectors";
import BillFormComponent from "./bill-form.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
});
export function BillFormContainer({ bodyshop, form, billEdit, disabled }) {
const { data: VendorAutoCompleteData } = useQuery(SEARCH_VENDOR_AUTOCOMPLETE);
const [loadLines, { data: lineData }] = useLazyQuery(
GET_JOB_LINES_TO_ENTER_BILL
);
return (
<BillFormComponent
disabled={disabled}
form={form}
billEdit={billEdit}
vendorAutoCompleteOptions={
VendorAutoCompleteData && VendorAutoCompleteData.vendors
}
loadLines={loadLines}
lineData={lineData ? lineData.joblines : []}
responsibilityCenters={bodyshop.md_responsibility_centers || null}
/>
);
}
export default connect(mapStateToProps, null)(BillFormContainer);

View File

@@ -0,0 +1,248 @@
import { DeleteFilled, WarningOutlined } from "@ant-design/icons";
import {
Button,
Divider,
Form,
Input,
InputNumber,
Select,
Switch,
} from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import BillLineSearchSelect from "../bill-line-search-select/bill-line-search-select.component";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
export default function BillEnterModalLinesComponent({
disabled,
lineData,
discount,
form,
responsibilityCenters,
}) {
const { t } = useTranslation();
const { setFieldsValue, getFieldsValue } = form;
return (
<Form.List name="billlines">
{(fields, { add, remove, move }) => {
return (
<div className="invoice-form-lines-wrapper">
{fields.map((field, index) => (
<Form.Item required={false} key={field.key}>
<div>
<div style={{ display: "flex", alignItems: "center" }}>
<LayoutFormRow style={{ flex: 1 }} grow>
<Form.Item
label={t("billlines.fields.jobline")}
key={`${index}joblinename`}
name={[field.name, "joblineid"]}
rules={[
{
required: true,
message: t("general.validation.required"),
},
]}
>
<BillLineSearchSelect
disabled={disabled}
options={lineData}
onSelect={(value, opt) => {
setFieldsValue({
billlines: getFieldsValue([
"billlines",
]).billlines.map((item, idx) => {
if (idx === index) {
return {
...item,
line_desc: opt.line_desc,
quantity: opt.part_qty || 1,
actual_price: opt.cost,
cost_center: opt.part_type
? responsibilityCenters.defaults.costs[
opt.part_type
] || null
: null,
};
}
return item;
}),
});
}}
/>
</Form.Item>
<Form.Item
label={t("billlines.fields.line_desc")}
key={`${index}line_desc`}
name={[field.name, "line_desc"]}
rules={[
{
required: true,
message: t("general.validation.required"),
},
]}
>
<Input disabled={disabled} />
</Form.Item>
<Form.Item
label={t("billlines.fields.quantity")}
key={`${index}quantity`}
name={[field.name, "quantity"]}
rules={[
{
required: true,
message: t("general.validation.required"),
},
]}
>
<InputNumber
precision={0}
min={0}
disabled={disabled}
/>
</Form.Item>
<Form.Item
label={t("billlines.fields.actual")}
key={`${index}actual_price`}
name={[field.name, "actual_price"]}
rules={[
{
required: true,
message: t("general.validation.required"),
},
]}
>
<CurrencyInput
min={0}
disabled={disabled}
onBlur={(e) => {
setFieldsValue({
billlines: getFieldsValue(
"billlines"
).billlines.map((item, idx) => {
if (idx === index) {
return {
...item,
actual_cost: !!item.actual_cost
? item.actual_cost
: parseFloat(e.target.value) *
(1 - discount),
};
}
return item;
}),
});
}}
/>
</Form.Item>
<Form.Item
label={t("billlines.fields.actual_cost")}
key={`${index}actual_cost`}
name={[field.name, "actual_cost"]}
rules={[
{
required: true,
message: t("general.validation.required"),
},
]}
>
<CurrencyInput min={0} disabled={disabled} />
</Form.Item>
<Form.Item shouldUpdate>
{() => {
const line = getFieldsValue(["billlines"]).billlines[
index
];
if (!!!line) return null;
const lineDiscount = (
1 -
Math.round(
(line.actual_cost / line.actual_price) * 100
) /
100
).toPrecision(2);
if (lineDiscount - discount === 0) return <div />;
return <WarningOutlined style={{ color: "red" }} />;
}}
</Form.Item>
<Form.Item
label={t("billlines.fields.cost_center")}
key={`${index}cost_center`}
name={[field.name, "cost_center"]}
rules={[
{
required: true,
message: t("general.validation.required"),
},
]}
>
<Select style={{ width: "150px" }} disabled={disabled}>
{responsibilityCenters.costs.map((item) => (
<Select.Option key={item.name}>
{item.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label={t("billlines.fields.federal_tax_applicable")}
key={`${index}fedtax`}
initialValue={true}
valuePropName="checked"
name={[field.name, "applicable_taxes", "federal"]}
>
<Switch disabled={disabled} />
</Form.Item>
<Form.Item
label={t("billlines.fields.state_tax_applicable")}
key={`${index}statetax`}
valuePropName="checked"
name={[field.name, "applicable_taxes", "state"]}
>
<Switch disabled={disabled} />
</Form.Item>
<Form.Item
label={t("billlines.fields.local_tax_applicable")}
key={`${index}localtax`}
valuePropName="checked"
name={[field.name, "applicable_taxes", "local"]}
>
<Switch disabled={disabled} />
</Form.Item>
</LayoutFormRow>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
/>
<DeleteFilled
disabled={disabled}
onClick={() => {
remove(field.name);
}}
/>
</div>
<Divider />
</div>
</Form.Item>
))}
<Form.Item>
<Button
disabled={disabled}
onClick={() => {
add();
}}
style={{ width: "100%" }}
>
{t("billlines.actions.newline")}
</Button>
</Form.Item>
</div>
);
}}
</Form.List>
);
}

View File

@@ -1,13 +1,12 @@
import Dinero from "dinero.js"; import Dinero from "dinero.js";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
export const CalculateInvoiceTotal = (invoice) => { export const CalculateBillTotal = (invoice) => {
logImEXEvent("invoice_calculate_total"); logImEXEvent("invoice_calculate_total");
const { const {
total, total,
billlines,
invoicelines,
federal_tax_rate, federal_tax_rate,
local_tax_rate, local_tax_rate,
state_tax_rate, state_tax_rate,
@@ -17,8 +16,10 @@ export const CalculateInvoiceTotal = (invoice) => {
let federalTax = Dinero({ amount: 0 }); let federalTax = Dinero({ amount: 0 });
let stateTax = Dinero({ amount: 0 }); let stateTax = Dinero({ amount: 0 });
let localTax = Dinero({ amount: 0 }); let localTax = Dinero({ amount: 0 });
if (!!!invoicelines) return null;
invoicelines.forEach((i) => { if (!!!billlines) return null;
billlines.forEach((i) => {
if (!!i) { if (!!i) {
const itemTotal = Dinero({ const itemTotal = Dinero({
amount: Math.round((i.actual_cost || 0) * 100) || 0, amount: Math.round((i.actual_cost || 0) * 100) || 0,

View File

@@ -5,8 +5,8 @@ import CurrencyFormatter from "../../utils/CurrencyFormatter";
//To be used as a form element only. //To be used as a form element only.
const { Option } = Select; const { Option } = Select;
const InvoiceLineSearchSelect = ( const BillLineSearchSelect = (
{ value, onChange, options, onBlur, onSelect }, { value, onChange, options, onBlur, onSelect, disabled },
ref ref
) => { ) => {
const [option, setOption] = useState(value); const [option, setOption] = useState(value);
@@ -20,12 +20,13 @@ const InvoiceLineSearchSelect = (
return ( return (
<Select <Select
disabled={disabled}
ref={ref} ref={ref}
showSearch showSearch
autoFocus autoFocus
value={option} value={option}
style={{ style={{
width: 300, width: "100%",
}} }}
onChange={setOption} onChange={setOption}
optionFilterProp="line_desc" optionFilterProp="line_desc"
@@ -33,7 +34,7 @@ const InvoiceLineSearchSelect = (
onSelect={onSelect} onSelect={onSelect}
> >
<Select.Option key={null} value={"noline"} cost={0} line_desc={""}> <Select.Option key={null} value={"noline"} cost={0} line_desc={""}>
{t("invoicelines.labels.other")} {t("billlines.labels.other")}
</Select.Option> </Select.Option>
{options {options
? options.map((item) => ( ? options.map((item) => (
@@ -48,12 +49,18 @@ const InvoiceLineSearchSelect = (
<Row justify="center" align="middle"> <Row justify="center" align="middle">
<Col span={12}>{item.line_desc}</Col> <Col span={12}>{item.line_desc}</Col>
<Col span={8}> <Col span={8}>
<Tag color="blue">{item.oem_partno}</Tag> {item.oem_partno ? (
<Tag color="blue">{item.oem_partno}</Tag>
) : null}
</Col> </Col>
<Col span={4}> <Col span={4}>
<Tag color="green"> {item.act_price ? (
<CurrencyFormatter>{item.act_price || 0}</CurrencyFormatter> <Tag color="green">
</Tag> <CurrencyFormatter>
{item.act_price || 0}
</CurrencyFormatter>
</Tag>
) : null}
</Col> </Col>
</Row> </Row>
</Option> </Option>
@@ -62,4 +69,4 @@ const InvoiceLineSearchSelect = (
</Select> </Select>
); );
}; };
export default forwardRef(InvoiceLineSearchSelect); export default forwardRef(BillLineSearchSelect);

View File

@@ -1,51 +1,51 @@
import { SyncOutlined } from "@ant-design/icons"; import { SyncOutlined } from "@ant-design/icons";
import { Button, Checkbox, Descriptions, Table, Input, Typography } from "antd"; import { Button, Checkbox, Descriptions, Input, Table, Typography } from "antd";
import queryString from "query-string";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Link } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { setModalContext } from "../../redux/modals/modals.actions"; import { setModalContext } from "../../redux/modals/modals.actions";
import CurrencyFormatter from "../../utils/CurrencyFormatter"; import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateFormatter } from "../../utils/DateFormatter"; import { DateFormatter } from "../../utils/DateFormatter";
import { alphaSort } from "../../utils/sorters"; import { alphaSort } from "../../utils/sorters";
import queryString from "query-string";
import { useLocation } from "react-router-dom"; const mapStateToProps = createStructuredSelector({
//jobRO: selectJobReadOnly,
});
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setPartsOrderContext: (context) => setPartsOrderContext: (context) =>
dispatch(setModalContext({ context: context, modal: "partsOrder" })), dispatch(setModalContext({ context: context, modal: "partsOrder" })),
setInvoiceEnterContext: (context) => setBillEnterContext: (context) =>
dispatch(setModalContext({ context: context, modal: "invoiceEnter" })), dispatch(setModalContext({ context: context, modal: "billEnter" })),
setReconciliationContext: (context) => setReconciliationContext: (context) =>
dispatch(setModalContext({ context: context, modal: "reconciliation" })), dispatch(setModalContext({ context: context, modal: "reconciliation" })),
}); });
export function InvoicesListTableComponent({ export function BillsListTableComponent({
job, job,
loading, billsQuery,
invoicesQuery,
handleOnRowClick, handleOnRowClick,
setPartsOrderContext, setPartsOrderContext,
setInvoiceEnterContext, setBillEnterContext,
setReconciliationContext, setReconciliationContext,
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [ const [selectedBillLinesByBill, setSelectedBillLinesByBill] = useState({});
selectedInvoiceLinesByInvoice,
setSelectedInvoiceLinesByInvoice,
] = useState({});
const [state, setState] = useState({ const [state, setState] = useState({
sortedInfo: {}, sortedInfo: {},
}); });
const search = queryString.parse(useLocation().search); const search = queryString.parse(useLocation().search);
const selectedInvoice = search.invoiceid; const selectedBill = search.billid;
const invoices = invoicesQuery.data ? invoicesQuery.data.invoices : []; const bills = billsQuery.data ? billsQuery.data.bills : [];
const { refetch } = invoicesQuery; const { refetch } = billsQuery;
const columns = [ const columns = [
{ {
title: t("invoices.fields.vendorname"), title: t("bills.fields.vendorname"),
dataIndex: "vendorname", dataIndex: "vendorname",
key: "vendorname", key: "vendorname",
sorter: (a, b) => alphaSort(a.vendor.name, b.vendor.name), sorter: (a, b) => alphaSort(a.vendor.name, b.vendor.name),
@@ -54,7 +54,7 @@ export function InvoicesListTableComponent({
render: (text, record) => <span>{record.vendor.name}</span>, render: (text, record) => <span>{record.vendor.name}</span>,
}, },
{ {
title: t("invoices.fields.invoice_number"), title: t("bills.fields.invoice_number"),
dataIndex: "invoice_number", dataIndex: "invoice_number",
key: "invoice_number", key: "invoice_number",
sorter: (a, b) => alphaSort(a.invoice_number, b.invoice_number), sorter: (a, b) => alphaSort(a.invoice_number, b.invoice_number),
@@ -63,7 +63,7 @@ export function InvoicesListTableComponent({
state.sortedInfo.order, state.sortedInfo.order,
}, },
{ {
title: t("invoices.fields.date"), title: t("bills.fields.date"),
dataIndex: "date", dataIndex: "date",
key: "date", key: "date",
sorter: (a, b) => a.date - b.date, sorter: (a, b) => a.date - b.date,
@@ -72,7 +72,7 @@ export function InvoicesListTableComponent({
render: (text, record) => <DateFormatter>{record.date}</DateFormatter>, render: (text, record) => <DateFormatter>{record.date}</DateFormatter>,
}, },
{ {
title: t("invoices.fields.total"), title: t("bills.fields.total"),
dataIndex: "total", dataIndex: "total",
key: "total", key: "total",
sorter: (a, b) => a.total - b.total, sorter: (a, b) => a.total - b.total,
@@ -83,7 +83,7 @@ export function InvoicesListTableComponent({
), ),
}, },
{ {
title: t("invoices.fields.is_credit_memo"), title: t("bills.fields.is_credit_memo"),
dataIndex: "is_credit_memo", dataIndex: "is_credit_memo",
key: "is_credit_memo", key: "is_credit_memo",
sorter: (a, b) => a.is_credit_memo - b.is_credit_memo, sorter: (a, b) => a.is_credit_memo - b.is_credit_memo,
@@ -92,17 +92,30 @@ export function InvoicesListTableComponent({
state.sortedInfo.order, state.sortedInfo.order,
render: (text, record) => <Checkbox checked={record.is_credit_memo} />, render: (text, record) => <Checkbox checked={record.is_credit_memo} />,
}, },
{
title: t("bills.fields.exported"),
dataIndex: "exported",
key: "exported",
sorter: (a, b) => a.exported - b.exported,
sortOrder:
state.sortedInfo.columnKey === "exported" && state.sortedInfo.order,
render: (text, record) => <Checkbox checked={record.exported} />,
},
{ {
title: t("general.labels.actions"), title: t("general.labels.actions"),
dataIndex: "actions", dataIndex: "actions",
key: "actions", key: "actions",
render: (text, record) => ( render: (text, record) => (
<div> <div>
<Link {record.exported ? (
to={`/manage/invoices?invoiceid=${record.id}&vendorid=${record.vendorid}`} <Button disabled>{t("bills.actions.edit")}</Button>
> ) : (
<Button>{t("invoices.actions.edit")}</Button> <Link
</Link> to={`/manage/bills?billid=${record.id}&vendorid=${record.vendorid}`}
>
<Button>{t("bills.actions.edit")}</Button>
</Link>
)}
</div> </div>
), ),
}, },
@@ -115,7 +128,7 @@ export function InvoicesListTableComponent({
const rowExpander = (record) => { const rowExpander = (record) => {
const columns = [ const columns = [
{ {
title: t("invoicelines.fields.line_desc"), title: t("billlines.fields.line_desc"),
dataIndex: "line_desc", dataIndex: "line_desc",
key: "line_desc", key: "line_desc",
sorter: (a, b) => alphaSort(a.line_desc, b.line_desc), sorter: (a, b) => alphaSort(a.line_desc, b.line_desc),
@@ -123,7 +136,7 @@ export function InvoicesListTableComponent({
state.sortedInfo.columnKey === "line_desc" && state.sortedInfo.order, state.sortedInfo.columnKey === "line_desc" && state.sortedInfo.order,
}, },
{ {
title: t("invoicelines.fields.retail"), title: t("billlines.fields.retail"),
dataIndex: "actual_price", dataIndex: "actual_price",
key: "actual_price", key: "actual_price",
sorter: (a, b) => a.actual_price - b.actual_price, sorter: (a, b) => a.actual_price - b.actual_price,
@@ -135,7 +148,7 @@ export function InvoicesListTableComponent({
), ),
}, },
{ {
title: t("invoicelines.fields.actual_cost"), title: t("billlines.fields.actual_cost"),
dataIndex: "actual_cost", dataIndex: "actual_cost",
key: "actual_cost", key: "actual_cost",
sorter: (a, b) => a.actual_cost - b.actual_cost, sorter: (a, b) => a.actual_cost - b.actual_cost,
@@ -147,7 +160,7 @@ export function InvoicesListTableComponent({
), ),
}, },
{ {
title: t("invoicelines.fields.quantity"), title: t("billlines.fields.quantity"),
dataIndex: "quantity", dataIndex: "quantity",
key: "quantity", key: "quantity",
sorter: (a, b) => a.quantity - b.quantity, sorter: (a, b) => a.quantity - b.quantity,
@@ -155,7 +168,7 @@ export function InvoicesListTableComponent({
state.sortedInfo.columnKey === "quantity" && state.sortedInfo.order, state.sortedInfo.columnKey === "quantity" && state.sortedInfo.order,
}, },
{ {
title: t("invoicelines.fields.cost_center"), title: t("billlines.fields.cost_center"),
dataIndex: "cost_center", dataIndex: "cost_center",
key: "cost_center", key: "cost_center",
sorter: (a, b) => alphaSort(a.cost_center, b.cost_center), sorter: (a, b) => alphaSort(a.cost_center, b.cost_center),
@@ -164,7 +177,7 @@ export function InvoicesListTableComponent({
state.sortedInfo.order, state.sortedInfo.order,
}, },
{ {
title: t("invoicelines.fields.federal_tax_applicable"), title: t("billlines.fields.federal_tax_applicable"),
dataIndex: "applicable_taxes.federal", dataIndex: "applicable_taxes.federal",
key: "applicable_taxes.federal", key: "applicable_taxes.federal",
render: (text, record) => ( render: (text, record) => (
@@ -178,7 +191,7 @@ export function InvoicesListTableComponent({
), ),
}, },
{ {
title: t("invoicelines.fields.state_tax_applicable"), title: t("billlines.fields.state_tax_applicable"),
dataIndex: "applicable_taxes.state", dataIndex: "applicable_taxes.state",
key: "applicable_taxes.state", key: "applicable_taxes.state",
render: (text, record) => ( render: (text, record) => (
@@ -192,7 +205,7 @@ export function InvoicesListTableComponent({
), ),
}, },
{ {
title: t("invoicelines.fields.local_tax_applicable"), title: t("billlines.fields.local_tax_applicable"),
dataIndex: "applicable_taxes.local", dataIndex: "applicable_taxes.local",
key: "applicable_taxes.local", key: "applicable_taxes.local",
render: (text, record) => ( render: (text, record) => (
@@ -207,42 +220,48 @@ export function InvoicesListTableComponent({
}, },
]; ];
const handleOnInvoiceRowclick = (selectedRows) => { const handleOnBillrowclick = (selectedRows) => {
setSelectedInvoiceLinesByInvoice({ setSelectedBillLinesByBill({
...selectedInvoiceLinesByInvoice, ...selectedBillLinesByBill,
[record.id]: selectedRows.map((r) => r.id), [record.id]: selectedRows.map((r) => r.id),
}); });
}; };
return ( return (
<div> <div>
<Typography.Title level={3}>{`${t("invoices.fields.invoice_number")} ${ <Typography.Title level={3}>{`${t("bills.fields.invoice_number")} ${
record.invoice_number record.invoice_number
}`}</Typography.Title> }`}</Typography.Title>
<Descriptions> <Descriptions>
<Descriptions.Item label={t("invoices.fields.federal_tax_rate")}> <Descriptions.Item label={t("bills.fields.federal_tax_rate")}>
{`${record.federal_tax_rate}%` || ""} {`${record.federal_tax_rate}%` || ""}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label={t("invoices.fields.state_tax_rate")}> <Descriptions.Item label={t("bills.fields.state_tax_rate")}>
{`${record.state_tax_rate}%` || ""} {`${record.state_tax_rate}%` || ""}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label={t("invoices.fields.local_tax_rate")}> <Descriptions.Item label={t("bills.fields.local_tax_rate")}>
{`${record.local_tax_rate}%` || ""} {`${record.local_tax_rate}%` || ""}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
<Button <Button
disabled={record.is_credit_memo} disabled={
!selectedBillLinesByBill[record.id] ||
(selectedBillLinesByBill[record.id] &&
selectedBillLinesByBill[record.id].length === 0) ||
record.is_credit_memo ||
record.exported
}
onClick={() => onClick={() =>
setPartsOrderContext({ setPartsOrderContext({
actions: {}, actions: {},
context: { context: {
jobId: job.id, jobId: job.id,
vendorId: record.vendorid, vendorId: record.vendorid,
returnFromInvoice: record.id, returnFromBill: record.id,
invoiceNumber: record.invoice_number, invoiceNumber: record.invoice_number,
linesToOrder: record.invoicelines linesToOrder: record.billlines
.filter((il) => .filter((il) =>
selectedInvoiceLinesByInvoice[record.id].includes(il.id) selectedBillLinesByBill[record.id].includes(il.id)
) )
.map((i) => { .map((i) => {
return { return {
@@ -258,7 +277,7 @@ export function InvoicesListTableComponent({
}) })
} }
> >
{t("invoices.actions.return")} {t("bills.actions.return")}
</Button> </Button>
<Table <Table
size="small" size="small"
@@ -266,15 +285,15 @@ export function InvoicesListTableComponent({
pagination={{ position: "top", defaultPageSize: 25 }} pagination={{ position: "top", defaultPageSize: 25 }}
columns={columns} columns={columns}
rowKey="id" rowKey="id"
dataSource={record.invoicelines} dataSource={record.billlines}
rowSelection={{ rowSelection={{
onSelect: (record, selected, selectedRows) => { onSelect: (record, selected, selectedRows) => {
handleOnInvoiceRowclick(selectedRows); handleOnBillrowclick(selectedRows);
}, },
onSelectAll: (selected, selectedRows, changeRows) => { onSelectAll: (selected, selectedRows, changeRows) => {
handleOnInvoiceRowclick(selectedRows); handleOnBillrowclick(selectedRows);
}, },
selectedRowKeys: selectedInvoiceLinesByInvoice[record.id], selectedRowKeys: selectedBillLinesByBill[record.id],
type: "checkbox", type: "checkbox",
}} }}
/> />
@@ -284,11 +303,9 @@ export function InvoicesListTableComponent({
return ( return (
<div> <div>
<Typography.Title level={4}> <Typography.Title level={4}>{t("bills.labels.bills")}</Typography.Title>
{t("invoices.labels.invoices")}
</Typography.Title>
<Table <Table
loading={loading} loading={billsQuery.loading}
size="small" size="small"
title={() => ( title={() => (
<div className="imex-table-header"> <div className="imex-table-header">
@@ -299,25 +316,23 @@ export function InvoicesListTableComponent({
<div> <div>
<Button <Button
onClick={() => { onClick={() => {
setInvoiceEnterContext({ setBillEnterContext({
actions: { refetch: invoicesQuery.refetch }, actions: { refetch: billsQuery.refetch },
context: { context: {
job, job,
}, },
}); });
}} }}
> >
{t("jobs.actions.postInvoices")} {t("jobs.actions.postbills")}
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
setReconciliationContext({ setReconciliationContext({
actions: { refetch: invoicesQuery.refetch }, actions: { refetch: billsQuery.refetch },
context: { context: {
job, job,
invoices: bills: (billsQuery.data && billsQuery.data.bills) || [],
(invoicesQuery.data && invoicesQuery.data.invoices) ||
[],
}, },
}); });
}} }}
@@ -342,10 +357,10 @@ export function InvoicesListTableComponent({
pagination={{ position: "top", defaultPageSize: 25 }} pagination={{ position: "top", defaultPageSize: 25 }}
columns={columns} columns={columns}
rowKey="id" rowKey="id"
dataSource={invoices} dataSource={bills}
onChange={handleTableChange} onChange={handleTableChange}
expandable={{ expandable={{
expandedRowKeys: [selectedInvoice], expandedRowKeys: [selectedBill],
onExpand: (expanded, record) => { onExpand: (expanded, record) => {
handleOnRowClick(expanded ? record : null); handleOnRowClick(expanded ? record : null);
}, },
@@ -354,7 +369,7 @@ export function InvoicesListTableComponent({
onSelect: (record) => { onSelect: (record) => {
handleOnRowClick(record); handleOnRowClick(record);
}, },
selectedRowKeys: [selectedInvoice], selectedRowKeys: [selectedBill],
type: "radio", type: "radio",
}} }}
onRow={(record, rowIndex) => { onRow={(record, rowIndex) => {
@@ -372,4 +387,7 @@ export function InvoicesListTableComponent({
</div> </div>
); );
} }
export default connect(null, mapDispatchToProps)(InvoicesListTableComponent); export default connect(
mapStateToProps,
mapDispatchToProps
)(BillsListTableComponent);

View File

@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
import { alphaSort } from "../../utils/sorters"; import { alphaSort } from "../../utils/sorters";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
export default function InvoicesVendorsList() { export default function BillsVendorsList() {
const search = queryString.parse(useLocation().search); const search = queryString.parse(useLocation().search);
const history = useHistory(); const history = useHistory();
@@ -51,7 +51,7 @@ export default function InvoicesVendorsList() {
const handleOnRowClick = (record) => { const handleOnRowClick = (record) => {
if (record) { if (record) {
delete search.invoiceid; delete search.billid;
if (record.id) { if (record.id) {
search.vendorid = record.id; search.vendorid = record.id;
history.push({ search: queryString.stringify(search) }); history.push({ search: queryString.stringify(search) });

View File

@@ -6,27 +6,44 @@ import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import ChatAffixComponent from "./chat-affix.component"; import ChatAffixComponent from "./chat-affix.component";
import { Affix } from "antd"; import { Affix } from "antd";
import "./chat-affix.styles.scss"; import "./chat-affix.styles.scss";
export default function ChatAffixContainer() {
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { selectChatVisible } from "../../redux/messaging/messaging.selectors";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
chatVisible: selectChatVisible,
});
export function ChatAffixContainer({ bodyshop, chatVisible }) {
const { loading, error, data } = useSubscription( const { loading, error, data } = useSubscription(
CONVERSATION_LIST_SUBSCRIPTION CONVERSATION_LIST_SUBSCRIPTION,
{
skip: !bodyshop || (bodyshop && !bodyshop.messagingservicesid),
}
); );
if (loading) return <LoadingSpinner />; if (loading) return <LoadingSpinner />;
if (error) return <AlertComponent message={error.message} type='error' />; if (error) return <AlertComponent message={error.message} type="error" />;
return ( return (
<Affix className='chat-affix'> <Affix className={`chat-affix ${chatVisible ? "chat-affix-open" : ""}`}>
<div> <div>
<ChatAffixComponent {bodyshop && bodyshop.messagingservicesid ? (
conversationList={(data && data.conversations) || []} <ChatAffixComponent
unreadCount={ conversationList={(data && data.conversations) || []}
(data && unreadCount={
data.conversations.reduce((acc, val) => { (data &&
return (acc = acc + val.messages_aggregate.aggregate.count); data.conversations.reduce((acc, val) => {
}, 0)) || return (acc = acc + val.messages_aggregate.aggregate.count);
0 }, 0)) ||
} 0
/> }
/>
) : null}
</div> </div>
</Affix> </Affix>
); );
} }
export default connect(mapStateToProps, null)(ChatAffixContainer);

View File

@@ -2,3 +2,9 @@
position: absolute; position: absolute;
bottom: 2vh; bottom: 2vh;
} }
.chat-affix-open {
-webkit-box-shadow: 0px 0px 10px 0px rgba(69, 69, 69, 1);
-moz-box-shadow: 0px 0px 10px 0px rgba(69, 69, 69, 1);
box-shadow: 0px 0px 10px 0px rgba(69, 69, 69, 1);
}

View File

@@ -1,6 +1,5 @@
import { Badge, List } from "antd"; import { Badge, List, Tag } from "antd";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { setSelectedConversation } from "../../redux/messaging/messaging.actions"; import { setSelectedConversation } from "../../redux/messaging/messaging.actions";
@@ -22,43 +21,46 @@ export function ChatConversationListComponent({
selectedConversation, selectedConversation,
setSelectedConversation, setSelectedConversation,
}) { }) {
const { t } = useTranslation();
return ( return (
<List <div className="chat-list-container">
bordered <List
size="small" bordered
dataSource={conversationList} size="small"
renderItem={(item) => ( dataSource={conversationList}
<List.Item renderItem={(item) => (
onClick={() => setSelectedConversation(item.id)} <List.Item
className={`chat-list-item ${ onClick={() => setSelectedConversation(item.id)}
item.id === selectedConversation className={`chat-list-item ${
? "chat-list-selected-conversation" item.id === selectedConversation
: null ? "chat-list-selected-conversation"
}`} : null
> }`}
<List.Item.Meta >
title={<PhoneFormatter>{item.phone_num}</PhoneFormatter>} {item.job_conversations.length > 0 ? (
description={ <div className="chat-name">
item.job_conversations.length > 0 ? ( {item.job_conversations.map((j, idx) => (
<div> <span key={idx}>
{item.job_conversations.map( {`${j.job.ownr_fn || ""} ${j.job.ownr_ln || ""} ${
(j) => j.job.ownr_co_nm || ""
`${j.job.ownr_fn || ""} ${j.job.ownr_ln || ""} ${ } `}
j.job.ownr_co_nm || "" </span>
}` ))}
)} </div>
</div> ) : (
) : ( <PhoneFormatter>{item.phone_num}</PhoneFormatter>
t("messaging.labels.nojobs") )}
) {item.job_conversations.length > 0
} ? item.job_conversations.map((j, idx) => (
/> <Tag key={idx} className="ro-number-tag">
<Badge count={item.messages_aggregate.aggregate.count || 0} /> {j.job.ro_number}
</List.Item> </Tag>
)} ))
/> : null}
<Badge count={item.messages_aggregate.aggregate.count || 0} />
</List.Item>
)}
/>
</div>
); );
} }
export default connect( export default connect(

View File

@@ -1,10 +1,24 @@
.chat-list-selected-conversation { .chat-list-selected-conversation {
background-color: rgba(128, 128, 128, 0.2); background-color: rgba(128, 128, 128, 0.2);
} }
.chat-list-container {
flex: 1;
overflow: auto;
height: 100%;
}
.chat-list-item { .chat-list-item {
display: flex;
flex-direction: row;
&:hover { &:hover {
cursor: pointer; cursor: pointer;
color: #ff7a00; color: #ff7a00;
} }
.chat-name {
flex: 1;
display: inline;
}
.ro-number-tag {
align-self: baseline;
}
} }

View File

@@ -29,11 +29,14 @@ export default function ChatConversationTitleTags({ jobConversations }) {
<Tag <Tag
key={item.job.id} key={item.job.id}
closable closable
color='blue' color="blue"
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
onClose={() => handleRemoveTag(item.job.id)}> onClose={() => handleRemoveTag(item.job.id)}
>
<Link to={`/manage/jobs/${item.job.id}`}> <Link to={`/manage/jobs/${item.job.id}`}>
{item.job.ro_number || "?"} {`${item.job.ro_number || "?"} | ${item.job.ownr_fn || ""} ${
item.job.ownr_ln || ""
} ${item.job.ownr_co_nm || ""}`}
</Link> </Link>
</Tag> </Tag>
))} ))}

View File

@@ -1,31 +1,23 @@
import { Space } from "antd";
import React from "react"; import React from "react";
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import ChatConversationTitleTags from "../chat-conversation-title-tags/chat-conversation-title-tags.component"; import ChatConversationTitleTags from "../chat-conversation-title-tags/chat-conversation-title-tags.component";
import ChatTagRoContainer from "../chat-tag-ro/chat-tag-ro.container"; import ChatTagRoContainer from "../chat-tag-ro/chat-tag-ro.container";
import ChatPresetsComponent from "../chat-presets/chat-presets.component";
export default function ChatConversationTitle({ conversation }) { export default function ChatConversationTitle({ conversation }) {
return ( return (
<div> <div>
<Space> <div className="imex-flex-row">
<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 <ChatConversationTitleTags
jobConversations={ jobConversations={
(conversation && conversation.job_conversations) || [] (conversation && conversation.job_conversations) || []
} }
/> />
<ChatTagRoContainer conversation={conversation || []} /> <ChatTagRoContainer conversation={conversation || []} />
<ChatPresetsComponent /> </div>
<div className="imex-flex-row">
<PhoneNumberFormatter>
{conversation && conversation.phone_num}
</PhoneNumberFormatter>
</div> </div>
</div> </div>
); );

View File

@@ -3,9 +3,12 @@
// bottom: 0rem; // bottom: 0rem;
color: whitesmoke; color: whitesmoke;
border: #000000; border: #000000;
margin-left: 0.2rem; position: absolute;
margin-right: 0rem; margin: 0 0.1rem;
// z-index: 5; bottom: 0.1rem;
right: 0.3rem;
z-index: 5;
} }
.chat { .chat {
@@ -84,6 +87,7 @@
background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%); background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%);
background-attachment: fixed; background-attachment: fixed;
position: relative; position: relative;
padding-bottom: 0.6rem;
} }
.mine .message.last:before { .mine .message.last:before {

View File

@@ -0,0 +1,49 @@
import { PlusCircleFilled } from "@ant-design/icons";
import { Button, Form, Popover } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
import PhoneFormItem from "../form-items-formatted/phone-form-item.component";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
});
const mapDispatchToProps = (dispatch) => ({
openChatByPhone: (phone) => dispatch(openChatByPhone(phone)),
});
export function ChatNewConversation({ openChatByPhone }) {
const { t } = useTranslation();
const [form] = Form.useForm();
const handleFinish = (values) => {
console.log("values :>> ", values);
openChatByPhone({ phone_num: values.phoneNumber });
form.resetFields();
};
const popContent = (
<div>
<Form form={form} onFinish={handleFinish}>
<Form.Item label={t("messaging.labels.phonenumber")} name="phoneNumber">
<PhoneFormItem />
</Form.Item>
<Button type="primary" htmlType="submit">
{t("messaging.actions.new")}
</Button>
</Form>
</div>
);
return (
<Popover trigger="click" content={popContent}>
<PlusCircleFilled style={{ margin: "1rem" }} />
</Popover>
);
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(ChatNewConversation);

View File

@@ -9,7 +9,10 @@ export function ChatOpenButton({ phone, jobid, openChatByPhone }) {
return ( return (
<MessageFilled <MessageFilled
style={{ margin: 4 }} style={{ margin: 4 }}
onClick={() => openChatByPhone({ phone_num: phone, jobid: jobid })} onClick={(e) => {
e.stopPropagation();
openChatByPhone({ phone_num: phone, jobid: jobid });
}}
/> />
); );
} }

View File

@@ -9,6 +9,7 @@ import ChatConversationListComponent from "../chat-conversation-list/chat-conver
import ChatConversationContainer from "../chat-conversation/chat-conversation.container"; import ChatConversationContainer from "../chat-conversation/chat-conversation.container";
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors"; import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
import "./chat-popup.styles.scss"; import "./chat-popup.styles.scss";
import ChatNewConversation from "../chat-new-conversation/chat-new-conversation.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation, selectedConversation: selectSelectedConversation,
@@ -25,15 +26,18 @@ export function ChatPopupComponent({
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="chat-popup"> <div className="chat-popup">
<Typography.Title level={4}> <div style={{ display: "flex", alignItems: "center" }}>
{t("messaging.labels.messaging")} <Typography.Title level={4}>
</Typography.Title> {t("messaging.labels.messaging")}
</Typography.Title>
<ChatNewConversation />
</div>
<ShrinkOutlined <ShrinkOutlined
onClick={() => toggleChatVisible()} onClick={() => toggleChatVisible()}
style={{ position: "absolute", right: ".5rem", top: ".5rem" }} style={{ position: "absolute", right: ".5rem", top: ".5rem" }}
/> />
<Row className="chat-popup-content"> <Row gutter={[8, 8]} className="chat-popup-content">
<Col span={8}> <Col span={8}>
<ChatConversationListComponent conversationList={conversationList} /> <ChatConversationListComponent conversationList={conversationList} />
</Col> </Col>

View File

@@ -6,8 +6,12 @@
} }
.chat-popup-content { .chat-popup-content {
//height: 50vh; min-height: 0px; /* IMPORTANT: you need this for non-chrome browsers */
flex: 1; flex: 1;
//height: 100%;
.ant-col {
height: 100%;
}
} }
@media only screen and (min-width: 992px) { @media only screen and (min-width: 992px) {

View File

@@ -1,7 +1,6 @@
import { DownOutlined } from "@ant-design/icons"; import { PlusCircleOutlined } from "@ant-design/icons";
import { Dropdown, Menu } from "antd"; import { Dropdown, Menu } from "antd";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { setMessage } from "../../redux/messaging/messaging.actions"; import { setMessage } from "../../redux/messaging/messaging.actions";
@@ -16,9 +15,7 @@ const mapDispatchToProps = (dispatch) => ({
setMessage: (message) => dispatch(setMessage(message)), setMessage: (message) => dispatch(setMessage(message)),
}); });
export function ChatPresetsComponent({ bodyshop, setMessage }) { export function ChatPresetsComponent({ bodyshop, setMessage, className }) {
const { t } = useTranslation();
const menu = ( const menu = (
<Menu> <Menu>
{bodyshop.md_messaging_presets.map((i, idx) => ( {bodyshop.md_messaging_presets.map((i, idx) => (
@@ -30,15 +27,9 @@ export function ChatPresetsComponent({ bodyshop, setMessage }) {
); );
return ( return (
<div> <div className={className}>
<Dropdown trigger={["click"]} overlay={menu}> <Dropdown trigger={["click"]} overlay={menu}>
<a <PlusCircleOutlined />
className="ant-dropdown-link"
href="# "
onClick={(e) => e.preventDefault()}
>
{t("messaging.labels.presets")} <DownOutlined />
</a>
</Dropdown> </Dropdown>
</div> </div>
); );

View File

@@ -14,6 +14,7 @@ import {
selectMessage, selectMessage,
} from "../../redux/messaging/messaging.selectors"; } from "../../redux/messaging/messaging.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import ChatPresetsComponent from "../chat-presets/chat-presets.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -53,6 +54,8 @@ function ChatSendMessageComponent({
return ( return (
<div className="imex-flex-row"> <div className="imex-flex-row">
<ChatPresetsComponent className="imex-flex-row__margin" />
<Input.TextArea <Input.TextArea
className="imex-flex-row__margin imex-flex-row__grow" className="imex-flex-row__margin imex-flex-row__grow"
allowClear allowClear
@@ -68,6 +71,7 @@ function ChatSendMessageComponent({
if (!!!event.shiftKey) handleEnter(); if (!!!event.shiftKey) handleEnter();
}} }}
/> />
<SendOutlined className="imex-flex-row__margin" onClick={handleEnter} /> <SendOutlined className="imex-flex-row__margin" onClick={handleEnter} />
<Spin <Spin
style={{ display: `${isSending ? "" : "none"}` }} style={{ display: `${isSending ? "" : "none"}` }}

View File

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

View File

@@ -1,32 +1,29 @@
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 { PlusOutlined } from "@ant-design/icons";
import { useLazyQuery, useMutation } from "@apollo/react-hooks";
import { Tag } from "antd";
import _ from "lodash";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import { SEARCH_FOR_JOBS } from "../../graphql/jobs.queries";
import ChatTagRo from "./chat-tag-ro.component";
export default function ChatTagRoContainer({ conversation }) { export default function ChatTagRoContainer({ conversation }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const searchQueryState = useState("");
const searchText = searchQueryState[0];
const [loadRo, { called, loading, data, refetch }] = useLazyQuery( const [loadRo, { loading, data }] = useLazyQuery(SEARCH_FOR_JOBS);
SEARCH_FOR_JOBS,
{
variables: { search: `%${searchText}%` },
}
);
const executeSearch = () => { const executeSearch = (v) => {
logImEXEvent("messaging_search_job_tag", { searchTerm: searchText }); logImEXEvent("messaging_search_job_tag", { searchTerm: v });
if (called) refetch(); loadRo(v);
else { };
loadRo();
} const debouncedExecuteSearch = _.debounce(executeSearch, 500);
const handleSearch = (value) => {
debouncedExecuteSearch({ variables: { search: value } });
}; };
const [insertTag] = useMutation(INSERT_CONVERSATION_TAG, { const [insertTag] = useMutation(INSERT_CONVERSATION_TAG, {
@@ -39,10 +36,13 @@ export default function ChatTagRoContainer({ conversation }) {
setVisible(false); setVisible(false);
}; };
const existingJobTags = conversation.job_conversations.map((i) => i.jobid); const existingJobTags =
conversation &&
conversation.job_conversations &&
conversation.job_conversations.map((i) => i.jobid);
const roOptions = data const roOptions = data
? data.jobs.filter((job) => !existingJobTags.includes(job.id)) ? data.search_jobs.filter((job) => !existingJobTags.includes(job.id))
: []; : [];
return ( return (
@@ -50,9 +50,8 @@ export default function ChatTagRoContainer({ conversation }) {
{visible ? ( {visible ? (
<ChatTagRo <ChatTagRo
loading={loading} loading={loading}
searchQueryState={searchQueryState}
roOptions={roOptions} roOptions={roOptions}
executeSearch={executeSearch} handleSearch={handleSearch}
handleInsertTag={handleInsertTag} handleInsertTag={handleInsertTag}
setVisible={setVisible} setVisible={setVisible}
/> />

View File

@@ -4,15 +4,17 @@ import { QUERY_AVAILABLE_CC } from "../../graphql/courtesy-car.queries";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import ContractCarsComponent from "./contract-cars.component"; import ContractCarsComponent from "./contract-cars.component";
export default function ContractCarsContainer({ selectedCarState, form }) {
export default function ContractCarsContainer({ selectedCarState, bodyshop }) {
const { loading, error, data } = useQuery(QUERY_AVAILABLE_CC); const { loading, error, data } = useQuery(QUERY_AVAILABLE_CC);
const [selectedCar, setSelectedCar] = selectedCarState; const [selectedCar, setSelectedCar] = selectedCarState;
const handleSelect = record => { const handleSelect = (record) => {
setSelectedCar(record.id); setSelectedCar(record.id);
form.setFieldsValue({
kmstart: record.mileage,
dailyrate: record.dailycost,
});
}; };
if (error) return <AlertComponent message={error.message} type="error" />; if (error) return <AlertComponent message={error.message} type="error" />;

View File

@@ -116,9 +116,9 @@ export function ContractConvertToRo({ bodyshop, currentUser, contract }) {
shopid: bodyshop.id, shopid: bodyshop.id,
ownerid: contract.job.ownerid, ownerid: contract.job.ownerid,
vehicleid: contract.job.vehicleid, vehicleid: contract.job.vehicleid,
federal_tax_rate: bodyshop.invoice_tax_rates.federal_tax_rate / 100, federal_tax_rate: bodyshop.bill_tax_rates.federal_tax_rate / 100,
state_tax_rate: bodyshop.invoice_tax_rates.state_tax_rate / 100, state_tax_rate: bodyshop.bill_tax_rates.state_tax_rate / 100,
local_tax_rate: bodyshop.invoice_tax_rates.local_tax_rate / 100, local_tax_rate: bodyshop.bill_tax_rates.local_tax_rate / 100,
clm_no: `${contract.job.clm_no}-CC`, clm_no: `${contract.job.clm_no}-CC`,
clm_total: 1234, //TODO clm_total: 1234, //TODO
ownr_fn: contract.job.owner.ownr_fn, ownr_fn: contract.job.owner.ownr_fn,

View File

@@ -6,7 +6,9 @@ import FormDatePicker from "../form-date-picker/form-date-picker.component";
import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.component"; import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
import InputPhone from "../form-items-formatted/phone-form-item.component"; import InputPhone from "../form-items-formatted/phone-form-item.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
export default function ContractFormComponent({ form }) { import InputNumberCalculator from "../form-input-number-calculator/form-input-number-calculator.component";
export default function ContractFormComponent({ form, create = false }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div> <div>
@@ -14,18 +16,21 @@ export default function ContractFormComponent({ form }) {
<FormFieldsChanged form={form} /> <FormFieldsChanged form={form} />
</div> </div>
<LayoutFormRow> <LayoutFormRow>
<Form.Item {create ? null : (
label={t("contracts.fields.status")} <Form.Item
name="status" label={t("contracts.fields.status")}
rules={[ name="status"
{ rules={[
required: true, {
message: t("general.validation.required"), required: true,
}, message: t("general.validation.required"),
]} },
> ]}
<ContractStatusSelector /> >
</Form.Item> <ContractStatusSelector />
</Form.Item>
)}
<Form.Item <Form.Item
label={t("contracts.fields.start")} label={t("contracts.fields.start")}
name="start" name="start"
@@ -50,12 +55,14 @@ export default function ContractFormComponent({ form }) {
> >
<FormDatePicker /> <FormDatePicker />
</Form.Item> </Form.Item>
<Form.Item {create ? null : (
label={t("contracts.fields.actualreturn")} <Form.Item
name="actualreturn" label={t("contracts.fields.actualreturn")}
> name="actualreturn"
<FormDatePicker /> >
</Form.Item> <FormDatePicker />
</Form.Item>
)}
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow> <LayoutFormRow>
@@ -71,9 +78,11 @@ export default function ContractFormComponent({ form }) {
> >
<InputNumber /> <InputNumber />
</Form.Item> </Form.Item>
<Form.Item label={t("contracts.fields.kmend")} name="kmend"> {create ? null : (
<InputNumber /> <Form.Item label={t("contracts.fields.kmend")} name="kmend">
</Form.Item> <InputNumber />
</Form.Item>
)}
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow> <LayoutFormRow>
<Form.Item <Form.Item
@@ -291,16 +300,16 @@ export default function ContractFormComponent({ form }) {
<InputNumber precision={2} /> <InputNumber precision={2} />
</Form.Item> </Form.Item>
<Form.Item label={t("contracts.fields.federaltax")} name="federaltax"> <Form.Item label={t("contracts.fields.federaltax")} name="federaltax">
<InputNumber precision={2} /> <InputNumberCalculator precision={2} />
</Form.Item> </Form.Item>
<Form.Item label={t("contracts.fields.statetax")} name="statetax"> <Form.Item label={t("contracts.fields.statetax")} name="statetax">
<InputNumber precision={2} /> <InputNumberCalculator precision={2} />
</Form.Item> </Form.Item>
<Form.Item label={t("contracts.fields.localtax")} name="localtax"> <Form.Item label={t("contracts.fields.localtax")} name="localtax">
<InputNumber precision={2} /> <InputNumberCalculator precision={2} />
</Form.Item> </Form.Item>
<Form.Item label={t("contracts.fields.coverage")} name="coverage"> <Form.Item label={t("contracts.fields.coverage")} name="coverage">
<InputNumber precision={2} /> <InputNumberCalculator precision={2} />
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
</div> </div>

View File

@@ -9,17 +9,17 @@ import { QUERY_ALL_ACTIVE_JOBS } from "../../graphql/jobs.queries";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser //currentUser: selectCurrentUser
bodyshop: selectBodyshop bodyshop: selectBodyshop,
}); });
export function ContractJobsContainer({ selectedJobState, bodyshop }) { export function ContractJobsContainer({ selectedJobState, bodyshop }) {
const { loading, error, data } = useQuery(QUERY_ALL_ACTIVE_JOBS, { const { loading, error, data } = useQuery(QUERY_ALL_ACTIVE_JOBS, {
variables: { variables: {
statuses: bodyshop.md_ro_statuses.open_statuses || ["Open"] statuses: bodyshop.md_ro_statuses.active_statuses || ["Open"],
} },
}); });
const [selectedJob, setSelectedJob] = selectedJobState; const [selectedJob, setSelectedJob] = selectedJobState;
const handleSelect = record => { const handleSelect = (record) => {
setSelectedJob(record.id); setSelectedJob(record.id);
}; };

View File

@@ -55,14 +55,17 @@ export default function ContractsList({ loading, contracts, refetch, total }) {
`${record.driver_fn || ""} ${record.driver_ln || ""}`, `${record.driver_fn || ""} ${record.driver_ln || ""}`,
}, },
{ {
title: t("contracts.fields.vehicle"), title: t("contracts.labels.vehicle"),
dataIndex: "vehicle", dataIndex: "vehicle",
key: "vehicle", key: "vehicle",
//sorter: (a, b) => alphaSort(a.status, b.status), //sorter: (a, b) => alphaSort(a.status, b.status),
//sortOrder: //sortOrder:
// state.sortedInfo.columnKey === "status" && state.sortedInfo.order, // state.sortedInfo.columnKey === "status" && state.sortedInfo.order,
render: (text, record) => render: (text, record) => (
`${record.courtesycar.fleetnumber} - ${record.courtesycar.year} ${record.courtesycar.make} ${record.courtesycar.model}`, <Link
to={`/manage/courtesycars/${record.courtesycar.id}`}
>{`${record.courtesycar.fleetnumber} - ${record.courtesycar.year} ${record.courtesycar.make} ${record.courtesycar.model}`}</Link>
),
}, },
{ {
title: t("contracts.fields.status"), title: t("contracts.fields.status"),

View File

@@ -1,15 +1,18 @@
import { Table } from "antd"; import { Table } from "antd";
import React, { useState } from "react"; import queryString from "query-string";
import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link, useHistory, useLocation } from "react-router-dom";
import { alphaSort } from "../../utils/sorters";
import { DateFormatter } from "../../utils/DateFormatter"; import { DateFormatter } from "../../utils/DateFormatter";
import { alphaSort } from "../../utils/sorters";
export default function CourtesyCarContractListComponent({ contracts }) { export default function CourtesyCarContractListComponent({
const [state, setState] = useState({ contracts,
sortedInfo: {}, totalContracts,
filteredInfo: { text: "" } }) {
}); const search = queryString.parse(useLocation().search);
const { page, sortcolumn, sortorder } = search;
const history = useHistory();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -19,79 +22,78 @@ export default function CourtesyCarContractListComponent({ contracts }) {
dataIndex: "agreementnumber", dataIndex: "agreementnumber",
key: "agreementnumber", key: "agreementnumber",
sorter: (a, b) => a.agreementnumber - b.agreementnumber, sorter: (a, b) => a.agreementnumber - b.agreementnumber,
sortOrder: sortOrder: sortcolumn === "agreementnumber" && sortorder,
state.sortedInfo.columnKey === "agreementnumber" &&
state.sortedInfo.order,
render: (text, record) => ( render: (text, record) => (
<Link to={`/manage/courtesycars/contracts/${record.id}`}> <Link to={`/manage/courtesycars/contracts/${record.id}`}>
{record.agreementnumber || ""} {record.agreementnumber || ""}
</Link> </Link>
) ),
}, },
{ {
title: t("jobs.fields.ro_number"), title: t("jobs.fields.ro_number"),
dataIndex: "job.ro_number", dataIndex: "job.ro_number",
key: "job.ro_number", key: "job.ro_number",
sorter: (a, b) => alphaSort(a.job.ro_number, b.job.ro_number),
sortOrder:
state.sortedInfo.columnKey === "job.ro_number" &&
state.sortedInfo.order,
render: (text, record) => ( render: (text, record) => (
<Link to={`/manage/jobs/${record.job.id}`}> <Link to={`/manage/jobs/${record.job.id}`}>
{record.job.ro_number || ""} {record.job.ro_number || ""}
</Link> </Link>
) ),
}, },
{ {
title: t("contracts.fields.driver"), title: t("contracts.fields.driver"),
dataIndex: "driver_ln", dataIndex: "driver_ln",
key: "driver_ln", key: "driver_ln",
sorter: (a, b) => alphaSort(a.driver_ln, b.driver_ln),
sortOrder:
state.sortedInfo.columnKey === "driver_ln" && state.sortedInfo.order,
render: (text, record) => render: (text, record) =>
`${record.driver_fn || ""} ${record.driver_ln || ""}` `${record.driver_fn || ""} ${record.driver_ln || ""}`,
}, },
{ {
title: t("contracts.fields.status"), title: t("contracts.fields.status"),
dataIndex: "status", dataIndex: "status",
key: "status", key: "status",
sorter: (a, b) => alphaSort(a.status, b.status), sorter: (a, b) => alphaSort(a.status, b.status),
sortOrder: sortOrder: sortcolumn === "status" && sortorder,
state.sortedInfo.columnKey === "status" && state.sortedInfo.order, render: (text, record) => t(record.status),
render: (text, record) => t(record.status)
}, },
{ {
title: t("contracts.fields.start"), title: t("contracts.fields.start"),
dataIndex: "start", dataIndex: "start",
key: "start", key: "start",
sorter: (a, b) => alphaSort(a.start, b.start), sorter: (a, b) => alphaSort(a.start, b.start),
sortOrder: sortOrder: sortcolumn === "start" && sortorder,
state.sortedInfo.columnKey === "start" && state.sortedInfo.order, render: (text, record) => <DateFormatter>{record.start}</DateFormatter>,
render: (text, record) => <DateFormatter>{record.start}</DateFormatter>
}, },
{ {
title: t("contracts.fields.scheduledreturn"), title: t("contracts.fields.scheduledreturn"),
dataIndex: "scheduledreturn", dataIndex: "scheduledreturn",
key: "scheduledreturn", key: "scheduledreturn",
sorter: (a, b) => a.scheduledreturn - b.scheduledreturn, sorter: (a, b) => a.scheduledreturn - b.scheduledreturn,
sortOrder: sortOrder: sortcolumn === "scheduledreturn" && sortorder,
state.sortedInfo.columnKey === "scheduledreturn" &&
state.sortedInfo.order,
render: (text, record) => ( render: (text, record) => (
<DateFormatter>{record.scheduledreturn}</DateFormatter> <DateFormatter>{record.scheduledreturn}</DateFormatter>
) ),
} },
]; ];
const handleTableChange = (pagination, filters, sorter) => { const handleTableChange = (pagination, filters, sorter) => {
setState({ ...state, filteredInfo: filters, sortedInfo: sorter }); search.page = pagination.current;
search.sortcolumn = sorter.columnKey;
search.sortorder = sorter.order;
history.push({ search: queryString.stringify(search) });
}; };
return ( return (
<Table <Table
size="small" size="small"
pagination={{ position: "top" }} scroll={{ x: true }}
columns={columns.map(item => ({ ...item }))} pagination={{
position: "top",
pageSize: 25,
current: parseInt(page || 1),
total: totalContracts,
}}
columns={columns.map((item) => ({ ...item }))}
rowKey="id" rowKey="id"
dataSource={contracts} dataSource={contracts}
onChange={handleTableChange} onChange={handleTableChange}

View File

@@ -6,161 +6,139 @@ import CourtesyCarStatus from "../courtesy-car-status-select/courtesy-car-status
import FormDatePicker from "../form-date-picker/form-date-picker.component"; import FormDatePicker from "../form-date-picker/form-date-picker.component";
import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.component"; import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
import CurrencyInput from "../form-items-formatted/currency-form-item.component"; import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import InputNumberCalculator from "../form-input-number-calculator/form-input-number-calculator.component";
export default function CourtesyCarCreateFormComponent({ form, saveLoading }) { export default function CourtesyCarCreateFormComponent({ form, saveLoading }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div> <div>
<Button type="primary" loading={saveLoading} htmlType="submit"> <Button
type="primary"
loading={saveLoading}
onClick={() => form.submit()}
>
{t("general.actions.save")} {t("general.actions.save")}
</Button> </Button>
<div className="imex-flex-row__grow imex-flex-row__margin-large"> <FormFieldsChanged form={form} />
<FormFieldsChanged form={form} /> <LayoutFormRow>
</div> <Form.Item
<Form.Item label={t("courtesycars.fields.make")}
label={t("courtesycars.fields.make")} name="make"
name="make" rules={[
rules={[ {
{ required: true,
required: true, message: t("general.validation.required"),
message: t("general.validation.required"), },
}, ]}
]} >
> <Input />
<Input /> </Form.Item>
</Form.Item> <Form.Item
<Form.Item label={t("courtesycars.fields.model")}
label={t("courtesycars.fields.model")} name="model"
name="model" rules={[
rules={[ {
{ required: true,
required: true, message: t("general.validation.required"),
message: t("general.validation.required"), },
}, ]}
]} >
> <Input />
<Input /> </Form.Item>
</Form.Item> <Form.Item
<Form.Item label={t("courtesycars.fields.year")}
label={t("courtesycars.fields.year")} name="year"
name="year" rules={[
rules={[ {
{ required: true,
required: true, message: t("general.validation.required"),
message: t("general.validation.required"), },
}, ]}
]} >
> <Input />
<Input /> </Form.Item>
</Form.Item> <Form.Item
<Form.Item label={t("courtesycars.fields.plate")}
label={t("courtesycars.fields.plate")} name="plate"
name="plate" rules={[
rules={[ {
{ required: true,
required: true, message: t("general.validation.required"),
message: t("general.validation.required"), },
}, ]}
]} >
> <Input />
<Input /> </Form.Item>
</Form.Item> <Form.Item
<Form.Item label={t("courtesycars.fields.color")}
label={t("courtesycars.fields.color")} name="color"
name="color" rules={[
rules={[ {
{ required: true,
required: true, message: t("general.validation.required"),
message: t("general.validation.required"), },
}, ]}
]} >
> <Input />
<Input /> </Form.Item>
</Form.Item> <Form.Item
<Form.Item label={t("courtesycars.fields.vin")}
label={t("courtesycars.fields.vin")} name="vin"
name="vin" rules={[
rules={[ {
{ required: true,
required: true, message: t("general.validation.required"),
message: t("general.validation.required"), },
}, ]}
]} >
> <Input />
<Input /> </Form.Item>
</Form.Item> </LayoutFormRow>
<Form.Item <LayoutFormRow>
label={t("courtesycars.fields.fleetnumber")} <Form.Item
name="fleetnumber" label={t("courtesycars.fields.mileage")}
> name="mileage"
<Input /> rules={[
</Form.Item> {
<Form.Item required: true,
label={t("courtesycars.fields.purchasedate")} message: t("general.validation.required"),
name="purchasedate" },
> ]}
<FormDatePicker /> >
</Form.Item> <InputNumber />
<Form.Item </Form.Item>
label={t("courtesycars.fields.servicestartdate")} <Form.Item
name="servicestartdate" label={t("courtesycars.fields.fleetnumber")}
> name="fleetnumber"
<Input /> >
</Form.Item> <Input />
<Form.Item </Form.Item>
label={t("courtesycars.fields.serviceenddate")} <Form.Item
name="serviceenddate" label={t("courtesycars.fields.purchasedate")}
> name="purchasedate"
<FormDatePicker /> >
</Form.Item> <FormDatePicker />
<Form.Item </Form.Item>
label={t("courtesycars.fields.leaseenddate")} <Form.Item
name="leaseenddate" label={t("courtesycars.fields.servicestartdate")}
> name="servicestartdate"
<FormDatePicker /> >
</Form.Item> <FormDatePicker />
<Form.Item </Form.Item>
label={t("courtesycars.fields.status")} <Form.Item
name="status" label={t("courtesycars.fields.serviceenddate")}
rules={[ name="serviceenddate"
{ >
required: true, <FormDatePicker />
message: t("general.validation.required"), </Form.Item>
}, <Form.Item
]} label={t("courtesycars.fields.leaseenddate")}
> name="leaseenddate"
<CourtesyCarStatus /> >
</Form.Item> <FormDatePicker />
<Form.Item </Form.Item>
label={t("courtesycars.fields.nextservicekm")} </LayoutFormRow>
name="nextservicekm"
rules={[
{
required: true,
message: t("general.validation.required"),
},
]}
>
<InputNumber />
</Form.Item>
<Form.Item
label={t("courtesycars.fields.nextservicedate")}
name="nextservicedate"
rules={[
{
required: true,
message: t("general.validation.required"),
},
]}
>
<FormDatePicker />
</Form.Item>
<Form.Item label={t("courtesycars.fields.damage")} name="damage">
<Input />
</Form.Item>
<Form.Item label={t("courtesycars.fields.notes")} name="notes">
<Input />
</Form.Item>
<Form.Item <Form.Item
label={t("courtesycars.fields.fuel")} label={t("courtesycars.fields.fuel")}
name="fuel" name="fuel"
@@ -173,33 +151,78 @@ export default function CourtesyCarCreateFormComponent({ form, saveLoading }) {
> >
<CourtesyCarFuelSlider /> <CourtesyCarFuelSlider />
</Form.Item> </Form.Item>
<Form.Item <LayoutFormRow>
label={t("courtesycars.fields.registrationexpires")} <Form.Item
name="registrationexpires" label={t("courtesycars.fields.status")}
rules={[ name="status"
{ rules={[
required: true, {
message: t("general.validation.required"), required: true,
}, message: t("general.validation.required"),
]} },
> ]}
<FormDatePicker /> >
</Form.Item> <CourtesyCarStatus />
<Form.Item </Form.Item>
label={t("courtesycars.fields.insuranceexpires")} <Form.Item
name="insuranceexpires" label={t("courtesycars.fields.nextservicekm")}
rules={[ name="nextservicekm"
{ rules={[
required: true, {
message: t("general.validation.required"), required: true,
}, message: t("general.validation.required"),
]} },
> ]}
<FormDatePicker /> >
</Form.Item> <InputNumberCalculator />
<Form.Item label={t("courtesycars.fields.dailycost")} name="dailycost"> </Form.Item>
<CurrencyInput /> <Form.Item
</Form.Item> label={t("courtesycars.fields.nextservicedate")}
name="nextservicedate"
rules={[
{
required: true,
message: t("general.validation.required"),
},
]}
>
<FormDatePicker />
</Form.Item>
<Form.Item label={t("courtesycars.fields.damage")} name="damage">
<Input.TextArea />
</Form.Item>
<Form.Item label={t("courtesycars.fields.notes")} name="notes">
<Input.TextArea />
</Form.Item>
<Form.Item
label={t("courtesycars.fields.registrationexpires")}
name="registrationexpires"
rules={[
{
required: true,
message: t("general.validation.required"),
},
]}
>
<FormDatePicker />
</Form.Item>
<Form.Item
label={t("courtesycars.fields.insuranceexpires")}
name="insuranceexpires"
rules={[
{
required: true,
message: t("general.validation.required"),
},
]}
>
<FormDatePicker />
</Form.Item>
<Form.Item label={t("courtesycars.fields.dailycost")} name="dailycost">
<CurrencyInput />
</Form.Item>
</LayoutFormRow>
</div> </div>
); );
} }

View File

@@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("courtesyCarReturn")), toggleModalVisible: () => dispatch(toggleModalVisible("courtesyCarReturn")),
}); });
export function InvoiceEnterModalContainer({ export function BillEnterModalContainer({
courtesyCarReturnModal, courtesyCarReturnModal,
toggleModalVisible, toggleModalVisible,
bodyshop, bodyshop,
@@ -84,4 +84,4 @@ export function InvoiceEnterModalContainer({
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(InvoiceEnterModalContainer); )(BillEnterModalContainer);

View File

@@ -76,7 +76,7 @@ export default function CourtesyCarsList({ loading, courtesycars, refetch }) {
state.sortedInfo.columnKey === "model" && state.sortedInfo.order, state.sortedInfo.columnKey === "model" && state.sortedInfo.order,
}, },
{ {
title: t("courtesycars.fields.outwith"), title: t("courtesycars.labels.outwith"),
dataIndex: "outwith", dataIndex: "outwith",
key: "outwith", key: "outwith",
// sorter: (a, b) => alphaSort(a.model, b.model), // sorter: (a, b) => alphaSort(a.model, b.model),

View File

@@ -1,20 +1,47 @@
import { UploadOutlined } from "@ant-design/icons"; import { UploadOutlined } from "@ant-design/icons";
import { Button, Upload } from "antd"; import { Button, Upload } from "antd";
import React from "react"; import React from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import {
selectBodyshop,
selectCurrentUser,
} from "../../redux/user/user.selectors";
import { handleUpload } from "./documents-upload.utility";
export default function DocumentsUploadComponent({ handleUpload, UploadRef }) { const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
bodyshop: selectBodyshop,
});
export function DocumentsUploadComponent({
currentUser,
bodyshop,
jobId,
tagsArray,
billId,
callbackAfterUpload,
}) {
return ( return (
<div> <Upload
<Upload multiple={true}
multiple={true} customRequest={(ev) =>
customRequest={handleUpload} handleUpload(ev, {
accept="audio/*,video/*,image/*" bodyshop: bodyshop,
ref={UploadRef} uploaded_by: currentUser.email,
> jobId: jobId,
<Button> billId: billId,
<UploadOutlined /> Click to Upload tagsArray: tagsArray,
</Button> callback: callbackAfterUpload,
</Upload> })
</div> }
accept="audio/*, video/*, image/*, .pdf, .doc, .docx, .xls, .xlsx"
showUploadList={false}
>
<Button type="primary">
<UploadOutlined />
</Button>
</Upload>
); );
} }
export default connect(mapStateToProps, null)(DocumentsUploadComponent);

View File

@@ -1,38 +0,0 @@
import React from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import DocumentsUploadComponent from "./documents-upload.component";
import { handleUpload } from "./documents-upload.utility";
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
bodyshop: selectBodyshop,
});
export function DocumentsUploadContainer({
jobId,
tagsArray,
invoiceId,
currentUser,
bodyshop,
callbackAfterUpload,
onChange,
}) {
return (
<DocumentsUploadComponent
handleUpload={(ev) =>
handleUpload(ev, {
bodyshop: bodyshop,
uploaded_by: currentUser.email,
jobId: jobId,
invoiceId: invoiceId,
tagsArray: tagsArray,
callback: callbackAfterUpload,
})
}
/>
);
}
export default connect(mapStateToProps, null)(DocumentsUploadContainer);

View File

@@ -1,61 +1,37 @@
import { notification } from "antd"; import { notification } from "antd";
import axios from "axios"; import axios from "axios";
import Resizer from "react-image-file-resizer";
import { client } from "../../App/App.container";
import { INSERT_NEW_DOCUMENT } from "../../graphql/documents.queries";
import i18n from "i18next"; import i18n from "i18next";
import { axiosAuthInterceptorId, client } from "../../App/App.container";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
import { INSERT_NEW_DOCUMENT } from "../../graphql/documents.queries";
//Context: currentUserEmail, bodyshop, jobid, invoiceid //Context: currentUserEmail, bodyshop, jobid, invoiceid
//Required to prevent headers from getting set and rejected from Cloudinary.
var cleanAxios = axios.create();
cleanAxios.interceptors.request.eject(axiosAuthInterceptorId);
export const handleUpload = (ev, context) => { export const handleUpload = (ev, context) => {
console.log("ev", ev); console.log("Handling Upload", ev);
logImEXEvent("document_upload", { filetype: ev.file.type }); logImEXEvent("document_upload", { filetype: ev.file.type });
const { onError, onSuccess, onProgress } = ev; const { onError, onSuccess, onProgress } = ev;
const { bodyshop, jobId } = context; const { bodyshop, jobId } = context;
//If PDF, upload directly.
//If JPEG, resize and upload. let key = `${bodyshop.id}/${jobId}/${ev.file.name.replace(/\.[^/.]+$/, "")}`;
//TODO If this is just an invoice job? Where to put it? uploadToCloudinary(
let key = `${bodyshop.id}/${jobId}/${ev.file.name}`; key,
if (ev.file.type.includes("image")) { ev.file.type,
Resizer.imageFileResizer( ev.file,
ev.file, onError,
2500, onSuccess,
2500, onProgress,
"JPEG", context
75, );
0,
(uri) => {
let file = new File([uri], ev.file.name, {});
file.uid = ev.file.uid;
uploadToS3(
key,
file.type,
file,
onError,
onSuccess,
onProgress,
context
);
},
"blob"
);
} else {
uploadToS3(
key,
ev.file.type,
ev.file,
onError,
onSuccess,
onProgress,
context
);
}
}; };
export const uploadToS3 = ( export const uploadToCloudinary = async (
fileName, key,
fileType, fileType,
file, file,
onError, onError,
@@ -63,103 +39,115 @@ export const uploadToS3 = (
onProgress, onProgress,
context context
) => { ) => {
const { const { bodyshop, jobId, billId, uploaded_by, callback, tagsArray } = context;
bodyshop,
jobId,
invoiceId,
uploaded_by,
callback,
tagsArray,
} = context;
//Set variables for getting the signed URL.
let timestamp = Math.floor(Date.now() / 1000); let timestamp = Math.floor(Date.now() / 1000);
let public_id = fileName; let public_id = key;
let tags = `${bodyshop.textid},${ let tags = `${bodyshop.textid},${
tagsArray ? tagsArray.map((tag) => `${tag},`) : "" tagsArray ? tagsArray.map((tag) => `${tag},`) : ""
}`; }`;
let eager = process.env.REACT_APP_CLOUDINARY_THUMB_TRANSFORMATIONS; // let eager = process.env.REACT_APP_CLOUDINARY_THUMB_TRANSFORMATIONS;
axios
.post("/media/sign", {
eager: eager,
public_id: public_id,
tags: tags,
timestamp: timestamp,
})
.then((response) => {
var signature = response.data;
var options = {
headers: { "X-Requested-With": "XMLHttpRequest" },
onUploadProgress: (e) => {
if (!!onProgress) onProgress({ percent: (e.loaded / e.total) * 100 });
},
};
const formData = new FormData();
formData.append("file", file);
formData.append("eager", eager);
formData.append("api_key", process.env.REACT_APP_CLOUDINARY_API_KEY);
formData.append("public_id", public_id);
formData.append("tags", tags);
formData.append("timestamp", timestamp);
formData.append("signature", signature);
axios //Get the signed url.
.post(
`${process.env.REACT_APP_CLOUDINARY_ENDPOINT}/upload`, const signedURLResponse = await axios.post("/media/sign", {
formData, public_id: public_id,
options tags: tags,
) timestamp: timestamp,
.then((response) => { upload_preset: "incoming_upload",
console.log("Upload Response", response); });
client
.mutate({ if (signedURLResponse.status !== 200) {
mutation: INSERT_NEW_DOCUMENT, console.log("Error Getting Signed URL", signedURLResponse.statusText);
variables: { if (!!onError) onError(signedURLResponse.statusText);
docInput: [ notification["error"]({
{ message: i18n.t("documents.errors.getpresignurl", {
jobid: jobId, message: signedURLResponse.statusText,
uploaded_by: uploaded_by, }),
key: fileName,
invoiceid: invoiceId,
type: fileType,
},
],
},
})
.then((r) => {
if (!!onSuccess)
onSuccess({
uid: r.data.insert_documents.returning[0].id,
name: r.data.insert_documents.returning[0].name,
status: "done",
key: r.data.insert_documents.returning[0].key,
});
notification["success"]({
message: i18n.t("documents.successes.insert"),
});
if (callback) {
callback();
}
// if (onChange) {
// //Used in a form.
// onChange(UploadRef.current.state.fileList);
// }
});
})
.catch((error) => {
if (!!onError) onError(error);
notification["error"]({
message: i18n.t("documents.errors.insert", {
message: JSON.stringify(error),
}),
});
});
})
.catch((error) => {
console.log("error", error);
notification["error"]({
message: i18n.t("documents.errors.getpresignurl", {
message: JSON.stringify(error),
}),
});
}); });
return;
}
//Build request to end to cloudinary.
var signature = signedURLResponse.data;
var options = {
headers: { "X-Requested-With": "XMLHttpRequest" },
onUploadProgress: (e) => {
if (!!onProgress) onProgress({ percent: (e.loaded / e.total) * 100 });
},
};
const formData = new FormData();
formData.append("file", file);
console.log("Applying lower quality transforms.");
formData.append("upload_preset", "incoming_upload");
formData.append("api_key", process.env.REACT_APP_CLOUDINARY_API_KEY);
formData.append("public_id", public_id);
formData.append("tags", tags);
formData.append("timestamp", timestamp);
formData.append("signature", signature);
//Upload request to Cloudinary
const cloudinaryUploadResponse = await cleanAxios.post(
`${process.env.REACT_APP_CLOUDINARY_ENDPOINT}/upload`,
formData,
{
...options,
}
);
console.log("Upload Response", cloudinaryUploadResponse.data);
if (cloudinaryUploadResponse.status !== 200) {
console.log(
"Error uploading to cloudinary.",
cloudinaryUploadResponse.statusText
);
if (!!onError) onError(cloudinaryUploadResponse.statusText);
notification["error"]({
message: i18n.t("documents.errors.insert", {
message: cloudinaryUploadResponse.statusText,
}),
});
return;
}
//Insert the document with the matching key.
const documentInsert = await client.mutate({
mutation: INSERT_NEW_DOCUMENT,
variables: {
docInput: [
{
jobid: jobId,
uploaded_by: uploaded_by,
key: key,
billid: billId,
type: fileType,
},
],
},
});
if (!documentInsert.errors) {
if (!!onSuccess)
onSuccess({
uid: documentInsert.data.insert_documents.returning[0].id,
name: documentInsert.data.insert_documents.returning[0].name,
status: "done",
key: documentInsert.data.insert_documents.returning[0].key,
});
notification["success"]({
message: i18n.t("documents.successes.insert"),
});
if (callback) {
callback();
}
} else {
if (!!onError) onError(JSON.stringify(documentInsert.errors));
notification["error"]({
message: i18n.t("documents.errors.insert", {
message: JSON.stringify(JSON.stringify(documentInsert.errors)),
}),
});
return;
}
}; };

View File

@@ -26,6 +26,7 @@ const FormDatePicker = ({ value, onChange, onBlur, ...restProps }, ref) => {
value={value ? moment(value) : null} value={value ? moment(value) : null}
onChange={handleChange} onChange={handleChange}
format={dateFormat} format={dateFormat}
onBlur={onBlur}
{...restProps} {...restProps}
/> />
</div> </div>

View File

@@ -6,7 +6,7 @@ import { TimePicker } from "antd";
import moment from "moment"; import moment from "moment";
//To be used as a form element only. //To be used as a form element only.
const DateTimePicker = ({ value, onChange, onBlur }, ref) => { const DateTimePicker = ({ value, onChange, onBlur, ...restProps }, ref) => {
// const handleChange = (newDate) => { // const handleChange = (newDate) => {
// if (value !== newDate && onChange) { // if (value !== newDate && onChange) {
// onChange(newDate); // onChange(newDate);
@@ -15,13 +15,20 @@ const DateTimePicker = ({ value, onChange, onBlur }, ref) => {
return ( return (
<div> <div>
<FormDatePicker value={value} onChange={onChange} /> <FormDatePicker
{...restProps}
value={value}
onBlur={onBlur}
onChange={onChange}
/>
<TimePicker <TimePicker
{...restProps}
value={value ? moment(value) : null} value={value ? moment(value) : null}
onChange={onChange} onChange={onChange}
showSecond={false} showSecond={false}
minuteStep={15} minuteStep={15}
onBlur={onBlur}
format="hh:mm a" format="hh:mm a"
/> />
</div> </div>

View File

@@ -1,31 +1,19 @@
import { Input, Popover } from "antd"; import { InputNumber, Popover } from "antd";
import React, { useEffect, useState, forwardRef } from "react"; import React, { forwardRef, useEffect, useRef, useState } from "react";
const FormInputNUmberCalculator = ( const FormInputNUmberCalculator = (
{ value: formValue, onChange: formOnChange }, { value: formValue, onChange: formOnChange, ...restProps },
ref refProp
) => { ) => {
const [value, setValue] = useState(formValue); const [value, setValue] = useState(formValue);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [history, setHistory] = useState([]); const [history, setHistory] = useState([]);
const handleChange = (e) => { const ref = useRef(null);
const key = e.target.value;
switch (key) {
case "/":
case "*":
case "+":
case "-":
return;
default:
setValue(key);
return;
}
};
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
console.log("e :>> ", e.currentTarget.value);
const { key } = e; const { key } = e;
let action; let action;
switch (key) { switch (key) {
case "/": case "/":
@@ -38,11 +26,13 @@ const FormInputNUmberCalculator = (
action = "="; action = "=";
break; break;
default: default:
setValue(e.currentTarget.value);
return; return;
} }
const val = parseFloat(value); const val = parseFloat(value);
setValue(null); setValue(null);
ref.current.blur();
ref.current.focus();
if (!isNaN(val)) { if (!isNaN(val)) {
setHistory([...history, val, action]); setHistory([...history, val, action]);
} }
@@ -82,7 +72,12 @@ const FormInputNUmberCalculator = (
} }
}, 0); }, 0);
setTotal(subTotal); setTotal(subTotal);
if (history[history.length - 1] === "=") setValue(subTotal); if (history[history.length - 1] === "=") {
setValue(subTotal);
ref.current.blur();
ref.current.focus();
setHistory([]);
}
} }
}, [history]); }, [history]);
@@ -101,13 +96,13 @@ const FormInputNUmberCalculator = (
return ( return (
<div> <div>
<Popover content={popContent} visible={history.length > 0}> <Popover content={popContent} visible={history.length > 0}>
<Input <InputNumber
ref={ref} ref={ref}
value={value} value={value}
defaultValue={value} defaultValue={value}
onChange={handleChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={(e) => setHistory([])} onBlur={(e) => setHistory([])}
{...restProps}
/> />
</Popover> </Popover>
</div> </div>

View File

@@ -0,0 +1,20 @@
import React, { forwardRef } from "react";
import { BlockPicker } from "react-color";
//To be used as a form element only.
const ColorPickerFormItem = ({ value, onChange, style, ...restProps }, ref) => {
const handleChangeComplete = (color) => {
if (onChange) onChange(color);
};
return (
<BlockPicker
{...restProps}
style={{ width: "100%", ...style }}
color={value}
triangle="hide"
onChangeComplete={handleChangeComplete}
/>
);
};
export default forwardRef(ColorPickerFormItem);

View File

@@ -0,0 +1,11 @@
import React, { forwardRef } from "react";
import { useTranslation } from "react-i18next";
const LaborTypeFormItem = ({ value, onChange }, ref) => {
const { t } = useTranslation();
if (!value) return null;
return <div>{t(`joblines.fields.lbr_types.${value}`)}</div>;
};
export default forwardRef(LaborTypeFormItem);

View File

@@ -0,0 +1,11 @@
import React, { forwardRef } from "react";
import { useTranslation } from "react-i18next";
const PartTypeFormItem = ({ value, onChange }, ref) => {
const { t } = useTranslation();
if (!value) return null;
return <div>{t(`joblines.fields.part_types.${value}`)}</div>;
};
export default forwardRef(PartTypeFormItem);

View File

@@ -0,0 +1,17 @@
import Dinero from "dinero.js";
import React, { forwardRef } from "react";
const ReadOnlyFormItem = ({ value, type = "text", onChange }, ref) => {
if (!value) return null;
switch (type) {
case "text":
return <div>{value}</div>;
case "currency":
return (
<div>{Dinero({ amount: Math.round(value * 100) }).toFormat()}</div>
);
default:
return <div>{value}</div>;
}
};
export default forwardRef(ReadOnlyFormItem);

View File

@@ -1,23 +0,0 @@
import { Button } from "antd";
import React, { forwardRef } from "react";
import { useTranslation } from "react-i18next";
import AlertComponent from "../alert/alert.component";
function ResetForm({ resetFields }) {
const { t } = useTranslation();
return (
<AlertComponent
message={
<div>
{t("general.messages.unsavedchanges")}
<Button style={{ marginLeft: "20px" }} onClick={() => resetFields()}>
{t("general.actions.reset")}
</Button>
</div>
}
closable
/>
);
}
export default forwardRef(ResetForm);

View File

@@ -132,16 +132,16 @@ export default function GlobalSearch() {
}), }),
}, },
{ {
label: renderTitle(t("menus.header.search.invoices")), label: renderTitle(t("menus.header.search.bills")),
options: data.search_invoices.map((invoice) => { options: data.search_bills.map((bill) => {
return { return {
value: `${invoice.invoice_number}`, value: `${bill.invoice_number}`,
label: ( label: (
<Link to={`/manage/invoices?invoiceid=${invoice.id}`}> <Link to={`/manage/bills?billid=${bill.id}`}>
<div className="imex-flex-row"> <div className="imex-flex-row">
<span className="imex-flex-row__margin-large">{`${invoice.invoice_number}`}</span> <span className="imex-flex-row__margin-large">{`${bill.invoice_number}`}</span>
<span className="imex-flex-row__margin-large">{`${invoice.vendor.name}`}</span> <span className="imex-flex-row__margin-large">{`${bill.vendor.name}`}</span>
<span className="imex-flex-row__margin-large">{`${invoice.date}`}</span> <span className="imex-flex-row__margin-large">{`${bill.date}`}</span>
</div> </div>
</Link> </Link>
), ),

View File

@@ -12,8 +12,9 @@ import Icon, {
TeamOutlined, TeamOutlined,
UnorderedListOutlined, UnorderedListOutlined,
UserOutlined, UserOutlined,
ToolFilled,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { Avatar, Layout, Menu } from "antd"; import { Avatar, Menu } from "antd";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BsKanban } from "react-icons/bs"; import { BsKanban } from "react-icons/bs";
@@ -26,25 +27,25 @@ import {
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectRecentItems } from "../../redux/application/application.selectors"; import {
selectRecentItems,
selectSelectedHeader,
} from "../../redux/application/application.selectors";
import { setModalContext } from "../../redux/modals/modals.actions"; import { setModalContext } from "../../redux/modals/modals.actions";
import { signOutStart } from "../../redux/user/user.actions"; import { signOutStart } from "../../redux/user/user.actions";
import { import { selectCurrentUser } from "../../redux/user/user.selectors";
selectBodyshop,
selectCurrentUser,
} from "../../redux/user/user.selectors";
import GlobalSearch from "../global-search/global-search.component"; import GlobalSearch from "../global-search/global-search.component";
import "./header.styles.scss"; import "./header.styles.scss";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser, currentUser: selectCurrentUser,
bodyshop: selectBodyshop,
recentItems: selectRecentItems, recentItems: selectRecentItems,
selectedHeader: selectSelectedHeader,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setInvoiceEnterContext: (context) => setBillEnterContext: (context) =>
dispatch(setModalContext({ context: context, modal: "invoiceEnter" })), dispatch(setModalContext({ context: context, modal: "billEnter" })),
setTimeTicketContext: (context) => setTimeTicketContext: (context) =>
dispatch(setModalContext({ context: context, modal: "timeTicket" })), dispatch(setModalContext({ context: context, modal: "timeTicket" })),
setPaymentContext: (context) => setPaymentContext: (context) =>
@@ -53,291 +54,292 @@ const mapDispatchToProps = (dispatch) => ({
}); });
function Header({ function Header({
bodyshop,
handleMenuClick, handleMenuClick,
currentUser, currentUser,
selectedHeader,
signOutStart, signOutStart,
setInvoiceEnterContext, setBillEnterContext,
setTimeTicketContext, setTimeTicketContext,
setPaymentContext, setPaymentContext,
recentItems, recentItems,
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { Header } = Layout;
return ( return (
<Header> <Menu
<Menu mode="horizontal"
mode="horizontal" theme="dark"
theme="dark" // style={{ backgroundColor: "#eee" }}
className="header-main-menu" className="header-main-menu"
selectedKeys={["home"]} selectedKeys={[selectedHeader]}
onClick={handleMenuClick} onClick={handleMenuClick}
>
<Menu.Item key="home">
<Link to="/manage">
<HomeFilled />
{t("menus.header.home")}
</Link>
</Menu.Item>
<Menu.Item key="schedule">
<Link to="/manage/schedule">
<Icon component={FaCalendarAlt} />
{t("menus.header.schedule")}
</Link>
</Menu.Item>
<Menu.SubMenu
title={
<span>
<Icon component={FaCarCrash} />
<span>{t("menus.header.jobs")}</span>
</span>
}
> >
<Menu.Item key="home"> <Menu.Item key="activejobs">
<Link to="/manage"> <FileFilled />
<HomeFilled /> <Link to="/manage/jobs">{t("menus.header.activejobs")}</Link>
{t("menus.header.home")} </Menu.Item>
<Menu.Item key="parts-queue">
<Link to="/manage/partsqueue">
<ToolFilled /> {t("menus.header.parts-queue")}
</Link> </Link>
</Menu.Item> </Menu.Item>
<Menu.Item key="schedule"> <Menu.Item key="availablejobs">
<Link to="/manage/schedule"> <Link to="/manage/available">
<Icon component={FaCalendarAlt} /> <ImportOutlined /> {t("menus.header.availablejobs")}
{t("menus.header.schedule")}
</Link> </Link>
</Menu.Item> </Menu.Item>
<Menu.SubMenu <Menu.Divider />
title={ <Menu.Item key="alljobs">
<span> <UnorderedListOutlined />
<Icon component={FaCarCrash} /> <Link to="/manage/jobs/all">{t("menus.header.alljobs")}</Link>
<span>{t("menus.header.jobs")}</span> </Menu.Item>
</span> <Menu.Divider />
}
> <Menu.Item key="productionlist">
<Menu.Item key="activejobs"> <Link to="/manage/production/list">
<ScheduleOutlined />
{t("menus.header.productionlist")}
</Link>
</Menu.Item>
<Menu.Item key="productionboard">
<Link to="/manage/production/board">
<Icon component={BsKanban} />
{t("menus.header.productionboard")}
</Link>
</Menu.Item>
<Menu.Divider />
<Menu.Item key="scoreboard">
<LineChartOutlined />
<Link to="/manage/scoreboard">{t("menus.header.scoreboard")}</Link>
</Menu.Item>
</Menu.SubMenu>
<Menu.SubMenu
title={
<span>
<UserOutlined />
<span>{t("menus.header.customers")}</span>
</span>
}
>
<Menu.Item key="owners">
<Link to="/manage/owners">
<TeamOutlined />
{t("menus.header.owners")}
</Link>
</Menu.Item>
<Menu.Item key="vehicles">
<Link to="/manage/vehicles">
<CarFilled />
{t("menus.header.vehicles")}
</Link>
</Menu.Item>
</Menu.SubMenu>
<Menu.SubMenu
title={
<span>
<CarFilled />
<span>{t("menus.header.courtesycars")}</span>
</span>
}
>
<Menu.Item key="courtesycarsall">
<Link to="/manage/courtesycars">
<CarFilled />
{t("menus.header.courtesycars-all")}
</Link>
</Menu.Item>
<Menu.Item key="contracts">
<Link to="/manage/courtesycars/contracts">
<FileFilled /> <FileFilled />
<Link to="/manage/jobs">{t("menus.header.activejobs")}</Link> {t("menus.header.courtesycars-contracts")}
</Menu.Item> </Link>
<Menu.Item key="availablejobs">
<Link to="/manage/available">
<ImportOutlined /> {t("menus.header.availablejobs")}
</Link>
</Menu.Item>
<Menu.Divider />
<Menu.Item key="alljobs">
<UnorderedListOutlined />
<Link to="/manage/jobs/all">{t("menus.header.alljobs")}</Link>
</Menu.Item>
<Menu.Divider />
<Menu.Item key="productionlist">
<Link to="/manage/production/list">
<ScheduleOutlined />
{t("menus.header.productionlist")}
</Link>
</Menu.Item>
<Menu.Item key="productionboard">
<Link to="/manage/production/board">
<Icon component={BsKanban} />
{t("menus.header.productionboard")}
</Link>
</Menu.Item>
<Menu.Divider />
<Menu.Item key="scoreboard">
<LineChartOutlined />
<Link to="/manage/scoreboard">{t("menus.header.scoreboard")}</Link>
</Menu.Item>
</Menu.SubMenu>
<Menu.SubMenu
title={
<span>
<UserOutlined />
<span>{t("menus.header.customers")}</span>
</span>
}
>
<Menu.Item key="owners">
<Link to="/manage/owners">
<TeamOutlined />
{t("menus.header.owners")}
</Link>
</Menu.Item>
<Menu.Item key="vehicles">
<Link to="/manage/vehicles">
<CarFilled />
{t("menus.header.vehicles")}
</Link>
</Menu.Item>
</Menu.SubMenu>
<Menu.SubMenu
title={
<span>
<CarFilled />
<span>{t("menus.header.courtesycars")}</span>
</span>
}
>
<Menu.Item key="courtesycarsall">
<Link to="/manage/courtesycars">
<CarFilled />
{t("menus.header.courtesycars-all")}
</Link>
</Menu.Item>
<Menu.Item key="contracts">
<Link to="/manage/courtesycars/contracts">
<FileFilled />
{t("menus.header.courtesycars-contracts")}
</Link>
</Menu.Item>
<Menu.Item key="newcontract">
<Link to="/manage/courtesycars/contracts/new">
<FileAddFilled />
{t("menus.header.courtesycars-newcontract")}
</Link>
</Menu.Item>
</Menu.SubMenu>
<Menu.SubMenu
title={
<span>
<DollarCircleFilled />
<span>{t("menus.header.accounting")}</span>
</span>
}
>
<Menu.Item key="invoices">
<Link to="/manage/invoices">{t("menus.header.invoices")}</Link>
</Menu.Item>
<Menu.Item
key="enterinvoices"
onClick={() => {
setInvoiceEnterContext({
actions: {},
context: {},
});
}}
>
<Icon component={FaFileInvoiceDollar} />
{t("menus.header.enterinvoices")}
</Menu.Item>
<Menu.Divider />
<Menu.Item key="allpayments">
<Link to="/manage/payments">{t("menus.header.allpayments")}</Link>
</Menu.Item>
<Menu.Item
key="enterpayments"
onClick={() => {
setPaymentContext({
actions: {},
context: {},
});
}}
>
<Icon component={FaCreditCard} />
{t("menus.header.enterpayment")}
</Menu.Item>
<Menu.Divider />
<Menu.Item key="timetickets">
<Link to="/manage/timetickets">
{t("menus.header.timetickets")}
</Link>
</Menu.Item>
<Menu.Item
key="entertimetickets"
onClick={() => {
setTimeTicketContext({
actions: {},
context: {},
});
}}
>
{t("menus.header.entertimeticket")}
</Menu.Item>
<Menu.Divider />
<Menu.SubMenu title={t("menus.header.export")}>
<Menu.Item key="receivables">
<Link to="/manage/accounting/receivables">
{t("menus.header.accounting-receivables")}
</Link>
</Menu.Item>
<Menu.Item key="payables">
<Link to="/manage/accounting/payables">
{t("menus.header.accounting-payables")}
</Link>
</Menu.Item>
<Menu.Item key="payments">
<Link to="/manage/accounting/payments">
{t("menus.header.accounting-payments")}
</Link>
</Menu.Item>
</Menu.SubMenu>
</Menu.SubMenu>
<Menu.SubMenu title={t("menus.header.shop")}>
<Menu.Item key="shop">
<Link to="/manage/shop">{t("menus.header.shop_config")}</Link>
</Menu.Item>
<Menu.Item key="shop-templates">
<Link to="/manage/shop/templates">
{t("menus.header.shop_templates")}
</Link>
</Menu.Item>
<Menu.Item key="shop-vendors">
<Link to="/manage/shop/vendors">
{t("menus.header.shop_vendors")}
</Link>
</Menu.Item>
<Menu.Item key="shop-csi">
<Link to="/manage/shop/csi">{t("menus.header.shop_csi")}</Link>
</Menu.Item>
</Menu.SubMenu>
<Menu.Item>
<GlobalSearch />
</Menu.Item> </Menu.Item>
<Menu.SubMenu title={<ClockCircleFilled />}> <Menu.Item key="newcontract">
{recentItems.map((i, idx) => ( <Link to="/manage/courtesycars/contracts/new">
<Menu.Item key={idx}> <FileAddFilled />
<Link to={i.url}>{i.label}</Link> {t("menus.header.courtesycars-newcontract")}
</Menu.Item> </Link>
))} </Menu.Item>
</Menu.SubMenu>
<Menu.SubMenu
title={
<span>
<DollarCircleFilled />
<span>{t("menus.header.accounting")}</span>
</span>
}
>
<Menu.Item key="bills">
<Link to="/manage/bills">{t("menus.header.bills")}</Link>
</Menu.Item>
<Menu.Item
key="enterbills"
onClick={() => {
setBillEnterContext({
actions: {},
context: {},
});
}}
>
<Icon component={FaFileInvoiceDollar} />
{t("menus.header.enterbills")}
</Menu.Item>
<Menu.Divider />
<Menu.Item key="allpayments">
<Link to="/manage/payments">{t("menus.header.allpayments")}</Link>
</Menu.Item>
<Menu.Item
key="enterpayments"
onClick={() => {
setPaymentContext({
actions: {},
context: {},
});
}}
>
<Icon component={FaCreditCard} />
{t("menus.header.enterpayment")}
</Menu.Item>
<Menu.Divider />
<Menu.Item key="timetickets">
<Link to="/manage/timetickets">{t("menus.header.timetickets")}</Link>
</Menu.Item>
<Menu.Item
key="entertimetickets"
onClick={() => {
setTimeTicketContext({
actions: {},
context: {},
});
}}
>
{t("menus.header.entertimeticket")}
</Menu.Item>
<Menu.Divider />
<Menu.SubMenu title={t("menus.header.export")}>
<Menu.Item key="receivables">
<Link to="/manage/accounting/receivables">
{t("menus.header.accounting-receivables")}
</Link>
</Menu.Item>
<Menu.Item key="payables">
<Link to="/manage/accounting/payables">
{t("menus.header.accounting-payables")}
</Link>
</Menu.Item>
<Menu.Item key="payments">
<Link to="/manage/accounting/payments">
{t("menus.header.accounting-payments")}
</Link>
</Menu.Item>
</Menu.SubMenu> </Menu.SubMenu>
</Menu.SubMenu>
<Menu.SubMenu title={t("menus.header.shop")}>
<Menu.Item key="shop">
<Link to="/manage/shop">{t("menus.header.shop_config")}</Link>
</Menu.Item>
<Menu.Item key="shop-templates">
<Link to="/manage/shop/templates">
{t("menus.header.shop_templates")}
</Link>
</Menu.Item>
<Menu.Item key="shop-vendors">
<Link to="/manage/shop/vendors">
{t("menus.header.shop_vendors")}
</Link>
</Menu.Item>
<Menu.Item key="shop-csi">
<Link to="/manage/shop/csi">{t("menus.header.shop_csi")}</Link>
</Menu.Item>
</Menu.SubMenu>
<Menu.Item>
<GlobalSearch />
</Menu.Item>
<Menu.SubMenu title={<ClockCircleFilled />}>
{recentItems.map((i, idx) => (
<Menu.Item key={idx}>
<Link to={i.url}>{i.label}</Link>
</Menu.Item>
))}
</Menu.SubMenu>
<Menu.SubMenu
title={
<div>
{currentUser.photoURL ? (
<Avatar
src={currentUser.photoURL}
style={{
margin: "10px",
}}
/>
) : (
<Avatar
style={{
backgroundColor: "#87d068",
margin: "10px",
}}
icon={<UserOutlined />}
/>
)}
{currentUser.displayName || t("general.labels.unknown")}
</div>
}
>
<Menu.Item danger onClick={() => signOutStart()}>
{t("user.actions.signout")}
</Menu.Item>
<Menu.Item key="shiftclock">
<Link to="/manage/shiftclock">{t("menus.header.shiftclock")}</Link>
</Menu.Item>
<Menu.Item key="profile">
<Link to="/manage/profile">{t("menus.currentuser.profile")}</Link>
</Menu.Item>
<Menu.SubMenu <Menu.SubMenu
title={ title={
<div> <span>
{currentUser.photoURL ? ( <GlobalOutlined />
<Avatar <span>{t("menus.currentuser.languageselector")}</span>
src={currentUser.photoURL} </span>
style={{
margin: "10px",
}}
/>
) : (
<Avatar
style={{
backgroundColor: "#87d068",
margin: "10px",
}}
icon={<UserOutlined />}
/>
)}
{currentUser.displayName || t("general.labels.unknown")}
</div>
} }
> >
<Menu.Item danger onClick={() => signOutStart()}> <Menu.Item actiontype="lang-select" key="en-US">
{t("user.actions.signout")} {t("general.languages.english")}
</Menu.Item> </Menu.Item>
<Menu.Item key="shiftclock"> <Menu.Item actiontype="lang-select" key="fr-CA">
<Link to="/manage/shiftclock">{t("menus.header.shiftclock")}</Link> {t("general.languages.french")}
</Menu.Item> </Menu.Item>
<Menu.Item> <Menu.Item actiontype="lang-select" key="es-MX">
<Link to="/manage/profile">{t("menus.currentuser.profile")}</Link> {t("general.languages.spanish")}
</Menu.Item> </Menu.Item>
<Menu.SubMenu
title={
<span>
<GlobalOutlined />
<span>{t("menus.currentuser.languageselector")}</span>
</span>
}
>
<Menu.Item actiontype="lang-select" key="en-US">
{t("general.languages.english")}
</Menu.Item>
<Menu.Item actiontype="lang-select" key="fr-CA">
{t("general.languages.french")}
</Menu.Item>
<Menu.Item actiontype="lang-select" key="es-MX">
{t("general.languages.spanish")}
</Menu.Item>
</Menu.SubMenu>
</Menu.SubMenu> </Menu.SubMenu>
</Menu> </Menu.SubMenu>
</Header> </Menu>
); );
} }

View File

@@ -4,6 +4,6 @@
max-height: 3.5rem; max-height: 3.5rem;
} }
.header-main-menu { .header-main-menu {
width: 80vw; //width: 95vw;
float: left; //float: left;
} }

View File

@@ -0,0 +1,53 @@
import { Button, Card, Input, Space } from "antd";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
export default function HelpRescue() {
const { t } = useTranslation();
const [code, setCode] = useState("");
const handleClick = async () => {
var bodyFormData = new FormData();
bodyFormData.append("Code", code);
const res1 = await fetch(
"https://secure.logmeinrescue.com/Customer/Code.aspx",
{
method: "POST",
body: bodyFormData,
}
);
console.log("handleClick -> res1", res1);
};
return (
<Card title={t("help.labels.rescuetitle")}>
<div style={{ display: "flex", justifyContent: "center" }}>
<Space direction="vertical" align="center">
<div>{t("help.labels.rescuedesc")}</div>
<form
name="logmeinsupport"
action="https://secure.logmeinrescue.com/Customer/Code.aspx"
method="post"
>
<span>
Enter your six-digit code, then click the Start Download button
below{" "}
</span>
<input type="text" name="Code" />
<br />
<input type="submit" value="Connect to technician" />
</form>
<Input
size="large"
style={{ width: "10rem" }}
onChange={(e) => setCode(e.target.value)}
value={code}
placeholder={t("help.labels.codeplacholder")}
/>
<Button onClick={handleClick}>{t("help.actions.connect")}</Button>
</Space>
</div>
</Card>
);
}

View File

@@ -1,124 +0,0 @@
import { useMutation, useQuery } from "@apollo/react-hooks";
import { Form, Button } from "antd";
import moment from "moment";
import queryString from "query-string";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useLocation } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import {
QUERY_INVOICE_BY_PK,
UPDATE_INVOICE,
} from "../../graphql/invoices.queries";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component";
import InvoiceFormContainer from "../invoice-form/invoice-form.container";
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
import { UPDATE_INVOICE_LINE } from "../../graphql/invoice-lines.queries";
import JobDocumentsGallery from "../jobs-documents-gallery/jobs-documents-gallery.container";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
});
export function InvoiceDetailEditContainer({ bodyshop }) {
const search = queryString.parse(useLocation().search);
const { t } = useTranslation();
const [form] = Form.useForm();
const [updateLoading, setUpdateLoading] = useState(false);
const [updateInvoice] = useMutation(UPDATE_INVOICE);
const [updateInvoiceLine] = useMutation(UPDATE_INVOICE_LINE);
const { loading, error, data, refetch } = useQuery(QUERY_INVOICE_BY_PK, {
variables: { invoiceid: search.invoiceid },
skip: !!!search.invoiceid,
});
const handleFinish = async (values) => {
setUpdateLoading(true);
const { invoicelines, upload, ...invoice } = values;
const updates = [];
updates.push(
updateInvoice({
variables: { invoiceId: search.invoiceid, invoice: invoice },
})
);
invoicelines.forEach((il) => {
delete il.__typename;
updates.push(
updateInvoiceLine({
variables: {
invoicelineId: il.id,
invoiceLine: {
...il,
joblineid: il.joblineid === "noline" ? null : il.joblineid,
},
},
})
);
});
await Promise.all(updates);
setUpdateLoading(false);
};
useEffect(() => {
if (search.invoiceid) {
form.resetFields();
}
}, [form, search.invoiceid]);
if (error) return <AlertComponent message={error.message} type="error" />;
if (!!!search.invoiceid)
return <div>{t("invoices.labels.noneselected")}</div>;
return (
<LoadingSkeleton loading={loading}>
<Form
form={form}
onFinish={handleFinish}
initialValues={
data
? {
...data.invoices_by_pk,
invoicelines: data.invoices_by_pk.invoicelines.map((i) => {
return {
...i,
joblineid: !!i.joblineid ? i.joblineid : "noline",
applicable_taxes: {
federal:
(i.applicable_taxes && i.applicable_taxes.federal) ||
false,
state:
(i.applicable_taxes && i.applicable_taxes.state) ||
false,
local:
(i.applicable_taxes && i.applicable_taxes.local) ||
false,
},
};
}),
date: data.invoices_by_pk
? moment(data.invoices_by_pk.date)
: null,
}
: {}
}
>
<Button htmlType="submit" loading={updateLoading} type="primary">
{t("general.actions.save")}
</Button>
<InvoiceFormContainer form={form} invoiceEdit />
<JobDocumentsGallery
jobId={data ? data.invoices_by_pk.jobid : null}
invoiceId={search.invoiceid}
documentsList={data ? data.invoices_by_pk.documents : []}
invoicesCallback={refetch}
/>
</Form>
</LoadingSkeleton>
);
}
export default connect(mapStateToProps, null)(InvoiceDetailEditContainer);

View File

@@ -1,42 +0,0 @@
import { useLazyQuery, useQuery } from "@apollo/react-hooks";
import React from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { GET_JOB_LINES_TO_ENTER_INVOICE } from "../../graphql/jobs-lines.queries";
import { ACTIVE_JOBS_FOR_AUTOCOMPLETE } from "../../graphql/jobs.queries";
import { SEARCH_VENDOR_AUTOCOMPLETE } from "../../graphql/vendors.queries";
import { selectBodyshop } from "../../redux/user/user.selectors";
import InvoiceFormComponent from "./invoice-form.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
});
export function InvoiceFormContainer({ bodyshop, form, invoiceEdit }) {
const { data: RoAutoCompleteData } = useQuery(ACTIVE_JOBS_FOR_AUTOCOMPLETE, {
variables: { statuses: bodyshop.md_ro_statuses.open_statuses || ["Open"] },
});
const { data: VendorAutoCompleteData } = useQuery(SEARCH_VENDOR_AUTOCOMPLETE);
const [loadLines, { data: lineData }] = useLazyQuery(
GET_JOB_LINES_TO_ENTER_INVOICE
);
return (
<div>
<InvoiceFormComponent
form={form}
invoiceEdit={invoiceEdit}
roAutoCompleteOptions={RoAutoCompleteData && RoAutoCompleteData.jobs}
vendorAutoCompleteOptions={
VendorAutoCompleteData && VendorAutoCompleteData.vendors
}
loadLines={loadLines}
lineData={lineData ? lineData.joblines : []}
responsibilityCenters={bodyshop.md_responsibility_centers || null}
/>
</div>
);
}
export default connect(mapStateToProps, null)(InvoiceFormContainer);

View File

@@ -1,224 +0,0 @@
import { DeleteFilled, WarningOutlined } from "@ant-design/icons";
import { Button, Form, Input, InputNumber, Select, Switch } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import InvoiceLineSearchSelect from "../invoice-line-search-select/invoice-line-search-select.component";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
export default function InvoiceEnterModalLinesComponent({
lineData,
discount,
form,
responsibilityCenters,
}) {
const { t } = useTranslation();
const { setFieldsValue, getFieldsValue } = form;
return (
<Form.List name="invoicelines">
{(fields, { add, remove, move }) => {
return (
<div className="invoice-form-lines-wrapper">
{fields.map((field, index) => (
<Form.Item required={false} key={field.key}>
<div className="invoice-form-line">
<Form.Item
label={t("invoicelines.fields.jobline")}
key={`${index}joblinename`}
name={[field.name, "joblineid"]}
rules={[
{
required: true,
message: t("general.validation.required"),
},
]}
>
<InvoiceLineSearchSelect
options={lineData}
onSelect={(value, opt) => {
setFieldsValue({
invoicelines: getFieldsValue([
"invoicelines",
]).invoicelines.map((item, idx) => {
if (idx === index) {
return {
...item,
line_desc: opt.line_desc,
quantity: opt.part_qty || 1,
actual_price: opt.cost,
cost_center: opt.part_type
? responsibilityCenters.defaults.costs[
opt.part_type
] || null
: null,
};
}
return item;
}),
});
}}
/>
</Form.Item>
<Form.Item
label={t("invoicelines.fields.line_desc")}
key={`${index}line_desc`}
name={[field.name, "line_desc"]}
rules={[
{
required: true,
message: t("general.validation.required"),
},
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("invoicelines.fields.quantity")}
key={`${index}quantity`}
name={[field.name, "quantity"]}
rules={[
{
required: true,
message: t("general.validation.required"),
},
]}
>
<InputNumber precision={0} min={0} />
</Form.Item>
<Form.Item
label={t("invoicelines.fields.actual")}
key={`${index}actual_price`}
name={[field.name, "actual_price"]}
rules={[
{
required: true,
message: t("general.validation.required"),
},
]}
>
<CurrencyInput
min={0}
onBlur={(e) => {
setFieldsValue({
invoicelines: getFieldsValue(
"invoicelines"
).invoicelines.map((item, idx) => {
if (idx === index) {
return {
...item,
actual_cost: !!item.actual_cost
? item.actual_cost
: parseFloat(e.target.value) * (1 - discount),
};
}
return item;
}),
});
}}
/>
</Form.Item>
<Form.Item
label={t("invoicelines.fields.actual_cost")}
key={`${index}actual_cost`}
name={[field.name, "actual_cost"]}
rules={[
{
required: true,
message: t("general.validation.required"),
},
]}
>
<CurrencyInput min={0} />
</Form.Item>
<Form.Item shouldUpdate>
{() => {
const line = getFieldsValue(["invoicelines"])
.invoicelines[index];
if (!!!line) return null;
const lineDiscount = (
1 -
Math.round(
(line.actual_cost / line.actual_price) * 100
) /
100
).toPrecision(2);
if (lineDiscount - discount === 0) return null;
return <WarningOutlined style={{ color: "red" }} />;
}}
</Form.Item>
<Form.Item
label={t("invoicelines.fields.cost_center")}
key={`${index}cost_center`}
name={[field.name, "cost_center"]}
rules={[
{
required: true,
message: t("general.validation.required"),
},
]}
>
<Select style={{ width: "150px" }}>
{responsibilityCenters.costs.map((item) => (
<Select.Option key={item.name}>
{item.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label={t("invoicelines.fields.federal_tax_applicable")}
key={`${index}fedtax`}
initialValue={true}
valuePropName="checked"
name={[field.name, "applicable_taxes", "federal"]}
>
<Switch />
</Form.Item>
<Form.Item
label={t("invoicelines.fields.state_tax_applicable")}
key={`${index}statetax`}
valuePropName="checked"
name={[field.name, "applicable_taxes", "state"]}
>
<Switch />
</Form.Item>
<Form.Item
label={t("invoicelines.fields.local_tax_applicable")}
key={`${index}localtax`}
valuePropName="checked"
name={[field.name, "applicable_taxes", "local"]}
>
<Switch />
</Form.Item>
<DeleteFilled
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
/>
</div>
</Form.Item>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => {
add();
}}
style={{ width: "50%" }}
>
{t("invoicelines.actions.newline")}
</Button>
</Form.Item>
</div>
);
}}
</Form.List>
);
}

View File

@@ -1,40 +0,0 @@
.invoice-form-wrapper {
display: flex;
flex-direction: column;
justify-content: left;
}
.invoice-form-totals {
display: flex;
justify-content: space-around;
align-items: flex-start;
flex-wrap: wrap;
& > * {
padding: 5px;
}
}
.invoice-form-invoice-details {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
& > * {
padding: 5px;
}
}
.invoice-form-lines-wrapper {
border: 3px ridge rgba(28, 110, 164, 0.24);
border-radius: 4px;
}
.invoice-form-line {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-around;
border-bottom: 2px dashed rgba(7, 7, 7, 0.4);
F & > * {
margin: 5px;
}
}

View File

@@ -1,161 +0,0 @@
//DEPRECATED.
// import React, { useState } from "react";
// import { QUERY_INVOICES_BY_VENDOR_PAGINATED } from "../../graphql/invoices.queries";
// import { useQuery } from "@apollo/react-hooks";
// import queryString from "query-string";
// import { useHistory, useLocation } from "react-router-dom";
// import { Table, Input } from "antd";
// import { useTranslation } from "react-i18next";
// import { alphaSort } from "../../utils/sorters";
// import AlertComponent from "../alert/alert.component";
// import { DateFormatter } from "../../utils/DateFormatter";
// import CurrencyFormatter from "../../utils/CurrencyFormatter";
// export default function InvoicesByVendorList() {
// const search = queryString.parse(useLocation().search);
// const history = useHistory();
// const { page, sortcolumn, sortorder } = search;
// const { loading, error, data } = useQuery(
// QUERY_INVOICES_BY_VENDOR_PAGINATED,
// {
// variables: {
// vendorId: search.vendorid,
// offset: page ? (page - 1) * 25 : 0,
// limit: 25,
// order: [
// {
// [sortcolumn || "date"]: sortorder
// ? sortorder === "descend"
// ? "desc"
// : "asc"
// : "desc",
// },
// ],
// },
// skip: !!!search.vendorid,
// }
// );
// const { t } = useTranslation();
// const [state, setState] = useState({
// sortedInfo: {},
// search: "",
// });
// const handleTableChange = (pagination, filters, sorter) => {
// setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
// search.page = pagination.current;
// search.sortcolumn = sorter.columnKey;
// search.sortorder = sorter.order;
// history.push({ search: queryString.stringify(search) });
// };
// const handleOnRowClick = (record) => {
// if (record) {
// if (record.id) {
// search.invoiceid = record.id;
// history.push({ search: queryString.stringify(search) });
// }
// } else {
// delete search.invoiceid;
// history.push({ search: queryString.stringify(search) });
// }
// };
// const columns = [
// {
// 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,
// },
// {
// 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>
// ),
// },
// ];
// const handleSearch = (e) => {
// setState({ ...state, search: e.target.value });
// };
// const dataSource = state.search
// ? data.invoices.filter(
// (i) =>
// (i.invoice_number || "")
// .toLowerCase()
// .includes(state.search.toLowerCase()) ||
// (i.amount || "").toString().includes(state.search)
// )
// : (data && data.invoices) || [];
// if (error) return <AlertComponent message={error.message} type='error' />;
// return (
// <Table
// loading={loading}
// title={() => {
// return (
// <div>
// <Input
// value={state.search}
// onChange={handleSearch}
// placeholder={t("general.labels.search")}
// allowClear
// />
// </div>
// );
// }}
// dataSource={dataSource}
// size='small'
// scroll={{ x: true }}
// pagination={{
// position: "top",
// pageSize: 25,
// current: parseInt(page || 1),
// total: data ? data.invoices_aggregate.aggregate.count : 0,
// }}
// columns={columns}
// rowKey='id'
// onChange={handleTableChange}
// rowSelection={{
// onSelect: (record) => {
// handleOnRowClick(record);
// },
// selectedRowKeys: [search.invoiceid],
// type: "radio",
// }}
// onRow={(record, rowIndex) => {
// return {
// onClick: (event) => {
// handleOnRowClick(record);
// }, // click row
// };
// }}
// />
// );
// }

View File

@@ -0,0 +1,29 @@
import React, { useEffect } from "react";
export default function JiraSupportComponent() {
useEffect(() => {
const script = document.createElement("script");
script.src = "https://jsd-widget.atlassian.com/assets/embed.js";
// script.attributes.setNamedItem({ "data-jsd-embedded": true });
// script.attributes.setNamedItem({
// "data-key": "d69bb65c-1dd3-483f-b109-66a970d03f44",
// });
// script.attributes.setNamedItem({
// "data-base-url": "https://jsd-widget.atlassian.com",
// });
// script["data-jsd-embedded"] = true;
// script["data-key"] = "d69bb65c-1dd3-483f-b109-66a970d03f44";
// script["data-base-url"] = "https://jsd-widget.atlassian.com";
script.async = true;
document.body.appendChild(script);
return () => {
document.body.removeChild(script);
};
}, []);
return <div>JIra</div>;
}

View File

@@ -4,23 +4,23 @@ import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component"; import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
import "./job-invoices-total.styles.scss"; import "./job-bills-total.styles.scss";
export default function JobInvoiceTotals({ loading, invoices, jobTotals }) { export default function JobBillsTotalComponent({ loading, bills, jobTotals }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (loading) return <LoadingSkeleton />; if (loading) return <LoadingSkeleton />;
if (!!!jobTotals) if (!!!jobTotals)
return ( return (
<AlertComponent type='error' message={t("jobs.errors.nofinancial")} /> <AlertComponent type="error" message={t("jobs.errors.nofinancial")} />
); );
const totals = JSON.parse(jobTotals);
let invoiceTotals = Dinero({ amount: 0 }); const totals = jobTotals;
invoices.forEach((i) =>
i.invoicelines.forEach((il) => { let billTotals = Dinero({ amount: 0 });
invoiceTotals = invoiceTotals.add( bills.forEach((i) =>
i.billlines.forEach((il) => {
billTotals = billTotals.add(
Dinero({ Dinero({
amount: Math.round( amount: Math.round(
(il.actual_cost || 0) * (i.is_credit_memo ? -1 : 1) * 100 (il.actual_cost || 0) * (i.is_credit_memo ? -1 : 1) * 100
@@ -30,10 +30,10 @@ export default function JobInvoiceTotals({ loading, invoices, jobTotals }) {
}) })
); );
const discrepancy = Dinero(totals.parts.parts.total).subtract(invoiceTotals); const discrepancy = Dinero(totals.parts.parts.total).subtract(billTotals);
return ( return (
<div className='job-invoices-totals-container'> <div className="job-bills-totals-container">
<Statistic <Statistic
title={t("jobs.labels.partstotal")} title={t("jobs.labels.partstotal")}
value={Dinero(totals.parts.parts.total).toFormat()} value={Dinero(totals.parts.parts.total).toFormat()}
@@ -43,11 +43,11 @@ export default function JobInvoiceTotals({ loading, invoices, jobTotals }) {
value={Dinero(totals.parts.sublets.total).toFormat()} value={Dinero(totals.parts.sublets.total).toFormat()}
/> />
<Statistic <Statistic
title={t("invoices.labels.retailtotal")} title={t("bills.labels.retailtotal")}
value={invoiceTotals.toFormat()} value={billTotals.toFormat()}
/> />
<Statistic <Statistic
title={t("invoices.labels.discrepancy")} title={t("bills.labels.discrepancy")}
valueStyle={{ valueStyle={{
color: discrepancy.getAmount === 0 ? "green" : "red", color: discrepancy.getAmount === 0 ? "green" : "red",
}} }}

View File

@@ -1,4 +1,4 @@
.job-invoices-totals-container { .job-bills-totals-container {
margin: 0rem 2rem; margin: 0rem 2rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -3,16 +3,9 @@ import Axios from "axios";
import React, { useState } from "react"; import React, { useState } from "react";
import { useMutation } from "react-apollo"; import { useMutation } from "react-apollo";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { UPDATE_JOB } from "../../graphql/jobs.queries"; import { UPDATE_JOB } from "../../graphql/jobs.queries";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({ export default function JobCalculateTotals({ job, disabled }) {
bodyshop: selectBodyshop,
});
export function JobCalculateTotals({ bodyshop, job }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [updateJob] = useMutation(UPDATE_JOB); const [updateJob] = useMutation(UPDATE_JOB);
@@ -22,7 +15,6 @@ export function JobCalculateTotals({ bodyshop, job }) {
const newTotals = ( const newTotals = (
await Axios.post("/job/totals", { await Axios.post("/job/totals", {
job: job, job: job,
shoprates: bodyshop.shoprates,
}) })
).data; ).data;
@@ -50,10 +42,9 @@ export function JobCalculateTotals({ bodyshop, job }) {
return ( return (
<div> <div>
<Button loading={loading} onClick={handleCalculate}> <Button loading={loading} onClick={handleCalculate} disabled={disabled}>
{t("jobs.actions.recalculate")} {t("jobs.actions.recalculate")}
</Button> </Button>
</div> </div>
); );
} }
export default connect(mapStateToProps, null)(JobCalculateTotals);

View File

@@ -0,0 +1,181 @@
import { useMutation } from "@apollo/react-hooks";
import { Button, Form, notification, Switch } from "antd";
import queryString from "query-string";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useHistory, useLocation, useParams } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { logImEXEvent } from "../../../../firebase/firebase.utils";
import { MARK_LATEST_APPOINTMENT_AS_ARRIVED } from "../../../../graphql/appointments.queries";
import { UPDATE_JOB } from "../../../../graphql/jobs.queries";
import {
selectBodyshop,
selectCurrentUser,
} from "../../../../redux/user/user.selectors";
import ConfigFormComponents from "../../../config-form-components/config-form-components.component";
import DateTimePicker from "../../../form-date-time-picker/form-date-time-picker.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
currentUser: selectCurrentUser,
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export function JobChecklistForm({
formItems,
bodyshop,
currentUser,
type,
job,
}) {
const { t } = useTranslation();
const [intakeJob] = useMutation(UPDATE_JOB);
const [loading, setLoading] = useState(false);
const [markAptArrived] = useMutation(MARK_LATEST_APPOINTMENT_AS_ARRIVED);
const { jobId } = useParams();
const history = useHistory();
const search = queryString.parse(useLocation().search);
const [form] = Form.useForm();
const handleFinish = async (values) => {
setLoading(true);
logImEXEvent("job_complete_intake");
const result = await intakeJob({
variables: {
jobId: jobId,
job: {
...(type === "intake" && { inproduction: values.addToProduction }),
status:
(type === "intake" && bodyshop.md_ro_statuses.default_arrived) ||
(type === "deliver" && bodyshop.md_ro_statuses.default_delivered),
...(type === "intake" && { actual_in: new Date() }),
...(type === "intake" && {
scheduled_completion: values.scheduled_completion,
}),
[(type === "intake" && "intakechecklist") ||
(type === "deliver" && "deliverchecklist")]: {
...values,
completed_by: currentUser.email,
completed_at: new Date(),
},
...(type === "deliver" && {
scheduled_delivery: values.scheduled_delivery,
}),
...(type === "deliver" &&
values.removeFromProduction && {
inproduction: false,
}),
},
},
});
if (!!search.appointmentId) {
const appUpdate = await markAptArrived({
variables: { appointmentId: search.appointmentId },
});
if (!!appUpdate.errors) {
notification["error"]({
message: t("checklist.errors.complete", {
error: JSON.stringify(result.errors),
}),
});
}
}
setLoading(false);
if (!!!result.errors) {
notification["success"]({ message: t("checklist.successes.completed") });
history.push(`/manage/jobs/${jobId}`);
} else {
notification["error"]({
message: t("checklist.errors.complete", {
error: JSON.stringify(result.errors),
}),
});
}
};
return (
<Form
form={form}
onFinish={handleFinish}
initialValues={{
...(type === "intake" && {
addToProduction: true,
scheduled_completion: job && job.scheduled_completion,
scheduled_delivery: job && job.scheduled_delivery,
}),
}}
>
{t("checklist.labels.checklist")}
<ConfigFormComponents componentList={formItems} />
{type === "intake" && (
<div>
<Form.Item
name="addToProduction"
valuePropName="checked"
label={t("checklist.labels.addtoproduction")}
>
<Switch />
</Form.Item>
<Form.Item
name="scheduled_completion"
label={t("jobs.fields.scheduled_completion")}
rules={[
{
required: true,
message: t("general.validation.required"),
},
]}
>
<DateTimePicker />
</Form.Item>
<Form.Item
name="scheduled_delivery"
label={t("jobs.fields.scheduled_delivery")}
>
<DateTimePicker />
</Form.Item>
</div>
)}
{type === "deliver" && (
<div>
<Form.Item
name="actualCompletion"
label={t("jobs.fields.actual_completion")}
rules={[
{
required: true,
message: t("general.validation.required"),
},
]}
>
<DateTimePicker />
</Form.Item>
<Form.Item
name="removeFromProduction"
valuePropName="checked"
label={t("checklist.labels.removefromproduction")}
>
<Switch />
</Form.Item>
</div>
)}
<Button loading={loading} htmlType="submit">
{t("general.actions.submit")}
</Button>
</Form>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(JobChecklistForm);

View File

@@ -0,0 +1,16 @@
import React from "react";
import { PrinterFilled } from "@ant-design/icons";
import { useTranslation } from "react-i18next";
export default function JobChecklistTemplateItem({
templateKey,
renderTemplate,
}) {
const { t } = useTranslation();
return (
<div>
{t(`printcenter.jobs.${templateKey}`)}
<PrinterFilled onClick={() => renderTemplate(templateKey)} />
</div>
);
}

View File

@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import JobIntakeTemplateItem from "../job-intake-template-item/job-intake-template-item.component"; import JobIntakeTemplateItem from "../job-checklist-template-item/job-checklist-template-item.component";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import RenderTemplate, { import RenderTemplate, {
displayTemplateInWindow, displayTemplateInWindow,
@@ -22,9 +22,8 @@ const mapDispatchToProps = (dispatch) => ({
export function JobIntakeTemplateList({ bodyshop, templates }) { export function JobIntakeTemplateList({ bodyshop, templates }) {
const { jobId } = useParams(); const { jobId } = useParams();
const { t } = useTranslation(); const { t } = useTranslation();
//TODO SHould this be using the generic one?
const renderTemplate = async (templateKey) => { const renderTemplate = async (templateKey) => {
logImEXEvent("job_intake_template_render"); logImEXEvent("job_checklist_template_render");
const html = await RenderTemplate( const html = await RenderTemplate(
{ {
@@ -37,7 +36,7 @@ export function JobIntakeTemplateList({ bodyshop, templates }) {
}; };
const renderAllTemplates = () => { const renderAllTemplates = () => {
logImEXEvent("job_intake_render_all_templates"); logImEXEvent("job_checklist_render_all_templates");
templates.forEach((template) => renderTemplate(template)); templates.forEach((template) => renderTemplate(template));
}; };
@@ -46,7 +45,7 @@ export function JobIntakeTemplateList({ bodyshop, templates }) {
<div> <div>
{t("intake.labels.printpack")} {t("intake.labels.printpack")}
<Button onClick={renderAllTemplates}> <Button onClick={renderAllTemplates}>
{t("intake.actions.printall")} {t("checklist.actions.printall")}
</Button> </Button>
{templates.map((template) => ( {templates.map((template) => (
<JobIntakeTemplateItem <JobIntakeTemplateItem

View File

@@ -0,0 +1,12 @@
import React from "react";
import ConfigFormComponents from "../config-form-components/config-form-components.component";
export default function JobChecklistDisplay({ checklist }) {
console.log("JobChecklistDisplay -> checklist", checklist);
if (!checklist) return <div></div>;
return (
<div>
<ConfigFormComponents readOnly componentList={checklist} />
</div>
);
}

View File

@@ -0,0 +1,18 @@
import React from "react";
import JobChecklistTemplateList from "./components/job-checklist-template-list/job-checklist-template-list.component";
import JobChecklistForm from "./components/job-checklist-form/job-checklist-form.component";
import { Row, Col } from "antd";
export default function JobIntakeComponent({ checklistConfig, type, job }) {
const { form, templates } = checklistConfig;
return (
<Row>
<Col span={12}>
<JobChecklistTemplateList templates={templates} type={type} />
</Col>
<Col span={12}>
<JobChecklistForm formItems={form} type={type} job={job} />
</Col>
</Row>
);
}

View File

@@ -51,28 +51,25 @@ export function JobCostingModalComponent({ bodyshop, job }) {
{ parts: {}, labor: {} } { parts: {}, labor: {} }
); );
const invoiceTotalsByProfitCenter = job.invoices.reduce( const billTotalsByProfitCenter = job.bills.reduce((bill_acc, bill_val) => {
(inv_acc, inv_val) => { //At the invoice level.
//At the invoice level. bill_val.billlines.map((line_val) => {
inv_val.invoicelines.map((line_val) => { //At the invoice line level.
//At the invoice line level. //console.log("JobCostingPartsTable -> line_val", line_val);
//console.log("JobCostingPartsTable -> line_val", line_val); if (!!!bill_acc[line_val.cost_center])
if (!!!inv_acc[line_val.cost_center]) bill_acc[line_val.cost_center] = Dinero();
inv_acc[line_val.cost_center] = Dinero();
inv_acc[line_val.cost_center] = inv_acc[line_val.cost_center].add( bill_acc[line_val.cost_center] = bill_acc[line_val.cost_center].add(
Dinero({ Dinero({
amount: Math.round((line_val.actual_cost || 0) * 100), amount: Math.round((line_val.actual_cost || 0) * 100),
}) })
.multiply(line_val.quantity) .multiply(line_val.quantity)
.multiply(inv_val.is_credit_memo ? -1 : 1) .multiply(bill_val.is_credit_memo ? -1 : 1)
); );
return null; return null;
}); });
return inv_acc; return bill_acc;
}, }, {});
{}
);
const ticketTotalsByProfitCenter = job.timetickets.reduce( const ticketTotalsByProfitCenter = job.timetickets.reduce(
(ticket_acc, ticket_val) => { (ticket_acc, ticket_val) => {
@@ -114,12 +111,11 @@ export function JobCostingModalComponent({ bodyshop, job }) {
const cost_labor = const cost_labor =
ticketTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 }); ticketTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 });
const cost_parts = const cost_parts = billTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 });
invoiceTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 });
const cost = ( const cost = (billTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 })).add(
invoiceTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 }) ticketTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 })
).add(ticketTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 })); );
const totalSales = sale_labor.add(sale_parts); const totalSales = sale_labor.add(sale_parts);
const gpdollars = totalSales.subtract(cost); const gpdollars = totalSales.subtract(cost);
const gppercent = ( const gppercent = (

View File

@@ -145,7 +145,7 @@ export function JobDetailCards({ setPrintCenterContext }) {
data={data ? data.jobs_by_pk : null} data={data ? data.jobs_by_pk : null}
/> />
</Col> </Col>
<Col span={24}> <Col {...colBreakPoints}>
<JobDetailCardsPartsComponent <JobDetailCardsPartsComponent
loading={loading} loading={loading}
data={data ? data.jobs_by_pk : null} data={data ? data.jobs_by_pk : null}
@@ -163,7 +163,7 @@ export function JobDetailCards({ setPrintCenterContext }) {
data={data ? data.jobs_by_pk : null} data={data ? data.jobs_by_pk : null}
/> />
</Col> </Col>
<Col span={24}> <Col {...colBreakPoints}>
<JobDetailCardsDamageComponent <JobDetailCardsDamageComponent
loading={loading} loading={loading}
data={data ? data.jobs_by_pk : null} data={data ? data.jobs_by_pk : null}

View File

@@ -1,141 +1,17 @@
import React, { useMemo, useState } from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Pie, PieChart, Sector } from "recharts"; import PartsStatusPie from "../parts-status-pie/parts-status-pie.component";
import CardTemplate from "./job-detail-cards.template.component"; import CardTemplate from "./job-detail-cards.template.component";
export default function JobDetailCardsPartsComponent({ loading, data }) { export default function JobDetailCardsPartsComponent({ loading, data }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { joblines_status } = data; const { joblines_status } = data;
// console.log(
// "JobDetailCardsPartsComponent -> joblines_stats",
// joblines_status
// );
const memoizedData = useMemo(() => Calculatedata(joblines_status), [
joblines_status,
]);
const [state, setState] = useState({ activeIndex: 0 });
const onPieEnter = (data, index) => {
setState({
activeIndex: index,
});
};
return ( return (
<div> <div>
<CardTemplate loading={loading} title={t("jobs.labels.cards.parts")}> <CardTemplate loading={loading} title={t("jobs.labels.cards.parts")}>
<PieChart width={400} height={400}> <PartsStatusPie joblines_status={joblines_status} />
<Pie
activeIndex={state.activeIndex}
activeShape={renderActiveShape}
data={memoizedData}
cx={200}
cy={200}
innerRadius={60}
outerRadius={80}
fill="#8884d8"
dataKey="value"
onMouseEnter={onPieEnter}
/>
</PieChart>
</CardTemplate> </CardTemplate>
</div> </div>
); );
} }
const Calculatedata = (data) => {
if (data.length > 0) {
const statusMapping = {};
data.map((i) => {
if (!statusMapping[i.status])
statusMapping[i.status] = { name: i.status || "No Status*", value: 0 };
statusMapping[i.status].value = statusMapping[i.status].value + i.count;
return null;
});
return Object.keys(statusMapping).map((key) => {
return statusMapping[key];
});
} else {
return [
{ name: "Group A", value: 400 },
{ name: "Group B", value: 300 },
{ name: "Group C", value: 300 },
{ name: "Group D", value: 200 },
];
}
};
const renderActiveShape = (props) => {
const RADIAN = Math.PI / 180;
const {
cx,
cy,
midAngle,
innerRadius,
outerRadius,
startAngle,
endAngle,
fill,
payload,
percent,
value,
} = props;
const sin = Math.sin(-RADIAN * midAngle);
const cos = Math.cos(-RADIAN * midAngle);
const sx = cx + (outerRadius + 10) * cos;
const sy = cy + (outerRadius + 10) * sin;
const mx = cx + (outerRadius + 30) * cos;
const my = cy + (outerRadius + 30) * sin;
const ex = mx + (cos >= 0 ? 1 : -1) * 22;
const ey = my;
const textAnchor = cos >= 0 ? "start" : "end";
return (
<g>
<text x={cx} y={cy} dy={8} textAnchor="middle" fill={fill}>
{payload.name}
</text>
<Sector
cx={cx}
cy={cy}
innerRadius={innerRadius}
outerRadius={outerRadius}
startAngle={startAngle}
endAngle={endAngle}
fill={fill}
/>
<Sector
cx={cx}
cy={cy}
startAngle={startAngle}
endAngle={endAngle}
innerRadius={outerRadius + 6}
outerRadius={outerRadius + 10}
fill={fill}
/>
<path
d={`M${sx},${sy}L${mx},${my}L${ex},${ey}`}
stroke={fill}
fill="none"
/>
<circle cx={ex} cy={ey} r={2} fill={fill} stroke="none" />
<text
x={ex + (cos >= 0 ? 1 : -1) * 12}
y={ey}
textAnchor={textAnchor}
fill="#333"
>{`Count: ${value}`}</text>
<text
x={ex + (cos >= 0 ? 1 : -1) * 12}
y={ey}
dy={18}
textAnchor={textAnchor}
fill="#999"
>
{`(${(percent * 100).toFixed(2)}%)`}
</text>
</g>
);
};

View File

@@ -6,22 +6,15 @@ import CardTemplate from "./job-detail-cards.template.component";
export default function JobDetailCardsTotalsComponent({ loading, data }) { export default function JobDetailCardsTotalsComponent({ loading, data }) {
const { t } = useTranslation(); const { t } = useTranslation();
let totals;
try {
totals = JSON.parse(data.job_totals);
} catch (error) {
console.log("Error in CardsTotal component", error);
}
return ( return (
<CardTemplate loading={loading} title={t("jobs.labels.cards.totals")}> <CardTemplate loading={loading} title={t("jobs.labels.cards.totals")}>
{totals ? ( {data.job_totals ? (
<div className="imex-flex-row imex-flex-row__flex-space-around"> <div className="imex-flex-row imex-flex-row__flex-space-around">
<Statistic <Statistic
className="imex-flex-row__margin-large" className="imex-flex-row__margin-large"
title={t("jobs.labels.total_repairs")} title={t("jobs.labels.total_repairs")}
value={Dinero(totals.totals.total_repairs).toFormat()} value={Dinero(data.job_totals.totals.total_repairs).toFormat()}
/> />
<Statistic <Statistic
className="imex-flex-row__margin-large" className="imex-flex-row__margin-large"
@@ -33,7 +26,7 @@ export default function JobDetailCardsTotalsComponent({ loading, data }) {
<Statistic <Statistic
className="imex-flex-row__margin-large" className="imex-flex-row__margin-large"
title={t("jobs.labels.net_repairs")} title={t("jobs.labels.net_repairs")}
value={Dinero(totals.totals.net_repairs).toFormat()} value={Dinero(data.job_totals.totals.net_repairs).toFormat()}
/> />
</div> </div>
) : ( ) : (

View File

@@ -15,6 +15,13 @@ import JobLineNotePopup from "../job-line-note-popup/job-line-note-popup.compone
// import AllocationsEmployeeLabelContainer from "../allocations-employee-label/allocations-employee-label.container"; // import AllocationsEmployeeLabelContainer from "../allocations-employee-label/allocations-employee-label.container";
import PartsOrderModalContainer from "../parts-order-modal/parts-order-modal.container"; import PartsOrderModalContainer from "../parts-order-modal/parts-order-modal.container";
import { createStructuredSelector } from "reselect";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
jobRO: selectJobReadOnly,
});
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setJobLineEditContext: (context) => setJobLineEditContext: (context) =>
dispatch(setModalContext({ context: context, modal: "jobLineEdit" })), dispatch(setModalContext({ context: context, modal: "jobLineEdit" })),
@@ -23,6 +30,7 @@ const mapDispatchToProps = (dispatch) => ({
}); });
export function JobLinesComponent({ export function JobLinesComponent({
jobRO,
setPartsOrderContext, setPartsOrderContext,
loading, loading,
refetch, refetch,
@@ -30,8 +38,9 @@ export function JobLinesComponent({
setSearchText, setSearchText,
selectedLines, selectedLines,
setSelectedLines, setSelectedLines,
jobId, job,
setJobLineEditContext, setJobLineEditContext,
form,
}) { }) {
const [state, setState] = useState({ const [state, setState] = useState({
sortedInfo: {}, sortedInfo: {},
@@ -139,20 +148,20 @@ export function JobLinesComponent({
dataIndex: "part_qty", dataIndex: "part_qty",
key: "part_qty", key: "part_qty",
}, },
{ // {
title: t("joblines.fields.total"), // title: t("joblines.fields.total"),
dataIndex: "total", // dataIndex: "total",
key: "total", // key: "total",
sorter: (a, b) => a.act_price * a.part_qty - b.act_price * b.part_qty, // sorter: (a, b) => a.act_price * a.part_qty - b.act_price * b.part_qty,
sortOrder: // sortOrder:
state.sortedInfo.columnKey === "total" && state.sortedInfo.order, // state.sortedInfo.columnKey === "total" && state.sortedInfo.order,
ellipsis: true, // ellipsis: true,
render: (text, record) => ( // render: (text, record) => (
<CurrencyFormatter> // <CurrencyFormatter>
{record.act_price * record.part_qty} // {record.act_price * record.part_qty}
</CurrencyFormatter> // </CurrencyFormatter>
), // ),
}, // },
{ {
title: t("joblines.fields.mod_lbr_ty"), title: t("joblines.fields.mod_lbr_ty"),
dataIndex: "mod_lbr_ty", dataIndex: "mod_lbr_ty",
@@ -179,13 +188,17 @@ export function JobLinesComponent({
title: t("joblines.fields.notes"), title: t("joblines.fields.notes"),
dataIndex: "notes", dataIndex: "notes",
key: "notes", key: "notes",
render: (text, record) => <JobLineNotePopup jobline={record} />, render: (text, record) => (
<JobLineNotePopup disabled={jobRO} jobline={record} />
),
}, },
{ {
title: t("joblines.fields.location"), title: t("joblines.fields.location"),
dataIndex: "location", dataIndex: "location",
key: "location", key: "location",
render: (text, record) => <JobLineLocationPopup jobline={record} />, render: (text, record) => (
<JobLineLocationPopup jobline={record} disabled={jobRO} />
),
}, },
{ {
title: t("joblines.fields.status"), title: t("joblines.fields.status"),
@@ -244,9 +257,10 @@ export function JobLinesComponent({
render: (text, record) => ( render: (text, record) => (
<div> <div>
<Button <Button
disabled={jobRO}
onClick={() => { onClick={() => {
setJobLineEditContext({ setJobLineEditContext({
actions: { refetch: refetch }, actions: { refetch: refetch, submit: form && form.submit },
context: record, context: record,
}); });
}} }}
@@ -308,12 +322,16 @@ export function JobLinesComponent({
<SyncOutlined /> <SyncOutlined />
</Button> </Button>
<Button <Button
disabled={selectedLines.length > 0 ? false : true} disabled={
!job.converted ||
(selectedLines.length > 0 ? false : true) ||
jobRO
}
onClick={() => { onClick={() => {
setPartsOrderContext({ setPartsOrderContext({
actions: { refetch: refetch }, actions: { refetch: refetch },
context: { context: {
jobId: jobId, jobId: job.id,
linesToOrder: selectedLines, linesToOrder: selectedLines,
}, },
}); });
@@ -343,10 +361,11 @@ export function JobLinesComponent({
// /> // />
} }
<Button <Button
disabled={jobRO}
onClick={() => { onClick={() => {
setJobLineEditContext({ setJobLineEditContext({
actions: { refetch: refetch }, actions: { refetch: refetch },
context: { jobid: jobId }, context: { jobid: job.id },
}); });
}} }}
> >
@@ -370,7 +389,7 @@ export function JobLinesComponent({
{record.parts_order_lines.map((item) => ( {record.parts_order_lines.map((item) => (
<div key={item.id}> <div key={item.id}>
<Link <Link
to={`/manage/jobs/${jobId}?tab=partssublet&partsorderid=${item.parts_order.id}`} to={`/manage/jobs/${job.id}?tab=partssublet&partsorderid=${item.parts_order.id}`}
> >
{item.parts_order.order_number || ""} {item.parts_order.order_number || ""}
</Link> </Link>
@@ -396,4 +415,4 @@ export function JobLinesComponent({
</div> </div>
); );
} }
export default connect(null, mapDispatchToProps)(JobLinesComponent); export default connect(mapStateToProps, mapDispatchToProps)(JobLinesComponent);

View File

@@ -1,7 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import JobLinesComponent from "./job-lines.component"; import JobLinesComponent from "./job-lines.component";
function JobLinesContainer({ jobId, joblines, refetch }) { function JobLinesContainer({ job, joblines, refetch, form }) {
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [selectedLines, setSelectedLines] = useState([]); const [selectedLines, setSelectedLines] = useState([]);
@@ -38,7 +38,8 @@ function JobLinesContainer({ jobId, joblines, refetch }) {
setSearchText={setSearchText} setSearchText={setSearchText}
selectedLines={selectedLines} selectedLines={selectedLines}
setSelectedLines={setSelectedLines} setSelectedLines={setSelectedLines}
jobId={jobId} job={job}
form={form}
/> />
); );
} }

View File

@@ -6,8 +6,10 @@ import { Select, Button, Popover } from "antd";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
jobRO: selectJobReadOnly,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
@@ -15,6 +17,7 @@ const mapDispatchToProps = (dispatch) => ({
export function JobEmployeeAssignments({ export function JobEmployeeAssignments({
bodyshop, bodyshop,
jobRO,
body, body,
refinish, refinish,
prep, prep,
@@ -52,7 +55,7 @@ export function JobEmployeeAssignments({
</Select> </Select>
<Button <Button
type="primary" type="primary"
disabled={!assignment.employeeid} disabled={!assignment.employeeid || jobRO}
onClick={() => { onClick={() => {
handleAdd(assignment); handleAdd(assignment);
setVisibility(false); setVisibility(false);
@@ -79,14 +82,18 @@ export function JobEmployeeAssignments({
}`}</span> }`}</span>
<MinusOutlined <MinusOutlined
operation="body" operation="body"
onClick={() => handleRemove("body")} disabled={jobRO}
onClick={() => !jobRO && handleRemove("body")}
/> />
</div> </div>
) : ( ) : (
<PlusCircleFilled <PlusCircleFilled
disabled={jobRO}
onClick={() => { onClick={() => {
setAssignment({ operation: "body" }); if (!jobRO) {
setVisibility(true); setAssignment({ operation: "body" });
setVisibility(true);
}
}} }}
/> />
)} )}
@@ -101,15 +108,19 @@ export function JobEmployeeAssignments({
prep.last_name || "" prep.last_name || ""
}`}</span> }`}</span>
<MinusOutlined <MinusOutlined
disabled={jobRO}
operation="prep" operation="prep"
onClick={() => handleRemove("prep")} onClick={() => !jobRO && handleRemove("prep")}
/> />
</div> </div>
) : ( ) : (
<PlusCircleFilled <PlusCircleFilled
disabled={jobRO}
onClick={() => { onClick={() => {
setAssignment({ operation: "prep" }); if (!jobRO) {
setVisibility(true); setAssignment({ operation: "prep" });
setVisibility(true);
}
}} }}
/> />
)} )}
@@ -124,15 +135,19 @@ export function JobEmployeeAssignments({
refinish.last_name || "" refinish.last_name || ""
}`}</span> }`}</span>
<MinusOutlined <MinusOutlined
disabled={jobRO}
operation="refinish" operation="refinish"
onClick={() => handleRemove("refinish")} onClick={() => !jobRO && handleRemove("refinish")}
/> />
</div> </div>
) : ( ) : (
<PlusCircleFilled <PlusCircleFilled
disabled={jobRO}
onClick={() => { onClick={() => {
setAssignment({ operation: "refinish" }); if (!jobRO) {
setVisibility(true); setAssignment({ operation: "refinish" });
setVisibility(true);
}
}} }}
/> />
)} )}

View File

@@ -1,120 +0,0 @@
import { useMutation } from "@apollo/react-hooks";
import { Button, Form, notification, Switch } from "antd";
import queryString from "query-string";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useHistory, useLocation, useParams } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { MARK_LATEST_APPOINTMENT_AS_ARRIVED } from "../../../../graphql/appointments.queries";
import { UPDATE_JOB } from "../../../../graphql/jobs.queries";
import { selectBodyshop } from "../../../../redux/user/user.selectors";
import DateTimePicker from "../../../form-date-time-picker/form-date-time-picker.component";
import ConfigFormComponents from "../../../config-form-components/config-form-components.component";
import { logImEXEvent } from "../../../../firebase/firebase.utils";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export function JobIntakeForm({ formItems, bodyshop }) {
const { t } = useTranslation();
const [intakeJob] = useMutation(UPDATE_JOB);
const [loading, setLoading] = useState(false);
const [markAptArrived] = useMutation(MARK_LATEST_APPOINTMENT_AS_ARRIVED);
const { jobId } = useParams();
const history = useHistory();
const search = queryString.parse(useLocation().search);
const handleFinish = async (values) => {
setLoading(true);
logImEXEvent("job_complete_intake");
const result = await intakeJob({
variables: {
jobId: jobId,
job: {
inproduction: values.addToProduction || false,
status: bodyshop.md_ro_statuses.default_arrived || "Arrived*",
actual_in: new Date(),
scheduled_completion: values.scheduledCompletion,
intakechecklist: values,
scheduled_delivery: values.scheduledDelivery,
},
},
});
if (!!search.appointmentId) {
const appUpdate = await markAptArrived({
variables: { appointmentId: search.appointmentId },
});
if (!!appUpdate.errors) {
notification["error"]({
message: t("intake.errors.intake", {
error: JSON.stringify(result.errors),
}),
});
}
}
if (!!!result.errors) {
notification["success"]({ message: t("intake.successes.intake") });
history.push(`/manage/jobs/${jobId}`);
} else {
notification["error"]({
message: t("intake.errors.intake", {
error: JSON.stringify(result.errors),
}),
});
}
setLoading(false);
};
const [form] = Form.useForm();
return (
<Form
form={form}
onFinish={handleFinish}
initialValues={{ addToProduction: true }}
>
{t("intake.labels.checklist")}
<ConfigFormComponents componentList={formItems} />
<Form.Item
name="addToProduction"
valuePropName="checked"
label={t("intake.labels.addtoproduction")}
>
<Switch />
</Form.Item>
<Form.Item
name="scheduledCompletion"
label={t("jobs.fields.scheduled_completion")}
rules={[
{
required: true,
message: t("general.validation.required"),
},
]}
>
<DateTimePicker />
</Form.Item>
<Form.Item
name="scheduledDelivery"
label={t("jobs.fields.scheduled_delivery")}
>
<DateTimePicker />
</Form.Item>
<Button loading={loading} htmlType="submit">
{t("general.actions.submit")}
</Button>
</Form>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(JobIntakeForm);

View File

@@ -1,10 +0,0 @@
import React from "react";
import { PrinterFilled } from "@ant-design/icons";
export default function JobIntakeTemplateItem({ templateKey, renderTemplate }) {
return (
<div>
{templateKey}
<PrinterFilled onClick={() => renderTemplate(templateKey)} />
</div>
);
}

View File

@@ -1,18 +0,0 @@
import React from "react";
import JobIntakeTemplateList from "./components/job-intake-template-list/job-intake-template-list.component";
import JobIntakeForm from "./components/job-intake-form/job-intake-form.component";
import { Row, Col } from "antd";
export default function JobIntakeComponent({ intakeChecklistConfig }) {
const { form, templates } = intakeChecklistConfig;
return (
<Row>
<Col span={12}>
<JobIntakeTemplateList templates={templates} />
</Col>
<Col span={12}>
<JobIntakeForm formItems={form} />
</Col>
</Row>
);
}

View File

@@ -16,7 +16,7 @@ const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
export function JobLineLocationPopup({ bodyshop, jobline }) { export function JobLineLocationPopup({ bodyshop, jobline, disabled }) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [location, setLocation] = useState(jobline.location); const [location, setLocation] = useState(jobline.location);
@@ -73,7 +73,7 @@ export function JobLineLocationPopup({ bodyshop, jobline }) {
return ( return (
<div <div
style={{ width: "100%", minHeight: "2rem", cursor: "pointer" }} style={{ width: "100%", minHeight: "2rem", cursor: "pointer" }}
onClick={() => setEditing(true)} onClick={() => !disabled && setEditing(true)}
> >
{jobline.location} {jobline.location}
</div> </div>

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