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",
"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"

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) {
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,7 +196,7 @@ const verifyMediaMessage = async (
body: media.filename
};
}
if(mediaAuthorized){
try {
await writeFileAsync(
join(__dirname, "..", "..", "..", "..", "..", "public", media.filename),
@ -206,11 +208,24 @@ const verifyMediaMessage = async (
logger.error(`There was an error: wbotMessageLitener.ts: ${err}`);
}
}
}
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

View File

@ -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,18 +273,22 @@ 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 handleNotifications = (data, notify) => {
let isQueue = false;
if(!notify){
const { message, contact, ticket } = data
const options = {
@ -297,7 +319,34 @@ const NotificationsPopOver = () => {
}
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()
}

View File

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

View File

@ -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,6 +684,7 @@ const Report = () => {
const renderSwitch = (param) => {
if(userA.profile !== 'supervisor'){
switch (param) {
case 'empty':
return (
@ -732,6 +735,7 @@ const Report = () => {
return (<><span>WAITING...</span></>)
}
}
}
const handleChange = (event) => {
@ -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}