feat: add sound notifications and popOver for new tickets in waiting status, with option to enable in settings
Details: - Implemented functionality to provide sound notifications and popOver alerts for new tickets in waiting status. Users can enable this feature in the settings. feat: add average waiting time in the Waiting tab on the Tickets screen, with option to enable in settings Details: - Added functionality to display the average waiting time in the Waiting tab on the Tickets screen. Users can enable this feature in the settings. feat: add ticket link in simple export directly from the reports table Details: - Implemented functionality to include the ticket link in the simple export directly from the reports table. feat: remove report export options for supervisor profile Details: - Removed the report export options for the supervisor profile to restrict access.feat-scaling-ticket-remote-creation
parent
8447628fbf
commit
860d462d37
|
@ -8,6 +8,7 @@
|
|||
"watch": "tsc -w",
|
||||
"start": "nodemon --expose-gc dist/server.js",
|
||||
"dev:server": "ts-node-dev --respawn --transpile-only --ignore node_modules src/server.ts",
|
||||
"dev": "nodemon --exec npm run dev:server",
|
||||
"pretest": "NODE_ENV=test sequelize db:migrate && NODE_ENV=test sequelize db:seed:all",
|
||||
"test": "NODE_ENV=test jest",
|
||||
"posttest": "NODE_ENV=test sequelize db:migrate:undo:all"
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
import { QueryInterface } from "sequelize";
|
||||
|
||||
module.exports = {
|
||||
up: (queryInterface: QueryInterface) => {
|
||||
return queryInterface.bulkInsert(
|
||||
"Settings",
|
||||
[
|
||||
{
|
||||
key: "blockAudioVideoMedia",
|
||||
value: "disabled",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
],
|
||||
{}
|
||||
);
|
||||
},
|
||||
|
||||
down: (queryInterface: QueryInterface) => {
|
||||
return queryInterface.bulkDelete("Settings", {});
|
||||
}
|
||||
};
|
|
@ -0,0 +1,22 @@
|
|||
import { QueryInterface } from "sequelize";
|
||||
|
||||
module.exports = {
|
||||
up: (queryInterface: QueryInterface) => {
|
||||
return queryInterface.bulkInsert(
|
||||
"Settings",
|
||||
[
|
||||
{
|
||||
key: "waitingTimeTickets",
|
||||
value: "disabled",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
],
|
||||
{}
|
||||
);
|
||||
},
|
||||
|
||||
down: (queryInterface: QueryInterface) => {
|
||||
return queryInterface.bulkDelete("Settings", {});
|
||||
}
|
||||
};
|
|
@ -0,0 +1,22 @@
|
|||
import { QueryInterface } from "sequelize";
|
||||
|
||||
module.exports = {
|
||||
up: (queryInterface: QueryInterface) => {
|
||||
return queryInterface.bulkInsert(
|
||||
"Settings",
|
||||
[
|
||||
{
|
||||
key: "notificationTransferQueue",
|
||||
value: "disabled",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
],
|
||||
{}
|
||||
);
|
||||
},
|
||||
|
||||
down: (queryInterface: QueryInterface) => {
|
||||
return queryInterface.bulkDelete("Settings", {});
|
||||
}
|
||||
};
|
|
@ -165,7 +165,7 @@ const verifyMediaMessage = async (
|
|||
if (!media) {
|
||||
throw new Error("ERR_WAPP_DOWNLOAD_MEDIA");
|
||||
}
|
||||
|
||||
let mediaAuthorized = true;
|
||||
let messageData = {
|
||||
id: msg.id.id,
|
||||
ticketId: ticket.id,
|
||||
|
@ -179,7 +179,9 @@ const verifyMediaMessage = async (
|
|||
phoneNumberId: msg?.phoneNumberId,
|
||||
fromAgent: false
|
||||
};
|
||||
|
||||
if(messageData.mediaType === 'video' || messageData.mediaType === 'audio' && getSettingValue('blockAudioVideoMedia')?.value === 'enabled'){
|
||||
mediaAuthorized = false;
|
||||
}
|
||||
if (msg?.fromMe) {
|
||||
messageData = { ...messageData, fromAgent: true };
|
||||
}
|
||||
|
@ -194,23 +196,36 @@ const verifyMediaMessage = async (
|
|||
body: media.filename
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await writeFileAsync(
|
||||
join(__dirname, "..", "..", "..", "..", "..", "public", media.filename),
|
||||
media.data,
|
||||
"base64"
|
||||
);
|
||||
} catch (err) {
|
||||
Sentry.captureException(err);
|
||||
logger.error(`There was an error: wbotMessageLitener.ts: ${err}`);
|
||||
if(mediaAuthorized){
|
||||
try {
|
||||
await writeFileAsync(
|
||||
join(__dirname, "..", "..", "..", "..", "..", "public", media.filename),
|
||||
media.data,
|
||||
"base64"
|
||||
);
|
||||
} catch (err) {
|
||||
Sentry.captureException(err);
|
||||
logger.error(`There was an error: wbotMessageLitener.ts: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ticket.update({ lastMessage: msg.body || media.filename });
|
||||
const newMessage = await CreateMessageService({ messageData });
|
||||
|
||||
return newMessage;
|
||||
if(mediaAuthorized){
|
||||
await ticket.update({ lastMessage: msg.body || media.filename });
|
||||
const newMessage = await CreateMessageService({ messageData });
|
||||
return newMessage;
|
||||
}else{
|
||||
if (ticket.status !== "queueChoice") {
|
||||
botSendMessage(
|
||||
ticket,
|
||||
`Atenção! Mensagem ignorada, tipo de mídia não suportado.`
|
||||
);
|
||||
}
|
||||
messageData.body = `Mensagem de *${messageData.mediaType}* ignorada, tipo de mídia não suportado.`;
|
||||
messageData.mediaUrl = '';
|
||||
await ticket.update({ lastMessage: `Mensagem de *${messageData.mediaType}* ignorada, tipo de mídia não suportado.`});
|
||||
const newMessage = await CreateMessageService({ messageData });
|
||||
console.log(`--------->>> Mensagem do tipo: ${messageData.mediaType}, ignorada!`)
|
||||
}
|
||||
};
|
||||
|
||||
// const verifyMediaMessage = async (
|
||||
|
@ -397,13 +412,15 @@ const verifyQueue = async (
|
|||
}
|
||||
|
||||
let body = "";
|
||||
|
||||
const io = getIO();
|
||||
if (botOptions.length > 0) {
|
||||
body = `\u200e${choosenQueue.greetingMessage}\n\n${botOptions}\n${final_message.msg}`;
|
||||
} else {
|
||||
body = `\u200e${choosenQueue.greetingMessage}`;
|
||||
}
|
||||
|
||||
io.emit('notifyPeding', {data: {ticket, queue: choosenQueue}});
|
||||
|
||||
sendWhatsAppMessageSocket(ticket, body);
|
||||
} else {
|
||||
//test del transfere o atendimento se entrar na ura infinita
|
||||
|
|
|
@ -20,6 +20,9 @@ import useTickets from "../../hooks/useTickets"
|
|||
import alertSound from "../../assets/sound.mp3"
|
||||
import { AuthContext } from "../../context/Auth/AuthContext"
|
||||
|
||||
import api from "../../services/api";
|
||||
import toastError from "../../errors/toastError";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
tabContainer: {
|
||||
overflowY: "auto",
|
||||
|
@ -83,7 +86,7 @@ const NotificationsPopOver = () => {
|
|||
const historyRef = useRef(history)
|
||||
|
||||
const { handleLogout } = useContext(AuthContext)
|
||||
|
||||
const [settings, setSettings] = useState([]);
|
||||
// const [lastRef] = useState(+history.location.pathname.split("/")[2])
|
||||
|
||||
|
||||
|
@ -110,7 +113,22 @@ const NotificationsPopOver = () => {
|
|||
ticketIdRef.current = ticketIdUrl
|
||||
}, [ticketIdUrl])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSession = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/settings')
|
||||
setSettings(data.settings)
|
||||
} catch (err) {
|
||||
toastError(err)
|
||||
}
|
||||
}
|
||||
fetchSession()
|
||||
}, [])
|
||||
|
||||
const getSettingValue = (key) => {
|
||||
const { value } = settings.find((s) => s.key === key)
|
||||
return value
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
|
@ -255,49 +273,80 @@ const NotificationsPopOver = () => {
|
|||
|
||||
if (shouldNotNotificate) return
|
||||
|
||||
|
||||
|
||||
handleNotifications(data)
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('notifyPeding', data =>{
|
||||
if(settings?.length > 0 && getSettingValue('notificationTransferQueue') === 'enabled') handleNotifications("", data);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect()
|
||||
}
|
||||
}, [user])
|
||||
}, [user, settings])
|
||||
|
||||
const handleNotifications = data => {
|
||||
const { message, contact, ticket } = data
|
||||
const handleNotifications = (data, notify) => {
|
||||
let isQueue = false;
|
||||
if(!notify){
|
||||
const { message, contact, ticket } = data
|
||||
|
||||
const options = {
|
||||
body: `${message.body} - ${format(new Date(), "HH:mm")}`,
|
||||
icon: contact.profilePicUrl,
|
||||
tag: ticket.id,
|
||||
renotify: true,
|
||||
}
|
||||
|
||||
const notification = new Notification(
|
||||
`${i18n.t("tickets.notification.message")} ${contact.name}`,
|
||||
options
|
||||
)
|
||||
|
||||
notification.onclick = e => {
|
||||
e.preventDefault()
|
||||
window.focus()
|
||||
historyRef.current.push(`/tickets/${ticket.id}`)
|
||||
}
|
||||
|
||||
setDesktopNotifications(prevState => {
|
||||
const notfiticationIndex = prevState.findIndex(
|
||||
n => n.tag === notification.tag
|
||||
)
|
||||
if (notfiticationIndex !== -1) {
|
||||
prevState[notfiticationIndex] = notification
|
||||
return [...prevState]
|
||||
const options = {
|
||||
body: `${message.body} - ${format(new Date(), "HH:mm")}`,
|
||||
icon: contact.profilePicUrl,
|
||||
tag: ticket.id,
|
||||
renotify: true,
|
||||
}
|
||||
return [notification, ...prevState]
|
||||
})
|
||||
|
||||
const notification = new Notification(
|
||||
`${i18n.t("tickets.notification.message")} ${contact.name}`,
|
||||
options
|
||||
)
|
||||
|
||||
notification.onclick = e => {
|
||||
e.preventDefault()
|
||||
window.focus()
|
||||
historyRef.current.push(`/tickets/${ticket.id}`)
|
||||
}
|
||||
|
||||
setDesktopNotifications(prevState => {
|
||||
const notfiticationIndex = prevState.findIndex(
|
||||
n => n.tag === notification.tag
|
||||
)
|
||||
if (notfiticationIndex !== -1) {
|
||||
prevState[notfiticationIndex] = notification
|
||||
return [...prevState]
|
||||
}
|
||||
return [notification, ...prevState]
|
||||
})
|
||||
}else{
|
||||
user.queues.forEach(queue =>{
|
||||
if(queue.id === notify.data?.queue?.id){
|
||||
isQueue = true;
|
||||
}
|
||||
})
|
||||
if(!isQueue){
|
||||
return;
|
||||
}else {
|
||||
const notification = new Notification(`${i18n.t("tickets.notification.messagePeding")} ${notify.data?.queue?.name}`);
|
||||
notification.onclick = e => {
|
||||
e.preventDefault()
|
||||
window.focus()
|
||||
historyRef.current.push(`/tickets`)
|
||||
}
|
||||
|
||||
setDesktopNotifications(prevState => {
|
||||
const notfiticationIndex = prevState.findIndex(
|
||||
n => n.tag === notification.tag
|
||||
)
|
||||
if (notfiticationIndex !== -1) {
|
||||
prevState[notfiticationIndex] = notification
|
||||
return [...prevState]
|
||||
}
|
||||
return [notification, ...prevState]
|
||||
})
|
||||
}
|
||||
}
|
||||
soundAlertRef.current()
|
||||
}
|
||||
|
||||
|
|
|
@ -33,6 +33,9 @@ import { Button } from "@material-ui/core";
|
|||
import { TabTicketContext } from "../../context/TabTicketHeaderOption/TabTicketHeaderOption";
|
||||
|
||||
import { SearchTicketContext } from "../../context/SearchTicket/SearchTicket";
|
||||
import useTickets from "../../hooks/useTickets"
|
||||
import api from "../../services/api";
|
||||
import toastError from "../../errors/toastError";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
ticketsWrapper: {
|
||||
|
@ -157,6 +160,10 @@ const TicketsManager = () => {
|
|||
|
||||
const [openTooltipSearch, setOpenTooltipSearch] = useState(false)
|
||||
|
||||
const [waitingTime, setWaitingTime] = useState('00:00');
|
||||
const [tickets, setTickets] = useState([]);
|
||||
const [settings, setSettings] = useState([])
|
||||
|
||||
let searchTimeout;
|
||||
let searchContentTimeout;
|
||||
|
||||
|
@ -178,6 +185,76 @@ const TicketsManager = () => {
|
|||
|
||||
}, [tab, setTabOption]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSession = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/settings')
|
||||
setSettings(data.settings)
|
||||
} catch (err) {
|
||||
toastError(err)
|
||||
}
|
||||
}
|
||||
fetchSession()
|
||||
}, [])
|
||||
|
||||
const getSettingValue = (key) => {
|
||||
const { value } = settings.find((s) => s.key === key)
|
||||
return value
|
||||
}
|
||||
|
||||
const fetchTickets = async () =>{
|
||||
try {
|
||||
const { data } = await api.get("/tickets", {
|
||||
params: {
|
||||
status: 'pending',
|
||||
queueIds: JSON.stringify(selectedQueueIds)
|
||||
},
|
||||
});
|
||||
setTickets(data.tickets);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
|
||||
}
|
||||
useEffect(() => {
|
||||
if(settings?.length > 0 && getSettingValue('waitingTimeTickets') === 'enabled') {
|
||||
fetchTickets();
|
||||
|
||||
const intervalId = setInterval(fetchTickets, 7000);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}
|
||||
}, [selectedQueueIds, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
const calculateAverageTime = () => {
|
||||
if(tickets.length > 0){
|
||||
const now = new Date();
|
||||
const differenceTime = tickets?.map(ticket => {
|
||||
const updatedAt = new Date(ticket.updatedAt);
|
||||
const difference = now - updatedAt;
|
||||
return difference;
|
||||
});
|
||||
const sumDifferences = differenceTime.reduce((total, difference) => total + difference, 0);
|
||||
const averageTimeMilliseconds = sumDifferences / tickets?.length;
|
||||
let hours = Math.floor(averageTimeMilliseconds / 3600000);
|
||||
const minutes = Math.floor((averageTimeMilliseconds % 3600000) / 60000);
|
||||
|
||||
let days = hours >= 24 ? parseInt(hours/24) : '';
|
||||
|
||||
if(days != '') hours = hours - (24*days);
|
||||
|
||||
const averageTimeFormated = `${days != '' ? `${days}d ` : days}${hours.toString().padStart(2, '0')}h${minutes.toString().padStart(2, '0')}`;
|
||||
|
||||
return averageTimeFormated;
|
||||
}else return '00:00';
|
||||
}
|
||||
|
||||
setWaitingTime(calculateAverageTime());
|
||||
},[tickets]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
// clearTimeout(searchContentTimeout);
|
||||
|
@ -203,7 +280,7 @@ const TicketsManager = () => {
|
|||
|
||||
useEffect(() => {
|
||||
|
||||
|
||||
//console.log(selectedQueueIds);
|
||||
if (tabOption === 'open') {
|
||||
|
||||
setTabOption('')
|
||||
|
@ -448,7 +525,17 @@ const TicketsManager = () => {
|
|||
</Badge>
|
||||
}
|
||||
value={"pending"}
|
||||
/>
|
||||
/>{
|
||||
(settings?.length > 0 && getSettingValue('waitingTimeTickets') === 'enabled') &&
|
||||
<span style={{display: 'flex', alignItems: 'center', flexDirection:'column', justifyContent: 'flex-start'}}>
|
||||
<label style ={{color: 'red',fontWeight: 'bold', padding: '.1rem', fontSize: '8px', textAlign:'center', margin:'0'}}>
|
||||
<i>ESPERA</i>
|
||||
</label>
|
||||
<label style={{color: 'gray',fontWeight: 'bold', padding: '.1rem', textDecoration: 'underline', fontSize: '13px'}}>
|
||||
{waitingTime}
|
||||
</label>
|
||||
</span>
|
||||
}
|
||||
</Tabs>
|
||||
<Paper className={classes.ticketsWrapper}>
|
||||
<TicketsList
|
||||
|
|
|
@ -236,6 +236,7 @@ let columnsData = [
|
|||
{ title: `${i18n.t("reports.listColumns.column1_8")}`, field: 'updatedAt' },
|
||||
{ title: `${i18n.t("reports.listColumns.column1_9")}`, field: 'statusChatEnd' },
|
||||
{ title: `Mensagens`, field: 'messagesToFilter', searchable: true, hidden: true },
|
||||
{ title: `Link`, field: 'link', searchable: false, hidden: true, export: true },
|
||||
]
|
||||
|
||||
let columnsDataSuper = [
|
||||
|
@ -250,6 +251,7 @@ let columnsDataSuper = [
|
|||
{ title: `${i18n.t("reports.listColumns.column1_8")}`, field: 'updatedAt' },
|
||||
{ title: `${i18n.t("reports.listColumns.column1_9")}`, field: 'statusChatEnd' },
|
||||
{ title: `Mensagens`, field: 'messagesToFilter', searchable: true, hidden: true },
|
||||
{ title: `Link`, field: 'link', searchable: false, hidden: true, export: true },
|
||||
]
|
||||
|
||||
|
||||
|
@ -374,9 +376,9 @@ const Report = () => {
|
|||
const tickets = data.tickets.map(ticket => ({
|
||||
...ticket,
|
||||
messagesToFilter: ticket.messages.map(message => message.body).join(' '),
|
||||
link: `${process.env.REACT_APP_FRONTEND_URL}/tickets/${ticket.id}`
|
||||
}))
|
||||
dispatchQ({ type: "LOAD_QUERY", payload: tickets })
|
||||
console.log(tickets)
|
||||
setHasMore(data.hasMore)
|
||||
setTotalCountTickets(data.count)
|
||||
setLoading(false)
|
||||
|
@ -682,54 +684,56 @@ const Report = () => {
|
|||
|
||||
|
||||
const renderSwitch = (param) => {
|
||||
switch (param) {
|
||||
case 'empty':
|
||||
return (
|
||||
<>
|
||||
{query && query.length > 0 &&
|
||||
<ReportModalType currencies={reportTypeList} func={reportTypeValue} reportOption={reportType} />
|
||||
}
|
||||
{/* <Button
|
||||
disabled={query && query.length > 0 ? false : true}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={(e) => {
|
||||
handleCSVMessages()
|
||||
}}
|
||||
>
|
||||
{"CSV ALL"}
|
||||
if(userA.profile !== 'supervisor'){
|
||||
switch (param) {
|
||||
case 'empty':
|
||||
return (
|
||||
<>
|
||||
{query && query.length > 0 &&
|
||||
<ReportModalType currencies={reportTypeList} func={reportTypeValue} reportOption={reportType} />
|
||||
}
|
||||
{/* <Button
|
||||
disabled={query && query.length > 0 ? false : true}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={(e) => {
|
||||
handleCSVMessages()
|
||||
}}
|
||||
>
|
||||
{"CSV ALL"}
|
||||
|
||||
</Button> */}
|
||||
</>)
|
||||
</Button> */}
|
||||
</>)
|
||||
|
||||
case 'pending' || 'processing':
|
||||
return (
|
||||
<>
|
||||
<span>PROCESSING...</span>
|
||||
</>)
|
||||
case 'pending' || 'processing':
|
||||
return (
|
||||
<>
|
||||
<span>PROCESSING...</span>
|
||||
</>)
|
||||
|
||||
case 'success':
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={(e) => {
|
||||
handleCSVDownload(e)
|
||||
}}
|
||||
>
|
||||
{'CSV DOWNLOAD'}
|
||||
</Button>
|
||||
</>)
|
||||
case 'downloading':
|
||||
return (
|
||||
<>
|
||||
<span>DOWNLOADING...</span>
|
||||
</>)
|
||||
case 'success':
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={(e) => {
|
||||
handleCSVDownload(e)
|
||||
}}
|
||||
>
|
||||
{'CSV DOWNLOAD'}
|
||||
</Button>
|
||||
</>)
|
||||
case 'downloading':
|
||||
return (
|
||||
<>
|
||||
<span>DOWNLOADING...</span>
|
||||
</>)
|
||||
|
||||
|
||||
default:
|
||||
return (<><span>WAITING...</span></>)
|
||||
default:
|
||||
return (<><span>WAITING...</span></>)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -840,7 +844,7 @@ const Report = () => {
|
|||
<>
|
||||
<MTable data={query}
|
||||
columns={userA.profile !== 'supervisor' ? columnsData : columnsDataSuper}
|
||||
hasChild={true}
|
||||
hasChild={userA.profile !== 'supervisor' ? true :false}
|
||||
removeClickRow={false}
|
||||
|
||||
handleScroll={handleScroll}
|
||||
|
|
Loading…
Reference in New Issue