[NEW] Show on-hold metrics on analytics pages and current chats (#23498)

* Show on hold metrics on analytics pages

* Add onhold chats to graphs

* make colors match cause ocd

* change way of calculating open rooms to account for onhold chats

* remove onhold chats from open chats

* fix colors

* fix summarize to remove onhold conversations
pull/23603/head
Kevin Aleman 4 years ago committed by GitHub
parent cbd2a0b719
commit 9c42d4074b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      app/livechat/client/lib/chartHandler.js
  2. 4
      app/livechat/imports/server/rest/rooms.js
  3. 2
      app/livechat/server/api/lib/rooms.js
  4. 13
      app/livechat/server/lib/Analytics.js
  5. 12
      app/livechat/server/lib/analytics/dashboards.js
  6. 2
      app/models/server/models/LivechatRooms.js
  7. 76
      app/models/server/raw/LivechatRooms.js
  8. 50
      client/views/omnichannel/currentChats/CurrentChatsRoute.tsx
  9. 3
      client/views/omnichannel/currentChats/FilterByText.tsx
  10. 8
      client/views/omnichannel/realTimeMonitoring/charts/ChatsChart.js
  11. 4
      client/views/omnichannel/realTimeMonitoring/charts/ChatsPerAgentChart.js
  12. 4
      ee/app/livechat-enterprise/server/api/rooms.ts
  13. 1
      packages/rocketchat-i18n/i18n/en.i18n.json
  14. 1
      packages/rocketchat-i18n/i18n/es.i18n.json

@ -194,9 +194,9 @@ export const drawDoughnutChart = async (chart, title, chartContext, dataLabels,
data: dataPoints, // data points corresponding to data labels, x-axis points
backgroundColor: [
'#2de0a5',
'#ffd21f',
'#f5455c',
'#cbced1',
'#f5455c',
'#ffd21f',
],
borderWidth: 0,
}],

@ -21,12 +21,13 @@ API.v1.addRoute('livechat/rooms', { authRequired: true }, {
get() {
const { offset, count } = this.getPaginationItems();
const { sort, fields } = this.parseJsonQuery();
const { agents, departmentId, open, tags, roomName } = this.requestParams();
const { agents, departmentId, open, tags, roomName, onhold } = this.requestParams();
let { createdAt, customFields, closedAt } = this.requestParams();
check(agents, Match.Maybe([String]));
check(roomName, Match.Maybe(String));
check(departmentId, Match.Maybe(String));
check(open, Match.Maybe(String));
check(onhold, Match.Maybe(String));
check(tags, Match.Maybe([String]));
const hasAdminAccess = hasPermission(this.userId, 'view-livechat-rooms');
@ -51,6 +52,7 @@ API.v1.addRoute('livechat/rooms', { authRequired: true }, {
closedAt,
tags,
customFields,
onhold,
options: { offset, count, sort, fields },
})));
},

