Initial commit
33
frontend/.docker/add-env-vars.sh
Normal file
@@ -0,0 +1,33 @@
|
||||
_writeFrontendEnvVars() {
|
||||
ENV_JSON="$(jq --compact-output --null-input 'env | with_entries(select(.key | startswith("REACT_APP_")))')"
|
||||
ENV_JSON_ESCAPED="$(printf "%s" "${ENV_JSON}" | sed -e 's/[\&/]/\\&/g')"
|
||||
sed -i "s/<noscript id=\"env-insertion-point\"><\/noscript>/<script>var ENV=${ENV_JSON_ESCAPED}<\/script>/g" ${PUBLIC_HTML}index.html
|
||||
}
|
||||
|
||||
_writeNginxEnvVars() {
|
||||
dockerize -template /etc/nginx/conf.d/default.conf:/etc/nginx/conf.d/default.conf
|
||||
}
|
||||
|
||||
_addSslConfig() {
|
||||
SSL_CERTIFICATE=/etc/nginx/ssl/${1}/fullchain.pem;
|
||||
SSL_CERTIFICATE_KEY=/etc/nginx/ssl/${1}/privkey.pem;
|
||||
FILE_CONF=/etc/nginx/sites.d/${1}.conf
|
||||
FILE_SSL_CONF=/etc/nginx/conf.d/00-ssl-redirect.conf;
|
||||
|
||||
if [ -f ${SSL_CERTIFICATE} ] && [ -f ${SSL_CERTIFICATE_KEY} ]; then
|
||||
echo "saving ssl config in ${FILE_CONF}"
|
||||
echo 'include include.d/ssl-redirect.conf;' >> ${FILE_SSL_CONF};
|
||||
echo 'include "include.d/ssl.conf";' >> ${FILE_CONF};
|
||||
echo "ssl_certificate ${SSL_CERTIFICATE};" >> ${FILE_CONF};
|
||||
echo "ssl_certificate_key ${SSL_CERTIFICATE_KEY};" >> ${FILE_CONF};
|
||||
else
|
||||
echo 'listen 80;' >> ${FILE_CONF};
|
||||
echo "ssl ${1} not found >> ${SSL_CERTIFICATE} -> ${SSL_CERTIFICATE_KEY}"
|
||||
fi;
|
||||
}
|
||||
|
||||
_writeFrontendEnvVars;
|
||||
_writeNginxEnvVars;
|
||||
|
||||
_addSslConfig 'backend'
|
||||
_addSslConfig 'frontend'
|
||||
27
frontend/.docker/nginx/conf.d/default.conf
Normal file
@@ -0,0 +1,27 @@
|
||||
client_max_body_size 20M;
|
||||
|
||||
upstream backend {
|
||||
server {{ .Env.URL_BACKEND }};
|
||||
}
|
||||
|
||||
server {
|
||||
index index.html;
|
||||
root /var/www/public/;
|
||||
|
||||
{{ if .Env.FRONTEND_SERVER_NAME }}
|
||||
server_name {{ .Env.FRONTEND_SERVER_NAME }};
|
||||
{{else}}
|
||||
server_name _;
|
||||
{{end}}
|
||||
|
||||
include sites.d/frontend.conf;
|
||||
include include.d/letsencrypt.conf;
|
||||
}
|
||||
|
||||
{{if .Env.BACKEND_SERVER_NAME}}
|
||||
server {
|
||||
server_name {{ .Env.BACKEND_SERVER_NAME }};
|
||||
include sites.d/backend.conf;
|
||||
include include.d/letsencrypt.conf;
|
||||
}
|
||||
{{end}}
|
||||
3
frontend/.docker/nginx/include.d/allcache.conf
Normal file
@@ -0,0 +1,3 @@
|
||||
expires 1y;
|
||||
add_header Cache-Control "public";
|
||||
access_log off;
|
||||
45
frontend/.docker/nginx/include.d/letsencrypt.conf
Normal file
@@ -0,0 +1,45 @@
|
||||
#############################################################################
|
||||
# Configuration file for Let's Encrypt ACME Challenge location
|
||||
# This file is already included in listen_xxx.conf files.
|
||||
# Do NOT include it separately!
|
||||
#############################################################################
|
||||
#
|
||||
# This config enables to access /.well-known/acme-challenge/xxxxxxxxxxx
|
||||
# on all our sites (HTTP), including all subdomains.
|
||||
# This is required by ACME Challenge (webroot authentication).
|
||||
# You can check that this location is working by placing ping.txt here:
|
||||
# /var/www/letsencrypt/.well-known/acme-challenge/ping.txt
|
||||
# And pointing your browser to:
|
||||
# http://xxx.domain.tld/.well-known/acme-challenge/ping.txt
|
||||
#
|
||||
# Sources:
|
||||
# https://community.letsencrypt.org/t/howto-easy-cert-generation-and-renewal-with-nginx/3491
|
||||
#
|
||||
#############################################################################
|
||||
|
||||
# Rule for legitimate ACME Challenge requests (like /.well-known/acme-challenge/xxxxxxxxx)
|
||||
# We use ^~ here, so that we don't check other regexes (for speed-up). We actually MUST cancel
|
||||
# other regex checks, because in our other config files have regex rule that denies access to files with dotted names.
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
|
||||
# Set correct content type. According to this:
|
||||
# https://community.letsencrypt.org/t/using-the-webroot-domain-verification-method/1445/29
|
||||
# Current specification requires "text/plain" or no content header at all.
|
||||
# It seems that "text/plain" is a safe option.
|
||||
default_type "text/plain";
|
||||
|
||||
# This directory must be the same as in /etc/letsencrypt/cli.ini
|
||||
# as "webroot-path" parameter. Also don't forget to set "authenticator" parameter
|
||||
# there to "webroot".
|
||||
# Do NOT use alias, use root! Target directory is located here:
|
||||
# /var/www/common/letsencrypt/.well-known/acme-challenge/
|
||||
root /var/www/letsencrypt;
|
||||
autoindex on;
|
||||
}
|
||||
|
||||
# Hide /acme-challenge subdirectory and return 404 on all requests.
|
||||
# It is somewhat more secure than letting Nginx return 403.
|
||||
# Ending slash is important!
|
||||
location = /.well-known/acme-challenge/ {
|
||||
return 404;
|
||||
}
|
||||
5
frontend/.docker/nginx/include.d/nocache.conf
Normal file
@@ -0,0 +1,5 @@
|
||||
add_header Last-Modified $date_gmt;
|
||||
add_header Cache-Control 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
|
||||
if_modified_since off;
|
||||
expires off;
|
||||
etag off;
|
||||
16
frontend/.docker/nginx/include.d/spa.conf
Normal file
@@ -0,0 +1,16 @@
|
||||
# X-Frame-Options is to prevent from clickJacking attack
|
||||
add_header X-Frame-Options SAMEORIGIN;
|
||||
|
||||
# disable content-type sniffing on some browsers.
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
# This header enables the Cross-site scripting (XSS) filter
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
|
||||
# This will enforce HTTP browsing into HTTPS and avoid ssl stripping attack
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubdomains;";
|
||||
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade";
|
||||
|
||||
# Enables response header of "Vary: Accept-Encoding"
|
||||
gzip_vary on;
|
||||
5
frontend/.docker/nginx/include.d/ssl-redirect.conf
Normal file
@@ -0,0 +1,5 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
return 302 https://$host$request_uri;
|
||||
}
|
||||
2
frontend/.docker/nginx/include.d/ssl.conf
Normal file
@@ -0,0 +1,2 @@
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
11
frontend/.docker/nginx/sites.d/backend.conf
Normal file
@@ -0,0 +1,11 @@
|
||||
location / {
|
||||
proxy_pass http://backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
11
frontend/.docker/nginx/sites.d/frontend.conf
Normal file
@@ -0,0 +1,11 @@
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
include include.d/nocache.conf;
|
||||
}
|
||||
|
||||
location /static {
|
||||
alias /var/www/public/static/;
|
||||
include include.d/allcache.conf;
|
||||
}
|
||||
|
||||
include "include.d/spa.conf";
|
||||
5
frontend/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
.git
|
||||
*Dockerfile*
|
||||
*docker-compose*
|
||||
node_modules
|
||||
build
|
||||
28
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
25
frontend/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM node:14-alpine as build-deps
|
||||
WORKDIR /usr/src/app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY .env* ./
|
||||
COPY src/ ./src/
|
||||
COPY public/ ./public/
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
RUN apk add --no-cache jq openssl
|
||||
|
||||
ENV DOCKERIZE_VERSION v0.6.1
|
||||
RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
|
||||
&& tar -C /usr/local/bin -xzvf dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
|
||||
&& rm dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz
|
||||
|
||||
ENV PUBLIC_HTML=/var/www/public/
|
||||
|
||||
COPY .docker/nginx /etc/nginx/
|
||||
COPY --from=build-deps /usr/src/app/build ${PUBLIC_HTML}
|
||||
EXPOSE 80
|
||||
|
||||
COPY .docker/add-env-vars.sh /docker-entrypoint.d/01-add-env-vars.sh
|
||||
RUN chmod +x /docker-entrypoint.d/01-add-env-vars.sh
|
||||
55
frontend/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "^4.0.0-alpha.56",
|
||||
"@testing-library/jest-dom": "^5.11.4",
|
||||
"@testing-library/react": "^11.0.4",
|
||||
"@testing-library/user-event": "^12.1.7",
|
||||
"axios": "^0.21.1",
|
||||
"date-fns": "^2.16.1",
|
||||
"emoji-mart": "^3.0.1",
|
||||
"formik": "^2.2.0",
|
||||
"i18next": "^19.8.2",
|
||||
"i18next-browser-languagedetector": "^6.0.1",
|
||||
"markdown-to-jsx": "^7.1.0",
|
||||
"mic-recorder-to-mp3": "^2.2.2",
|
||||
"qrcode.react": "^1.0.0",
|
||||
"react": "^16.13.1",
|
||||
"react-color": "^2.19.3",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-modal-image": "^2.5.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-scripts": "3.4.3",
|
||||
"react-toastify": "^6.0.9",
|
||||
"recharts": "^2.0.2",
|
||||
"socket.io-client": "^3.0.5",
|
||||
"use-sound": "^2.0.1",
|
||||
"yup": "^0.32.8"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts --openssl-legacy-provider start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
BIN
frontend/public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
frontend/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 638 B |
BIN
frontend/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
24
frontend/public/index.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>WhaTicket</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/apple-touch-icon.png" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<link rel=”shortcut icon” href=”%PUBLIC_URL%/favicon.ico”>
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="minimum-scale=1, initial-scale=1, width=device-width"
|
||||
/>
|
||||
<noscript id="env-insertion-point"></noscript>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
20
frontend/public/manifest.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"short_name": "WhaTicket",
|
||||
"name": "WhaTicket",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
BIN
frontend/public/mstile-150x150.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
9
frontend/server.js
Normal file
@@ -0,0 +1,9 @@
|
||||
//simple express server to run frontend production build;
|
||||
const express = require("express");
|
||||
const path = require("path");
|
||||
const app = express();
|
||||
app.use(express.static(path.join(__dirname, "build")));
|
||||
app.get("/*", function (req, res) {
|
||||
res.sendFile(path.join(__dirname, "build", "index.html"));
|
||||
});
|
||||
app.listen(3333);
|
||||
47
frontend/src/App.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Routes from "./routes";
|
||||
import "react-toastify/dist/ReactToastify.css";
|
||||
|
||||
import { createTheme, ThemeProvider } from "@material-ui/core/styles";
|
||||
import { ptBR } from "@material-ui/core/locale";
|
||||
|
||||
const App = () => {
|
||||
const [locale, setLocale] = useState();
|
||||
|
||||
const theme = createTheme(
|
||||
{
|
||||
scrollbarStyles: {
|
||||
"&::-webkit-scrollbar": {
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
},
|
||||
"&::-webkit-scrollbar-thumb": {
|
||||
boxShadow: "inset 0 0 6px rgba(0, 0, 0, 0.3)",
|
||||
backgroundColor: "#e8e8e8",
|
||||
},
|
||||
},
|
||||
palette: {
|
||||
primary: { main: "#2576d2" },
|
||||
},
|
||||
},
|
||||
locale
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const i18nlocale = localStorage.getItem("i18nextLng");
|
||||
const browserLocale =
|
||||
i18nlocale.substring(0, 2) + i18nlocale.substring(3, 5);
|
||||
|
||||
if (browserLocale === "ptBR") {
|
||||
setLocale(ptBR);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<Routes />
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
BIN
frontend/src/assets/sound.mp3
Normal file
BIN
frontend/src/assets/sound.ogg
Normal file
BIN
frontend/src/assets/wa-background.png
Normal file
|
After Width: | Height: | Size: 682 KiB |
62
frontend/src/components/Audio/index.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Button } from "@material-ui/core";
|
||||
import React, { useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
const LS_NAME = 'audioMessageRate';
|
||||
|
||||
export default function({url}) {
|
||||
const audioRef = useRef(null);
|
||||
const [audioRate, setAudioRate] = useState( parseFloat(localStorage.getItem(LS_NAME) || "1") );
|
||||
const [showButtonRate, setShowButtonRate] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
audioRef.current.playbackRate = audioRate;
|
||||
localStorage.setItem(LS_NAME, audioRate);
|
||||
}, [audioRate]);
|
||||
|
||||
useEffect(() => {
|
||||
audioRef.current.onplaying = () => {
|
||||
setShowButtonRate(true);
|
||||
};
|
||||
audioRef.current.onpause = () => {
|
||||
setShowButtonRate(false);
|
||||
};
|
||||
audioRef.current.onended = () => {
|
||||
setShowButtonRate(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toogleRate = () => {
|
||||
let newRate = null;
|
||||
|
||||
switch(audioRate) {
|
||||
case 0.5:
|
||||
newRate = 1;
|
||||
break;
|
||||
case 1:
|
||||
newRate = 1.5;
|
||||
break;
|
||||
case 1.5:
|
||||
newRate = 2;
|
||||
break;
|
||||
case 2:
|
||||
newRate = 0.5;
|
||||
break;
|
||||
default:
|
||||
newRate = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
setAudioRate(newRate);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<audio ref={audioRef} controls>
|
||||
<source src={url} type="audio/ogg"></source>
|
||||
</audio>
|
||||
{showButtonRate && <Button style={{marginLeft: "5px", marginTop: "-45px"}} onClick={toogleRate}>{audioRate}x</Button>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/BackdropLoading/index.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
|
||||
import Backdrop from "@material-ui/core/Backdrop";
|
||||
import CircularProgress from "@material-ui/core/CircularProgress";
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
backdrop: {
|
||||
zIndex: theme.zIndex.drawer + 1,
|
||||
color: "#fff",
|
||||
},
|
||||
}));
|
||||
|
||||
const BackdropLoading = () => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Backdrop className={classes.backdrop} open={true}>
|
||||
<CircularProgress color="inherit" />
|
||||
</Backdrop>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackdropLoading;
|
||||
35
frontend/src/components/ButtonWithSpinner/index.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import { green } from "@material-ui/core/colors";
|
||||
import { CircularProgress, Button } from "@material-ui/core";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
button: {
|
||||
position: "relative",
|
||||
},
|
||||
|
||||
buttonProgress: {
|
||||
color: green[500],
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
marginTop: -12,
|
||||
marginLeft: -12,
|
||||
},
|
||||
}));
|
||||
|
||||
const ButtonWithSpinner = ({ loading, children, ...rest }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Button className={classes.button} disabled={loading} {...rest}>
|
||||
{children}
|
||||
{loading && (
|
||||
<CircularProgress size={24} className={classes.buttonProgress} />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ButtonWithSpinner;
|
||||
39
frontend/src/components/Can/index.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import rules from "../../rules";
|
||||
|
||||
const check = (role, action, data) => {
|
||||
const permissions = rules[role];
|
||||
if (!permissions) {
|
||||
// role is not present in the rules
|
||||
return false;
|
||||
}
|
||||
|
||||
const staticPermissions = permissions.static;
|
||||
|
||||
if (staticPermissions && staticPermissions.includes(action)) {
|
||||
// static rule not provided for action
|
||||
return true;
|
||||
}
|
||||
|
||||
const dynamicPermissions = permissions.dynamic;
|
||||
|
||||
if (dynamicPermissions) {
|
||||
const permissionCondition = dynamicPermissions[action];
|
||||
if (!permissionCondition) {
|
||||
// dynamic rule not provided for action
|
||||
return false;
|
||||
}
|
||||
|
||||
return permissionCondition(data);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const Can = ({ role, perform, data, yes, no }) =>
|
||||
check(role, perform, data) ? yes() : no();
|
||||
|
||||
Can.defaultProps = {
|
||||
yes: () => null,
|
||||
no: () => null,
|
||||
};
|
||||
|
||||
export { Can };
|
||||
87
frontend/src/components/ColorPicker/index.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Dialog } from "@material-ui/core";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { GithubPicker } from "react-color";
|
||||
|
||||
const ColorPicker = ({ onChange, currentColor, handleClose, open }) => {
|
||||
const [selectedColor, setSelectedColor] = useState(currentColor);
|
||||
const colors = [
|
||||
"#B80000",
|
||||
"#DB3E00",
|
||||
"#FCCB00",
|
||||
"#008B02",
|
||||
"#006B76",
|
||||
"#1273DE",
|
||||
"#004DCF",
|
||||
"#5300EB",
|
||||
"#EB9694",
|
||||
"#FAD0C3",
|
||||
"#FEF3BD",
|
||||
"#C1E1C5",
|
||||
"#BEDADC",
|
||||
"#C4DEF6",
|
||||
"#BED3F3",
|
||||
"#D4C4FB",
|
||||
"#4D4D4D",
|
||||
"#999999",
|
||||
"#FFFFFF",
|
||||
"#F44E3B",
|
||||
"#FE9200",
|
||||
"#FCDC00",
|
||||
"#DBDF00",
|
||||
"#A4DD00",
|
||||
"#68CCCA",
|
||||
"#73D8FF",
|
||||
"#AEA1FF",
|
||||
"#FDA1FF",
|
||||
"#333333",
|
||||
"#808080",
|
||||
"#cccccc",
|
||||
"#D33115",
|
||||
"#E27300",
|
||||
"#FCC400",
|
||||
"#B0BC00",
|
||||
"#68BC00",
|
||||
"#16A5A5",
|
||||
"#009CE0",
|
||||
"#7B64FF",
|
||||
"#FA28FF",
|
||||
"#666666",
|
||||
"#B3B3B3",
|
||||
"#9F0500",
|
||||
"#C45100",
|
||||
"#FB9E00",
|
||||
"#808900",
|
||||
"#194D33",
|
||||
"#0C797D",
|
||||
"#0062B1",
|
||||
"#653294",
|
||||
"#AB149E",
|
||||
];
|
||||
|
||||
const handleChange = (color) => {
|
||||
setSelectedColor(color.hex);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onClose={handleClose}
|
||||
aria-labelledby="simple-dialog-title"
|
||||
open={open}
|
||||
maxWidth="xs"
|
||||
paperFullWidth
|
||||
>
|
||||
<GithubPicker
|
||||
width={"100%"}
|
||||
triangle="hide"
|
||||
color={selectedColor}
|
||||
colors={colors}
|
||||
onChange={handleChange}
|
||||
onChangeComplete={(color) => onChange(color.hex)}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorPicker;
|
||||
45
frontend/src/components/ConfirmationModal/index.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import Button from "@material-ui/core/Button";
|
||||
import Dialog from "@material-ui/core/Dialog";
|
||||
import DialogActions from "@material-ui/core/DialogActions";
|
||||
import DialogContent from "@material-ui/core/DialogContent";
|
||||
import DialogTitle from "@material-ui/core/DialogTitle";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
|
||||
const ConfirmationModal = ({ title, children, open, onClose, onConfirm }) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => onClose(false)}
|
||||
aria-labelledby="confirm-dialog"
|
||||
>
|
||||
<DialogTitle id="confirm-dialog">{title}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography>{children}</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => onClose(false)}
|
||||
color="default"
|
||||
>
|
||||
{i18n.t("confirmationModal.buttons.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
onClose(false);
|
||||
onConfirm();
|
||||
}}
|
||||
color="secondary"
|
||||
>
|
||||
{i18n.t("confirmationModal.buttons.confirm")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmationModal;
|
||||
165
frontend/src/components/ContactDrawer/index.js
Normal file
@@ -0,0 +1,165 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
import IconButton from "@material-ui/core/IconButton";
|
||||
import CloseIcon from "@material-ui/icons/Close";
|
||||
import Drawer from "@material-ui/core/Drawer";
|
||||
import Link from "@material-ui/core/Link";
|
||||
import InputLabel from "@material-ui/core/InputLabel";
|
||||
import Avatar from "@material-ui/core/Avatar";
|
||||
import Button from "@material-ui/core/Button";
|
||||
import Paper from "@material-ui/core/Paper";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
|
||||
import ContactModal from "../ContactModal";
|
||||
import ContactDrawerSkeleton from "../ContactDrawerSkeleton";
|
||||
import MarkdownWrapper from "../MarkdownWrapper";
|
||||
|
||||
const drawerWidth = 320;
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
drawer: {
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
},
|
||||
drawerPaper: {
|
||||
width: drawerWidth,
|
||||
display: "flex",
|
||||
borderTop: "1px solid rgba(0, 0, 0, 0.12)",
|
||||
borderRight: "1px solid rgba(0, 0, 0, 0.12)",
|
||||
borderBottom: "1px solid rgba(0, 0, 0, 0.12)",
|
||||
borderTopRightRadius: 4,
|
||||
borderBottomRightRadius: 4,
|
||||
},
|
||||
header: {
|
||||
display: "flex",
|
||||
borderBottom: "1px solid rgba(0, 0, 0, 0.12)",
|
||||
backgroundColor: "#eee",
|
||||
alignItems: "center",
|
||||
padding: theme.spacing(0, 1),
|
||||
minHeight: "73px",
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
content: {
|
||||
display: "flex",
|
||||
backgroundColor: "#eee",
|
||||
flexDirection: "column",
|
||||
padding: "8px 0px 8px 8px",
|
||||
height: "100%",
|
||||
overflowY: "scroll",
|
||||
...theme.scrollbarStyles,
|
||||
},
|
||||
|
||||
contactAvatar: {
|
||||
margin: 15,
|
||||
width: 160,
|
||||
height: 160,
|
||||
},
|
||||
|
||||
contactHeader: {
|
||||
display: "flex",
|
||||
padding: 8,
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
"& > *": {
|
||||
margin: 4,
|
||||
},
|
||||
},
|
||||
|
||||
contactDetails: {
|
||||
marginTop: 8,
|
||||
padding: 8,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
contactExtraInfo: {
|
||||
marginTop: 4,
|
||||
padding: 6,
|
||||
},
|
||||
}));
|
||||
|
||||
const ContactDrawer = ({ open, handleDrawerClose, contact, loading }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
className={classes.drawer}
|
||||
variant="persistent"
|
||||
anchor="right"
|
||||
open={open}
|
||||
PaperProps={{ style: { position: "absolute" } }}
|
||||
BackdropProps={{ style: { position: "absolute" } }}
|
||||
ModalProps={{
|
||||
container: document.getElementById("drawer-container"),
|
||||
style: { position: "absolute" },
|
||||
}}
|
||||
classes={{
|
||||
paper: classes.drawerPaper,
|
||||
}}
|
||||
>
|
||||
<div className={classes.header}>
|
||||
<IconButton onClick={handleDrawerClose}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Typography style={{ justifySelf: "center" }}>
|
||||
{i18n.t("contactDrawer.header")}
|
||||
</Typography>
|
||||
</div>
|
||||
{loading ? (
|
||||
<ContactDrawerSkeleton classes={classes} />
|
||||
) : (
|
||||
<div className={classes.content}>
|
||||
<Paper square variant="outlined" className={classes.contactHeader}>
|
||||
<Avatar
|
||||
alt={contact.name}
|
||||
src={contact.profilePicUrl}
|
||||
className={classes.contactAvatar}
|
||||
></Avatar>
|
||||
|
||||
<Typography>{contact.name}</Typography>
|
||||
<Typography>
|
||||
<Link href={`tel:${contact.number}`}>{contact.number}</Link>
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => setModalOpen(true)}
|
||||
>
|
||||
{i18n.t("contactDrawer.buttons.edit")}
|
||||
</Button>
|
||||
</Paper>
|
||||
<Paper square variant="outlined" className={classes.contactDetails}>
|
||||
<ContactModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
contactId={contact.id}
|
||||
></ContactModal>
|
||||
<Typography variant="subtitle1">
|
||||
{i18n.t("contactDrawer.extraInfo")}
|
||||
</Typography>
|
||||
{contact?.extraInfo?.map(info => (
|
||||
<Paper
|
||||
key={info.id}
|
||||
square
|
||||
variant="outlined"
|
||||
className={classes.contactExtraInfo}
|
||||
>
|
||||
<InputLabel>{info.name}</InputLabel>
|
||||
<Typography component="div" noWrap style={{ paddingTop: 2 }}>
|
||||
<MarkdownWrapper>{info.value}</MarkdownWrapper>
|
||||
</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Paper>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactDrawer;
|
||||
43
frontend/src/components/ContactDrawerSkeleton/index.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import Skeleton from "@material-ui/lab/Skeleton";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
import Paper from "@material-ui/core/Paper";
|
||||
import { i18n } from "../../translate/i18n";
|
||||
|
||||
const ContactDrawerSkeleton = ({ classes }) => {
|
||||
return (
|
||||
<div className={classes.content}>
|
||||
<Paper square variant="outlined" className={classes.contactHeader}>
|
||||
<Skeleton
|
||||
animation="wave"
|
||||
variant="circle"
|
||||
width={160}
|
||||
height={160}
|
||||
className={classes.contactAvatar}
|
||||
/>
|
||||
<Skeleton animation="wave" height={25} width={90} />
|
||||
<Skeleton animation="wave" height={25} width={80} />
|
||||
<Skeleton animation="wave" height={25} width={80} />
|
||||
</Paper>
|
||||
<Paper square className={classes.contactDetails}>
|
||||
<Typography variant="subtitle1">
|
||||
{i18n.t("contactDrawer.extraInfo")}
|
||||
</Typography>
|
||||
<Paper square variant="outlined" className={classes.contactExtraInfo}>
|
||||
<Skeleton animation="wave" height={20} width={60} />
|
||||
<Skeleton animation="wave" height={20} width={160} />
|
||||
</Paper>
|
||||
<Paper square variant="outlined" className={classes.contactExtraInfo}>
|
||||
<Skeleton animation="wave" height={20} width={60} />
|
||||
<Skeleton animation="wave" height={20} width={160} />
|
||||
</Paper>
|
||||
<Paper square variant="outlined" className={classes.contactExtraInfo}>
|
||||
<Skeleton animation="wave" height={20} width={60} />
|
||||
<Skeleton animation="wave" height={20} width={160} />
|
||||
</Paper>
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactDrawerSkeleton;
|
||||
277
frontend/src/components/ContactModal/index.js
Normal file
@@ -0,0 +1,277 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
|
||||
import * as Yup from "yup";
|
||||
import { Formik, FieldArray, Form, Field } from "formik";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import { green } from "@material-ui/core/colors";
|
||||
import Button from "@material-ui/core/Button";
|
||||
import TextField from "@material-ui/core/TextField";
|
||||
import Dialog from "@material-ui/core/Dialog";
|
||||
import DialogActions from "@material-ui/core/DialogActions";
|
||||
import DialogContent from "@material-ui/core/DialogContent";
|
||||
import DialogTitle from "@material-ui/core/DialogTitle";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
import IconButton from "@material-ui/core/IconButton";
|
||||
import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline";
|
||||
import CircularProgress from "@material-ui/core/CircularProgress";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
|
||||
import api from "../../services/api";
|
||||
import toastError from "../../errors/toastError";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
textField: {
|
||||
marginRight: theme.spacing(1),
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
extraAttr: {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
|
||||
btnWrapper: {
|
||||
position: "relative",
|
||||
},
|
||||
|
||||
buttonProgress: {
|
||||
color: green[500],
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
marginTop: -12,
|
||||
marginLeft: -12,
|
||||
},
|
||||
}));
|
||||
|
||||
const ContactSchema = Yup.object().shape({
|
||||
name: Yup.string()
|
||||
.min(2, "Too Short!")
|
||||
.max(50, "Too Long!")
|
||||
.required("Required"),
|
||||
number: Yup.string().min(8, "Too Short!").max(50, "Too Long!"),
|
||||
email: Yup.string().email("Invalid email"),
|
||||
});
|
||||
|
||||
const ContactModal = ({ open, onClose, contactId, initialValues, onSave }) => {
|
||||
const classes = useStyles();
|
||||
const isMounted = useRef(true);
|
||||
|
||||
const initialState = {
|
||||
name: "",
|
||||
number: "",
|
||||
email: "",
|
||||
};
|
||||
|
||||
const [contact, setContact] = useState(initialState);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchContact = async () => {
|
||||
if (initialValues) {
|
||||
setContact(prevState => {
|
||||
return { ...prevState, ...initialValues };
|
||||
});
|
||||
}
|
||||
|
||||
if (!contactId) return;
|
||||
|
||||
try {
|
||||
const { data } = await api.get(`/contacts/${contactId}`);
|
||||
if (isMounted.current) {
|
||||
setContact(data);
|
||||
}
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchContact();
|
||||
}, [contactId, open, initialValues]);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setContact(initialState);
|
||||
};
|
||||
|
||||
const handleSaveContact = async values => {
|
||||
try {
|
||||
if (contactId) {
|
||||
await api.put(`/contacts/${contactId}`, values);
|
||||
handleClose();
|
||||
} else {
|
||||
const { data } = await api.post("/contacts", values);
|
||||
if (onSave) {
|
||||
onSave(data);
|
||||
}
|
||||
handleClose();
|
||||
}
|
||||
toast.success(i18n.t("contactModal.success"));
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="lg" scroll="paper">
|
||||
<DialogTitle id="form-dialog-title">
|
||||
{contactId
|
||||
? `${i18n.t("contactModal.title.edit")}`
|
||||
: `${i18n.t("contactModal.title.add")}`}
|
||||
</DialogTitle>
|
||||
<Formik
|
||||
initialValues={contact}
|
||||
enableReinitialize={true}
|
||||
validationSchema={ContactSchema}
|
||||
onSubmit={(values, actions) => {
|
||||
setTimeout(() => {
|
||||
handleSaveContact(values);
|
||||
actions.setSubmitting(false);
|
||||
}, 400);
|
||||
}}
|
||||
>
|
||||
{({ values, errors, touched, isSubmitting }) => (
|
||||
<Form>
|
||||
<DialogContent dividers>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{i18n.t("contactModal.form.mainInfo")}
|
||||
</Typography>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("contactModal.form.name")}
|
||||
name="name"
|
||||
autoFocus
|
||||
error={touched.name && Boolean(errors.name)}
|
||||
helperText={touched.name && errors.name}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
className={classes.textField}
|
||||
/>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("contactModal.form.number")}
|
||||
name="number"
|
||||
error={touched.number && Boolean(errors.number)}
|
||||
helperText={touched.number && errors.number}
|
||||
placeholder="5513912344321"
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
/>
|
||||
<div>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("contactModal.form.email")}
|
||||
name="email"
|
||||
error={touched.email && Boolean(errors.email)}
|
||||
helperText={touched.email && errors.email}
|
||||
placeholder="Email address"
|
||||
fullWidth
|
||||
margin="dense"
|
||||
variant="outlined"
|
||||
/>
|
||||
</div>
|
||||
<Typography
|
||||
style={{ marginBottom: 8, marginTop: 12 }}
|
||||
variant="subtitle1"
|
||||
>
|
||||
{i18n.t("contactModal.form.extraInfo")}
|
||||
</Typography>
|
||||
|
||||
<FieldArray name="extraInfo">
|
||||
{({ push, remove }) => (
|
||||
<>
|
||||
{values.extraInfo &&
|
||||
values.extraInfo.length > 0 &&
|
||||
values.extraInfo.map((info, index) => (
|
||||
<div
|
||||
className={classes.extraAttr}
|
||||
key={`${index}-info`}
|
||||
>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("contactModal.form.extraName")}
|
||||
name={`extraInfo[${index}].name`}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
className={classes.textField}
|
||||
/>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("contactModal.form.extraValue")}
|
||||
name={`extraInfo[${index}].value`}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
className={classes.textField}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
<div className={classes.extraAttr}>
|
||||
<Button
|
||||
style={{ flex: 1, marginTop: 8 }}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => push({ name: "", value: "" })}
|
||||
>
|
||||
{`+ ${i18n.t("contactModal.buttons.addExtraInfo")}`}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</FieldArray>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
color="secondary"
|
||||
disabled={isSubmitting}
|
||||
variant="outlined"
|
||||
>
|
||||
{i18n.t("contactModal.buttons.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={isSubmitting}
|
||||
variant="contained"
|
||||
className={classes.btnWrapper}
|
||||
>
|
||||
{contactId
|
||||
? `${i18n.t("contactModal.buttons.okEdit")}`
|
||||
: `${i18n.t("contactModal.buttons.okAdd")}`}
|
||||
{isSubmitting && (
|
||||
<CircularProgress
|
||||
size={24}
|
||||
className={classes.buttonProgress}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactModal;
|
||||
53
frontend/src/components/LocationPreview/index.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import toastError from "../../errors/toastError";
|
||||
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
import Grid from "@material-ui/core/Grid";
|
||||
|
||||
import { Button, Divider, } from "@material-ui/core";
|
||||
|
||||
const LocationPreview = ({ image, link, description }) => {
|
||||
useEffect(() => {}, [image, link, description]);
|
||||
|
||||
const handleLocation = async() => {
|
||||
try {
|
||||
window.open(link);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{
|
||||
minWidth: "250px",
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ float: "left" }}>
|
||||
<img src={image} onClick={handleLocation} style={{ width: "100px" }} />
|
||||
</div>
|
||||
{ description && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap" }}>
|
||||
<Typography style={{ marginTop: "12px", marginLeft: "15px", marginRight: "15px", float: "left" }} variant="subtitle1" color="primary" gutterBottom>
|
||||
<div dangerouslySetInnerHTML={{ __html: description.replace('\\n', '<br />') }}></div>
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "block", content: "", clear: "both" }}></div>
|
||||
<div>
|
||||
<Divider />
|
||||
<Button
|
||||
fullWidth
|
||||
color="primary"
|
||||
onClick={handleLocation}
|
||||
disabled={!link}
|
||||
>Visualizar</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
export default LocationPreview;
|
||||
33
frontend/src/components/MainContainer/index.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import Container from "@material-ui/core/Container";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
mainContainer: {
|
||||
flex: 1,
|
||||
// padding: theme.spacing(2),
|
||||
// height: `calc(100% - 48px)`,
|
||||
padding: 0,
|
||||
height: "100%",
|
||||
},
|
||||
|
||||
contentWrapper: {
|
||||
height: "100%",
|
||||
overflowY: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
}));
|
||||
|
||||
const MainContainer = ({ children }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Container className={classes.mainContainer} maxWidth={false}>
|
||||
<div className={classes.contentWrapper}>{children}</div>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainContainer;
|
||||
19
frontend/src/components/MainHeader/index.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
contactsHeader: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "0px 6px 6px 6px",
|
||||
},
|
||||
}));
|
||||
|
||||
const MainHeader = ({ children }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return <div className={classes.contactsHeader}>{children}</div>;
|
||||
};
|
||||
|
||||
export default MainHeader;
|
||||
21
frontend/src/components/MainHeaderButtonsWrapper/index.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
MainHeaderButtonsWrapper: {
|
||||
flex: "none",
|
||||
marginLeft: "auto",
|
||||
"& > *": {
|
||||
margin: theme.spacing(1),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const MainHeaderButtonsWrapper = ({ children }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return <div className={classes.MainHeaderButtonsWrapper}>{children}</div>;
|
||||
};
|
||||
|
||||
export default MainHeaderButtonsWrapper;
|
||||
193
frontend/src/components/MarkdownWrapper/index.js
Normal file
@@ -0,0 +1,193 @@
|
||||
import React from "react";
|
||||
import Markdown from "markdown-to-jsx";
|
||||
|
||||
const elements = [
|
||||
"a",
|
||||
"abbr",
|
||||
"address",
|
||||
"area",
|
||||
"article",
|
||||
"aside",
|
||||
"audio",
|
||||
"b",
|
||||
"base",
|
||||
"bdi",
|
||||
"bdo",
|
||||
"big",
|
||||
"blockquote",
|
||||
"body",
|
||||
"br",
|
||||
"button",
|
||||
"canvas",
|
||||
"caption",
|
||||
"cite",
|
||||
"code",
|
||||
"col",
|
||||
"colgroup",
|
||||
"data",
|
||||
"datalist",
|
||||
"dd",
|
||||
"del",
|
||||
"details",
|
||||
"dfn",
|
||||
"dialog",
|
||||
"div",
|
||||
"dl",
|
||||
"dt",
|
||||
"em",
|
||||
"embed",
|
||||
"fieldset",
|
||||
"figcaption",
|
||||
"figure",
|
||||
"footer",
|
||||
"form",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"head",
|
||||
"header",
|
||||
"hgroup",
|
||||
"hr",
|
||||
"html",
|
||||
"i",
|
||||
"iframe",
|
||||
"img",
|
||||
"input",
|
||||
"ins",
|
||||
"kbd",
|
||||
"keygen",
|
||||
"label",
|
||||
"legend",
|
||||
"li",
|
||||
"link",
|
||||
"main",
|
||||
"map",
|
||||
"mark",
|
||||
"marquee",
|
||||
"menu",
|
||||
"menuitem",
|
||||
"meta",
|
||||
"meter",
|
||||
"nav",
|
||||
"noscript",
|
||||
"object",
|
||||
"ol",
|
||||
"optgroup",
|
||||
"option",
|
||||
"output",
|
||||
"p",
|
||||
"param",
|
||||
"picture",
|
||||
"pre",
|
||||
"progress",
|
||||
"q",
|
||||
"rp",
|
||||
"rt",
|
||||
"ruby",
|
||||
"s",
|
||||
"samp",
|
||||
"script",
|
||||
"section",
|
||||
"select",
|
||||
"small",
|
||||
"source",
|
||||
"span",
|
||||
"strong",
|
||||
"style",
|
||||
"sub",
|
||||
"summary",
|
||||
"sup",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
"textarea",
|
||||
"tfoot",
|
||||
"th",
|
||||
"thead",
|
||||
"time",
|
||||
"title",
|
||||
"tr",
|
||||
"track",
|
||||
"u",
|
||||
"ul",
|
||||
"var",
|
||||
"video",
|
||||
"wbr",
|
||||
|
||||
// SVG
|
||||
"circle",
|
||||
"clipPath",
|
||||
"defs",
|
||||
"ellipse",
|
||||
"foreignObject",
|
||||
"g",
|
||||
"image",
|
||||
"line",
|
||||
"linearGradient",
|
||||
"marker",
|
||||
"mask",
|
||||
"path",
|
||||
"pattern",
|
||||
"polygon",
|
||||
"polyline",
|
||||
"radialGradient",
|
||||
"rect",
|
||||
"stop",
|
||||
"svg",
|
||||
"text",
|
||||
"tspan",
|
||||
];
|
||||
|
||||
const allowedElements = ["a", "b", "strong", "em", "u", "code", "del"];
|
||||
|
||||
const CustomLink = ({ children, ...props }) => (
|
||||
<a {...props} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
|
||||
const MarkdownWrapper = ({ children }) => {
|
||||
const boldRegex = /\*(.*?)\*/g;
|
||||
const tildaRegex = /~(.*?)~/g;
|
||||
|
||||
if(children && children.includes('BEGIN:VCARD'))
|
||||
//children = "Diga olá ao seu novo contato clicando em *conversar*!";
|
||||
children = null;
|
||||
|
||||
if(children && children.includes('data:image/'))
|
||||
children = null;
|
||||
|
||||
if (children && boldRegex.test(children)) {
|
||||
children = children.replace(boldRegex, "**$1**");
|
||||
}
|
||||
if (children && tildaRegex.test(children)) {
|
||||
children = children.replace(tildaRegex, "~~$1~~");
|
||||
}
|
||||
|
||||
const options = React.useMemo(() => {
|
||||
const markdownOptions = {
|
||||
disableParsingRawHTML: true,
|
||||
forceInline: true,
|
||||
overrides: {
|
||||
a: { component: CustomLink },
|
||||
},
|
||||
};
|
||||
|
||||
elements.forEach(element => {
|
||||
if (!allowedElements.includes(element)) {
|
||||
markdownOptions.overrides[element] = el => el.children || null;
|
||||
}
|
||||
});
|
||||
|
||||
return markdownOptions;
|
||||
}, []);
|
||||
|
||||
if (!children) return null;
|
||||
|
||||
return <Markdown options={options}>{children}</Markdown>;
|
||||
};
|
||||
|
||||
export default MarkdownWrapper;
|
||||
48
frontend/src/components/MessageInput/RecordingTimer.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
timerBox: {
|
||||
display: "flex",
|
||||
marginLeft: 10,
|
||||
marginRight: 10,
|
||||
alignItems: "center",
|
||||
},
|
||||
}));
|
||||
|
||||
const RecordingTimer = () => {
|
||||
const classes = useStyles();
|
||||
const initialState = {
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
};
|
||||
const [timer, setTimer] = useState(initialState);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(
|
||||
() =>
|
||||
setTimer(prevState => {
|
||||
if (prevState.seconds === 59) {
|
||||
return { ...prevState, minutes: prevState.minutes + 1, seconds: 0 };
|
||||
}
|
||||
return { ...prevState, seconds: prevState.seconds + 1 };
|
||||
}),
|
||||
1000
|
||||
);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const addZero = n => {
|
||||
return n < 10 ? "0" + n : n;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.timerBox}>
|
||||
<span>{`${addZero(timer.minutes)}:${addZero(timer.seconds)}`}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecordingTimer;
|
||||
674
frontend/src/components/MessageInput/index.js
Normal file
@@ -0,0 +1,674 @@
|
||||
import React, { useState, useEffect, useContext, useRef } from "react";
|
||||
import "emoji-mart/css/emoji-mart.css";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Picker } from "emoji-mart";
|
||||
import MicRecorder from "mic-recorder-to-mp3";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import Paper from "@material-ui/core/Paper";
|
||||
import InputBase from "@material-ui/core/InputBase";
|
||||
import CircularProgress from "@material-ui/core/CircularProgress";
|
||||
import { green } from "@material-ui/core/colors";
|
||||
import AttachFileIcon from "@material-ui/icons/AttachFile";
|
||||
import IconButton from "@material-ui/core/IconButton";
|
||||
import MoreVert from "@material-ui/icons/MoreVert";
|
||||
import MoodIcon from "@material-ui/icons/Mood";
|
||||
import SendIcon from "@material-ui/icons/Send";
|
||||
import CancelIcon from "@material-ui/icons/Cancel";
|
||||
import ClearIcon from "@material-ui/icons/Clear";
|
||||
import MicIcon from "@material-ui/icons/Mic";
|
||||
import CheckCircleOutlineIcon from "@material-ui/icons/CheckCircleOutline";
|
||||
import HighlightOffIcon from "@material-ui/icons/HighlightOff";
|
||||
import {
|
||||
FormControlLabel,
|
||||
Hidden,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Switch,
|
||||
} from "@material-ui/core";
|
||||
import ClickAwayListener from "@material-ui/core/ClickAwayListener";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import api from "../../services/api";
|
||||
import RecordingTimer from "./RecordingTimer";
|
||||
import { ReplyMessageContext } from "../../context/ReplyingMessage/ReplyingMessageContext";
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
import { useLocalStorage } from "../../hooks/useLocalStorage";
|
||||
import toastError from "../../errors/toastError";
|
||||
|
||||
const Mp3Recorder = new MicRecorder({ bitRate: 128 });
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
mainWrapper: {
|
||||
background: "#eee",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
borderTop: "1px solid rgba(0, 0, 0, 0.12)",
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
position: "fixed",
|
||||
bottom: 0,
|
||||
width: "100%",
|
||||
},
|
||||
},
|
||||
|
||||
newMessageBox: {
|
||||
background: "#eee",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
padding: "7px",
|
||||
alignItems: "center",
|
||||
},
|
||||
|
||||
messageInputWrapper: {
|
||||
padding: 6,
|
||||
marginRight: 7,
|
||||
background: "#fff",
|
||||
display: "flex",
|
||||
borderRadius: 20,
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
},
|
||||
|
||||
messageInput: {
|
||||
paddingLeft: 10,
|
||||
flex: 1,
|
||||
border: "none",
|
||||
},
|
||||
|
||||
sendMessageIcons: {
|
||||
color: "grey",
|
||||
},
|
||||
|
||||
uploadInput: {
|
||||
display: "none",
|
||||
},
|
||||
|
||||
viewMediaInputWrapper: {
|
||||
display: "flex",
|
||||
padding: "10px 13px",
|
||||
position: "relative",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#eee",
|
||||
borderTop: "1px solid rgba(0, 0, 0, 0.12)",
|
||||
},
|
||||
|
||||
emojiBox: {
|
||||
position: "absolute",
|
||||
bottom: 63,
|
||||
width: 40,
|
||||
borderTop: "1px solid #e8e8e8",
|
||||
},
|
||||
|
||||
circleLoading: {
|
||||
color: green[500],
|
||||
opacity: "70%",
|
||||
position: "absolute",
|
||||
top: "20%",
|
||||
left: "50%",
|
||||
marginLeft: -12,
|
||||
},
|
||||
|
||||
audioLoading: {
|
||||
color: green[500],
|
||||
opacity: "70%",
|
||||
},
|
||||
|
||||
recorderWrapper: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
alignContent: "middle",
|
||||
},
|
||||
|
||||
cancelAudioIcon: {
|
||||
color: "red",
|
||||
},
|
||||
|
||||
sendAudioIcon: {
|
||||
color: "green",
|
||||
},
|
||||
|
||||
replyginMsgWrapper: {
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingTop: 8,
|
||||
paddingLeft: 73,
|
||||
paddingRight: 7,
|
||||
},
|
||||
|
||||
replyginMsgContainer: {
|
||||
flex: 1,
|
||||
marginRight: 5,
|
||||
overflowY: "hidden",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.05)",
|
||||
borderRadius: "7.5px",
|
||||
display: "flex",
|
||||
position: "relative",
|
||||
},
|
||||
|
||||
replyginMsgBody: {
|
||||
padding: 10,
|
||||
height: "auto",
|
||||
display: "block",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflow: "hidden",
|
||||
},
|
||||
|
||||
replyginContactMsgSideColor: {
|
||||
flex: "none",
|
||||
width: "4px",
|
||||
backgroundColor: "#35cd96",
|
||||
},
|
||||
|
||||
replyginSelfMsgSideColor: {
|
||||
flex: "none",
|
||||
width: "4px",
|
||||
backgroundColor: "#6bcbef",
|
||||
},
|
||||
|
||||
messageContactName: {
|
||||
display: "flex",
|
||||
color: "#6bcbef",
|
||||
fontWeight: 500,
|
||||
},
|
||||
messageQuickAnswersWrapper: {
|
||||
margin: 0,
|
||||
position: "absolute",
|
||||
bottom: "50px",
|
||||
background: "#ffffff",
|
||||
padding: "2px",
|
||||
border: "1px solid #CCC",
|
||||
left: 0,
|
||||
width: "100%",
|
||||
"& li": {
|
||||
listStyle: "none",
|
||||
"& a": {
|
||||
display: "block",
|
||||
padding: "8px",
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
maxHeight: "32px",
|
||||
"&:hover": {
|
||||
background: "#F1F1F1",
|
||||
cursor: "pointer",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const MessageInput = ({ ticketStatus }) => {
|
||||
const classes = useStyles();
|
||||
const { ticketId } = useParams();
|
||||
|
||||
const [medias, setMedias] = useState([]);
|
||||
const [inputMessage, setInputMessage] = useState("");
|
||||
const [showEmoji, setShowEmoji] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [quickAnswers, setQuickAnswer] = useState([]);
|
||||
const [typeBar, setTypeBar] = useState(false);
|
||||
const inputRef = useRef();
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const { setReplyingMessage, replyingMessage } =
|
||||
useContext(ReplyMessageContext);
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
const [signMessage, setSignMessage] = useLocalStorage("signOption", true);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current.focus();
|
||||
}, [replyingMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current.focus();
|
||||
return () => {
|
||||
setInputMessage("");
|
||||
setShowEmoji(false);
|
||||
setMedias([]);
|
||||
setReplyingMessage(null);
|
||||
};
|
||||
}, [ticketId, setReplyingMessage]);
|
||||
|
||||
const handleChangeInput = (e) => {
|
||||
setInputMessage(e.target.value);
|
||||
handleLoadQuickAnswer(e.target.value);
|
||||
};
|
||||
|
||||
const handleQuickAnswersClick = (value) => {
|
||||
setInputMessage(value);
|
||||
setTypeBar(false);
|
||||
};
|
||||
|
||||
const handleAddEmoji = (e) => {
|
||||
let emoji = e.native;
|
||||
setInputMessage((prevState) => prevState + emoji);
|
||||
};
|
||||
|
||||
const handleChangeMedias = (e) => {
|
||||
if (!e.target.files) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedMedias = Array.from(e.target.files);
|
||||
setMedias(selectedMedias);
|
||||
};
|
||||
|
||||
const handleInputPaste = (e) => {
|
||||
if (e.clipboardData.files[0]) {
|
||||
setMedias([e.clipboardData.files[0]]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadMedia = async (e) => {
|
||||
setLoading(true);
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("fromMe", true);
|
||||
medias.forEach((media) => {
|
||||
formData.append("medias", media);
|
||||
formData.append("body", media.name);
|
||||
});
|
||||
|
||||
try {
|
||||
await api.post(`/messages/${ticketId}`, formData);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
setMedias([]);
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (inputMessage.trim() === "") return;
|
||||
setLoading(true);
|
||||
|
||||
const message = {
|
||||
read: 1,
|
||||
fromMe: true,
|
||||
mediaUrl: "",
|
||||
body: signMessage
|
||||
? `*${user?.name}:*\n${inputMessage.trim()}`
|
||||
: inputMessage.trim(),
|
||||
quotedMsg: replyingMessage,
|
||||
};
|
||||
try {
|
||||
await api.post(`/messages/${ticketId}`, message);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
|
||||
setInputMessage("");
|
||||
setShowEmoji(false);
|
||||
setLoading(false);
|
||||
setReplyingMessage(null);
|
||||
};
|
||||
|
||||
const handleStartRecording = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
await Mp3Recorder.start();
|
||||
setRecording(true);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadQuickAnswer = async (value) => {
|
||||
if (value && value.indexOf("/") === 0) {
|
||||
try {
|
||||
const { data } = await api.get("/quickAnswers/", {
|
||||
params: { searchParam: inputMessage.substring(1) },
|
||||
});
|
||||
setQuickAnswer(data.quickAnswers);
|
||||
if (data.quickAnswers.length > 0) {
|
||||
setTypeBar(true);
|
||||
} else {
|
||||
setTypeBar(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setTypeBar(false);
|
||||
}
|
||||
} else {
|
||||
setTypeBar(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadAudio = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [, blob] = await Mp3Recorder.stop().getMp3();
|
||||
if (blob.size < 10000) {
|
||||
setLoading(false);
|
||||
setRecording(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
const filename = `${new Date().getTime()}.mp3`;
|
||||
formData.append("medias", blob, filename);
|
||||
formData.append("body", filename);
|
||||
formData.append("fromMe", true);
|
||||
|
||||
await api.post(`/messages/${ticketId}`, formData);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
|
||||
setRecording(false);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleCancelAudio = async () => {
|
||||
try {
|
||||
await Mp3Recorder.stop().getMp3();
|
||||
setRecording(false);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenMenuClick = (event) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleMenuItemClick = (event) => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const renderReplyingMessage = (message) => {
|
||||
return (
|
||||
<div className={classes.replyginMsgWrapper}>
|
||||
<div className={classes.replyginMsgContainer}>
|
||||
<span
|
||||
className={clsx(classes.replyginContactMsgSideColor, {
|
||||
[classes.replyginSelfMsgSideColor]: !message.fromMe,
|
||||
})}
|
||||
></span>
|
||||
<div className={classes.replyginMsgBody}>
|
||||
{!message.fromMe && (
|
||||
<span className={classes.messageContactName}>
|
||||
{message.contact?.name}
|
||||
</span>
|
||||
)}
|
||||
{message.body}
|
||||
</div>
|
||||
</div>
|
||||
<IconButton
|
||||
aria-label="showRecorder"
|
||||
component="span"
|
||||
disabled={loading || ticketStatus !== "open"}
|
||||
onClick={() => setReplyingMessage(null)}
|
||||
>
|
||||
<ClearIcon className={classes.sendMessageIcons} />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (medias.length > 0)
|
||||
return (
|
||||
<Paper elevation={0} square className={classes.viewMediaInputWrapper}>
|
||||
<IconButton
|
||||
aria-label="cancel-upload"
|
||||
component="span"
|
||||
onClick={(e) => setMedias([])}
|
||||
>
|
||||
<CancelIcon className={classes.sendMessageIcons} />
|
||||
</IconButton>
|
||||
|
||||
{loading ? (
|
||||
<div>
|
||||
<CircularProgress className={classes.circleLoading} />
|
||||
</div>
|
||||
) : (
|
||||
<span>
|
||||
{medias[0]?.name}
|
||||
{/* <img src={media.preview} alt=""></img> */}
|
||||
</span>
|
||||
)}
|
||||
<IconButton
|
||||
aria-label="send-upload"
|
||||
component="span"
|
||||
onClick={handleUploadMedia}
|
||||
disabled={loading}
|
||||
>
|
||||
<SendIcon className={classes.sendMessageIcons} />
|
||||
</IconButton>
|
||||
</Paper>
|
||||
);
|
||||
else {
|
||||
return (
|
||||
<Paper square elevation={0} className={classes.mainWrapper}>
|
||||
{replyingMessage && renderReplyingMessage(replyingMessage)}
|
||||
<div className={classes.newMessageBox}>
|
||||
<Hidden only={["sm", "xs"]}>
|
||||
<IconButton
|
||||
aria-label="emojiPicker"
|
||||
component="span"
|
||||
disabled={loading || recording || ticketStatus !== "open"}
|
||||
onClick={(e) => setShowEmoji((prevState) => !prevState)}
|
||||
>
|
||||
<MoodIcon className={classes.sendMessageIcons} />
|
||||
</IconButton>
|
||||
{showEmoji ? (
|
||||
<div className={classes.emojiBox}>
|
||||
<ClickAwayListener onClickAway={(e) => setShowEmoji(false)}>
|
||||
<Picker
|
||||
perLine={16}
|
||||
showPreview={false}
|
||||
showSkinTones={false}
|
||||
onSelect={handleAddEmoji}
|
||||
/>
|
||||
</ClickAwayListener>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<input
|
||||
multiple
|
||||
type="file"
|
||||
id="upload-button"
|
||||
disabled={loading || recording || ticketStatus !== "open"}
|
||||
className={classes.uploadInput}
|
||||
onChange={handleChangeMedias}
|
||||
/>
|
||||
<label htmlFor="upload-button">
|
||||
<IconButton
|
||||
aria-label="upload"
|
||||
component="span"
|
||||
disabled={loading || recording || ticketStatus !== "open"}
|
||||
>
|
||||
<AttachFileIcon className={classes.sendMessageIcons} />
|
||||
</IconButton>
|
||||
</label>
|
||||
<FormControlLabel
|
||||
style={{ marginRight: 7, color: "gray" }}
|
||||
label={i18n.t("messagesInput.signMessage")}
|
||||
labelPlacement="start"
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={signMessage}
|
||||
onChange={(e) => {
|
||||
setSignMessage(e.target.checked);
|
||||
}}
|
||||
name="showAllTickets"
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Hidden>
|
||||
<Hidden only={["md", "lg", "xl"]}>
|
||||
<IconButton
|
||||
aria-controls="simple-menu"
|
||||
aria-haspopup="true"
|
||||
onClick={handleOpenMenuClick}
|
||||
>
|
||||
<MoreVert></MoreVert>
|
||||
</IconButton>
|
||||
<Menu
|
||||
id="simple-menu"
|
||||
keepMounted
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuItemClick}
|
||||
>
|
||||
<MenuItem onClick={handleMenuItemClick}>
|
||||
<IconButton
|
||||
aria-label="emojiPicker"
|
||||
component="span"
|
||||
disabled={loading || recording || ticketStatus !== "open"}
|
||||
onClick={(e) => setShowEmoji((prevState) => !prevState)}
|
||||
>
|
||||
<MoodIcon className={classes.sendMessageIcons} />
|
||||
</IconButton>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleMenuItemClick}>
|
||||
<input
|
||||
multiple
|
||||
type="file"
|
||||
id="upload-button"
|
||||
disabled={loading || recording || ticketStatus !== "open"}
|
||||
className={classes.uploadInput}
|
||||
onChange={handleChangeMedias}
|
||||
/>
|
||||
<label htmlFor="upload-button">
|
||||
<IconButton
|
||||
aria-label="upload"
|
||||
component="span"
|
||||
disabled={loading || recording || ticketStatus !== "open"}
|
||||
>
|
||||
<AttachFileIcon className={classes.sendMessageIcons} />
|
||||
</IconButton>
|
||||
</label>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleMenuItemClick}>
|
||||
<FormControlLabel
|
||||
style={{ marginRight: 7, color: "gray" }}
|
||||
label={i18n.t("messagesInput.signMessage")}
|
||||
labelPlacement="start"
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={signMessage}
|
||||
onChange={(e) => {
|
||||
setSignMessage(e.target.checked);
|
||||
}}
|
||||
name="showAllTickets"
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Hidden>
|
||||
<div className={classes.messageInputWrapper}>
|
||||
<InputBase
|
||||
inputRef={(input) => {
|
||||
input && input.focus();
|
||||
input && (inputRef.current = input);
|
||||
}}
|
||||
className={classes.messageInput}
|
||||
placeholder={
|
||||
ticketStatus === "open"
|
||||
? i18n.t("messagesInput.placeholderOpen")
|
||||
: i18n.t("messagesInput.placeholderClosed")
|
||||
}
|
||||
multiline
|
||||
maxRows={5}
|
||||
value={inputMessage}
|
||||
onChange={handleChangeInput}
|
||||
disabled={recording || loading || ticketStatus !== "open"}
|
||||
onPaste={(e) => {
|
||||
ticketStatus === "open" && handleInputPaste(e);
|
||||
}}
|
||||
onKeyPress={(e) => {
|
||||
if (loading || e.shiftKey) return;
|
||||
else if (e.key === "Enter") {
|
||||
handleSendMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{typeBar ? (
|
||||
<ul className={classes.messageQuickAnswersWrapper}>
|
||||
{quickAnswers.map((value, index) => {
|
||||
return (
|
||||
<li
|
||||
className={classes.messageQuickAnswersWrapperItem}
|
||||
key={index}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
|
||||
<a onClick={() => handleQuickAnswersClick(value.message)}>
|
||||
{`${value.shortcut} - ${value.message}`}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
</div>
|
||||
{inputMessage ? (
|
||||
<IconButton
|
||||
aria-label="sendMessage"
|
||||
component="span"
|
||||
onClick={handleSendMessage}
|
||||
disabled={loading}
|
||||
>
|
||||
<SendIcon className={classes.sendMessageIcons} />
|
||||
</IconButton>
|
||||
) : recording ? (
|
||||
<div className={classes.recorderWrapper}>
|
||||
<IconButton
|
||||
aria-label="cancelRecording"
|
||||
component="span"
|
||||
fontSize="large"
|
||||
disabled={loading}
|
||||
onClick={handleCancelAudio}
|
||||
>
|
||||
<HighlightOffIcon className={classes.cancelAudioIcon} />
|
||||
</IconButton>
|
||||
{loading ? (
|
||||
<div>
|
||||
<CircularProgress className={classes.audioLoading} />
|
||||
</div>
|
||||
) : (
|
||||
<RecordingTimer />
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
aria-label="sendRecordedAudio"
|
||||
component="span"
|
||||
onClick={handleUploadAudio}
|
||||
disabled={loading}
|
||||
>
|
||||
<CheckCircleOutlineIcon className={classes.sendAudioIcon} />
|
||||
</IconButton>
|
||||
</div>
|
||||
) : (
|
||||
<IconButton
|
||||
aria-label="showRecorder"
|
||||
component="span"
|
||||
disabled={loading || ticketStatus !== "open"}
|
||||
onClick={handleStartRecording}
|
||||
>
|
||||
<MicIcon className={classes.sendMessageIcons} />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default MessageInput;
|
||||
71
frontend/src/components/MessageOptionsMenu/index.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useState, useContext } from "react";
|
||||
|
||||
import MenuItem from "@material-ui/core/MenuItem";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import api from "../../services/api";
|
||||
import ConfirmationModal from "../ConfirmationModal";
|
||||
import { Menu } from "@material-ui/core";
|
||||
import { ReplyMessageContext } from "../../context/ReplyingMessage/ReplyingMessageContext";
|
||||
import toastError from "../../errors/toastError";
|
||||
|
||||
const MessageOptionsMenu = ({ message, menuOpen, handleClose, anchorEl }) => {
|
||||
const { setReplyingMessage } = useContext(ReplyMessageContext);
|
||||
const [confirmationOpen, setConfirmationOpen] = useState(false);
|
||||
|
||||
const handleDeleteMessage = async () => {
|
||||
try {
|
||||
await api.delete(`/messages/${message.id}`);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const hanldeReplyMessage = () => {
|
||||
setReplyingMessage(message);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleOpenConfirmationModal = (e) => {
|
||||
setConfirmationOpen(true);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmationModal
|
||||
title={i18n.t("messageOptionsMenu.confirmationModal.title")}
|
||||
open={confirmationOpen}
|
||||
onClose={setConfirmationOpen}
|
||||
onConfirm={handleDeleteMessage}
|
||||
>
|
||||
{i18n.t("messageOptionsMenu.confirmationModal.message")}
|
||||
</ConfirmationModal>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
getContentAnchorEl={null}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "right",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
open={menuOpen}
|
||||
onClose={handleClose}
|
||||
>
|
||||
{message.fromMe && (
|
||||
<MenuItem onClick={handleOpenConfirmationModal}>
|
||||
{i18n.t("messageOptionsMenu.delete")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={hanldeReplyMessage}>
|
||||
{i18n.t("messageOptionsMenu.reply")}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageOptionsMenu;
|
||||
703
frontend/src/components/MessagesList/index.js
Normal file
@@ -0,0 +1,703 @@
|
||||
import React, { useState, useEffect, useReducer, useRef } from "react";
|
||||
|
||||
import { isSameDay, parseISO, format } from "date-fns";
|
||||
import openSocket from "../../services/socket-io";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { green } from "@material-ui/core/colors";
|
||||
import {
|
||||
Button,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
IconButton,
|
||||
makeStyles,
|
||||
} from "@material-ui/core";
|
||||
import {
|
||||
AccessTime,
|
||||
Block,
|
||||
Done,
|
||||
DoneAll,
|
||||
ExpandMore,
|
||||
GetApp,
|
||||
} from "@material-ui/icons";
|
||||
|
||||
import MarkdownWrapper from "../MarkdownWrapper";
|
||||
import VcardPreview from "../VcardPreview";
|
||||
import LocationPreview from "../LocationPreview";
|
||||
import ModalImageCors from "../ModalImageCors";
|
||||
import MessageOptionsMenu from "../MessageOptionsMenu";
|
||||
import whatsBackground from "../../assets/wa-background.png";
|
||||
|
||||
import api from "../../services/api";
|
||||
import toastError from "../../errors/toastError";
|
||||
import Audio from "../Audio";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
messagesListWrapper: {
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flexGrow: 1,
|
||||
},
|
||||
|
||||
messagesList: {
|
||||
backgroundImage: `url(${whatsBackground})`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flexGrow: 1,
|
||||
padding: "20px 20px 20px 20px",
|
||||
overflowY: "scroll",
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
paddingBottom: "90px",
|
||||
},
|
||||
...theme.scrollbarStyles,
|
||||
},
|
||||
|
||||
circleLoading: {
|
||||
color: green[500],
|
||||
position: "absolute",
|
||||
opacity: "70%",
|
||||
top: 0,
|
||||
left: "50%",
|
||||
marginTop: 12,
|
||||
},
|
||||
|
||||
messageLeft: {
|
||||
marginRight: 20,
|
||||
marginTop: 2,
|
||||
minWidth: 100,
|
||||
maxWidth: 600,
|
||||
height: "auto",
|
||||
display: "block",
|
||||
position: "relative",
|
||||
"&:hover #messageActionsButton": {
|
||||
display: "flex",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
},
|
||||
|
||||
whiteSpace: "pre-wrap",
|
||||
backgroundColor: "#ffffff",
|
||||
color: "#303030",
|
||||
alignSelf: "flex-start",
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 8,
|
||||
borderBottomLeftRadius: 8,
|
||||
borderBottomRightRadius: 8,
|
||||
paddingLeft: 5,
|
||||
paddingRight: 5,
|
||||
paddingTop: 5,
|
||||
paddingBottom: 0,
|
||||
boxShadow: "0 1px 1px #b3b3b3",
|
||||
},
|
||||
|
||||
quotedContainerLeft: {
|
||||
margin: "-3px -80px 6px -6px",
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#f0f0f0",
|
||||
borderRadius: "7.5px",
|
||||
display: "flex",
|
||||
position: "relative",
|
||||
},
|
||||
|
||||
quotedMsg: {
|
||||
padding: 10,
|
||||
maxWidth: 300,
|
||||
height: "auto",
|
||||
display: "block",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflow: "hidden",
|
||||
},
|
||||
|
||||
quotedSideColorLeft: {
|
||||
flex: "none",
|
||||
width: "4px",
|
||||
backgroundColor: "#6bcbef",
|
||||
},
|
||||
|
||||
messageRight: {
|
||||
marginLeft: 20,
|
||||
marginTop: 2,
|
||||
minWidth: 100,
|
||||
maxWidth: 600,
|
||||
height: "auto",
|
||||
display: "block",
|
||||
position: "relative",
|
||||
"&:hover #messageActionsButton": {
|
||||
display: "flex",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
},
|
||||
|
||||
whiteSpace: "pre-wrap",
|
||||
backgroundColor: "#dcf8c6",
|
||||
color: "#303030",
|
||||
alignSelf: "flex-end",
|
||||
borderTopLeftRadius: 8,
|
||||
borderTopRightRadius: 8,
|
||||
borderBottomLeftRadius: 8,
|
||||
borderBottomRightRadius: 0,
|
||||
paddingLeft: 5,
|
||||
paddingRight: 5,
|
||||
paddingTop: 5,
|
||||
paddingBottom: 0,
|
||||
boxShadow: "0 1px 1px #b3b3b3",
|
||||
},
|
||||
|
||||
quotedContainerRight: {
|
||||
margin: "-3px -80px 6px -6px",
|
||||
overflowY: "hidden",
|
||||
backgroundColor: "#cfe9ba",
|
||||
borderRadius: "7.5px",
|
||||
display: "flex",
|
||||
position: "relative",
|
||||
},
|
||||
|
||||
quotedMsgRight: {
|
||||
padding: 10,
|
||||
maxWidth: 300,
|
||||
height: "auto",
|
||||
whiteSpace: "pre-wrap",
|
||||
},
|
||||
|
||||
quotedSideColorRight: {
|
||||
flex: "none",
|
||||
width: "4px",
|
||||
backgroundColor: "#35cd96",
|
||||
},
|
||||
|
||||
messageActionsButton: {
|
||||
display: "none",
|
||||
position: "relative",
|
||||
color: "#999",
|
||||
zIndex: 1,
|
||||
backgroundColor: "inherit",
|
||||
opacity: "90%",
|
||||
"&:hover, &.Mui-focusVisible": { backgroundColor: "inherit" },
|
||||
},
|
||||
|
||||
messageContactName: {
|
||||
display: "flex",
|
||||
color: "#6bcbef",
|
||||
fontWeight: 500,
|
||||
},
|
||||
|
||||
textContentItem: {
|
||||
overflowWrap: "break-word",
|
||||
padding: "3px 80px 6px 6px",
|
||||
},
|
||||
|
||||
textContentItemDeleted: {
|
||||
fontStyle: "italic",
|
||||
color: "rgba(0, 0, 0, 0.36)",
|
||||
overflowWrap: "break-word",
|
||||
padding: "3px 80px 6px 6px",
|
||||
},
|
||||
|
||||
messageMedia: {
|
||||
objectFit: "cover",
|
||||
width: 250,
|
||||
height: 200,
|
||||
borderTopLeftRadius: 8,
|
||||
borderTopRightRadius: 8,
|
||||
borderBottomLeftRadius: 8,
|
||||
borderBottomRightRadius: 8,
|
||||
},
|
||||
|
||||
timestamp: {
|
||||
fontSize: 11,
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
right: 5,
|
||||
color: "#999",
|
||||
},
|
||||
|
||||
dailyTimestamp: {
|
||||
alignItems: "center",
|
||||
textAlign: "center",
|
||||
alignSelf: "center",
|
||||
width: "110px",
|
||||
backgroundColor: "#e1f3fb",
|
||||
margin: "10px",
|
||||
borderRadius: "10px",
|
||||
boxShadow: "0 1px 1px #b3b3b3",
|
||||
},
|
||||
|
||||
dailyTimestampText: {
|
||||
color: "#808888",
|
||||
padding: 8,
|
||||
alignSelf: "center",
|
||||
marginLeft: "0px",
|
||||
},
|
||||
|
||||
ackIcons: {
|
||||
fontSize: 18,
|
||||
verticalAlign: "middle",
|
||||
marginLeft: 4,
|
||||
},
|
||||
|
||||
deletedIcon: {
|
||||
fontSize: 18,
|
||||
verticalAlign: "middle",
|
||||
marginRight: 4,
|
||||
},
|
||||
|
||||
ackDoneAllIcon: {
|
||||
color: green[500],
|
||||
fontSize: 18,
|
||||
verticalAlign: "middle",
|
||||
marginLeft: 4,
|
||||
},
|
||||
|
||||
downloadMedia: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "inherit",
|
||||
padding: 10,
|
||||
},
|
||||
}));
|
||||
|
||||
const reducer = (state, action) => {
|
||||
if (action.type === "LOAD_MESSAGES") {
|
||||
const messages = action.payload;
|
||||
const newMessages = [];
|
||||
|
||||
messages.forEach((message) => {
|
||||
const messageIndex = state.findIndex((m) => m.id === message.id);
|
||||
if (messageIndex !== -1) {
|
||||
state[messageIndex] = message;
|
||||
} else {
|
||||
newMessages.push(message);
|
||||
}
|
||||
});
|
||||
|
||||
return [...newMessages, ...state];
|
||||
}
|
||||
|
||||
if (action.type === "ADD_MESSAGE") {
|
||||
const newMessage = action.payload;
|
||||
const messageIndex = state.findIndex((m) => m.id === newMessage.id);
|
||||
|
||||
if (messageIndex !== -1) {
|
||||
state[messageIndex] = newMessage;
|
||||
} else {
|
||||
state.push(newMessage);
|
||||
}
|
||||
|
||||
return [...state];
|
||||
}
|
||||
|
||||
if (action.type === "UPDATE_MESSAGE") {
|
||||
const messageToUpdate = action.payload;
|
||||
const messageIndex = state.findIndex((m) => m.id === messageToUpdate.id);
|
||||
|
||||
if (messageIndex !== -1) {
|
||||
state[messageIndex] = messageToUpdate;
|
||||
}
|
||||
|
||||
return [...state];
|
||||
}
|
||||
|
||||
if (action.type === "RESET") {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const MessagesList = ({ ticketId, isGroup }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
const [messagesList, dispatch] = useReducer(reducer, []);
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const lastMessageRef = useRef();
|
||||
|
||||
const [selectedMessage, setSelectedMessage] = useState({});
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const messageOptionsMenuOpen = Boolean(anchorEl);
|
||||
const currentTicketId = useRef(ticketId);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: "RESET" });
|
||||
setPageNumber(1);
|
||||
|
||||
currentTicketId.current = ticketId;
|
||||
}, [ticketId]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const delayDebounceFn = setTimeout(() => {
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
const { data } = await api.get("/messages/" + ticketId, {
|
||||
params: { pageNumber },
|
||||
});
|
||||
|
||||
if (currentTicketId.current === ticketId) {
|
||||
dispatch({ type: "LOAD_MESSAGES", payload: data.messages });
|
||||
setHasMore(data.hasMore);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (pageNumber === 1 && data.messages.length > 1) {
|
||||
scrollToBottom();
|
||||
}
|
||||
} catch (err) {
|
||||
setLoading(false);
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
fetchMessages();
|
||||
}, 500);
|
||||
return () => {
|
||||
clearTimeout(delayDebounceFn);
|
||||
};
|
||||
}, [pageNumber, ticketId]);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket();
|
||||
|
||||
socket.on("connect", () => socket.emit("joinChatBox", ticketId));
|
||||
|
||||
socket.on("appMessage", (data) => {
|
||||
if (data.action === "create") {
|
||||
dispatch({ type: "ADD_MESSAGE", payload: data.message });
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
if (data.action === "update") {
|
||||
dispatch({ type: "UPDATE_MESSAGE", payload: data.message });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [ticketId]);
|
||||
|
||||
const loadMore = () => {
|
||||
setPageNumber((prevPageNumber) => prevPageNumber + 1);
|
||||
};
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (lastMessageRef.current) {
|
||||
lastMessageRef.current.scrollIntoView({});
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = (e) => {
|
||||
if (!hasMore) return;
|
||||
const { scrollTop } = e.currentTarget;
|
||||
|
||||
if (scrollTop === 0) {
|
||||
document.getElementById("messagesList").scrollTop = 1;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollTop < 50) {
|
||||
loadMore();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenMessageOptionsMenu = (e, message) => {
|
||||
setAnchorEl(e.currentTarget);
|
||||
setSelectedMessage(message);
|
||||
};
|
||||
|
||||
const handleCloseMessageOptionsMenu = (e) => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const checkMessageMedia = (message) => {
|
||||
if (message.mediaType === "location" && message.body.split('|').length >= 2) {
|
||||
let locationParts = message.body.split('|')
|
||||
let imageLocation = locationParts[0]
|
||||
let linkLocation = locationParts[1]
|
||||
|
||||
let descriptionLocation = null
|
||||
|
||||
if (locationParts.length > 2)
|
||||
descriptionLocation = message.body.split('|')[2]
|
||||
|
||||
return <LocationPreview image={imageLocation} link={linkLocation} description={descriptionLocation} />
|
||||
}
|
||||
else if (message.mediaType === "vcard") {
|
||||
//console.log("vcard")
|
||||
//console.log(message)
|
||||
let array = message.body.split("\n");
|
||||
let obj = [];
|
||||
let contact = "";
|
||||
for (let index = 0; index < array.length; index++) {
|
||||
const v = array[index];
|
||||
let values = v.split(":");
|
||||
for (let ind = 0; ind < values.length; ind++) {
|
||||
if (values[ind].indexOf("+") !== -1) {
|
||||
obj.push({ number: values[ind] });
|
||||
}
|
||||
if (values[ind].indexOf("FN") !== -1) {
|
||||
contact = values[ind + 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
return <VcardPreview contact={contact} numbers={obj[0]?.number} />
|
||||
}
|
||||
/*else if (message.mediaType === "multi_vcard") {
|
||||
console.log("multi_vcard")
|
||||
console.log(message)
|
||||
|
||||
if(message.body !== null && message.body !== "") {
|
||||
let newBody = JSON.parse(message.body)
|
||||
return (
|
||||
<>
|
||||
{
|
||||
newBody.map(v => (
|
||||
<VcardPreview contact={v.name} numbers={v.number} />
|
||||
))
|
||||
}
|
||||
</>
|
||||
)
|
||||
} else return (<></>)
|
||||
}*/
|
||||
else if (message.mediaType === "image") {
|
||||
return <ModalImageCors imageUrl={message.mediaUrl} />;
|
||||
} else if (message.mediaType === "audio") {
|
||||
return <Audio url={message.mediaUrl} />
|
||||
} else if (message.mediaType === "video") {
|
||||
return (
|
||||
<video
|
||||
className={classes.messageMedia}
|
||||
src={message.mediaUrl}
|
||||
controls
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<div className={classes.downloadMedia}>
|
||||
<Button
|
||||
startIcon={<GetApp />}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
target="_blank"
|
||||
href={message.mediaUrl}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
<Divider />
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderMessageAck = (message) => {
|
||||
if (message.ack === 0) {
|
||||
return <AccessTime fontSize="small" className={classes.ackIcons} />;
|
||||
}
|
||||
if (message.ack === 1) {
|
||||
return <Done fontSize="small" className={classes.ackIcons} />;
|
||||
}
|
||||
if (message.ack === 2) {
|
||||
return <DoneAll fontSize="small" className={classes.ackIcons} />;
|
||||
}
|
||||
if (message.ack === 3 || message.ack === 4) {
|
||||
return <DoneAll fontSize="small" className={classes.ackDoneAllIcon} />;
|
||||
}
|
||||
};
|
||||
|
||||
const renderDailyTimestamps = (message, index) => {
|
||||
if (index === 0) {
|
||||
return (
|
||||
<span
|
||||
className={classes.dailyTimestamp}
|
||||
key={`timestamp-${message.id}`}
|
||||
>
|
||||
<div className={classes.dailyTimestampText}>
|
||||
{format(parseISO(messagesList[index].createdAt), "dd/MM/yyyy")}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (index < messagesList.length - 1) {
|
||||
let messageDay = parseISO(messagesList[index].createdAt);
|
||||
let previousMessageDay = parseISO(messagesList[index - 1].createdAt);
|
||||
|
||||
if (!isSameDay(messageDay, previousMessageDay)) {
|
||||
return (
|
||||
<span
|
||||
className={classes.dailyTimestamp}
|
||||
key={`timestamp-${message.id}`}
|
||||
>
|
||||
<div className={classes.dailyTimestampText}>
|
||||
{format(parseISO(messagesList[index].createdAt), "dd/MM/yyyy")}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
if (index === messagesList.length - 1) {
|
||||
return (
|
||||
<div
|
||||
key={`ref-${message.createdAt}`}
|
||||
ref={lastMessageRef}
|
||||
style={{ float: "left", clear: "both" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderMessageDivider = (message, index) => {
|
||||
if (index < messagesList.length && index > 0) {
|
||||
let messageUser = messagesList[index].fromMe;
|
||||
let previousMessageUser = messagesList[index - 1].fromMe;
|
||||
|
||||
if (messageUser !== previousMessageUser) {
|
||||
return (
|
||||
<span style={{ marginTop: 16 }} key={`divider-${message.id}`}></span>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderQuotedMessage = (message) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(classes.quotedContainerLeft, {
|
||||
[classes.quotedContainerRight]: message.fromMe,
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={clsx(classes.quotedSideColorLeft, {
|
||||
[classes.quotedSideColorRight]: message.quotedMsg?.fromMe,
|
||||
})}
|
||||
></span>
|
||||
<div className={classes.quotedMsg}>
|
||||
{!message.quotedMsg?.fromMe && (
|
||||
<span className={classes.messageContactName}>
|
||||
{message.quotedMsg?.contact?.name}
|
||||
</span>
|
||||
)}
|
||||
{message.quotedMsg?.body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMessages = () => {
|
||||
if (messagesList.length > 0) {
|
||||
const viewMessagesList = messagesList.map((message, index) => {
|
||||
if (!message.fromMe) {
|
||||
return (
|
||||
<React.Fragment key={message.id}>
|
||||
{renderDailyTimestamps(message, index)}
|
||||
{renderMessageDivider(message, index)}
|
||||
<div className={classes.messageLeft}>
|
||||
<IconButton
|
||||
variant="contained"
|
||||
size="small"
|
||||
id="messageActionsButton"
|
||||
disabled={message.isDeleted}
|
||||
className={classes.messageActionsButton}
|
||||
onClick={(e) => handleOpenMessageOptionsMenu(e, message)}
|
||||
>
|
||||
<ExpandMore />
|
||||
</IconButton>
|
||||
{isGroup && (
|
||||
<span className={classes.messageContactName}>
|
||||
{message.contact?.name}
|
||||
</span>
|
||||
)}
|
||||
{(message.mediaUrl || message.mediaType === "location" || message.mediaType === "vcard"
|
||||
//|| message.mediaType === "multi_vcard"
|
||||
) && checkMessageMedia(message)}
|
||||
<div className={classes.textContentItem}>
|
||||
{message.quotedMsg && renderQuotedMessage(message)}
|
||||
<MarkdownWrapper>{message.body}</MarkdownWrapper>
|
||||
<span className={classes.timestamp}>
|
||||
{format(parseISO(message.createdAt), "HH:mm")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<React.Fragment key={message.id}>
|
||||
{renderDailyTimestamps(message, index)}
|
||||
{renderMessageDivider(message, index)}
|
||||
<div className={classes.messageRight}>
|
||||
<IconButton
|
||||
variant="contained"
|
||||
size="small"
|
||||
id="messageActionsButton"
|
||||
disabled={message.isDeleted}
|
||||
className={classes.messageActionsButton}
|
||||
onClick={(e) => handleOpenMessageOptionsMenu(e, message)}
|
||||
>
|
||||
<ExpandMore />
|
||||
</IconButton>
|
||||
{(message.mediaUrl || message.mediaType === "location" || message.mediaType === "vcard"
|
||||
//|| message.mediaType === "multi_vcard"
|
||||
) && checkMessageMedia(message)}
|
||||
<div
|
||||
className={clsx(classes.textContentItem, {
|
||||
[classes.textContentItemDeleted]: message.isDeleted,
|
||||
})}
|
||||
>
|
||||
{message.isDeleted && (
|
||||
<Block
|
||||
color="disabled"
|
||||
fontSize="small"
|
||||
className={classes.deletedIcon}
|
||||
/>
|
||||
)}
|
||||
{message.quotedMsg && renderQuotedMessage(message)}
|
||||
<MarkdownWrapper>{message.body}</MarkdownWrapper>
|
||||
<span className={classes.timestamp}>
|
||||
{format(parseISO(message.createdAt), "HH:mm")}
|
||||
{renderMessageAck(message)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
});
|
||||
return viewMessagesList;
|
||||
} else {
|
||||
return <div>Say hello to your new contact!</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.messagesListWrapper}>
|
||||
<MessageOptionsMenu
|
||||
message={selectedMessage}
|
||||
anchorEl={anchorEl}
|
||||
menuOpen={messageOptionsMenuOpen}
|
||||
handleClose={handleCloseMessageOptionsMenu}
|
||||
/>
|
||||
<div
|
||||
id="messagesList"
|
||||
className={classes.messagesList}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{messagesList.length > 0 ? renderMessages() : []}
|
||||
</div>
|
||||
{loading && (
|
||||
<div>
|
||||
<CircularProgress className={classes.circleLoading} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessagesList;
|
||||
50
frontend/src/components/ModalImageCors/index.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
|
||||
import ModalImage from "react-modal-image";
|
||||
import api from "../../services/api";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
messageMedia: {
|
||||
objectFit: "cover",
|
||||
width: 250,
|
||||
height: 200,
|
||||
borderTopLeftRadius: 8,
|
||||
borderTopRightRadius: 8,
|
||||
borderBottomLeftRadius: 8,
|
||||
borderBottomRightRadius: 8,
|
||||
},
|
||||
}));
|
||||
|
||||
const ModalImageCors = ({ imageUrl }) => {
|
||||
const classes = useStyles();
|
||||
const [fetching, setFetching] = useState(true);
|
||||
const [blobUrl, setBlobUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageUrl) return;
|
||||
const fetchImage = async () => {
|
||||
const { data, headers } = await api.get(imageUrl, {
|
||||
responseType: "blob",
|
||||
});
|
||||
const url = window.URL.createObjectURL(
|
||||
new Blob([data], { type: headers["content-type"] })
|
||||
);
|
||||
setBlobUrl(url);
|
||||
setFetching(false);
|
||||
};
|
||||
fetchImage();
|
||||
}, [imageUrl]);
|
||||
|
||||
return (
|
||||
<ModalImage
|
||||
className={classes.messageMedia}
|
||||
smallSrcSet={fetching ? imageUrl : blobUrl}
|
||||
medium={fetching ? imageUrl : blobUrl}
|
||||
large={fetching ? imageUrl : blobUrl}
|
||||
alt="image"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalImageCors;
|
||||
209
frontend/src/components/NewTicketModal/index.js
Normal file
@@ -0,0 +1,209 @@
|
||||
import React, { useState, useEffect, useContext } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
import Button from "@material-ui/core/Button";
|
||||
import TextField from "@material-ui/core/TextField";
|
||||
import Dialog from "@material-ui/core/Dialog";
|
||||
|
||||
import DialogActions from "@material-ui/core/DialogActions";
|
||||
import DialogContent from "@material-ui/core/DialogContent";
|
||||
import DialogTitle from "@material-ui/core/DialogTitle";
|
||||
import Autocomplete, {
|
||||
createFilterOptions,
|
||||
} from "@material-ui/lab/Autocomplete";
|
||||
import CircularProgress from "@material-ui/core/CircularProgress";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import api from "../../services/api";
|
||||
import ButtonWithSpinner from "../ButtonWithSpinner";
|
||||
import ContactModal from "../ContactModal";
|
||||
import toastError from "../../errors/toastError";
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
|
||||
const filter = createFilterOptions({
|
||||
trim: true,
|
||||
});
|
||||
|
||||
const NewTicketModal = ({ modalOpen, onClose }) => {
|
||||
const history = useHistory();
|
||||
|
||||
const [options, setOptions] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchParam, setSearchParam] = useState("");
|
||||
const [selectedContact, setSelectedContact] = useState(null);
|
||||
const [newContact, setNewContact] = useState({});
|
||||
const [contactModalOpen, setContactModalOpen] = useState(false);
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalOpen || searchParam.length < 3) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const delayDebounceFn = setTimeout(() => {
|
||||
const fetchContacts = async () => {
|
||||
try {
|
||||
const { data } = await api.get("contacts", {
|
||||
params: { searchParam },
|
||||
});
|
||||
setOptions(data.contacts);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setLoading(false);
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchContacts();
|
||||
}, 500);
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [searchParam, modalOpen]);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setSearchParam("");
|
||||
setSelectedContact(null);
|
||||
};
|
||||
|
||||
const handleSaveTicket = async contactId => {
|
||||
if (!contactId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data: ticket } = await api.post("/tickets", {
|
||||
contactId: contactId,
|
||||
userId: user.id,
|
||||
status: "open",
|
||||
});
|
||||
history.push(`/tickets/${ticket.id}`);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
setLoading(false);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleSelectOption = (e, newValue) => {
|
||||
if (newValue?.number) {
|
||||
setSelectedContact(newValue);
|
||||
} else if (newValue?.name) {
|
||||
setNewContact({ name: newValue.name });
|
||||
setContactModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseContactModal = () => {
|
||||
setContactModalOpen(false);
|
||||
};
|
||||
|
||||
const handleAddNewContactTicket = contact => {
|
||||
handleSaveTicket(contact.id);
|
||||
};
|
||||
|
||||
const createAddContactOption = (filterOptions, params) => {
|
||||
const filtered = filter(filterOptions, params);
|
||||
|
||||
if (params.inputValue !== "" && !loading && searchParam.length >= 3) {
|
||||
filtered.push({
|
||||
name: `${params.inputValue}`,
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const renderOption = option => {
|
||||
if (option.number) {
|
||||
return `${option.name} - ${option.number}`;
|
||||
} else {
|
||||
return `${i18n.t("newTicketModal.add")} ${option.name}`;
|
||||
}
|
||||
};
|
||||
|
||||
const renderOptionLabel = option => {
|
||||
if (option.number) {
|
||||
return `${option.name} - ${option.number}`;
|
||||
} else {
|
||||
return `${option.name}`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContactModal
|
||||
open={contactModalOpen}
|
||||
initialValues={newContact}
|
||||
onClose={handleCloseContactModal}
|
||||
onSave={handleAddNewContactTicket}
|
||||
></ContactModal>
|
||||
<Dialog open={modalOpen} onClose={handleClose}>
|
||||
<DialogTitle id="form-dialog-title">
|
||||
{i18n.t("newTicketModal.title")}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Autocomplete
|
||||
options={options}
|
||||
loading={loading}
|
||||
style={{ width: 300 }}
|
||||
clearOnBlur
|
||||
autoHighlight
|
||||
freeSolo
|
||||
clearOnEscape
|
||||
getOptionLabel={renderOptionLabel}
|
||||
renderOption={renderOption}
|
||||
filterOptions={createAddContactOption}
|
||||
onChange={(e, newValue) => handleSelectOption(e, newValue)}
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={i18n.t("newTicketModal.fieldLabel")}
|
||||
variant="outlined"
|
||||
autoFocus
|
||||
onChange={e => setSearchParam(e.target.value)}
|
||||
onKeyPress={e => {
|
||||
if (loading || !selectedContact) return;
|
||||
else if (e.key === "Enter") {
|
||||
handleSaveTicket(selectedContact.id);
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<React.Fragment>
|
||||
{loading ? (
|
||||
<CircularProgress color="inherit" size={20} />
|
||||
) : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</React.Fragment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
color="secondary"
|
||||
disabled={loading}
|
||||
variant="outlined"
|
||||
>
|
||||
{i18n.t("newTicketModal.buttons.cancel")}
|
||||
</Button>
|
||||
<ButtonWithSpinner
|
||||
variant="contained"
|
||||
type="button"
|
||||
disabled={!selectedContact}
|
||||
onClick={() => handleSaveTicket(selectedContact.id)}
|
||||
color="primary"
|
||||
loading={loading}
|
||||
>
|
||||
{i18n.t("newTicketModal.buttons.ok")}
|
||||
</ButtonWithSpinner>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewTicketModal;
|
||||
234
frontend/src/components/NotificationsPopOver/index.js
Normal file
@@ -0,0 +1,234 @@
|
||||
import React, { useState, useRef, useEffect, useContext } from "react";
|
||||
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { format } from "date-fns";
|
||||
import openSocket from "../../services/socket-io";
|
||||
import useSound from "use-sound";
|
||||
|
||||
import Popover from "@material-ui/core/Popover";
|
||||
import IconButton from "@material-ui/core/IconButton";
|
||||
import List from "@material-ui/core/List";
|
||||
import ListItem from "@material-ui/core/ListItem";
|
||||
import ListItemText from "@material-ui/core/ListItemText";
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import Badge from "@material-ui/core/Badge";
|
||||
import ChatIcon from "@material-ui/icons/Chat";
|
||||
|
||||
import TicketListItem from "../TicketListItem";
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import useTickets from "../../hooks/useTickets";
|
||||
import alertSound from "../../assets/sound.mp3";
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
tabContainer: {
|
||||
overflowY: "auto",
|
||||
maxHeight: 350,
|
||||
...theme.scrollbarStyles,
|
||||
},
|
||||
popoverPaper: {
|
||||
width: "100%",
|
||||
maxWidth: 350,
|
||||
marginLeft: theme.spacing(2),
|
||||
marginRight: theme.spacing(1),
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
maxWidth: 270,
|
||||
},
|
||||
},
|
||||
noShadow: {
|
||||
boxShadow: "none !important",
|
||||
},
|
||||
}));
|
||||
|
||||
const NotificationsPopOver = () => {
|
||||
const classes = useStyles();
|
||||
|
||||
const history = useHistory();
|
||||
const { user } = useContext(AuthContext);
|
||||
const ticketIdUrl = +history.location.pathname.split("/")[2];
|
||||
const ticketIdRef = useRef(ticketIdUrl);
|
||||
const anchorEl = useRef();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
|
||||
const [, setDesktopNotifications] = useState([]);
|
||||
|
||||
const { tickets } = useTickets({ withUnreadMessages: "true" });
|
||||
const [play] = useSound(alertSound);
|
||||
const soundAlertRef = useRef();
|
||||
|
||||
const historyRef = useRef(history);
|
||||
|
||||
useEffect(() => {
|
||||
soundAlertRef.current = play;
|
||||
|
||||
if (!("Notification" in window)) {
|
||||
console.log("This browser doesn't support notifications");
|
||||
} else {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
}, [play]);
|
||||
|
||||
useEffect(() => {
|
||||
setNotifications(tickets);
|
||||
}, [tickets]);
|
||||
|
||||
useEffect(() => {
|
||||
ticketIdRef.current = ticketIdUrl;
|
||||
}, [ticketIdUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket();
|
||||
|
||||
socket.on("connect", () => socket.emit("joinNotification"));
|
||||
|
||||
socket.on("ticket", data => {
|
||||
if (data.action === "updateUnread" || data.action === "delete") {
|
||||
setNotifications(prevState => {
|
||||
const ticketIndex = prevState.findIndex(t => t.id === data.ticketId);
|
||||
if (ticketIndex !== -1) {
|
||||
prevState.splice(ticketIndex, 1);
|
||||
return [...prevState];
|
||||
}
|
||||
return prevState;
|
||||
});
|
||||
|
||||
setDesktopNotifications(prevState => {
|
||||
const notfiticationIndex = prevState.findIndex(
|
||||
n => n.tag === String(data.ticketId)
|
||||
);
|
||||
if (notfiticationIndex !== -1) {
|
||||
prevState[notfiticationIndex].close();
|
||||
prevState.splice(notfiticationIndex, 1);
|
||||
return [...prevState];
|
||||
}
|
||||
return prevState;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("appMessage", data => {
|
||||
if (
|
||||
data.action === "create" &&
|
||||
!data.message.read &&
|
||||
(data.ticket.userId === user?.id || !data.ticket.userId)
|
||||
) {
|
||||
setNotifications(prevState => {
|
||||
const ticketIndex = prevState.findIndex(t => t.id === data.ticket.id);
|
||||
if (ticketIndex !== -1) {
|
||||
prevState[ticketIndex] = data.ticket;
|
||||
return [...prevState];
|
||||
}
|
||||
return [data.ticket, ...prevState];
|
||||
});
|
||||
|
||||
const shouldNotNotificate =
|
||||
(data.message.ticketId === ticketIdRef.current &&
|
||||
document.visibilityState === "visible") ||
|
||||
(data.ticket.userId && data.ticket.userId !== user?.id) ||
|
||||
data.ticket.isGroup;
|
||||
|
||||
if (shouldNotNotificate) return;
|
||||
|
||||
handleNotifications(data);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const handleNotifications = data => {
|
||||
const { message, contact, ticket } = data;
|
||||
|
||||
const options = {
|
||||
body: `${message.body} - ${format(new Date(), "HH:mm")}`,
|
||||
icon: contact.profilePicUrl,
|
||||
tag: ticket.id,
|
||||
renotify: true,
|
||||
};
|
||||
|
||||
const notification = new Notification(
|
||||
`${i18n.t("tickets.notification.message")} ${contact.name}`,
|
||||
options
|
||||
);
|
||||
|
||||
notification.onclick = e => {
|
||||
e.preventDefault();
|
||||
window.focus();
|
||||
historyRef.current.push(`/tickets/${ticket.id}`);
|
||||
};
|
||||
|
||||
setDesktopNotifications(prevState => {
|
||||
const notfiticationIndex = prevState.findIndex(
|
||||
n => n.tag === notification.tag
|
||||
);
|
||||
if (notfiticationIndex !== -1) {
|
||||
prevState[notfiticationIndex] = notification;
|
||||
return [...prevState];
|
||||
}
|
||||
return [notification, ...prevState];
|
||||
});
|
||||
|
||||
soundAlertRef.current();
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
setIsOpen(prevState => !prevState);
|
||||
};
|
||||
|
||||
const handleClickAway = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const NotificationTicket = ({ children }) => {
|
||||
return <div onClick={handleClickAway}>{children}</div>;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={handleClick}
|
||||
ref={anchorEl}
|
||||
aria-label="Open Notifications"
|
||||
color="inherit"
|
||||
>
|
||||
<Badge badgeContent={notifications.length} color="secondary">
|
||||
<ChatIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
<Popover
|
||||
disableScrollLock
|
||||
open={isOpen}
|
||||
anchorEl={anchorEl.current}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "right",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
classes={{ paper: classes.popoverPaper }}
|
||||
onClose={handleClickAway}
|
||||
>
|
||||
<List dense className={classes.tabContainer}>
|
||||
{notifications.length === 0 ? (
|
||||
<ListItem>
|
||||
<ListItemText>{i18n.t("notifications.noTickets")}</ListItemText>
|
||||
</ListItem>
|
||||
) : (
|
||||
notifications.map(ticket => (
|
||||
<NotificationTicket key={ticket.id}>
|
||||
<TicketListItem ticket={ticket} />
|
||||
</NotificationTicket>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsPopOver;
|
||||
64
frontend/src/components/QrcodeModal/index.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import QRCode from "qrcode.react";
|
||||
import openSocket from "../../services/socket-io";
|
||||
import toastError from "../../errors/toastError";
|
||||
|
||||
import { Dialog, DialogContent, Paper, Typography } from "@material-ui/core";
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import api from "../../services/api";
|
||||
|
||||
const QrcodeModal = ({ open, onClose, whatsAppId }) => {
|
||||
const [qrCode, setQrCode] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSession = async () => {
|
||||
if (!whatsAppId) return;
|
||||
|
||||
try {
|
||||
const { data } = await api.get(`/whatsapp/${whatsAppId}`);
|
||||
setQrCode(data.qrcode);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
fetchSession();
|
||||
}, [whatsAppId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!whatsAppId) return;
|
||||
const socket = openSocket();
|
||||
|
||||
socket.on("whatsappSession", data => {
|
||||
if (data.action === "update" && data.session.id === whatsAppId) {
|
||||
setQrCode(data.session.qrcode);
|
||||
}
|
||||
|
||||
if (data.action === "update" && data.session.qrcode === "") {
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [whatsAppId, onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="lg" scroll="paper">
|
||||
<DialogContent>
|
||||
<Paper elevation={0}>
|
||||
<Typography color="primary" gutterBottom>
|
||||
{i18n.t("qrCode.message")}
|
||||
</Typography>
|
||||
{qrCode ? (
|
||||
<QRCode value={qrCode} size={256} />
|
||||
) : (
|
||||
<span>Waiting for QR Code</span>
|
||||
)}
|
||||
</Paper>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(QrcodeModal);
|
||||
252
frontend/src/components/QueueModal/index.js
Normal file
@@ -0,0 +1,252 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
|
||||
import * as Yup from "yup";
|
||||
import { Formik, Form, Field } from "formik";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import { green } from "@material-ui/core/colors";
|
||||
import Button from "@material-ui/core/Button";
|
||||
import TextField from "@material-ui/core/TextField";
|
||||
import Dialog from "@material-ui/core/Dialog";
|
||||
import DialogActions from "@material-ui/core/DialogActions";
|
||||
import DialogContent from "@material-ui/core/DialogContent";
|
||||
import DialogTitle from "@material-ui/core/DialogTitle";
|
||||
import CircularProgress from "@material-ui/core/CircularProgress";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
|
||||
import api from "../../services/api";
|
||||
import toastError from "../../errors/toastError";
|
||||
import ColorPicker from "../ColorPicker";
|
||||
import { IconButton, InputAdornment } from "@material-ui/core";
|
||||
import { Colorize } from "@material-ui/icons";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
textField: {
|
||||
marginRight: theme.spacing(1),
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
btnWrapper: {
|
||||
position: "relative",
|
||||
},
|
||||
|
||||
buttonProgress: {
|
||||
color: green[500],
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
marginTop: -12,
|
||||
marginLeft: -12,
|
||||
},
|
||||
formControl: {
|
||||
margin: theme.spacing(1),
|
||||
minWidth: 120,
|
||||
},
|
||||
colorAdorment: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
}));
|
||||
|
||||
const QueueSchema = Yup.object().shape({
|
||||
name: Yup.string()
|
||||
.min(2, "Too Short!")
|
||||
.max(50, "Too Long!")
|
||||
.required("Required"),
|
||||
color: Yup.string().min(3, "Too Short!").max(9, "Too Long!").required(),
|
||||
greetingMessage: Yup.string(),
|
||||
});
|
||||
|
||||
const QueueModal = ({ open, onClose, queueId }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
const initialState = {
|
||||
name: "",
|
||||
color: "",
|
||||
greetingMessage: "",
|
||||
};
|
||||
|
||||
const [colorPickerModalOpen, setColorPickerModalOpen] = useState(false);
|
||||
const [queue, setQueue] = useState(initialState);
|
||||
const greetingRef = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!queueId) return;
|
||||
try {
|
||||
const { data } = await api.get(`/queue/${queueId}`);
|
||||
setQueue(prevState => {
|
||||
return { ...prevState, ...data };
|
||||
});
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
setQueue({
|
||||
name: "",
|
||||
color: "",
|
||||
greetingMessage: "",
|
||||
});
|
||||
};
|
||||
}, [queueId, open]);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setQueue(initialState);
|
||||
};
|
||||
|
||||
const handleSaveQueue = async values => {
|
||||
try {
|
||||
if (queueId) {
|
||||
await api.put(`/queue/${queueId}`, values);
|
||||
} else {
|
||||
await api.post("/queue", values);
|
||||
}
|
||||
toast.success("Queue saved successfully");
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Dialog open={open} onClose={handleClose} scroll="paper">
|
||||
<DialogTitle>
|
||||
{queueId
|
||||
? `${i18n.t("queueModal.title.edit")}`
|
||||
: `${i18n.t("queueModal.title.add")}`}
|
||||
</DialogTitle>
|
||||
<Formik
|
||||
initialValues={queue}
|
||||
enableReinitialize={true}
|
||||
validationSchema={QueueSchema}
|
||||
onSubmit={(values, actions) => {
|
||||
setTimeout(() => {
|
||||
handleSaveQueue(values);
|
||||
actions.setSubmitting(false);
|
||||
}, 400);
|
||||
}}
|
||||
>
|
||||
{({ touched, errors, isSubmitting, values }) => (
|
||||
<Form>
|
||||
<DialogContent dividers>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("queueModal.form.name")}
|
||||
autoFocus
|
||||
name="name"
|
||||
error={touched.name && Boolean(errors.name)}
|
||||
helperText={touched.name && errors.name}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
className={classes.textField}
|
||||
/>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("queueModal.form.color")}
|
||||
name="color"
|
||||
id="color"
|
||||
onFocus={() => {
|
||||
setColorPickerModalOpen(true);
|
||||
greetingRef.current.focus();
|
||||
}}
|
||||
error={touched.color && Boolean(errors.color)}
|
||||
helperText={touched.color && errors.color}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<div
|
||||
style={{ backgroundColor: values.color }}
|
||||
className={classes.colorAdorment}
|
||||
></div>
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="default"
|
||||
onClick={() => setColorPickerModalOpen(true)}
|
||||
>
|
||||
<Colorize />
|
||||
</IconButton>
|
||||
),
|
||||
}}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
/>
|
||||
<ColorPicker
|
||||
open={colorPickerModalOpen}
|
||||
handleClose={() => setColorPickerModalOpen(false)}
|
||||
onChange={color => {
|
||||
values.color = color;
|
||||
setQueue(() => {
|
||||
return { ...values, color };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("queueModal.form.greetingMessage")}
|
||||
type="greetingMessage"
|
||||
multiline
|
||||
inputRef={greetingRef}
|
||||
rows={5}
|
||||
fullWidth
|
||||
name="greetingMessage"
|
||||
error={
|
||||
touched.greetingMessage && Boolean(errors.greetingMessage)
|
||||
}
|
||||
helperText={
|
||||
touched.greetingMessage && errors.greetingMessage
|
||||
}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
color="secondary"
|
||||
disabled={isSubmitting}
|
||||
variant="outlined"
|
||||
>
|
||||
{i18n.t("queueModal.buttons.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={isSubmitting}
|
||||
variant="contained"
|
||||
className={classes.btnWrapper}
|
||||
>
|
||||
{queueId
|
||||
? `${i18n.t("queueModal.buttons.okEdit")}`
|
||||
: `${i18n.t("queueModal.buttons.okAdd")}`}
|
||||
{isSubmitting && (
|
||||
<CircularProgress
|
||||
size={24}
|
||||
className={classes.buttonProgress}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QueueModal;
|
||||
90
frontend/src/components/QueueSelect/index.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import InputLabel from "@material-ui/core/InputLabel";
|
||||
import MenuItem from "@material-ui/core/MenuItem";
|
||||
import FormControl from "@material-ui/core/FormControl";
|
||||
import Select from "@material-ui/core/Select";
|
||||
import Chip from "@material-ui/core/Chip";
|
||||
import toastError from "../../errors/toastError";
|
||||
import api from "../../services/api";
|
||||
import { i18n } from "../../translate/i18n";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
chips: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
chip: {
|
||||
margin: 2,
|
||||
},
|
||||
}));
|
||||
|
||||
const QueueSelect = ({ selectedQueueIds, onChange }) => {
|
||||
const classes = useStyles();
|
||||
const [queues, setQueues] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const { data } = await api.get("/queue");
|
||||
setQueues(data);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const handleChange = e => {
|
||||
onChange(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<FormControl fullWidth margin="dense" variant="outlined">
|
||||
<InputLabel>{i18n.t("queueSelect.inputLabel")}</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
labelWidth={60}
|
||||
value={selectedQueueIds}
|
||||
onChange={handleChange}
|
||||
MenuProps={{
|
||||
anchorOrigin: {
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
},
|
||||
transformOrigin: {
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
},
|
||||
getContentAnchorEl: null,
|
||||
}}
|
||||
renderValue={selected => (
|
||||
<div className={classes.chips}>
|
||||
{selected?.length > 0 &&
|
||||
selected.map(id => {
|
||||
const queue = queues.find(q => q.id === id);
|
||||
return queue ? (
|
||||
<Chip
|
||||
key={id}
|
||||
style={{ backgroundColor: queue.color }}
|
||||
variant="outlined"
|
||||
label={queue.name}
|
||||
className={classes.chip}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{queues.map(queue => (
|
||||
<MenuItem key={queue.id} value={queue.id}>
|
||||
{queue.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QueueSelect;
|
||||
222
frontend/src/components/QuickAnswersModal/index.js
Normal file
@@ -0,0 +1,222 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
|
||||
import * as Yup from "yup";
|
||||
import { Formik, Form, Field } from "formik";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import {
|
||||
makeStyles,
|
||||
Button,
|
||||
TextField,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
CircularProgress,
|
||||
} from "@material-ui/core";
|
||||
import { green } from "@material-ui/core/colors";
|
||||
import { i18n } from "../../translate/i18n";
|
||||
|
||||
import api from "../../services/api";
|
||||
import toastError from "../../errors/toastError";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
textField: {
|
||||
marginRight: theme.spacing(1),
|
||||
width: "100%",
|
||||
},
|
||||
|
||||
btnWrapper: {
|
||||
position: "relative",
|
||||
},
|
||||
|
||||
buttonProgress: {
|
||||
color: green[500],
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
marginTop: -12,
|
||||
marginLeft: -12,
|
||||
},
|
||||
textQuickAnswerContainer: {
|
||||
width: "100%",
|
||||
},
|
||||
}));
|
||||
|
||||
const QuickAnswerSchema = Yup.object().shape({
|
||||
shortcut: Yup.string()
|
||||
.min(2, "Too Short!")
|
||||
.max(15, "Too Long!")
|
||||
.required("Required"),
|
||||
message: Yup.string()
|
||||
.min(8, "Too Short!")
|
||||
.max(30000, "Too Long!")
|
||||
.required("Required"),
|
||||
});
|
||||
|
||||
const QuickAnswersModal = ({
|
||||
open,
|
||||
onClose,
|
||||
quickAnswerId,
|
||||
initialValues,
|
||||
onSave,
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
const isMounted = useRef(true);
|
||||
|
||||
const initialState = {
|
||||
shortcut: "",
|
||||
message: "",
|
||||
};
|
||||
|
||||
const [quickAnswer, setQuickAnswer] = useState(initialState);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchQuickAnswer = async () => {
|
||||
if (initialValues) {
|
||||
setQuickAnswer((prevState) => {
|
||||
return { ...prevState, ...initialValues };
|
||||
});
|
||||
}
|
||||
|
||||
if (!quickAnswerId) return;
|
||||
|
||||
try {
|
||||
const { data } = await api.get(`/quickAnswers/${quickAnswerId}`);
|
||||
if (isMounted.current) {
|
||||
setQuickAnswer(data);
|
||||
}
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchQuickAnswer();
|
||||
}, [quickAnswerId, open, initialValues]);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setQuickAnswer(initialState);
|
||||
};
|
||||
|
||||
const handleSaveQuickAnswer = async (values) => {
|
||||
try {
|
||||
if (quickAnswerId) {
|
||||
await api.put(`/quickAnswers/${quickAnswerId}`, values);
|
||||
handleClose();
|
||||
} else {
|
||||
const { data } = await api.post("/quickAnswers", values);
|
||||
if (onSave) {
|
||||
onSave(data);
|
||||
}
|
||||
handleClose();
|
||||
}
|
||||
toast.success(i18n.t("quickAnswersModal.success"));
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
scroll="paper"
|
||||
>
|
||||
<DialogTitle id="form-dialog-title">
|
||||
{quickAnswerId
|
||||
? `${i18n.t("quickAnswersModal.title.edit")}`
|
||||
: `${i18n.t("quickAnswersModal.title.add")}`}
|
||||
</DialogTitle>
|
||||
<Formik
|
||||
initialValues={quickAnswer}
|
||||
enableReinitialize={true}
|
||||
validationSchema={QuickAnswerSchema}
|
||||
onSubmit={(values, actions) => {
|
||||
setTimeout(() => {
|
||||
handleSaveQuickAnswer(values);
|
||||
actions.setSubmitting(false);
|
||||
}, 400);
|
||||
}}
|
||||
>
|
||||
{({ values, errors, touched, isSubmitting }) => (
|
||||
<Form>
|
||||
<DialogContent dividers>
|
||||
<div className={classes.textQuickAnswerContainer}>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("quickAnswersModal.form.shortcut")}
|
||||
name="shortcut"
|
||||
autoFocus
|
||||
error={touched.shortcut && Boolean(errors.shortcut)}
|
||||
helperText={touched.shortcut && errors.shortcut}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
className={classes.textField}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.textQuickAnswerContainer}>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("quickAnswersModal.form.message")}
|
||||
name="message"
|
||||
error={touched.message && Boolean(errors.message)}
|
||||
helperText={touched.message && errors.message}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
className={classes.textField}
|
||||
multiline
|
||||
rows={5}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
color="secondary"
|
||||
disabled={isSubmitting}
|
||||
variant="outlined"
|
||||
>
|
||||
{i18n.t("quickAnswersModal.buttons.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={isSubmitting}
|
||||
variant="contained"
|
||||
className={classes.btnWrapper}
|
||||
>
|
||||
{quickAnswerId
|
||||
? `${i18n.t("quickAnswersModal.buttons.okEdit")}`
|
||||
: `${i18n.t("quickAnswersModal.buttons.okAdd")}`}
|
||||
{isSubmitting && (
|
||||
<CircularProgress
|
||||
size={24}
|
||||
className={classes.buttonProgress}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickAnswersModal;
|
||||
18
frontend/src/components/TabPanel/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
const TabPanel = ({ children, value, name, ...rest }) => {
|
||||
if (value === name) {
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`simple-tabpanel-${name}`}
|
||||
aria-labelledby={`simple-tab-${name}`}
|
||||
{...rest}
|
||||
>
|
||||
<>{children}</>
|
||||
</div>
|
||||
);
|
||||
} else return null;
|
||||
};
|
||||
|
||||
export default TabPanel;
|
||||
52
frontend/src/components/TableRowSkeleton/index.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from "react";
|
||||
import TableCell from "@material-ui/core/TableCell";
|
||||
import TableRow from "@material-ui/core/TableRow";
|
||||
import Skeleton from "@material-ui/lab/Skeleton";
|
||||
import { makeStyles } from "@material-ui/core";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
customTableCell: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
}));
|
||||
|
||||
const TableRowSkeleton = ({ avatar, columns }) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<>
|
||||
<TableRow>
|
||||
{avatar && (
|
||||
<>
|
||||
<TableCell style={{ paddingRight: 0 }}>
|
||||
<Skeleton
|
||||
animation="wave"
|
||||
variant="circle"
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton animation="wave" height={30} width={80} />
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
{Array.from({ length: columns }, (_, index) => (
|
||||
<TableCell align="center" key={index}>
|
||||
<div className={classes.customTableCell}>
|
||||
<Skeleton
|
||||
align="center"
|
||||
animation="wave"
|
||||
height={30}
|
||||
width={80}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableRowSkeleton;
|
||||
185
frontend/src/components/Ticket/index.js
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams, useHistory } from "react-router-dom";
|
||||
|
||||
import { toast } from "react-toastify";
|
||||
import openSocket from "../../services/socket-io";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { Paper, makeStyles } from "@material-ui/core";
|
||||
|
||||
import ContactDrawer from "../ContactDrawer";
|
||||
import MessageInput from "../MessageInput/";
|
||||
import TicketHeader from "../TicketHeader";
|
||||
import TicketInfo from "../TicketInfo";
|
||||
import TicketActionButtons from "../TicketActionButtons";
|
||||
import MessagesList from "../MessagesList";
|
||||
import api from "../../services/api";
|
||||
import { ReplyMessageProvider } from "../../context/ReplyingMessage/ReplyingMessageContext";
|
||||
import toastError from "../../errors/toastError";
|
||||
|
||||
const drawerWidth = 320;
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
},
|
||||
|
||||
ticketInfo: {
|
||||
maxWidth: "50%",
|
||||
flexBasis: "50%",
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
maxWidth: "80%",
|
||||
flexBasis: "80%",
|
||||
},
|
||||
},
|
||||
ticketActionButtons: {
|
||||
maxWidth: "50%",
|
||||
flexBasis: "50%",
|
||||
display: "flex",
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
maxWidth: "100%",
|
||||
flexBasis: "100%",
|
||||
marginBottom: "5px",
|
||||
},
|
||||
},
|
||||
|
||||
mainWrapper: {
|
||||
flex: 1,
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderLeft: "0",
|
||||
marginRight: -drawerWidth,
|
||||
transition: theme.transitions.create("margin", {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
},
|
||||
|
||||
mainWrapperShift: {
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
transition: theme.transitions.create("margin", {
|
||||
easing: theme.transitions.easing.easeOut,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
marginRight: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
const Ticket = () => {
|
||||
const { ticketId } = useParams();
|
||||
const history = useHistory();
|
||||
const classes = useStyles();
|
||||
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [contact, setContact] = useState({});
|
||||
const [ticket, setTicket] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const delayDebounceFn = setTimeout(() => {
|
||||
const fetchTicket = async () => {
|
||||
try {
|
||||
const { data } = await api.get("/tickets/" + ticketId);
|
||||
|
||||
setContact(data.contact);
|
||||
setTicket(data);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setLoading(false);
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
fetchTicket();
|
||||
}, 500);
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [ticketId, history]);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket();
|
||||
|
||||
socket.on("connect", () => socket.emit("joinChatBox", ticketId));
|
||||
|
||||
socket.on("ticket", (data) => {
|
||||
if (data.action === "update") {
|
||||
setTicket(data.ticket);
|
||||
}
|
||||
|
||||
if (data.action === "delete") {
|
||||
toast.success("Ticket deleted sucessfully.");
|
||||
history.push("/tickets");
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("contact", (data) => {
|
||||
if (data.action === "update") {
|
||||
setContact((prevState) => {
|
||||
if (prevState.id === data.contact?.id) {
|
||||
return { ...prevState, ...data.contact };
|
||||
}
|
||||
return prevState;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [ticketId, history]);
|
||||
|
||||
const handleDrawerOpen = () => {
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
setDrawerOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.root} id="drawer-container">
|
||||
<Paper
|
||||
variant="outlined"
|
||||
elevation={0}
|
||||
className={clsx(classes.mainWrapper, {
|
||||
[classes.mainWrapperShift]: drawerOpen,
|
||||
})}
|
||||
>
|
||||
<TicketHeader loading={loading}>
|
||||
<div className={classes.ticketInfo}>
|
||||
<TicketInfo
|
||||
contact={contact}
|
||||
ticket={ticket}
|
||||
onClick={handleDrawerOpen}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.ticketActionButtons}>
|
||||
<TicketActionButtons ticket={ticket} />
|
||||
</div>
|
||||
</TicketHeader>
|
||||
<ReplyMessageProvider>
|
||||
<MessagesList
|
||||
ticketId={ticketId}
|
||||
isGroup={ticket.isGroup}
|
||||
></MessagesList>
|
||||
<MessageInput ticketStatus={ticket.status} />
|
||||
</ReplyMessageProvider>
|
||||
</Paper>
|
||||
<ContactDrawer
|
||||
open={drawerOpen}
|
||||
handleDrawerClose={handleDrawerClose}
|
||||
contact={contact}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Ticket;
|
||||
120
frontend/src/components/TicketActionButtons/index.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import { IconButton } from "@material-ui/core";
|
||||
import { MoreVert, Replay } from "@material-ui/icons";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import api from "../../services/api";
|
||||
import TicketOptionsMenu from "../TicketOptionsMenu";
|
||||
import ButtonWithSpinner from "../ButtonWithSpinner";
|
||||
import toastError from "../../errors/toastError";
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
actionButtons: {
|
||||
marginRight: 6,
|
||||
flex: "none",
|
||||
alignSelf: "center",
|
||||
marginLeft: "auto",
|
||||
"& > *": {
|
||||
margin: theme.spacing(1),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const TicketActionButtons = ({ ticket }) => {
|
||||
const classes = useStyles();
|
||||
const history = useHistory();
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ticketOptionsMenuOpen = Boolean(anchorEl);
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
const handleOpenTicketOptionsMenu = e => {
|
||||
setAnchorEl(e.currentTarget);
|
||||
};
|
||||
|
||||
const handleCloseTicketOptionsMenu = e => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleUpdateTicketStatus = async (e, status, userId) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.put(`/tickets/${ticket.id}`, {
|
||||
status: status,
|
||||
userId: userId || null,
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
if (status === "open") {
|
||||
history.push(`/tickets/${ticket.id}`);
|
||||
} else {
|
||||
history.push("/tickets");
|
||||
}
|
||||
} catch (err) {
|
||||
setLoading(false);
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.actionButtons}>
|
||||
{ticket.status === "closed" && (
|
||||
<ButtonWithSpinner
|
||||
loading={loading}
|
||||
startIcon={<Replay />}
|
||||
size="small"
|
||||
onClick={e => handleUpdateTicketStatus(e, "open", user?.id)}
|
||||
>
|
||||
{i18n.t("messagesList.header.buttons.reopen")}
|
||||
</ButtonWithSpinner>
|
||||
)}
|
||||
{ticket.status === "open" && (
|
||||
<>
|
||||
<ButtonWithSpinner
|
||||
loading={loading}
|
||||
startIcon={<Replay />}
|
||||
size="small"
|
||||
onClick={e => handleUpdateTicketStatus(e, "pending", null)}
|
||||
>
|
||||
{i18n.t("messagesList.header.buttons.return")}
|
||||
</ButtonWithSpinner>
|
||||
<ButtonWithSpinner
|
||||
loading={loading}
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={e => handleUpdateTicketStatus(e, "closed", user?.id)}
|
||||
>
|
||||
{i18n.t("messagesList.header.buttons.resolve")}
|
||||
</ButtonWithSpinner>
|
||||
<IconButton onClick={handleOpenTicketOptionsMenu}>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
<TicketOptionsMenu
|
||||
ticket={ticket}
|
||||
anchorEl={anchorEl}
|
||||
menuOpen={ticketOptionsMenuOpen}
|
||||
handleClose={handleCloseTicketOptionsMenu}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{ticket.status === "pending" && (
|
||||
<ButtonWithSpinner
|
||||
loading={loading}
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={e => handleUpdateTicketStatus(e, "open", user?.id)}
|
||||
>
|
||||
{i18n.t("messagesList.header.buttons.accept")}
|
||||
</ButtonWithSpinner>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketActionButtons;
|
||||
44
frontend/src/components/TicketHeader/index.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
|
||||
import { Card, Button } from "@material-ui/core";
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import TicketHeaderSkeleton from "../TicketHeaderSkeleton";
|
||||
import ArrowBackIos from "@material-ui/icons/ArrowBackIos";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
ticketHeader: {
|
||||
display: "flex",
|
||||
backgroundColor: "#eee",
|
||||
flex: "none",
|
||||
borderBottom: "1px solid rgba(0, 0, 0, 0.12)",
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const TicketHeader = ({ loading, children }) => {
|
||||
const classes = useStyles();
|
||||
const history = useHistory();
|
||||
const handleBack = () => {
|
||||
history.push("/tickets");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading ? (
|
||||
<TicketHeaderSkeleton />
|
||||
) : (
|
||||
<Card square className={classes.ticketHeader}>
|
||||
<Button color="primary" onClick={handleBack}>
|
||||
<ArrowBackIos />
|
||||
</Button>
|
||||
{children}
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketHeader;
|
||||
36
frontend/src/components/TicketHeaderSkeleton/index.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import { Avatar, Card, CardHeader } from "@material-ui/core";
|
||||
import Skeleton from "@material-ui/lab/Skeleton";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
ticketHeader: {
|
||||
display: "flex",
|
||||
backgroundColor: "#eee",
|
||||
flex: "none",
|
||||
borderBottom: "1px solid rgba(0, 0, 0, 0.12)",
|
||||
},
|
||||
}));
|
||||
|
||||
const TicketHeaderSkeleton = () => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Card square className={classes.ticketHeader}>
|
||||
<CardHeader
|
||||
titleTypographyProps={{ noWrap: true }}
|
||||
subheaderTypographyProps={{ noWrap: true }}
|
||||
avatar={
|
||||
<Skeleton animation="wave" variant="circle">
|
||||
<Avatar alt="contact_image" />
|
||||
</Skeleton>
|
||||
}
|
||||
title={<Skeleton animation="wave" width={80} />}
|
||||
subheader={<Skeleton animation="wave" width={140} />}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketHeaderSkeleton;
|
||||
24
frontend/src/components/TicketInfo/index.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
|
||||
import { Avatar, CardHeader } from "@material-ui/core";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
|
||||
const TicketInfo = ({ contact, ticket, onClick }) => {
|
||||
return (
|
||||
<CardHeader
|
||||
onClick={onClick}
|
||||
style={{ cursor: "pointer" }}
|
||||
titleTypographyProps={{ noWrap: true }}
|
||||
subheaderTypographyProps={{ noWrap: true }}
|
||||
avatar={<Avatar src={contact.profilePicUrl} alt="contact_image" />}
|
||||
title={`${contact.name} #${ticket.id}`}
|
||||
subheader={
|
||||
ticket.user &&
|
||||
`${i18n.t("messagesList.header.assignedTo")} ${ticket.user.name}`
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketInfo;
|
||||
263
frontend/src/components/TicketListItem/index.js
Normal file
@@ -0,0 +1,263 @@
|
||||
import React, { useState, useEffect, useRef, useContext } from "react";
|
||||
|
||||
import { useHistory, useParams } from "react-router-dom";
|
||||
import { parseISO, format, isSameDay } from "date-fns";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import { green } from "@material-ui/core/colors";
|
||||
import ListItem from "@material-ui/core/ListItem";
|
||||
import ListItemText from "@material-ui/core/ListItemText";
|
||||
import ListItemAvatar from "@material-ui/core/ListItemAvatar";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
import Avatar from "@material-ui/core/Avatar";
|
||||
import Divider from "@material-ui/core/Divider";
|
||||
import Badge from "@material-ui/core/Badge";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
|
||||
import api from "../../services/api";
|
||||
import ButtonWithSpinner from "../ButtonWithSpinner";
|
||||
import MarkdownWrapper from "../MarkdownWrapper";
|
||||
import { Tooltip } from "@material-ui/core";
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
import toastError from "../../errors/toastError";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
ticket: {
|
||||
position: "relative",
|
||||
},
|
||||
|
||||
pendingTicket: {
|
||||
cursor: "unset",
|
||||
},
|
||||
|
||||
noTicketsDiv: {
|
||||
display: "flex",
|
||||
height: "100px",
|
||||
margin: 40,
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
|
||||
noTicketsText: {
|
||||
textAlign: "center",
|
||||
color: "rgb(104, 121, 146)",
|
||||
fontSize: "14px",
|
||||
lineHeight: "1.4",
|
||||
},
|
||||
|
||||
noTicketsTitle: {
|
||||
textAlign: "center",
|
||||
fontSize: "16px",
|
||||
fontWeight: "600",
|
||||
margin: "0px",
|
||||
},
|
||||
|
||||
contactNameWrapper: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
|
||||
lastMessageTime: {
|
||||
justifySelf: "flex-end",
|
||||
},
|
||||
|
||||
closedBadge: {
|
||||
alignSelf: "center",
|
||||
justifySelf: "flex-end",
|
||||
marginRight: 32,
|
||||
marginLeft: "auto",
|
||||
},
|
||||
|
||||
contactLastMessage: {
|
||||
paddingRight: 20,
|
||||
},
|
||||
|
||||
newMessagesCount: {
|
||||
alignSelf: "center",
|
||||
marginRight: 8,
|
||||
marginLeft: "auto",
|
||||
},
|
||||
|
||||
badgeStyle: {
|
||||
color: "white",
|
||||
backgroundColor: green[500],
|
||||
},
|
||||
|
||||
acceptButton: {
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
},
|
||||
|
||||
ticketQueueColor: {
|
||||
flex: "none",
|
||||
width: "8px",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
top: "0%",
|
||||
left: "0%",
|
||||
},
|
||||
|
||||
userTag: {
|
||||
position: "absolute",
|
||||
marginRight: 5,
|
||||
right: 5,
|
||||
bottom: 5,
|
||||
background: "#2576D2",
|
||||
color: "#ffffff",
|
||||
border: "1px solid #CCC",
|
||||
padding: 1,
|
||||
paddingLeft: 5,
|
||||
paddingRight: 5,
|
||||
borderRadius: 10,
|
||||
fontSize: "0.9em"
|
||||
},
|
||||
}));
|
||||
|
||||
const TicketListItem = ({ ticket }) => {
|
||||
const classes = useStyles();
|
||||
const history = useHistory();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { ticketId } = useParams();
|
||||
const isMounted = useRef(true);
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAcepptTicket = async id => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.put(`/tickets/${id}`, {
|
||||
status: "open",
|
||||
userId: user?.id,
|
||||
});
|
||||
} catch (err) {
|
||||
setLoading(false);
|
||||
toastError(err);
|
||||
}
|
||||
if (isMounted.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
history.push(`/tickets/${id}`);
|
||||
};
|
||||
|
||||
const handleSelectTicket = id => {
|
||||
history.push(`/tickets/${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment key={ticket.id}>
|
||||
<ListItem
|
||||
dense
|
||||
button
|
||||
onClick={e => {
|
||||
if (ticket.status === "pending") return;
|
||||
handleSelectTicket(ticket.id);
|
||||
}}
|
||||
selected={ticketId && +ticketId === ticket.id}
|
||||
className={clsx(classes.ticket, {
|
||||
[classes.pendingTicket]: ticket.status === "pending",
|
||||
})}
|
||||
>
|
||||
<Tooltip
|
||||
arrow
|
||||
placement="right"
|
||||
title={ticket.queue?.name || "Sem fila"}
|
||||
>
|
||||
<span
|
||||
style={{ backgroundColor: ticket.queue?.color || "#7C7C7C" }}
|
||||
className={classes.ticketQueueColor}
|
||||
></span>
|
||||
</Tooltip>
|
||||
<ListItemAvatar>
|
||||
<Avatar src={ticket?.contact?.profilePicUrl} />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
disableTypography
|
||||
primary={
|
||||
<span className={classes.contactNameWrapper}>
|
||||
<Typography
|
||||
noWrap
|
||||
component="span"
|
||||
variant="body2"
|
||||
color="textPrimary"
|
||||
>
|
||||
{ticket.contact.name}
|
||||
</Typography>
|
||||
{ticket.status === "closed" && (
|
||||
<Badge
|
||||
className={classes.closedBadge}
|
||||
badgeContent={"closed"}
|
||||
color="primary"
|
||||
/>
|
||||
)}
|
||||
{ticket.lastMessage && (
|
||||
<Typography
|
||||
className={classes.lastMessageTime}
|
||||
component="span"
|
||||
variant="body2"
|
||||
color="textSecondary"
|
||||
>
|
||||
{isSameDay(parseISO(ticket.updatedAt), new Date()) ? (
|
||||
<>{format(parseISO(ticket.updatedAt), "HH:mm")}</>
|
||||
) : (
|
||||
<>{format(parseISO(ticket.updatedAt), "dd/MM/yyyy")}</>
|
||||
)}
|
||||
</Typography>
|
||||
)}
|
||||
{ticket.whatsappId && (
|
||||
<div className={classes.userTag} title={i18n.t("ticketsList.connectionTitle")}>{ticket.whatsapp?.name}</div>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
secondary={
|
||||
<span className={classes.contactNameWrapper}>
|
||||
<Typography
|
||||
className={classes.contactLastMessage}
|
||||
noWrap
|
||||
component="span"
|
||||
variant="body2"
|
||||
color="textSecondary"
|
||||
>
|
||||
{ticket.lastMessage ? (
|
||||
<MarkdownWrapper>{ticket.lastMessage}</MarkdownWrapper>
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
<Badge
|
||||
className={classes.newMessagesCount}
|
||||
badgeContent={ticket.unreadMessages}
|
||||
classes={{
|
||||
badge: classes.badgeStyle,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{ticket.status === "pending" && (
|
||||
<ButtonWithSpinner
|
||||
color="primary"
|
||||
variant="contained"
|
||||
className={classes.acceptButton}
|
||||
size="small"
|
||||
loading={loading}
|
||||
onClick={e => handleAcepptTicket(ticket.id)}
|
||||
>
|
||||
{i18n.t("ticketsList.buttons.accept")}
|
||||
</ButtonWithSpinner>
|
||||
)}
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketListItem;
|
||||
103
frontend/src/components/TicketOptionsMenu/index.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import React, { useContext, useEffect, useRef, useState } from "react";
|
||||
|
||||
import MenuItem from "@material-ui/core/MenuItem";
|
||||
import Menu from "@material-ui/core/Menu";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import api from "../../services/api";
|
||||
import ConfirmationModal from "../ConfirmationModal";
|
||||
import TransferTicketModal from "../TransferTicketModal";
|
||||
import toastError from "../../errors/toastError";
|
||||
import { Can } from "../Can";
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
|
||||
const TicketOptionsMenu = ({ ticket, menuOpen, handleClose, anchorEl }) => {
|
||||
const [confirmationOpen, setConfirmationOpen] = useState(false);
|
||||
const [transferTicketModalOpen, setTransferTicketModalOpen] = useState(false);
|
||||
const isMounted = useRef(true);
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDeleteTicket = async () => {
|
||||
try {
|
||||
await api.delete(`/tickets/${ticket.id}`);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenConfirmationModal = e => {
|
||||
setConfirmationOpen(true);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleOpenTransferModal = e => {
|
||||
setTransferTicketModalOpen(true);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleCloseTransferTicketModal = () => {
|
||||
if (isMounted.current) {
|
||||
setTransferTicketModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
id="menu-appbar"
|
||||
anchorEl={anchorEl}
|
||||
getContentAnchorEl={null}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "right",
|
||||
}}
|
||||
keepMounted
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
open={menuOpen}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<MenuItem onClick={handleOpenTransferModal}>
|
||||
{i18n.t("ticketOptionsMenu.transfer")}
|
||||
</MenuItem>
|
||||
<Can
|
||||
role={user.profile}
|
||||
perform="ticket-options:deleteTicket"
|
||||
yes={() => (
|
||||
<MenuItem onClick={handleOpenConfirmationModal}>
|
||||
{i18n.t("ticketOptionsMenu.delete")}
|
||||
</MenuItem>
|
||||
)}
|
||||
/>
|
||||
</Menu>
|
||||
<ConfirmationModal
|
||||
title={`${i18n.t("ticketOptionsMenu.confirmationModal.title")}${
|
||||
ticket.id
|
||||
} ${i18n.t("ticketOptionsMenu.confirmationModal.titleFrom")} ${
|
||||
ticket.contact.name
|
||||
}?`}
|
||||
open={confirmationOpen}
|
||||
onClose={setConfirmationOpen}
|
||||
onConfirm={handleDeleteTicket}
|
||||
>
|
||||
{i18n.t("ticketOptionsMenu.confirmationModal.message")}
|
||||
</ConfirmationModal>
|
||||
<TransferTicketModal
|
||||
modalOpen={transferTicketModalOpen}
|
||||
onClose={handleCloseTransferTicketModal}
|
||||
ticketid={ticket.id}
|
||||
ticketWhatsappId={ticket.whatsappId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketOptionsMenu;
|
||||
303
frontend/src/components/TicketsList/index.js
Normal file
@@ -0,0 +1,303 @@
|
||||
import React, { useState, useEffect, useReducer, useContext } from "react";
|
||||
import openSocket from "../../services/socket-io";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import List from "@material-ui/core/List";
|
||||
import Paper from "@material-ui/core/Paper";
|
||||
|
||||
import TicketListItem from "../TicketListItem";
|
||||
import TicketsListSkeleton from "../TicketsListSkeleton";
|
||||
|
||||
import useTickets from "../../hooks/useTickets";
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
ticketsListWrapper: {
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
|
||||
ticketsList: {
|
||||
flex: 1,
|
||||
overflowY: "scroll",
|
||||
...theme.scrollbarStyles,
|
||||
borderTop: "2px solid rgba(0, 0, 0, 0.12)",
|
||||
},
|
||||
|
||||
ticketsListHeader: {
|
||||
color: "rgb(67, 83, 105)",
|
||||
zIndex: 2,
|
||||
backgroundColor: "white",
|
||||
borderBottom: "1px solid rgba(0, 0, 0, 0.12)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
|
||||
ticketsCount: {
|
||||
fontWeight: "normal",
|
||||
color: "rgb(104, 121, 146)",
|
||||
marginLeft: "8px",
|
||||
fontSize: "14px",
|
||||
},
|
||||
|
||||
noTicketsText: {
|
||||
textAlign: "center",
|
||||
color: "rgb(104, 121, 146)",
|
||||
fontSize: "14px",
|
||||
lineHeight: "1.4",
|
||||
},
|
||||
|
||||
noTicketsTitle: {
|
||||
textAlign: "center",
|
||||
fontSize: "16px",
|
||||
fontWeight: "600",
|
||||
margin: "0px",
|
||||
},
|
||||
|
||||
noTicketsDiv: {
|
||||
display: "flex",
|
||||
height: "100px",
|
||||
margin: 40,
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
}));
|
||||
|
||||
const reducer = (state, action) => {
|
||||
if (action.type === "LOAD_TICKETS") {
|
||||
const newTickets = action.payload;
|
||||
|
||||
newTickets.forEach(ticket => {
|
||||
const ticketIndex = state.findIndex(t => t.id === ticket.id);
|
||||
if (ticketIndex !== -1) {
|
||||
state[ticketIndex] = ticket;
|
||||
if (ticket.unreadMessages > 0) {
|
||||
state.unshift(state.splice(ticketIndex, 1)[0]);
|
||||
}
|
||||
} else {
|
||||
state.push(ticket);
|
||||
}
|
||||
});
|
||||
|
||||
return [...state];
|
||||
}
|
||||
|
||||
if (action.type === "RESET_UNREAD") {
|
||||
const ticketId = action.payload;
|
||||
|
||||
const ticketIndex = state.findIndex(t => t.id === ticketId);
|
||||
if (ticketIndex !== -1) {
|
||||
state[ticketIndex].unreadMessages = 0;
|
||||
}
|
||||
|
||||
return [...state];
|
||||
}
|
||||
|
||||
if (action.type === "UPDATE_TICKET") {
|
||||
const ticket = action.payload;
|
||||
|
||||
const ticketIndex = state.findIndex(t => t.id === ticket.id);
|
||||
if (ticketIndex !== -1) {
|
||||
state[ticketIndex] = ticket;
|
||||
} else {
|
||||
state.unshift(ticket);
|
||||
}
|
||||
|
||||
return [...state];
|
||||
}
|
||||
|
||||
if (action.type === "UPDATE_TICKET_UNREAD_MESSAGES") {
|
||||
const ticket = action.payload;
|
||||
|
||||
const ticketIndex = state.findIndex(t => t.id === ticket.id);
|
||||
if (ticketIndex !== -1) {
|
||||
state[ticketIndex] = ticket;
|
||||
state.unshift(state.splice(ticketIndex, 1)[0]);
|
||||
} else {
|
||||
state.unshift(ticket);
|
||||
}
|
||||
|
||||
return [...state];
|
||||
}
|
||||
|
||||
if (action.type === "UPDATE_TICKET_CONTACT") {
|
||||
const contact = action.payload;
|
||||
const ticketIndex = state.findIndex(t => t.contactId === contact.id);
|
||||
if (ticketIndex !== -1) {
|
||||
state[ticketIndex].contact = contact;
|
||||
}
|
||||
return [...state];
|
||||
}
|
||||
|
||||
if (action.type === "DELETE_TICKET") {
|
||||
const ticketId = action.payload;
|
||||
const ticketIndex = state.findIndex(t => t.id === ticketId);
|
||||
if (ticketIndex !== -1) {
|
||||
state.splice(ticketIndex, 1);
|
||||
}
|
||||
|
||||
return [...state];
|
||||
}
|
||||
|
||||
if (action.type === "RESET") {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const TicketsList = (props) => {
|
||||
const { status, searchParam, showAll, selectedQueueIds, updateCount, style } =
|
||||
props;
|
||||
const classes = useStyles();
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
const [ticketsList, dispatch] = useReducer(reducer, []);
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: "RESET" });
|
||||
setPageNumber(1);
|
||||
}, [status, searchParam, dispatch, showAll, selectedQueueIds]);
|
||||
|
||||
const { tickets, hasMore, loading } = useTickets({
|
||||
pageNumber,
|
||||
searchParam,
|
||||
status,
|
||||
showAll,
|
||||
queueIds: JSON.stringify(selectedQueueIds),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!status && !searchParam) return;
|
||||
dispatch({
|
||||
type: "LOAD_TICKETS",
|
||||
payload: tickets,
|
||||
});
|
||||
}, [tickets, status, searchParam]);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket();
|
||||
|
||||
const shouldUpdateTicket = ticket =>
|
||||
(!ticket.userId || ticket.userId === user?.id || showAll) &&
|
||||
(!ticket.queueId || selectedQueueIds.indexOf(ticket.queueId) > -1);
|
||||
|
||||
const notBelongsToUserQueues = ticket =>
|
||||
ticket.queueId && selectedQueueIds.indexOf(ticket.queueId) === -1;
|
||||
|
||||
socket.on("connect", () => {
|
||||
if (status) {
|
||||
socket.emit("joinTickets", status);
|
||||
} else {
|
||||
socket.emit("joinNotification");
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("ticket", data => {
|
||||
if (data.action === "updateUnread") {
|
||||
dispatch({
|
||||
type: "RESET_UNREAD",
|
||||
payload: data.ticketId,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.action === "update" && shouldUpdateTicket(data.ticket)) {
|
||||
dispatch({
|
||||
type: "UPDATE_TICKET",
|
||||
payload: data.ticket,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.action === "update" && notBelongsToUserQueues(data.ticket)) {
|
||||
dispatch({ type: "DELETE_TICKET", payload: data.ticket.id });
|
||||
}
|
||||
|
||||
if (data.action === "delete") {
|
||||
dispatch({ type: "DELETE_TICKET", payload: data.ticketId });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("appMessage", data => {
|
||||
if (data.action === "create" && shouldUpdateTicket(data.ticket)) {
|
||||
dispatch({
|
||||
type: "UPDATE_TICKET_UNREAD_MESSAGES",
|
||||
payload: data.ticket,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("contact", data => {
|
||||
if (data.action === "update") {
|
||||
dispatch({
|
||||
type: "UPDATE_TICKET_CONTACT",
|
||||
payload: data.contact,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [status, showAll, user, selectedQueueIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof updateCount === "function") {
|
||||
updateCount(ticketsList.length);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ticketsList]);
|
||||
|
||||
const loadMore = () => {
|
||||
setPageNumber(prevState => prevState + 1);
|
||||
};
|
||||
|
||||
const handleScroll = e => {
|
||||
if (!hasMore || loading) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
|
||||
if (scrollHeight - (scrollTop + 100) < clientHeight) {
|
||||
loadMore();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper className={classes.ticketsListWrapper} style={style}>
|
||||
<Paper
|
||||
square
|
||||
name="closed"
|
||||
elevation={0}
|
||||
className={classes.ticketsList}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<List style={{ paddingTop: 0 }}>
|
||||
{ticketsList.length === 0 && !loading ? (
|
||||
<div className={classes.noTicketsDiv}>
|
||||
<span className={classes.noTicketsTitle}>
|
||||
{i18n.t("ticketsList.noTicketsTitle")}
|
||||
</span>
|
||||
<p className={classes.noTicketsText}>
|
||||
{i18n.t("ticketsList.noTicketsMessage")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{ticketsList.map(ticket => (
|
||||
<TicketListItem ticket={ticket} key={ticket.id} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{loading && <TicketsListSkeleton />}
|
||||
</List>
|
||||
</Paper>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketsList;
|
||||
46
frontend/src/components/TicketsListSkeleton/index.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
|
||||
import ListItem from "@material-ui/core/ListItem";
|
||||
import ListItemText from "@material-ui/core/ListItemText";
|
||||
import ListItemAvatar from "@material-ui/core/ListItemAvatar";
|
||||
import Divider from "@material-ui/core/Divider";
|
||||
import Skeleton from "@material-ui/lab/Skeleton";
|
||||
|
||||
const TicketsSkeleton = () => {
|
||||
return (
|
||||
<>
|
||||
<ListItem dense>
|
||||
<ListItemAvatar>
|
||||
<Skeleton animation="wave" variant="circle" width={40} height={40} />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={<Skeleton animation="wave" height={20} width={60} />}
|
||||
secondary={<Skeleton animation="wave" height={20} width={90} />}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider variant="inset" />
|
||||
<ListItem dense>
|
||||
<ListItemAvatar>
|
||||
<Skeleton animation="wave" variant="circle" width={40} height={40} />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={<Skeleton animation="wave" height={20} width={70} />}
|
||||
secondary={<Skeleton animation="wave" height={20} width={120} />}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider variant="inset" />
|
||||
<ListItem dense>
|
||||
<ListItemAvatar>
|
||||
<Skeleton animation="wave" variant="circle" width={40} height={40} />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={<Skeleton animation="wave" height={20} width={60} />}
|
||||
secondary={<Skeleton animation="wave" height={20} width={90} />}
|
||||
/>
|
||||
</ListItem>
|
||||
<Divider variant="inset" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketsSkeleton;
|
||||
309
frontend/src/components/TicketsManager/index.js
Normal file
@@ -0,0 +1,309 @@
|
||||
import React, { useContext, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import Paper from "@material-ui/core/Paper";
|
||||
import SearchIcon from "@material-ui/icons/Search";
|
||||
import InputBase from "@material-ui/core/InputBase";
|
||||
import Tabs from "@material-ui/core/Tabs";
|
||||
import Tab from "@material-ui/core/Tab";
|
||||
import Badge from "@material-ui/core/Badge";
|
||||
import MoveToInboxIcon from "@material-ui/icons/MoveToInbox";
|
||||
import CheckBoxIcon from "@material-ui/icons/CheckBox";
|
||||
|
||||
import FormControlLabel from "@material-ui/core/FormControlLabel";
|
||||
import Switch from "@material-ui/core/Switch";
|
||||
|
||||
import NewTicketModal from "../NewTicketModal";
|
||||
import TicketsList from "../TicketsList";
|
||||
import TabPanel from "../TabPanel";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
import { Can } from "../Can";
|
||||
import TicketsQueueSelect from "../TicketsQueueSelect";
|
||||
import { Button } from "@material-ui/core";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
ticketsWrapper: {
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
|
||||
tabsHeader: {
|
||||
flex: "none",
|
||||
backgroundColor: "#eee",
|
||||
},
|
||||
|
||||
settingsIcon: {
|
||||
alignSelf: "center",
|
||||
marginLeft: "auto",
|
||||
padding: 8,
|
||||
},
|
||||
|
||||
tab: {
|
||||
minWidth: 120,
|
||||
width: 120,
|
||||
},
|
||||
|
||||
ticketOptionsBox: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
background: "#fafafa",
|
||||
padding: theme.spacing(1),
|
||||
},
|
||||
|
||||
serachInputWrapper: {
|
||||
flex: 1,
|
||||
background: "#fff",
|
||||
display: "flex",
|
||||
borderRadius: 40,
|
||||
padding: 4,
|
||||
marginRight: theme.spacing(1),
|
||||
},
|
||||
|
||||
searchIcon: {
|
||||
color: "grey",
|
||||
marginLeft: 6,
|
||||
marginRight: 6,
|
||||
alignSelf: "center",
|
||||
},
|
||||
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
border: "none",
|
||||
borderRadius: 30,
|
||||
},
|
||||
|
||||
badge: {
|
||||
right: "-10px",
|
||||
},
|
||||
show: {
|
||||
display: "block",
|
||||
},
|
||||
hide: {
|
||||
display: "none !important",
|
||||
},
|
||||
}));
|
||||
|
||||
const TicketsManager = () => {
|
||||
const classes = useStyles();
|
||||
|
||||
const [searchParam, setSearchParam] = useState("");
|
||||
const [tab, setTab] = useState("open");
|
||||
const [tabOpen, setTabOpen] = useState("open");
|
||||
const [newTicketModalOpen, setNewTicketModalOpen] = useState(false);
|
||||
const [showAllTickets, setShowAllTickets] = useState(false);
|
||||
const searchInputRef = useRef();
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
const [openCount, setOpenCount] = useState(0);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
|
||||
const userQueueIds = user.queues.map((q) => q.id);
|
||||
const [selectedQueueIds, setSelectedQueueIds] = useState(userQueueIds || []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user.profile.toUpperCase() === "ADMIN") {
|
||||
setShowAllTickets(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === "search") {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [tab]);
|
||||
|
||||
let searchTimeout;
|
||||
|
||||
const handleSearch = (e) => {
|
||||
const searchedTerm = e.target.value.toLowerCase();
|
||||
|
||||
clearTimeout(searchTimeout);
|
||||
|
||||
if (searchedTerm === "") {
|
||||
setSearchParam(searchedTerm);
|
||||
setTab("open");
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearchParam(searchedTerm);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleChangeTab = (e, newValue) => {
|
||||
setTab(newValue);
|
||||
};
|
||||
|
||||
const handleChangeTabOpen = (e, newValue) => {
|
||||
setTabOpen(newValue);
|
||||
};
|
||||
|
||||
const applyPanelStyle = (status) => {
|
||||
if (tabOpen !== status) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper elevation={0} variant="outlined" className={classes.ticketsWrapper}>
|
||||
<NewTicketModal
|
||||
modalOpen={newTicketModalOpen}
|
||||
onClose={(e) => setNewTicketModalOpen(false)}
|
||||
/>
|
||||
<Paper elevation={0} square className={classes.tabsHeader}>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={handleChangeTab}
|
||||
variant="fullWidth"
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
aria-label="icon label tabs example"
|
||||
>
|
||||
<Tab
|
||||
value={"open"}
|
||||
icon={<MoveToInboxIcon />}
|
||||
label={i18n.t("tickets.tabs.open.title")}
|
||||
classes={{ root: classes.tab }}
|
||||
/>
|
||||
<Tab
|
||||
value={"closed"}
|
||||
icon={<CheckBoxIcon />}
|
||||
label={i18n.t("tickets.tabs.closed.title")}
|
||||
classes={{ root: classes.tab }}
|
||||
/>
|
||||
<Tab
|
||||
value={"search"}
|
||||
icon={<SearchIcon />}
|
||||
label={i18n.t("tickets.tabs.search.title")}
|
||||
classes={{ root: classes.tab }}
|
||||
/>
|
||||
</Tabs>
|
||||
</Paper>
|
||||
<Paper square elevation={0} className={classes.ticketOptionsBox}>
|
||||
{tab === "search" ? (
|
||||
<div className={classes.serachInputWrapper}>
|
||||
<SearchIcon className={classes.searchIcon} />
|
||||
<InputBase
|
||||
className={classes.searchInput}
|
||||
inputRef={searchInputRef}
|
||||
placeholder={i18n.t("tickets.search.placeholder")}
|
||||
type="search"
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => setNewTicketModalOpen(true)}
|
||||
>
|
||||
{i18n.t("ticketsManager.buttons.newTicket")}
|
||||
</Button>
|
||||
<Can
|
||||
role={user.profile}
|
||||
perform="tickets-manager:showall"
|
||||
yes={() => (
|
||||
<FormControlLabel
|
||||
label={i18n.t("tickets.buttons.showAll")}
|
||||
labelPlacement="start"
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={showAllTickets}
|
||||
onChange={() =>
|
||||
setShowAllTickets((prevState) => !prevState)
|
||||
}
|
||||
name="showAllTickets"
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<TicketsQueueSelect
|
||||
style={{ marginLeft: 6 }}
|
||||
selectedQueueIds={selectedQueueIds}
|
||||
userQueues={user?.queues}
|
||||
onChange={(values) => setSelectedQueueIds(values)}
|
||||
/>
|
||||
</Paper>
|
||||
<TabPanel value={tab} name="open" className={classes.ticketsWrapper}>
|
||||
<Tabs
|
||||
value={tabOpen}
|
||||
onChange={handleChangeTabOpen}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
variant="fullWidth"
|
||||
>
|
||||
<Tab
|
||||
label={
|
||||
<Badge
|
||||
className={classes.badge}
|
||||
badgeContent={openCount}
|
||||
color="primary"
|
||||
>
|
||||
{i18n.t("ticketsList.assignedHeader")}
|
||||
</Badge>
|
||||
}
|
||||
value={"open"}
|
||||
/>
|
||||
<Tab
|
||||
label={
|
||||
<Badge
|
||||
className={classes.badge}
|
||||
badgeContent={pendingCount}
|
||||
color="secondary"
|
||||
>
|
||||
{i18n.t("ticketsList.pendingHeader")}
|
||||
</Badge>
|
||||
}
|
||||
value={"pending"}
|
||||
/>
|
||||
</Tabs>
|
||||
<Paper className={classes.ticketsWrapper}>
|
||||
<TicketsList
|
||||
status="open"
|
||||
showAll={showAllTickets}
|
||||
selectedQueueIds={selectedQueueIds}
|
||||
updateCount={(val) => setOpenCount(val)}
|
||||
style={applyPanelStyle("open")}
|
||||
/>
|
||||
<TicketsList
|
||||
status="pending"
|
||||
selectedQueueIds={selectedQueueIds}
|
||||
updateCount={(val) => setPendingCount(val)}
|
||||
style={applyPanelStyle("pending")}
|
||||
/>
|
||||
</Paper>
|
||||
</TabPanel>
|
||||
<TabPanel value={tab} name="closed" className={classes.ticketsWrapper}>
|
||||
<TicketsList
|
||||
status="closed"
|
||||
showAll={true}
|
||||
selectedQueueIds={selectedQueueIds}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel value={tab} name="search" className={classes.ticketsWrapper}>
|
||||
<TicketsList
|
||||
searchParam={searchParam}
|
||||
showAll={true}
|
||||
selectedQueueIds={selectedQueueIds}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketsManager;
|
||||
60
frontend/src/components/TicketsQueueSelect/index.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
|
||||
import MenuItem from "@material-ui/core/MenuItem";
|
||||
import FormControl from "@material-ui/core/FormControl";
|
||||
import Select from "@material-ui/core/Select";
|
||||
import { Checkbox, ListItemText } from "@material-ui/core";
|
||||
import { i18n } from "../../translate/i18n";
|
||||
|
||||
const TicketsQueueSelect = ({
|
||||
userQueues,
|
||||
selectedQueueIds = [],
|
||||
onChange,
|
||||
}) => {
|
||||
const handleChange = e => {
|
||||
onChange(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ width: 120, marginTop: -4 }}>
|
||||
<FormControl fullWidth margin="dense">
|
||||
<Select
|
||||
multiple
|
||||
displayEmpty
|
||||
variant="outlined"
|
||||
value={selectedQueueIds}
|
||||
onChange={handleChange}
|
||||
MenuProps={{
|
||||
anchorOrigin: {
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
},
|
||||
transformOrigin: {
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
},
|
||||
getContentAnchorEl: null,
|
||||
}}
|
||||
renderValue={() => i18n.t("ticketsQueueSelect.placeholder")}
|
||||
>
|
||||
{userQueues?.length > 0 &&
|
||||
userQueues.map(queue => (
|
||||
<MenuItem dense key={queue.id} value={queue.id}>
|
||||
<Checkbox
|
||||
style={{
|
||||
color: queue.color,
|
||||
}}
|
||||
size="small"
|
||||
color="primary"
|
||||
checked={selectedQueueIds.indexOf(queue.id) > -1}
|
||||
/>
|
||||
<ListItemText primary={queue.name} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketsQueueSelect;
|
||||
10
frontend/src/components/Title/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
|
||||
export default function Title(props) {
|
||||
return (
|
||||
<Typography variant="h5" color="primary" gutterBottom>
|
||||
{props.children}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
233
frontend/src/components/TransferTicketModal/index.js
Normal file
@@ -0,0 +1,233 @@
|
||||
import React, { useState, useEffect, useContext } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
import Button from "@material-ui/core/Button";
|
||||
import TextField from "@material-ui/core/TextField";
|
||||
import Dialog from "@material-ui/core/Dialog";
|
||||
import Select from "@material-ui/core/Select";
|
||||
import FormControl from "@material-ui/core/FormControl";
|
||||
import InputLabel from "@material-ui/core/InputLabel";
|
||||
import MenuItem from "@material-ui/core/MenuItem";
|
||||
import { makeStyles } from "@material-ui/core";
|
||||
|
||||
import DialogActions from "@material-ui/core/DialogActions";
|
||||
import DialogContent from "@material-ui/core/DialogContent";
|
||||
import DialogTitle from "@material-ui/core/DialogTitle";
|
||||
import Autocomplete, {
|
||||
createFilterOptions,
|
||||
} from "@material-ui/lab/Autocomplete";
|
||||
import CircularProgress from "@material-ui/core/CircularProgress";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import api from "../../services/api";
|
||||
import ButtonWithSpinner from "../ButtonWithSpinner";
|
||||
import toastError from "../../errors/toastError";
|
||||
import useQueues from "../../hooks/useQueues";
|
||||
import useWhatsApps from "../../hooks/useWhatsApps";
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
import { Can } from "../Can";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
maxWidth: {
|
||||
width: "100%",
|
||||
},
|
||||
}));
|
||||
|
||||
const filterOptions = createFilterOptions({
|
||||
trim: true,
|
||||
});
|
||||
|
||||
const TransferTicketModal = ({ modalOpen, onClose, ticketid, ticketWhatsappId }) => {
|
||||
const history = useHistory();
|
||||
const [options, setOptions] = useState([]);
|
||||
const [queues, setQueues] = useState([]);
|
||||
const [allQueues, setAllQueues] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchParam, setSearchParam] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
const [selectedQueue, setSelectedQueue] = useState('');
|
||||
const [selectedWhatsapp, setSelectedWhatsapp] = useState(ticketWhatsappId);
|
||||
const classes = useStyles();
|
||||
const { findAll: findAllQueues } = useQueues();
|
||||
const { loadingWhatsapps, whatsApps } = useWhatsApps();
|
||||
|
||||
const { user: loggedInUser } = useContext(AuthContext);
|
||||
|
||||
useEffect(() => {
|
||||
const loadQueues = async () => {
|
||||
const list = await findAllQueues();
|
||||
setAllQueues(list);
|
||||
setQueues(list);
|
||||
}
|
||||
loadQueues();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalOpen || searchParam.length < 3) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const delayDebounceFn = setTimeout(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const { data } = await api.get("/users/", {
|
||||
params: { searchParam },
|
||||
});
|
||||
setOptions(data.users);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setLoading(false);
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUsers();
|
||||
}, 500);
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [searchParam, modalOpen]);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setSearchParam("");
|
||||
setSelectedUser(null);
|
||||
};
|
||||
|
||||
const handleSaveTicket = async e => {
|
||||
e.preventDefault();
|
||||
if (!ticketid) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
let data = {};
|
||||
|
||||
if (selectedUser) {
|
||||
data.userId = selectedUser.id
|
||||
}
|
||||
|
||||
if (selectedQueue && selectedQueue !== null) {
|
||||
data.queueId = selectedQueue
|
||||
|
||||
if (!selectedUser) {
|
||||
data.status = 'pending';
|
||||
data.userId = null;
|
||||
}
|
||||
}
|
||||
|
||||
if(selectedWhatsapp) {
|
||||
data.whatsappId = selectedWhatsapp;
|
||||
}
|
||||
|
||||
await api.put(`/tickets/${ticketid}`, data);
|
||||
|
||||
setLoading(false);
|
||||
history.push(`/tickets`);
|
||||
} catch (err) {
|
||||
setLoading(false);
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={modalOpen} onClose={handleClose} maxWidth="lg" scroll="paper">
|
||||
<form onSubmit={handleSaveTicket}>
|
||||
<DialogTitle id="form-dialog-title">
|
||||
{i18n.t("transferTicketModal.title")}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Autocomplete
|
||||
style={{ width: 300, marginBottom: 20 }}
|
||||
getOptionLabel={option => `${option.name}`}
|
||||
onChange={(e, newValue) => {
|
||||
setSelectedUser(newValue);
|
||||
if (newValue != null && Array.isArray(newValue.queues)) {
|
||||
setQueues(newValue.queues);
|
||||
} else {
|
||||
setQueues(allQueues);
|
||||
setSelectedQueue('');
|
||||
}
|
||||
}}
|
||||
options={options}
|
||||
filterOptions={filterOptions}
|
||||
freeSolo
|
||||
autoHighlight
|
||||
noOptionsText={i18n.t("transferTicketModal.noOptions")}
|
||||
loading={loading}
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={i18n.t("transferTicketModal.fieldLabel")}
|
||||
variant="outlined"
|
||||
required
|
||||
autoFocus
|
||||
onChange={e => setSearchParam(e.target.value)}
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<React.Fragment>
|
||||
{loading ? (
|
||||
<CircularProgress color="inherit" size={20} />
|
||||
) : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</React.Fragment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormControl variant="outlined" className={classes.maxWidth}>
|
||||
<InputLabel>{i18n.t("transferTicketModal.fieldQueueLabel")}</InputLabel>
|
||||
<Select
|
||||
value={selectedQueue}
|
||||
onChange={(e) => setSelectedQueue(e.target.value)}
|
||||
label={i18n.t("transferTicketModal.fieldQueuePlaceholder")}
|
||||
>
|
||||
<MenuItem value={''}> </MenuItem>
|
||||
{queues.map((queue) => (
|
||||
<MenuItem key={queue.id} value={queue.id}>{queue.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Can
|
||||
role={loggedInUser.profile}
|
||||
perform="ticket-options:transferWhatsapp"
|
||||
yes={() => (!loadingWhatsapps &&
|
||||
<FormControl variant="outlined" className={classes.maxWidth} style={{ marginTop: 20 }}>
|
||||
<InputLabel>{i18n.t("transferTicketModal.fieldConnectionLabel")}</InputLabel>
|
||||
<Select
|
||||
value={selectedWhatsapp}
|
||||
onChange={(e) => setSelectedWhatsapp(e.target.value)}
|
||||
label={i18n.t("transferTicketModal.fieldConnectionPlaceholder")}
|
||||
>
|
||||
{whatsApps.map((whasapp) => (
|
||||
<MenuItem key={whasapp.id} value={whasapp.id}>{whasapp.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
color="secondary"
|
||||
disabled={loading}
|
||||
variant="outlined"
|
||||
>
|
||||
{i18n.t("transferTicketModal.buttons.cancel")}
|
||||
</Button>
|
||||
<ButtonWithSpinner
|
||||
variant="contained"
|
||||
type="submit"
|
||||
color="primary"
|
||||
loading={loading}
|
||||
>
|
||||
{i18n.t("transferTicketModal.buttons.ok")}
|
||||
</ButtonWithSpinner>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransferTicketModal;
|
||||
304
frontend/src/components/UserModal/index.js
Normal file
@@ -0,0 +1,304 @@
|
||||
import React, { useState, useEffect, useContext } from "react";
|
||||
|
||||
import * as Yup from "yup";
|
||||
import { Formik, Form, Field } from "formik";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
CircularProgress,
|
||||
Select,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
IconButton
|
||||
} from '@material-ui/core';
|
||||
|
||||
import { Visibility, VisibilityOff } from '@material-ui/icons';
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import { green } from "@material-ui/core/colors";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
|
||||
import api from "../../services/api";
|
||||
import toastError from "../../errors/toastError";
|
||||
import QueueSelect from "../QueueSelect";
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
import { Can } from "../Can";
|
||||
import useWhatsApps from "../../hooks/useWhatsApps";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
multFieldLine: {
|
||||
display: "flex",
|
||||
"& > *:not(:last-child)": {
|
||||
marginRight: theme.spacing(1),
|
||||
},
|
||||
},
|
||||
|
||||
btnWrapper: {
|
||||
position: "relative",
|
||||
},
|
||||
|
||||
buttonProgress: {
|
||||
color: green[500],
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
marginTop: -12,
|
||||
marginLeft: -12,
|
||||
},
|
||||
formControl: {
|
||||
margin: theme.spacing(1),
|
||||
minWidth: 120,
|
||||
},
|
||||
}));
|
||||
|
||||
const UserSchema = Yup.object().shape({
|
||||
name: Yup.string()
|
||||
.min(2, "Too Short!")
|
||||
.max(50, "Too Long!")
|
||||
.required("Required"),
|
||||
password: Yup.string().min(5, "Too Short!").max(50, "Too Long!"),
|
||||
email: Yup.string().email("Invalid email").required("Required"),
|
||||
});
|
||||
|
||||
const UserModal = ({ open, onClose, userId }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
const initialState = {
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
profile: "user"
|
||||
};
|
||||
|
||||
const { user: loggedInUser } = useContext(AuthContext);
|
||||
|
||||
const [user, setUser] = useState(initialState);
|
||||
const [selectedQueueIds, setSelectedQueueIds] = useState([]);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [whatsappId, setWhatsappId] = useState(false);
|
||||
const {loading, whatsApps} = useWhatsApps();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
const { data } = await api.get(`/users/${userId}`);
|
||||
setUser(prevState => {
|
||||
return { ...prevState, ...data };
|
||||
});
|
||||
const userQueueIds = data.queues?.map(queue => queue.id);
|
||||
setSelectedQueueIds(userQueueIds);
|
||||
setWhatsappId(data.whatsappId ? data.whatsappId : '');
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
}, [userId, open]);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setUser(initialState);
|
||||
};
|
||||
|
||||
const handleSaveUser = async values => {
|
||||
const userData = { ...values, whatsappId, queueIds: selectedQueueIds };
|
||||
try {
|
||||
if (userId) {
|
||||
await api.put(`/users/${userId}`, userData);
|
||||
} else {
|
||||
await api.post("/users", userData);
|
||||
}
|
||||
toast.success(i18n.t("userModal.success"));
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
scroll="paper"
|
||||
>
|
||||
<DialogTitle id="form-dialog-title">
|
||||
{userId
|
||||
? `${i18n.t("userModal.title.edit")}`
|
||||
: `${i18n.t("userModal.title.add")}`}
|
||||
</DialogTitle>
|
||||
<Formik
|
||||
initialValues={user}
|
||||
enableReinitialize={true}
|
||||
validationSchema={UserSchema}
|
||||
onSubmit={(values, actions) => {
|
||||
setTimeout(() => {
|
||||
handleSaveUser(values);
|
||||
actions.setSubmitting(false);
|
||||
}, 400);
|
||||
}}
|
||||
>
|
||||
{({ touched, errors, isSubmitting }) => (
|
||||
<Form>
|
||||
<DialogContent dividers>
|
||||
<div className={classes.multFieldLine}>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("userModal.form.name")}
|
||||
autoFocus
|
||||
name="name"
|
||||
error={touched.name && Boolean(errors.name)}
|
||||
helperText={touched.name && errors.name}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
fullWidth
|
||||
/>
|
||||
<Field
|
||||
as={TextField}
|
||||
name="password"
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
label={i18n.t("userModal.form.password")}
|
||||
error={touched.password && Boolean(errors.password)}
|
||||
helperText={touched.password && errors.password}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={() => setShowPassword((e) => !e)}
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.multFieldLine}>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("userModal.form.email")}
|
||||
name="email"
|
||||
error={touched.email && Boolean(errors.email)}
|
||||
helperText={touched.email && errors.email}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
fullWidth
|
||||
/>
|
||||
<FormControl
|
||||
variant="outlined"
|
||||
className={classes.formControl}
|
||||
margin="dense"
|
||||
>
|
||||
<Can
|
||||
role={loggedInUser.profile}
|
||||
perform="user-modal:editProfile"
|
||||
yes={() => (
|
||||
<>
|
||||
<InputLabel id="profile-selection-input-label">
|
||||
{i18n.t("userModal.form.profile")}
|
||||
</InputLabel>
|
||||
|
||||
<Field
|
||||
as={Select}
|
||||
label={i18n.t("userModal.form.profile")}
|
||||
name="profile"
|
||||
labelId="profile-selection-label"
|
||||
id="profile-selection"
|
||||
required
|
||||
>
|
||||
<MenuItem value="admin">Admin</MenuItem>
|
||||
<MenuItem value="user">User</MenuItem>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<Can
|
||||
role={loggedInUser.profile}
|
||||
perform="user-modal:editQueues"
|
||||
yes={() => (
|
||||
<QueueSelect
|
||||
selectedQueueIds={selectedQueueIds}
|
||||
onChange={values => setSelectedQueueIds(values)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Can
|
||||
role={loggedInUser.profile}
|
||||
perform="user-modal:editQueues"
|
||||
yes={() => (!loading &&
|
||||
<FormControl variant="outlined" margin="dense" className={classes.maxWidth} fullWidth>
|
||||
<InputLabel>{i18n.t("userModal.form.whatsapp")}</InputLabel>
|
||||
<Field
|
||||
as={Select}
|
||||
value={whatsappId}
|
||||
onChange={(e) => setWhatsappId(e.target.value)}
|
||||
label={i18n.t("userModal.form.whatsapp")}
|
||||
>
|
||||
<MenuItem value={''}> </MenuItem>
|
||||
{whatsApps.map((whatsapp) => (
|
||||
<MenuItem key={whatsapp.id} value={whatsapp.id}>{whatsapp.name}</MenuItem>
|
||||
))}
|
||||
</Field>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
color="secondary"
|
||||
disabled={isSubmitting}
|
||||
variant="outlined"
|
||||
>
|
||||
{i18n.t("userModal.buttons.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={isSubmitting}
|
||||
variant="contained"
|
||||
className={classes.btnWrapper}
|
||||
>
|
||||
{userId
|
||||
? `${i18n.t("userModal.buttons.okEdit")}`
|
||||
: `${i18n.t("userModal.buttons.okAdd")}`}
|
||||
{isSubmitting && (
|
||||
<CircularProgress
|
||||
size={24}
|
||||
className={classes.buttonProgress}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserModal;
|
||||
90
frontend/src/components/VcardPreview/index.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import React, { useEffect, useState, useContext } from 'react';
|
||||
import { useHistory } from "react-router-dom";
|
||||
import toastError from "../../errors/toastError";
|
||||
import api from "../../services/api";
|
||||
|
||||
import Avatar from "@material-ui/core/Avatar";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
import Grid from "@material-ui/core/Grid";
|
||||
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
|
||||
import { Button, Divider, } from "@material-ui/core";
|
||||
|
||||
const VcardPreview = ({ contact, numbers }) => {
|
||||
const history = useHistory();
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
const [selectedContact, setContact] = useState({
|
||||
name: "",
|
||||
number: 0,
|
||||
profilePicUrl: ""
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const delayDebounceFn = setTimeout(() => {
|
||||
const fetchContacts = async () => {
|
||||
try {
|
||||
let contactObj = {
|
||||
name: contact,
|
||||
// number: numbers.replace(/\D/g, ""),
|
||||
number: numbers !== undefined && numbers.replace(/\D/g, ""),
|
||||
email: ""
|
||||
}
|
||||
const { data } = await api.post("/contact", contactObj);
|
||||
setContact(data)
|
||||
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
fetchContacts();
|
||||
}, 500);
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [contact, numbers]);
|
||||
|
||||
const handleNewChat = async () => {
|
||||
try {
|
||||
const { data: ticket } = await api.post("/tickets", {
|
||||
contactId: selectedContact.id,
|
||||
userId: user.id,
|
||||
status: "open",
|
||||
});
|
||||
history.push(`/tickets/${ticket.id}`);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{
|
||||
minWidth: "250px",
|
||||
}}>
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={2}>
|
||||
<Avatar src={selectedContact.profilePicUrl} />
|
||||
</Grid>
|
||||
<Grid item xs={9}>
|
||||
<Typography style={{ marginTop: "12px", marginLeft: "10px" }} variant="subtitle1" color="primary" gutterBottom>
|
||||
{selectedContact.name}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Divider />
|
||||
<Button
|
||||
fullWidth
|
||||
color="primary"
|
||||
onClick={handleNewChat}
|
||||
disabled={!selectedContact.number}
|
||||
>Conversar</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
export default VcardPreview;
|
||||
239
frontend/src/components/WhatsAppModal/index.js
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import * as Yup from "yup";
|
||||
import { Formik, Form, Field } from "formik";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import { green } from "@material-ui/core/colors";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Button,
|
||||
DialogActions,
|
||||
CircularProgress,
|
||||
TextField,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
} from "@material-ui/core";
|
||||
|
||||
import api from "../../services/api";
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import toastError from "../../errors/toastError";
|
||||
import QueueSelect from "../QueueSelect";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
|
||||
multFieldLine: {
|
||||
display: "flex",
|
||||
"& > *:not(:last-child)": {
|
||||
marginRight: theme.spacing(1),
|
||||
},
|
||||
},
|
||||
|
||||
btnWrapper: {
|
||||
position: "relative",
|
||||
},
|
||||
|
||||
buttonProgress: {
|
||||
color: green[500],
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
marginTop: -12,
|
||||
marginLeft: -12,
|
||||
},
|
||||
}));
|
||||
|
||||
const SessionSchema = Yup.object().shape({
|
||||
name: Yup.string()
|
||||
.min(2, "Too Short!")
|
||||
.max(50, "Too Long!")
|
||||
.required("Required"),
|
||||
});
|
||||
|
||||
const WhatsAppModal = ({ open, onClose, whatsAppId }) => {
|
||||
const classes = useStyles();
|
||||
const initialState = {
|
||||
name: "",
|
||||
greetingMessage: "",
|
||||
farewellMessage: "",
|
||||
isDefault: false,
|
||||
};
|
||||
const [whatsApp, setWhatsApp] = useState(initialState);
|
||||
const [selectedQueueIds, setSelectedQueueIds] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSession = async () => {
|
||||
if (!whatsAppId) return;
|
||||
|
||||
try {
|
||||
const { data } = await api.get(`whatsapp/${whatsAppId}`);
|
||||
setWhatsApp(data);
|
||||
|
||||
const whatsQueueIds = data.queues?.map(queue => queue.id);
|
||||
setSelectedQueueIds(whatsQueueIds);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
fetchSession();
|
||||
}, [whatsAppId]);
|
||||
|
||||
const handleSaveWhatsApp = async values => {
|
||||
const whatsappData = { ...values, queueIds: selectedQueueIds };
|
||||
|
||||
try {
|
||||
if (whatsAppId) {
|
||||
await api.put(`/whatsapp/${whatsAppId}`, whatsappData);
|
||||
} else {
|
||||
await api.post("/whatsapp", whatsappData);
|
||||
}
|
||||
toast.success(i18n.t("whatsappModal.success"));
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setWhatsApp(initialState);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
scroll="paper"
|
||||
>
|
||||
<DialogTitle>
|
||||
{whatsAppId
|
||||
? i18n.t("whatsappModal.title.edit")
|
||||
: i18n.t("whatsappModal.title.add")}
|
||||
</DialogTitle>
|
||||
<Formik
|
||||
initialValues={whatsApp}
|
||||
enableReinitialize={true}
|
||||
validationSchema={SessionSchema}
|
||||
onSubmit={(values, actions) => {
|
||||
setTimeout(() => {
|
||||
handleSaveWhatsApp(values);
|
||||
actions.setSubmitting(false);
|
||||
}, 400);
|
||||
}}
|
||||
>
|
||||
{({ values, touched, errors, isSubmitting }) => (
|
||||
<Form>
|
||||
<DialogContent dividers>
|
||||
<div className={classes.multFieldLine}>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("whatsappModal.form.name")}
|
||||
autoFocus
|
||||
name="name"
|
||||
error={touched.name && Boolean(errors.name)}
|
||||
helperText={touched.name && errors.name}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
className={classes.textField}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Field
|
||||
as={Switch}
|
||||
color="primary"
|
||||
name="isDefault"
|
||||
checked={values.isDefault}
|
||||
/>
|
||||
}
|
||||
label={i18n.t("whatsappModal.form.default")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("queueModal.form.greetingMessage")}
|
||||
type="greetingMessage"
|
||||
multiline
|
||||
rows={5}
|
||||
fullWidth
|
||||
name="greetingMessage"
|
||||
error={
|
||||
touched.greetingMessage && Boolean(errors.greetingMessage)
|
||||
}
|
||||
helperText={
|
||||
touched.greetingMessage && errors.greetingMessage
|
||||
}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Field
|
||||
as={TextField}
|
||||
label={i18n.t("whatsappModal.form.farewellMessage")}
|
||||
type="farewellMessage"
|
||||
multiline
|
||||
rows={5}
|
||||
fullWidth
|
||||
name="farewellMessage"
|
||||
error={
|
||||
touched.farewellMessage && Boolean(errors.farewellMessage)
|
||||
}
|
||||
helperText={
|
||||
touched.farewellMessage && errors.farewellMessage
|
||||
}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
/>
|
||||
</div>
|
||||
<QueueSelect
|
||||
selectedQueueIds={selectedQueueIds}
|
||||
onChange={selectedIds => setSelectedQueueIds(selectedIds)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
color="secondary"
|
||||
disabled={isSubmitting}
|
||||
variant="outlined"
|
||||
>
|
||||
{i18n.t("whatsappModal.buttons.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={isSubmitting}
|
||||
variant="contained"
|
||||
className={classes.btnWrapper}
|
||||
>
|
||||
{whatsAppId
|
||||
? i18n.t("whatsappModal.buttons.okEdit")
|
||||
: i18n.t("whatsappModal.buttons.okAdd")}
|
||||
{isSubmitting && (
|
||||
<CircularProgress
|
||||
size={24}
|
||||
className={classes.buttonProgress}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(WhatsAppModal);
|
||||
16
frontend/src/config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
function getConfig(name, defaultValue=null) {
|
||||
// If inside a docker container, use window.ENV
|
||||
if( window.ENV !== undefined ) {
|
||||
return window.ENV[name] || defaultValue;
|
||||
}
|
||||
|
||||
return process.env[name] || defaultValue;
|
||||
}
|
||||
|
||||
export function getBackendUrl() {
|
||||
return getConfig('REACT_APP_BACKEND_URL');
|
||||
}
|
||||
|
||||
export function getHoursCloseTicketsAuto() {
|
||||
return getConfig('REACT_APP_HOURS_CLOSE_TICKETS_AUTO');
|
||||
}
|
||||
19
frontend/src/context/Auth/AuthContext.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import React, { createContext } from "react";
|
||||
|
||||
import useAuth from "../../hooks/useAuth.js";
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
const AuthProvider = ({ children }) => {
|
||||
const { loading, user, isAuth, handleLogin, handleLogout } = useAuth();
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{ loading, user, isAuth, handleLogin, handleLogout }}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export { AuthContext, AuthProvider };
|
||||
@@ -0,0 +1,17 @@
|
||||
import React, { useState, createContext } from "react";
|
||||
|
||||
const ReplyMessageContext = createContext();
|
||||
|
||||
const ReplyMessageProvider = ({ children }) => {
|
||||
const [replyingMessage, setReplyingMessage] = useState(null);
|
||||
|
||||
return (
|
||||
<ReplyMessageContext.Provider
|
||||
value={{ replyingMessage, setReplyingMessage }}
|
||||
>
|
||||
{children}
|
||||
</ReplyMessageContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export { ReplyMessageContext, ReplyMessageProvider };
|
||||
17
frontend/src/context/WhatsApp/WhatsAppsContext.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import React, { createContext } from "react";
|
||||
|
||||
import useWhatsApps from "../../hooks/useWhatsApps";
|
||||
|
||||
const WhatsAppsContext = createContext();
|
||||
|
||||
const WhatsAppsProvider = ({ children }) => {
|
||||
const { loading, whatsApps } = useWhatsApps();
|
||||
|
||||
return (
|
||||
<WhatsAppsContext.Provider value={{ whatsApps, loading }}>
|
||||
{children}
|
||||
</WhatsAppsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export { WhatsAppsContext, WhatsAppsProvider };
|
||||
21
frontend/src/errors/toastError.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { toast } from "react-toastify";
|
||||
import { i18n } from "../translate/i18n";
|
||||
|
||||
const toastError = err => {
|
||||
const errorMsg = err.response?.data?.message || err.response.data.error;
|
||||
if (errorMsg) {
|
||||
if (i18n.exists(`backendErrors.${errorMsg}`)) {
|
||||
toast.error(i18n.t(`backendErrors.${errorMsg}`), {
|
||||
toastId: errorMsg,
|
||||
});
|
||||
} else {
|
||||
toast.error(errorMsg, {
|
||||
toastId: errorMsg,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toast.error("An error occurred!");
|
||||
}
|
||||
};
|
||||
|
||||
export default toastError;
|
||||
125
frontend/src/hooks/useAuth.js/index.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import openSocket from "../../services/socket-io";
|
||||
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import api from "../../services/api";
|
||||
import toastError from "../../errors/toastError";
|
||||
|
||||
const useAuth = () => {
|
||||
const history = useHistory();
|
||||
const [isAuth, setIsAuth] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState({});
|
||||
|
||||
api.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
config.headers["Authorization"] = `Bearer ${JSON.parse(token)}`;
|
||||
setIsAuth(true);
|
||||
}
|
||||
return config;
|
||||
},
|
||||
error => {
|
||||
Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
return response;
|
||||
},
|
||||
async error => {
|
||||
const originalRequest = error.config;
|
||||
if (error?.response?.status === 403 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
const { data } = await api.post("/auth/refresh_token");
|
||||
if (data) {
|
||||
localStorage.setItem("token", JSON.stringify(data.token));
|
||||
api.defaults.headers.Authorization = `Bearer ${data.token}`;
|
||||
}
|
||||
return api(originalRequest);
|
||||
}
|
||||
if (error?.response?.status === 401) {
|
||||
localStorage.removeItem("token");
|
||||
api.defaults.headers.Authorization = undefined;
|
||||
setIsAuth(false);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("token");
|
||||
(async () => {
|
||||
if (token) {
|
||||
try {
|
||||
const { data } = await api.post("/auth/refresh_token");
|
||||
api.defaults.headers.Authorization = `Bearer ${data.token}`;
|
||||
setIsAuth(true);
|
||||
setUser(data.user);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket();
|
||||
|
||||
socket.on("user", data => {
|
||||
if (data.action === "update" && data.user.id === user.id) {
|
||||
setUser(data.user);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const handleLogin = async userData => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { data } = await api.post("/auth/login", userData);
|
||||
localStorage.setItem("token", JSON.stringify(data.token));
|
||||
api.defaults.headers.Authorization = `Bearer ${data.token}`;
|
||||
setUser(data.user);
|
||||
setIsAuth(true);
|
||||
toast.success(i18n.t("auth.toasts.success"));
|
||||
history.push("/tickets");
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await api.delete("/auth/logout");
|
||||
setIsAuth(false);
|
||||
setUser({});
|
||||
localStorage.removeItem("token");
|
||||
api.defaults.headers.Authorization = undefined;
|
||||
setLoading(false);
|
||||
history.push("/login");
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { isAuth, user, loading, handleLogin, handleLogout };
|
||||
};
|
||||
|
||||
export default useAuth;
|
||||
29
frontend/src/hooks/useLocalStorage/index.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState } from "react";
|
||||
import toastError from "../../errors/toastError";
|
||||
|
||||
export function useLocalStorage(key, initialValue) {
|
||||
const [storedValue, setStoredValue] = useState(() => {
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
toastError(error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
const setValue = value => {
|
||||
try {
|
||||
const valueToStore =
|
||||
value instanceof Function ? value(storedValue) : value;
|
||||
|
||||
setStoredValue(valueToStore);
|
||||
|
||||
localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
} catch (error) {
|
||||
toastError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return [storedValue, setValue];
|
||||
}
|
||||
12
frontend/src/hooks/useQueues/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import api from "../../services/api";
|
||||
|
||||
const useQueues = () => {
|
||||
const findAll = async () => {
|
||||
const { data } = await api.get("/queue");
|
||||
return data;
|
||||
}
|
||||
|
||||
return { findAll };
|
||||
};
|
||||
|
||||
export default useQueues;
|
||||
88
frontend/src/hooks/useTickets/index.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { getHoursCloseTicketsAuto } from "../../config";
|
||||
import toastError from "../../errors/toastError";
|
||||
|
||||
import api from "../../services/api";
|
||||
|
||||
const useTickets = ({
|
||||
searchParam,
|
||||
pageNumber,
|
||||
status,
|
||||
date,
|
||||
showAll,
|
||||
queueIds,
|
||||
withUnreadMessages,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [tickets, setTickets] = useState([]);
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const delayDebounceFn = setTimeout(() => {
|
||||
const fetchTickets = async() => {
|
||||
try {
|
||||
const { data } = await api.get("/tickets", {
|
||||
params: {
|
||||
searchParam,
|
||||
pageNumber,
|
||||
status,
|
||||
date,
|
||||
showAll,
|
||||
queueIds,
|
||||
withUnreadMessages,
|
||||
},
|
||||
})
|
||||
setTickets(data.tickets)
|
||||
|
||||
let horasFecharAutomaticamente = getHoursCloseTicketsAuto();
|
||||
|
||||
if (status === "open" && horasFecharAutomaticamente && horasFecharAutomaticamente !== "" &&
|
||||
horasFecharAutomaticamente !== "0" && Number(horasFecharAutomaticamente) > 0) {
|
||||
|
||||
let dataLimite = new Date()
|
||||
dataLimite.setHours(dataLimite.getHours() - Number(horasFecharAutomaticamente))
|
||||
|
||||
data.tickets.forEach(ticket => {
|
||||
if (ticket.status !== "closed") {
|
||||
let dataUltimaInteracaoChamado = new Date(ticket.updatedAt)
|
||||
if (dataUltimaInteracaoChamado < dataLimite)
|
||||
closeTicket(ticket)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setHasMore(data.hasMore)
|
||||
setCount(data.count)
|
||||
setLoading(false)
|
||||
} catch (err) {
|
||||
setLoading(false)
|
||||
toastError(err)
|
||||
}
|
||||
}
|
||||
|
||||
const closeTicket = async(ticket) => {
|
||||
await api.put(`/tickets/${ticket.id}`, {
|
||||
status: "closed",
|
||||
userId: ticket.userId || null,
|
||||
})
|
||||
}
|
||||
|
||||
fetchTickets()
|
||||
}, 500)
|
||||
return () => clearTimeout(delayDebounceFn)
|
||||
}, [
|
||||
searchParam,
|
||||
pageNumber,
|
||||
status,
|
||||
date,
|
||||
showAll,
|
||||
queueIds,
|
||||
withUnreadMessages,
|
||||
])
|
||||
|
||||
return { tickets, loading, hasMore, count };
|
||||
};
|
||||
|
||||
export default useTickets;
|
||||
104
frontend/src/hooks/useWhatsApps/index.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState, useEffect, useReducer } from "react";
|
||||
import openSocket from "../../services/socket-io";
|
||||
import toastError from "../../errors/toastError";
|
||||
|
||||
import api from "../../services/api";
|
||||
|
||||
const reducer = (state, action) => {
|
||||
if (action.type === "LOAD_WHATSAPPS") {
|
||||
const whatsApps = action.payload;
|
||||
|
||||
return [...whatsApps];
|
||||
}
|
||||
|
||||
if (action.type === "UPDATE_WHATSAPPS") {
|
||||
const whatsApp = action.payload;
|
||||
const whatsAppIndex = state.findIndex(s => s.id === whatsApp.id);
|
||||
|
||||
if (whatsAppIndex !== -1) {
|
||||
state[whatsAppIndex] = whatsApp;
|
||||
return [...state];
|
||||
} else {
|
||||
return [whatsApp, ...state];
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === "UPDATE_SESSION") {
|
||||
const whatsApp = action.payload;
|
||||
const whatsAppIndex = state.findIndex(s => s.id === whatsApp.id);
|
||||
|
||||
if (whatsAppIndex !== -1) {
|
||||
state[whatsAppIndex].status = whatsApp.status;
|
||||
state[whatsAppIndex].updatedAt = whatsApp.updatedAt;
|
||||
state[whatsAppIndex].qrcode = whatsApp.qrcode;
|
||||
state[whatsAppIndex].retries = whatsApp.retries;
|
||||
return [...state];
|
||||
} else {
|
||||
return [...state];
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === "DELETE_WHATSAPPS") {
|
||||
const whatsAppId = action.payload;
|
||||
|
||||
const whatsAppIndex = state.findIndex(s => s.id === whatsAppId);
|
||||
if (whatsAppIndex !== -1) {
|
||||
state.splice(whatsAppIndex, 1);
|
||||
}
|
||||
return [...state];
|
||||
}
|
||||
|
||||
if (action.type === "RESET") {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const useWhatsApps = () => {
|
||||
const [whatsApps, dispatch] = useReducer(reducer, []);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const fetchSession = async () => {
|
||||
try {
|
||||
const { data } = await api.get("/whatsapp/");
|
||||
dispatch({ type: "LOAD_WHATSAPPS", payload: data });
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setLoading(false);
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
fetchSession();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket();
|
||||
|
||||
socket.on("whatsapp", data => {
|
||||
if (data.action === "update") {
|
||||
dispatch({ type: "UPDATE_WHATSAPPS", payload: data.whatsapp });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("whatsapp", data => {
|
||||
if (data.action === "delete") {
|
||||
dispatch({ type: "DELETE_WHATSAPPS", payload: data.whatsappId });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("whatsappSession", data => {
|
||||
if (data.action === "update") {
|
||||
dispatch({ type: "UPDATE_SESSION", payload: data.session });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { whatsApps, loading };
|
||||
};
|
||||
|
||||
export default useWhatsApps;
|
||||
22
frontend/src/index.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import CssBaseline from "@material-ui/core/CssBaseline";
|
||||
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.render(
|
||||
<CssBaseline>
|
||||
<App />
|
||||
</CssBaseline>,
|
||||
document.getElementById("root")
|
||||
);
|
||||
|
||||
// ReactDOM.render(
|
||||
// <React.StrictMode>
|
||||
// <CssBaseline>
|
||||
// <App />
|
||||
// </CssBaseline>,
|
||||
// </React.StrictMode>
|
||||
|
||||
// document.getElementById("root")
|
||||
// );
|
||||
136
frontend/src/layout/MainListItems.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import ListItem from "@material-ui/core/ListItem";
|
||||
import ListItemIcon from "@material-ui/core/ListItemIcon";
|
||||
import ListItemText from "@material-ui/core/ListItemText";
|
||||
import ListSubheader from "@material-ui/core/ListSubheader";
|
||||
import Divider from "@material-ui/core/Divider";
|
||||
import { Badge } from "@material-ui/core";
|
||||
import DashboardOutlinedIcon from "@material-ui/icons/DashboardOutlined";
|
||||
import WhatsAppIcon from "@material-ui/icons/WhatsApp";
|
||||
import SyncAltIcon from "@material-ui/icons/SyncAlt";
|
||||
import SettingsOutlinedIcon from "@material-ui/icons/SettingsOutlined";
|
||||
import PeopleAltOutlinedIcon from "@material-ui/icons/PeopleAltOutlined";
|
||||
import ContactPhoneOutlinedIcon from "@material-ui/icons/ContactPhoneOutlined";
|
||||
import AccountTreeOutlinedIcon from "@material-ui/icons/AccountTreeOutlined";
|
||||
import QuestionAnswerOutlinedIcon from "@material-ui/icons/QuestionAnswerOutlined";
|
||||
|
||||
import { i18n } from "../translate/i18n";
|
||||
import { WhatsAppsContext } from "../context/WhatsApp/WhatsAppsContext";
|
||||
import { AuthContext } from "../context/Auth/AuthContext";
|
||||
import { Can } from "../components/Can";
|
||||
|
||||
function ListItemLink(props) {
|
||||
const { icon, primary, to, className } = props;
|
||||
|
||||
const renderLink = React.useMemo(
|
||||
() =>
|
||||
React.forwardRef((itemProps, ref) => (
|
||||
<RouterLink to={to} ref={ref} {...itemProps} />
|
||||
)),
|
||||
[to]
|
||||
);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<ListItem button component={renderLink} className={className}>
|
||||
{icon ? <ListItemIcon>{icon}</ListItemIcon> : null}
|
||||
<ListItemText primary={primary} />
|
||||
</ListItem>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const MainListItems = (props) => {
|
||||
const { drawerClose } = props;
|
||||
const { whatsApps } = useContext(WhatsAppsContext);
|
||||
const { user } = useContext(AuthContext);
|
||||
const [connectionWarning, setConnectionWarning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const delayDebounceFn = setTimeout(() => {
|
||||
if (whatsApps.length > 0) {
|
||||
const offlineWhats = whatsApps.filter((whats) => {
|
||||
return (
|
||||
whats.status === "qrcode" ||
|
||||
whats.status === "PAIRING" ||
|
||||
whats.status === "DISCONNECTED" ||
|
||||
whats.status === "TIMEOUT" ||
|
||||
whats.status === "OPENING"
|
||||
);
|
||||
});
|
||||
if (offlineWhats.length > 0) {
|
||||
setConnectionWarning(true);
|
||||
} else {
|
||||
setConnectionWarning(false);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [whatsApps]);
|
||||
|
||||
return (
|
||||
<div onClick={drawerClose}>
|
||||
<ListItemLink
|
||||
to="/"
|
||||
primary="Dashboard"
|
||||
icon={<DashboardOutlinedIcon />}
|
||||
/>
|
||||
<ListItemLink
|
||||
to="/connections"
|
||||
primary={i18n.t("mainDrawer.listItems.connections")}
|
||||
icon={
|
||||
<Badge badgeContent={connectionWarning ? "!" : 0} color="error">
|
||||
<SyncAltIcon />
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<ListItemLink
|
||||
to="/tickets"
|
||||
primary={i18n.t("mainDrawer.listItems.tickets")}
|
||||
icon={<WhatsAppIcon />}
|
||||
/>
|
||||
|
||||
<ListItemLink
|
||||
to="/contacts"
|
||||
primary={i18n.t("mainDrawer.listItems.contacts")}
|
||||
icon={<ContactPhoneOutlinedIcon />}
|
||||
/>
|
||||
<ListItemLink
|
||||
to="/quickAnswers"
|
||||
primary={i18n.t("mainDrawer.listItems.quickAnswers")}
|
||||
icon={<QuestionAnswerOutlinedIcon />}
|
||||
/>
|
||||
<Can
|
||||
role={user.profile}
|
||||
perform="drawer-admin-items:view"
|
||||
yes={() => (
|
||||
<>
|
||||
<Divider />
|
||||
<ListSubheader inset>
|
||||
{i18n.t("mainDrawer.listItems.administration")}
|
||||
</ListSubheader>
|
||||
<ListItemLink
|
||||
to="/users"
|
||||
primary={i18n.t("mainDrawer.listItems.users")}
|
||||
icon={<PeopleAltOutlinedIcon />}
|
||||
/>
|
||||
<ListItemLink
|
||||
to="/queues"
|
||||
primary={i18n.t("mainDrawer.listItems.queues")}
|
||||
icon={<AccountTreeOutlinedIcon />}
|
||||
/>
|
||||
<ListItemLink
|
||||
to="/settings"
|
||||
primary={i18n.t("mainDrawer.listItems.settings")}
|
||||
icon={<SettingsOutlinedIcon />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainListItems;
|
||||
268
frontend/src/layout/index.js
Normal file
@@ -0,0 +1,268 @@
|
||||
import React, { useState, useContext, useEffect } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import {
|
||||
makeStyles,
|
||||
Drawer,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
List,
|
||||
Typography,
|
||||
Divider,
|
||||
MenuItem,
|
||||
IconButton,
|
||||
Menu,
|
||||
} from "@material-ui/core";
|
||||
|
||||
import MenuIcon from "@material-ui/icons/Menu";
|
||||
import ChevronLeftIcon from "@material-ui/icons/ChevronLeft";
|
||||
import AccountCircle from "@material-ui/icons/AccountCircle";
|
||||
|
||||
import MainListItems from "./MainListItems";
|
||||
import NotificationsPopOver from "../components/NotificationsPopOver";
|
||||
import UserModal from "../components/UserModal";
|
||||
import { AuthContext } from "../context/Auth/AuthContext";
|
||||
import BackdropLoading from "../components/BackdropLoading";
|
||||
import { i18n } from "../translate/i18n";
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
height: "100vh",
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
height: "calc(100vh - 56px)",
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
paddingRight: 24, // keep right padding when drawer closed
|
||||
},
|
||||
toolbarIcon: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
padding: "0 8px",
|
||||
minHeight: "48px",
|
||||
},
|
||||
appBar: {
|
||||
zIndex: theme.zIndex.drawer + 1,
|
||||
transition: theme.transitions.create(["width", "margin"], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
},
|
||||
appBarShift: {
|
||||
marginLeft: drawerWidth,
|
||||
width: `calc(100% - ${drawerWidth}px)`,
|
||||
transition: theme.transitions.create(["width", "margin"], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
},
|
||||
menuButton: {
|
||||
marginRight: 36,
|
||||
},
|
||||
menuButtonHidden: {
|
||||
display: "none",
|
||||
},
|
||||
title: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
drawerPaper: {
|
||||
position: "relative",
|
||||
whiteSpace: "nowrap",
|
||||
width: drawerWidth,
|
||||
transition: theme.transitions.create("width", {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
},
|
||||
drawerPaperClose: {
|
||||
overflowX: "hidden",
|
||||
transition: theme.transitions.create("width", {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
width: theme.spacing(7),
|
||||
[theme.breakpoints.up("sm")]: {
|
||||
width: theme.spacing(9),
|
||||
},
|
||||
},
|
||||
appBarSpacer: {
|
||||
minHeight: "48px",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
},
|
||||
container: {
|
||||
paddingTop: theme.spacing(4),
|
||||
paddingBottom: theme.spacing(4),
|
||||
},
|
||||
paper: {
|
||||
padding: theme.spacing(2),
|
||||
display: "flex",
|
||||
overflow: "auto",
|
||||
flexDirection: "column",
|
||||
},
|
||||
}));
|
||||
|
||||
const LoggedInLayout = ({ children }) => {
|
||||
const classes = useStyles();
|
||||
const [userModalOpen, setUserModalOpen] = useState(false);
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { handleLogout, loading } = useContext(AuthContext);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [drawerVariant, setDrawerVariant] = useState("permanent");
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (document.body.offsetWidth > 600) {
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (document.body.offsetWidth < 600) {
|
||||
setDrawerVariant("temporary");
|
||||
} else {
|
||||
setDrawerVariant("permanent");
|
||||
}
|
||||
}, [drawerOpen]);
|
||||
|
||||
const handleMenu = (event) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
setMenuOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseMenu = () => {
|
||||
setAnchorEl(null);
|
||||
setMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleOpenUserModal = () => {
|
||||
setUserModalOpen(true);
|
||||
handleCloseMenu();
|
||||
};
|
||||
|
||||
const handleClickLogout = () => {
|
||||
handleCloseMenu();
|
||||
handleLogout();
|
||||
};
|
||||
|
||||
const drawerClose = () => {
|
||||
if (document.body.offsetWidth < 600) {
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <BackdropLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Drawer
|
||||
variant={drawerVariant}
|
||||
className={drawerOpen ? classes.drawerPaper : classes.drawerPaperClose}
|
||||
classes={{
|
||||
paper: clsx(
|
||||
classes.drawerPaper,
|
||||
!drawerOpen && classes.drawerPaperClose
|
||||
),
|
||||
}}
|
||||
open={drawerOpen}
|
||||
>
|
||||
<div className={classes.toolbarIcon}>
|
||||
<IconButton onClick={() => setDrawerOpen(!drawerOpen)}>
|
||||
<ChevronLeftIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Divider />
|
||||
<List>
|
||||
<MainListItems drawerClose={drawerClose} />
|
||||
</List>
|
||||
<Divider />
|
||||
</Drawer>
|
||||
<UserModal
|
||||
open={userModalOpen}
|
||||
onClose={() => setUserModalOpen(false)}
|
||||
userId={user?.id}
|
||||
/>
|
||||
<AppBar
|
||||
position="absolute"
|
||||
className={clsx(classes.appBar, drawerOpen && classes.appBarShift)}
|
||||
color={process.env.NODE_ENV === "development" ? "inherit" : "primary"}
|
||||
>
|
||||
<Toolbar variant="dense" className={classes.toolbar}>
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
onClick={() => setDrawerOpen(!drawerOpen)}
|
||||
className={clsx(
|
||||
classes.menuButton,
|
||||
drawerOpen && classes.menuButtonHidden
|
||||
)}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography
|
||||
component="h1"
|
||||
variant="h6"
|
||||
color="inherit"
|
||||
noWrap
|
||||
className={classes.title}
|
||||
>
|
||||
WhaTicket
|
||||
</Typography>
|
||||
{user.id && <NotificationsPopOver />}
|
||||
|
||||
<div>
|
||||
<IconButton
|
||||
aria-label="account of current user"
|
||||
aria-controls="menu-appbar"
|
||||
aria-haspopup="true"
|
||||
onClick={handleMenu}
|
||||
color="inherit"
|
||||
>
|
||||
<AccountCircle />
|
||||
</IconButton>
|
||||
<Menu
|
||||
id="menu-appbar"
|
||||
anchorEl={anchorEl}
|
||||
getContentAnchorEl={null}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "right",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
open={menuOpen}
|
||||
onClose={handleCloseMenu}
|
||||
>
|
||||
<MenuItem onClick={handleOpenUserModal}>
|
||||
{i18n.t("mainDrawer.appBar.user.profile")}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleClickLogout}>
|
||||
{i18n.t("mainDrawer.appBar.user.logout")}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<main className={classes.content}>
|
||||
<div className={classes.appBarSpacer} />
|
||||
|
||||
{children ? children : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoggedInLayout;
|
||||
401
frontend/src/pages/Connections/index.js
Normal file
@@ -0,0 +1,401 @@
|
||||
import React, { useState, useCallback, useContext } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import { green } from "@material-ui/core/colors";
|
||||
import {
|
||||
Button,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
IconButton,
|
||||
Table,
|
||||
TableHead,
|
||||
Paper,
|
||||
Tooltip,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
} from "@material-ui/core";
|
||||
import {
|
||||
Edit,
|
||||
CheckCircle,
|
||||
SignalCellularConnectedNoInternet2Bar,
|
||||
SignalCellularConnectedNoInternet0Bar,
|
||||
SignalCellular4Bar,
|
||||
CropFree,
|
||||
DeleteOutline,
|
||||
} from "@material-ui/icons";
|
||||
|
||||
import MainContainer from "../../components/MainContainer";
|
||||
import MainHeader from "../../components/MainHeader";
|
||||
import MainHeaderButtonsWrapper from "../../components/MainHeaderButtonsWrapper";
|
||||
import Title from "../../components/Title";
|
||||
import TableRowSkeleton from "../../components/TableRowSkeleton";
|
||||
|
||||
import api from "../../services/api";
|
||||
import WhatsAppModal from "../../components/WhatsAppModal";
|
||||
import ConfirmationModal from "../../components/ConfirmationModal";
|
||||
import QrcodeModal from "../../components/QrcodeModal";
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import { WhatsAppsContext } from "../../context/WhatsApp/WhatsAppsContext";
|
||||
import toastError from "../../errors/toastError";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
mainPaper: {
|
||||
flex: 1,
|
||||
padding: theme.spacing(1),
|
||||
overflowY: "scroll",
|
||||
...theme.scrollbarStyles,
|
||||
},
|
||||
customTableCell: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "#f5f5f9",
|
||||
color: "rgba(0, 0, 0, 0.87)",
|
||||
fontSize: theme.typography.pxToRem(14),
|
||||
border: "1px solid #dadde9",
|
||||
maxWidth: 450,
|
||||
},
|
||||
tooltipPopper: {
|
||||
textAlign: "center",
|
||||
},
|
||||
buttonProgress: {
|
||||
color: green[500],
|
||||
},
|
||||
}));
|
||||
|
||||
const CustomToolTip = ({ title, content, children }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
arrow
|
||||
classes={{
|
||||
tooltip: classes.tooltip,
|
||||
popper: classes.tooltipPopper,
|
||||
}}
|
||||
title={
|
||||
<React.Fragment>
|
||||
<Typography gutterBottom color="inherit">
|
||||
{title}
|
||||
</Typography>
|
||||
{content && <Typography>{content}</Typography>}
|
||||
</React.Fragment>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const Connections = () => {
|
||||
const classes = useStyles();
|
||||
|
||||
const { whatsApps, loading } = useContext(WhatsAppsContext);
|
||||
const [whatsAppModalOpen, setWhatsAppModalOpen] = useState(false);
|
||||
const [qrModalOpen, setQrModalOpen] = useState(false);
|
||||
const [selectedWhatsApp, setSelectedWhatsApp] = useState(null);
|
||||
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
|
||||
const confirmationModalInitialState = {
|
||||
action: "",
|
||||
title: "",
|
||||
message: "",
|
||||
whatsAppId: "",
|
||||
open: false,
|
||||
};
|
||||
const [confirmModalInfo, setConfirmModalInfo] = useState(
|
||||
confirmationModalInitialState
|
||||
);
|
||||
|
||||
const handleStartWhatsAppSession = async whatsAppId => {
|
||||
try {
|
||||
await api.post(`/whatsappsession/${whatsAppId}`);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestNewQrCode = async whatsAppId => {
|
||||
try {
|
||||
await api.put(`/whatsappsession/${whatsAppId}`);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenWhatsAppModal = () => {
|
||||
setSelectedWhatsApp(null);
|
||||
setWhatsAppModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseWhatsAppModal = useCallback(() => {
|
||||
setWhatsAppModalOpen(false);
|
||||
setSelectedWhatsApp(null);
|
||||
}, [setSelectedWhatsApp, setWhatsAppModalOpen]);
|
||||
|
||||
const handleOpenQrModal = whatsApp => {
|
||||
setSelectedWhatsApp(whatsApp);
|
||||
setQrModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseQrModal = useCallback(() => {
|
||||
setSelectedWhatsApp(null);
|
||||
setQrModalOpen(false);
|
||||
}, [setQrModalOpen, setSelectedWhatsApp]);
|
||||
|
||||
const handleEditWhatsApp = whatsApp => {
|
||||
setSelectedWhatsApp(whatsApp);
|
||||
setWhatsAppModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenConfirmationModal = (action, whatsAppId) => {
|
||||
if (action === "disconnect") {
|
||||
setConfirmModalInfo({
|
||||
action: action,
|
||||
title: i18n.t("connections.confirmationModal.disconnectTitle"),
|
||||
message: i18n.t("connections.confirmationModal.disconnectMessage"),
|
||||
whatsAppId: whatsAppId,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "delete") {
|
||||
setConfirmModalInfo({
|
||||
action: action,
|
||||
title: i18n.t("connections.confirmationModal.deleteTitle"),
|
||||
message: i18n.t("connections.confirmationModal.deleteMessage"),
|
||||
whatsAppId: whatsAppId,
|
||||
});
|
||||
}
|
||||
setConfirmModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmitConfirmationModal = async () => {
|
||||
if (confirmModalInfo.action === "disconnect") {
|
||||
try {
|
||||
await api.delete(`/whatsappsession/${confirmModalInfo.whatsAppId}`);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmModalInfo.action === "delete") {
|
||||
try {
|
||||
await api.delete(`/whatsapp/${confirmModalInfo.whatsAppId}`);
|
||||
toast.success(i18n.t("connections.toasts.deleted"));
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
}
|
||||
|
||||
setConfirmModalInfo(confirmationModalInitialState);
|
||||
};
|
||||
|
||||
const renderActionButtons = whatsApp => {
|
||||
return (
|
||||
<>
|
||||
{whatsApp.status === "qrcode" && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => handleOpenQrModal(whatsApp)}
|
||||
>
|
||||
{i18n.t("connections.buttons.qrcode")}
|
||||
</Button>
|
||||
)}
|
||||
{whatsApp.status === "DISCONNECTED" && (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => handleStartWhatsAppSession(whatsApp.id)}
|
||||
>
|
||||
{i18n.t("connections.buttons.tryAgain")}
|
||||
</Button>{" "}
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() => handleRequestNewQrCode(whatsApp.id)}
|
||||
>
|
||||
{i18n.t("connections.buttons.newQr")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(whatsApp.status === "CONNECTED" ||
|
||||
whatsApp.status === "PAIRING" ||
|
||||
whatsApp.status === "TIMEOUT") && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
handleOpenConfirmationModal("disconnect", whatsApp.id);
|
||||
}}
|
||||
>
|
||||
{i18n.t("connections.buttons.disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
{whatsApp.status === "OPENING" && (
|
||||
<Button size="small" variant="outlined" disabled color="default">
|
||||
{i18n.t("connections.buttons.connecting")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatusToolTips = whatsApp => {
|
||||
return (
|
||||
<div className={classes.customTableCell}>
|
||||
{whatsApp.status === "DISCONNECTED" && (
|
||||
<CustomToolTip
|
||||
title={i18n.t("connections.toolTips.disconnected.title")}
|
||||
content={i18n.t("connections.toolTips.disconnected.content")}
|
||||
>
|
||||
<SignalCellularConnectedNoInternet0Bar color="secondary" />
|
||||
</CustomToolTip>
|
||||
)}
|
||||
{whatsApp.status === "OPENING" && (
|
||||
<CircularProgress size={24} className={classes.buttonProgress} />
|
||||
)}
|
||||
{whatsApp.status === "qrcode" && (
|
||||
<CustomToolTip
|
||||
title={i18n.t("connections.toolTips.qrcode.title")}
|
||||
content={i18n.t("connections.toolTips.qrcode.content")}
|
||||
>
|
||||
<CropFree />
|
||||
</CustomToolTip>
|
||||
)}
|
||||
{whatsApp.status === "CONNECTED" && (
|
||||
<CustomToolTip title={i18n.t("connections.toolTips.connected.title")}>
|
||||
<SignalCellular4Bar style={{ color: green[500] }} />
|
||||
</CustomToolTip>
|
||||
)}
|
||||
{(whatsApp.status === "TIMEOUT" || whatsApp.status === "PAIRING") && (
|
||||
<CustomToolTip
|
||||
title={i18n.t("connections.toolTips.timeout.title")}
|
||||
content={i18n.t("connections.toolTips.timeout.content")}
|
||||
>
|
||||
<SignalCellularConnectedNoInternet2Bar color="secondary" />
|
||||
</CustomToolTip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<MainContainer>
|
||||
<ConfirmationModal
|
||||
title={confirmModalInfo.title}
|
||||
open={confirmModalOpen}
|
||||
onClose={setConfirmModalOpen}
|
||||
onConfirm={handleSubmitConfirmationModal}
|
||||
>
|
||||
{confirmModalInfo.message}
|
||||
</ConfirmationModal>
|
||||
<QrcodeModal
|
||||
open={qrModalOpen}
|
||||
onClose={handleCloseQrModal}
|
||||
whatsAppId={!whatsAppModalOpen && selectedWhatsApp?.id}
|
||||
/>
|
||||
<WhatsAppModal
|
||||
open={whatsAppModalOpen}
|
||||
onClose={handleCloseWhatsAppModal}
|
||||
whatsAppId={!qrModalOpen && selectedWhatsApp?.id}
|
||||
/>
|
||||
<MainHeader>
|
||||
<Title>{i18n.t("connections.title")}</Title>
|
||||
<MainHeaderButtonsWrapper>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleOpenWhatsAppModal}
|
||||
>
|
||||
{i18n.t("connections.buttons.add")}
|
||||
</Button>
|
||||
</MainHeaderButtonsWrapper>
|
||||
</MainHeader>
|
||||
<Paper className={classes.mainPaper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell align="center">
|
||||
{i18n.t("connections.table.name")}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{i18n.t("connections.table.status")}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{i18n.t("connections.table.session")}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{i18n.t("connections.table.lastUpdate")}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{i18n.t("connections.table.default")}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{i18n.t("connections.table.actions")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRowSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{whatsApps?.length > 0 &&
|
||||
whatsApps.map(whatsApp => (
|
||||
<TableRow key={whatsApp.id}>
|
||||
<TableCell align="center">{whatsApp.name}</TableCell>
|
||||
<TableCell align="center">
|
||||
{renderStatusToolTips(whatsApp)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{renderActionButtons(whatsApp)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{format(parseISO(whatsApp.updatedAt), "dd/MM/yy HH:mm")}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{whatsApp.isDefault && (
|
||||
<div className={classes.customTableCell}>
|
||||
<CheckCircle style={{ color: green[500] }} />
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleEditWhatsApp(whatsApp)}
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => {
|
||||
handleOpenConfirmationModal("delete", whatsApp.id);
|
||||
}}
|
||||
>
|
||||
<DeleteOutline />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</MainContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Connections;
|
||||
349
frontend/src/pages/Contacts/index.js
Normal file
@@ -0,0 +1,349 @@
|
||||
import React, { useState, useEffect, useReducer, useContext } from "react";
|
||||
import openSocket from "../../services/socket-io";
|
||||
import { toast } from "react-toastify";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import Table from "@material-ui/core/Table";
|
||||
import TableBody from "@material-ui/core/TableBody";
|
||||
import TableCell from "@material-ui/core/TableCell";
|
||||
import TableHead from "@material-ui/core/TableHead";
|
||||
import TableRow from "@material-ui/core/TableRow";
|
||||
import Paper from "@material-ui/core/Paper";
|
||||
import Button from "@material-ui/core/Button";
|
||||
import Avatar from "@material-ui/core/Avatar";
|
||||
import WhatsAppIcon from "@material-ui/icons/WhatsApp";
|
||||
import SearchIcon from "@material-ui/icons/Search";
|
||||
import TextField from "@material-ui/core/TextField";
|
||||
import InputAdornment from "@material-ui/core/InputAdornment";
|
||||
|
||||
import IconButton from "@material-ui/core/IconButton";
|
||||
import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline";
|
||||
import EditIcon from "@material-ui/icons/Edit";
|
||||
|
||||
import api from "../../services/api";
|
||||
import TableRowSkeleton from "../../components/TableRowSkeleton";
|
||||
import ContactModal from "../../components/ContactModal";
|
||||
import ConfirmationModal from "../../components/ConfirmationModal/";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import MainHeader from "../../components/MainHeader";
|
||||
import Title from "../../components/Title";
|
||||
import MainHeaderButtonsWrapper from "../../components/MainHeaderButtonsWrapper";
|
||||
import MainContainer from "../../components/MainContainer";
|
||||
import toastError from "../../errors/toastError";
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
import { Can } from "../../components/Can";
|
||||
|
||||
const reducer = (state, action) => {
|
||||
if (action.type === "LOAD_CONTACTS") {
|
||||
const contacts = action.payload;
|
||||
const newContacts = [];
|
||||
|
||||
contacts.forEach((contact) => {
|
||||
const contactIndex = state.findIndex((c) => c.id === contact.id);
|
||||
if (contactIndex !== -1) {
|
||||
state[contactIndex] = contact;
|
||||
} else {
|
||||
newContacts.push(contact);
|
||||
}
|
||||
});
|
||||
|
||||
return [...state, ...newContacts];
|
||||
}
|
||||
|
||||
if (action.type === "UPDATE_CONTACTS") {
|
||||
const contact = action.payload;
|
||||
const contactIndex = state.findIndex((c) => c.id === contact.id);
|
||||
|
||||
if (contactIndex !== -1) {
|
||||
state[contactIndex] = contact;
|
||||
return [...state];
|
||||
} else {
|
||||
return [contact, ...state];
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === "DELETE_CONTACT") {
|
||||
const contactId = action.payload;
|
||||
|
||||
const contactIndex = state.findIndex((c) => c.id === contactId);
|
||||
if (contactIndex !== -1) {
|
||||
state.splice(contactIndex, 1);
|
||||
}
|
||||
return [...state];
|
||||
}
|
||||
|
||||
if (action.type === "RESET") {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
mainPaper: {
|
||||
flex: 1,
|
||||
padding: theme.spacing(1),
|
||||
overflowY: "scroll",
|
||||
...theme.scrollbarStyles,
|
||||
},
|
||||
}));
|
||||
|
||||
const Contacts = () => {
|
||||
const classes = useStyles();
|
||||
const history = useHistory();
|
||||
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
const [searchParam, setSearchParam] = useState("");
|
||||
const [contacts, dispatch] = useReducer(reducer, []);
|
||||
const [selectedContactId, setSelectedContactId] = useState(null);
|
||||
const [contactModalOpen, setContactModalOpen] = useState(false);
|
||||
const [deletingContact, setDeletingContact] = useState(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: "RESET" });
|
||||
setPageNumber(1);
|
||||
}, [searchParam]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const delayDebounceFn = setTimeout(() => {
|
||||
const fetchContacts = async () => {
|
||||
try {
|
||||
const { data } = await api.get("/contacts/", {
|
||||
params: { searchParam, pageNumber },
|
||||
});
|
||||
dispatch({ type: "LOAD_CONTACTS", payload: data.contacts });
|
||||
setHasMore(data.hasMore);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
fetchContacts();
|
||||
}, 500);
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [searchParam, pageNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket();
|
||||
|
||||
socket.on("contact", (data) => {
|
||||
if (data.action === "update" || data.action === "create") {
|
||||
dispatch({ type: "UPDATE_CONTACTS", payload: data.contact });
|
||||
}
|
||||
|
||||
if (data.action === "delete") {
|
||||
dispatch({ type: "DELETE_CONTACT", payload: +data.contactId });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSearch = (event) => {
|
||||
setSearchParam(event.target.value.toLowerCase());
|
||||
};
|
||||
|
||||
const handleOpenContactModal = () => {
|
||||
setSelectedContactId(null);
|
||||
setContactModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseContactModal = () => {
|
||||
setSelectedContactId(null);
|
||||
setContactModalOpen(false);
|
||||
};
|
||||
|
||||
const handleSaveTicket = async (contactId) => {
|
||||
if (!contactId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data: ticket } = await api.post("/tickets", {
|
||||
contactId: contactId,
|
||||
userId: user?.id,
|
||||
status: "open",
|
||||
});
|
||||
history.push(`/tickets/${ticket.id}`);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const hadleEditContact = (contactId) => {
|
||||
setSelectedContactId(contactId);
|
||||
setContactModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteContact = async (contactId) => {
|
||||
try {
|
||||
await api.delete(`/contacts/${contactId}`);
|
||||
toast.success(i18n.t("contacts.toasts.deleted"));
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
setDeletingContact(null);
|
||||
setSearchParam("");
|
||||
setPageNumber(1);
|
||||
};
|
||||
|
||||
const handleimportContact = async () => {
|
||||
try {
|
||||
await api.post("/contacts/import");
|
||||
history.go(0);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
setPageNumber((prevState) => prevState + 1);
|
||||
};
|
||||
|
||||
const handleScroll = (e) => {
|
||||
if (!hasMore || loading) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
if (scrollHeight - (scrollTop + 100) < clientHeight) {
|
||||
loadMore();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MainContainer className={classes.mainContainer}>
|
||||
<ContactModal
|
||||
open={contactModalOpen}
|
||||
onClose={handleCloseContactModal}
|
||||
aria-labelledby="form-dialog-title"
|
||||
contactId={selectedContactId}
|
||||
></ContactModal>
|
||||
<ConfirmationModal
|
||||
title={
|
||||
deletingContact
|
||||
? `${i18n.t("contacts.confirmationModal.deleteTitle")} ${
|
||||
deletingContact.name
|
||||
}?`
|
||||
: `${i18n.t("contacts.confirmationModal.importTitlte")}`
|
||||
}
|
||||
open={confirmOpen}
|
||||
onClose={setConfirmOpen}
|
||||
onConfirm={(e) =>
|
||||
deletingContact
|
||||
? handleDeleteContact(deletingContact.id)
|
||||
: handleimportContact()
|
||||
}
|
||||
>
|
||||
{deletingContact
|
||||
? `${i18n.t("contacts.confirmationModal.deleteMessage")}`
|
||||
: `${i18n.t("contacts.confirmationModal.importMessage")}`}
|
||||
</ConfirmationModal>
|
||||
<MainHeader>
|
||||
<Title>{i18n.t("contacts.title")}</Title>
|
||||
<MainHeaderButtonsWrapper>
|
||||
<TextField
|
||||
placeholder={i18n.t("contacts.searchPlaceholder")}
|
||||
type="search"
|
||||
value={searchParam}
|
||||
onChange={handleSearch}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon style={{ color: "gray" }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={(e) => setConfirmOpen(true)}
|
||||
>
|
||||
{i18n.t("contacts.buttons.import")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleOpenContactModal}
|
||||
>
|
||||
{i18n.t("contacts.buttons.add")}
|
||||
</Button>
|
||||
</MainHeaderButtonsWrapper>
|
||||
</MainHeader>
|
||||
<Paper
|
||||
className={classes.mainPaper}
|
||||
variant="outlined"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox" />
|
||||
<TableCell>{i18n.t("contacts.table.name")}</TableCell>
|
||||
<TableCell align="center">
|
||||
{i18n.t("contacts.table.whatsapp")}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{i18n.t("contacts.table.email")}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{i18n.t("contacts.table.actions")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<>
|
||||
{contacts.map((contact) => (
|
||||
<TableRow key={contact.id}>
|
||||
<TableCell style={{ paddingRight: 0 }}>
|
||||
{<Avatar src={contact.profilePicUrl} />}
|
||||
</TableCell>
|
||||
<TableCell>{contact.name}</TableCell>
|
||||
<TableCell align="center">{contact.number}</TableCell>
|
||||
<TableCell align="center">{contact.email}</TableCell>
|
||||
<TableCell align="center">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleSaveTicket(contact.id)}
|
||||
>
|
||||
<WhatsAppIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => hadleEditContact(contact.id)}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<Can
|
||||
role={user.profile}
|
||||
perform="contacts-page:deleteContact"
|
||||
yes={() => (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
setConfirmOpen(true);
|
||||
setDeletingContact(contact);
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{loading && <TableRowSkeleton avatar columns={3} />}
|
||||
</>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</MainContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Contacts;
|
||||
95
frontend/src/pages/Dashboard/Chart.js
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useTheme } from "@material-ui/core/styles";
|
||||
import {
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Label,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { startOfHour, parseISO, format } from "date-fns";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
|
||||
import Title from "./Title";
|
||||
import useTickets from "../../hooks/useTickets";
|
||||
|
||||
const Chart = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
const date = useRef(new Date().toISOString());
|
||||
const { tickets } = useTickets({ date: date.current });
|
||||
|
||||
const [chartData, setChartData] = useState([
|
||||
{ time: "08:00", amount: 0 },
|
||||
{ time: "09:00", amount: 0 },
|
||||
{ time: "10:00", amount: 0 },
|
||||
{ time: "11:00", amount: 0 },
|
||||
{ time: "12:00", amount: 0 },
|
||||
{ time: "13:00", amount: 0 },
|
||||
{ time: "14:00", amount: 0 },
|
||||
{ time: "15:00", amount: 0 },
|
||||
{ time: "16:00", amount: 0 },
|
||||
{ time: "17:00", amount: 0 },
|
||||
{ time: "18:00", amount: 0 },
|
||||
{ time: "19:00", amount: 0 },
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setChartData(prevState => {
|
||||
let aux = [...prevState];
|
||||
|
||||
aux.forEach(a => {
|
||||
tickets.forEach(ticket => {
|
||||
format(startOfHour(parseISO(ticket.createdAt)), "HH:mm") === a.time &&
|
||||
a.amount++;
|
||||
});
|
||||
});
|
||||
|
||||
return aux;
|
||||
});
|
||||
}, [tickets]);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Title>{`${i18n.t("dashboard.charts.perDay.title")}${
|
||||
tickets.length
|
||||
}`}</Title>
|
||||
<ResponsiveContainer>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
barSize={40}
|
||||
width={730}
|
||||
height={250}
|
||||
margin={{
|
||||
top: 16,
|
||||
right: 16,
|
||||
bottom: 0,
|
||||
left: 24,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" stroke={theme.palette.text.secondary} />
|
||||
<YAxis
|
||||
type="number"
|
||||
allowDecimals={false}
|
||||
stroke={theme.palette.text.secondary}
|
||||
>
|
||||
<Label
|
||||
angle={270}
|
||||
position="left"
|
||||
style={{ textAnchor: "middle", fill: theme.palette.text.primary }}
|
||||
>
|
||||
Tickets
|
||||
</Label>
|
||||
</YAxis>
|
||||
<Bar dataKey="amount" fill={theme.palette.primary.main} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default Chart;
|
||||
12
frontend/src/pages/Dashboard/Title.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
|
||||
const Title = props => {
|
||||
return (
|
||||
<Typography component="h2" variant="h6" color="primary" gutterBottom>
|
||||
{props.children}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
export default Title;
|
||||
117
frontend/src/pages/Dashboard/index.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { useContext } from "react"
|
||||
|
||||
import Paper from "@material-ui/core/Paper"
|
||||
import Container from "@material-ui/core/Container"
|
||||
import Grid from "@material-ui/core/Grid"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
|
||||
import useTickets from "../../hooks/useTickets"
|
||||
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
|
||||
import Chart from "./Chart"
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
container: {
|
||||
paddingTop: theme.spacing(4),
|
||||
paddingBottom: theme.spacing(4),
|
||||
},
|
||||
fixedHeightPaper: {
|
||||
padding: theme.spacing(2),
|
||||
display: "flex",
|
||||
overflow: "auto",
|
||||
flexDirection: "column",
|
||||
height: 240,
|
||||
},
|
||||
customFixedHeightPaper: {
|
||||
padding: theme.spacing(2),
|
||||
display: "flex",
|
||||
overflow: "auto",
|
||||
flexDirection: "column",
|
||||
height: 120,
|
||||
},
|
||||
customFixedHeightPaperLg: {
|
||||
padding: theme.spacing(2),
|
||||
display: "flex",
|
||||
overflow: "auto",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
},
|
||||
}))
|
||||
|
||||
const Dashboard = () => {
|
||||
const classes = useStyles()
|
||||
|
||||
const { user } = useContext(AuthContext);
|
||||
var userQueueIds = [];
|
||||
|
||||
if (user.queues && user.queues.length > 0) {
|
||||
userQueueIds = user.queues.map(q => q.id);
|
||||
}
|
||||
|
||||
const GetTickets = (status, showAll, withUnreadMessages) => {
|
||||
|
||||
const { count } = useTickets({
|
||||
status: status,
|
||||
showAll: showAll,
|
||||
withUnreadMessages: withUnreadMessages,
|
||||
queueIds: JSON.stringify(userQueueIds)
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Container maxWidth="lg" className={classes.container}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={4}>
|
||||
<Paper className={classes.customFixedHeightPaper} style={{ overflow: "hidden" }}>
|
||||
<Typography component="h3" variant="h6" color="primary" paragraph>
|
||||
{i18n.t("dashboard.messages.inAttendance.title")}
|
||||
</Typography>
|
||||
<Grid item>
|
||||
<Typography component="h1" variant="h4">
|
||||
{GetTickets("open", "true", "false")}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Paper className={classes.customFixedHeightPaper} style={{ overflow: "hidden" }}>
|
||||
<Typography component="h3" variant="h6" color="primary" paragraph>
|
||||
{i18n.t("dashboard.messages.waiting.title")}
|
||||
</Typography>
|
||||
<Grid item>
|
||||
<Typography component="h1" variant="h4">
|
||||
{GetTickets("pending", "true", "false")}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Paper className={classes.customFixedHeightPaper} style={{ overflow: "hidden" }}>
|
||||
<Typography component="h3" variant="h6" color="primary" paragraph>
|
||||
{i18n.t("dashboard.messages.closed.title")}
|
||||
</Typography>
|
||||
<Grid item>
|
||||
<Typography component="h1" variant="h4">
|
||||
{GetTickets("closed", "true", "false")}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Paper className={classes.fixedHeightPaper}>
|
||||
<Chart />
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
153
frontend/src/pages/Login/index.js
Normal file
@@ -0,0 +1,153 @@
|
||||
import React, { useState, useContext } from "react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
CssBaseline,
|
||||
TextField,
|
||||
Grid,
|
||||
Box,
|
||||
Typography,
|
||||
Container,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Link
|
||||
} from '@material-ui/core';
|
||||
|
||||
import { LockOutlined, Visibility, VisibilityOff } from '@material-ui/icons';
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
|
||||
import { AuthContext } from "../../context/Auth/AuthContext";
|
||||
|
||||
// const Copyright = () => {
|
||||
// return (
|
||||
// <Typography variant="body2" color="textSecondary" align="center">
|
||||
// {"Copyleft "}
|
||||
// <Link color="inherit" href="https://github.com/canove">
|
||||
// Canove
|
||||
// </Link>{" "}
|
||||
// {new Date().getFullYear()}
|
||||
// {"."}
|
||||
// </Typography>
|
||||
// );
|
||||
// };
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
paper: {
|
||||
marginTop: theme.spacing(8),
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
},
|
||||
avatar: {
|
||||
margin: theme.spacing(1),
|
||||
backgroundColor: theme.palette.secondary.main,
|
||||
},
|
||||
form: {
|
||||
width: "100%", // Fix IE 11 issue.
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
submit: {
|
||||
margin: theme.spacing(3, 0, 2),
|
||||
},
|
||||
}));
|
||||
|
||||
const Login = () => {
|
||||
const classes = useStyles();
|
||||
|
||||
const [user, setUser] = useState({ email: "", password: "" });
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const { handleLogin } = useContext(AuthContext);
|
||||
|
||||
const handleChangeInput = (e) => {
|
||||
setUser({ ...user, [e.target.name]: e.target.value });
|
||||
};
|
||||
|
||||
const handlSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
handleLogin(user);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container component="main" maxWidth="xs">
|
||||
<CssBaseline />
|
||||
<div className={classes.paper}>
|
||||
<Avatar className={classes.avatar}>
|
||||
<LockOutlined />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
{i18n.t("login.title")}
|
||||
</Typography>
|
||||
<form className={classes.form} noValidate onSubmit={handlSubmit}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="email"
|
||||
label={i18n.t("login.form.email")}
|
||||
name="email"
|
||||
value={user.email}
|
||||
onChange={handleChangeInput}
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
/>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="password"
|
||||
label={i18n.t("login.form.password")}
|
||||
id="password"
|
||||
value={user.password}
|
||||
onChange={handleChangeInput}
|
||||
autoComplete="current-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={() => setShowPassword((e) => !e)}
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className={classes.submit}
|
||||
>
|
||||
{i18n.t("login.buttons.submit")}
|
||||
</Button>
|
||||
<Grid container>
|
||||
<Grid item>
|
||||
<Link
|
||||
href="#"
|
||||
variant="body2"
|
||||
component={RouterLink}
|
||||
to="/signup"
|
||||
>
|
||||
{i18n.t("login.buttons.register")}
|
||||
</Link>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</form>
|
||||
</div>
|
||||
<Box mt={8}>{/* <Copyright /> */}</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
268
frontend/src/pages/Queues/index.js
Normal file
@@ -0,0 +1,268 @@
|
||||
import React, { useEffect, useReducer, useState } from "react";
|
||||
|
||||
import openSocket from "../../services/socket-io";
|
||||
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
makeStyles,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from "@material-ui/core";
|
||||
|
||||
import MainContainer from "../../components/MainContainer";
|
||||
import MainHeader from "../../components/MainHeader";
|
||||
import MainHeaderButtonsWrapper from "../../components/MainHeaderButtonsWrapper";
|
||||
import TableRowSkeleton from "../../components/TableRowSkeleton";
|
||||
import Title from "../../components/Title";
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import toastError from "../../errors/toastError";
|
||||
import api from "../../services/api";
|
||||
import { DeleteOutline, Edit } from "@material-ui/icons";
|
||||
import QueueModal from "../../components/QueueModal";
|
||||
import { toast } from "react-toastify";
|
||||
import ConfirmationModal from "../../components/ConfirmationModal";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
mainPaper: {
|
||||
flex: 1,
|
||||
padding: theme.spacing(1),
|
||||
overflowY: "scroll",
|
||||
...theme.scrollbarStyles,
|
||||
},
|
||||
customTableCell: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
}));
|
||||
|
||||
const reducer = (state, action) => {
|
||||
if (action.type === "LOAD_QUEUES") {
|
||||
const queues = action.payload;
|
||||
const newQueues = [];
|
||||
|
||||
queues.forEach((queue) => {
|
||||
const queueIndex = state.findIndex((q) => q.id === queue.id);
|
||||
if (queueIndex !== -1) {
|
||||
state[queueIndex] = queue;
|
||||
} else {
|
||||
newQueues.push(queue);
|
||||
}
|
||||
});
|
||||
|
||||
return [...state, ...newQueues];
|
||||
}
|
||||
|
||||
if (action.type === "UPDATE_QUEUES") {
|
||||
const queue = action.payload;
|
||||
const queueIndex = state.findIndex((u) => u.id === queue.id);
|
||||
|
||||
if (queueIndex !== -1) {
|
||||
state[queueIndex] = queue;
|
||||
return [...state];
|
||||
} else {
|
||||
return [queue, ...state];
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === "DELETE_QUEUE") {
|
||||
const queueId = action.payload;
|
||||
const queueIndex = state.findIndex((q) => q.id === queueId);
|
||||
if (queueIndex !== -1) {
|
||||
state.splice(queueIndex, 1);
|
||||
}
|
||||
return [...state];
|
||||
}
|
||||
|
||||
if (action.type === "RESET") {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const Queues = () => {
|
||||
const classes = useStyles();
|
||||
|
||||
const [queues, dispatch] = useReducer(reducer, []);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [queueModalOpen, setQueueModalOpen] = useState(false);
|
||||
const [selectedQueue, setSelectedQueue] = useState(null);
|
||||
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get("/queue");
|
||||
dispatch({ type: "LOAD_QUEUES", payload: data });
|
||||
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket();
|
||||
|
||||
socket.on("queue", (data) => {
|
||||
if (data.action === "update" || data.action === "create") {
|
||||
dispatch({ type: "UPDATE_QUEUES", payload: data.queue });
|
||||
}
|
||||
|
||||
if (data.action === "delete") {
|
||||
dispatch({ type: "DELETE_QUEUE", payload: data.queueId });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleOpenQueueModal = () => {
|
||||
setQueueModalOpen(true);
|
||||
setSelectedQueue(null);
|
||||
};
|
||||
|
||||
const handleCloseQueueModal = () => {
|
||||
setQueueModalOpen(false);
|
||||
setSelectedQueue(null);
|
||||
};
|
||||
|
||||
const handleEditQueue = (queue) => {
|
||||
setSelectedQueue(queue);
|
||||
setQueueModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseConfirmationModal = () => {
|
||||
setConfirmModalOpen(false);
|
||||
setSelectedQueue(null);
|
||||
};
|
||||
|
||||
const handleDeleteQueue = async (queueId) => {
|
||||
try {
|
||||
await api.delete(`/queue/${queueId}`);
|
||||
toast.success(i18n.t("Queue deleted successfully!"));
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
setSelectedQueue(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<MainContainer>
|
||||
<ConfirmationModal
|
||||
title={
|
||||
selectedQueue &&
|
||||
`${i18n.t("queues.confirmationModal.deleteTitle")} ${
|
||||
selectedQueue.name
|
||||
}?`
|
||||
}
|
||||
open={confirmModalOpen}
|
||||
onClose={handleCloseConfirmationModal}
|
||||
onConfirm={() => handleDeleteQueue(selectedQueue.id)}
|
||||
>
|
||||
{i18n.t("queues.confirmationModal.deleteMessage")}
|
||||
</ConfirmationModal>
|
||||
<QueueModal
|
||||
open={queueModalOpen}
|
||||
onClose={handleCloseQueueModal}
|
||||
queueId={selectedQueue?.id}
|
||||
/>
|
||||
<MainHeader>
|
||||
<Title>{i18n.t("queues.title")}</Title>
|
||||
<MainHeaderButtonsWrapper>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleOpenQueueModal}
|
||||
>
|
||||
{i18n.t("queues.buttons.add")}
|
||||
</Button>
|
||||
</MainHeaderButtonsWrapper>
|
||||
</MainHeader>
|
||||
<Paper className={classes.mainPaper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell align="center">
|
||||
{i18n.t("queues.table.name")}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{i18n.t("queues.table.color")}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{i18n.t("queues.table.greeting")}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{i18n.t("queues.table.actions")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<>
|
||||
{queues.map((queue) => (
|
||||
<TableRow key={queue.id}>
|
||||
<TableCell align="center">{queue.name}</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className={classes.customTableCell}>
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: queue.color,
|
||||
width: 60,
|
||||
height: 20,
|
||||
alignSelf: "center",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className={classes.customTableCell}>
|
||||
<Typography
|
||||
style={{ width: 300, align: "center" }}
|
||||
noWrap
|
||||
variant="body2"
|
||||
>
|
||||
{queue.greetingMessage}
|
||||
</Typography>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleEditQueue(queue)}
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setSelectedQueue(queue);
|
||||
setConfirmModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<DeleteOutline />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{loading && <TableRowSkeleton columns={4} />}
|
||||
</>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</MainContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Queues;
|
||||
288
frontend/src/pages/QuickAnswers/index.js
Normal file
@@ -0,0 +1,288 @@
|
||||
import React, { useState, useEffect, useReducer } from "react";
|
||||
import openSocket from "../../services/socket-io";
|
||||
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
makeStyles,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
InputAdornment,
|
||||
TextField,
|
||||
} from "@material-ui/core";
|
||||
import { Edit, DeleteOutline } from "@material-ui/icons";
|
||||
import SearchIcon from "@material-ui/icons/Search";
|
||||
|
||||
import MainContainer from "../../components/MainContainer";
|
||||
import MainHeader from "../../components/MainHeader";
|
||||
import MainHeaderButtonsWrapper from "../../components/MainHeaderButtonsWrapper";
|
||||
import Title from "../../components/Title";
|
||||
|
||||
import api from "../../services/api";
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import TableRowSkeleton from "../../components/TableRowSkeleton";
|
||||
import QuickAnswersModal from "../../components/QuickAnswersModal";
|
||||
import ConfirmationModal from "../../components/ConfirmationModal";
|
||||
import { toast } from "react-toastify";
|
||||
import toastError from "../../errors/toastError";
|
||||
|
||||
const reducer = (state, action) => {
|
||||
if (action.type === "LOAD_QUICK_ANSWERS") {
|
||||
const quickAnswers = action.payload;
|
||||
const newQuickAnswers = [];
|
||||
|
||||
quickAnswers.forEach((quickAnswer) => {
|
||||
const quickAnswerIndex = state.findIndex((q) => q.id === quickAnswer.id);
|
||||
if (quickAnswerIndex !== -1) {
|
||||
state[quickAnswerIndex] = quickAnswer;
|
||||
} else {
|
||||
newQuickAnswers.push(quickAnswer);
|
||||
}
|
||||
});
|
||||
|
||||
return [...state, ...newQuickAnswers];
|
||||
}
|
||||
|
||||
if (action.type === "UPDATE_QUICK_ANSWERS") {
|
||||
const quickAnswer = action.payload;
|
||||
const quickAnswerIndex = state.findIndex((q) => q.id === quickAnswer.id);
|
||||
|
||||
if (quickAnswerIndex !== -1) {
|
||||
state[quickAnswerIndex] = quickAnswer;
|
||||
return [...state];
|
||||
} else {
|
||||
return [quickAnswer, ...state];
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === "DELETE_QUICK_ANSWERS") {
|
||||
const quickAnswerId = action.payload;
|
||||
|
||||
const quickAnswerIndex = state.findIndex((q) => q.id === quickAnswerId);
|
||||
if (quickAnswerIndex !== -1) {
|
||||
state.splice(quickAnswerIndex, 1);
|
||||
}
|
||||
return [...state];
|
||||
}
|
||||
|
||||
if (action.type === "RESET") {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
mainPaper: {
|
||||
flex: 1,
|
||||
padding: theme.spacing(1),
|
||||
overflowY: "scroll",
|
||||
...theme.scrollbarStyles,
|
||||
},
|
||||
}));
|
||||
|
||||
const QuickAnswers = () => {
|
||||
const classes = useStyles();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
const [searchParam, setSearchParam] = useState("");
|
||||
const [quickAnswers, dispatch] = useReducer(reducer, []);
|
||||
const [selectedQuickAnswers, setSelectedQuickAnswers] = useState(null);
|
||||
const [quickAnswersModalOpen, setQuickAnswersModalOpen] = useState(false);
|
||||
const [deletingQuickAnswers, setDeletingQuickAnswers] = useState(null);
|
||||
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: "RESET" });
|
||||
setPageNumber(1);
|
||||
}, [searchParam]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const delayDebounceFn = setTimeout(() => {
|
||||
const fetchQuickAnswers = async () => {
|
||||
try {
|
||||
const { data } = await api.get("/quickAnswers/", {
|
||||
params: { searchParam, pageNumber },
|
||||
});
|
||||
dispatch({ type: "LOAD_QUICK_ANSWERS", payload: data.quickAnswers });
|
||||
setHasMore(data.hasMore);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
fetchQuickAnswers();
|
||||
}, 500);
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [searchParam, pageNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket();
|
||||
|
||||
socket.on("quickAnswer", (data) => {
|
||||
if (data.action === "update" || data.action === "create") {
|
||||
dispatch({ type: "UPDATE_QUICK_ANSWERS", payload: data.quickAnswer });
|
||||
}
|
||||
|
||||
if (data.action === "delete") {
|
||||
dispatch({
|
||||
type: "DELETE_QUICK_ANSWERS",
|
||||
payload: +data.quickAnswerId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSearch = (event) => {
|
||||
setSearchParam(event.target.value.toLowerCase());
|
||||
};
|
||||
|
||||
const handleOpenQuickAnswersModal = () => {
|
||||
setSelectedQuickAnswers(null);
|
||||
setQuickAnswersModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseQuickAnswersModal = () => {
|
||||
setSelectedQuickAnswers(null);
|
||||
setQuickAnswersModalOpen(false);
|
||||
};
|
||||
|
||||
const handleEditQuickAnswers = (quickAnswer) => {
|
||||
setSelectedQuickAnswers(quickAnswer);
|
||||
setQuickAnswersModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteQuickAnswers = async (quickAnswerId) => {
|
||||
try {
|
||||
await api.delete(`/quickAnswers/${quickAnswerId}`);
|
||||
toast.success(i18n.t("quickAnswers.toasts.deleted"));
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
setDeletingQuickAnswers(null);
|
||||
setSearchParam("");
|
||||
setPageNumber(1);
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
setPageNumber((prevState) => prevState + 1);
|
||||
};
|
||||
|
||||
const handleScroll = (e) => {
|
||||
if (!hasMore || loading) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
if (scrollHeight - (scrollTop + 100) < clientHeight) {
|
||||
loadMore();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MainContainer>
|
||||
<ConfirmationModal
|
||||
title={
|
||||
deletingQuickAnswers &&
|
||||
`${i18n.t("quickAnswers.confirmationModal.deleteTitle")} ${
|
||||
deletingQuickAnswers.shortcut
|
||||
}?`
|
||||
}
|
||||
open={confirmModalOpen}
|
||||
onClose={setConfirmModalOpen}
|
||||
onConfirm={() => handleDeleteQuickAnswers(deletingQuickAnswers.id)}
|
||||
>
|
||||
{i18n.t("quickAnswers.confirmationModal.deleteMessage")}
|
||||
</ConfirmationModal>
|
||||
<QuickAnswersModal
|
||||
open={quickAnswersModalOpen}
|
||||
onClose={handleCloseQuickAnswersModal}
|
||||
aria-labelledby="form-dialog-title"
|
||||
quickAnswerId={selectedQuickAnswers && selectedQuickAnswers.id}
|
||||
></QuickAnswersModal>
|
||||
<MainHeader>
|
||||
<Title>{i18n.t("quickAnswers.title")}</Title>
|
||||
<MainHeaderButtonsWrapper>
|
||||
<TextField
|
||||
placeholder={i18n.t("quickAnswers.searchPlaceholder")}
|
||||
type="search"
|
||||
value={searchParam}
|
||||
onChange={handleSearch}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon style={{ color: "gray" }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleOpenQuickAnswersModal}
|
||||
>
|
||||
{i18n.t("quickAnswers.buttons.add")}
|
||||
</Button>
|
||||
</MainHeaderButtonsWrapper>
|
||||
</MainHeader>
|
||||
<Paper
|
||||
className={classes.mainPaper}
|
||||
variant="outlined"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell align="center">
|
||||
{i18n.t("quickAnswers.table.shortcut")}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{i18n.t("quickAnswers.table.message")}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{i18n.t("quickAnswers.table.actions")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<>
|
||||
{quickAnswers.map((quickAnswer) => (
|
||||
<TableRow key={quickAnswer.id}>
|
||||
<TableCell align="center">{quickAnswer.shortcut}</TableCell>
|
||||
<TableCell align="center">{quickAnswer.message}</TableCell>
|
||||
<TableCell align="center">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleEditQuickAnswers(quickAnswer)}
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
setConfirmModalOpen(true);
|
||||
setDeletingQuickAnswers(quickAnswer);
|
||||
}}
|
||||
>
|
||||
<DeleteOutline />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{loading && <TableRowSkeleton columns={3} />}
|
||||
</>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</MainContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickAnswers;
|
||||
144
frontend/src/pages/Settings/index.js
Normal file
@@ -0,0 +1,144 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import openSocket from "../../services/socket-io";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import Paper from "@material-ui/core/Paper";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
import Container from "@material-ui/core/Container";
|
||||
import Select from "@material-ui/core/Select";
|
||||
import TextField from "@material-ui/core/TextField";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import api from "../../services/api";
|
||||
import { i18n } from "../../translate/i18n.js";
|
||||
import toastError from "../../errors/toastError";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: theme.spacing(8, 8, 3),
|
||||
},
|
||||
|
||||
paper: {
|
||||
padding: theme.spacing(2),
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
marginBottom: 12,
|
||||
|
||||
},
|
||||
|
||||
settingOption: {
|
||||
marginLeft: "auto",
|
||||
},
|
||||
margin: {
|
||||
margin: theme.spacing(1),
|
||||
},
|
||||
|
||||
}));
|
||||
|
||||
const Settings = () => {
|
||||
const classes = useStyles();
|
||||
|
||||
const [settings, setSettings] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSession = async () => {
|
||||
try {
|
||||
const { data } = await api.get("/settings");
|
||||
setSettings(data);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
fetchSession();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket();
|
||||
|
||||
socket.on("settings", data => {
|
||||
if (data.action === "update") {
|
||||
setSettings(prevState => {
|
||||
const aux = [...prevState];
|
||||
const settingIndex = aux.findIndex(s => s.key === data.setting.key);
|
||||
aux[settingIndex].value = data.setting.value;
|
||||
return aux;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChangeSetting = async e => {
|
||||
const selectedValue = e.target.value;
|
||||
const settingKey = e.target.name;
|
||||
|
||||
try {
|
||||
await api.put(`/settings/${settingKey}`, {
|
||||
value: selectedValue,
|
||||
});
|
||||
toast.success(i18n.t("settings.success"));
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const getSettingValue = key => {
|
||||
const { value } = settings.find(s => s.key === key);
|
||||
return value;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Container className={classes.container} maxWidth="sm">
|
||||
<Typography variant="body2" gutterBottom>
|
||||
{i18n.t("settings.title")}
|
||||
</Typography>
|
||||
<Paper className={classes.paper}>
|
||||
<Typography variant="body1">
|
||||
{i18n.t("settings.settings.userCreation.name")}
|
||||
</Typography>
|
||||
<Select
|
||||
margin="dense"
|
||||
variant="outlined"
|
||||
native
|
||||
id="userCreation-setting"
|
||||
name="userCreation"
|
||||
value={
|
||||
settings && settings.length > 0 && getSettingValue("userCreation")
|
||||
}
|
||||
className={classes.settingOption}
|
||||
onChange={handleChangeSetting}
|
||||
>
|
||||
<option value="enabled">
|
||||
{i18n.t("settings.settings.userCreation.options.enabled")}
|
||||
</option>
|
||||
<option value="disabled">
|
||||
{i18n.t("settings.settings.userCreation.options.disabled")}
|
||||
</option>
|
||||
</Select>
|
||||
|
||||
</Paper>
|
||||
|
||||
<Paper className={classes.paper}>
|
||||
<TextField
|
||||
id="api-token-setting"
|
||||
readonly
|
||||
label="Token Api"
|
||||
margin="dense"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
value={settings && settings.length > 0 && getSettingValue("userApiToken")}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
202
frontend/src/pages/Signup/index.js
Normal file
@@ -0,0 +1,202 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import * as Yup from "yup";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { Formik, Form, Field } from "formik";
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
CssBaseline,
|
||||
TextField,
|
||||
Grid,
|
||||
Box,
|
||||
Typography,
|
||||
Container,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Link
|
||||
} from '@material-ui/core';
|
||||
|
||||
import { LockOutlined, Visibility, VisibilityOff } from '@material-ui/icons';
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
|
||||
import api from "../../services/api";
|
||||
import toastError from "../../errors/toastError";
|
||||
|
||||
// const Copyright = () => {
|
||||
// return (
|
||||
// <Typography variant="body2" color="textSecondary" align="center">
|
||||
// {"Copyleft "}
|
||||
// <Link color="inherit" href="https://github.com/canove">
|
||||
// Canove
|
||||
// </Link>{" "}
|
||||
// {new Date().getFullYear()}
|
||||
// {"."}
|
||||
// </Typography>
|
||||
// );
|
||||
// };
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
paper: {
|
||||
marginTop: theme.spacing(8),
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
},
|
||||
avatar: {
|
||||
margin: theme.spacing(1),
|
||||
backgroundColor: theme.palette.secondary.main,
|
||||
},
|
||||
form: {
|
||||
width: "100%",
|
||||
marginTop: theme.spacing(3),
|
||||
},
|
||||
submit: {
|
||||
margin: theme.spacing(3, 0, 2),
|
||||
},
|
||||
}));
|
||||
|
||||
const UserSchema = Yup.object().shape({
|
||||
name: Yup.string()
|
||||
.min(2, "Too Short!")
|
||||
.max(50, "Too Long!")
|
||||
.required("Required"),
|
||||
password: Yup.string().min(5, "Too Short!").max(50, "Too Long!"),
|
||||
email: Yup.string().email("Invalid email").required("Required"),
|
||||
});
|
||||
|
||||
const SignUp = () => {
|
||||
const classes = useStyles();
|
||||
const history = useHistory();
|
||||
|
||||
const initialState = { name: "", email: "", password: "" };
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [user] = useState(initialState);
|
||||
|
||||
const handleSignUp = async values => {
|
||||
try {
|
||||
await api.post("/auth/signup", values);
|
||||
toast.success(i18n.t("signup.toasts.success"));
|
||||
history.push("/login");
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container component="main" maxWidth="xs">
|
||||
<CssBaseline />
|
||||
<div className={classes.paper}>
|
||||
<Avatar className={classes.avatar}>
|
||||
<LockOutlined />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
{i18n.t("signup.title")}
|
||||
</Typography>
|
||||
{/* <form className={classes.form} noValidate onSubmit={handleSignUp}> */}
|
||||
<Formik
|
||||
initialValues={user}
|
||||
enableReinitialize={true}
|
||||
validationSchema={UserSchema}
|
||||
onSubmit={(values, actions) => {
|
||||
setTimeout(() => {
|
||||
handleSignUp(values);
|
||||
actions.setSubmitting(false);
|
||||
}, 400);
|
||||
}}
|
||||
>
|
||||
{({ touched, errors, isSubmitting }) => (
|
||||
<Form className={classes.form}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Field
|
||||
as={TextField}
|
||||
autoComplete="name"
|
||||
name="name"
|
||||
error={touched.name && Boolean(errors.name)}
|
||||
helperText={touched.name && errors.name}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
id="name"
|
||||
label={i18n.t("signup.form.name")}
|
||||
autoFocus
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Field
|
||||
as={TextField}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
id="email"
|
||||
label={i18n.t("signup.form.email")}
|
||||
name="email"
|
||||
error={touched.email && Boolean(errors.email)}
|
||||
helperText={touched.email && errors.email}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Field
|
||||
as={TextField}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
name="password"
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
error={touched.password && Boolean(errors.password)}
|
||||
helperText={touched.password && errors.password}
|
||||
label={i18n.t("signup.form.password")}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={() => setShowPassword((e) => !e)}
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className={classes.submit}
|
||||
>
|
||||
{i18n.t("signup.buttons.submit")}
|
||||
</Button>
|
||||
<Grid container justifyContent="flex-end">
|
||||
<Grid item>
|
||||
<Link
|
||||
href="#"
|
||||
variant="body2"
|
||||
component={RouterLink}
|
||||
to="/login"
|
||||
>
|
||||
{i18n.t("signup.buttons.login")}
|
||||
</Link>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
<Box mt={5}>{/* <Copyright /> */}</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignUp;
|
||||
105
frontend/src/pages/Tickets/index.js
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import Grid from "@material-ui/core/Grid";
|
||||
import Paper from "@material-ui/core/Paper";
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
|
||||
import TicketsManager from "../../components/TicketsManager/";
|
||||
import Ticket from "../../components/Ticket/";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import Hidden from "@material-ui/core/Hidden";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
chatContainer: {
|
||||
flex: 1,
|
||||
// // backgroundColor: "#eee",
|
||||
// padding: theme.spacing(4),
|
||||
height: `calc(100% - 48px)`,
|
||||
overflowY: "hidden",
|
||||
},
|
||||
|
||||
chatPapper: {
|
||||
// backgroundColor: "red",
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
},
|
||||
|
||||
contactsWrapper: {
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
flexDirection: "column",
|
||||
overflowY: "hidden",
|
||||
},
|
||||
contactsWrapperSmall: {
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
flexDirection: "column",
|
||||
overflowY: "hidden",
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
messagessWrapper: {
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
flexDirection: "column",
|
||||
},
|
||||
welcomeMsg: {
|
||||
backgroundColor: "#eee",
|
||||
display: "flex",
|
||||
justifyContent: "space-evenly",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
textAlign: "center",
|
||||
borderRadius: 0,
|
||||
},
|
||||
ticketsManager: {},
|
||||
ticketsManagerClosed: {
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const Chat = () => {
|
||||
const classes = useStyles();
|
||||
const { ticketId } = useParams();
|
||||
|
||||
return (
|
||||
<div className={classes.chatContainer}>
|
||||
<div className={classes.chatPapper}>
|
||||
<Grid container spacing={0}>
|
||||
{/* <Grid item xs={4} className={classes.contactsWrapper}> */}
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
md={4}
|
||||
className={
|
||||
ticketId ? classes.contactsWrapperSmall : classes.contactsWrapper
|
||||
}
|
||||
>
|
||||
<TicketsManager />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={8} className={classes.messagessWrapper}>
|
||||
{/* <Grid item xs={8} className={classes.messagessWrapper}> */}
|
||||
{ticketId ? (
|
||||
<>
|
||||
<Ticket />
|
||||
</>
|
||||
) : (
|
||||
<Hidden only={["sm", "xs"]}>
|
||||
<Paper className={classes.welcomeMsg}>
|
||||
{/* <Paper square variant="outlined" className={classes.welcomeMsg}> */}
|
||||
<span>{i18n.t("chat.noTicketMessage")}</span>
|
||||
</Paper>
|
||||
</Hidden>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Chat;
|
||||
291
frontend/src/pages/Users/index.js
Normal file
@@ -0,0 +1,291 @@
|
||||
import React, { useState, useEffect, useReducer } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import openSocket from "../../services/socket-io";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import Paper from "@material-ui/core/Paper";
|
||||
import Button from "@material-ui/core/Button";
|
||||
import Table from "@material-ui/core/Table";
|
||||
import TableBody from "@material-ui/core/TableBody";
|
||||
import TableCell from "@material-ui/core/TableCell";
|
||||
import TableHead from "@material-ui/core/TableHead";
|
||||
import TableRow from "@material-ui/core/TableRow";
|
||||
import IconButton from "@material-ui/core/IconButton";
|
||||
import SearchIcon from "@material-ui/icons/Search";
|
||||
import TextField from "@material-ui/core/TextField";
|
||||
import InputAdornment from "@material-ui/core/InputAdornment";
|
||||
|
||||
import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline";
|
||||
import EditIcon from "@material-ui/icons/Edit";
|
||||
|
||||
import MainContainer from "../../components/MainContainer";
|
||||
import MainHeader from "../../components/MainHeader";
|
||||
import MainHeaderButtonsWrapper from "../../components/MainHeaderButtonsWrapper";
|
||||
import Title from "../../components/Title";
|
||||
|
||||
import api from "../../services/api";
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import TableRowSkeleton from "../../components/TableRowSkeleton";
|
||||
import UserModal from "../../components/UserModal";
|
||||
import ConfirmationModal from "../../components/ConfirmationModal";
|
||||
import toastError from "../../errors/toastError";
|
||||
|
||||
const reducer = (state, action) => {
|
||||
if (action.type === "LOAD_USERS") {
|
||||
const users = action.payload;
|
||||
const newUsers = [];
|
||||
|
||||
users.forEach((user) => {
|
||||
const userIndex = state.findIndex((u) => u.id === user.id);
|
||||
if (userIndex !== -1) {
|
||||
state[userIndex] = user;
|
||||
} else {
|
||||
newUsers.push(user);
|
||||
}
|
||||
});
|
||||
|
||||
return [...state, ...newUsers];
|
||||
}
|
||||
|
||||
if (action.type === "UPDATE_USERS") {
|
||||
const user = action.payload;
|
||||
const userIndex = state.findIndex((u) => u.id === user.id);
|
||||
|
||||
if (userIndex !== -1) {
|
||||
state[userIndex] = user;
|
||||
return [...state];
|
||||
} else {
|
||||
return [user, ...state];
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === "DELETE_USER") {
|
||||
const userId = action.payload;
|
||||
|
||||
const userIndex = state.findIndex((u) => u.id === userId);
|
||||
if (userIndex !== -1) {
|
||||
state.splice(userIndex, 1);
|
||||
}
|
||||
return [...state];
|
||||
}
|
||||
|
||||
if (action.type === "RESET") {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
mainPaper: {
|
||||
flex: 1,
|
||||
padding: theme.spacing(1),
|
||||
overflowY: "scroll",
|
||||
...theme.scrollbarStyles,
|
||||
},
|
||||
}));
|
||||
|
||||
const Users = () => {
|
||||
const classes = useStyles();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
const [deletingUser, setDeletingUser] = useState(null);
|
||||
const [userModalOpen, setUserModalOpen] = useState(false);
|
||||
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
|
||||
const [searchParam, setSearchParam] = useState("");
|
||||
const [users, dispatch] = useReducer(reducer, []);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: "RESET" });
|
||||
setPageNumber(1);
|
||||
}, [searchParam]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const delayDebounceFn = setTimeout(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const { data } = await api.get("/users/", {
|
||||
params: { searchParam, pageNumber },
|
||||
});
|
||||
dispatch({ type: "LOAD_USERS", payload: data.users });
|
||||
setHasMore(data.hasMore);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
fetchUsers();
|
||||
}, 500);
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [searchParam, pageNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = openSocket();
|
||||
|
||||
socket.on("user", (data) => {
|
||||
if (data.action === "update" || data.action === "create") {
|
||||
dispatch({ type: "UPDATE_USERS", payload: data.user });
|
||||
}
|
||||
|
||||
if (data.action === "delete") {
|
||||
dispatch({ type: "DELETE_USER", payload: +data.userId });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleOpenUserModal = () => {
|
||||
setSelectedUser(null);
|
||||
setUserModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseUserModal = () => {
|
||||
setSelectedUser(null);
|
||||
setUserModalOpen(false);
|
||||
};
|
||||
|
||||
const handleSearch = (event) => {
|
||||
setSearchParam(event.target.value.toLowerCase());
|
||||
};
|
||||
|
||||
const handleEditUser = (user) => {
|
||||
setSelectedUser(user);
|
||||
setUserModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId) => {
|
||||
try {
|
||||
await api.delete(`/users/${userId}`);
|
||||
toast.success(i18n.t("users.toasts.deleted"));
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
setDeletingUser(null);
|
||||
setSearchParam("");
|
||||
setPageNumber(1);
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
setPageNumber((prevState) => prevState + 1);
|
||||
};
|
||||
|
||||
const handleScroll = (e) => {
|
||||
if (!hasMore || loading) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
if (scrollHeight - (scrollTop + 100) < clientHeight) {
|
||||
loadMore();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MainContainer>
|
||||
<ConfirmationModal
|
||||
title={
|
||||
deletingUser &&
|
||||
`${i18n.t("users.confirmationModal.deleteTitle")} ${
|
||||
deletingUser.name
|
||||
}?`
|
||||
}
|
||||
open={confirmModalOpen}
|
||||
onClose={setConfirmModalOpen}
|
||||
onConfirm={() => handleDeleteUser(deletingUser.id)}
|
||||
>
|
||||
{i18n.t("users.confirmationModal.deleteMessage")}
|
||||
</ConfirmationModal>
|
||||
<UserModal
|
||||
open={userModalOpen}
|
||||
onClose={handleCloseUserModal}
|
||||
aria-labelledby="form-dialog-title"
|
||||
userId={selectedUser && selectedUser.id}
|
||||
/>
|
||||
<MainHeader>
|
||||
<Title>{i18n.t("users.title")}</Title>
|
||||
<MainHeaderButtonsWrapper>
|
||||
<TextField
|
||||
placeholder={i18n.t("contacts.searchPlaceholder")}
|
||||
type="search"
|
||||
value={searchParam}
|
||||
onChange={handleSearch}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon style={{ color: "gray" }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleOpenUserModal}
|
||||
>
|
||||
{i18n.t("users.buttons.add")}
|
||||
</Button>
|
||||
</MainHeaderButtonsWrapper>
|
||||
</MainHeader>
|
||||
<Paper
|
||||
className={classes.mainPaper}
|
||||
variant="outlined"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell align="center">{i18n.t("users.table.name")}</TableCell>
|
||||
<TableCell align="center">
|
||||
{i18n.t("users.table.email")}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{i18n.t("users.table.profile")}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{i18n.t("users.table.whatsapp")}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{i18n.t("users.table.actions")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell align="center">{user.name}</TableCell>
|
||||
<TableCell align="center">{user.email}</TableCell>
|
||||
<TableCell align="center">{user.profile}</TableCell>
|
||||
<TableCell align="center">{user.whatsapp?.name}</TableCell>
|
||||
<TableCell align="center">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleEditUser(user)}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
setConfirmModalOpen(true);
|
||||
setDeletingUser(user);
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{loading && <TableRowSkeleton columns={4} />}
|
||||
</>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</MainContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Users;
|
||||
36
frontend/src/routes/Route.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import React, { useContext } from "react";
|
||||
import { Route as RouterRoute, Redirect } from "react-router-dom";
|
||||
|
||||
import { AuthContext } from "../context/Auth/AuthContext";
|
||||
import BackdropLoading from "../components/BackdropLoading";
|
||||
|
||||
const Route = ({ component: Component, isPrivate = false, ...rest }) => {
|
||||
const { isAuth, loading } = useContext(AuthContext);
|
||||
|
||||
if (!isAuth && isPrivate) {
|
||||
return (
|
||||
<>
|
||||
{loading && <BackdropLoading />}
|
||||
<Redirect to={{ pathname: "/login", state: { from: rest.location } }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuth && !isPrivate) {
|
||||
return (
|
||||
<>
|
||||
{loading && <BackdropLoading />}
|
||||
<Redirect to={{ pathname: "/", state: { from: rest.location } }} />;
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading && <BackdropLoading />}
|
||||
<RouterRoute {...rest} component={Component} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Route;
|
||||
61
frontend/src/routes/index.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import { BrowserRouter, Switch } from "react-router-dom";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
|
||||
import LoggedInLayout from "../layout";
|
||||
import Dashboard from "../pages/Dashboard/";
|
||||
import Tickets from "../pages/Tickets/";
|
||||
import Signup from "../pages/Signup/";
|
||||
import Login from "../pages/Login/";
|
||||
import Connections from "../pages/Connections/";
|
||||
import Settings from "../pages/Settings/";
|
||||
import Users from "../pages/Users";
|
||||
import Contacts from "../pages/Contacts/";
|
||||
import QuickAnswers from "../pages/QuickAnswers/";
|
||||
import Queues from "../pages/Queues/";
|
||||
import { AuthProvider } from "../context/Auth/AuthContext";
|
||||
import { WhatsAppsProvider } from "../context/WhatsApp/WhatsAppsContext";
|
||||
import Route from "./Route";
|
||||
|
||||
const Routes = () => {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Switch>
|
||||
<Route exact path="/login" component={Login} />
|
||||
<Route exact path="/signup" component={Signup} />
|
||||
<WhatsAppsProvider>
|
||||
<LoggedInLayout>
|
||||
<Route exact path="/" component={Dashboard} isPrivate />
|
||||
<Route
|
||||
exact
|
||||
path="/tickets/:ticketId?"
|
||||
component={Tickets}
|
||||
isPrivate
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/connections"
|
||||
component={Connections}
|
||||
isPrivate
|
||||
/>
|
||||
<Route exact path="/contacts" component={Contacts} isPrivate />
|
||||
<Route exact path="/users" component={Users} isPrivate />
|
||||
<Route
|
||||
exact
|
||||
path="/quickAnswers"
|
||||
component={QuickAnswers}
|
||||
isPrivate
|
||||
/>
|
||||
<Route exact path="/Settings" component={Settings} isPrivate />
|
||||
<Route exact path="/Queues" component={Queues} isPrivate />
|
||||
</LoggedInLayout>
|
||||
</WhatsAppsProvider>
|
||||
</Switch>
|
||||
<ToastContainer autoClose={3000} />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
export default Routes;
|
||||
19
frontend/src/rules.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const rules = {
|
||||
user: {
|
||||
static: [],
|
||||
},
|
||||
|
||||
admin: {
|
||||
static: [
|
||||
"drawer-admin-items:view",
|
||||
"tickets-manager:showall",
|
||||
"user-modal:editProfile",
|
||||
"user-modal:editQueues",
|
||||
"ticket-options:deleteTicket",
|
||||
"ticket-options:transferWhatsapp",
|
||||
"contacts-page:deleteContact",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default rules;
|
||||
9
frontend/src/services/api.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import axios from "axios";
|
||||
import { getBackendUrl } from "../config";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: getBackendUrl(),
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
export default api;
|
||||
8
frontend/src/services/socket-io.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import openSocket from "socket.io-client";
|
||||
import { getBackendUrl } from "../config";
|
||||
|
||||
function connectToSocket() {
|
||||
return openSocket(getBackendUrl());
|
||||
}
|
||||
|
||||
export default connectToSocket;
|
||||