Initial commit

This commit is contained in:
2023-02-23 18:13:04 -06:00
commit 43c3e1563f
298 changed files with 18530 additions and 0 deletions

View 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'

View 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}}

View File

@@ -0,0 +1,3 @@
expires 1y;
add_header Cache-Control "public";
access_log off;

View 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;
}

View 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;

View 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;

View File

@@ -0,0 +1,5 @@
server {
listen 80;
listen [::]:80;
return 302 https://$host$request_uri;
}

View File

@@ -0,0 +1,2 @@
listen 443 ssl http2;
listen [::]:443 ssl http2;

View 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;
}

View 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
View File

@@ -0,0 +1,5 @@
.git
*Dockerfile*
*docker-compose*
node_modules
build

28
frontend/.gitignore vendored Normal file
View 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
View 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
View 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": {}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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>

View 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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

9
frontend/server.js Normal file
View 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
View 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;

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 682 KiB

View 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>}
</>
);
}

View 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;

View 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;

View 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 };

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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);

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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>
);
}

View 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={''}>&nbsp;</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;

View 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={''}>&nbsp;</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;

View 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;

View 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
View 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');
}

View 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 };

View File

@@ -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 };

View 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 };

View 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;

View 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;

View 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];
}

View 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;

View 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;

View 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
View 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")
// );

View 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;

View 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;

View 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;

View 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
View 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;

View 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;

View 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

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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;

View File

@@ -0,0 +1,9 @@
import axios from "axios";
import { getBackendUrl } from "../config";
const api = axios.create({
baseURL: getBackendUrl(),
withCredentials: true,
});
export default api;

View File

@@ -0,0 +1,8 @@
import openSocket from "socket.io-client";
import { getBackendUrl } from "../config";
function connectToSocket() {
return openSocket(getBackendUrl());
}
export default connectToSocket;

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