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
gustavo-gsp 2024-04-18 10:18:17 -03:00
parent 8447628fbf
commit 860d462d37
8 changed files with 324 additions and 100 deletions

View File

@ -8,6 +8,7 @@
"watch": "tsc -w", "watch": "tsc -w",
"start": "nodemon --expose-gc dist/server.js", "start": "nodemon --expose-gc dist/server.js",
"dev:server": "ts-node-dev --respawn --transpile-only --ignore node_modules src/server.ts", "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", "pretest": "NODE_ENV=test sequelize db:migrate && NODE_ENV=test sequelize db:seed:all",
"test": "NODE_ENV=test jest", "test": "NODE_ENV=test jest",
"posttest": "NODE_ENV=test sequelize db:migrate:undo:all" "posttest": "NODE_ENV=test sequelize db:migrate:undo:all"

View File

@ -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", {});
}
};

View File

@ -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", {});
}
};

View File

@ -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", {});
}
};

View File

@ -165,7 +165,7 @@ const verifyMediaMessage = async (
if (!media) { if (!media) {
throw new Error("ERR_WAPP_DOWNLOAD_MEDIA"); throw new Error("ERR_WAPP_DOWNLOAD_MEDIA");
} }
let mediaAuthorized = true;
let messageData = { let messageData = {
id: msg.id.id, id: msg.id.id,
ticketId: ticket.id, ticketId: ticket.id,
@ -179,7 +179,9 @@ const verifyMediaMessage = async (
phoneNumberId: msg?.phoneNumberId, phoneNumberId: msg?.phoneNumberId,
fromAgent: false fromAgent: false
}; };
if(messageData.mediaType === 'video' || messageData.mediaType === 'audio' && getSettingValue('blockAudioVideoMedia')?.value === 'enabled'){
mediaAuthorized = false;
}
if (msg?.fromMe) { if (msg?.fromMe) {
messageData = { ...messageData, fromAgent: true }; messageData = { ...messageData, fromAgent: true };
} }
@ -194,23 +196,36 @@ const verifyMediaMessage = async (
body: media.filename body: media.filename
}; };
} }
if(mediaAuthorized){
try { try {
await writeFileAsync( await writeFileAsync(
join(__dirname, "..", "..", "..", "..", "..", "public", media.filename), join(__dirname, "..", "..", "..", "..", "..", "public", media.filename),
media.data, media.data,
"base64" "base64"
); );
} catch (err) { } catch (err) {
Sentry.captureException(err); Sentry.captureException(err);
logger.error(`There was an error: wbotMessageLitener.ts: ${err}`); logger.error(`There was an error: wbotMessageLitener.ts: ${err}`);
}
} }
} }
if(mediaAuthorized){
await ticket.update({ lastMessage: msg.body || media.filename }); await ticket.update({ lastMessage: msg.body || media.filename });
const newMessage = await CreateMessageService({ messageData }); const newMessage = await CreateMessageService({ messageData });
return newMessage;
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 ( // const verifyMediaMessage = async (
@ -397,13 +412,15 @@ const verifyQueue = async (
} }
let body = ""; let body = "";
const io = getIO();
if (botOptions.length > 0) { if (botOptions.length > 0) {
body = `\u200e${choosenQueue.greetingMessage}\n\n${botOptions}\n${final_message.msg}`; body = `\u200e${choosenQueue.greetingMessage}\n\n${botOptions}\n${final_message.msg}`;
} else { } else {
body = `\u200e${choosenQueue.greetingMessage}`; body = `\u200e${choosenQueue.greetingMessage}`;
} }
io.emit('notifyPeding', {data: {ticket, queue: choosenQueue}});
sendWhatsAppMessageSocket(ticket, body); sendWhatsAppMessageSocket(ticket, body);
} else { } else {
//test del transfere o atendimento se entrar na ura infinita //test del transfere o atendimento se entrar na ura infinita

View File

@ -20,6 +20,9 @@ import useTickets from "../../hooks/useTickets"
import alertSound from "../../assets/sound.mp3" import alertSound from "../../assets/sound.mp3"
import { AuthContext } from "../../context/Auth/AuthContext" import { AuthContext } from "../../context/Auth/AuthContext"
import api from "../../services/api";
import toastError from "../../errors/toastError";
const useStyles = makeStyles(theme => ({ const useStyles = makeStyles(theme => ({
tabContainer: { tabContainer: {
overflowY: "auto", overflowY: "auto",
@ -83,7 +86,7 @@ const NotificationsPopOver = () => {
const historyRef = useRef(history) const historyRef = useRef(history)
const { handleLogout } = useContext(AuthContext) const { handleLogout } = useContext(AuthContext)
const [settings, setSettings] = useState([]);
// const [lastRef] = useState(+history.location.pathname.split("/")[2]) // const [lastRef] = useState(+history.location.pathname.split("/")[2])
@ -110,7 +113,22 @@ const NotificationsPopOver = () => {
ticketIdRef.current = ticketIdUrl ticketIdRef.current = ticketIdUrl
}, [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(() => { useEffect(() => {
@ -255,49 +273,80 @@ const NotificationsPopOver = () => {
if (shouldNotNotificate) return if (shouldNotNotificate) return
handleNotifications(data) handleNotifications(data)
} }
}) })
socket.on('notifyPeding', data =>{
if(settings?.length > 0 && getSettingValue('notificationTransferQueue') === 'enabled') handleNotifications("", data);
});
return () => { return () => {
socket.disconnect() socket.disconnect()
} }
}, [user]) }, [user, settings])
const handleNotifications = data => { const handleNotifications = (data, notify) => {
const { message, contact, ticket } = data let isQueue = false;
if(!notify){
const { message, contact, ticket } = data
const options = { const options = {
body: `${message.body} - ${format(new Date(), "HH:mm")}`, body: `${message.body} - ${format(new Date(), "HH:mm")}`,
icon: contact.profilePicUrl, icon: contact.profilePicUrl,
tag: ticket.id, tag: ticket.id,
renotify: true, renotify: true,
}
const notification = new Notification(
`${i18n.t("tickets.notification.message")} ${contact.name}`,
options
)
notification.onclick = e => {
e.preventDefault()
window.focus()
historyRef.current.push(`/tickets/${ticket.id}`)
}
setDesktopNotifications(prevState => {
const notfiticationIndex = prevState.findIndex(
n => n.tag === notification.tag
)
if (notfiticationIndex !== -1) {
prevState[notfiticationIndex] = notification
return [...prevState]
} }
return [notification, ...prevState]
})
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() soundAlertRef.current()
} }

View File

@ -33,6 +33,9 @@ import { Button } from "@material-ui/core";
import { TabTicketContext } from "../../context/TabTicketHeaderOption/TabTicketHeaderOption"; import { TabTicketContext } from "../../context/TabTicketHeaderOption/TabTicketHeaderOption";
import { SearchTicketContext } from "../../context/SearchTicket/SearchTicket"; 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) => ({ const useStyles = makeStyles((theme) => ({
ticketsWrapper: { ticketsWrapper: {
@ -157,6 +160,10 @@ const TicketsManager = () => {
const [openTooltipSearch, setOpenTooltipSearch] = useState(false) const [openTooltipSearch, setOpenTooltipSearch] = useState(false)
const [waitingTime, setWaitingTime] = useState('00:00');
const [tickets, setTickets] = useState([]);
const [settings, setSettings] = useState([])
let searchTimeout; let searchTimeout;
let searchContentTimeout; let searchContentTimeout;
@ -178,6 +185,76 @@ const TicketsManager = () => {
}, [tab, setTabOption]); }, [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(() => { useEffect(() => {
// clearTimeout(searchContentTimeout); // clearTimeout(searchContentTimeout);
@ -203,7 +280,7 @@ const TicketsManager = () => {
useEffect(() => { useEffect(() => {
//console.log(selectedQueueIds);
if (tabOption === 'open') { if (tabOption === 'open') {
setTabOption('') setTabOption('')
@ -448,7 +525,17 @@ const TicketsManager = () => {
</Badge> </Badge>
} }
value={"pending"} 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> </Tabs>
<Paper className={classes.ticketsWrapper}> <Paper className={classes.ticketsWrapper}>
<TicketsList <TicketsList

View File

@ -236,7 +236,8 @@ let columnsData = [
{ title: `${i18n.t("reports.listColumns.column1_8")}`, field: 'updatedAt' }, { title: `${i18n.t("reports.listColumns.column1_8")}`, field: 'updatedAt' },
{ title: `${i18n.t("reports.listColumns.column1_9")}`, field: 'statusChatEnd' }, { title: `${i18n.t("reports.listColumns.column1_9")}`, field: 'statusChatEnd' },
{ title: `Mensagens`, field: 'messagesToFilter', searchable: true, hidden: true }, { title: `Mensagens`, field: 'messagesToFilter', searchable: true, hidden: true },
] { title: `Link`, field: 'link', searchable: false, hidden: true, export: true },
]
let columnsDataSuper = [ let columnsDataSuper = [
{ title: `${i18n.t("reports.listColumns.column1_1")}`, field: 'whatsapp.name' }, { title: `${i18n.t("reports.listColumns.column1_1")}`, field: 'whatsapp.name' },
@ -250,6 +251,7 @@ let columnsDataSuper = [
{ title: `${i18n.t("reports.listColumns.column1_8")}`, field: 'updatedAt' }, { title: `${i18n.t("reports.listColumns.column1_8")}`, field: 'updatedAt' },
{ title: `${i18n.t("reports.listColumns.column1_9")}`, field: 'statusChatEnd' }, { title: `${i18n.t("reports.listColumns.column1_9")}`, field: 'statusChatEnd' },
{ title: `Mensagens`, field: 'messagesToFilter', searchable: true, hidden: true }, { 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 => ({ const tickets = data.tickets.map(ticket => ({
...ticket, ...ticket,
messagesToFilter: ticket.messages.map(message => message.body).join(' '), messagesToFilter: ticket.messages.map(message => message.body).join(' '),
link: `${process.env.REACT_APP_FRONTEND_URL}/tickets/${ticket.id}`
})) }))
dispatchQ({ type: "LOAD_QUERY", payload: tickets }) dispatchQ({ type: "LOAD_QUERY", payload: tickets })
console.log(tickets)
setHasMore(data.hasMore) setHasMore(data.hasMore)
setTotalCountTickets(data.count) setTotalCountTickets(data.count)
setLoading(false) setLoading(false)
@ -682,54 +684,56 @@ const Report = () => {
const renderSwitch = (param) => { const renderSwitch = (param) => {
switch (param) { if(userA.profile !== 'supervisor'){
case 'empty': switch (param) {
return ( case 'empty':
<> return (
{query && query.length > 0 && <>
<ReportModalType currencies={reportTypeList} func={reportTypeValue} reportOption={reportType} /> {query && query.length > 0 &&
} <ReportModalType currencies={reportTypeList} func={reportTypeValue} reportOption={reportType} />
{/* <Button }
disabled={query && query.length > 0 ? false : true} {/* <Button
variant="contained" disabled={query && query.length > 0 ? false : true}
color="primary" variant="contained"
onClick={(e) => { color="primary"
handleCSVMessages() onClick={(e) => {
}} handleCSVMessages()
> }}
{"CSV ALL"} >
{"CSV ALL"}
</Button> */}
</>) </Button> */}
</>)
case 'pending' || 'processing': case 'pending' || 'processing':
return ( return (
<> <>
<span>PROCESSING...</span> <span>PROCESSING...</span>
</>) </>)
case 'success': case 'success':
return ( return (
<> <>
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
onClick={(e) => { onClick={(e) => {
handleCSVDownload(e) handleCSVDownload(e)
}} }}
> >
{'CSV DOWNLOAD'} {'CSV DOWNLOAD'}
</Button> </Button>
</>) </>)
case 'downloading': case 'downloading':
return ( return (
<> <>
<span>DOWNLOADING...</span> <span>DOWNLOADING...</span>
</>) </>)
default: default:
return (<><span>WAITING...</span></>) return (<><span>WAITING...</span></>)
}
} }
} }
@ -840,7 +844,7 @@ const Report = () => {
<> <>
<MTable data={query} <MTable data={query}
columns={userA.profile !== 'supervisor' ? columnsData : columnsDataSuper} columns={userA.profile !== 'supervisor' ? columnsData : columnsDataSuper}
hasChild={true} hasChild={userA.profile !== 'supervisor' ? true :false}
removeClickRow={false} removeClickRow={false}
handleScroll={handleScroll} handleScroll={handleScroll}