This commit is contained in:
2025-04-30 13:14:27 -06:00
parent 2f84152f34
commit 6b32fe18d2
757 changed files with 97380 additions and 1 deletions

2
frontend/.env.exemple Normal file
View File

@@ -0,0 +1,2 @@
REACT_APP_BACKEND_URL=https://url front
REACT_APP_HOURS_CLOSE_TICKETS_AUTO = 24

27059
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

91
frontend/package.json Normal file
View File

@@ -0,0 +1,91 @@
{
"name": "frontend",
"version": "6.0.0",
"versionSystem": "6.0.0",
"nomeEmpresa": "Atendechat",
"private": true,
"dependencies": {
"@date-io/date-fns": "^2.14.0",
"@emotion/styled": "^11.10.6",
"@material-ui/core": "4.12.3",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.56",
"@material-ui/pickers": "^3.3.10",
"@material-ui/styles": "^4.11.5",
"@mui/material": "^5.10.13",
"@mui/x-date-pickers": "^6.0.1",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.0.4",
"@testing-library/user-event": "^12.1.7",
"axios": "^0.21.1",
"bootstrap": "^5.2.3",
"chart.js": "^3.9.1",
"chartjs-plugin-datalabels": "^2.1.0",
"context": "^4.0.0",
"date-fns": "^2.16.1",
"emoji-mart": "^3.0.0",
"formik": "^2.2.0",
"formik-material-ui": "^3.0.1",
"gn-api-sdk-node": "^3.0.2",
"i18next": "^19.8.2",
"i18next-browser-languagedetector": "^6.0.1",
"jsonwebtoken": "^9.0.2",
"markdown-to-jsx": "^7.1.0",
"material-ui-color": "^1.2.0",
"mic-recorder-to-mp3": "^2.2.2",
"moment": "^2.29.1",
"qrcode.react": "^1.0.0",
"query-string": "^7.0.0",
"react": "^17.0.2",
"react-big-calendar": "^1.8.7",
"react-bootstrap": "^2.7.0",
"react-chartjs-2": "^4.3.1",
"react-color": "^2.19.3",
"react-copy-to-clipboard": "^5.1.0",
"react-csv": "^2.2.2",
"react-currency-format": "^1.1.0",
"react-dom": "^17.0.2",
"react-icons": "^4.4.0",
"react-input-mask": "^2.0.4",
"react-modal-image": "^2.5.0",
"react-number-format": "^4.6.4",
"react-qr-code": "^2.0.7",
"react-query": "^3.39.3",
"react-router-dom": "^5.2.0",
"react-scripts": "3.4.3",
"react-text-mask": "^5.5.0",
"react-toastify": "9.0.0",
"react-trello": "^2.2.11",
"recharts": "^2.0.2",
"socket.io-client": "^4.7.5",
"styled-components": "^5.3.5",
"text-mask-addons": "^3.8.0",
"use-debounce": "^7.0.0",
"use-sound": "^2.0.1",
"uuid": "^8.3.2",
"xlsx": "^0.18.5",
"yup": "^0.32.8"
},
"scripts": {
"start": "NODE_OPTIONS=--openssl-legacy-provider react-scripts start",
"build": "NODE_OPTIONS=--openssl-legacy-provider GENERATE_SOURCEMAP=false react-scripts build",
"builddev": "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"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Atendechat</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"
/>
<!-- Issue workaround for React v16. -->
<script>
// See https://github.com/facebook/react/issues/20829#issuecomment-802088260
if (!crossOriginIsolated) SharedArrayBuffer = ArrayBuffer;
</script>
</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": "Atendechat",
"name": "Atendechat",
"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: 110 KiB

119
frontend/src/App.js Normal file
View File

@@ -0,0 +1,119 @@
import React, { useState, useEffect } from "react";
import "react-toastify/dist/ReactToastify.css";
import { QueryClient, QueryClientProvider } from "react-query";
import {enUS, ptBR, esES} from "@material-ui/core/locale";
import { createTheme, ThemeProvider } from "@material-ui/core/styles";
import { useMediaQuery } from "@material-ui/core";
import ColorModeContext from "./layout/themeContext";
import { SocketContext, SocketManager } from './context/Socket/SocketContext';
import Routes from "./routes";
const queryClient = new QueryClient();
const App = () => {
const [locale, setLocale] = useState();
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
const preferredTheme = window.localStorage.getItem("preferredTheme");
const [mode, setMode] = useState(preferredTheme ? preferredTheme : prefersDarkMode ? "dark" : "light");
const colorMode = React.useMemo(
() => ({
toggleColorMode: () => {
setMode((prevMode) => (prevMode === "light" ? "dark" : "light"));
},
}),
[]
);
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: "#682EE3",
},
},
scrollbarStylesSoft: {
"&::-webkit-scrollbar": {
width: "8px",
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: mode === "light" ? "#F3F3F3" : "#333333",
},
},
palette: {
type: mode,
primary: { main: mode === "light" ? "#682EE3" : "#FFFFFF" },
textPrimary: mode === "light" ? "#682EE3" : "#FFFFFF",
borderPrimary: mode === "light" ? "#682EE3" : "#FFFFFF",
dark: { main: mode === "light" ? "#333333" : "#F3F3F3" },
light: { main: mode === "light" ? "#F3F3F3" : "#333333" },
tabHeaderBackground: mode === "light" ? "#EEE" : "#666",
optionsBackground: mode === "light" ? "#fafafa" : "#333",
options: mode === "light" ? "#fafafa" : "#666",
fontecor: mode === "light" ? "#128c7e" : "#fff",
fancyBackground: mode === "light" ? "#fafafa" : "#333",
bordabox: mode === "light" ? "#eee" : "#333",
newmessagebox: mode === "light" ? "#eee" : "#333",
inputdigita: mode === "light" ? "#fff" : "#666",
contactdrawer: mode === "light" ? "#fff" : "#666",
announcements: mode === "light" ? "#ededed" : "#333",
login: mode === "light" ? "#fff" : "#1C1C1C",
announcementspopover: mode === "light" ? "#fff" : "#666",
chatlist: mode === "light" ? "#eee" : "#666",
boxlist: mode === "light" ? "#ededed" : "#666",
boxchatlist: mode === "light" ? "#ededed" : "#333",
total: mode === "light" ? "#fff" : "#222",
messageIcons: mode === "light" ? "grey" : "#F3F3F3",
inputBackground: mode === "light" ? "#FFFFFF" : "#333",
barraSuperior: mode === "light" ? "linear-gradient(to right, #682EE3, #682EE3 , #682EE3)" : "#666",
boxticket: mode === "light" ? "#EEE" : "#666",
campaigntab: mode === "light" ? "#ededed" : "#666",
mediainput: mode === "light" ? "#ededed" : "#1c1c1c",
},
mode,
},
locale
);
useEffect(() => {
const i18nlocale = localStorage.getItem("i18nextLng");
const browserLocale = i18nlocale?.substring(0, 2) ?? 'pt';
if (browserLocale === "pt"){
setLocale(ptBR);
}else if( browserLocale === "en" ) {
setLocale(enUS)
}else if( browserLocale === "es" )
setLocale(esES)
}, []);
useEffect(() => {
window.localStorage.setItem("preferredTheme", mode);
}, [mode]);
return (
<ColorModeContext.Provider value={{ colorMode }}>
<ThemeProvider theme={theme}>
<QueryClientProvider client={queryClient}>
<SocketContext.Provider value={SocketManager}>
<Routes />
</SocketContext.Provider>
</QueryClientProvider>
</ThemeProvider>
</ColorModeContext.Provider>
);
};
export default App;

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
frontend/src/assets/n8n.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,338 @@
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 AttachFileIcon from "@material-ui/icons/AttachFile";
import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline";
import IconButton from "@material-ui/core/IconButton";
import { i18n } from "../../translate/i18n";
import { head } from "lodash";
import api from "../../services/api";
import toastError from "../../errors/toastError";
import {
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
} from "@material-ui/core";
import ConfirmationModal from "../ConfirmationModal";
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,
},
colorAdorment: {
width: 20,
height: 20,
},
}));
const AnnouncementSchema = Yup.object().shape({
title: Yup.string().required(i18n.t("announcements.dialog.form.required")),
text: Yup.string().required(i18n.t("announcements.dialog.form.required")),
});
const AnnouncementModal = ({ open, onClose, announcementId, reload }) => {
const classes = useStyles();
const initialState = {
title: "",
text: "",
priority: 3,
status: true,
};
const [confirmationOpen, setConfirmationOpen] = useState(false);
const [announcement, setAnnouncement] = useState(initialState);
const [attachment, setAttachment] = useState(null);
const attachmentFile = useRef(null);
useEffect(() => {
try {
(async () => {
if (!announcementId) return;
const { data } = await api.get(`/announcements/${announcementId}`);
setAnnouncement((prevState) => {
return { ...prevState, ...data };
});
})();
} catch (err) {
toastError(err);
}
}, [announcementId, open]);
const handleClose = () => {
setAnnouncement(initialState);
setAttachment(null);
onClose();
};
const handleAttachmentFile = (e) => {
const file = head(e.target.files);
if (file) {
setAttachment(file);
}
};
const handleSaveAnnouncement = async (values) => {
const announcementData = { ...values };
try {
if (announcementId) {
await api.put(`/announcements/${announcementId}`, announcementData);
if (attachment != null) {
const formData = new FormData();
formData.append("file", attachment);
await api.post(
`/announcements/${announcementId}/media-upload`,
formData
);
}
} else {
const { data } = await api.post("/announcements", announcementData);
if (attachment != null) {
const formData = new FormData();
formData.append("file", attachment);
await api.post(`/announcements/${data.id}/media-upload`, formData);
}
}
toast.success(i18n.t("announcements.toasts.success"));
if (typeof reload == "function") {
reload();
}
} catch (err) {
toastError(err);
}
handleClose();
};
const deleteMedia = async () => {
if (attachment) {
setAttachment(null);
attachmentFile.current.value = null;
}
if (announcement.mediaPath) {
await api.delete(`/announcements/${announcement.id}/media-upload`);
setAnnouncement((prev) => ({
...prev,
mediaPath: null,
}));
toast.success(i18n.t("announcements.toasts.deleted"));
if (typeof reload == "function") {
reload();
}
}
};
return (
<div className={classes.root}>
<ConfirmationModal
title={i18n.t("announcements.confirmationModal.deleteTitle")}
open={confirmationOpen}
onClose={() => setConfirmationOpen(false)}
onConfirm={deleteMedia}
>
{i18n.t("announcements.confirmationModal.deleteMessage")}
</ConfirmationModal>
<Dialog
open={open}
onClose={handleClose}
maxWidth="xs"
fullWidth
scroll="paper"
>
<DialogTitle id="form-dialog-title">
{announcementId
? `${i18n.t("announcements.dialog.edit")}`
: `${i18n.t("announcements.dialog.add")}`}
</DialogTitle>
<div style={{ display: "none" }}>
<input
type="file"
accept=".png,.jpg,.jpeg"
ref={attachmentFile}
onChange={(e) => handleAttachmentFile(e)}
/>
</div>
<Formik
initialValues={announcement}
enableReinitialize={true}
validationSchema={AnnouncementSchema}
onSubmit={(values, actions) => {
setTimeout(() => {
handleSaveAnnouncement(values);
actions.setSubmitting(false);
}, 400);
}}
>
{({ touched, errors, isSubmitting, values }) => (
<Form>
<DialogContent dividers>
<Grid spacing={2} container>
<Grid xs={12} item>
<Field
as={TextField}
label={i18n.t("announcements.dialog.form.title")}
name="title"
error={touched.title && Boolean(errors.title)}
helperText={touched.title && errors.title}
variant="outlined"
margin="dense"
fullWidth
/>
</Grid>
<Grid xs={12} item>
<Field
as={TextField}
label={i18n.t("announcements.dialog.form.text")}
name="text"
error={touched.text && Boolean(errors.text)}
helperText={touched.text && errors.text}
variant="outlined"
margin="dense"
multiline={true}
rows={7}
fullWidth
/>
</Grid>
<Grid xs={12} item>
<FormControl variant="outlined" margin="dense" fullWidth>
<InputLabel id="status-selection-label">
{i18n.t("announcements.dialog.form.status")}
</InputLabel>
<Field
as={Select}
label={i18n.t("announcements.dialog.form.status")}
placeholder={i18n.t("announcements.dialog.form.status")}
labelId="status-selection-label"
id="status"
name="status"
error={touched.status && Boolean(errors.status)}
>
<MenuItem value={true}>Ativo</MenuItem>
<MenuItem value={false}>Inativo</MenuItem>
</Field>
</FormControl>
</Grid>
<Grid xs={12} item>
<FormControl variant="outlined" margin="dense" fullWidth>
<InputLabel id="priority-selection-label">
{i18n.t("announcements.dialog.form.priority")}
</InputLabel>
<Field
as={Select}
label={i18n.t("announcements.dialog.form.priority")}
placeholder={i18n.t(
"announcements.dialog.form.priority"
)}
labelId="priority-selection-label"
id="priority"
name="priority"
error={touched.priority && Boolean(errors.priority)}
>
<MenuItem value={1}>Alta</MenuItem>
<MenuItem value={2}>Média</MenuItem>
<MenuItem value={3}>Baixa</MenuItem>
</Field>
</FormControl>
</Grid>
{(announcement.mediaPath || attachment) && (
<Grid xs={12} item>
<Button startIcon={<AttachFileIcon />}>
{attachment ? attachment.name : announcement.mediaName}
</Button>
<IconButton
onClick={() => setConfirmationOpen(true)}
color="secondary"
>
<DeleteOutlineIcon />
</IconButton>
</Grid>
)}
</Grid>
</DialogContent>
<DialogActions>
{!attachment && !announcement.mediaPath && (
<Button
color="primary"
onClick={() => attachmentFile.current.click()}
disabled={isSubmitting}
variant="outlined"
>
{i18n.t("announcements.dialog.buttons.attach")}
</Button>
)}
<Button
onClick={handleClose}
color="secondary"
disabled={isSubmitting}
variant="outlined"
>
{i18n.t("announcements.dialog.buttons.cancel")}
</Button>
<Button
type="submit"
color="primary"
disabled={isSubmitting}
variant="contained"
className={classes.btnWrapper}
>
{announcementId
? `${i18n.t("announcements.dialog.buttons.add")}`
: `${i18n.t("announcements.dialog.buttons.edit")}`}
{isSubmitting && (
<CircularProgress
size={24}
className={classes.buttonProgress}
/>
)}
</Button>
</DialogActions>
</Form>
)}
</Formik>
</Dialog>
</div>
);
};
export default AnnouncementModal;

View File

