carga
2
frontend/.env.exemple
Normal 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
91
frontend/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/android-chrome-192x192.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
frontend/public/apple-touch-icon.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
frontend/public/favicon-16x16.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
frontend/public/favicon-32x32.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
frontend/public/import-contatos.xlsx
Normal file
28
frontend/public/index.html
Normal 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>
|
||||
20
frontend/public/manifest.json
Normal 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"
|
||||
}
|
||||
BIN
frontend/public/mstile-150x150.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
119
frontend/src/App.js
Normal 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;
|
||||
BIN
frontend/src/assets/chat_notify.mp3
Normal file
BIN
frontend/src/assets/dialogflow.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
frontend/src/assets/logo.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
frontend/src/assets/n8n.png
Normal file
|
After Width: | Height: | Size: 246 KiB |
BIN
frontend/src/assets/planilha.xlsx
Normal file
BIN
frontend/src/assets/sound.mp3
Normal file
BIN
frontend/src/assets/sound.ogg
Normal file
BIN
frontend/src/assets/typebot.jpg
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
frontend/src/assets/wa-background-dark.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
frontend/src/assets/wa-background.png
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
frontend/src/assets/webhook.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
338
frontend/src/components/AnnouncementModal/index.js
Normal 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;
|
||||
329
frontend/src/components/AnnouncementsPopover/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/BackdropLoading/index.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
|
||||
import Backdrop from "@material-ui/core/Backdrop";
|
||||
import CircularProgress from "@material-ui/core/CircularProgress";
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
backdrop: {
|
||||
zIndex: theme.zIndex.drawer + 1,
|
||||
color: "#fff",
|
||||
},
|
||||
}));
|
||||
|
||||
const BackdropLoading = () => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Backdrop className={classes.backdrop} open={true}>
|
||||
<CircularProgress color="inherit" />
|
||||
</Backdrop>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackdropLoading;
|
||||
35
frontend/src/components/ButtonWithSpinner/index.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import { green } from "@material-ui/core/colors";
|
||||
import { CircularProgress, Button } from "@material-ui/core";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
button: {
|
||||
position: "relative",
|
||||
},
|
||||
|
||||
buttonProgress: {
|
||||
color: green[500],
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
marginTop: -12,
|
||||
marginLeft: -12,
|
||||
},
|
||||
}));
|
||||
|
||||
const ButtonWithSpinner = ({ loading, children, ...rest }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Button className={classes.button} disabled={loading} {...rest}>
|
||||
{children}
|
||||
{loading && (
|
||||
<CircularProgress size={24} className={classes.buttonProgress} />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ButtonWithSpinner;
|
||||
634
frontend/src/components/CampaignModal/index.js
Normal 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;
|
||||
39
frontend/src/components/Can/index.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import rules from "../../rules";
|
||||
|
||||
const check = (role, action, data) => {
|
||||
const permissions = rules[role];
|
||||
if (!permissions) {
|
||||
// role is not present in the rules
|
||||
return false;
|
||||
}
|
||||
|
||||
const staticPermissions = permissions.static;
|
||||
|
||||
if (staticPermissions && staticPermissions.includes(action)) {
|
||||
// static rule not provided for action
|
||||
return true;
|
||||
}
|
||||
|
||||
const dynamicPermissions = permissions.dynamic;
|
||||
|
||||
if (dynamicPermissions) {
|
||||
const permissionCondition = dynamicPermissions[action];
|
||||
if (!permissionCondition) {
|
||||
// dynamic rule not provided for action
|
||||
return false;
|
||||
}
|
||||
|
||||
return permissionCondition(data);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const Can = ({ role, perform, data, yes, no }) =>
|
||||
check(role, perform, data) ? yes() : no();
|
||||
|
||||
Can.defaultProps = {
|
||||
yes: () => null,
|
||||
no: () => null,
|
||||
};
|
||||
|
||||
export { Can };
|
||||
175
frontend/src/components/CheckoutPage/CheckoutPage.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
import CheckoutSuccess from './CheckoutSuccess';
|
||||
export default CheckoutSuccess;
|
||||
117
frontend/src/components/CheckoutPage/CheckoutSuccess/style.js
Normal 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;
|
||||
`;
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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]: ''
|
||||
};
|
||||
@@ -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}`)
|
||||
}),
|
||||
|
||||
];
|
||||
134
frontend/src/components/CheckoutPage/Forms/AddressForm.js
Normal 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>
|
||||
);
|
||||
}
|
||||
238
frontend/src/components/CheckoutPage/Forms/PaymentForm.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
import ReviewOrder from './ReviewOrder';
|
||||
export default ReviewOrder;
|
||||
12
frontend/src/components/CheckoutPage/ReviewOrder/styles.js
Normal 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)
|
||||
}
|
||||
}));
|
||||
2
frontend/src/components/CheckoutPage/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import CheckoutPage from './CheckoutPage';
|
||||
export default CheckoutPage;
|
||||
23
frontend/src/components/CheckoutPage/styles.js
Normal 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%'
|
||||
}
|
||||
}));
|
||||
85
frontend/src/components/ColorPicker/index.js
Normal 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;
|
||||
634
frontend/src/components/CompaniesManager/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
45
frontend/src/components/ConfirmationModal/index.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import Button from "@material-ui/core/Button";
|
||||
import Dialog from "@material-ui/core/Dialog";
|
||||
import DialogActions from "@material-ui/core/DialogActions";
|
||||
import DialogContent from "@material-ui/core/DialogContent";
|
||||
import DialogTitle from "@material-ui/core/DialogTitle";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
|
||||
const ConfirmationModal = ({ title, children, open, onClose, onConfirm }) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => onClose(false)}
|
||||
aria-labelledby="confirm-dialog"
|
||||
>
|
||||
<DialogTitle id="confirm-dialog">{title}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography>{children}</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => onClose(false)}
|
||||
color="default"
|
||||
>
|
||||
{i18n.t("confirmationModal.buttons.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
onClose(false);
|
||||
onConfirm();
|
||||
}}
|
||||
color="secondary"
|
||||
>
|
||||
{i18n.t("confirmationModal.buttons.confirm")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmationModal;
|
||||
199
frontend/src/components/ContactDrawer/index.js
Normal 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;
|
||||
50
frontend/src/components/ContactDrawerSkeleton/index.js
Normal 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;
|
||||
187
frontend/src/components/ContactForm/index.js
Normal 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>
|
||||
)
|
||||
}
|
||||
181
frontend/src/components/ContactListDialog/index.js
Normal 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;
|
||||
242
frontend/src/components/ContactListItemModal/index.js
Normal 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;
|
||||
103
frontend/src/components/ContactListTable/index.js
Normal 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;
|
||||
300
frontend/src/components/ContactModal/index.js
Normal 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;
|
||||
204
frontend/src/components/ContactNotes/index.js
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
206
frontend/src/components/ContactNotesDialog/index.js
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
64
frontend/src/components/ContactNotesDialogListItem/index.js
Normal 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
|
||||
}
|
||||
26
frontend/src/components/ContactTag/index.js
Normal 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;
|
||||
50
frontend/src/components/CurrencyInput/index.js
Normal 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
|
||||
70
frontend/src/components/DarkMode/index.js
Normal 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;
|
||||
53
frontend/src/components/Dashboard/CardCounter.js
Normal 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} />
|
||||
)
|
||||
|
||||
}
|
||||
89
frontend/src/components/Dashboard/TableAttendantsStatus.js
Normal 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} />
|
||||
)
|
||||
}
|
||||
34
frontend/src/components/Dialog/index.js
Normal 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;
|
||||
350
frontend/src/components/FileModal/index.js
Normal 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;
|
||||
38
frontend/src/components/FormFields/CheckboxField.js
Normal 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>
|
||||
);
|
||||
}
|
||||
54
frontend/src/components/FormFields/DatePickerField.js
Normal 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>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/FormFields/InputField.js
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
48
frontend/src/components/FormFields/SelectField.js
Normal 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;
|
||||
5
frontend/src/components/FormFields/index.js
Normal 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 };
|
||||
291
frontend/src/components/HelpsManager/index.js
Normal 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>
|
||||
)
|
||||
}
|
||||
217
frontend/src/components/ImportContactsModal/index.js
Normal 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;
|
||||
45
frontend/src/components/LanguageControl/index.js
Normal 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;
|
||||
53
frontend/src/components/LocationPreview/index.js
Normal 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;
|
||||
31
frontend/src/components/MainContainer/index.js
Normal 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;
|
||||
19
frontend/src/components/MainHeader/index.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
contactsHeader: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "0px 6px 6px 6px",
|
||||
},
|
||||
}));
|
||||
|
||||
const MainHeader = ({ children }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return <div className={classes.contactsHeader}>{children}</div>;
|
||||
};
|
||||
|
||||
export default MainHeader;
|
||||
21
frontend/src/components/MainHeaderButtonsWrapper/index.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
MainHeaderButtonsWrapper: {
|
||||
flex: "none",
|
||||
marginLeft: "auto",
|
||||
"& > *": {
|
||||
margin: theme.spacing(1),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const MainHeaderButtonsWrapper = ({ children }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return <div className={classes.MainHeaderButtonsWrapper}>{children}</div>;
|
||||
};
|
||||
|
||||
export default MainHeaderButtonsWrapper;
|
||||
186
frontend/src/components/MarkdownWrapper/index.js
Normal 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;
|
||||
48
frontend/src/components/MessageInput/RecordingTimer.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
timerBox: {
|
||||
display: "flex",
|
||||
marginLeft: 10,
|
||||
marginRight: 10,
|
||||
alignItems: "center",
|
||||
},
|
||||
}));
|
||||
|
||||
const RecordingTimer = () => {
|
||||
const classes = useStyles();
|
||||
const initialState = {
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
};
|
||||
const [timer, setTimer] = useState(initialState);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(
|
||||
() =>
|
||||
setTimer(prevState => {
|
||||
if (prevState.seconds === 59) {
|
||||
return { ...prevState, minutes: prevState.minutes + 1, seconds: 0 };
|
||||
}
|
||||
return { ...prevState, seconds: prevState.seconds + 1 };
|
||||
}),
|
||||
1000
|
||||
);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const addZero = n => {
|
||||
return n < 10 ? "0" + n : n;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.timerBox}>
|
||||
<span>{`${addZero(timer.minutes)}:${addZero(timer.seconds)}`}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecordingTimer;
|
||||
513
frontend/src/components/MessageInput/index.js
Normal 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;
|
||||
48
frontend/src/components/MessageInputCustom/RecordingTimer.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
timerBox: {
|
||||
display: "flex",
|
||||
marginLeft: 10,
|
||||
marginRight: 10,
|
||||
alignItems: "center",
|
||||
},
|
||||
}));
|
||||
|
||||
const RecordingTimer = () => {
|
||||
const classes = useStyles();
|
||||
const initialState = {
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
};
|
||||
const [timer, setTimer] = useState(initialState);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(
|
||||
() =>
|
||||
setTimer(prevState => {
|
||||
if (prevState.seconds === 59) {
|
||||
return { ...prevState, minutes: prevState.minutes + 1, seconds: 0 };
|
||||
}
|
||||
return { ...prevState, seconds: prevState.seconds + 1 };
|
||||
}),
|
||||
1000
|
||||
);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const addZero = n => {
|
||||
return n < 10 ? "0" + n : n;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.timerBox}>
|
||||
<span>{`${addZero(timer.minutes)}:${addZero(timer.seconds)}`}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecordingTimer;
|
||||
773
frontend/src/components/MessageInputCustom/index.js
Normal 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);
|
||||
71
frontend/src/components/MessageOptionsMenu/index.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useState, useContext } from "react";
|
||||
|
||||
import MenuItem from "@material-ui/core/MenuItem";
|
||||
|
||||
import { i18n } from "../../translate/i18n";
|
||||
import api from "../../services/api";
|
||||
import ConfirmationModal from "../ConfirmationModal";
|
||||
import { Menu } from "@material-ui/core";
|
||||
import { ReplyMessageContext } from "../../context/ReplyingMessage/ReplyingMessageContext";
|
||||
import toastError from "../../errors/toastError";
|
||||
|
||||
const MessageOptionsMenu = ({ message, menuOpen, handleClose, anchorEl }) => {
|
||||
const { setReplyingMessage } = useContext(ReplyMessageContext);
|
||||
const [confirmationOpen, setConfirmationOpen] = useState(false);
|
||||
|
||||
const handleDeleteMessage = async () => {
|
||||
try {
|
||||
await api.delete(`/messages/${message.id}`);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const hanldeReplyMessage = () => {
|
||||
setReplyingMessage(message);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleOpenConfirmationModal = e => {
|
||||
setConfirmationOpen(true);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmationModal
|
||||
title={i18n.t("messageOptionsMenu.confirmationModal.title")}
|
||||
open={confirmationOpen}
|
||||
onClose={setConfirmationOpen}
|
||||
onConfirm={handleDeleteMessage}
|
||||
>
|
||||
{i18n.t("messageOptionsMenu.confirmationModal.message")}
|
||||
</ConfirmationModal>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
getContentAnchorEl={null}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "right",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
open={menuOpen}
|
||||
onClose={handleClose}
|
||||
>
|
||||
{message.fromMe && (
|
||||
<MenuItem onClick={handleOpenConfirmationModal}>
|
||||
{i18n.t("messageOptionsMenu.delete")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={hanldeReplyMessage}>
|
||||
{i18n.t("messageOptionsMenu.reply")}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageOptionsMenu;
|
||||
66
frontend/src/components/MessageVariablesPicker/index.js
Normal 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;
|
||||
837
frontend/src/components/MessagesList/index.js
Normal 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")}
|
||||
<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;
|
||||
50
frontend/src/components/ModalImageCors/index.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
|
||||
import ModalImage from "react-modal-image";
|
||||
import api from "../../services/api";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
messageMedia: {
|
||||
objectFit: "cover",
|
||||
width: 250,
|
||||
height: 200,
|
||||
borderTopLeftRadius: 8,
|
||||
borderTopRightRadius: 8,
|
||||
borderBottomLeftRadius: 8,
|
||||
borderBottomRightRadius: 8,
|
||||
},
|
||||
}));
|
||||
|
||||
const ModalImageCors = ({ imageUrl }) => {
|
||||
const classes = useStyles();
|
||||
const [fetching, setFetching] = useState(true);
|
||||
const [blobUrl, setBlobUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageUrl) return;
|
||||
const fetchImage = async () => {
|
||||
const { data, headers } = await api.get(imageUrl, {
|
||||
responseType: "blob",
|
||||
});
|
||||
const url = window.URL.createObjectURL(
|
||||
new Blob([data], { type: headers["content-type"] })
|
||||
);
|
||||
setBlobUrl(url);
|
||||
setFetching(false);
|
||||
};
|
||||
fetchImage();
|
||||
}, [imageUrl]);
|
||||
|
||||
return (
|
||||
<ModalImage
|
||||
className={classes.messageMedia}
|
||||
smallSrcSet={fetching ? imageUrl : blobUrl}
|
||||
medium={fetching ? imageUrl : blobUrl}
|
||||
large={fetching ? imageUrl : blobUrl}
|
||||
alt="image"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalImageCors;
|
||||
263
frontend/src/components/ModalUsers/index.js
Normal 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;
|
||||
447
frontend/src/components/NewTicketModal/index.js
Normal 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} <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;
|
||||
270
frontend/src/components/NotificationsPopOver/index.js
Normal 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;
|
||||
275
frontend/src/components/NotificationsPopOver/index_Antigo.js
Normal 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;
|
||||
110
frontend/src/components/NotificationsVolume/index.js
Normal 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;
|
||||
9
frontend/src/components/OnlyForSuperUser/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const OnlyForSuperUser = ({ user, yes, no }) => user.super ? yes() : no();
|
||||
|
||||
OnlyForSuperUser.defaultProps = {
|
||||
user: {},
|
||||
yes: () => null,
|
||||
no: () => null,
|
||||
};
|
||||
|
||||
export default OnlyForSuperUser;
|
||||
30
frontend/src/components/OutlinedDiv/index.js
Normal 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;
|
||||
561
frontend/src/components/PlansManager/index.js
Normal 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>
|
||||
)
|
||||
}
|
||||
312
frontend/src/components/PromptModal/index.js
Normal 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;
|
||||
84
frontend/src/components/QrcodeModal/index.js
Normal 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);
|
||||
505
frontend/src/components/QueueIntegrationModal/index.js
Normal 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;
|
||||
510
frontend/src/components/QueueModal/index.js
Normal 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;
|
||||