@ -9,6 +9,7 @@ export async function findRooms({
closedAt,
tags,
customFields,
onhold,
options: {
offset,
count,
@ -25,6 +26,7 @@ export async function findRooms({
closedAt,
tags,
customFields,
onhold: ['t', 'true', '1'].includes(onhold),
options: {
sort: sort || { ts: -1 },
offset,

@ -2,6 +2,7 @@ import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import moment from 'moment';
import { LivechatRooms } from '../../../models';
import { LivechatRooms as LivechatRoomsRaw } from '../../../models/server/raw';
import { secondsToHHMMSS } from '../../../utils/server';
import { getTimezone } from '../../../utils/server/lib/getTimezone';
import { Logger } from '../../../logger';
@ -288,8 +289,8 @@ export const Analytics = {
const totalMessagesInHour = new Map(); // total messages in hour 0, 1, ... 23 of weekday
const days = to.diff(from, 'days') + 1; // total days
const summarize = (m) => ({ metrics, msgs }) => {
if (metrics && !metrics.chatDuration) {
const summarize = (m) => ({ metrics, msgs, onHold = false }) => {
if (metrics && !metrics.chatDuration && !onHold) {
openConversations++;
}
totalMessages += msgs;
@ -337,13 +338,17 @@ export const Analytics = {
to: utcBusiestHour >= 0 ? moment.utc().set({ hour: utcBusiestHour }).tz(timezone).format('hA') : '-',
from: utcBusiestHour >= 0 ? moment.utc().set({ hour: utcBusiestHour }).subtract(1, 'hour').tz(timezone).format('hA') : '',
};
const onHoldConversations = Promise.await(LivechatRoomsRaw.getOnHoldConversationsBetweenDate(from, to, departmentId));
const data = [{
return [{
title: 'Total_conversations',
value: totalConversations,
}, {
title: 'Open_conversations',
value: openConversations,
}, {
title: 'On_Hold_conversations',
value: onHoldConversations,
}, {
title: 'Total_messages',
value: totalMessages,
@ -357,8 +362,6 @@ export const Analytics = {
title: 'Busiest_time',
value: `${ busiestHour.from }${ busiestHour.to ? `- ${ busiestHour.to }` : '' }`,
}];
return data;
},
/**

@ -25,6 +25,7 @@ const findAllChatsStatusAsync = async ({
open: await LivechatRooms.countAllOpenChatsBetweenDate({ start, end, departmentId }),
closed: await LivechatRooms.countAllClosedChatsBetweenDate({ start, end, departmentId }),
queued: await LivechatRooms.countAllQueuedChatsBetweenDate({ start, end, departmentId }),
onhold: await LivechatRooms.getOnHoldConversationsBetweenDate(start, end, departmentId),
};
};
@ -193,7 +194,7 @@ const getConversationsMetricsAsync = async ({
utcOffset: user.utcOffset,
language: user.language || settings.get('Language') || 'en',
});
const metrics = ['Total_conversations', 'Open_conversations', 'Total_messages'];
const metrics = ['Total_conversations', 'Open_conversations', 'On_Hold_conversations', 'Total_messages'];
const visitorsCount = await LivechatVisitors.getVisitorsBetweenDate({ start, end, department: departmentId }).count();
return {
totalizers: [
@ -213,13 +214,20 @@ const findAllChatMetricsByAgentAsync = async ({
}
const open = await LivechatRooms.countAllOpenChatsByAgentBetweenDate({ start, end, departmentId });
const closed = await LivechatRooms.countAllClosedChatsByAgentBetweenDate({ start, end, departmentId });
const onhold = await LivechatRooms.countAllOnHoldChatsByAgentBetweenDate({ start, end, departmentId });
const result = {};
(open || []).forEach((agent) => {
result[agent._id] = { open: agent.chats, closed: 0 };
result[agent._id] = { open: agent.chats, closed: 0, onhold: 0 };
});
(closed || []).forEach((agent) => {
result[agent._id] = { open: result[agent._id] ? result[agent._id].open : 0, closed: agent.chats };
});
(onhold || []).forEach((agent) => {
result[agent._id] = {
...result[agent._id],
onhold: agent.chats,
};
});
return result;
};

@ -563,6 +563,7 @@ export class LivechatRooms extends Base {
open: '$open',
servedBy: '$servedBy',
metrics: '$metrics',
onHold: '$onHold',
},
messagesCount: {
$sum: 1,
@ -578,6 +579,7 @@ export class LivechatRooms extends Base {
servedBy: '$_id.servedBy',
metrics: '$_id.metrics',
msgs: '$messagesCount',
onHold: '$_id.onHold',
},
},
]);

@ -479,6 +479,16 @@ export class LivechatRoomsRaw extends BaseRaw {
'metrics.chatDuration': {
$exists: false,
},
$or: [{
onHold: {
$exists: false,
},
}, {
onHold: {
$exists: true,
$eq: false,
},
}],
servedBy: { $exists: true },
ts: { $gte: new Date(start), $lte: new Date(end) },
};
@ -494,7 +504,6 @@ export class LivechatRoomsRaw extends BaseRaw {
'metrics.chatDuration': {
$exists: true,
},
servedBy: { $exists: true },
ts: { $gte: new Date(start), $lte: new Date(end) },
};
if (departmentId && departmentId !== 'undefined') {
@ -507,6 +516,7 @@ export class LivechatRoomsRaw extends BaseRaw {
const query = {
t: 'l',
servedBy: { $exists: false },
open: true,
ts: { $gte: new Date(start), $lte: new Date(end) },
};
if (departmentId && departmentId !== 'undefined') {
@ -521,6 +531,41 @@ export class LivechatRoomsRaw extends BaseRaw {
t: 'l',
'servedBy.username': { $exists: true },
open: true,
$or: [{
onHold: {
$exists: false,
},
}, {
onHold: {
$exists: true,
$eq: false,
},
}],
ts: { $gte: new Date(start), $lte: new Date(end) },
},
};
const group = {
$group: {
_id: '$servedBy.username',
chats: { $sum: 1 },
},
};
if (departmentId && departmentId !== 'undefined') {
match.$match.departmentId = departmentId;
}
return this.col.aggregate([match, group]).toArray();
}
countAllOnHoldChatsByAgentBetweenDate({ start, end, departmentId }) {
const match = {
$match: {
t: 'l',
'servedBy.username': { $exists: true },
open: true,
onHold: {
$exists: true,
$eq: true,
},
ts: { $gte: new Date(start), $lte: new Date(end) },
},
};
@ -896,7 +941,7 @@ export class LivechatRoomsRaw extends BaseRaw {
return this.col.aggregate(params);
}
findRoomsWithCriteria({ agents, roomName, departmentId, open, served, createdAt, closedAt, tags, customFields, visitorId, roomIds, options = {} }) {
findRoomsWithCriteria({ agents, roomName, departmentId, open, served, createdAt, closedAt, tags, customFields, visitorId, roomIds, onhold, options = {} }) {
const query = {
t: 'l',
};
@ -911,6 +956,7 @@ export class LivechatRoomsRaw extends BaseRaw {
}
if (open !== undefined) {
query.open = { $exists: open };
query.onHold = { $ne: true };
}
if (served !== undefined) {
query.servedBy = { $exists: served };
@ -947,9 +993,35 @@ export class LivechatRoomsRaw extends BaseRaw {
query._id = { $in: roomIds };
}
if (onhold) {
query.onHold = {
$exists: true,
$eq: onhold,
};
}
return this.find(query, { sort: options.sort || { name: 1 }, skip: options.offset, limit: options.count });
}
getOnHoldConversationsBetweenDate(from, to, departmentId) {
const query = {
onHold: {
$exists: true,
$eq: true,
},
ts: {
$gte: new Date(from), // ISO Date, ts >= date.gte
$lt: new Date(to), // ISODate, ts < date.lt
},
};
if (departmentId && departmentId !== 'undefined') {
query.departmentId = departmentId;
}
return this.find(query).count();
}
findAllServiceTimeByAgent({ start, end, onlyCount = false, options = {} }) {
const match = {
$match: {

@ -47,6 +47,7 @@ const useQuery: useQueryType = (
departmentId?: string;
tags?: string[];
customFields?: string;
onhold?: boolean;
} = {
...(guest && { roomName: guest }),
sort: JSON.stringify({
@ -71,8 +72,10 @@ const useQuery: useQueryType = (
}),
});
}
if (status !== 'all') {
query.open = status === 'opened';
query.open = status === 'opened' || status === 'onhold';
query.onhold = status === 'onhold';
}
if (servedBy && servedBy !== 'all') {
query.agents = [servedBy];
@ -215,25 +218,32 @@ const CurrentChatsRoute: FC = () => {
);
const renderRow = useCallback(
({ _id, fname, servedBy, ts, lm, department, open }) => (
<Table.Row
key={_id}
tabIndex={0}
role='link'
onClick={(): void => onRowClick(_id)}
action
qa-user-id={_id}
>
<Table.Cell withTruncatedText>{fname}</Table.Cell>
<Table.Cell withTruncatedText>{department ? department.name : ''}</Table.Cell>
<Table.Cell withTruncatedText>{servedBy && servedBy.username}</Table.Cell>
<Table.Cell withTruncatedText>{moment(ts).format('L LTS')}</Table.Cell>
<Table.Cell withTruncatedText>{moment(lm).format('L LTS')}</Table.Cell>
<Table.Cell withTruncatedText>{open ? t('Open') : t('Closed')}</Table.Cell>
{canRemoveClosedChats && !open && <RemoveChatButton _id={_id} reload={reload} />}
</Table.Row>
),
[onRowClick, reload, t, canRemoveClosedChats],
({ _id, fname, servedBy, ts, lm, department, open, onHold }) => {
const getStatusText = (open: boolean, onHold: boolean): string => {
if (!open) return t('Closed');
return onHold ? t('On_Hold_Chats') : t('Open');
};
return (
<Table.Row
key={_id}
tabIndex={0}
role='link'
onClick={(): void => onRowClick(_id)}
action
qa-user-id={_id}
>
<Table.Cell withTruncatedText>{fname}</Table.Cell>
<Table.Cell withTruncatedText>{department ? department.name : ''}</Table.Cell>
<Table.Cell withTruncatedText>{servedBy && servedBy.username}</Table.Cell>
<Table.Cell withTruncatedText>{moment(ts).format('L LTS')}</Table.Cell>
<Table.Cell withTruncatedText>{moment(lm).format('L LTS')}</Table.Cell>
<Table.Cell withTruncatedText>{getStatusText(open, onHold)}</Table.Cell>
{canRemoveClosedChats && !open && <RemoveChatButton _id={_id} reload={reload} />}
</Table.Row>
);
},
[onRowClick, reload, canRemoveClosedChats, t],
);
if (!canViewCurrentChats) {

@ -31,6 +31,7 @@ const FilterByText: FilterByTextType = ({ setFilter, reload, ...props }) => {
['all', t('All')],
['closed', t('Closed')],
['opened', t('Open')],
['onhold', t('On_Hold_Chats')],
];
const customFieldsOptions: [string, string][] = useMemo(
() =>
@ -110,7 +111,7 @@ const FilterByText: FilterByTextType = ({ setFilter, reload, ...props }) => {
reload && reload();
dispatchToastMessage({ type: 'success', message: t('Chat_removed') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
dispatchToastMessage({ type: 'error', message: (error as Error).message });
}
setModal(null);
};

@ -7,11 +7,12 @@ import { useEndpointData } from '../../../../hooks/useEndpointData';
import Chart from './Chart';
import { useUpdateChartData } from './useUpdateChartData';
const labels = ['Open', 'Queued', 'Closed'];
const labels = ['Open', 'Queued', 'On_Hold_Chats', 'Closed'];
const initialData = {
open: 0,
queued: 0,
onhold: 0,
closed: 0,
};
@ -45,7 +46,7 @@ const ChatsChart = ({ params, reloadRef, ...props }) => {
reloadRef.current.chatsChart = reload;
const { open, queued, closed } = data ?? initialData;
const { open, queued, closed, onhold } = data ?? initialData;
useEffect(() => {
const initChart = async () => {
@ -58,9 +59,10 @@ const ChatsChart = ({ params, reloadRef, ...props }) => {
if (state === AsyncStatePhase.RESOLVED) {
updateChartData(t('Open'), [open]);
updateChartData(t('Closed'), [closed]);
updateChartData(t('On_Hold_Chats'), [onhold]);
updateChartData(t('Queued'), [queued]);
}
}, [closed, open, queued, state, t, updateChartData]);
}, [closed, open, queued, onhold, state, t, updateChartData]);
return <Chart ref={canvas} {...props} />;
};

@ -12,7 +12,7 @@ const initialData = {
};
const init = (canvas, context, t) =>
drawLineChart(canvas, context, [t('Open'), t('Closed')], [], [[], []], {
drawLineChart(canvas, context, [t('Open'), t('Closed'), t('On_Hold_Chats')], [], [[], []], {
legends: true,
anim: true,
smallTicks: true,
@ -53,7 +53,7 @@ const ChatsPerAgentChart = ({ params, reloadRef, ...props }) => {
if (chartData && chartData.success) {
delete chartData.success;
Object.entries(chartData).forEach(([name, value]) => {
updateChartData(name, [value.open, value.closed]);
updateChartData(name, [value.open, value.closed, value.onhold]);
});
}
}

@ -32,6 +32,10 @@ API.v1.addRoute('livechat/room.onHold', { authRequired: true }, {
return API.v1.failure('Room is already On-Hold');
}
if (!room.open) {
return API.v1.failure('Room cannot be placed on hold after being closed');
}
const user = Meteor.user();
if (!user) {
return API.v1.failure('Invalid user');

@ -3182,6 +3182,7 @@
"Omnichannel_External_Frame_URL": "External frame URL",
"On": "On",
"On_Hold_Chats": "On Hold",
"On_Hold_conversations": "On hold conversations",
"online": "online",
"Online": "Online",
"Only_authorized_users_can_write_new_messages": "Only authorized users can write new messages",

@ -3177,6 +3177,7 @@
"Omnichannel_External_Frame_URL": "URL del marco externo",
"On": "Activar",
"On_Hold_Chats": "En espera",
"On_Hold_conversations": "Conversaciones en espera",
"online": "en línea",
"Online": "Conectado",
"Only_authorized_users_can_write_new_messages": "Sólo los usuarios autorizados pueden escribir nuevos mensajes",

Loading…
Cancel
Save