@@ -0,0 +1,329 @@
import React, { useEffect, useReducer, useState, useContext } from "react";
import { makeStyles } from "@material-ui/core/styles";
import toastError from "../../errors/toastError";
import Popover from "@material-ui/core/Popover";
import AnnouncementIcon from "@material-ui/icons/Announcement";
import Notifications from "@material-ui/icons/Notifications"
import {
Avatar,
Badge,
IconButton,
List,
ListItem,
ListItemAvatar,
ListItemText,
Dialog,
Paper,
Typography,
DialogTitle,
DialogContent,
DialogActions,
Button,
DialogContentText,
} from "@material-ui/core";
import api from "../../services/api";
import { isArray } from "lodash";
import moment from "moment";
import { SocketContext } from "../../context/Socket/SocketContext";
const useStyles = makeStyles((theme) => ({
mainPaper: {
flex: 1,
maxHeight: 3000,
maxWidth: 5000,
padding: theme.spacing(1),
overflowY: "scroll",
...theme.scrollbarStyles,
},
}));
function AnnouncementDialog({ announcement, open, handleClose }) {
const getMediaPath = (filename) => {
return `${process.env.REACT_APP_BACKEND_URL}/public/${filename}`;
};
return (
<Dialog
open={open}
onClose={() => handleClose()}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">{announcement.title}</DialogTitle>
<DialogContent>
{announcement.mediaPath && (
<div
style={{
border: "1px solid #f1f1f1",
margin: "0 auto 20px",
textAlign: "center",
width: "400px",
height: 300,
backgroundImage: `url(${getMediaPath(announcement.mediaPath)})`,
backgroundRepeat: "no-repeat",
backgroundSize: "contain",
backgroundPosition: "center",
}}
></div>
)}
<DialogContentText id="alert-dialog-description">
{announcement.text}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => handleClose()} color="primary" autoFocus>
Fechar
</Button>
</DialogActions>
</Dialog>
);
}
const reducer = (state, action) => {
if (action.type === "LOAD_ANNOUNCEMENTS") {
const announcements = action.payload;
const newAnnouncements = [];
if (isArray(announcements)) {
announcements.forEach((announcement) => {
const announcementIndex = state.findIndex(
(u) => u.id === announcement.id
);
if (announcementIndex !== -1) {
state[announcementIndex] = announcement;
} else {
newAnnouncements.push(announcement);
}
});
}
return [...state, ...newAnnouncements];
}
if (action.type === "UPDATE_ANNOUNCEMENTS") {
const announcement = action.payload;
const announcementIndex = state.findIndex((u) => u.id === announcement.id);
if (announcementIndex !== -1) {
state[announcementIndex] = announcement;
return [...state];
} else {
return [announcement, ...state];
}
}
if (action.type === "DELETE_ANNOUNCEMENT") {
const announcementId = action.payload;
const announcementIndex = state.findIndex((u) => u.id === announcementId);
if (announcementIndex !== -1) {
state.splice(announcementIndex, 1);
}
return [...state];
}
if (action.type === "RESET") {
return [];
}
};
export default function AnnouncementsPopover() {
const classes = useStyles();
const [loading, setLoading] = useState(false);
const [anchorEl, setAnchorEl] = useState(null);
const [pageNumber, setPageNumber] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [searchParam] = useState("");
const [announcements, dispatch] = useReducer(reducer, []);
const [invisible, setInvisible] = useState(false);
const [announcement, setAnnouncement] = useState({});
const [showAnnouncementDialog, setShowAnnouncementDialog] = useState(false);
const socketManager = useContext(SocketContext);
useEffect(() => {
dispatch({ type: "RESET" });
setPageNumber(1);
}, [searchParam]);
useEffect(() => {
setLoading(true);
const delayDebounceFn = setTimeout(() => {
fetchAnnouncements();
}, 500);
return () => clearTimeout(delayDebounceFn);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParam, pageNumber]);
useEffect(() => {
const companyId = localStorage.getItem("companyId");
const socket = socketManager.getSocket(companyId);
if (!socket) {
return () => {};
}
socket.on(`company-announcement`, (data) => {
if (data.action === "update" || data.action === "create") {
dispatch({ type: "UPDATE_ANNOUNCEMENTS", payload: data.record });
setInvisible(false);
}
if (data.action === "delete") {
dispatch({ type: "DELETE_ANNOUNCEMENT", payload: +data.id });
}
});
return () => {
socket.disconnect();
};
}, [socketManager]);
const fetchAnnouncements = async () => {
try {
const { data } = await api.get("/announcements/", {
params: { searchParam, pageNumber },
});
dispatch({ type: "LOAD_ANNOUNCEMENTS", payload: data.records });
setHasMore(data.hasMore);
setLoading(false);
} 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();
}
};
const handleClick = (event) => {
setAnchorEl(event.currentTarget);
setInvisible(true);
};
const handleClose = () => {
setAnchorEl(null);
};
const borderPriority = (priority) => {
if (priority === 1) {
return "4px solid #b81111";
}
if (priority === 2) {
return "4px solid orange";
}
if (priority === 3) {
return "4px solid grey";
}
};
const getMediaPath = (filename) => {
return `${process.env.REACT_APP_BACKEND_URL}/public/${filename}`;
};
const handleShowAnnouncementDialog = (record) => {
setAnnouncement(record);
setShowAnnouncementDialog(true);
setAnchorEl(null);
};
const open = Boolean(anchorEl);
const id = open ? "simple-popover" : undefined;
return (
<div>
<AnnouncementDialog
announcement={announcement}
open={showAnnouncementDialog}
handleClose={() => setShowAnnouncementDialog(false)}
/>
<IconButton
variant="contained"
aria-describedby={id}
onClick={handleClick}
style={{ color: "white" }}
>
<Badge
color="secondary"
variant="dot"
invisible={invisible || announcements.length < 1}
>
<Notifications />
</Badge>
</IconButton>
<Popover
id={id}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "center",
}}
>
<Paper
variant="outlined"
onScroll={handleScroll}
className={classes.mainPaper}
>
<List
component="nav"
aria-label="main mailbox folders"
style={{ minWidth: 300 }}
>
{isArray(announcements) &&
announcements.map((item, key) => (
<ListItem
key={key}
style={{
//background: key % 2 === 0 ? "#ededed" : "white",
border: "1px solid #eee",
borderLeft: borderPriority(item.priority),
cursor: "pointer",
}}
onClick={() => handleShowAnnouncementDialog(item)}
>
{item.mediaPath && (
<ListItemAvatar>
<Avatar
alt={item.mediaName}
src={getMediaPath(item.mediaPath)}
/>
</ListItemAvatar>
)}
<ListItemText
primary={item.title}
secondary={
<>
<Typography component="span" style={{ fontSize: 12 }}>
{moment(item.createdAt).format("DD/MM/YYYY")}
</Typography>
<span style={{ marginTop: 5, display: "block" }}></span>
<Typography component="span" variant="body2">
{item.text}
</Typography>
</>
}
/>
</ListItem>
))}
{isArray(announcements) && announcements.length === 0 && (
<ListItemText primary="Nenhum registro" />
)}
</List>
</Paper>
</Popover>
</div>
);
}

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,634 @@
import React, { useState, useEffect, useRef, useContext } from "react";
import * as Yup from "yup";
import { Formik, Form, Field } from "formik";
import { toast } from "react-toastify";
import { head } from "lodash";
import { makeStyles } from "@material-ui/core/styles";
import { green } from "@material-ui/core/colors";
import Button from "@material-ui/core/Button";
import IconButton from "@material-ui/core/IconButton";
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 AttachFileIcon from "@material-ui/icons/AttachFile";
import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline";
import { i18n } from "../../translate/i18n";
import moment from "moment";
import api from "../../services/api";
import toastError from "../../errors/toastError";
import {
Box,
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
Tab,
Tabs,
} from "@material-ui/core";
import { AuthContext } from "../../context/Auth/AuthContext";
import ConfirmationModal from "../ConfirmationModal";
const useStyles = makeStyles((theme) => ({
root: {
display: "flex",
flexWrap: "wrap",
backgroundColor: "#fff"
},
tabmsg: {
backgroundColor: theme.palette.campaigntab,
},
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 CampaignSchema = Yup.object().shape({
name: Yup.string()
.min(2, i18n.t("campaigns.dialog.form.nameShort"))
.max(50, i18n.t("campaigns.dialog.form.nameLong"))
.required(i18n.t("campaigns.dialog.form.nameRequired")),
});
const CampaignModal = ({
open,
onClose,
campaignId,
initialValues,
onSave,
resetPagination,
}) => {
const classes = useStyles();
const isMounted = useRef(true);
const { user } = useContext(AuthContext);
const { companyId } = user;
const [file, setFile] = useState(null);
const initialState = {
name: "",
message1: "",
message2: "",
message3: "",
message4: "",
message5: "",
status: "INATIVA", // INATIVA, PROGRAMADA, EM_ANDAMENTO, CANCELADA, FINALIZADA,
scheduledAt: "",
whatsappId: "",
contactListId: "",
tagListId: "Nenhuma",
companyId,
};
const [campaign, setCampaign] = useState(initialState);
const [whatsapps, setWhatsapps] = useState([]);
const [contactLists, setContactLists] = useState([]);
const [messageTab, setMessageTab] = useState(0);
const [attachment, setAttachment] = useState(null);
const [confirmationOpen, setConfirmationOpen] = useState(false);
const [campaignEditable, setCampaignEditable] = useState(true);
const attachmentFile = useRef(null);
const [tagLists, setTagLists] = useState([]);
useEffect(() => {
return () => {
isMounted.current = false;
};
}, []);
useEffect(() => {
(async () => {
try {
const { data } = await api.get("/files/", {
params: { companyId }
});
setFile(data.files);
} catch (err) {
toastError(err);
}
})();
}, []);
useEffect(() => {
if (isMounted.current) {
if (initialValues) {
setCampaign((prevState) => {
return { ...prevState, ...initialValues };
});
}
api
.get(`/contact-lists/list`, { params: { companyId } })
.then(({ data }) => setContactLists(data));
api
.get(`/whatsapp`, { params: { companyId, session: 0 } })
.then(({ data }) => setWhatsapps(data));
api.get(`/tags`, { params: { companyId } })
.then(({ data }) => {
const fetchedTags = data.tags;
// Perform any necessary data transformation here
const formattedTagLists = fetchedTags.map((tag) => ({
id: tag.id,
name: tag.name,
}));
setTagLists(formattedTagLists);
})
.catch((error) => {
console.error("Error retrieving tags:", error);
});
if (!campaignId) return;
api.get(`/campaigns/${campaignId}`).then(({ data }) => {
setCampaign((prev) => {
let prevCampaignData = Object.assign({}, prev);
Object.entries(data).forEach(([key, value]) => {
if (key === "scheduledAt" && value !== "" && value !== null) {
prevCampaignData[key] = moment(value).format("YYYY-MM-DDTHH:mm");
} else {
prevCampaignData[key] = value === null ? "" : value;
}
});
return {...prevCampaignData, tagListId: data.tagId || "Nenhuma"};
});
});
}
}, [campaignId, open, initialValues, companyId]);
useEffect(() => {
const now = moment();
const scheduledAt = moment(campaign.scheduledAt);
const moreThenAnHour =
!Number.isNaN(scheduledAt.diff(now)) && scheduledAt.diff(now, "hour") > 1;
const isEditable =
campaign.status === "INATIVA" ||
(campaign.status === "PROGRAMADA" && moreThenAnHour);
setCampaignEditable(isEditable);
}, [campaign.status, campaign.scheduledAt]);
const handleClose = () => {
onClose();
setCampaign(initialState);
};
const handleAttachmentFile = (e) => {
const file = head(e.target.files);
if (file) {
setAttachment(file);
}
};
const handleSaveCampaign = async (values) => {
try {
const dataValues = {};
Object.entries(values).forEach(([key, value]) => {
if (key === "scheduledAt" && value !== "" && value !== null) {
dataValues[key] = moment(value).format("YYYY-MM-DD HH:mm:ss");
} else {
dataValues[key] = value === "" ? null : value;
}
});
if (campaignId) {
await api.put(`/campaigns/${campaignId}`, dataValues);
if (attachment != null) {
const formData = new FormData();
formData.append("file", attachment);
await api.post(`/campaigns/${campaignId}/media-upload`, formData);
}
handleClose();
} else {
const { data } = await api.post("/campaigns", dataValues);
if (attachment != null) {
const formData = new FormData();
formData.append("file", attachment);
await api.post(`/campaigns/${data.id}/media-upload`, formData);
}
if (onSave) {
onSave(data);
}
handleClose();
}
toast.success(i18n.t("campaigns.toasts.success"));
} catch (err) {
console.log(err);
toastError(err);
}
};
const deleteMedia = async () => {
if (attachment) {
setAttachment(null);
attachmentFile.current.value = null;
}
if (campaign.mediaPath) {
await api.delete(`/campaigns/${campaign.id}/media-upload`);
setCampaign((prev) => ({ ...prev, mediaPath: null, mediaName: null }));
toast.success(i18n.t("campaigns.toasts.deleted"));
}
};
const renderMessageField = (identifier) => {
return (
<Field
as={TextField}
id={identifier}
name={identifier}
fullWidth
rows={5}
label={i18n.t(`campaigns.dialog.form.${identifier}`)}
placeholder={i18n.t("campaigns.dialog.form.messagePlaceholder")}
multiline={true}
variant="outlined"
helperText={i18n.t("campaigns.dialog.form.helper")}
disabled={!campaignEditable && campaign.status !== "CANCELADA"}
/>
);
};
const cancelCampaign = async () => {
try {
await api.post(`/campaigns/${campaign.id}/cancel`);
toast.success(i18n.t("campaigns.toasts.cancel"));
setCampaign((prev) => ({ ...prev, status: "CANCELADA" }));
resetPagination();
} catch (err) {
toast.error(err.message);
}
};
const restartCampaign = async () => {
try {
await api.post(`/campaigns/${campaign.id}/restart`);
toast.success(i18n.t("campaigns.toasts.restart"));
setCampaign((prev) => ({ ...prev, status: "EM_ANDAMENTO" }));
resetPagination();
} catch (err) {
toast.error(err.message);
}
};
return (
<div className={classes.root}>
<ConfirmationModal
title={i18n.t("campaigns.confirmationModal.deleteTitle")}
open={confirmationOpen}
onClose={() => setConfirmationOpen(false)}
onConfirm={deleteMedia}
>
{i18n.t("campaigns.confirmationModal.deleteMessage")}
</ConfirmationModal>
<Dialog
open={open}
onClose={handleClose}
fullWidth
maxWidth="md"
scroll="paper"
>
<DialogTitle id="form-dialog-title">
{campaignEditable ? (
<>
{campaignId
? `${i18n.t("campaigns.dialog.update")}`
: `${i18n.t("campaigns.dialog.new")}`}
</>
) : (
<>{`${i18n.t("campaigns.dialog.readonly")}`}</>
)}
</DialogTitle>
<div style={{ display: "none" }}>
<input
type="file"
ref={attachmentFile}
onChange={(e) => handleAttachmentFile(e)}
/>
</div>
<Formik
initialValues={campaign}
enableReinitialize={true}
validationSchema={CampaignSchema}
onSubmit={(values, actions) => {
setTimeout(() => {
handleSaveCampaign(values);
actions.setSubmitting(false);
}, 400);
}}
>
{({ values, errors, touched, isSubmitting }) => (
<Form>
<DialogContent dividers>
<Grid spacing={2} container>
<Grid xs={12} md={9} item>
<Field
as={TextField}
label={i18n.t("campaigns.dialog.form.name")}
name="name"
error={touched.name && Boolean(errors.name)}
helperText={touched.name && errors.name}
variant="outlined"
margin="dense"
fullWidth
className={classes.textField}
disabled={!campaignEditable}
/>
</Grid>
<Grid xs={12} md={4} item>
<FormControl
variant="outlined"
margin="dense"
fullWidth
className={classes.formControl}
>
<InputLabel id="contactList-selection-label">
{i18n.t("campaigns.dialog.form.contactList")}
</InputLabel>
<Field
as={Select}
label={i18n.t("campaigns.dialog.form.contactList")}
placeholder={i18n.t(
"campaigns.dialog.form.contactList"
)}
labelId="contactList-selection-label"
id="contactListId"
name="contactListId"
error={
touched.contactListId && Boolean(errors.contactListId)
}
disabled={!campaignEditable}
>
<MenuItem value="">Nenhuma</MenuItem>
{contactLists &&
contactLists.map((contactList) => (
<MenuItem
key={contactList.id}
value={contactList.id}
>
{contactList.name}
</MenuItem>
))}
</Field>
</FormControl>
</Grid>
<Grid xs={12} md={4} item>
<FormControl
variant="outlined"
margin="dense"
fullWidth
className={classes.formControl}
>
<InputLabel id="tagList-selection-label">
{i18n.t("campaigns.dialog.form.tagList")}
</InputLabel>
<Field
as={Select}
label={i18n.t("campaigns.dialog.form.tagList")}
placeholder={i18n.t("campaigns.dialog.form.tagList")}
labelId="tagList-selection-label"
id="tagListId"
name="tagListId"
error={touched.tagListId && Boolean(errors.tagListId)}
disabled={!campaignEditable}
>
<MenuItem value="">Nenhuma</MenuItem>
{Array.isArray(tagLists) &&
tagLists.map((tagList) => (
<MenuItem key={tagList.id} value={tagList.id}>
{tagList.name}
</MenuItem>
))}
</Field>
</FormControl>
</Grid>
<Grid xs={12} md={4} item>
<FormControl
variant="outlined"
margin="dense"
fullWidth
className={classes.formControl}
>
<InputLabel id="whatsapp-selection-label">
{i18n.t("campaigns.dialog.form.whatsapp")}
</InputLabel>
<Field
as={Select}
label={i18n.t("campaigns.dialog.form.whatsapp")}
placeholder={i18n.t("campaigns.dialog.form.whatsapp")}
labelId="whatsapp-selection-label"
id="whatsappId"
name="whatsappId"
error={touched.whatsappId && Boolean(errors.whatsappId)}
disabled={!campaignEditable}
>
<MenuItem value="">Nenhuma</MenuItem>
{whatsapps &&
whatsapps.map((whatsapp) => (
<MenuItem key={whatsapp.id} value={whatsapp.id}>
{whatsapp.name}
</MenuItem>
))}
</Field>
</FormControl>
</Grid>
<Grid xs={12} md={4} item>
<Field
as={TextField}
label={i18n.t("campaigns.dialog.form.scheduledAt")}
name="scheduledAt"
error={touched.scheduledAt && Boolean(errors.scheduledAt)}
helperText={touched.scheduledAt && errors.scheduledAt}
variant="outlined"
margin="dense"
type="datetime-local"
InputLabelProps={{
shrink: true,
}}
fullWidth
className={classes.textField}
disabled={!campaignEditable}
/>
</Grid>
<Grid xs={12} md={4} item>
<FormControl
variant="outlined"
margin="dense"
className={classes.FormControl}
fullWidth
>
<InputLabel id="fileListId-selection-label">{i18n.t("campaigns.dialog.form.fileList")}</InputLabel>
<Field
as={Select}
label={i18n.t("campaigns.dialog.form.fileList")}
name="fileListId"
id="fileListId"
placeholder={i18n.t("campaigns.dialog.form.fileList")}
labelId="fileListId-selection-label"
value={values.fileListId || ""}
>
<MenuItem value={""} >{"Nenhum"}</MenuItem>
{file.map(f => (
<MenuItem key={f.id} value={f.id}>
{f.name}
</MenuItem>
))}
</Field>
</FormControl>
</Grid>
<Grid xs={12} item>
<Tabs
value={messageTab}
indicatorColor="primary"
textColor="primary"
className={classes.tabmsg}
onChange={(e, v) => setMessageTab(v)}
variant="fullWidth"
centered
style={{
borderRadius: 2,
}}
>
<Tab label="Msg. 1" index={0} />
<Tab label="Msg. 2" index={1} />
<Tab label="Msg. 3" index={2} />
<Tab label="Msg. 4" index={3} />
<Tab label="Msg. 5" index={4} />
</Tabs>
<Box style={{ paddingTop: 20, border: "none" }}>
{messageTab === 0 && (
<>{renderMessageField("message1")}</>
)}
{messageTab === 1 && (
<>{renderMessageField("message2")}</>
)}
{messageTab === 2 && (
<>{renderMessageField("message3")}</>
)}
{messageTab === 3 && (
<>{renderMessageField("message4")}</>
)}
{messageTab === 4 && (
<>{renderMessageField("message5")}</>
)}
</Box>
</Grid>
{(campaign.mediaPath || attachment) && (
<Grid xs={12} item>
<Button startIcon={<AttachFileIcon />}>
{attachment != null
? attachment.name
: campaign.mediaName}
</Button>
{campaignEditable && (
<IconButton
onClick={() => setConfirmationOpen(true)}
color="secondary"
>
<DeleteOutlineIcon />
</IconButton>
)}
</Grid>
)}
</Grid>
</DialogContent>
<DialogActions>
{campaign.status === "CANCELADA" && (
<Button
color="primary"
onClick={() => restartCampaign()}
variant="outlined"
>
{i18n.t("campaigns.dialog.buttons.restart")}
</Button>
)}
{campaign.status === "EM_ANDAMENTO" && (
<Button
color="primary"
onClick={() => cancelCampaign()}
variant="outlined"
>
{i18n.t("campaigns.dialog.buttons.cancel")}
</Button>
)}
{!attachment && !campaign.mediaPath && campaignEditable && (
<Button
color="primary"
onClick={() => attachmentFile.current.click()}
disabled={isSubmitting}
variant="outlined"
>
{i18n.t("campaigns.dialog.buttons.attach")}
</Button>
)}
<Button
onClick={handleClose}
color="secondary"
disabled={isSubmitting}
variant="outlined"
>
{i18n.t("campaigns.dialog.buttons.close")}
</Button>
{(campaignEditable || campaign.status === "CANCELADA") && (
<Button
type="submit"
color="primary"
disabled={isSubmitting}
variant="contained"
className={classes.btnWrapper}
>
{campaignId
? `${i18n.t("campaigns.dialog.buttons.edit")}`
: `${i18n.t("campaigns.dialog.buttons.add")}`}
{isSubmitting && (
<CircularProgress
size={24}
className={classes.buttonProgress}
/>
)}
</Button>
)}
</DialogActions>
</Form>
)}
</Formik>
</Dialog>
</div>
);
};
export default CampaignModal;

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,175 @@
import React, { useContext, useState } from "react";
import {
Stepper,
Step,
StepLabel,
Button,
Typography,
CircularProgress,
} from "@material-ui/core";
import { Formik, Form } from "formik";
import AddressForm from "./Forms/AddressForm";
import PaymentForm from "./Forms/PaymentForm";
import ReviewOrder from "./ReviewOrder";
import CheckoutSuccess from "./CheckoutSuccess";
import api from "../../services/api";
import toastError from "../../errors/toastError";
import { toast } from "react-toastify";
import { AuthContext } from "../../context/Auth/AuthContext";
import validationSchema from "./FormModel/validationSchema";
import checkoutFormModel from "./FormModel/checkoutFormModel";
import formInitialValues from "./FormModel/formInitialValues";
import useStyles from "./styles";
import Invoices from "../../pages/Financeiro";
import { i18n } from "../../translate/i18n";
export default function CheckoutPage(props) {
const steps = [i18n.t("checkoutPage.steps.data"), i18n.t("checkoutPage.steps.customize"), i18n.t("checkoutPage.steps.review")];
const { formId, formField } = checkoutFormModel;
const classes = useStyles();
const [activeStep, setActiveStep] = useState(1);
const [datePayment, setDatePayment] = useState(null);
const [invoiceId, setinvoiceId] = useState(props.Invoice.id);
const currentValidationSchema = validationSchema[activeStep];
const isLastStep = activeStep === steps.length - 1;
const { user } = useContext(AuthContext);
function _renderStepContent(step, setFieldValue, setActiveStep, values ) {
switch (step) {
case 0:
return <AddressForm formField={formField} values={values} setFieldValue={setFieldValue} />;
case 1:
return <PaymentForm
formField={formField}
setFieldValue={setFieldValue}
setActiveStep={setActiveStep}
activeStep={step}
invoiceId={invoiceId}
values={values}
/>;
case 2:
return <ReviewOrder />;
default:
return <div>Not Found</div>;
}
}
async function _submitForm(values, actions) {
try {
const plan = JSON.parse(values.plan);
const newValues = {
firstName: values.firstName,
lastName: values.lastName,
address2: values.address2,
city: values.city,
state: values.state,
zipcode: values.zipcode,
country: values.country,
useAddressForPaymentDetails: values.useAddressForPaymentDetails,
nameOnCard: values.nameOnCard,
cardNumber: values.cardNumber,
cvv: values.cvv,
plan: values.plan,
price: plan.price,
users: plan.users,
connections: plan.connections,
invoiceId: invoiceId
}
const { data } = await api.post("/subscription", newValues);
setDatePayment(data)
actions.setSubmitting(false);
setActiveStep(activeStep + 1);
toast.success(i18n.t("checkoutPage.success"));
} catch (err) {
toastError(err);
}
}
function _handleSubmit(values, actions) {
if (isLastStep) {
_submitForm(values, actions);
} else {
setActiveStep(activeStep + 1);
actions.setTouched({});
actions.setSubmitting(false);
}
}
function _handleBack() {
setActiveStep(activeStep - 1);
}
return (
<React.Fragment>
<Typography component="h1" variant="h4" align="center">
{i18n.t("checkoutPage.closeToEnd")}
</Typography>
<Stepper activeStep={activeStep} className={classes.stepper}>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
<React.Fragment>
{activeStep === steps.length ? (
<CheckoutSuccess pix={datePayment} />
) : (
<Formik
initialValues={{
...user,
...formInitialValues
}}
validationSchema={currentValidationSchema}
onSubmit={_handleSubmit}
>
{({ isSubmitting, setFieldValue, values }) => (
<Form id={formId}>
{_renderStepContent(activeStep, setFieldValue, setActiveStep, values)}
<div className={classes.buttons}>
{activeStep !== 1 && (
<Button onClick={_handleBack} className={classes.button}>
{i18n.t("checkoutPage.BACK")}
</Button>
)}
<div className={classes.wrapper}>
{activeStep !== 1 && (
<Button
disabled={isSubmitting}
type="submit"
variant="contained"
color="primary"
className={classes.button}
>
{isLastStep ? i18n.t("checkoutPage.PAY") : i18n.t("checkoutPage.NEXT")}
</Button>
)}
{isSubmitting && (
<CircularProgress
size={24}
className={classes.buttonProgress}
/>
)}
</div>
</div>
</Form>
)}
</Formik>
)}
</React.Fragment>
</React.Fragment>
);
}

View File

@@ -0,0 +1,76 @@
import React, { useState, useEffect, useContext } from 'react';
import { useHistory } from "react-router-dom";
import QRCode from 'react-qr-code';
import { SuccessContent, Total } from './style';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { FaCopy, FaCheckCircle } from 'react-icons/fa';
import { SocketContext } from "../../../context/Socket/SocketContext";
import { useDate } from "../../../hooks/useDate";
import { toast } from "react-toastify";
function CheckoutSuccess(props) {
const { pix } = props;
const [pixString,] = useState(pix.qrcode.qrcode);
const [copied, setCopied] = useState(false);
const history = useHistory();
const { dateToClient } = useDate();
const socketManager = useContext(SocketContext);
useEffect(() => {
const companyId = localStorage.getItem("companyId");
const socket = socketManager.getSocket(companyId);
socket.on(`company-${companyId}-payment`, (data) => {
if (data.action === "CONCLUIDA") {
toast.success(`Sua licença foi renovada até ${dateToClient(data.company.dueDate)}!`);
setTimeout(() => {
history.push("/");
}, 4000);
}
});
}, [history, socketManager]);
const handleCopyQR = () => {
setTimeout(() => {
setCopied(false);
}, 1 * 1000);
setCopied(true);
};
return (
<React.Fragment>
<Total>
<span>TOTAL</span>
<strong>R${pix.valor.original.toLocaleString('pt-br', { minimumFractionDigits: 2 })}</strong>
</Total>
<SuccessContent>
<QRCode value={pixString} />
<CopyToClipboard text={pixString} onCopy={handleCopyQR}>
<button className="copy-button" type="button">
{copied ? (
<>
<span>Copiado</span>
<FaCheckCircle size={18} />
</>
) : (
<>
<span>Copiar código QR</span>
<FaCopy size={18} />
</>
)}
</button>
</CopyToClipboard>
<span>
Para finalizar, basta realizar o pagamento escaneando ou colando o
código Pix acima :)
</span>
</SuccessContent>
</React.Fragment>
);
}
export default CheckoutSuccess;

View File

@@ -0,0 +1,2 @@
import CheckoutSuccess from './CheckoutSuccess';
export default CheckoutSuccess;

View File

@@ -0,0 +1,117 @@
import styled from 'styled-components';
export const Container = styled.div`
footer {
margin-top: 30px;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
@media (max-width: 768px) {
flex-direction: column;
.checkout-buttons {
display: flex;
flex-direction: column-reverse;
width: 100%;
button {
width: 100%;
margin-top: 16px;
margin-left: 0;
}
}
}
button {
margin-left: 16px;
}
}
`;
export const Total = styled.div`
display: flex;
align-items: baseline;
span {
color: #333;
font-weight: bold;
}
strong {
color: #333;
font-size: 28px;
margin-left: 5px;
}
@media (max-width: 768px) {
min-width: 100%;
justify-content: space-between;
}
`;
export const SuccessContent = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
> h2 {
text-align: center;
}
> svg {
margin-top: 16px;
}
> span {
margin-top: 24px;
text-align: center;
}
> p,
strong {
margin-top: 8px;
font-size: 9px;
color: #999;
}
.copy-button {
font-size: 14px;
cursor: pointer;
text-align: center;
user-select: none;
min-height: 56px;
display: inline-flex;
-webkit-box-pack: center;
justify-content: center;
-webkit-box-align: center;
align-items: center;
background-color: #f9f9f9;
color: #000;
-webkit-appearance: none !important;
outline: none;
margin-top: 16px;
width: 256px;
font-weight: 600;
text-transform: uppercase;
border: none;
> span {
margin-right: 8px;
}
}
`;
export const CheckoutWrapper = styled.div`
width: 100%;
margin: 0 auto 0;
max-width: 1110px;
display: flex;
flex-direction: column;
-webkit-box-align: center;
align-items: center;
padding: 50px 95px;
background: #fff;
@media (max-width: 768px) {
padding: 16px 24px;
`;

View File

@@ -0,0 +1,75 @@
import { i18n } from "../../../translate/i18n";
export default {
formId: 'checkoutForm',
formField: {
firstName: {
name: 'firstName',
label: i18n.t('checkoutPage.form.firstName.label'),
requiredErrorMsg: i18n.t('checkoutPage.form.firstName.required')
},
lastName: {
name: 'lastName',
label: i18n.t('checkoutPage.form.lastName.label'),
requiredErrorMsg: i18n.t('checkoutPage.form.lastName.required')
},
address1: {
name: 'address2',
label: i18n.t('checkoutPage.form.address1.label'),
requiredErrorMsg: i18n.t('checkoutPage.form.address1.required')
},
city: {
name: 'city',
label: i18n.t('checkoutPage.form.city.label'),
requiredErrorMsg: i18n.t('checkoutPage.form.city.required')
},
state: {
name: 'state',
label: i18n.t('checkoutPage.form.state.label'),
requiredErrorMsg: i18n.t('checkoutPage.form.state.required')
},
zipcode: {
name: 'zipcode',
label: i18n.t('checkoutPage.form.zipcode.label'),
requiredErrorMsg: i18n.t('checkoutPage.form.zipcode.required'),
invalidErrorMsg: i18n.t('checkoutPage.form.zipcode.invalid')
},
country: {
name: 'country',
label: i18n.t('checkoutPage.form.country.label'),
requiredErrorMsg: i18n.t('checkoutPage.form.country.required')
},
useAddressForPaymentDetails: {
name: 'useAddressForPaymentDetails',
label: i18n.t('checkoutPage.form.useAddressForPaymentDetails.label')
},
invoiceId: {
name: 'invoiceId',
label: i18n.t('checkoutPage.form.invoiceId.label'),
},
nameOnCard: {
name: 'nameOnCard',
label: i18n.t('checkoutPage.form.nameOnCard.label'),
requiredErrorMsg: i18n.t('checkoutPage.form.nameOnCard.required')
},
cardNumber: {
name: 'cardNumber',
label: i18n.t('checkoutPage.form.cardNumber.label'),
requiredErrorMsg: i18n.t('checkoutPage.form.cardNumber.required'),
invalidErrorMsg: i18n.t('checkoutPage.form.cardNumber.invalid')
},
expiryDate: {
name: 'expiryDate',
label: i18n.t('checkoutPage.form.expiryDate.label'),
requiredErrorMsg: i18n.t('checkoutPage.form.expiryDate.required'),
invalidErrorMsg: i18n.t('checkoutPage.form.expiryDate.invalid')
},
cvv: {
name: 'cvv',
label: i18n.t('checkoutPage.form.cvv.label'),
requiredErrorMsg: i18n.t('checkoutPage.form.cvv.required'),
invalidErrorMsg: i18n.t('checkoutPage.form.cvv.invalid')
}
}
};

View File

@@ -0,0 +1,32 @@
import checkoutFormModel from './checkoutFormModel';
const {
formField: {
firstName,
lastName,
address1,
city,
state,
zipcode,
country,
useAddressForPaymentDetails,
nameOnCard,
cardNumber,
invoiceId,
cvv
}
} = checkoutFormModel;
export default {
[firstName.name]: '',
[lastName.name]: '',
[address1.name]: '',
[city.name]: '',
[state.name]: '',
[zipcode.name]: '',
[country.name]: '',
[useAddressForPaymentDetails.name]: false,
[nameOnCard.name]: '',
[cardNumber.name]: '',
[invoiceId.name]: '',
[cvv.name]: ''
};

View File

@@ -0,0 +1,29 @@
import * as Yup from 'yup';
import checkoutFormModel from './checkoutFormModel';
const {
formField: {
firstName,
address1,
city,
zipcode,
country,
}
} = checkoutFormModel;
export default [
Yup.object().shape({
[firstName.name]: Yup.string().required(`${firstName.requiredErrorMsg}`),
[address1.name]: Yup.string().required(`${address1.requiredErrorMsg}`),
[city.name]: Yup.string()
.nullable()
.required(`${city.requiredErrorMsg}`),
[zipcode.name]: Yup.string()
.required(`${zipcode.requiredErrorMsg}`),
[country.name]: Yup.string()
.nullable()
.required(`${country.requiredErrorMsg}`)
}),
];

View File

@@ -0,0 +1,134 @@
import React, { useContext, useEffect, useState } from "react";
import { Grid, Typography } from "@material-ui/core";
import { InputField, SelectField } from "../../FormFields";
import { AuthContext } from "../../../context/Auth/AuthContext";
const countries = [
{
value: "BR",
label: "Brasil",
},
{
value: "usa",
label: "United States",
},
];
export default function AddressForm(props) {
const { user } = useContext(AuthContext);
const [billingName, setBillingName] = useState(user.company.billingName);
const [addressZipCode, setAddressZipCode] = useState(user.company.addressZipCode);
const [addressStreet, setAddressStreet] = useState(user.company.addressStreet);
const [addressState, setAddressState] = useState(user.company.addressState);
const [addressCity, setAddressCity] = useState(user.company.addressCity);
const [addressDistrict, setAddressDistrict] = useState(user.company.addressDistrict);
const {
formField: {
firstName,
address1,
city,
state,
zipcode,
country,
},
setFieldValue
} = props;
useEffect(() => {
setFieldValue("firstName", billingName)
setFieldValue("zipcode", addressZipCode)
setFieldValue("address2", addressStreet)
setFieldValue("state", addressState)
setFieldValue("city", addressCity)
setFieldValue("country", addressDistrict)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<React.Fragment>
<Typography variant="h6" gutterBottom>
Vamos precisar de algumas informações
</Typography>
<Grid container spacing={3}>
<Grid item xs={6} sm={6}>
<InputField name={firstName.name} label={firstName.label} fullWidth
value={billingName}
onChange={(e) => {
setBillingName(e.target.value)
setFieldValue("firstName", e.target.value)
}}
/>
</Grid>
<Grid item xs={6} sm={6}>
<SelectField
name={country.name}
label={country.label}
data={countries}
fullWidth
value={addressDistrict}
onChange={(e) => {
setAddressDistrict(e.target.value)
setFieldValue("country", e.target.value)
}
}
/>
</Grid>
<Grid item xs={4}>
<InputField
name={zipcode.name}
label={zipcode.label}
fullWidth
value={addressZipCode}
onChange={(e) => {
setAddressZipCode(e.target.value)
setFieldValue("zipcode", e.target.value)
}}
/>
</Grid>
<Grid item xs={8}>
<InputField
name={address1.name}
label={address1.label}
fullWidth
value={addressStreet}
onChange={(e) => {
setAddressStreet(e.target.value)
setFieldValue("address2", e.target.value)
}}
/>
</Grid>
<Grid item xs={4}>
<InputField
name={state.name}
label={state.label}
fullWidth
value={addressState}
onChange={(e) => {
setAddressState(e.target.value)
setFieldValue("state", e.target.value)
}}
/>
</Grid>
<Grid item xs={8}>
<InputField
name={city.name}
label={city.label}
fullWidth
value={addressCity}
onChange={(e) => {
setAddressCity(e.target.value)
setFieldValue("city", e.target.value)
}}
/>
</Grid>
</Grid>
</React.Fragment>
);
}

View File

@@ -0,0 +1,238 @@
import React, { useState, useEffect, useReducer } from 'react';
import Button from '@material-ui/core/Button';
import Card from '@material-ui/core/Card';
import CardActions from '@material-ui/core/CardActions';
import CardContent from '@material-ui/core/CardContent';
import CardHeader from '@material-ui/core/CardHeader';
import Grid from '@material-ui/core/Grid';
import StarIcon from '@material-ui/icons/StarBorder';
import Typography from '@material-ui/core/Typography';
import { makeStyles } from '@material-ui/core/styles';
import IconButton from '@material-ui/core/IconButton';
import MinimizeIcon from '@material-ui/icons/Minimize';
import AddIcon from '@material-ui/icons/Add';
import usePlans from "../../../hooks/usePlans";
import useCompanies from "../../../hooks/useCompanies";
import { i18n } from '../../../translate/i18n';
const useStyles = makeStyles((theme) => ({
'@global': {
ul: {
margin: 0,
padding: 0,
listStyle: 'none',
},
},
margin: {
margin: theme.spacing(1),
},
cardHeader: {
backgroundColor:
theme.palette.type === 'light' ? theme.palette.grey[200] : theme.palette.grey[700],
},
cardPricing: {
display: 'flex',
justifyContent: 'center',
alignItems: 'baseline',
marginBottom: theme.spacing(2),
},
footer: {
borderTop: `1px solid ${theme.palette.divider}`,
marginTop: theme.spacing(8),
paddingTop: theme.spacing(3),
paddingBottom: theme.spacing(3),
[theme.breakpoints.up('sm')]: {
paddingTop: theme.spacing(6),
paddingBottom: theme.spacing(6),
},
},
customCard: {
display: "flex",
marginTop: "16px",
alignItems: "center",
flexDirection: "column",
},
custom: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}
}));
export default function Pricing(props) {
const {
setFieldValue,
setActiveStep,
activeStep,
} = props;
const handleChangeAdd = (event, newValue) => {
if (newValue < 3) return
const newPrice = 11.00;
setUsersPlans(newValue);
setCustomValuePlans(customValuePlans + newPrice);
}
const handleChangeMin = (event, newValue) => {
if (newValue < 3) return
const newPrice = 11;
setUsersPlans(newValue);
setCustomValuePlans(customValuePlans - newPrice);
}
const handleChangeConnectionsAdd = (event, newValue) => {
if (newValue < 3) return
const newPrice = 20.00;
setConnectionsPlans(newValue);
setCustomValuePlans(customValuePlans + newPrice);
}
const handleChangeConnectionsMin = (event, newValue) => {
if (newValue < 3) return
const newPrice = 20;
setConnectionsPlans(newValue);
setCustomValuePlans(customValuePlans - newPrice);
}
const { list, finder } = usePlans();
const { find } = useCompanies();
const classes = useStyles();
const [usersPlans, setUsersPlans] = React.useState(3);
const [companiesPlans, setCompaniesPlans] = useState(0);
const [connectionsPlans, setConnectionsPlans] = React.useState(3);
const [storagePlans, setStoragePlans] = React.useState([]);
const [customValuePlans, setCustomValuePlans] = React.useState(49.00);
const [loading, setLoading] = React.useState(false);
const companyId = localStorage.getItem("companyId");
useEffect(() => {
async function fetchData() {
await loadCompanies();
}
fetchData();
}, [])
const loadCompanies = async () => {
setLoading(true);
try {
const companiesList = await find(companyId);
setCompaniesPlans(companiesList.planId);
await loadPlans(companiesList.planId);
} catch (e) {
console.log(e);
// toast.error("Não foi possível carregar a lista de registros");
}
setLoading(false);
};
const loadPlans = async (companiesPlans) => {
setLoading(true);
try {
const plansCompanies = await finder(companiesPlans);
const plans = []
//plansCompanies.forEach((plan) => {
plans.push({
title: plansCompanies.name,
planId: plansCompanies.id,
price: plansCompanies.value,
description: [
`${plansCompanies.users} ${i18n.t("checkoutPage.pricing.users")}`,
`${plansCompanies.connections} ${i18n.t("checkoutPage.pricing.connection")}`,
`${plansCompanies.queues} ${i18n.t("checkoutPage.pricing.queues")}`
],
users: plansCompanies.users,
connections: plansCompanies.connections,
queues: plansCompanies.queues,
buttonText: i18n.t("checkoutPage.pricing.SELECT"),
buttonVariant: 'outlined',
})
// setStoragePlans(data);
//});
setStoragePlans(plans);
} catch (e) {
console.log(e);
// toast.error("Não foi possível carregar a lista de registros");
}
setLoading(false);
};
const tiers = storagePlans
return (
<React.Fragment>
<Grid container spacing={3}>
{tiers.map((tier) => (
// Enterprise card is full width at sm breakpoint
<Grid item key={tier.title} xs={12} sm={tier.title === 'Enterprise' ? 12 : 12} md={12}>
<Card>
<CardHeader
title={tier.title}
subheader={tier.subheader}
titleTypographyProps={{ align: 'center' }}
subheaderTypographyProps={{ align: 'center' }}
action={tier.title === 'Pro' ? <StarIcon /> : null}
className={classes.cardHeader}
/>
<CardContent>
<div className={classes.cardPricing}>
<Typography component="h2" variant="h3" color="textPrimary">
{
<React.Fragment>
R${tier.price.toLocaleString('pt-br', { minimumFractionDigits: 2 })}
</React.Fragment>
}
</Typography>
<Typography variant="h6" color="textSecondary">
/{i18n.t("checkoutPage.pricing.month")}
</Typography>
</div>
<ul>
{tier.description.map((line) => (
<Typography component="li" variant="subtitle1" align="center" key={line}>
{line}
</Typography>
))}
</ul>
</CardContent>
<CardActions>
<Button
fullWidth
variant={tier.buttonVariant}
color="primary"
onClick={() => {
if (tier.custom) {
setFieldValue("plan", JSON.stringify({
users: usersPlans,
connections: connectionsPlans,
price: customValuePlans,
}));
} else {
setFieldValue("plan", JSON.stringify(tier));
}
setActiveStep(activeStep + 1);
}
}
>
{tier.buttonText}
</Button>
</CardActions>
</Card>
</Grid>
))}
</Grid>
</React.Fragment>
);
}

View File

@@ -0,0 +1,61 @@
import React, {useContext} from 'react';
import { Typography, Grid } from '@material-ui/core';
import useStyles from './styles';
import { AuthContext } from "../../../context/Auth/AuthContext";
function PaymentDetails(props) {
const { formValues } = props;
const classes = useStyles();
const { firstName, address2, city, zipcode, state, country, plan } = formValues;
const { user } = useContext(AuthContext);
const newPlan = JSON.parse(plan);
const { price } = newPlan;
return (
<Grid item container direction="column" xs={12} sm={6}>
<Typography variant="h6" gutterBottom className={classes.title}>
Informação de pagamento
</Typography>
<Grid container>
<React.Fragment>
<Grid item xs={6}>
<Typography gutterBottom>Email:</Typography>
</Grid>
<Grid item xs={6}>
<Typography gutterBottom>{user.company.email}</Typography>
</Grid>
</React.Fragment>
<React.Fragment>
<Grid item xs={6}>
<Typography gutterBottom>Nome:</Typography>
</Grid>
<Grid item xs={6}>
<Typography gutterBottom>{firstName}</Typography>
</Grid>
</React.Fragment>
<React.Fragment>
<Grid item xs={6}>
<Typography gutterBottom>Endereço:</Typography>
</Grid>
<Grid item xs={6}>
<Typography gutterBottom>
{address2}, {city} - {state} {zipcode} {country}
</Typography>
</Grid>
</React.Fragment>
<React.Fragment>
<Grid item xs={6}>
<Typography gutterBottom>Total:</Typography>
</Grid>
<Grid item xs={6}>
<Typography gutterBottom>R${price.toLocaleString('pt-br', {minimumFractionDigits: 2})}</Typography>
</Grid>
</React.Fragment>
</Grid>
</Grid>
);
}
export default PaymentDetails;

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { Typography, List, ListItem, ListItemText } from '@material-ui/core';
import useStyles from './styles';
const products = [
{ name: 'Product 1', desc: 'A nice thing', price: '$9.99' },
{ name: 'Product 2', desc: 'Another thing', price: '$3.45' },
{ name: 'Product 3', desc: 'Something else', price: '$6.51' },
{ name: 'Product 4', desc: 'Best thing of all', price: '$14.11' },
{ name: 'Shipping', desc: '', price: 'Free' }
];
function ProductDetails() {
const classes = useStyles();
return (
<List disablePadding>
{products.map(product => (
<ListItem className={classes.listItem} key={product.name}>
<ListItemText primary={product.name} secondary={product.desc} />
<Typography variant="body2">{product.price}</Typography>
</ListItem>
))}
<ListItem className={classes.listItem}>
<ListItemText primary="Total" />
<Typography variant="subtitle1" className={classes.total}>
$34.06
</Typography>
</ListItem>
</List>
);
}
export default ProductDetails;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { useFormikContext } from 'formik';
import { Typography, Grid } from '@material-ui/core';
import ShippingDetails from './ShippingDetails';
import PaymentDetails from './PaymentDetails';
import { i18n } from '../../../translate/i18n';
export default function ReviewOrder() {
const { values: formValues } = useFormikContext();
return (
<React.Fragment>
<Typography variant="h6" gutterBottom>
{i18n.t('checkoutPage.review.title')}
</Typography>
<Grid container spacing={2}>
<ShippingDetails formValues={formValues} />
</Grid>
</React.Fragment>
);
}

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { Typography, Grid } from '@material-ui/core';
import useStyles from './styles';
import { i18n } from '../../../translate/i18n';
function PaymentDetails(props) {
const { formValues } = props;
const classes = useStyles();
const { plan } = formValues;
const newPlan = JSON.parse(plan);
const { users, connections, price } = newPlan;
return (
<Grid item xs={12} sm={12}>
<Typography variant="h6" gutterBottom className={classes.title}>
{i18n.t('checkoutPage.review.details')}
</Typography>
<Typography gutterBottom>{i18n.t('checkoutPage.review.users')}: {users}</Typography>
<Typography gutterBottom>{i18n.t('checkoutPage.review.whatsapp')}: {connections}</Typography>
<Typography gutterBottom>{i18n.t('checkoutPage.review.charges')}</Typography>
<Typography gutterBottom>{i18n.t('checkoutPage.review.total')}: R${price.toLocaleString('pt-br', {minimumFractionDigits: 2})}</Typography>
</Grid>
);
}
export default PaymentDetails;

View File

@@ -0,0 +1,2 @@
import ReviewOrder from './ReviewOrder';
export default ReviewOrder;

View File

@@ -0,0 +1,12 @@
import { makeStyles } from '@material-ui/core/styles';
export default makeStyles(theme => ({
listItem: {
padding: theme.spacing(1, 0)
},
total: {
fontWeight: '700'
},
title: {
marginTop: theme.spacing(2)
}
}));

View File

@@ -0,0 +1,2 @@
import CheckoutPage from './CheckoutPage';
export default CheckoutPage;

View File

@@ -0,0 +1,23 @@
import { makeStyles } from '@material-ui/core/styles';
export default makeStyles(theme => ({
stepper: {
padding: theme.spacing(3, 0, 5)
},
buttons: {
display: 'flex',
justifyContent: 'flex-end'
},
button: {
marginTop: theme.spacing(3),
marginLeft: theme.spacing(1)
},
wrapper: {
margin: theme.spacing(1),
position: 'relative'
},
buttonProgress: {
position: 'absolute',
top: '50%',
left: '50%'
}
}));

View File

@@ -0,0 +1,85 @@
import { Dialog } from "@material-ui/core";
import React, { useState } from "react";
import { BlockPicker } from "react-color";
const ColorPicker = ({ onChange, currentColor, handleClose, open }) => {
const [selectedColor, setSelectedColor] = useState(currentColor);
const handleChange = color => {
setSelectedColor(color.hex);
handleClose();
};
const colors = [
"#B80000",
"#DB3E00",
"#FCCB00",
"#008B02",
"#006B76",
"#1273DE",
"#004DCF",
"#5300EB",
"#EB9694",
"#FAD0C3",
"#FEF3BD",
"#C1E1C5",
"#BEDADC",
"#C4DEF6",
"#BED3F3",
"#D4C4FB",
"#4D4D4D",
"#999999",
"#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",
];
return (
<Dialog
onClose={handleClose}
aria-labelledby="simple-dialog-title"
open={open}
>
<BlockPicker
width={"100%"}
triangle="hide"
color={selectedColor}
colors={colors}
onChange={handleChange}
onChangeComplete={color => onChange(color.hex)}
/>
</Dialog>
);
};
export default ColorPicker;

View File

@@ -0,0 +1,634 @@
import React, { useState, useEffect } from "react";
import {
makeStyles,
Paper,
Grid,
FormControl,
InputLabel,
MenuItem,
TextField,
Table,
TableHead,
TableBody,
TableCell,
TableRow,
IconButton,
Select,
} from "@material-ui/core";
import { Formik, Form, Field } from "formik";
import ButtonWithSpinner from "../ButtonWithSpinner";
import ConfirmationModal from "../ConfirmationModal";
import { Edit as EditIcon } from "@material-ui/icons";
import { toast } from "react-toastify";
import useCompanies from "../../hooks/useCompanies";
import usePlans from "../../hooks/usePlans";
import ModalUsers from "../ModalUsers";
import api from "../../services/api";
import { head, isArray, has } from "lodash";
import { useDate } from "../../hooks/useDate";
import moment from "moment";
import { i18n } from "../../translate/i18n";
const useStyles = makeStyles((theme) => ({
root: {
width: "100%",
},
mainPaper: {
width: "100%",
flex: 1,
padding: theme.spacing(2),
},
fullWidth: {
width: "100%",
},
tableContainer: {
width: "100%",
overflowX: "scroll",
...theme.scrollbarStyles,
},
textfield: {
width: "100%",
},
textRight: {
textAlign: "right",
},
row: {
paddingTop: theme.spacing(2),
paddingBottom: theme.spacing(2),
},
control: {
paddingRight: theme.spacing(1),
paddingLeft: theme.spacing(1),
},
buttonContainer: {
textAlign: "right",
padding: theme.spacing(1),
},
}));
export function CompanyForm(props) {
const { onSubmit, onDelete, onCancel, initialValue, loading } = props;
const classes = useStyles();
const [plans, setPlans] = useState([]);
const [modalUser, setModalUser] = useState(false);
const [firstUser, setFirstUser] = useState({});
const [record, setRecord] = useState({
name: "",
email: "",
phone: "",
planId: "",
status: true,
campaignsEnabled: false,
dueDate: "",
recurrence: "",
...initialValue,
});
const { list: listPlans } = usePlans();
useEffect(() => {
async function fetchData() {
const list = await listPlans();
setPlans(list);
}
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
setRecord((prev) => {
if (moment(initialValue).isValid()) {
initialValue.dueDate = moment(initialValue.dueDate).format(
"YYYY-MM-DD"
);
}
return {
...prev,
...initialValue,
};
});
}, [initialValue]);
const handleSubmit = async (data) => {
if (data.dueDate === "" || moment(data.dueDate).isValid() === false) {
data.dueDate = null;
}
onSubmit(data);
setRecord({ ...initialValue, dueDate: "" });
};
const handleOpenModalUsers = async () => {
try {
const { data } = await api.get("/users/list", {
params: {
companyId: initialValue.id,
},
});
if (isArray(data) && data.length) {
setFirstUser(head(data));
}
setModalUser(true);
} catch (e) {
toast.error(e);
}
};
const handleCloseModalUsers = () => {
setFirstUser({});
setModalUser(false);
};
const incrementDueDate = () => {
const data = { ...record };
if (data.dueDate !== "" && data.dueDate !== null) {
switch (data.recurrence) {
case "MENSAL":
data.dueDate = moment(data.dueDate)
.add(1, "month")
.format("YYYY-MM-DD");
break;
case "BIMESTRAL":
data.dueDate = moment(data.dueDate)
.add(2, "month")
.format("YYYY-MM-DD");
break;
case "TRIMESTRAL":
data.dueDate = moment(data.dueDate)
.add(3, "month")
.format("YYYY-MM-DD");
break;
case "SEMESTRAL":
data.dueDate = moment(data.dueDate)
.add(6, "month")
.format("YYYY-MM-DD");
break;
case "ANUAL":
data.dueDate = moment(data.dueDate)
.add(12, "month")
.format("YYYY-MM-DD");
break;
default:
break;
}
}
setRecord(data);
};
return (
<>
<ModalUsers
userId={firstUser.id}
companyId={initialValue.id}
open={modalUser}
onClose={handleCloseModalUsers}
/>
<Formik
enableReinitialize
className={classes.fullWidth}
initialValues={record}
onSubmit={(values, { resetForm }) =>
setTimeout(() => {
handleSubmit(values);
resetForm();
}, 500)
}
>
{(values, setValues) => (
<Form className={classes.fullWidth}>
<Grid spacing={2} justifyContent="flex-end" container>
<Grid xs={12} sm={6} md={4} item>
<Field
as={TextField}
label={i18n.t("settings.company.form.name")}
name="name"
variant="outlined"
className={classes.fullWidth}
margin="dense"
/>
</Grid>
<Grid xs={12} sm={6} md={2} item>
<Field
as={TextField}
label={i18n.t("settings.company.form.email")}
name="email"
variant="outlined"
className={classes.fullWidth}
margin="dense"
required
/>
</Grid>
<Grid xs={12} sm={6} md={2} item>
<Field
as={TextField}
label={i18n.t("settings.company.form.phone")}
name="phone"
variant="outlined"
className={classes.fullWidth}
margin="dense"
/>
</Grid>
<Grid xs={12} sm={6} md={2} item>
<FormControl margin="dense" variant="outlined" fullWidth>
<InputLabel htmlFor="plan-selection">
{i18n.t("settings.company.form.plan")}
</InputLabel>
<Field
as={Select}
id="plan-selection"
label={i18n.t("settings.company.form.plan")}
labelId="plan-selection-label"
name="planId"
margin="dense"
required
>
{plans.map((plan, key) => (
<MenuItem key={key} value={plan.id}>
{plan.name}
</MenuItem>
))}
</Field>
</FormControl>
</Grid>
<Grid xs={12} sm={6} md={2} item>
<FormControl margin="dense" variant="outlined" fullWidth>
<InputLabel htmlFor="status-selection">
{i18n.t("settings.company.form.status")}
</InputLabel>
<Field
as={Select}
id="status-selection"
label={i18n.t("settings.company.form.status")}
labelId="status-selection-label"
name="status"
margin="dense"
>
<MenuItem value={true}>{i18n.t("settings.company.form.yes")}</MenuItem>
<MenuItem value={false}>{i18n.t("settings.company.form.no")}</MenuItem>
</Field>
</FormControl>
</Grid>
<Grid xs={12} sm={6} md={2} item>
<FormControl margin="dense" variant="outlined" fullWidth>
<InputLabel htmlFor="status-selection">{i18n.t("settings.company.form.campanhas")}</InputLabel>
<Field
as={Select}
id="campaigns-selection"
label={i18n.t("settings.company.form.campanhas")}
labelId="campaigns-selection-label"
name="campaignsEnabled"
margin="dense"
>
<MenuItem value={true}>{i18n.t("settings.company.form.enabled")}</MenuItem>
<MenuItem value={false}>{i18n.t("settings.company.form.disabled")}</MenuItem>
</Field>
</FormControl>
</Grid>
<Grid xs={12} sm={6} md={2} item>
<FormControl variant="outlined" fullWidth>
<Field
as={TextField}
label={i18n.t("settings.company.form.dueDate")}
type="date"
name="dueDate"
InputLabelProps={{
shrink: true,
}}
variant="outlined"
fullWidth
margin="dense"
/>
</FormControl>
</Grid>
<Grid xs={12} sm={6} md={2} item>
<FormControl margin="dense" variant="outlined" fullWidth>
<InputLabel htmlFor="recorrencia-selection">
{i18n.t("settings.company.form.recurrence")}
</InputLabel>
<Field
as={Select}
label={i18n.t("settings.company.form.recurrence")}
labelId="recorrencia-selection-label"
id="recurrence"
name="recurrence"
margin="dense"
>
<MenuItem value="MENSAL">{i18n.t("settings.company.form.monthly")}</MenuItem>
{/*<MenuItem value="BIMESTRAL">Bimestral</MenuItem>*/}
{/*<MenuItem value="TRIMESTRAL">Trimestral</MenuItem>*/}
{/*<MenuItem value="SEMESTRAL">Semestral</MenuItem>*/}
{/*<MenuItem value="ANUAL">Anual</MenuItem>*/}
</Field>
</FormControl>
</Grid>
<Grid xs={12} item>
<Grid justifyContent="flex-end" spacing={1} container>
<Grid xs={4} md={1} item>
<ButtonWithSpinner
className={classes.fullWidth}
style={{ marginTop: 7 }}
loading={loading}
onClick={() => onCancel()}
variant="contained"
>
{i18n.t("settings.company.buttons.clear")}
</ButtonWithSpinner>
</Grid>
{record.id !== undefined ? (
<>
<Grid xs={6} md={1} item>
<ButtonWithSpinner
style={{ marginTop: 7 }}
className={classes.fullWidth}
loading={loading}
onClick={() => onDelete(record)}
variant="contained"
color="secondary"
>
{i18n.t("settings.company.buttons.delete")}
</ButtonWithSpinner>
</Grid>
<Grid xs={6} md={2} item>
<ButtonWithSpinner
style={{ marginTop: 7 }}
className={classes.fullWidth}
loading={loading}
onClick={() => incrementDueDate()}
variant="contained"
color="primary"
>
{i18n.t("settings.company.buttons.expire")}
</ButtonWithSpinner>
</Grid>
<Grid xs={6} md={1} item>
<ButtonWithSpinner
style={{ marginTop: 7 }}
className={classes.fullWidth}
loading={loading}
onClick={() => handleOpenModalUsers()}
variant="contained"
color="primary"
>
{i18n.t("settings.company.buttons.user")}
</ButtonWithSpinner>
</Grid>
</>
) : null}
<Grid xs={6} md={1} item>
<ButtonWithSpinner
className={classes.fullWidth}
style={{ marginTop: 7 }}
loading={loading}
type="submit"
variant="contained"
color="primary"
>
{i18n.t("settings.company.buttons.save")}
</ButtonWithSpinner>
</Grid>
</Grid>
</Grid>
</Grid>
</Form>
)}
</Formik>
</>
);
}
export function CompaniesManagerGrid(props) {
const { records, onSelect } = props;
const classes = useStyles();
const { dateToClient } = useDate();
const renderStatus = (row) => {
return row.status === false ? "Não" : "Sim";
};
const renderPlan = (row) => {
return row.planId !== null ? row.plan.name : "-";
};
const renderCampaignsStatus = (row) => {
if (
has(row, "settings") &&
isArray(row.settings) &&
row.settings.length > 0
) {
const setting = row.settings.find((s) => s.key === "campaignsEnabled");
if (setting) {
return setting.value === "true" ? i18n.t("settings.company.form.enabled") : i18n.t("settings.company.form.disabled");
}
}
return i18n.t("settings.company.form.disabled")
};
const rowStyle = (record) => {
if (moment(record.dueDate).isValid()) {
const now = moment();
const dueDate = moment(record.dueDate);
const diff = dueDate.diff(now, "days");
if (diff === 5) {
return { backgroundColor: "#fffead" };
}
if (diff >= -3 && diff <= 4) {
return { backgroundColor: "#f7cc8f" };
}
if (diff === -4) {
return { backgroundColor: "#fa8c8c" };
}
}
return {};
};
return (
<Paper className={classes.tableContainer}>
<Table
className={classes.fullWidth}
size="small"
aria-label="a dense table"
>
<TableHead>
<TableRow>
<TableCell align="center" style={{ width: "1%" }}>
#
</TableCell>
<TableCell align="left">{i18n.t("settings.company.form.name")}</TableCell>
<TableCell align="left">{i18n.t("settings.company.form.email")}</TableCell>
<TableCell align="left">{i18n.t("settings.company.form.phone")}</TableCell>
<TableCell align="left">{i18n.t("settings.company.form.plan")}</TableCell>
<TableCell align="left">{i18n.t("settings.company.form.campanhas")}</TableCell>
<TableCell align="left">{i18n.t("settings.company.form.status")}</TableCell>
<TableCell align="left">{i18n.t("settings.company.form.createdAt")}</TableCell>
<TableCell align="left">{i18n.t("settings.company.form.expire")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{records.map((row, key) => (
<TableRow style={rowStyle(row)} key={key}>
<TableCell align="center" style={{ width: "1%" }}>
<IconButton onClick={() => onSelect(row)} aria-label="delete">
<EditIcon />
</IconButton>
</TableCell>
<TableCell align="left">{row.name || "-"}</TableCell>
<TableCell align="left">{row.email || "-"}</TableCell>
<TableCell align="left">{row.phone || "-"}</TableCell>
<TableCell align="left">{renderPlan(row)}</TableCell>
<TableCell align="left">{renderCampaignsStatus(row)}</TableCell>
<TableCell align="left">{renderStatus(row)}</TableCell>
<TableCell align="left">{dateToClient(row.createdAt)}</TableCell>
<TableCell align="left">
{dateToClient(row.dueDate)}
<br />
<span>{row.recurrence}</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
);
}
export default function CompaniesManager() {
const classes = useStyles();
const { list, save, update, remove } = useCompanies();
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [loading, setLoading] = useState(false);
const [records, setRecords] = useState([]);
const [record, setRecord] = useState({
name: "",
email: "",
phone: "",
planId: "",
status: true,
campaignsEnabled: false,
dueDate: "",
recurrence: "",
});
useEffect(() => {
loadPlans();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadPlans = async () => {
setLoading(true);
try {
const companyList = await list();
setRecords(companyList);
} catch (e) {
toast.error(i18n.t("settings.company.toasts.errorList"));
}
setLoading(false);
};
const handleSubmit = async (data) => {
setLoading(true);
try {
if (data.id !== 0 && data.id !== undefined) {
await update(data);
} else {
await save(data);
}
await loadPlans();
handleCancel();
toast.success(i18n.t("settings.company.toasts.success"));
} catch (e) {
toast.error(
i18n.t("settings.company.toasts.error")
);
}
setLoading(false);
};
const handleDelete = async () => {
setLoading(true);
try {
await remove(record.id);
await loadPlans();
handleCancel();
toast.success(i18n.t("settings.company.toasts.success"));
} catch (e) {
toast.error(i18n.t("settings.company.toasts.errorOperation"));
}
setLoading(false);
};
const handleOpenDeleteDialog = () => {
setShowConfirmDialog(true);
};
const handleCancel = () => {
setRecord((prev) => ({
...prev,
id: undefined,
name: "",
email: "",
phone: "",
planId: "",
status: true,
campaignsEnabled: false,
dueDate: "",
recurrence: "",
}));
};
const handleSelect = (data) => {
let campaignsEnabled = false;
const setting = data.settings.find(
(s) => s.key.indexOf("campaignsEnabled") > -1
);
if (setting) {
campaignsEnabled =
setting.value === "true" || setting.value === "enabled";
}
setRecord((prev) => ({
...prev,
id: data.id,
name: data.name || "",
phone: data.phone || "",
email: data.email || "",
planId: data.planId || "",
status: data.status === false ? false : true,
campaignsEnabled,
dueDate: data.dueDate || "",
recurrence: data.recurrence || "",
}));
};
return (
<Paper className={classes.mainPaper} elevation={0}>
<Grid spacing={2} container>
<Grid xs={12} item>
<CompanyForm
initialValue={record}
onDelete={handleOpenDeleteDialog}
onSubmit={handleSubmit}
onCancel={handleCancel}
loading={loading}
/>
</Grid>
<Grid xs={12} item>
<CompaniesManagerGrid records={records} onSelect={handleSelect} />
</Grid>
</Grid>
<ConfirmationModal
title={i18n.t("settings.company.confirmModal.title")}
open={showConfirmDialog}
onClose={() => setShowConfirmDialog(false)}
onConfirm={() => handleDelete()}
>
{i18n.t("settings.company.confirmModal.message")}
</ConfirmationModal>
</Paper>
);
}

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,199 @@
import React, { useEffect, 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 CreateIcon from '@material-ui/icons/Create';
import { i18n } from "../../translate/i18n";
import ContactDrawerSkeleton from "../ContactDrawerSkeleton";
import MarkdownWrapper from "../MarkdownWrapper";
import { CardHeader } from "@material-ui/core";
import { ContactForm } from "../ContactForm";
import ContactModal from "../ContactModal";
import { ContactNotes } from "../ContactNotes";
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: theme.palette.contactdrawer, //DARK MODE PLW DESIGN//
alignItems: "center",
padding: theme.spacing(0, 1),
minHeight: "73px",
justifyContent: "flex-start",
},
content: {
display: "flex",
backgroundColor: theme.palette.contactdrawer, //DARK MODE PLW DESIGN//
flexDirection: "column",
padding: "8px 0px 8px 8px",
height: "100%",
overflowY: "scroll",
...theme.scrollbarStyles,
},
contactAvatar: {
margin: 15,
width: 100,
height: 100,
},
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, ticket, loading }) => {
const classes = useStyles();
const [modalOpen, setModalOpen] = useState(false);
const [openForm, setOpenForm] = useState(false);
useEffect(() => {
setOpenForm(false);
}, [open, contact]);
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}>
<CardHeader
onClick={() => {}}
style={{ cursor: "pointer", width: '100%' }}
titleTypographyProps={{ noWrap: true }}
subheaderTypographyProps={{ noWrap: true }}
avatar={<Avatar src={contact.profilePicUrl} alt="contact_image" style={{ width: 60, height: 60 }} />}
title={
<>
<Typography onClick={() => setOpenForm(true)}>
{contact.name}
<CreateIcon style={{fontSize: 16, marginLeft: 5}} />
</Typography>
</>
}
subheader={
<>
<Typography style={{fontSize: 12}}>
<Link href={`tel:${contact.number}`}>{contact.number}</Link>
</Typography>
<Typography style={{fontSize: 12}}>
<Link href={`mailto:${contact.email}`}>{contact.email}</Link>
</Typography>
</>
}
/>
<Button
variant="outlined"
color="primary"
onClick={() => setModalOpen(!openForm)}
style={{fontSize: 12}}
>
{i18n.t("contactDrawer.buttons.edit")}
</Button>
{(contact.id && openForm) && <ContactForm initialContact={contact} onCancel={() => setOpenForm(false)} />}
</Paper>
<Paper square variant="outlined" className={classes.contactDetails}>
<Typography variant="subtitle1" style={{marginBottom: 10}}>
{i18n.t("ticketOptionsMenu.appointmentsModal.title")}
</Typography>
<ContactNotes ticket={ticket} />
</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,50 @@
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";
import { Grid } from "@material-ui/core";
const ContactDrawerSkeleton = ({ classes }) => {
return (
<div className={classes.content}>
<Paper square variant="outlined" className={classes.contactHeader}>
<Grid container>
<Grid item>
<Skeleton
animation="wave"
variant="circle"
width={60}
height={60}
className={classes.contactAvatar}
/>
</Grid>
<Grid item>
<Skeleton animation="wave" height={25} width={90} />
<Skeleton animation="wave" height={25} width={80} />
<Skeleton animation="wave" height={25} width={80} />
</Grid>
</Grid>
</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,187 @@
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 Button from "@material-ui/core/Button";
import TextField from "@material-ui/core/TextField";
import CircularProgress from "@material-ui/core/CircularProgress";
import { i18n } from "../../translate/i18n";
import api from "../../services/api";
import toastError from "../../errors/toastError";
import { Grid } from "@material-ui/core";
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,
},
textCenter: {
backgroundColor: 'red'
}
}));
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"),
});
export function ContactForm ({ initialContact, onSave, onCancel }) {
const classes = useStyles();
const [contact, setContact] = useState(initialContact);
useEffect(() => {
setContact(initialContact);
}, [initialContact]);
const handleSaveContact = async values => {
try {
if (contact.id) {
await api.put(`/contacts/${contact.id}`, values);
} else {
const { data } = await api.post("/contacts", values);
if (onSave) {
onSave(data);
}
}
toast.success(i18n.t("contactModal.success"));
} catch (err) {
toastError(err);
}
};
return (
<Formik
initialValues={contact}
enableReinitialize={true}
validationSchema={ContactSchema}
onSubmit={(values, actions) => {
setTimeout(() => {
handleSaveContact(values);
actions.setSubmitting(false);
}, 400);
}}
>
{({ values, errors, touched, isSubmitting }) => (
<Form>
<Grid container spacing={1}>
{/* <Grid item xs={12}>
<Typography variant="subtitle1" gutterBottom>
{i18n.t("contactModal.form.mainInfo")}
</Typography>
</Grid> */}
<Grid item xs={12}>
<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}
fullWidth
/>
</Grid>
<Grid item xs={12}>
<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"
fullWidth
/>
</Grid>
<Grid item xs={12}>
<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"
/>
</Grid>
<Grid item xs={12} spacing={1}>
<Grid container spacing={1}>
<Grid xs={6} item>
<Button
onClick={onCancel}
color="secondary"
disabled={isSubmitting}
variant="outlined"
fullWidth
>
{i18n.t("contactModal.buttons.cancel")}
</Button>
</Grid>
<Grid classes={classes.textCenter} xs={6} item>
<Button
type="submit"
color="primary"
disabled={isSubmitting}
variant="contained"
className={classes.btnWrapper}
fullWidth
>
{contact.id
? `${i18n.t("contactModal.buttons.okEdit")}`
: `${i18n.t("contactModal.buttons.okAdd")}`}
{isSubmitting && (
<CircularProgress
size={24}
className={classes.buttonProgress}
/>
)}
</Button>
</Grid>
</Grid>
</Grid>
</Grid>
</Form>
)}
</Formik>
)
}

View File

@@ -0,0 +1,181 @@
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 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";
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 ContactListSchema = Yup.object().shape({
name: Yup.string()
.min(2, i18n.t("contactLists.dialog.nameShort"))
.max(50, i18n.t("contactLists.dialog.nameLong"))
.required(i18n.t("contactLists.dialog.nameRequired")),
});
const ContactListModal = ({ open, onClose, contactListId }) => {
const classes = useStyles();
const initialState = {
name: "",
};
const [contactList, setContactList] = useState(initialState);
useEffect(() => {
const fetchContactList = async () => {
if (!contactListId) return;
try {
const { data } = await api.get(`/contact-lists/${contactListId}`);
setContactList((prevState) => {
return { ...prevState, ...data };
});
} catch (err) {
toastError(err);
}
};
fetchContactList();
}, [contactListId, open]);
const handleClose = () => {
onClose();
setContactList(initialState);
};
const handleSaveContactList = async (values) => {
const contactListData = { ...values };
try {
if (contactListId) {
await api.put(`/contact-lists/${contactListId}`, contactListData);
} else {
await api.post("/contact-lists", contactListData);
}
toast.success(i18n.t("contactList.toasts.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">
{contactListId
? `${i18n.t("contactLists.dialog.edit")}`
: `${i18n.t("contactLists.dialog.add")}`}
</DialogTitle>
<Formik
initialValues={contactList}
enableReinitialize={true}
validationSchema={ContactListSchema}
onSubmit={(values, actions) => {
setTimeout(() => {
handleSaveContactList(values);
actions.setSubmitting(false);
}, 400);
}}
>
{({ touched, errors, isSubmitting }) => (
<Form>
<DialogContent dividers>
<div className={classes.multFieldLine}>
<Field
as={TextField}
label={i18n.t("contactLists.dialog.name")}
autoFocus
name="name"
error={touched.name && Boolean(errors.name)}
helperText={touched.name && errors.name}
variant="outlined"
margin="dense"
fullWidth
/>
</div>
</DialogContent>
<DialogActions>
<Button
onClick={handleClose}
color="secondary"
disabled={isSubmitting}
variant="outlined"
>
{i18n.t("contactLists.dialog.cancel")}
</Button>
<Button
type="submit"
color="primary"
disabled={isSubmitting}
variant="contained"
className={classes.btnWrapper}
>
{contactListId
? `${i18n.t("contactLists.dialog.okEdit")}`
: `${i18n.t("contactLists.dialog.okAdd")}`}
{isSubmitting && (
<CircularProgress
size={24}
className={classes.buttonProgress}
/>
)}
</Button>
</DialogActions>
</Form>
)}
</Formik>
</Dialog>
</div>
);
};
export default ContactListModal;

View File

@@ -0,0 +1,242 @@
import React, { useState, useEffect, useRef, useContext } 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 Typography from "@material-ui/core/Typography";
import CircularProgress from "@material-ui/core/CircularProgress";
import { i18n } from "../../translate/i18n";
import api from "../../services/api";
import toastError from "../../errors/toastError";
import { useParams } from "react-router-dom";
import { AuthContext } from "../../context/Auth/AuthContext";
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, i18n.t("contactListItems.dialog.nameShort"))
.max(50, i18n.t("contactListItems.dialog.nameLong"))
.required(i18n.t("contactListItems.dialog.nameRequired")),
number: Yup.string().min(8, i18n.t("contactListItems.dialog.numberShort")).max(50, i18n.t("contactListItems.dialog.numberLong")),
email: Yup.string().email(i18n.t("contactListItems.dialog.emailInvalid")),
});
const ContactListItemModal = ({
open,
onClose,
contactId,
initialValues,
onSave,
}) => {
const classes = useStyles();
const isMounted = useRef(true);
const {
user: { companyId },
} = useContext(AuthContext);
const { contactListId } = useParams();
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(`/contact-list-items/${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(`/contact-list-items/${contactId}`, {
...values,
companyId,
contactListId,
});
handleClose();
} else {
const { data } = await api.post("/contact-list-items", {
...values,
companyId,
contactListId,
});
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={i18n.t("contactModal.form.email")}
fullWidth
margin="dense"
variant="outlined"
/>
</div>
</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 ContactListItemModal;

View File

@@ -0,0 +1,103 @@
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import {
Table,
TableHead,
TableBody,
TableCell,
TableRow,
IconButton,
} from "@material-ui/core";
import {
Edit as EditIcon,
DeleteOutline as DeleteOutlineIcon,
People as PeopleIcon,
} from "@material-ui/icons";
import TableRowSkeleton from "../../components/TableRowSkeleton";
function ContactListsTable(props) {
const {
contactLists,
showLoading,
editContactList,
deleteContactList,
readOnly,
} = props;
const [loading, setLoading] = useState(true);
const [rows, setRows] = useState([]);
useEffect(() => {
if (Array.isArray(contactLists)) {
setRows(contactLists);
}
if (showLoading !== undefined) {
setLoading(showLoading);
}
}, [contactLists, showLoading]);
const handleEdit = (contactList) => {
editContactList(contactList);
};
const handleDelete = (contactList) => {
deleteContactList(contactList);
};
const renderRows = () => {
return rows.map((contactList) => {
return (
<TableRow key={contactList.id}>
<TableCell align="left">{contactList.name}</TableCell>
<TableCell align="center"></TableCell>
{!readOnly ? (
<TableCell align="center">
<IconButton size="small">
<PeopleIcon />
</IconButton>
<IconButton size="small" onClick={() => handleEdit(contactList)}>
<EditIcon />
</IconButton>
<IconButton
size="small"
onClick={() => handleDelete(contactList)}
>
<DeleteOutlineIcon />
</IconButton>
</TableCell>
) : null}
</TableRow>
);
});
};
return (
<Table size="small">
<TableHead>
<TableRow>
<TableCell align="left">Nome</TableCell>
<TableCell align="center">Contatos</TableCell>
{!readOnly ? <TableCell align="center">Ações</TableCell> : null}
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRowSkeleton columns={readOnly ? 2 : 3} />
) : (
renderRows()
)}
</TableBody>
</Table>
);
}
ContactListsTable.propTypes = {
contactLists: PropTypes.array.isRequired,
showLoading: PropTypes.bool,
editContactList: PropTypes.func.isRequired,
deleteContactList: PropTypes.func.isRequired,
};
export default ContactListsTable;

View File

@@ -0,0 +1,300 @@
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";
import InputMask from 'react-input-mask';
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 MaskedTextField = ({ field, form, ...props }) => {
return (
<InputMask {...field} {...props}>
{(inputProps) => <TextField {...inputProps} />}
</InputMask>
);
};
const ContactSchema = Yup.object().shape({
name: Yup.string()
.min(2, i18n.t("contactModal.formErrors.name.short"))
.max(50, i18n.t("contactModal.formErrors.name.long"))
.required(i18n.t("contactModal.formErrors.name.required")),
number: Yup.string().min(8,
i18n.t("contactModal.formErrors.phone.short")).max(50,
i18n.t("contactModal.formErrors.phone.long")),
email: Yup.string().email(i18n.t("contactModal.formErrors.email.invalid")),
});
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) {
console.log(data)
setContact({
...data,
number: data.number,
});
}
} 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 (e) {
toastError(e);
}
};
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}
name="number"
label={i18n.t("contactModal.form.number")}
error={touched.number && Boolean(errors.number)}
helperText={touched.number && errors.number}
placeholder=""
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.whatsapp")} {contact?.whatsapp ? contact?.whatsapp.name : ""}
</Typography>
<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,204 @@
import React, { useState, useEffect } from 'react';
import Button from '@material-ui/core/Button';
import TextField from '@material-ui/core/TextField';
import List from '@material-ui/core/List';
import { makeStyles } from '@material-ui/core/styles';
import * as Yup from "yup";
import { Formik, Form, Field } from "formik";
import ContactNotesDialogListItem from '../ContactNotesDialogListItem';
import ConfirmationModal from '../ConfirmationModal';
import { toast } from "react-toastify";
import { i18n } from "../../translate/i18n";
import ButtonWithSpinner from '../ButtonWithSpinner';
import useTicketNotes from '../../hooks/useTicketNotes';
import { Grid } from '@material-ui/core';
const useStyles = makeStyles((theme) => ({
root: {
'& .MuiTextField-root': {
margin: theme.spacing(1),
width: '350px',
},
},
list: {
width: '100%',
maxWidth: '350px',
maxHeight: '200px',
backgroundColor: theme.palette.background.paper,
overflow: 'auto'
},
inline: {
width: '100%'
}
}));
const NoteSchema = Yup.object().shape({
note: Yup.string()
.min(2, "Too Short!")
.required("Required")
});
export function ContactNotes ({ ticket }) {
const { id: ticketId, contactId } = ticket
const classes = useStyles()
const [newNote, setNewNote] = useState({ note: "" });
const [loading, setLoading] = useState(false)
const [showOnDeleteDialog, setShowOnDeleteDialog] = useState(false)
const [selectedNote, setSelectedNote] = useState({})
const [notes, setNotes] = useState([])
const { saveNote, deleteNote, listNotes } = useTicketNotes()
useEffect(() => {
async function openAndFetchData () {
handleResetState()
await loadNotes()
}
openAndFetchData()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleResetState = () => {
setNewNote({ note: "" })
setLoading(false)
}
const handleChangeComment = (e) => {
setNewNote({ note: e.target.value })
}
const handleSave = async values => {
setLoading(true)
try {
await saveNote({
...values,
ticketId,
contactId
})
await loadNotes()
setNewNote({ note: '' })
toast.success('Observação adicionada com sucesso!')
} catch (e) {
toast.error(e)
}
setLoading(false)
}
const handleOpenDialogDelete = (item) => {
setSelectedNote(item)
setShowOnDeleteDialog(true)
}
const handleDelete = async () => {
setLoading(true)
try {
await deleteNote(selectedNote.id)
await loadNotes()
setSelectedNote({})
toast.success('Observação excluída com sucesso!')
} catch (e) {
toast.error(e)
}
setLoading(false)
}
const loadNotes = async () => {
setLoading(true)
try {
const notes = await listNotes({ ticketId, contactId })
setNotes(notes)
} catch (e) {
toast.error(e)
}
setLoading(false)
}
const renderNoteList = () => {
return notes.map((note) => {
return <ContactNotesDialogListItem
note={note}
key={note.id}
deleteItem={handleOpenDialogDelete}
/>
})
}
return (
<>
<ConfirmationModal
title="Excluir Registro"
open={showOnDeleteDialog}
onClose={setShowOnDeleteDialog}
onConfirm={handleDelete}
>
Deseja realmente excluir este registro?
</ConfirmationModal>
<Formik
initialValues={newNote}
enableReinitialize={true}
validationSchema={NoteSchema}
onSubmit={(values, actions) => {
setTimeout(() => {
handleSave(values);
actions.setSubmitting(false);
}, 400);
}}
>
{({ touched, errors, setErrors }) => (
<Form>
<Grid container spacing={2}>
<Grid xs={12} item>
<Field
as={TextField}
name="note"
rows={3}
label={i18n.t("ticketOptionsMenu.appointmentsModal.textarea")}
placeholder={i18n.t("ticketOptionsMenu.appointmentsModal.placeholder")}
multiline={true}
error={touched.note && Boolean(errors.note)}
helperText={touched.note && errors.note}
variant="outlined"
onChange={handleChangeComment}
fullWidth
/>
</Grid>
{ notes.length > 0 && (
<Grid xs={12} item>
<List className={classes.list}>
{ renderNoteList() }
</List>
</Grid>
) }
<Grid xs={12} item>
<Grid container spacing={2}>
<Grid xs={6} item>
<Button
onClick={() => {
setNewNote("");
setErrors({});
}}
color="primary"
variant="outlined"
fullWidth
>
Cancelar
</Button>
</Grid>
<Grid xs={6} item>
<ButtonWithSpinner loading={loading} color="primary" type="submit" variant="contained" autoFocus fullWidth>
Salvar
</ButtonWithSpinner>
</Grid>
</Grid>
</Grid>
</Grid>
</Form>
)}
</Formik>
</>
);
}

View File

@@ -0,0 +1,206 @@
import React, { useState, useEffect } 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 TextField from '@material-ui/core/TextField';
import List from '@material-ui/core/List';
import { makeStyles } from '@material-ui/core/styles';
import * as Yup from "yup";
import { Formik, Form, Field } from "formik";
import ContactNotesDialogListItem from '../ContactNotesDialogListItem';
import ConfirmationModal from '../ConfirmationModal';
import { toast } from "react-toastify";
import { i18n } from "../../translate/i18n";
import ButtonWithSpinner from '../ButtonWithSpinner';
import useTicketNotes from '../../hooks/useTicketNotes';
const useStyles = makeStyles((theme) => ({
root: {
'& .MuiTextField-root': {
margin: theme.spacing(1),
width: '350px',
},
},
list: {
width: '100%',
maxWidth: '350px',
maxHeight: '200px',
backgroundColor: theme.palette.background.paper,
},
inline: {
width: '100%'
}
}));
const NoteSchema = Yup.object().shape({
note: Yup.string()
.min(2, "Too Short!")
.required("Required")
});
export default function ContactNotesDialog ({ modalOpen, onClose, ticket }) {
const { id: ticketId, contactId } = ticket
const classes = useStyles()
const [open, setOpen] = useState(false);
const [newNote, setNewNote] = useState({ note: "" });
const [loading, setLoading] = useState(false)
const [showOnDeleteDialog, setShowOnDeleteDialog] = useState(false)
const [selectedNote, setSelectedNote] = useState({})
const [notes, setNotes] = useState([])
const { saveNote, deleteNote, listNotes } = useTicketNotes()
useEffect(() => {
async function openAndFetchData () {
if (modalOpen) {
setOpen(true)
handleResetState()
await loadNotes()
}
}
openAndFetchData()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modalOpen])
const handleResetState = () => {
setNewNote({ note: "" })
setLoading(false)
}
const handleChangeComment = (e) => {
setNewNote({ note: e.target.value })
}
const handleClose = () => {
setOpen(false);
onClose()
};
const handleSave = async values => {
setLoading(true)
try {
await saveNote({
...values,
ticketId,
contactId
})
await loadNotes()
setNewNote({ note: '' })
toast.success('Observação adicionada com sucesso!')
} catch (e) {
toast.error(e)
}
setLoading(false)
}
const handleOpenDialogDelete = (item) => {
setSelectedNote(item)
setShowOnDeleteDialog(true)
}
const handleDelete = async () => {
setLoading(true)
try {
await deleteNote(selectedNote.id)
await loadNotes()
setSelectedNote({})
toast.success('Observação excluída com sucesso!')
} catch (e) {
toast.error(e)
}
setLoading(false)
}
const loadNotes = async () => {
setLoading(true)
try {
const notes = await listNotes({ ticketId, contactId })
setNotes(notes)
} catch (e) {
toast.error(e)
}
setLoading(false)
}
const renderNoteList = () => {
return notes.map((note) => {
return <ContactNotesDialogListItem
note={note}
key={note.id}
deleteItem={handleOpenDialogDelete}
/>
})
}
return (
<>
<ConfirmationModal
title="Excluir Registro"
open={showOnDeleteDialog}
onClose={setShowOnDeleteDialog}
onConfirm={handleDelete}
>
Deseja realmente excluir este registro?
</ConfirmationModal>
<Dialog
open={open}
onClose={handleClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">
{ i18n.t("ticketOptionsMenu.appointmentsModal.title") }
</DialogTitle>
<Formik
initialValues={newNote}
enableReinitialize={true}
validationSchema={NoteSchema}
onSubmit={(values, actions) => {
setTimeout(() => {
handleSave(values);
actions.setSubmitting(false);
}, 400);
}}
>
{({ touched, errors }) => (
<Form>
<DialogContent className={classes.root} dividers>
<Field
as={TextField}
name="note"
rows={3}
label={i18n.t("ticketOptionsMenu.appointmentsModal.textarea")}
placeholder={i18n.t("ticketOptionsMenu.appointmentsModal.placeholder")}
multiline={true}
error={touched.note && Boolean(errors.note)}
helperText={touched.note && errors.note}
variant="outlined"
onChange={handleChangeComment}
/>
<List className={classes.list}>
{ renderNoteList() }
</List>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="primary">
Fechar
</Button>
<ButtonWithSpinner loading={loading} color="primary" type="submit" variant="contained" autoFocus>
Salvar
</ButtonWithSpinner>
</DialogActions>
</Form>
)}
</Formik>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,64 @@
import React from 'react';
import PropTypes from 'prop-types';
import IconButton from '@material-ui/core/IconButton';
import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import ListItemAvatar from '@material-ui/core/ListItemAvatar';
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction';
import Avatar from '@material-ui/core/Avatar';
import Typography from '@material-ui/core/Typography';
import { makeStyles } from '@material-ui/core/styles';
import DeleteIcon from '@material-ui/icons/Delete';
import moment from 'moment';
const useStyles = makeStyles((theme) => ({
inline: {
width: '100%'
}
}));
export default function ContactNotesDialogListItem (props) {
const { note, deleteItem } = props;
const classes = useStyles();
const handleDelete = (item) => {
deleteItem(item);
}
return (
<ListItem alignItems="flex-start">
<ListItemAvatar>
<Avatar alt={note.user.name} src="/static/images/avatar/1.jpg" />
</ListItemAvatar>
<ListItemText
primary={
<>
<Typography
component="span"
variant="body2"
className={classes.inline}
color="textPrimary"
>
{note.note}
</Typography>
</>
}
secondary={
<>
{note.user.name}, {moment(note.createdAt).format('DD/MM/YY HH:mm')}
</>
}
/>
<ListItemSecondaryAction>
<IconButton onClick={() => handleDelete(note)} edge="end" aria-label="delete">
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
)
}
ContactNotesDialogListItem.propTypes = {
note: PropTypes.object.isRequired,
deleteItem: PropTypes.func.isRequired
}

View File

@@ -0,0 +1,26 @@
import { makeStyles } from "@material-ui/styles";
import React from "react";
const useStyles = makeStyles(theme => ({
tag: {
padding: "1px 5px",
borderRadius: "3px",
fontSize: "0.8em",
fontWeight: "bold",
color: "#FFF",
marginRight: "5px",
whiteSpace: "nowrap"
}
}));
const ContactTag = ({ tag }) => {
const classes = useStyles();
return (
<div className={classes.tag} style={{ backgroundColor: tag.color, marginTop: "2px" }}>
{tag.name.toUpperCase()}
</div>
)
}
export default ContactTag;

View File

@@ -0,0 +1,50 @@
import React from 'react'
import PropTypes from 'prop-types'
import MaskedInput from 'react-text-mask'
import createNumberMask from 'text-mask-addons/dist/createNumberMask'
const defaultMaskOptions = {
prefix: 'R$',
suffix: '',
includeThousandsSeparator: true,
thousandsSeparatorSymbol: '.',
allowDecimal: true,
decimalSymbol: ',',
decimalLimit: 2, // how many digits allowed after the decimal
integerLimit: 7, // limit length of integer numbers
allowNegative: false,
allowLeadingZeroes: false,
}
const CurrencyInput = ({ maskOptions, ...inputProps }) => {
const currencyMask = createNumberMask({
...defaultMaskOptions,
...maskOptions,
})
return <MaskedInput mask={currencyMask} {...inputProps} />
}
CurrencyInput.defaultProps = {
inputMode: 'numeric',
maskOptions: {},
}
CurrencyInput.propTypes = {
inputmode: PropTypes.string,
maskOptions: PropTypes.shape({
prefix: PropTypes.string,
suffix: PropTypes.string,
includeThousandsSeparator: PropTypes.bool,
thousandsSeparatorSymbol: PropTypes.string,
allowDecimal: PropTypes.bool,
decimalSymbol: PropTypes.string,
decimalLimit: PropTypes.string,
requireDecimal: PropTypes.bool,
allowNegative: PropTypes.bool,
allowLeadingZeroes: PropTypes.bool,
integerLimit: PropTypes.number,
}),
}
export default CurrencyInput

View File

@@ -0,0 +1,70 @@
import React, { useState } from "react";
import { makeStyles } from "@material-ui/core/styles";
import { CssBaseline, IconButton } from "@material-ui/core";
import Brightness4Icon from "@material-ui/icons/Brightness4";
import Brightness7Icon from "@material-ui/icons/Brightness7";
const useStyles = makeStyles((theme) => ({
icons: {
color: "#fff",
},
switch: {
color: "#fff",
},
visible: {
display: "none",
},
btnHeader: {
color: "#fff",
},
}));
const DarkMode = (props) => {
const classes = useStyles();
const [theme, setTheme] = useState("light");
const themeToggle = () => {
theme === "light" ? setTheme("dark") : setTheme("light");
};
const handleClick = () => {
props.themeToggle();
themeToggle();
};
return (
<>
{theme === "light" ? (
<>
<CssBaseline />
<IconButton
className={classes.icons}
onClick={handleClick}
// ref={anchorEl}
aria-label="Dark Mode"
color="inherit"
>
<Brightness4Icon />
</IconButton>
</>
) : (
<>
<CssBaseline />
<IconButton
className={classes.icons}
onClick={handleClick}
// ref={anchorEl}
aria-label="Dark Mode"
color="inherit"
>
<Brightness7Icon />
</IconButton>
</>
)}
</>
);
};
export default DarkMode;

View File

@@ -0,0 +1,53 @@
import React from "react";
import { Avatar, Card, CardHeader, Typography } from "@material-ui/core";
import Skeleton from "@material-ui/lab/Skeleton";
import { makeStyles } from "@material-ui/core/styles";
import { grey } from '@material-ui/core/colors';
const useStyles = makeStyles(theme => ({
cardAvatar: {
fontSize: '55px',
color: grey[500],
backgroundColor: '#ffffff',
width: theme.spacing(7),
height: theme.spacing(7)
},
cardTitle: {
fontSize: '18px',
color: theme.palette.text.primary
},
cardSubtitle: {
color: grey[600],
fontSize: '14px'
}
}));
export default function CardCounter(props) {
const { icon, title, value, loading } = props
const classes = useStyles();
return ( !loading ?
<Card>
<CardHeader
avatar={
<Avatar className={classes.cardAvatar}>
{icon}
</Avatar>
}
title={
<Typography variant="h6" component="h2" className={classes.cardTitle}>
{ title }
</Typography>
}
subheader={
<Typography variant="subtitle1" component="p" className={classes.cardSubtitle}>
{ value }
</Typography>
}
/>
</Card>
: <Skeleton variant="rect" height={80} />
)
}

View File

@@ -0,0 +1,89 @@
import React from "react";
import Paper from "@material-ui/core/Paper";
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableContainer from '@material-ui/core/TableContainer';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import Skeleton from "@material-ui/lab/Skeleton";
import { makeStyles } from "@material-ui/core/styles";
import { green, red } from '@material-ui/core/colors';
import CheckCircleIcon from '@material-ui/icons/CheckCircle';
import ErrorIcon from '@material-ui/icons/Error';
import moment from 'moment';
import Rating from '@material-ui/lab/Rating';
import { i18n } from "../../translate/i18n";
const useStyles = makeStyles(theme => ({
on: {
color: green[600],
fontSize: '20px'
},
off: {
color: red[600],
fontSize: '20px'
},
pointer: {
cursor: "pointer"
}
}));
export function RatingBox ({ rating }) {
const ratingTrunc = rating === null ? 0 : Math.trunc(rating);
return <Rating
defaultValue={ratingTrunc}
max={3}
readOnly
/>
}
export default function TableAttendantsStatus(props) {
const { loading, attendants } = props
const classes = useStyles();
function renderList () {
return attendants.map((a, k) => (
<TableRow key={k}>
<TableCell>{a.name}</TableCell>
<TableCell align="center" title={i18n.t("dashboard.onlineTable.ratingLabel")} className={classes.pointer}>
<RatingBox rating={a.rating} />
</TableCell>
<TableCell align="center">{formatTime(a.avgSupportTime, 2)}</TableCell>
<TableCell align="center">
{ a.online ?
<CheckCircleIcon className={classes.on} />
: <ErrorIcon className={classes.off} />
}
</TableCell>
</TableRow>
))
}
function formatTime(minutes){
return moment().startOf('day').add(minutes, 'minutes').format('HH[h] mm[m]');
}
return ( !loading ?
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>{i18n.t("dashboard.onlineTable.name")}</TableCell>
<TableCell align="center">{i18n.t("dashboard.onlineTable.ratings")}</TableCell>
<TableCell align="center">{i18n.t("dashboard.onlineTable.avgSupportTime")}</TableCell>
<TableCell align="center">{i18n.t("dashboard.onlineTable.status")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{ renderList() }
</TableBody>
</Table>
</TableContainer>
: <Skeleton variant="rect" height={150} />
)
}

View File

@@ -0,0 +1,34 @@
import React, { useState, useEffect } from 'react';
import CoreDialog from '@material-ui/core/Dialog';
import DialogTitle from '@material-ui/core/DialogTitle';
function Dialog ({ title, modalOpen, onClose, children }) {
const [open, setOpen] = useState(false);
useEffect(() => {
setOpen(modalOpen)
}, [modalOpen])
const handleClose = () => {
setOpen(false);
onClose()
};
return (
<>
<CoreDialog
open={open}
onClose={handleClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">
{title}
</DialogTitle>
{children}
</CoreDialog>
</>
);
}
export default Dialog;

View File

@@ -0,0 +1,350 @@
import React, { useState, useEffect, useContext } from "react";
import * as Yup from "yup";
import {
Formik,
Form,
Field,
FieldArray
} from "formik";
import { toast } from "react-toastify";
import {
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
Grid,
makeStyles,
TextField
} from "@material-ui/core";
import IconButton from "@material-ui/core/IconButton";
import Typography from "@material-ui/core/Typography";
import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline";
import AttachFileIcon from "@material-ui/icons/AttachFile";
import { green } from "@material-ui/core/colors";
import { i18n } from "../../translate/i18n";
import api from "../../services/api";
import toastError from "../../errors/toastError";
import { AuthContext } from "../../context/Auth/AuthContext";
const useStyles = makeStyles(theme => ({
root: {
display: "flex",
flexWrap: "wrap",
gap: 4
},
multFieldLine: {
display: "flex",
"& > *:not(:last-child)": {
marginRight: theme.spacing(1),
},
},
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,
},
formControl: {
margin: theme.spacing(1),
minWidth: 2000,
},
colorAdorment: {
width: 20,
height: 20,
},
}));
const FileListSchema = Yup.object().shape({
name: Yup.string()
.min(3, i18n.t("fileModal.formErrors.name.short"))
.required(i18n.t("fileModal.formErrors.name.required")),
message: Yup.string()
.required(i18n.t("fileModal.formErrors.message.required"))
});
const FilesModal = ({ open, onClose, fileListId, reload }) => {
const classes = useStyles();
const { user } = useContext(AuthContext);
const [ files, setFiles ] = useState([]);
const [selectedFileNames, setSelectedFileNames] = useState([]);
const initialState = {
name: "",
message: "",
options: [{ name: "", path:"", mediaType:"" }],
};
const [fileList, setFileList] = useState(initialState);
useEffect(() => {
try {
(async () => {
if (!fileListId) return;
const { data } = await api.get(`/files/${fileListId}`);
setFileList(data);
})()
} catch (err) {
toastError(err);
}
}, [fileListId, open]);
const handleClose = () => {
setFileList(initialState);
setFiles([]);
onClose();
};
const handleSaveFileList = async (values) => {
const uploadFiles = async (options, filesOptions, id) => {
const formData = new FormData();
formData.append("fileId", id);
formData.append("typeArch", "fileList")
filesOptions.forEach((fileOption, index) => {
if (fileOption.file) {
formData.append("files", fileOption.file);
formData.append("mediaType", fileOption.file.type)
formData.append("name", options[index].name);
formData.append("id", options[index].id);
}
});
try {
const { data } = await api.post(`/files/uploadList/${id}`, formData);
setFiles([]);
return data;
} catch (err) {
toastError(err);
}
return null;
}
const fileData = { ...values, userId: user.id };
try {
if (fileListId) {
const { data } = await api.put(`/files/${fileListId}`, fileData)
if (data.options.length > 0)
uploadFiles(data.options, values.options, fileListId)
} else {
const { data } = await api.post("/files", fileData);
if (data.options.length > 0)
uploadFiles(data.options, values.options, data.id)
}
toast.success(i18n.t("fileModal.success"));
if (typeof reload == 'function') {
reload();
}
} catch (err) {
toastError(err);
}
handleClose();
};
return (
<div className={classes.root}>
<Dialog
open={open}
onClose={handleClose}
maxWidth="md"
fullWidth
scroll="paper">
<DialogTitle id="form-dialog-title">
{(fileListId ? `${i18n.t("fileModal.title.edit")}` : `${i18n.t("fileModal.title.add")}`)}
</DialogTitle>
<Formik
initialValues={fileList}
enableReinitialize={true}
validationSchema={FileListSchema}
onSubmit={(values, actions) => {
setTimeout(() => {
handleSaveFileList(values);
actions.setSubmitting(false);
}, 400);
}}
>
{({ touched, errors, isSubmitting, values }) => (
<Form>
<DialogContent dividers>
<div className={classes.multFieldLine}>
<Field
as={TextField}
label={i18n.t("fileModal.form.name")}
name="name"
error={touched.name && Boolean(errors.name)}
helperText={touched.name && errors.name}
variant="outlined"
margin="dense"
fullWidth
/>
</div>
<br />
<div className={classes.multFieldLine}>
<Field
as={TextField}
label={i18n.t("fileModal.form.message")}
type="message"
multiline
minRows={5}
fullWidth
name="message"
error={
touched.message && Boolean(errors.message)
}
helperText={
touched.message && errors.message
}
variant="outlined"
margin="dense"
/>
</div>
<Typography
style={{ marginBottom: 8, marginTop: 12 }}
variant="subtitle1"
>
{i18n.t("fileModal.form.fileOptions")}
</Typography>
<FieldArray name="options">
{({ push, remove }) => (
<>
{values.options &&
values.options.length > 0 &&
values.options.map((info, index) => (
<div
className={classes.extraAttr}
key={`${index}-info`}
>
<Grid container spacing={0}>
<Grid xs={6} md={10} item>
<Field
as={TextField}
label={i18n.t("fileModal.form.extraName")}
name={`options[${index}].name`}
variant="outlined"
margin="dense"
multiline
fullWidth
minRows={2}
className={classes.textField}
/>
</Grid>
<Grid xs={2} md={2} item style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
<input
type="file"
onChange={(e) => {
const selectedFile = e.target.files[0];
const updatedOptions = [...values.options];
updatedOptions[index].file = selectedFile;
setFiles('options', updatedOptions);
// Atualize a lista selectedFileNames para o campo específico
const updatedFileNames = [...selectedFileNames];
updatedFileNames[index] = selectedFile ? selectedFile.name : '';
setSelectedFileNames(updatedFileNames);
}}
style={{ display: 'none' }}
name={`options[${index}].file`}
id={`file-upload-${index}`}
/>
<label htmlFor={`file-upload-${index}`}>
<IconButton component="span">
<AttachFileIcon />
</IconButton>
</label>
<IconButton
size="small"
onClick={() => remove(index)}
>
<DeleteOutlineIcon />
</IconButton>
</Grid>
<Grid xs={12} md={12} item>
{info.path? info.path : selectedFileNames[index]}
</Grid>
</Grid>
</div>
))}
<div className={classes.extraAttr}>
<Button
style={{ flex: 1, marginTop: 8 }}
variant="outlined"
color="primary"
onClick={() => {push({ name: "", path: ""});
setSelectedFileNames([...selectedFileNames, ""]);
}}
>
{`+ ${i18n.t("fileModal.buttons.fileOptions")}`}
</Button>
</div>
</>
)}
</FieldArray>
</DialogContent>
<DialogActions>
<Button
onClick={handleClose}
color="secondary"
disabled={isSubmitting}
variant="outlined"
>
{i18n.t("fileModal.buttons.cancel")}
</Button>
<Button
type="submit"
color="primary"
disabled={isSubmitting}
variant="contained"
className={classes.btnWrapper}
>
{fileListId
? `${i18n.t("fileModal.buttons.okEdit")}`
: `${i18n.t("fileModal.buttons.okAdd")}`}
{isSubmitting && (
<CircularProgress
size={24}
className={classes.buttonProgress}
/>
)}
</Button>
</DialogActions>
</Form>
)}
</Formik>
</Dialog>
</div>
);
};
export default FilesModal;

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { at } from 'lodash';
import { useField } from 'formik';
import {
Checkbox,
FormControl,
FormControlLabel,
FormHelperText
} from '@material-ui/core';
export default function CheckboxField(props) {
const { label, ...rest } = props;
const [field, meta, helper] = useField(props);
const { setValue } = helper;
function _renderHelperText() {
const [touched, error] = at(meta, 'touched', 'error');
if (touched && error) {
return <FormHelperText>{error}</FormHelperText>;
}
}
function _onChange(e) {
setValue(e.target.checked);
}
return (
<FormControl {...rest}>
<FormControlLabel
value={field.checked}
checked={field.checked}
control={<Checkbox {...field} onChange={_onChange} />}
label={label}
/>
{_renderHelperText()}
</FormControl>
);
}

View File

@@ -0,0 +1,54 @@
import React, { useState, useEffect } from 'react';
import { useField } from 'formik';
import Grid from '@material-ui/core/Grid';
import {
MuiPickersUtilsProvider,
KeyboardDatePicker
} from '@material-ui/pickers';
import DateFnsUtils from '@date-io/date-fns';
export default function DatePickerField(props) {
const [field, meta, helper] = useField(props);
const { touched, error } = meta;
const { setValue } = helper;
const isError = touched && error && true;
const { value } = field;
const [selectedDate, setSelectedDate] = useState(null);
useEffect(() => {
if (value) {
const date = new Date(value);
setSelectedDate(date);
}
}, [value]);
function _onChange(date) {
if (date) {
setSelectedDate(date);
try {
const ISODateString = date.toISOString();
setValue(ISODateString);
} catch (error) {
setValue(date);
}
} else {
setValue(date);
}
}
return (
<Grid container>
<MuiPickersUtilsProvider utils={DateFnsUtils}>
<KeyboardDatePicker
{...field}
{...props}
value={selectedDate}
onChange={_onChange}
error={isError}
invalidDateMessage={isError && error}
helperText={isError && error}
/>
</MuiPickersUtilsProvider>
</Grid>
);
}

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { at } from 'lodash';
import { useField } from 'formik';
import { TextField } from '@material-ui/core';
export default function InputField(props) {
const { errorText, ...rest } = props;
const [field, meta] = useField(props);
function _renderHelperText() {
const [touched, error] = at(meta, 'touched', 'error');
if (touched && error) {
return error;
}
}
return (
<TextField
type="text"
error={meta.touched && meta.error && true}
helperText={_renderHelperText()}
{...field}
{...rest}
/>
);
}

View File

@@ -0,0 +1,48 @@
import React from 'react';
import PropTypes from 'prop-types';
import { at } from 'lodash';
import { useField } from 'formik';
import {
InputLabel,
FormControl,
Select,
MenuItem,
FormHelperText
} from '@material-ui/core';
function SelectField(props) {
const { label, data, ...rest } = props;
const [field, meta] = useField(props);
const { value: selectedValue } = field;
const [touched, error] = at(meta, 'touched', 'error');
const isError = touched && error && true;
function _renderHelperText() {
if (isError) {
return <FormHelperText>{error}</FormHelperText>;
}
}
return (
<FormControl {...rest} error={isError}>
<InputLabel>{label}</InputLabel>
<Select {...field} value={selectedValue ? selectedValue : ''}>
{data.map((item, index) => (
<MenuItem key={index} value={item.value}>
{item.label}
</MenuItem>
))}
</Select>
{_renderHelperText()}
</FormControl>
);
}
SelectField.defaultProps = {
data: []
};
SelectField.propTypes = {
data: PropTypes.array.isRequired
};
export default SelectField;

View File

@@ -0,0 +1,5 @@
import InputField from './InputField';
import CheckboxField from './CheckboxField';
import SelectField from './SelectField';
import DatePickerField from './DatePickerField';
export { InputField, CheckboxField, SelectField, DatePickerField };

View File

@@ -0,0 +1,291 @@
import React, { useState, useEffect } from "react";
import {
makeStyles,
Paper,
Grid,
TextField,
Table,
TableHead,
TableBody,
TableCell,
TableRow,
IconButton
} from "@material-ui/core";
import { Formik, Form, Field } from 'formik';
import ButtonWithSpinner from "../ButtonWithSpinner";
import ConfirmationModal from "../ConfirmationModal";
import { Edit as EditIcon } from "@material-ui/icons";
import { toast } from "react-toastify";
import useHelps from "../../hooks/useHelps";
import { i18n } from "../../translate/i18n";
const useStyles = makeStyles(theme => ({
root: {
width: '100%'
},
mainPaper: {
width: '100%',
flex: 1,
padding: theme.spacing(2)
},
fullWidth: {
width: '100%'
},
tableContainer: {
width: '100%',
overflowX: "scroll",
...theme.scrollbarStyles
},
textfield: {
width: '100%'
},
textRight: {
textAlign: 'right'
},
row: {
paddingTop: theme.spacing(2),
paddingBottom: theme.spacing(2)
},
control: {
paddingRight: theme.spacing(1),
paddingLeft: theme.spacing(1)
},
buttonContainer: {
textAlign: 'right',
padding: theme.spacing(1)
}
}));
export function HelpManagerForm (props) {
const { onSubmit, onDelete, onCancel, initialValue, loading } = props;
const classes = useStyles()
const [record, setRecord] = useState(initialValue);
useEffect(() => {
setRecord(initialValue)
}, [initialValue])
const handleSubmit = async(data) => {
onSubmit(data)
}
return (
<Formik
enableReinitialize
className={classes.fullWidth}
initialValues={record}
onSubmit={(values, { resetForm }) =>
setTimeout(() => {
handleSubmit(values)
resetForm()
}, 500)
}
>
{(values) => (
<Form className={classes.fullWidth}>
<Grid spacing={2} justifyContent="flex-end" container>
<Grid xs={12} sm={6} md={3} item>
<Field
as={TextField}
label="Título"
name="title"
variant="outlined"
className={classes.fullWidth}
margin="dense"
/>
</Grid>
<Grid xs={12} sm={6} md={3} item>
<Field
as={TextField}
label="Código do Vídeo"
name="video"
variant="outlined"
className={classes.fullWidth}
margin="dense"
/>
</Grid>
<Grid xs={12} sm={12} md={6} item>
<Field
as={TextField}
label="Descrição"
name="description"
variant="outlined"
className={classes.fullWidth}
margin="dense"
/>
</Grid>
<Grid sm={3} md={1} item>
<ButtonWithSpinner className={classes.fullWidth} loading={loading} onClick={() => onCancel()} variant="contained">
{i18n.t('settings.helps.buttons.clean')}
</ButtonWithSpinner>
</Grid>
{ record.id !== undefined ? (
<Grid sm={3} md={1} item>
<ButtonWithSpinner className={classes.fullWidth} loading={loading} onClick={() => onDelete(record)} variant="contained" color="secondary">
{i18n.t('settings.helps.buttons.delete')}
</ButtonWithSpinner>
</Grid>
) : null}
<Grid sm={3} md={1} item>
<ButtonWithSpinner className={classes.fullWidth} loading={loading} type="submit" variant="contained" color="primary">
{i18n.t('settings.helps.buttons.save')}
</ButtonWithSpinner>
</Grid>
</Grid>
</Form>
)}
</Formik>
)
}
export function HelpsManagerGrid (props) {
const { records, onSelect } = props
const classes = useStyles()
return (
<Paper className={classes.tableContainer}>
<Table className={classes.fullWidth} size="small" aria-label="a dense table">
<TableHead>
<TableRow>
<TableCell align="center" style={{width: '1%'}}>#</TableCell>
<TableCell align="left">{i18n.t("settings.helps.grid.title")}</TableCell>
<TableCell align="left">{i18n.t("settings.helps.grid.description")}</TableCell>
<TableCell align="left">{i18n.t("settings.helps.grid.video")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{records.map((row) => (
<TableRow key={row.id}>
<TableCell align="center" style={{width: '1%'}}>
<IconButton onClick={() => onSelect(row)} aria-label="delete">
<EditIcon />
</IconButton>
</TableCell>
<TableCell align="left">{row.title || '-'}</TableCell>
<TableCell align="left">{row.description || '-'}</TableCell>
<TableCell align="left">{row.video || '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
)
}
export default function HelpsManager () {
const classes = useStyles()
const { list, save, update, remove } = useHelps()
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
const [loading, setLoading] = useState(false)
const [records, setRecords] = useState([])
const [record, setRecord] = useState({
title: '',
description: '',
video: ''
})
useEffect(() => {
async function fetchData () {
await loadHelps()
}
fetchData()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const loadHelps = async () => {
setLoading(true)
try {
const helpList = await list()
setRecords(helpList)
} catch (e) {
toast.error(i18n.t('settings.helps.toasts.errorList'))
}
setLoading(false)
}
const handleSubmit = async (data) => {
setLoading(true)
try {
if (data.id !== undefined) {
await update(data)
} else {
await save(data)
}
await loadHelps()
handleCancel()
toast.success(i18n.t('settings.helps.toasts.success'))
} catch (e) {
toast.error(i18n.t('settings.helps.toasts.error'))
}
setLoading(false)
}
const handleDelete = async () => {
setLoading(true)
try {
await remove(record.id)
await loadHelps()
handleCancel()
toast.success(i18n.t('settings.helps.toasts.success'))
} catch (e) {
toast.error(i18n.t('settings.helps.toasts.errorOperation'))
}
setLoading(false)
}
const handleOpenDeleteDialog = () => {
setShowConfirmDialog(true)
}
const handleCancel = () => {
setRecord({
title: '',
description: '',
video: ''
})
}
const handleSelect = (data) => {
setRecord({
id: data.id,
title: data.title || '',
description: data.description || '',
video: data.video || ''
})
}
return (
<Paper className={classes.mainPaper} elevation={0}>
<Grid spacing={2} container>
<Grid xs={12} item>
<HelpManagerForm
initialValue={record}
onDelete={handleOpenDeleteDialog}
onSubmit={handleSubmit}
onCancel={handleCancel}
loading={loading}
/>
</Grid>
<Grid xs={12} item>
<HelpsManagerGrid
records={records}
onSelect={handleSelect}
/>
</Grid>
</Grid>
<ConfirmationModal
title={i18n.t('settings.helps.confirmModal.title')}
open={showConfirmDialog}
onClose={() => setShowConfirmDialog(false)}
onConfirm={() => handleDelete()}
>
{i18n.t('settings.helps.confirmModal.confirm')}
</ConfirmationModal>
</Paper>
)
}

View File

@@ -0,0 +1,217 @@
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Grid, IconButton, makeStyles, Modal, Typography } from "@material-ui/core";
import { CloseOutlined, FontDownload, ImportContacts } from "@material-ui/icons";
import React, { useState } from "react";
import { FaDownload } from "react-icons/fa";
import * as XLSX from 'xlsx';
import { array } from "yup";
import toastError from "../../errors/toastError";
import api from "../../services/api";
import { i18n } from "../../translate/i18n";
const useStyles = makeStyles((theme) => ({
root: {
display: "flex",
flexWrap: "wrap",
},
dialogImport: {
minWidth: 500,
},
iFile: {
display: "none"
},
lbFile: {
border: "dashed",
borderWidth: 2,
padding: 18,
cursor: "pointer",
display: "inline-block",
width: "70%"
},
btnWrapper: {
position: "relative"
},
iconPlanilha: {
marginRight: 10
},
cLbFile: {
textAlign: "center"
},
titleLb: {
textTransform: "uppercase",
fontWeight: "bolder",
marginTop:10
},
iconDownload: {
color: theme.palette.primary.main,
fontSize: 18
},
cModal: {
paddingTop: 50,
paddingBottom: 50
},
cSuccessContacts: {
backgroundColor: "#AAEE9C80",
padding: 10,
borderRadius: 8,
marginTop:30
},
cErrorContacts: {
backgroundColor: "#DD011B40",
padding: 10,
borderRadius: 8,
marginTop:30
},
titleResult: {
fontWeight: "bolder"
},
cCloseModal: {
textAlign: "end"
}
}))
const ImportContactsModal = ( props ) => {
const classes = useStyles();
const {
open,
onClose
} = props;
const [isSubmitting, setIsSubmitting] = useState(false);
const [nameFile, setNameFile] = useState('');
const [listcontacts, setListContacts] = useState([]);
const [successUpload, setSuccessUpload] = useState([]);
const [errorUpload, setErrorUpload] = useState([]);
const handleNewFile = ( e ) => {
const file = e.target.files[0];
if(!file) return;
setNameFile( file.name );
readXlsx( file );
}
const readXlsx = ( file ) => {
const reader = new FileReader();
reader.onload = ( e ) => {
const ab = e.target.result;
const wb = XLSX.read(ab,{type: 'array'})
const wsname = wb.SheetNames[0];
const ws = wb.Sheets[wsname];
const data = XLSX.utils.sheet_to_json(ws);
setListContacts(data);
}
reader.readAsArrayBuffer(file);
}
const handleSaveListContacts = async ( ) => {
setIsSubmitting(true);
try{
const {data: responseData} = await api.post("/contacts/upload", listcontacts);
setSuccessUpload(responseData.newContacts);
setErrorUpload(responseData.errorBag);
}catch(e){
toastError(e);
}finally{
setIsSubmitting(false);
}
}
const handleDownloadModel = ( ) => {
window.location.href = `${window.location.protocol}//${window.location.host}/import-contatos.xlsx`;
}
return (
<div >
<Dialog open={open} maxWidth="sm" fullWidth scroll="paper" >
<DialogTitle>
<Grid container alignItems="center">
<Grid item xs={6}>
{i18n.t("contactImportModal.title")}
</Grid>
<Grid item xs={6} className={classes.cCloseModal}>
<IconButton onClick={onClose}>
<CloseOutlined />
</IconButton>
</Grid>
</Grid>
</DialogTitle>
<DialogContent dividers className={classes.cModal}>
<div className={classes.cLbFile}>
<label className={classes.lbFile} htmlFor="i-import-contacts">
<FaDownload className={classes.iconDownload} />
<div className={classes.titleLb}>
{i18n.t("contactImportModal.labels.import")}
</div>
{nameFile !== '' && (
<div>
({ nameFile } - {listcontacts.length} {i18n.t("contactImportModal.labels.result")})
</div>
)}
</label>
<input onChange={handleNewFile} className={classes.iFile} type="file" accept=".xlsx" id="i-import-contacts"/>
</div>
{successUpload.length > 0 && (
<div className={classes.cSuccessContacts}>
<Typography className={classes.titleResult}>
{i18n.t("contactImportModal.labels.added")}:
</Typography>
{successUpload.map((contact) => (
<div>
{contact.contactId} | {contact.contactName} - {i18n.t("contactImportModal.labels.savedContact")}
</div>
))}
</div>
)}
{errorUpload.length > 0 && (
<div className={classes.cErrorContacts}>
<Typography className={classes.titleResult}>
{i18n.t("contactImportModal.labels.errors")}:
</Typography>
<ul>
{errorUpload.map((contact) => (
<li>
{contact.contactName} - {contact.error.message}
</li>
))}
</ul>
</div>
)}
</DialogContent>
<DialogActions>
<Button
color="primary"
disabled={isSubmitting}
variant="outlined"
onClick={handleDownloadModel}
>
<ImportContacts className={classes.iconPlanilha} />
{i18n.t("contactImportModal.buttons.download")}
</Button>
<Button
color="primary"
disabled={isSubmitting}
variant="contained"
className={classes.btnWrapper}
onClick={handleSaveListContacts}
>
{i18n.t("contactImportModal.buttons.import")}
</Button>
</DialogActions>
</Dialog>
</div>
);
}
export default ImportContactsModal;

View File

@@ -0,0 +1,45 @@
import React, { useEffect, useState } from 'react';
import { changeLanguage, i18n } from "../../translate/i18n";
import { RadioGroup, FormControlLabel, Radio } from '@material-ui/core';
import api from "../../services/api";
const LanguageControl = () => {
const [selectedLanguage, setSelectedLanguage] = useState('en');
const handleLanguageChange = async (event) => {
const newLanguage = event.target.value;
setSelectedLanguage(newLanguage);
changeLanguage(newLanguage);
try{
await api.post(`/users/set-language/${newLanguage}`);
}catch(error){
console.error(error);
}
};
useEffect(() => {
const saveLanguage = localStorage.getItem('i18nextLng');
setSelectedLanguage(saveLanguage);
}, []);
return (
<div>
<label htmlFor="language-select">{i18n.t("selectLanguage")}</label>
<RadioGroup
aria-label="language"
name="language-radio-group"
value={selectedLanguage}
onChange={handleLanguageChange}
row
>
<FormControlLabel value="pt" control={<Radio />} label="Português (BR)" />
<FormControlLabel value="en" control={<Radio />} label="English" />
<FormControlLabel value="es" control={<Radio />} label="Español" />
</RadioGroup>
</div>
);
};
export default LanguageControl;

View File

@@ -0,0 +1,53 @@
import React, { useEffect } from 'react';
import toastError from "../../errors/toastError";
import { Button, Divider, Typography} from "@material-ui/core";
import { i18n } from '../../translate/i18n';
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} alt="loc" 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}
>
{i18n.t("locationPreview.button")}
</Button>
</div>
</div>
</div>
</>
);
};
export default LocationPreview;

View File

@@ -0,0 +1,31 @@
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)`,
},
contentWrapper: {
height: "100%",
overflowY: "hidden",
display: "flex",
flexDirection: "column",
},
}));
const MainContainer = ({ children }) => {
const classes = useStyles();
return (
<Container className={classes.mainContainer}>
<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,186 @@
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 && 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,513 @@
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 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, Switch } from "@material-ui/core";
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: {
backgroundColor: theme.palette.bordabox, //DARK MODE PLW DESIGN//
display: "flex",
flexDirection: "column",
alignItems: "center",
borderTop: "1px solid rgba(0, 0, 0, 0.12)",
},
newMessageBox: {
background: "#eee",
width: "100%",
display: "flex",
padding: "7px",
alignItems: "center",
},
messageInputWrapper: {
padding: 6,
marginRight: 7,
background: "#fff",
display: "flex",
borderRadius: 20,
flex: 1,
},
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,
},
}));
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 inputRef = useRef();
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);
};
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 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 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}>
<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}>
<Picker
perLine={16}
showPreview={false}
showSkinTones={false}
onSelect={handleAddEmoji}
/>
</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"
/>
}
/>
<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();
}
}}
/>
</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,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,773 @@
import React, { useState, useEffect, useContext, useRef } from "react";
import withWidth, { isWidthUp } from "@material-ui/core/withWidth";
import "emoji-mart/css/emoji-mart.css";
import { Picker } from "emoji-mart";
import MicRecorder from "mic-recorder-to-mp3";
import clsx from "clsx";
import { isNil } from "lodash";
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 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, Switch } from "@material-ui/core";
import Autocomplete from "@material-ui/lab/Autocomplete";
import { isString, isEmpty, isObject, has } from "lodash";
import { i18n } from "../../translate/i18n";
import api from "../../services/api";
import axios from "axios";
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";
import useQuickMessages from "../../hooks/useQuickMessages";
const Mp3Recorder = new MicRecorder({ bitRate: 128 });
const useStyles = makeStyles((theme) => ({
mainWrapper: {
backgroundColor: theme.palette.bordabox, //DARK MODE PLW DESIGN//
display: "flex",
flexDirection: "column",
alignItems: "center",
borderTop: "1px solid rgba(0, 0, 0, 0.12)",
},
newMessageBox: {
backgroundColor: theme.palette.newmessagebox, //DARK MODE PLW DESIGN//
width: "100%",
display: "flex",
padding: "7px",
alignItems: "center",
},
messageInputWrapper: {
padding: 6,
marginRight: 7,
backgroundColor: theme.palette.inputdigita, //DARK MODE PLW DESIGN//
display: "flex",
borderRadius: 20,
flex: 1,
},
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,
},
}));
const EmojiOptions = (props) => {
const { disabled, showEmoji, setShowEmoji, handleAddEmoji } = props;
const classes = useStyles();
return (
<>
<IconButton
aria-label="emojiPicker"
component="span"
disabled={disabled}
onClick={(e) => setShowEmoji((prevState) => !prevState)}
>
<MoodIcon className={classes.sendMessageIcons} />
</IconButton>
{showEmoji ? (
<div className={classes.emojiBox}>
<Picker
perLine={16}
showPreview={false}
showSkinTones={false}
onSelect={handleAddEmoji}
/>
</div>
) : null}
</>
);
};
const SignSwitch = (props) => {
const { width, setSignMessage, signMessage } = props;
if (isWidthUp("md", width)) {
return (
<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"
/>
}
/>
);
}
return null;
};
const FileInput = (props) => {
const { handleChangeMedias, disableOption } = props;
const classes = useStyles();
return (
<>
<input
multiple
type="file"
id="upload-button"
disabled={disableOption()}
className={classes.uploadInput}
onChange={handleChangeMedias}
/>
<label htmlFor="upload-button">
<IconButton
aria-label="upload"
component="span"
disabled={disableOption()}
>
<AttachFileIcon className={classes.sendMessageIcons} />
</IconButton>
</label>
</>
);
};
const ActionButtons = (props) => {
const {
inputMessage,
loading,
recording,
ticketStatus,
handleSendMessage,
handleCancelAudio,
handleUploadAudio,
handleStartRecording,
} = props;
const classes = useStyles();
if (inputMessage) {
return (
<IconButton
aria-label="sendMessage"
component="span"
onClick={handleSendMessage}
disabled={loading}
>
<SendIcon className={classes.sendMessageIcons} />
</IconButton>
);
} else if (recording) {
return (
<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>
);
} else {
return (
<IconButton
aria-label="showRecorder"
component="span"
disabled={loading || ticketStatus !== "open"}
onClick={handleStartRecording}
>
<MicIcon className={classes.sendMessageIcons} />
</IconButton>
);
}
};
const CustomInput = (props) => {
const {
loading,
inputRef,
ticketStatus,
inputMessage,
setInputMessage,
handleSendMessage,
handleInputPaste,
disableOption,
handleQuickAnswersClick,
} = props;
const classes = useStyles();
const [quickMessages, setQuickMessages] = useState([]);
const [options, setOptions] = useState([]);
const [popupOpen, setPopupOpen] = useState(false);
const { user } = useContext(AuthContext);
const { list: listQuickMessages } = useQuickMessages();
useEffect(() => {
async function fetchData() {
const companyId = localStorage.getItem("companyId");
const messages = await listQuickMessages({ companyId, userId: user.id });
const options = messages.map((m) => {
let truncatedMessage = m.message;
if (isString(truncatedMessage) && truncatedMessage.length > 35) {
truncatedMessage = m.message.substring(0, 35) + "...";
}
return {
value: m.message,
label: `/${m.shortcode} - ${truncatedMessage}`,
mediaPath: m.mediaPath,
};
});
setQuickMessages(options);
}
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (
isString(inputMessage) &&
!isEmpty(inputMessage) &&
inputMessage.length > 1
) {
const firstWord = inputMessage.charAt(0);
setPopupOpen(firstWord.indexOf("/") > -1);
const filteredOptions = quickMessages.filter(
(m) => m.label.indexOf(inputMessage) > -1
);
setOptions(filteredOptions);
} else {
setPopupOpen(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputMessage]);
const onKeyPress = (e) => {
if (loading || e.shiftKey) return;
else if (e.key === "Enter") {
handleSendMessage();
}
};
const onPaste = (e) => {
if (ticketStatus === "open") {
handleInputPaste(e);
}
};
const renderPlaceholder = () => {
if (ticketStatus === "open") {
return i18n.t("messagesInput.placeholderOpen");
}
return i18n.t("messagesInput.placeholderClosed");
};
const setInputRef = (input) => {
if (input) {
input.focus();
inputRef.current = input;
}
};
return (
<div className={classes.messageInputWrapper}>
<Autocomplete
freeSolo
open={popupOpen}
id="grouped-demo"
value={inputMessage}
options={options}
closeIcon={null}
getOptionLabel={(option) => {
if (isObject(option)) {
return option.label;
} else {
return option;
}
}}
onChange={(event, opt) => {
if (isObject(opt) && has(opt, "value") && isNil(opt.mediaPath)) {
setInputMessage(opt.value);
setTimeout(() => {
inputRef.current.scrollTop = inputRef.current.scrollHeight;
}, 200);
} else if (isObject(opt) && has(opt, "value") && !isNil(opt.mediaPath)) {
handleQuickAnswersClick(opt);
setTimeout(() => {
inputRef.current.scrollTop = inputRef.current.scrollHeight;
}, 200);
}
}}
onInputChange={(event, opt, reason) => {
if (reason === "input") {
setInputMessage(event.target.value);
}
}}
onPaste={onPaste}
onKeyPress={onKeyPress}
style={{ width: "100%" }}
renderInput={(params) => {
const { InputLabelProps, InputProps, ...rest } = params;
return (
<InputBase
{...params.InputProps}
{...rest}
disabled={disableOption()}
inputRef={setInputRef}
placeholder={renderPlaceholder()}
multiline
className={classes.messageInput}
maxRows={5}
/>
);
}}
/>
</div>
);
};
const MessageInputCustom = (props) => {
const { ticketStatus, ticketId } = props;
const classes = useStyles();
const [medias, setMedias] = useState([]);
const [inputMessage, setInputMessage] = useState("");
const [showEmoji, setShowEmoji] = useState(false);
const [loading, setLoading] = useState(false);
const [recording, setRecording] = useState(false);
const inputRef = useRef();
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 => {
// if (isObject(e) && has(e, 'value')) {
// setInputMessage(e.value);
// } else {
// setInputMessage(e.target.value)
// }
// };
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 handleUploadQuickMessageMedia = async (blob, message) => {
setLoading(true);
try {
const extension = blob.type.split("/")[1];
const formData = new FormData();
const filename = `${new Date().getTime()}.${extension}`;
formData.append("medias", blob, filename);
formData.append("body", message);
formData.append("fromMe", true);
await api.post(`/messages/${ticketId}`, formData);
} catch (err) {
toastError(err);
setLoading(false);
}
setLoading(false);
};
const handleQuickAnswersClick = async (value) => {
if (value.mediaPath) {
try {
const { data } = await axios.get(value.mediaPath, {
responseType: "blob",
});
handleUploadQuickMessageMedia(data, value.value);
setInputMessage("");
return;
// handleChangeMedias(response)
} catch (err) {
toastError(err);
}
}
setInputMessage("");
setInputMessage(value.value);
};
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 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 = `audio-record-site-${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 disableOption = () => {
return loading || recording || ticketStatus !== "open";
};
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}>
<EmojiOptions
disabled={disableOption()}
handleAddEmoji={handleAddEmoji}
showEmoji={showEmoji}
setShowEmoji={setShowEmoji}
/>
<FileInput
disableOption={disableOption}
handleChangeMedias={handleChangeMedias}
/>
<SignSwitch
width={props.width}
setSignMessage={setSignMessage}
signMessage={signMessage}
/>
<CustomInput
loading={loading}
inputRef={inputRef}
ticketStatus={ticketStatus}
inputMessage={inputMessage}
setInputMessage={setInputMessage}
// handleChangeInput={handleChangeInput}
handleSendMessage={handleSendMessage}
handleInputPaste={handleInputPaste}
disableOption={disableOption}
handleQuickAnswersClick={handleQuickAnswersClick}
/>
<ActionButtons
inputMessage={inputMessage}
loading={loading}
recording={recording}
ticketStatus={ticketStatus}
handleSendMessage={handleSendMessage}
handleCancelAudio={handleCancelAudio}
handleUploadAudio={handleUploadAudio}
handleStartRecording={handleStartRecording}
/>
</div>
</Paper>
);
}
};
export default withWidth()(MessageInputCustom);

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,66 @@
import React from "react";
import { Chip, makeStyles } from "@material-ui/core";
import { i18n } from "../../translate/i18n";
import OutlinedDiv from "../OutlinedDiv";
const useStyles = makeStyles(theme => ({
chip: {
margin: theme.spacing(0.5),
cursor: "pointer"
}
}));
const MessageVariablesPicker = ({ onClick, disabled }) => {
const classes = useStyles();
const handleClick = (e, value) => {
e.preventDefault();
if (disabled) return;
onClick(value);
};
const msgVars = [
{
name: i18n.t("messageVariablesPicker.vars.contactFirstName"),
value: "{{firstName}}"
},
{
name: i18n.t("messageVariablesPicker.vars.contactName"),
value: "{{name}} "
},
{
name: i18n.t("messageVariablesPicker.vars.greeting"),
value: "{{ms}} "
},
{
name: i18n.t("messageVariablesPicker.vars.protocolNumber"),
value: "{{protocol}} "
},
{
name: i18n.t("messageVariablesPicker.vars.hour"),
value: "{{hora}} "
},
];
return (
<OutlinedDiv
margin="dense"
fullWidth
label={i18n.t("messageVariablesPicker.label")}
disabled={disabled}
>
{msgVars.map(msgVar => (
<Chip
key={msgVar.value}
onMouseDown={e => handleClick(e, msgVar.value)}
label={msgVar.name}
size="small"
className={classes.chip}
color="primary"
/>
))}
</OutlinedDiv>
);
};
export default MessageVariablesPicker;

View File

@@ -0,0 +1,837 @@
import React, { useState, useEffect, useReducer, useRef, useContext } from "react";
import { isSameDay, parseISO, format } from "date-fns";
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 ModalImageCors from "../ModalImageCors";
import MessageOptionsMenu from "../MessageOptionsMenu";
import whatsBackground from "../../assets/wa-background.png";
import LocationPreview from "../LocationPreview";
import whatsBackgroundDark from "../../assets/wa-background-dark.png"; //DARK MODE PLW DESIGN//
import api from "../../services/api";
import toastError from "../../errors/toastError";
import { SocketContext } from "../../context/Socket/SocketContext";
import { i18n } from "../../translate/i18n";
const useStyles = makeStyles((theme) => ({
messagesListWrapper: {
overflow: "hidden",
position: "relative",
display: "flex",
flexDirection: "column",
flexGrow: 1,
width: "100%",
minWidth: 300,
minHeight: 200,
},
messagesList: {
backgroundImage: theme.mode === 'light' ? `url(${whatsBackground})` : `url(${whatsBackgroundDark})`, //DARK MODE PLW DESIGN//
display: "flex",
flexDirection: "column",
flexGrow: 1,
padding: "20px 20px 20px 20px",
overflowY: "scroll",
...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",
},
textContentItemEdited: {
overflowWrap: "break-word",
padding: "3px 120px 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 = ({ ticket, 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);
const socketManager = useContext(SocketContext);
useEffect(() => {
dispatch({ type: "RESET" });
setPageNumber(1);
currentTicketId.current = ticketId;
}, [ticketId]);
useEffect(() => {
setLoading(true);
const delayDebounceFn = setTimeout(() => {
const fetchMessages = async () => {
if (ticketId === undefined) return;
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 companyId = localStorage.getItem("companyId");
const socket = socketManager.getSocket(companyId);
socket.on("ready", () => socket.emit("joinChatBox", `${ticket.id}`));
socket.on(`company-${companyId}-appMessage`, (data) => {
if (data.action === "create" && data.message.ticketId === currentTicketId.current) {
dispatch({ type: "ADD_MESSAGE", payload: data.message });
scrollToBottom();
}
if (data.action === "update" && data.message.ticketId === currentTicketId.current) {
dispatch({ type: "UPDATE_MESSAGE", payload: data.message });
}
});
return () => {
socket.disconnect();
};
}, [ticketId, ticket, socketManager]);
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 === "locationMessage" && 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") {
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 controls>
<source src={message.mediaUrl} type="audio/ogg"></source>
</audio>
);
} 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}
>
{i18n.t("messagesList.header.buttons.download")}
</Button>
</div>
<div style={{marginBottom: message.body === "" ? 8 : 0}}>
<Divider />
</div>
</>
);
}
};
const renderMessageAck = (message) => {
if (message.ack === 1) {
return <AccessTime fontSize="small" className={classes.ackIcons} />;
}
if (message.ack === 2) {
return <Done fontSize="small" className={classes.ackIcons} />;
}
if (message.ack === 3) {
return <DoneAll fontSize="small" className={classes.ackIcons} />;
}
if (message.ack === 4 || message.ack === 5) {
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 renderNumberTicket = (message, index) => {
if (index < messagesList.length && index > 0) {
let messageTicket = message.ticketId;
let connectionName = message.ticket?.whatsapp?.name;
let previousMessageTicket = messagesList[index - 1].ticketId;
if (messageTicket !== previousMessageTicket) {
return (
<center>
<div className={classes.ticketNunberClosed}>
Conversa encerrada: {format(parseISO(messagesList[index - 1].createdAt), "dd/MM/yyyy HH:mm:ss")}
</div>
<div className={classes.ticketNunberOpen}>
Conversa iniciada: {format(parseISO(message.createdAt), "dd/MM/yyyy HH:mm:ss")}
</div>
</center>
);
}
}
};
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.mediaType === "audio"
&& (
<div className={classes.downloadMedia}>
<audio controls>
<source src={message.quotedMsg.mediaUrl} type="audio/ogg"></source>
</audio>
</div>
)
}
{message.quotedMsg.mediaType === "video"
&& (
<video
className={classes.messageMedia}
src={message.quotedMsg.mediaUrl}
controls
/>
)
}
{message.quotedMsg.mediaType === "application"
&& (
<div className={classes.downloadMedia}>
<Button
startIcon={<GetApp />}
color="primary"
variant="outlined"
target="_blank"
href={message.quotedMsg.mediaUrl}
>
{i18n.t("messagesList.header.buttons.download")}
</Button>
</div>
)
}
{message.quotedMsg.mediaType === "image"
&& (<ModalImageCors imageUrl={message.quotedMsg.mediaUrl} />)}
{message.quotedMsg.mediaType === "contactMessage"
&& (
<span>{message.quotedMsg.body}</span>
)
}
</div>
</div>
);
};
const renderMessages = () => {
if (messagesList.length > 0) {
const viewMessagesList = messagesList.map((message, index) => {
if (message.mediaType === "call_log") {
return (
<React.Fragment key={message.id}>
{renderDailyTimestamps(message, index)}
{renderNumberTicket(message, index)}
{renderMessageDivider(message, index)}
<div className={classes.messageCenter}>
<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>
)}
<div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 17" width="20" height="17">
<path fill="#df3333" d="M18.2 12.1c-1.5-1.8-5-2.7-8.2-2.7s-6.7 1-8.2 2.7c-.7.8-.3 2.3.2 2.8.2.2.3.3.5.3 1.4 0 3.6-.7 3.6-.7.5-.2.8-.5.8-1v-1.3c.7-1.2 5.4-1.2 6.4-.1l.1.1v1.3c0 .2.1.4.2.6.1.2.3.3.5.4 0 0 2.2.7 3.6.7.2 0 1.4-2 .5-3.1zM5.4 3.2l4.7 4.6 5.8-5.7-.9-.8L10.1 6 6.4 2.3h2.5V1H4.1v4.8h1.3V3.2z"></path>
</svg> <span>{i18n.t("messagesList.lostCall")} {format(parseISO(message.createdAt), "HH:mm")}</span>
</div>
</div>
</React.Fragment>
);
}
if (!message.fromMe) {
return (
<React.Fragment key={message.id}>
{renderDailyTimestamps(message, index)}
{renderNumberTicket(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>
)}
{/* aviso de mensagem apagado pelo contato */}
{message.isDeleted && (
<div>
<span className={"message-deleted"}
>{i18n.t("messagesList.deletedMessage")} &nbsp;
<Block
color="error"
fontSize="small"
className={classes.deletedIcon}
/>
</span>
</div>
)}
{(message.mediaUrl || message.mediaType === "locationMessage" || message.mediaType === "vcard"
//|| message.mediaType === "multi_vcard"
) && checkMessageMedia(message)}
<div className={classes.textContentItem}>
{message.quotedMsg && renderQuotedMessage(message)}
<MarkdownWrapper>{message.mediaType === "locationMessage" ? null : message.body}</MarkdownWrapper>
<span className={classes.timestamp}>
{message.isEdited && <span>Editada </span>}
{format(parseISO(message.createdAt), "HH:mm")}
</span>
</div>
</div>
</React.Fragment>
);
} else {
return (
<React.Fragment key={message.id}>
{renderDailyTimestamps(message, index)}
{renderNumberTicket(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 === "locationMessage" || message.mediaType === "vcard"
//|| message.mediaType === "multi_vcard"
) && checkMessageMedia(message)}
<div
className={clsx(classes.textContentItem, {
[classes.textContentItemDeleted]: message.isDeleted,
[classes.textContentItemEdited]: message.isEdited,
})}
>
{message.isDeleted && (
<Block
color="disabled"
fontSize="small"
className={classes.deletedIcon}
/>
)}
{message.quotedMsg && renderQuotedMessage(message)}
<MarkdownWrapper>{message.mediaType === "locationMessage" ? null : message.body}</MarkdownWrapper>
<span className={classes.timestamp}>
{message.isEdited && <span>{i18n.t("messagesList.edited")}</span>}
{format(parseISO(message.createdAt), "HH:mm")}
{renderMessageAck(message)}
</span>
</div>
</div>
</React.Fragment>
);
}
});
return viewMessagesList;
} else {
return <div>{i18n.t("messagesList.saudation")}</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,263 @@
import React, { useState, useEffect, useContext } 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 Select from "@material-ui/core/Select";
import InputLabel from "@material-ui/core/InputLabel";
import MenuItem from "@material-ui/core/MenuItem";
import FormControl from "@material-ui/core/FormControl";
import { i18n } from "../../translate/i18n";
import api from "../../services/api";
import toastError from "../../errors/toastError";
import QueueSelectCustom from "../QueueSelectCustom";
import { AuthContext } from "../../context/Auth/AuthContext";
import { Can } from "../Can";
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 ModalUsers = ({ open, onClose, userId, companyId }) => {
const classes = useStyles();
const initialState = {
name: "",
email: "",
password: "",
profile: "user",
};
const { user: loggedInUser } = useContext(AuthContext);
const [user, setUser] = useState(initialState);
const [selectedQueueIds, setSelectedQueueIds] = useState([]);
useEffect(() => {
const fetchUser = async () => {
if (!userId) return;
if (open) {
try {
const { data } = await api.get(`/users/${userId}`);
setUser((prevState) => {
return { ...prevState, ...data };
});
const userQueueIds = data.queues?.map((queue) => queue.id);
setSelectedQueueIds(userQueueIds);
} catch (err) {
toastError(err);
}
}
};
fetchUser();
}, [userId, open]);
const handleClose = () => {
onClose();
setUser(initialState);
};
const handleSaveUser = async (values) => {
const userData = { ...values, companyId, 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}
label={i18n.t("userModal.form.password")}
type="password"
name="password"
error={touched.password && Boolean(errors.password)}
helperText={touched.password && errors.password}
variant="outlined"
margin="dense"
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={() => (
<QueueSelectCustom
companyId={companyId}
selectedQueueIds={selectedQueueIds}
onChange={(values) => setSelectedQueueIds(values)}
/>
)}
/>
</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 ModalUsers;

View File

@@ -0,0 +1,447 @@
import React, { useState, useEffect, useContext } from "react";
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 { makeStyles } from "@material-ui/core/styles";
import { AuthContext } from "../../context/Auth/AuthContext";
import { WhatsApp } from "@material-ui/icons";
import { Grid, ListItemText, MenuItem, Select } from "@material-ui/core";
import Typography from "@material-ui/core/Typography";
import { toast } from "react-toastify";
//import ShowTicketOpen from "../ShowTicketOpenModal";
const useStyles = makeStyles((theme) => ({
online: {
fontSize: 11,
color: "#25d366"
},
offline: {
fontSize: 11,
color: "#e1306c"
}
}));
const filter = createFilterOptions({
trim: true,
});
const NewTicketModal = ({ modalOpen, onClose, initialContact }) => {
const classes = useStyles();
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [searchParam, setSearchParam] = useState("");
const [selectedContact, setSelectedContact] = useState(null);
const [selectedQueue, setSelectedQueue] = useState("");
const [selectedWhatsapp, setSelectedWhatsapp] = useState("");
const [newContact, setNewContact] = useState({});
const [whatsapps, setWhatsapps] = useState([]);
const [queues, setQueues] = useState([]);
const [contactModalOpen, setContactModalOpen] = useState(false);
const { user } = useContext(AuthContext);
const { companyId, whatsappId } = user;
const [ openAlert, setOpenAlert ] = useState(false);
const [ userTicketOpen, setUserTicketOpen] = useState("");
const [ queueTicketOpen, setQueueTicketOpen] = useState("");
useEffect(() => {
if (initialContact?.id !== undefined) {
setOptions([initialContact]);
setSelectedContact(initialContact);
}
}, [initialContact]);
useEffect(() => {
setLoading(true);
const delayDebounceFn = setTimeout(() => {
const fetchContacts = async () => {
api
.get(`/whatsapp`, { params: { companyId, session: 0 } })
.then(({ data }) => setWhatsapps(data));
};
if (whatsappId !== null && whatsappId!== undefined) {
setSelectedWhatsapp(whatsappId)
}
const fetchQueues = async ( ) => {
if( user.profile !== "admin" ){
setQueues( user.queues );
if (user.queues.length === 1) {
setSelectedQueue(user.queues[0].id)
}
return;
}
try{
const {data: queues} = await api.get('/queue');
setQueues( queues )
if( queues.length === 1) {
setSelectedQueue(queues[0].id)
}
}catch(err){
toastError(i18n.t("newTicketModal.searchQueueError"));
}
}
fetchQueues( );
fetchContacts( );
setLoading(false);
}, 500);
return () => clearTimeout(delayDebounceFn);
}, [])
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 IconChannel = (channel) => {
// switch (channel) {
// case "facebook":
// return <Facebook style={{ color: "#3b5998", verticalAlign: "middle" }} />;
// case "instagram":
// return <Instagram style={{ color: "#e1306c", verticalAlign: "middle" }} />;
// case "whatsapp":
// return <WhatsApp style={{ color: "#25d366", verticalAlign: "middle" }} />
// default:
// return "error";
// }
// };
const handleClose = () => {
onClose();
setSearchParam("");
setOpenAlert(false);
setUserTicketOpen("");
setQueueTicketOpen("");
setSelectedContact(null);
};
const handleCloseAlert = () => {
setOpenAlert(false);
setLoading(false);
setOpenAlert(false);
setUserTicketOpen("");
setQueueTicketOpen("");
};
const handleSaveTicket = async contactId => {
if (!contactId) return;
if (selectedQueue === "" && user.profile !== 'admin') {
toast.error(i18n.t("newTicketModal.selectQueue"));
return;
}
setLoading(true);
try {
const queueId = selectedQueue !== "" ? selectedQueue : null;
const whatsappId = selectedWhatsapp !== "" ? selectedWhatsapp : null;
const { data: ticket } = await api.post("/tickets", {
contactId: contactId,
queueId,
whatsappId,
userId: user.id,
status: "open",
});
onClose(ticket);
} catch (err) {
console.log(err);
const ticket = err.response.data.error;
console.log(ticket);
if( ticket === "ERR_OTHER_OPEN_TICKET" )
toastError(err);
if ( ticket !== "ERR_OTHER_OPEN_TICKET" && ticket.userId !== user?.id) {
setOpenAlert(true);
setUserTicketOpen(ticket.user.name);
setQueueTicketOpen(ticket.queue.name);
} else {
setOpenAlert(false);
setUserTicketOpen("");
setQueueTicketOpen("");
setLoading(false);
onClose(ticket);
}
}
setLoading(false);
};
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 <>
{/* {IconChannel(option.channel)} */}
<Typography component="span" style={{ fontSize: 14, marginLeft: "10px", display: "inline-flex", alignItems: "center", lineHeight: "2" }}>
{option.name} - {option.number}
</Typography>
</>
} else {
return `${i18n.t("newTicketModal.add")} ${option.name}`;
}
};
const renderOptionLabel = option => {
if (option.number) {
return `${option.name} - ${option.number}`;
} else {
return `${option.name}`;
}
};
const renderContactAutocomplete = () => {
if (initialContact === undefined || initialContact.id === undefined) {
return (
<Grid xs={12} item>
<Autocomplete
fullWidth
options={options}
loading={loading}
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>
),
}}
/>
)}
/>
</Grid>
)
}
return null;
}
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>
<Grid style={{ width: 300 }} container spacing={2}>
{/* CONTATO */}
{renderContactAutocomplete()}
{/* FILA */}
<Grid xs={12} item>
<Select
required
fullWidth
displayEmpty
variant="outlined"
value={selectedQueue}
onChange={(e) => {
setSelectedQueue(e.target.value)
}}
MenuProps={{
anchorOrigin: {
vertical: "bottom",
horizontal: "left",
},
transformOrigin: {
vertical: "top",
horizontal: "left",
},
getContentAnchorEl: null,
}}
renderValue={() => {
if (selectedQueue === "") {
return i18n.t("newTicketModal.selectQueue")
}
const queue = queues.find(q => q.id === selectedQueue)
return queue.name
}}
>
{queues?.length > 0 &&
queues.map((queue, key) => (
<MenuItem dense key={key} value={queue.id}>
<ListItemText primary={queue.name} />
</MenuItem>
))
}
</Select>
</Grid>
{/* CONEXAO */}
<Grid xs={12} item>
<Select
required
fullWidth
displayEmpty
variant="outlined"
value={selectedWhatsapp}
onChange={(e) => {
setSelectedWhatsapp(e.target.value)
}}
MenuProps={{
anchorOrigin: {
vertical: "bottom",
horizontal: "left",
},
transformOrigin: {
vertical: "top",
horizontal: "left",
},
getContentAnchorEl: null,
}}
renderValue={() => {
if (selectedWhatsapp === "") {
return i18n.t("newTicketModal.selectConection")
}
const whatsapp = whatsapps.find(w => w.id === selectedWhatsapp)
return whatsapp.name
}}
>
{whatsapps?.length > 0 &&
whatsapps.map((whatsapp, key) => (
<MenuItem dense key={key} value={whatsapp.id}>
<ListItemText
primary={
<>
{/* {IconChannel(whatsapp.channel)} */}
<Typography component="span" style={{ fontSize: 14, marginLeft: "10px", display: "inline-flex", alignItems: "center", lineHeight: "2" }}>
{whatsapp.name} &nbsp; <p className={(whatsapp.status) === 'CONNECTED' ? classes.online : classes.offline} >({whatsapp.status})</p>
</Typography>
</>
}
/>
</MenuItem>
))}
</Select>
</Grid>
</Grid>
</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>
{/* <ShowTicketOpen
isOpen={openAlert}
handleClose={handleCloseAlert}
user={userTicketOpen}
queue={queueTicketOpen}
/> */}
</Dialog >
</>
);
};
export default NewTicketModal;

View File

@@ -0,0 +1,270 @@
import React, { useState, useRef, useEffect, useContext } from "react";
import { useHistory } from "react-router-dom";
import { format } from "date-fns";
import { SocketContext } from "../../context/Socket/SocketContext";
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 "../TicketListItemCustom";
import useTickets from "../../hooks/useTickets";
import alertSound from "../../assets/sound.mp3";
import { AuthContext } from "../../context/Auth/AuthContext";
import { i18n } from "../../translate/i18n";
import toastError from "../../errors/toastError";
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 = (volume) => {
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 [showPendingTickets, setShowPendingTickets] = useState(false);
const [, setDesktopNotifications] = useState([]);
const { tickets } = useTickets({ withUnreadMessages: "true" });
const [play] = useSound(alertSound, volume);
const soundAlertRef = useRef();
const historyRef = useRef(history);
const socketManager = useContext(SocketContext);
useEffect(() => {
const fetchSettings = async () => {
try {
if (user.allTicket === "enable") {
setShowPendingTickets(true);
}
} catch (err) {
toastError(err);
}
}
fetchSettings();
}, []);
useEffect(() => {
soundAlertRef.current = play;
if (!("Notification" in window)) {
console.log("This browser doesn't support notifications");
} else {
Notification.requestPermission();
}
}, [play]);
useEffect(() => {
const processNotifications = () => {
if (showPendingTickets) {
setNotifications(tickets);
} else {
const newNotifications = tickets.filter(ticket => ticket.status !== "pending");
setNotifications(newNotifications);
}
}
processNotifications();
}, [tickets]);
useEffect(() => {
ticketIdRef.current = ticketIdUrl;
}, [ticketIdUrl]);
useEffect(() => {
const socket = socketManager.getSocket(user.companyId);
socket.on("ready", () => socket.emit("joinNotification"));
socket.on(`company-${user.companyId}-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(`company-${user.companyId}-appMessage`, data => {
if (
data.action === "create" && !data.message.fromMe &&
(data.ticket.status !== "pending" ) &&
(!data.message.read || data.ticket.status === "pending") &&
(data.ticket.userId === user?.id || !data.ticket.userId) &&
(user?.queues?.some(queue => (queue.id === data.ticket.queueId)) || !data.ticket.queueId)
) {
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, showPendingTickets, socketManager]);
const handleNotifications = data => {
const { message, contact, ticket } = data;
const options = {
body: `${message.body} - ${format(new Date(), "HH:mm")}`,
icon: contact.urlPicture,
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.uuid}`);
// handleChangeTab(null, ticket.isGroup? "group" : "open");
};
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"
style={{color:"white"}}
>
<Badge overlap="rectangular" 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,275 @@
import React, { useState, useRef, useEffect, useContext } from "react";
import { useHistory } from "react-router-dom";
import { format } from "date-fns";
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";
import { socketConnection } from "../../services/socket";
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",
},
icons: {
color: "#fff",
},
customBadge: {
backgroundColor: "#f44336",
color: "#fff",
},
}));
const NotificationsPopOver = ({ volume }) => {
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 { profile, queues } = user;
const [, setDesktopNotifications] = useState([]);
const { tickets } = useTickets({ withUnreadMessages: "true" });
const [play] = useSound(alertSound, { volume, });
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(() => {
const queueIds = queues.map((q) => q.id);
const filteredTickets = tickets.filter(
(t) => queueIds.indexOf(t.queueId) > -1
);
if (profile === "user") {
setNotifications(filteredTickets);
} else {
setNotifications(tickets);
}
}, [tickets, queues, profile]);
useEffect(() => {
ticketIdRef.current = ticketIdUrl;
}, [ticketIdUrl]);
useEffect(() => {
const companyId = localStorage.getItem("companyId");
const socket = socketConnection({ companyId });
if (!socket) {
return () => {};
}
const queueIds = queues.map((q) => q.id);
socket.on("connect", () => socket.emit("joinNotification"));
socket.on(`company-${companyId}-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(`company-${companyId}-appMessage`, (data) => {
if (
data.action === "create" &&
!data.message.read &&
(data.ticket.userId === user?.id || !data.ticket.userId)
) {
if (
profile === "user" &&
(queueIds.indexOf(data.ticket.queue?.id) === -1 ||
data.ticket.queue === null)
) {
return;
}
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 ||
data.ticket.isBot;
if (shouldNotNotificate) return;
handleNotifications(data);
}
});
return () => {
socket.disconnect();
};
}, [user, profile, queues]);
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.uuid}`);
};
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
className={classes.icons}
onClick={handleClick}
ref={anchorEl}
aria-label="Open Notifications"
variant="contained"
>
<Badge
overlap="rectangular"
badgeContent={notifications.length}
classes={{ badge: classes.customBadge }}
>
<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,110 @@
import React, { useState, useRef } from "react";
import Popover from "@material-ui/core/Popover";
import IconButton from "@material-ui/core/IconButton";
import List from "@material-ui/core/List";
import { makeStyles } from "@material-ui/core/styles";
import VolumeUpIcon from "@material-ui/icons/VolumeUp";
import VolumeDownIcon from "@material-ui/icons/VolumeDown";
import { Grid, Slider } from "@material-ui/core";
const useStyles = makeStyles((theme) => ({
tabContainer: {
padding: theme.spacing(2),
},
popoverPaper: {
width: "100%",
maxWidth: 350,
marginLeft: theme.spacing(2),
marginRight: theme.spacing(1),
[theme.breakpoints.down("sm")]: {
maxWidth: 270,
},
},
noShadow: {
boxShadow: "none !important",
},
icons: {
color: "#fff",
},
customBadge: {
backgroundColor: "#f44336",
color: "#fff",
},
}));
const NotificationsVolume = ({ volume, setVolume }) => {
const classes = useStyles();
const anchorEl = useRef();
const [isOpen, setIsOpen] = useState(false);
const handleClick = () => {
setIsOpen((prevState) => !prevState);
};
const handleClickAway = () => {
setIsOpen(false);
};
const handleVolumeChange = (value) => {
setVolume(value);
localStorage.setItem("volume", value);
};
return (
<>
<IconButton
className={classes.icons}
onClick={handleClick}
ref={anchorEl}
aria-label="Open Notifications"
// color="inherit"
// color="secondary"
>
<VolumeUpIcon color="inherit" />
</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}>
<Grid container spacing={2}>
<Grid item>
<VolumeDownIcon />
</Grid>
<Grid item xs>
<Slider
value={volume}
aria-labelledby="continuous-slider"
step={0.1}
min={0}
max={1}
onChange={(e, value) =>
handleVolumeChange(value)
}
/>
</Grid>
<Grid item>
<VolumeUpIcon />
</Grid>
</Grid>
</List>
</Popover>
</>
);
};
export default NotificationsVolume;

View File

@@ -0,0 +1,9 @@
const OnlyForSuperUser = ({ user, yes, no }) => user.super ? yes() : no();
OnlyForSuperUser.defaultProps = {
user: {},
yes: () => null,
no: () => null,
};
export default OnlyForSuperUser;

View File

@@ -0,0 +1,30 @@
import React from "react";
import TextField from "@material-ui/core/TextField";
const InputComponent = ({ inputRef, ...other }) => <div {...other} />;
const OutlinedDiv = ({
InputProps,
children,
InputLabelProps,
label,
...other
}) => {
return (
<TextField
{...other}
variant="outlined"
label={label}
multiline
InputLabelProps={{ shrink: true, ...InputLabelProps }}
InputProps={{
inputComponent: InputComponent,
...InputProps
}}
inputProps={{ children: children }}
/>
);
};
export default OutlinedDiv;

View File

@@ -0,0 +1,561 @@
import React, { useState, useEffect } from "react";
import {
makeStyles,
Paper,
Grid,
TextField,
Table,
TableHead,
TableBody,
TableCell,
TableRow,
IconButton,
FormControl,
InputLabel,
MenuItem,
Select
} from "@material-ui/core";
import { Formik, Form, Field } from 'formik';
import ButtonWithSpinner from "../ButtonWithSpinner";
import ConfirmationModal from "../ConfirmationModal";
import { Edit as EditIcon } from "@material-ui/icons";
import { toast } from "react-toastify";
import usePlans from "../../hooks/usePlans";
import { i18n } from "../../translate/i18n";
const useStyles = makeStyles(theme => ({
root: {
width: '100%'
},
mainPaper: {
width: '100%',
flex: 1,
padding: theme.spacing(2)
},
fullWidth: {
width: '100%'
},
tableContainer: {
width: '100%',
overflowX: "scroll",
...theme.scrollbarStyles
},
textfield: {
width: '100%'
},
textRight: {
textAlign: 'right'
},
row: {
paddingTop: theme.spacing(2),
paddingBottom: theme.spacing(2)
},
control: {
paddingRight: theme.spacing(1),
paddingLeft: theme.spacing(1)
},
buttonContainer: {
textAlign: 'right',
padding: theme.spacing(1)
}
}));
export function PlanManagerForm(props) {
const { onSubmit, onDelete, onCancel, initialValue, loading } = props;
const classes = useStyles()
const [record, setRecord] = useState({
name: '',
users: 0,
connections: 0,
queues: 0,
value: 0,
useCampaigns: true,
useSchedules: true,
useInternalChat: true,
useExternalApi: true,
useKanban: true,
useOpenAi: true,
useIntegrations: true,
});
useEffect(() => {
setRecord(initialValue)
}, [initialValue])
const handleSubmit = async (data) => {
onSubmit(data)
}
return (
<Formik
enableReinitialize
className={classes.fullWidth}
initialValues={record}
onSubmit={(values, { resetForm }) =>
setTimeout(() => {
handleSubmit(values)
resetForm()
}, 500)
}
>
{(values) => (
<Form className={classes.fullWidth}>
<Grid spacing={1} justifyContent="flex-start" container>
{/* NOME */}
<Grid xs={12} sm={6} md={2} item>
<Field
as={TextField}
label={i18n.t("plans.form.name")}
name="name"
variant="outlined"
className={classes.fullWidth}
margin="dense"
/>
</Grid>
{/* USUARIOS */}
<Grid xs={12} sm={6} md={1} item>
<Field
as={TextField}
label={i18n.t("plans.form.users")}
name="users"
variant="outlined"
className={classes.fullWidth}
margin="dense"
type="number"
/>
</Grid>
{/* CONEXOES */}
<Grid xs={12} sm={6} md={1} item>
<Field
as={TextField}
label={i18n.t("plans.form.connections")}
name="connections"
variant="outlined"
className={classes.fullWidth}
margin="dense"
type="number"
/>
</Grid>
{/* FILAS */}
<Grid xs={12} sm={6} md={1} item>
<Field
as={TextField}
label={i18n.t("plans.form.queues")}
name="queues"
variant="outlined"
className={classes.fullWidth}
margin="dense"
type="number"
/>
</Grid>
{/* VALOR */}
<Grid xs={12} sm={6} md={1} item>
<Field
as={TextField}
label={i18n.t("plans.form.value")}
name="value"
variant="outlined"
className={classes.fullWidth}
margin="dense"
type="text"
/>
</Grid>
{/* CAMPANHAS */}
<Grid xs={12} sm={6} md={2} item>
<FormControl margin="dense" variant="outlined" fullWidth>
<InputLabel htmlFor="useCampaigns-selection">{i18n.t("plans.form.campaigns")}</InputLabel>
<Field
as={Select}
id="useCampaigns-selection"
label={i18n.t("plans.form.campaigns")}
labelId="useCampaigns-selection-label"
name="useCampaigns"
margin="dense"
>
<MenuItem value={true}>{i18n.t("plans.form.enabled")}</MenuItem>
<MenuItem value={false}>{i18n.t("plans.form.disabled")}</MenuItem>
</Field>
</FormControl>
</Grid>
{/* AGENDAMENTOS */}
<Grid xs={12} sm={8} md={2} item>
<FormControl margin="dense" variant="outlined" fullWidth>
<InputLabel htmlFor="useSchedules-selection">{i18n.t("plans.form.schedules")}</InputLabel>
<Field
as={Select}
id="useSchedules-selection"
label={i18n.t("plans.form.schedules")}
labelId="useSchedules-selection-label"
name="useSchedules"
margin="dense"
>
<MenuItem value={true}>{i18n.t("plans.form.enabled")}</MenuItem>
<MenuItem value={false}>{i18n.t("plans.form.disabled")}</MenuItem>
</Field>
</FormControl>
</Grid>
{/* CHAT INTERNO */}
<Grid xs={12} sm={8} md={2} item>
<FormControl margin="dense" variant="outlined" fullWidth>
<InputLabel htmlFor="useInternalChat-selection">{i18n.t("plans.form.internalChat")}</InputLabel>
<Field
as={Select}
id="useInternalChat-selection"
label={i18n.t("plans.form.internalChat")}
labelId="useInternalChat-selection-label"
name="useInternalChat"
margin="dense"
>
<MenuItem value={true}>{i18n.t("plans.form.enabled")}</MenuItem>
<MenuItem value={false}>{i18n.t("plans.form.disabled")}</MenuItem>
</Field>
</FormControl>
</Grid>
{/* API Externa */}
<Grid xs={12} sm={8} md={4} item>
<FormControl margin="dense" variant="outlined" fullWidth>
<InputLabel htmlFor="useExternalApi-selection">{i18n.t("plans.form.externalApi")}</InputLabel>
<Field
as={Select}
id="useExternalApi-selection"
label={i18n.t("plans.form.externalApi")}
labelId="useExternalApi-selection-label"
name="useExternalApi"
margin="dense"
>
<MenuItem value={true}>{i18n.t("plans.form.enabled")}</MenuItem>
<MenuItem value={false}>{i18n.t("plans.form.disabled")}</MenuItem>
</Field>
</FormControl>
</Grid>
{/* KANBAN */}
<Grid xs={12} sm={8} md={2} item>
<FormControl margin="dense" variant="outlined" fullWidth>
<InputLabel htmlFor="useKanban-selection">{i18n.t("plans.form.kanban")}</InputLabel>
<Field
as={Select}
id="useKanban-selection"
label={i18n.t("plans.form.kanban")}
labelId="useKanban-selection-label"
name="useKanban"
margin="dense"
>
<MenuItem value={true}>{i18n.t("plans.form.enabled")}</MenuItem>
<MenuItem value={false}>{i18n.t("plans.form.disabled")}</MenuItem>
</Field>
</FormControl>
</Grid>
{/* OPENAI */}
<Grid xs={12} sm={8} md={2} item>
<FormControl margin="dense" variant="outlined" fullWidth>
<InputLabel htmlFor="useOpenAi-selection">Open.Ai</InputLabel>
<Field
as={Select}
id="useOpenAi-selection"
label="Talk.Ai"
labelId="useOpenAi-selection-label"
name="useOpenAi"
margin="dense"
>
<MenuItem value={true}>{i18n.t("plans.form.enabled")}</MenuItem>
<MenuItem value={false}>{i18n.t("plans.form.disabled")}</MenuItem>
</Field>
</FormControl>
</Grid>
{/* INTEGRACOES */}
<Grid xs={12} sm={8} md={2} item>
<FormControl margin="dense" variant="outlined" fullWidth>
<InputLabel htmlFor="useIntegrations-selection">
{i18n.t("plans.form.integrations")}
</InputLabel>
<Field
as={Select}
id="useIntegrations-selection"
label={i18n.t("plans.form.integrations")}
labelId="useIntegrations-selection-label"
name="useIntegrations"
margin="dense"
>
<MenuItem value={true}>{i18n.t("plans.form.enabled")}</MenuItem>
<MenuItem value={false}>{i18n.t("plans.form.disabled")}</MenuItem>
</Field>
</FormControl>
</Grid>
</Grid>
<Grid spacing={2} justifyContent="flex-end" container>
<Grid sm={3} md={2} item>
<ButtonWithSpinner className={classes.fullWidth} loading={loading} onClick={() => onCancel()} variant="contained">
{i18n.t("plans.form.clear")}
</ButtonWithSpinner>
</Grid>
{record.id !== undefined ? (
<Grid sm={3} md={2} item>
<ButtonWithSpinner className={classes.fullWidth} loading={loading} onClick={() => onDelete(record)} variant="contained" color="secondary">
{i18n.t("plans.form.delete")}
</ButtonWithSpinner>
</Grid>
) : null}
<Grid sm={3} md={2} item>
<ButtonWithSpinner className={classes.fullWidth} loading={loading} type="submit" variant="contained" color="primary">
{i18n.t("plans.form.save")}
</ButtonWithSpinner>
</Grid>
</Grid>
</Form>
)}
</Formik>
)
}
export function PlansManagerGrid(props) {
const { records, onSelect } = props
const classes = useStyles()
const renderCampaigns = (row) => {
return row.useCampaigns === false ? `${i18n.t("plans.form.no")}` : `${i18n.t("plans.form.yes")}`;
};
const renderSchedules = (row) => {
return row.useSchedules === false ? `${i18n.t("plans.form.no")}` : `${i18n.t("plans.form.yes")}`;
};
const renderInternalChat = (row) => {
return row.useInternalChat === false ? `${i18n.t("plans.form.no")}` : `${i18n.t("plans.form.yes")}`;
};
const renderExternalApi = (row) => {
return row.useExternalApi === false ? `${i18n.t("plans.form.no")}` : `${i18n.t("plans.form.yes")}`;
};
const renderKanban = (row) => {
return row.useKanban === false ? `${i18n.t("plans.form.no")}` : `${i18n.t("plans.form.yes")}`;
};
const renderOpenAi = (row) => {
return row.useOpenAi === false ? `${i18n.t("plans.form.no")}` : `${i18n.t("plans.form.yes")}`;
};
const renderIntegrations = (row) => {
return row.useIntegrations === false ? `${i18n.t("plans.form.no")}` : `${i18n.t("plans.form.yes")}`;
};
return (
<Paper className={classes.tableContainer}>
<Table
className={classes.fullWidth}
// size="small"
padding="none"
aria-label="a dense table"
>
<TableHead>
<TableRow>
<TableCell align="center" style={{ width: '1%' }}>#</TableCell>
<TableCell align="left">{i18n.t("plans.form.name")}</TableCell>
<TableCell align="center">{i18n.t("plans.form.users")}</TableCell>
<TableCell align="center">{i18n.t("plans.form.connections")}</TableCell>
<TableCell align="center">{i18n.t("plans.form.queues")}</TableCell>
<TableCell align="center">{i18n.t("plans.form.value")}</TableCell>
<TableCell align="center">{i18n.t("plans.form.campaigns")}</TableCell>
<TableCell align="center">{i18n.t("plans.form.schedules")}</TableCell>
<TableCell align="center">{i18n.t("plans.form.internalChat")}</TableCell>
<TableCell align="center">{i18n.t("plans.form.externalApi")}</TableCell>
<TableCell align="center">{i18n.t("plans.form.kanban")}</TableCell>
<TableCell align="center">Open.Ai</TableCell>
<TableCell align="center">{i18n.t("plans.form.integrations")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{records.map((row) => (
<TableRow key={row.id}>
<TableCell align="center" style={{ width: '1%' }}>
<IconButton onClick={() => onSelect(row)} aria-label="delete">
<EditIcon />
</IconButton>
</TableCell>
<TableCell align="left">{row.name || '-'}</TableCell>
<TableCell align="center">{row.users || '-'}</TableCell>
<TableCell align="center">{row.connections || '-'}</TableCell>
<TableCell align="center">{row.queues || '-'}</TableCell>
<TableCell align="center">{i18n.t("plans.form.money")} {row.value ? row.value.toLocaleString('pt-br', { minimumFractionDigits: 2 }) : '00.00'}</TableCell>
<TableCell align="center">{renderCampaigns(row)}</TableCell>
<TableCell align="center">{renderSchedules(row)}</TableCell>
<TableCell align="center">{renderInternalChat(row)}</TableCell>
<TableCell align="center">{renderExternalApi(row)}</TableCell>
<TableCell align="center">{renderKanban(row)}</TableCell>
<TableCell align="center">{renderOpenAi(row)}</TableCell>
<TableCell align="center">{renderIntegrations(row)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
)
}
export default function PlansManager() {
const classes = useStyles()
const { list, save, update, remove } = usePlans()
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
const [loading, setLoading] = useState(false)
const [records, setRecords] = useState([])
const [record, setRecord] = useState({
name: '',
users: 0,
connections: 0,
queues: 0,
value: 0,
useCampaigns: true,
useSchedules: true,
useInternalChat: true,
useExternalApi: true,
useKanban: true,
useOpenAi: true,
useIntegrations: true,
})
useEffect(() => {
async function fetchData() {
await loadPlans()
}
fetchData()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [record])
const loadPlans = async () => {
setLoading(true)
try {
const planList = await list()
setRecords(planList)
} catch (e) {
toast.error(i18n.t("plans.toasts.errorList"))
}
setLoading(false)
}
const handleSubmit = async (data) => {
setLoading(true)
console.log(data)
try {
if (data.id !== undefined) {
await update(data)
} else {
await save(data)
}
await loadPlans()
handleCancel()
toast.success(i18n.t("plans.toasts.success"))
} catch (e) {
toast.error(i18n.t("plans.toasts.error"))
}
setLoading(false)
}
const handleDelete = async () => {
setLoading(true)
try {
await remove(record.id)
await loadPlans()
handleCancel()
toast.success(i18n.t("plans.toasts.success"))
} catch (e) {
toast.error(i18n.t("plans.toasts.errorOperation"))
}
setLoading(false)
}
const handleOpenDeleteDialog = () => {
setShowConfirmDialog(true)
}
const handleCancel = () => {
setRecord({
id: undefined,
name: '',
users: 0,
connections: 0,
queues: 0,
value: 0,
useCampaigns: true,
useSchedules: true,
useInternalChat: true,
useExternalApi: true,
useKanban: true,
useOpenAi: true,
useIntegrations: true
})
}
const handleSelect = (data) => {
let useCampaigns = data.useCampaigns === false ? false : true
let useSchedules = data.useSchedules === false ? false : true
let useInternalChat = data.useInternalChat === false ? false : true
let useExternalApi = data.useExternalApi === false ? false : true
let useKanban = data.useKanban === false ? false : true
let useOpenAi = data.useOpenAi === false ? false : true
let useIntegrations = data.useIntegrations === false ? false : true
setRecord({
id: data.id,
name: data.name || '',
users: data.users || 0,
connections: data.connections || 0,
queues: data.queues || 0,
value: data.value?.toLocaleString('pt-br', { minimumFractionDigits: 0 }) || 0,
useCampaigns,
useSchedules,
useInternalChat,
useExternalApi,
useKanban,
useOpenAi,
useIntegrations
})
}
return (
<Paper className={classes.mainPaper} elevation={0}>
<Grid spacing={2} container>
<Grid xs={12} item>
<PlanManagerForm
initialValue={record}
onDelete={handleOpenDeleteDialog}
onSubmit={handleSubmit}
onCancel={handleCancel}
loading={loading}
/>
</Grid>
<Grid xs={12} item>
<PlansManagerGrid
records={records}
onSelect={handleSelect}
/>
</Grid>
</Grid>
<ConfirmationModal
title={i18n.t("plans.confirm.title")}
open={showConfirmDialog}
onClose={() => setShowConfirmDialog(false)}
onConfirm={() => handleDelete()}
>
{i18n.t("plans.confirm.message")}
</ConfirmationModal>
</Paper>
)
}

View File

@@ -0,0 +1,312 @@
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 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 { MenuItem, FormControl, InputLabel, Select, Menu, Grid } from "@material-ui/core";
import { Visibility, VisibilityOff } from "@material-ui/icons";
import { InputAdornment, IconButton } from "@material-ui/core";
import QueueSelectSingle from "../../components/QueueSelectSingle";
import api from "../../services/api";
import toastError from "../../errors/toastError";
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,
},
colorAdorment: {
width: 20,
height: 20,
},
}));
const PromptSchema = Yup.object().shape({
name: Yup.string().min(5, i18n.t("promptModal.formErrors.name.short")).max(100, i18n.t("promptModal.formErrors.name.long")).required(i18n.t("promptModal.formErrors.name.required")),
prompt: Yup.string().min(50, i18n.t("promptModal.formErrors.prompt.short")).required(i18n.t("promptModal.formErrors.prompt.required")),
model: Yup.string().required(i18n.t("promptModal.formErrors.modal.required")),
maxTokens: Yup.number().required(i18n.t("promptModal.formErrors.maxTokens.required")),
temperature: Yup.number().required(i18n.t("promptModal.formErrors.temperature.required")),
apiKey: Yup.string().required(i18n.t("promptModal.formErrors.apikey.required")),
queueId: Yup.number().required(i18n.t("promptModal.formErrors.queueId.required")),
maxMessages: Yup.number().required(i18n.t("promptModal.formErrors.maxMessages.required"))
});
const PromptModal = ({ open, onClose, promptId, refreshPrompts }) => {
const classes = useStyles();
const [selectedModel, setSelectedModel] = useState("gpt-3.5-turbo-1106");
const [showApiKey, setShowApiKey] = useState(false);
const handleToggleApiKey = () => {
setShowApiKey(!showApiKey);
};
const initialState = {
name: "",
prompt: "",
model: "gpt-3.5-turbo-1106",
maxTokens: 100,
temperature: 1,
apiKey: "",
queueId: '',
maxMessages: 10
};
const [prompt, setPrompt] = useState(initialState);
useEffect(() => {
const fetchPrompt = async () => {
if (!promptId) {
setPrompt(initialState);
return;
}
try {
const { data } = await api.get(`/prompt/${promptId}`);
setPrompt(prevState => {
return { ...prevState, ...data };
});
setSelectedModel(data.model);
} catch (err) {
toastError(err);
}
};
fetchPrompt();
}, [promptId, open]);
const handleClose = () => {
setPrompt(initialState);
setSelectedModel("gpt-3.5-turbo-1106");
onClose();
};
const handleChangeModel = (e) => {
setSelectedModel(e.target.value);
};
const handleSavePrompt = async values => {
const promptData = { ...values, model: selectedModel };
console.log(promptData);
if (!values.queueId) {
toastError(i18n.t("promptModal.setor"));
return;
}
try {
if (promptId) {
await api.put(`/prompt/${promptId}`, promptData);
} else {
await api.post("/prompt", promptData);
}
toast.success(i18n.t("promptModal.success"));
refreshPrompts( )
} catch (err) {
toastError(err);
}
handleClose();
};
return (
<div className={classes.root}>
<Dialog
open={open}
onClose={handleClose}
maxWidth="md"
scroll="paper"
fullWidth
>
<DialogTitle id="form-dialog-title">
{promptId
? `${i18n.t("promptModal.title.edit")}`
: `${i18n.t("promptModal.title.add")}`}
</DialogTitle>
<Formik
initialValues={prompt}
enableReinitialize={true}
validationSchema={PromptSchema}
onSubmit={(values, actions) => {
setTimeout(() => {
handleSavePrompt(values);
actions.setSubmitting(false);
}, 400);
}}
>
{({ touched, errors, isSubmitting, values }) => (
<Form style={{ width: "100%" }}>
<DialogContent dividers>
<Field
as={TextField}
label={i18n.t("promptModal.form.name")}
name="name"
error={touched.name && Boolean(errors.name)}
helperText={touched.name && errors.name}
variant="outlined"
margin="dense"
fullWidth
/>
<FormControl fullWidth margin="dense" variant="outlined">
<Field
as={TextField}
label={i18n.t("promptModal.form.apikey")}
name="apiKey"
type={showApiKey ? 'text' : 'password'}
error={touched.apiKey && Boolean(errors.apiKey)}
helperText={touched.apiKey && errors.apiKey}
variant="outlined"
margin="dense"
fullWidth
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={handleToggleApiKey}>
{showApiKey ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
</FormControl>
<Field
as={TextField}
label={i18n.t("promptModal.form.prompt")}
name="prompt"
error={touched.prompt && Boolean(errors.prompt)}
helperText={touched.prompt && errors.prompt}
variant="outlined"
margin="dense"
fullWidth
rows={10}
multiline={true}
/>
<QueueSelectSingle touched={touched} errors={errors}/>
<div className={classes.multFieldLine}>
<FormControl fullWidth margin="dense" variant="outlined">
<InputLabel>{i18n.t("promptModal.form.model")}</InputLabel>
<Select
id="type-select"
labelWidth={60}
name="model"
value={selectedModel}
onChange={handleChangeModel}
multiple={false}
>
<MenuItem key={"gpt-3.5"} value={"gpt-3.5-turbo-1106"}>
GPT 3.5 turbo
</MenuItem>
<MenuItem key={"gpt-4"} value={"gpt-4o-mini"}>
GPT 4.0
</MenuItem>
</Select>
</FormControl>
<Field
as={TextField}
label={i18n.t("promptModal.form.temperature")}
name="temperature"
error={touched.temperature && Boolean(errors.temperature)}
helperText={touched.temperature && errors.temperature}
variant="outlined"
margin="dense"
fullWidth
type="number"
inputProps={{
step: "0.1",
min: "0",
max: "1"
}}
/>
</div>
<div className={classes.multFieldLine}>
<Field
as={TextField}
label={i18n.t("promptModal.form.max_tokens")}
name="maxTokens"
error={touched.maxTokens && Boolean(errors.maxTokens)}
helperText={touched.maxTokens && errors.maxTokens}
variant="outlined"
margin="dense"
fullWidth
/>
<Field
as={TextField}
label={i18n.t("promptModal.form.max_messages")}
name="maxMessages"
error={touched.maxMessages && Boolean(errors.maxMessages)}
helperText={touched.maxMessages && errors.maxMessages}
variant="outlined"
margin="dense"
fullWidth
/>
</div>
</DialogContent>
<DialogActions>
<Button
onClick={handleClose}
color="secondary"
disabled={isSubmitting}
variant="outlined"
>
{i18n.t("promptModal.buttons.cancel")}
</Button>
<Button
type="submit"
color="primary"
disabled={isSubmitting}
variant="contained"
className={classes.btnWrapper}
>
{promptId
? `${i18n.t("promptModal.buttons.okEdit")}`
: `${i18n.t("promptModal.buttons.okAdd")}`}
{isSubmitting && (
<CircularProgress
size={24}
className={classes.buttonProgress}
/>
)}
</Button>
</DialogActions>
</Form>
)}
</Formik>
</Dialog>
</div>
);
};
export default PromptModal;

View File

@@ -0,0 +1,84 @@
import React, { useEffect, useState, useContext } from "react";
import QRCode from "qrcode.react";
import toastError from "../../errors/toastError";
import { Dialog, DialogContent, Paper, Typography, useTheme } from "@material-ui/core";
import { i18n } from "../../translate/i18n";
import api from "../../services/api";
import { SocketContext } from "../../context/Socket/SocketContext";
const QrcodeModal = ({ open, onClose, whatsAppId }) => {
const [qrCode, setQrCode] = useState("");
const theme = useTheme();
const socketManager = useContext(SocketContext);
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 companyId = localStorage.getItem("companyId");
const socket = socketManager.getSocket(companyId);
socket.on(`company-${companyId}-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, socketManager]);
return (
<Dialog open={open} onClose={onClose} maxWidth="lg" scroll="paper">
<DialogContent>
<Paper elevation={0} style={{ display: "flex", alignItems: "center" }}>
<div style={{ marginRight: "20px" }}>
<Typography variant="h2" component="h2" color="textPrimary" gutterBottom style={{ fontFamily: "Montserrat", fontWeight: "bold", fontSize:"20px",}}>
{i18n.t("qrCodeModal.title")}
</Typography>
<Typography variant="body1" color="textPrimary" gutterBottom>
{i18n.t("qrCodeModal.steps.one")}
</Typography>
<Typography variant="body1" color="textPrimary" gutterBottom>
{i18n.t("qrCodeModal.steps.two.partOne")} <svg class="MuiSvgIcon-root" focusable="false" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"></path></svg> {i18n.t("qrCodeModal.steps.two.partTwo")} <svg class="MuiSvgIcon-root" focusable="false" viewBox="0 0 24 24" aria-hidden="true"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"></path></svg> {i18n.t("qrCodeModal.steps.two.partThree")}
</Typography>
<Typography variant="body1" color="textPrimary" gutterBottom>
{i18n.t("qrCodeModal.steps.three")}
</Typography>
<Typography variant="body1" color="textPrimary" gutterBottom>
{i18n.t("qrCodeModal.steps.four")}
</Typography>
</div>
<div>
{qrCode ? (
<QRCode value={qrCode} size={256} />
) : (
<span>{i18n.t("qrCodeModal.waiting")}</span>
)}
</div>
</Paper>
</DialogContent>
</Dialog>
);
};
export default React.memo(QrcodeModal);

View File

@@ -0,0 +1,505 @@
import React, { useState, useEffect } 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,
Grid,
Paper,
} from "@material-ui/core";
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";
const useStyles = makeStyles((theme) => ({
root: {
display: "flex",
flexWrap: "wrap",
gap: 4
},
textField: {
marginRight: theme.spacing(1),
flex: 1,
},
btnWrapper: {
position: "relative",
},
buttonProgress: {
color: green[500],
position: "absolute",
top: "50%",
left: "50%",
marginTop: -12,
marginLeft: -12,
},
btnLeft: {
display: "flex",
marginRight: "auto",
marginLeft: 12,
},
colorAdorment: {
width: 20,
height: 20,
},
}));
const DialogflowSchema = Yup.object().shape({
name: Yup.string()
.min(2, "Too Short!")
.max(50, "Too Long!")
.required("Required"),
// projectName: Yup.string()
// .min(3, "Too Short!")
// .max(100, "Too Long!")
// .required(),
// jsonContent: Yup.string().min(3, "Too Short!").required(),
// language: Yup.string().min(2, "Too Short!").max(50, "Too Long!").required(),
});
const QueueIntegration = ({ open, onClose, integrationId }) => {
const classes = useStyles();
const initialState = {
type: "typebot",
name: "",
projectName: "",
jsonContent: "",
language: "",
urlN8N: "",
typebotDelayMessage: 1000,
typebotExpires: 1,
typebotKeywordFinish: "",
typebotKeywordRestart: "",
typebotRestartMessage: "",
typebotSlug: "",
typebotUnknownMessage: "",
};
const [integration, setIntegration] = useState(initialState);
useEffect(() => {
(async () => {
if (!integrationId) return;
try {
const { data } = await api.get(`/queueIntegration/${integrationId}`);
setIntegration((prevState) => {
return { ...prevState, ...data };
});
} catch (err) {
toastError(err);
}
})();
return () => {
setIntegration({
type: "dialogflow",
name: "",
projectName: "",
jsonContent: "",
language: "",
urlN8N: "",
typebotDelayMessage: 1000
});
};
}, [integrationId, open]);
const handleClose = () => {
onClose();
setIntegration(initialState);
};
const handleTestSession = async (event, values) => {
try {
const { projectName, jsonContent, language } = values;
await api.post(`/queueIntegration/testSession`, {
projectName,
jsonContent,
language,
});
toast.success(i18n.t("queueIntegrationModal.messages.testSuccess"));
} catch (err) {
toastError(err);
}
};
const handleSaveDialogflow = async (values) => {
try {
if (values.type === 'n8n' || values.type === 'webhook' || values.type === 'typebot') values.projectName = values.name
if (integrationId) {
await api.put(`/queueIntegration/${integrationId}`, values);
toast.success(i18n.t("queueIntegrationModal.messages.editSuccess"));
} else {
await api.post("/queueIntegration", values);
toast.success(i18n.t("queueIntegrationModal.messages.addSuccess"));
}
handleClose();
} catch (err) {
toastError(err);
}
};
return (
<div className={classes.root}>
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="md" scroll="paper">
<DialogTitle>
{integrationId
? `${i18n.t("queueIntegrationModal.title.edit")}`
: `${i18n.t("queueIntegrationModal.title.add")}`}
</DialogTitle>
<Formik
initialValues={integration}
enableReinitialize={true}
validationSchema={DialogflowSchema}
onSubmit={(values, actions, event) => {
setTimeout(() => {
handleSaveDialogflow(values);
actions.setSubmitting(false);
}, 400);
}}
>
{({ touched, errors, isSubmitting, values }) => (
<Form>
<Paper square className={classes.mainPaper} elevation={1}>
<DialogContent dividers>
<Grid container spacing={1}>
<Grid item xs={12} md={6} xl={6}>
<FormControl
variant="outlined"
className={classes.formControl}
margin="dense"
fullWidth
>
<InputLabel id="type-selection-input-label">
{i18n.t("queueIntegrationModal.form.type")}
</InputLabel>
<Field
as={Select}
label={i18n.t("queueIntegrationModal.form.type")}
name="type"
labelId="profile-selection-label"
error={touched.type && Boolean(errors.type)}
helpertext={touched.type && errors.type}
id="type"
required
>
<MenuItem value="dialogflow">DialogFlow</MenuItem>
<MenuItem value="n8n">N8N</MenuItem>
<MenuItem value="webhook">WebHooks</MenuItem>
<MenuItem value="typebot">Typebot</MenuItem>
</Field>
</FormControl>
</Grid>
{values.type === "dialogflow" && (
<>
<Grid item xs={12} md={6} xl={6} >
<Field
as={TextField}
label={i18n.t("queueIntegrationModal.form.name")}
autoFocus
name="name"
fullWidth
error={touched.name && Boolean(errors.name)}
helpertext={touched.name && errors.name}
variant="outlined"
margin="dense"
className={classes.textField}
/>
</Grid>
<Grid item xs={12} md={6} xl={6} >
<FormControl
variant="outlined"
className={classes.formControl}
margin="dense"
fullWidth
>
<InputLabel id="language-selection-input-label">
{i18n.t("queueIntegrationModal.form.language")}
</InputLabel>
<Field
as={Select}
label={i18n.t("queueIntegrationModal.form.language")}
name="language"
labelId="profile-selection-label"
fullWidth
error={touched.language && Boolean(errors.language)}
helpertext={touched.language && errors.language}
id="language-selection"
required
>
<MenuItem value="pt-BR">Portugues</MenuItem>
<MenuItem value="en">Inglês</MenuItem>
<MenuItem value="es">Español</MenuItem>
</Field>
</FormControl>
</Grid>
<Grid item xs={12} md={6} xl={6} >
<Field
as={TextField}
label={i18n.t("queueIntegrationModal.form.projectName")}
name="projectName"
error={touched.projectName && Boolean(errors.projectName)}
helpertext={touched.projectName && errors.projectName}
fullWidth
variant="outlined"
margin="dense"
/>
</Grid>
<Grid item xs={12} md={12} xl={12} >
<Field
as={TextField}
label={i18n.t("queueIntegrationModal.form.jsonContent")}
type="jsonContent"
multiline
//inputRef={greetingRef}
maxRows={5}
minRows={5}
fullWidth
name="jsonContent"
error={touched.jsonContent && Boolean(errors.jsonContent)}
helpertext={touched.jsonContent && errors.jsonContent}
variant="outlined"
margin="dense"
/>
</Grid>
</>
)}
{(values.type === "n8n" || values.type === "webhook") && (
<>
<Grid item xs={12} md={6} xl={6} >
<Field
as={TextField}
label={i18n.t("queueIntegrationModal.form.name")}
autoFocus
required
name="name"
error={touched.name && Boolean(errors.name)}
helpertext={touched.name && errors.name}
variant="outlined"
margin="dense"
fullWidth
className={classes.textField}
/>
</Grid>
<Grid item xs={12} md={12} xl={12} >
<Field
as={TextField}
label={i18n.t("queueIntegrationModal.form.urlN8N")}
name="urlN8N"
error={touched.urlN8N && Boolean(errors.urlN8N)}
helpertext={touched.urlN8N && errors.urlN8N}
variant="outlined"
margin="dense"
required
fullWidth
className={classes.textField}
/>
</Grid>
</>
)}
{(values.type === "typebot") && (
<>
<Grid item xs={12} md={6} xl={6} >
<Field
as={TextField}
label={i18n.t("queueIntegrationModal.form.name")}
autoFocus
name="name"
error={touched.name && Boolean(errors.name)}
helpertext={touched.name && errors.name}
variant="outlined"
margin="dense"
required
fullWidth
className={classes.textField}
/>
</Grid>
<Grid item xs={12} md={12} xl={12} >
<Field
as={TextField}
label={i18n.t("queueIntegrationModal.form.urlN8N")}
name="urlN8N"
error={touched.urlN8N && Boolean(errors.urlN8N)}
helpertext={touched.urlN8N && errors.urlN8N}
variant="outlined"
margin="dense"
required
fullWidth
className={classes.textField}
/>
</Grid>
<Grid item xs={12} md={6} xl={6} >
<Field
as={TextField}
label={i18n.t("queueIntegrationModal.form.typebotSlug")}
name="typebotSlug"
error={touched.typebotSlug && Boolean(errors.typebotSlug)}
helpertext={touched.typebotSlug && errors.typebotSlug}
required
variant="outlined"
margin="dense"
fullWidth
className={classes.textField}
/>
</Grid>
<Grid item xs={12} md={6} xl={6} >
<Field
as={TextField}
label={i18n.t("queueIntegrationModal.form.typebotExpires")}
name="typebotExpires"
error={touched.typebotExpires && Boolean(errors.typebotExpires)}
helpertext={touched.typebotExpires && errors.typebotExpires}
variant="outlined"
margin="dense"
fullWidth
className={classes.textField}
/>
</Grid>
<Grid item xs={12} md={6} xl={6} >
<Field
as={TextField}
label={i18n.t("queueIntegrationModal.form.typebotDelayMessage")}
name="typebotDelayMessage"
error={touched.typebotDelayMessage && Boolean(errors.typebotDelayMessage)}
helpertext={touched.typebotDelayMessage && errors.typebotDelayMessage}
variant="outlined"
margin="dense"
fullWidth
className={classes.textField}
/>
</Grid>
<Grid item xs={12} md={6} xl={6} >
<Field
as={TextField}
label={i18n.t("queueIntegrationModal.form.typebotKeywordFinish")}
name="typebotKeywordFinish"
error={touched.typebotKeywordFinish && Boolean(errors.typebotKeywordFinish)}
helpertext={touched.typebotKeywordFinish && errors.typebotKeywordFinish}
variant="outlined"
margin="dense"
fullWidth
className={classes.textField}
/>
</Grid>
<Grid item xs={12} md={6} xl={6} >
<Field
as={TextField}
label={i18n.t("queueIntegrationModal.form.typebotKeywordRestart")}
name="typebotKeywordRestart"
error={touched.typebotKeywordRestart && Boolean(errors.typebotKeywordRestart)}
helpertext={touched.typebotKeywordRestart && errors.typebotKeywordRestart}
variant="outlined"
margin="dense"
fullWidth
className={classes.textField}
/>
</Grid>
<Grid item xs={12} md={6} xl={6} >
<Field
as={TextField}
label={i18n.t("queueIntegrationModal.form.typebotUnknownMessage")}
name="typebotUnknownMessage"
error={touched.typebotUnknownMessage && Boolean(errors.typebotUnknownMessage)}
helpertext={touched.typebotUnknownMessage && errors.typebotUnknownMessage}
variant="outlined"
margin="dense"
fullWidth
className={classes.textField}
/>
</Grid>
<Grid item xs={12} md={12} xl={12} >
<Field
as={TextField}
label={i18n.t("queueIntegrationModal.form.typebotRestartMessage")}
name="typebotRestartMessage"
error={touched.typebotRestartMessage && Boolean(errors.typebotRestartMessage)}
helpertext={touched.typebotRestartMessage && errors.typebotRestartMessage}
variant="outlined"
margin="dense"
fullWidth
className={classes.textField}
/>
</Grid>
</>
)}
</Grid>
</DialogContent>
</Paper>
<DialogActions>
{values.type === "dialogflow" && (
<Button
//type="submit"
onClick={(e) => handleTestSession(e, values)}
color="inherit"
disabled={isSubmitting}
name="testSession"
variant="outlined"
className={classes.btnLeft}
>
{i18n.t("queueIntegrationModal.buttons.test")}
</Button>
)}
<Button
onClick={handleClose}
color="secondary"
disabled={isSubmitting}
variant="outlined"
>
{i18n.t("queueIntegrationModal.buttons.cancel")}
</Button>
<Button
type="submit"
color="primary"
disabled={isSubmitting}
variant="contained"
className={classes.btnWrapper}
>
{integrationId
? `${i18n.t("queueIntegrationModal.buttons.okEdit")}`
: `${i18n.t("queueIntegrationModal.buttons.okAdd")}`}
{isSubmitting && (
<CircularProgress
size={24}
className={classes.buttonProgress}
/>
)}
</Button>
</DialogActions>
</Form>
)}
</Formik>
</Dialog>
</div >
);
};
export default QueueIntegration;

View File

@@ -0,0 +1,510 @@
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 {
FormControl,
Grid,
IconButton,
InputAdornment,
InputLabel,
MenuItem,
Paper,
Select,
Tab,
Tabs,
} from "@material-ui/core";
import { Colorize } from "@material-ui/icons";
import { QueueOptions } from "../QueueOptions";
import SchedulesForm from "../SchedulesForm";
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, i18n.t("queueModal.form.nameShort"))
.max(50, i18n.t("queueModal.form.nameLong"))
.required(i18n.t("queueModal.form.nameRequired")),
color: Yup.string()
.min(3, i18n.t("queueModal.form.colorShort"))
.max(9, i18n.t("queueModal.form.colorLong"))
.required(),
greetingMessage: Yup.string(),
});
const QueueModal = ({ open, onClose, queueId }) => {
const classes = useStyles();
const initialState = {
name: "",
color: "",
greetingMessage: "",
outOfHoursMessage: "",
orderQueue: "",
integrationId: "",
promptId: "",
};
const [colorPickerModalOpen, setColorPickerModalOpen] = useState(false);
const [queue, setQueue] = useState(initialState);
const [tab, setTab] = useState(0);
const [schedulesEnabled, setSchedulesEnabled] = useState(false);
const greetingRef = useRef();
const [integrations, setIntegrations] = useState([]);
const [schedules, setSchedules] = useState([
{
weekday: "Segunda-feira",
weekdayEn: "monday",
startTime: "08:00",
endTime: "18:00",
},
{
weekday: "Terça-feira",
weekdayEn: "tuesday",
startTime: "08:00",
endTime: "18:00",
},
{
weekday: "Quarta-feira",
weekdayEn: "wednesday",
startTime: "08:00",
endTime: "18:00",
},
{
weekday: "Quinta-feira",
weekdayEn: "thursday",
startTime: "08:00",
endTime: "18:00",
},
{
weekday: "Sexta-feira",
weekdayEn: "friday",
startTime: "08:00",
endTime: "18:00",
},
{
weekday: "Sábado",
weekdayEn: "saturday",
startTime: "08:00",
endTime: "12:00",
},
{
weekday: "Domingo",
weekdayEn: "sunday",
startTime: "00:00",
endTime: "00:00",
},
]);
const [selectedPrompt, setSelectedPrompt] = useState(null);
const [prompts, setPrompts] = useState([]);
useEffect(() => {
(async () => {
try {
const { data } = await api.get("/prompt");
setPrompts(data.prompts);
} catch (err) {
toastError(err);
}
})();
}, []);
useEffect(() => {
api.get(`/settings`).then(({ data }) => {
if (Array.isArray(data)) {
const scheduleType = data.find((d) => d.key === "scheduleType");
if (scheduleType) {
setSchedulesEnabled(scheduleType.value === "queue");
}
}
});
}, []);
useEffect(() => {
(async () => {
try {
const { data } = await api.get("/queueIntegration");
setIntegrations(data.queueIntegrations);
} catch (err) {
toastError(err);
}
})();
}, []);
useEffect(() => {
(async () => {
if (!queueId) return;
try {
const { data } = await api.get(`/queue/${queueId}`);
setQueue((prevState) => {
return { ...prevState, ...data };
});
data.promptId
? setSelectedPrompt(data.promptId)
: setSelectedPrompt(null);
setSchedules(data.schedules);
} catch (err) {
toastError(err);
}
})();
return () => {
setQueue({
name: "",
color: "",
greetingMessage: "",
outOfHoursMessage: "",
orderQueue: "",
integrationId: "",
});
};
}, [queueId, open]);
const handleClose = () => {
onClose();
setQueue(initialState);
};
const handleSaveQueue = async (values) => {
try {
if (queueId) {
await api.put(`/queue/${queueId}`, {
...values,
schedules,
promptId: selectedPrompt ? selectedPrompt : null,
});
} else {
await api.post("/queue", {
...values,
schedules,
promptId: selectedPrompt ? selectedPrompt : null,
});
}
toast.success(i18n.t("queueModal.toasts.success"));
handleClose();
} catch (err) {
toastError(err);
}
};
const handleSaveSchedules = async (values) => {
toast.success(i18n.t("queueModal.toasts.info"));
setSchedules(values);
setTab(0);
};
const handleChangePrompt = (e) => {
setSelectedPrompt(e.target.value);
};
return (
<div className={classes.root}>
<Dialog
maxWidth="md"
fullWidth={true}
open={open}
onClose={handleClose}
scroll="paper"
>
<DialogTitle>
{queueId
? `${i18n.t("queueModal.title.edit")}`
: `${i18n.t("queueModal.title.add")}`}
</DialogTitle>
<Tabs
value={tab}
indicatorColor="primary"
textColor="primary"
onChange={(_, v) => setTab(v)}
aria-label="disabled tabs example"
>
<Tab label={i18n.t("queueModal.tabs.queueData")} />
{schedulesEnabled && <Tab label={i18n.t("queueModal.tabs.attendanceTime")} />}
</Tabs>
{tab === 0 && (
<Paper>
<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"
className={classes.textField}
/>
<ColorPicker
open={colorPickerModalOpen}
handleClose={() => setColorPickerModalOpen(false)}
onChange={(color) => {
values.color = color;
setQueue(() => {
return { ...values, color };
});
}}
/>
<Field
as={TextField}
label={i18n.t("queueModal.form.orderQueue")}
name="orderQueue"
type="orderQueue"
error={touched.orderQueue && Boolean(errors.orderQueue)}
helperText={touched.orderQueue && errors.orderQueue}
variant="outlined"
margin="dense"
className={classes.textField1}
/>
<div>
<FormControl
variant="outlined"
margin="dense"
className={classes.FormControl}
fullWidth
>
<InputLabel id="integrationId-selection-label">
{i18n.t("queueModal.form.integrationId")}
</InputLabel>
<Field
as={Select}
label={i18n.t("queueModal.form.integrationId")}
name="integrationId"
id="integrationId"
placeholder={i18n.t("queueModal.form.integrationId")}
labelId="integrationId-selection-label"
value={values.integrationId || ""}
>
<MenuItem value={""}>{"Nenhum"}</MenuItem>
{integrations.map((integration) => (
<MenuItem
key={integration.id}
value={integration.id}
>
{integration.name}
</MenuItem>
))}
</Field>
</FormControl>
<FormControl margin="dense" variant="outlined" fullWidth>
<InputLabel>
{i18n.t("whatsappModal.form.prompt")}
</InputLabel>
<Select
labelId="dialog-select-prompt-label"
id="dialog-select-prompt"
name="promptId"
value={selectedPrompt || ""}
onChange={handleChangePrompt}
label={i18n.t("whatsappModal.form.prompt")}
fullWidth
MenuProps={{
anchorOrigin: {
vertical: "bottom",
horizontal: "left",
},
transformOrigin: {
vertical: "top",
horizontal: "left",
},
getContentAnchorEl: null,
}}
>
{prompts.map((prompt) => (
<MenuItem key={prompt.id} value={prompt.id}>
{prompt.name}
</MenuItem>
))}
</Select>
</FormControl>
</div>
<div style={{ marginTop: 5 }}>
<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"
/>
{schedulesEnabled && (
<Field
as={TextField}
label={i18n.t("queueModal.form.outOfHoursMessage")}
type="outOfHoursMessage"
multiline
inputRef={greetingRef}
rows={5}
fullWidth
name="outOfHoursMessage"
error={
touched.outOfHoursMessage &&
Boolean(errors.outOfHoursMessage)
}
helperText={
touched.outOfHoursMessage &&
errors.outOfHoursMessage
}
variant="outlined"
margin="dense"
/>
)}
</div>
<QueueOptions queueId={queueId} />
</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>
</Paper>
)}
{tab === 1 && (
<Paper style={{ padding: 20 }}>
<SchedulesForm
loading={false}
onSubmit={handleSaveSchedules}
initialValues={schedules}
labelSaveButton={i18n.t("queueModal.buttons.okAdd")}
/>
</Paper>
)}
</Dialog>
</div>
);
};
export default QueueModal;

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