feat: Omnichannel Reports (#30051)

Co-authored-by: Martin Schoeler <20868078+MartinSchoeler@users.noreply.github.com>
Co-authored-by: Kevin Aleman <11577696+KevLehman@users.noreply.github.com>
Co-authored-by: Tasso Evangelista <2263066+tassoevan@users.noreply.github.com>
pull/30142/head^2
Aleksander Nicacio da Silva 3 years ago committed by GitHub
parent ba18325806
commit ebab8c4dd8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      .changeset/serious-geckos-drive.md
  2. 6
      apps/meteor/client/views/omnichannel/sidebarItems.ts
  3. 1
      apps/meteor/ee/app/livechat-enterprise/server/api/index.ts
  4. 62
      apps/meteor/ee/app/livechat-enterprise/server/api/lib/dashboards.ts
  5. 130
      apps/meteor/ee/app/livechat-enterprise/server/api/reports.ts
  6. 4
      apps/meteor/ee/app/livechat-enterprise/server/hooks/applyRoomRestrictions.ts
  7. 1
      apps/meteor/ee/app/livechat-enterprise/server/permissions.ts
  8. 2
      apps/meteor/ee/client/components/dashboards/DownloadDataButton.tsx
  9. 0
      apps/meteor/ee/client/components/dashboards/PeriodSelector.tsx
  10. 41
      apps/meteor/ee/client/components/dashboards/periods.ts
  11. 0
      apps/meteor/ee/client/components/dashboards/usePeriodLabel.ts
  12. 0
      apps/meteor/ee/client/components/dashboards/usePeriodSelectorState.ts
  13. 26
      apps/meteor/ee/client/components/dashboards/usePeriodSelectorStorage.ts
  14. 37
      apps/meteor/ee/client/omnichannel/reports/ReportsPage.tsx
  15. 53
      apps/meteor/ee/client/omnichannel/reports/components/AgentsTable.tsx
  16. 149
      apps/meteor/ee/client/omnichannel/reports/components/BarChart.tsx
  17. 30
      apps/meteor/ee/client/omnichannel/reports/components/CardErrorState.tsx
  18. 60
      apps/meteor/ee/client/omnichannel/reports/components/PieChart.tsx
  19. 97
      apps/meteor/ee/client/omnichannel/reports/components/ReportCard.tsx
  20. 14
      apps/meteor/ee/client/omnichannel/reports/components/ResizeObserver.tsx
  21. 44
      apps/meteor/ee/client/omnichannel/reports/components/constants.ts
  22. 5
      apps/meteor/ee/client/omnichannel/reports/components/index.ts
  23. 6
      apps/meteor/ee/client/omnichannel/reports/hooks/index.ts
  24. 95
      apps/meteor/ee/client/omnichannel/reports/hooks/useAgentsSection.tsx
  25. 90
      apps/meteor/ee/client/omnichannel/reports/hooks/useChannelsSection.tsx
  26. 29
      apps/meteor/ee/client/omnichannel/reports/hooks/useDefaultDownload.tsx
  27. 67
      apps/meteor/ee/client/omnichannel/reports/hooks/useDepartmentsSection.tsx
  28. 7
      apps/meteor/ee/client/omnichannel/reports/hooks/useIsResizing.tsx
  29. 44
      apps/meteor/ee/client/omnichannel/reports/hooks/useResizeObserver.tsx
  30. 85
      apps/meteor/ee/client/omnichannel/reports/hooks/useStatusSection.tsx
  31. 77
      apps/meteor/ee/client/omnichannel/reports/hooks/useTagsSection.tsx
  32. 69
      apps/meteor/ee/client/omnichannel/reports/sections/AgentsSection.tsx
  33. 16
      apps/meteor/ee/client/omnichannel/reports/sections/ChannelsSection.tsx
  34. 33
      apps/meteor/ee/client/omnichannel/reports/sections/DepartmentsSection.tsx
  35. 14
      apps/meteor/ee/client/omnichannel/reports/sections/StatusSection.tsx
  36. 33
      apps/meteor/ee/client/omnichannel/reports/sections/TagsSection.tsx
  37. 5
      apps/meteor/ee/client/omnichannel/reports/sections/index.ts
  38. 3
      apps/meteor/ee/client/omnichannel/reports/utils/ellipsis.ts
  39. 2
      apps/meteor/ee/client/omnichannel/reports/utils/formatAttachmentName.ts
  40. 8
      apps/meteor/ee/client/omnichannel/reports/utils/formatPeriodDescription.tsx
  41. 18
      apps/meteor/ee/client/omnichannel/reports/utils/formatPeriodRange.ts
  42. 10
      apps/meteor/ee/client/omnichannel/reports/utils/getTop.ts
  43. 4
      apps/meteor/ee/client/omnichannel/reports/utils/round.ts
  44. 9
      apps/meteor/ee/client/omnichannel/routes.ts
  45. 6
      apps/meteor/ee/client/views/admin/engagementDashboard/channels/ChannelsOverview.tsx
  46. 4
      apps/meteor/ee/client/views/admin/engagementDashboard/channels/useChannelsList.ts
  47. 6
      apps/meteor/ee/client/views/admin/engagementDashboard/messages/MessagesPerChannelSection.tsx
  48. 8
      apps/meteor/ee/client/views/admin/engagementDashboard/messages/MessagesSentSection.tsx
  49. 4
      apps/meteor/ee/client/views/admin/engagementDashboard/messages/useMessageOrigins.ts
  50. 4
      apps/meteor/ee/client/views/admin/engagementDashboard/messages/useMessagesSent.ts
  51. 4
      apps/meteor/ee/client/views/admin/engagementDashboard/messages/useTopFivePopularChannels.ts
  52. 2
      apps/meteor/ee/client/views/admin/engagementDashboard/users/ActiveUsersSection.tsx
  53. 8
      apps/meteor/ee/client/views/admin/engagementDashboard/users/NewUsersSection.tsx
  54. 6
      apps/meteor/ee/client/views/admin/engagementDashboard/users/UsersByTimeOfTheDaySection.tsx
  55. 2
      apps/meteor/ee/client/views/admin/engagementDashboard/users/useActiveUsers.ts
  56. 4
      apps/meteor/ee/client/views/admin/engagementDashboard/users/useNewUsers.ts
  57. 4
      apps/meteor/ee/client/views/admin/engagementDashboard/users/useUsersByTimeOfTheDay.ts
  58. 440
      apps/meteor/ee/server/models/raw/LivechatRooms.ts
  59. 27
      apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json
  60. 29
      apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json
  61. 53
      apps/meteor/server/models/raw/LivechatRooms.ts
  62. 1
      apps/meteor/server/models/raw/Rooms.ts
  63. 576
      apps/meteor/tests/end-to-end/api/livechat/21-reports.ts
  64. 1
      packages/core-typings/src/omnichannel/index.ts
  65. 3
      packages/core-typings/src/omnichannel/reports.ts
  66. 42
      packages/rest-typings/src/v1/omnichannel.ts
  67. 6
      packages/ui-client/src/components/Card/index.ts

@ -0,0 +1,8 @@
---
'@rocket.chat/core-typings': minor
'@rocket.chat/rest-typings': minor
'@rocket.chat/ui-client': minor
'@rocket.chat/meteor': minor
---
Added Reports Metrics Dashboard to Omnichannel

@ -13,6 +13,12 @@ export const {
i18nLabel: 'Current_Chats',
permissionGranted: (): boolean => hasPermission('view-livechat-current-chats'),
},
{
href: '/omnichannel/reports',
icon: 'file',
i18nLabel: 'Reports',
permissionGranted: (): boolean => hasPermission('view-livechat-reports'),
},
{
href: '/omnichannel/analytics',
icon: 'dashboard',

@ -9,3 +9,4 @@ import './units';
import './business-hours';
import './rooms';
import './transcript';
import './reports';

@ -0,0 +1,62 @@
import type { ReportResult, ReportWithUnmatchingElements, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatRooms } from '@rocket.chat/models';
import mem from 'mem';
import type { Filter } from 'mongodb';
type AggParams = { start: Date; end: Date; sort: Record<string, 1 | -1>; extraQuery: Filter<IOmnichannelRoom> };
const defaultValue = { data: [], total: 0 };
export const findAllConversationsBySource = async ({ start, end, extraQuery }: Omit<AggParams, 'sort'>): Promise<ReportResult> => {
return (await LivechatRooms.getConversationsBySource(start, end, extraQuery).toArray())[0] || defaultValue;
};
export const findAllConversationsByStatus = async ({ start, end, extraQuery }: Omit<AggParams, 'sort'>): Promise<ReportResult> => {
return (await LivechatRooms.getConversationsByStatus(start, end, extraQuery).toArray())[0] || defaultValue;
};
export const findAllConversationsByDepartment = async ({
start,
end,
sort,
extraQuery,
}: AggParams): Promise<ReportWithUnmatchingElements> => {
const [result, total] = await Promise.all([
LivechatRooms.getConversationsByDepartment(start, end, sort, extraQuery).toArray(),
LivechatRooms.getTotalConversationsWithoutDepartmentBetweenDates(start, end, extraQuery),
]);
return {
...(result?.[0] || defaultValue),
unspecified: total || 0,
};
};
export const findAllConversationsByTags = async ({ start, end, sort, extraQuery }: AggParams): Promise<ReportWithUnmatchingElements> => {
const [result, total] = await Promise.all([
LivechatRooms.getConversationsByTags(start, end, sort, extraQuery).toArray(),
LivechatRooms.getConversationsWithoutTagsBetweenDate(start, end, extraQuery),
]);
return {
...(result?.[0] || defaultValue),
unspecified: total || 0,
};
};
export const findAllConversationsByAgents = async ({ start, end, sort, extraQuery }: AggParams): Promise<ReportWithUnmatchingElements> => {
const [result, total] = await Promise.all([
LivechatRooms.getConversationsByAgents(start, end, sort, extraQuery).toArray(),
LivechatRooms.getTotalConversationsWithoutAgentsBetweenDate(start, end, extraQuery),
]);
return {
...(result?.[0] || defaultValue),
unspecified: total || 0,
};
};
export const findAllConversationsBySourceCached = mem(findAllConversationsBySource, { maxAge: 60000, cacheKey: JSON.stringify });
export const findAllConversationsByStatusCached = mem(findAllConversationsByStatus, { maxAge: 60000, cacheKey: JSON.stringify });
export const findAllConversationsByDepartmentCached = mem(findAllConversationsByDepartment, { maxAge: 60000, cacheKey: JSON.stringify });
export const findAllConversationsByTagsCached = mem(findAllConversationsByTags, { maxAge: 60000, cacheKey: JSON.stringify });
export const findAllConversationsByAgentsCached = mem(findAllConversationsByAgents, { maxAge: 60000, cacheKey: JSON.stringify });

@ -0,0 +1,130 @@
import { isGETDashboardConversationsByType } from '@rocket.chat/rest-typings';
import type { Moment } from 'moment';
import moment from 'moment';
import { API } from '../../../../../app/api/server';
import { restrictQuery } from '../hooks/applyRoomRestrictions';
import {
findAllConversationsBySourceCached,
findAllConversationsByStatusCached,
findAllConversationsByDepartmentCached,
findAllConversationsByTagsCached,
findAllConversationsByAgentsCached,
} from './lib/dashboards';
const checkDates = (start: Moment, end: Moment) => {
if (!start.isValid()) {
throw new Error('The "start" query parameter must be a valid date.');
}
if (!end.isValid()) {
throw new Error('The "end" query parameter must be a valid date.');
}
// Check dates are no more than 1 year apart using moment
// 1.01 === "we allow to pass year by some hours/days"
if (moment(end).startOf('day').diff(moment(start).startOf('day'), 'year', true) > 1.01) {
throw new Error('The "start" and "end" query parameters must be less than 1 year apart.');
}
if (start.isAfter(end)) {
throw new Error('The "start" query parameter must be before the "end" query parameter.');
}
};
API.v1.addRoute(
'livechat/analytics/dashboards/conversations-by-source',
{ authRequired: true, permissionsRequired: ['view-livechat-reports'], validateParams: isGETDashboardConversationsByType },
{
async get() {
const { start, end } = this.queryParams;
const startDate = moment(start);
const endDate = moment(end);
checkDates(startDate, endDate);
const extraQuery = await restrictQuery();
const result = await findAllConversationsBySourceCached({ start: startDate.toDate(), end: endDate.toDate(), extraQuery });
return API.v1.success(result);
},
},
);
API.v1.addRoute(
'livechat/analytics/dashboards/conversations-by-status',
{ authRequired: true, permissionsRequired: ['view-livechat-reports'], validateParams: isGETDashboardConversationsByType },
{
async get() {
const { start, end } = this.queryParams;
const startDate = moment(start);
const endDate = moment(end);
checkDates(startDate, endDate);
const extraQuery = await restrictQuery();
const result = await findAllConversationsByStatusCached({ start: startDate.toDate(), end: endDate.toDate(), extraQuery });
return API.v1.success(result);
},
},
);
API.v1.addRoute(
'livechat/analytics/dashboards/conversations-by-department',
{ authRequired: true, permissionsRequired: ['view-livechat-reports'], validateParams: isGETDashboardConversationsByType },
{
async get() {
const { start, end } = this.queryParams;
const { sort } = await this.parseJsonQuery();
const startDate = moment(start);
const endDate = moment(end);
checkDates(startDate, endDate);
const extraQuery = await restrictQuery();
const result = await findAllConversationsByDepartmentCached({ start: startDate.toDate(), end: endDate.toDate(), sort, extraQuery });
return API.v1.success(result);
},
},
);
API.v1.addRoute(
'livechat/analytics/dashboards/conversations-by-tags',
{ authRequired: true, permissionsRequired: ['view-livechat-reports'], validateParams: isGETDashboardConversationsByType },
{
async get() {
const { start, end } = this.queryParams;
const { sort } = await this.parseJsonQuery();
const startDate = moment(start);
const endDate = moment(end);
checkDates(startDate, endDate);
const extraQuery = await restrictQuery();
const result = await findAllConversationsByTagsCached({ start: startDate.toDate(), end: endDate.toDate(), sort, extraQuery });
return API.v1.success(result);
},
},
);
API.v1.addRoute(
'livechat/analytics/dashboards/conversations-by-agent',
{ authRequired: true, permissionsRequired: ['view-livechat-reports'], validateParams: isGETDashboardConversationsByType },
{
async get() {
const { start, end } = this.queryParams;
const { sort } = await this.parseJsonQuery();
const startDate = moment(start);
const endDate = moment(end);
checkDates(startDate, endDate);
const extraQuery = await restrictQuery();
const result = await findAllConversationsByAgentsCached({ start: startDate.toDate(), end: endDate.toDate(), sort, extraQuery });
return API.v1.success(result);
},
},
);

@ -1,4 +1,5 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatDepartment } from '@rocket.chat/models';
import type { FilterOperators } from 'mongodb';
import { callbacks } from '../../../../../lib/callbacks';
@ -12,10 +13,11 @@ export const restrictQuery = async (originalQuery: FilterOperators<IOmnichannelR
if (!Array.isArray(units)) {
return query;
}
const departments = await LivechatDepartment.find({ ancestors: { $in: units } }, { projection: { _id: 1 } }).toArray();
const expressions = query.$and || [];
const condition = {
$or: [{ departmentAncestors: { $in: units } }, { departmentId: { $in: units } }],
$or: [{ departmentAncestors: { $in: units } }, { departmentId: { $in: departments.map(({ _id }) => _id) } }],
};
query.$and = [condition, ...expressions];

@ -17,6 +17,7 @@ export const omnichannelEEPermissions = [
{ _id: 'spy-voip-calls', roles: [adminRole, livechatManagerRole, livechatMonitorRole] },
{ _id: 'outbound-voip-calls', roles: [adminRole, livechatManagerRole] },
{ _id: 'request-pdf-transcript', roles: [adminRole, livechatManagerRole, livechatMonitorRole, livechatAgentRole] },
{ _id: 'view-livechat-reports', roles: [adminRole, livechatManagerRole, livechatMonitorRole] },
];
export const createPermissions = async (): Promise<void> => {

@ -4,7 +4,7 @@ import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-context
import type { ComponentProps, ReactElement } from 'react';
import React from 'react';
import { downloadCsvAs } from '../../../../../../client/lib/download';
import { downloadCsvAs } from '../../../../client/lib/download';
type RowFor<THeaders extends readonly string[]> = readonly unknown[] & {
length: THeaders['length'];

@ -14,21 +14,36 @@ const lastNDays =
end: Date;
}) =>
(utc): { start: Date; end: Date } => ({
start: utc
? moment.utc().startOf('day').subtract(n, 'days').toDate()
: moment()
.startOf('day')
.subtract(n + 1, 'days')
.toDate(),
end: utc ? moment.utc().endOf('day').subtract(1, 'days').toDate() : moment().endOf('day').toDate(),
start: utc ? moment.utc().startOf('day').subtract(n, 'days').toDate() : moment().startOf('day').subtract(n, 'days').toDate(),
end: utc ? moment.utc().endOf('day').toDate() : moment().endOf('day').toDate(),
});
const periods = [
{
key: 'today',
label: label('Today'),
range: lastNDays(0),
},
{
key: 'this week',
label: label('This_week'),
range: lastNDays(7),
},
{
key: 'last 7 days',
label: label('Last_7_days'),
range: lastNDays(7),
},
{
key: 'last 15 days',
label: label('Last_15_days'),
range: lastNDays(15),
},
{
key: 'this month',
label: label('This_month'),
range: lastNDays(30),
},
{
key: 'last 30 days',
label: label('Last_30_days'),
@ -39,6 +54,16 @@ const periods = [
label: label('Last_90_days'),
range: lastNDays(90),
},
{
key: 'last 6 months',
label: label('Last_6_months'),
range: lastNDays(180),
},
{
key: 'last year',
label: label('Last_year'),
range: lastNDays(365),
},
] as const;
export type Period = (typeof periods)[number];
@ -55,7 +80,7 @@ export const getPeriod = (key: (typeof periods)[number]['key']): Period => {
export const getPeriodRange = (
key: (typeof periods)[number]['key'],
utc = false,
utc = true,
): {
start: Date;
end: Date;

@ -0,0 +1,26 @@
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
import type { Period } from './periods';
export const usePeriodSelectorStorage = <TPeriod extends Period['key']>(
storageKey: string,
periods: TPeriod[],
): [
period: TPeriod,
periodSelectorProps: {
periods: TPeriod[];
value: TPeriod;
onChange: (value: TPeriod) => void;
},
] => {
const [period, setPeriod] = useLocalStorage<TPeriod>(storageKey, periods[0]);
return [
period,
{
periods,
value: period,
onChange: (value): void => setPeriod(value),
},
];
};

@ -0,0 +1,37 @@
import { Box } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React from 'react';
import Page from '../../../../client/components/Page';
import { ResizeObserver } from './components/ResizeObserver';
import { AgentsSection, ChannelsSection, DepartmentsSection, StatusSection, TagsSection } from './sections';
const ReportsPage = () => {
const t = useTranslation();
return (
<Page background='tint'>
<Page.Header title={t('Reports')} />
<Box is='p' color='hint' fontScale='p2' mi={24}>
{t('Omnichannel_Reports_Summary')}
</Box>
<Page.ScrollableContentWithShadow alignItems='center'>
<ResizeObserver>
<Box display='flex' flexWrap='wrap' width='100rem' maxWidth='100%' m={-8}>
<StatusSection />
<ChannelsSection />
<DepartmentsSection />
<TagsSection />
<AgentsSection />
</Box>
</ResizeObserver>
</Page.ScrollableContentWithShadow>
</Page>
);
};
export default ReportsPage;

@ -0,0 +1,53 @@
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React, { memo } from 'react';
import {
GenericTable,
GenericTableBody,
GenericTableCell,
GenericTableHeader,
GenericTableHeaderCell,
GenericTableRow,
} from '../../../../../client/components/GenericTable';
type AgentsTableProps = {
data: {
label: string;
value: number;
}[];
sortBy: string;
sortDirection: 'asc' | 'desc';
setSort: (sortBy: 'name' | 'total', sortDirection: 'asc' | 'desc') => void;
};
export const AgentsTable = memo(({ data, sortBy, sortDirection, setSort }: AgentsTableProps) => {
const t = useTranslation();
const onHeaderClick = useMutableCallback((id) => {
setSort(id, sortDirection === 'asc' ? 'desc' : 'asc');
});
return (
<GenericTable>
<GenericTableHeader>
<GenericTableHeaderCell sort='name' direction={sortDirection} active={sortBy === 'name'} onClick={onHeaderClick}>
{t('Agents')}
</GenericTableHeaderCell>
<GenericTableHeaderCell sort='total' direction={sortDirection} active={sortBy === 'total'} onClick={onHeaderClick}>
{t('Total_conversations')}
</GenericTableHeaderCell>
</GenericTableHeader>
<GenericTableBody>
{data.map((item) => (
<GenericTableRow key={`${item.label}_${item.value}`}>
<GenericTableCell withTruncatedText>{item.label}</GenericTableCell>
<GenericTableCell>{item.value}</GenericTableCell>
</GenericTableRow>
))}
</GenericTableBody>
</GenericTable>
);
});
AgentsTable.displayName = 'AgentsTable';

@ -0,0 +1,149 @@
import type { BarCustomLayerProps, BarDatum } from '@nivo/bar';
import { ResponsiveBar } from '@nivo/bar';
import { Box, Palette, Tooltip } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import React, { useMemo } from 'react';
import { REPORTS_CHARTS_THEME } from './constants';
type axisItem = {
ticksPosition?: 'before' | 'after';
tickValues?: number;
tickSize?: number;
tickPadding?: number;
tickRotation?: number;
format?: string | ((v: string | number) => string | number);
renderTick?: (props: any) => JSX.Element;
legend?: React.ReactNode;
legendPosition?: 'start' | 'middle' | 'end';
legendOffset?: number;
ariaHidden?: boolean;
};
type BarChartProps = {
data: {
label: string;
value: number;
color: string;
}[];
maxWidth?: string | number;
height: number;
direction?: 'vertical' | 'horizontal';
indexBy?: string;
keys?: string[];
reverse?: boolean;
margins?: { top?: number; right?: number; bottom?: number; left?: number };
axis: { axisTop?: axisItem; axisLeft?: axisItem; axisRight?: axisItem; axisBottom?: axisItem };
enableGridX?: boolean;
enableGridY?: boolean;
colors?: ComponentProps<typeof ResponsiveBar>['colors'];
};
const sideLabelStyle = {
fill: Palette.text['font-annotation'].toString(),
fontFamily:
'Inter, -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Meiryo UI", Arial, sans-serif',
fontSize: 12,
};
const horizontalSideLabel = ({ bars, labelSkipWidth }: BarCustomLayerProps<BarDatum>) => (
<g>
{bars?.map(({ width, height, y, data }) => {
if (width >= labelSkipWidth) {
return null;
}
return (
<text
key={data.indexValue}
transform={`translate(${width + 8}, ${y + height / 2})`}
textAnchor='left'
dominantBaseline='central'
style={sideLabelStyle}
>
{data.formattedValue}
</text>
);
})}
</g>
);
const verticalSideLabel = ({ bars, labelSkipHeight, innerHeight }: BarCustomLayerProps<BarDatum>) => (
<g>
{bars?.map(({ width, height, x, data }) => {
if (height >= labelSkipHeight) {
return null;
}
return (
<text
key={data.indexValue}
transform={`translate(${x + width / 2}, ${innerHeight - height - 8})`}
textAnchor='middle'
dominantBaseline='central'
style={sideLabelStyle}
>
{data.formattedValue}
</text>
);
})}
</g>
);
export const BarChart = ({
data,
maxWidth,
height,
direction = 'vertical',
indexBy = 'label',
keys,
margins,
reverse,
enableGridX = false,
enableGridY = false,
axis: { axisTop, axisLeft, axisRight, axisBottom } = {},
colors,
}: BarChartProps) => {
const { minHeight, padding } = useMemo(() => {
const minHeight = data.length * 22;
const padding = data.length <= 4 ? 0.5 : 0.05;
return { minHeight, padding };
}, [data.length]);
const sideLabel: any = direction === 'vertical' ? verticalSideLabel : horizontalSideLabel;
return (
<Box maxWidth={maxWidth} height={height} overflowY='auto'>
<Box position='relative' height={Math.max(minHeight, height)} padding={8} overflow='hidden'>
<ResponsiveBar
animate
data={data}
indexBy={indexBy}
layout={direction}
layers={['grid', 'axes', 'bars', 'markers', 'legends', 'annotations', sideLabel]}
indexScale={{ type: 'band', round: false }}
keys={keys}
groupMode='grouped'
padding={padding}
colors={colors ?? { datum: 'data.color' }}
enableGridY={enableGridY}
enableGridX={enableGridX}
axisTop={axisTop || null}
axisRight={axisRight || null}
axisBottom={axisBottom || null}
axisLeft={axisLeft || null}
reverse={reverse}
borderRadius={4}
labelTextColor='white'
margin={margins}
motionConfig='stiff'
theme={REPORTS_CHARTS_THEME}
labelSkipWidth={direction === 'horizontal' ? 24 : undefined}
labelSkipHeight={direction === 'vertical' ? 16 : undefined}
valueScale={{ type: 'linear' }}
tooltip={({ data }) => <Tooltip>{`${data.label}: ${data.value}`}</Tooltip>}
/>
</Box>
</Box>
);
};

@ -0,0 +1,30 @@
import { States, StatesAction, StatesActions, StatesIcon, StatesTitle } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { ReactElement, ReactNode } from 'react';
import React from 'react';
type CardErrorStateProps = {
children: ReactNode;
isError?: boolean;
onRetry?: () => void;
};
export const CardErrorState = ({ children, isError, onRetry }: CardErrorStateProps): ReactElement => {
const t = useTranslation();
return (
<>
{isError ? (
<States>
<StatesIcon name='circle-exclamation' />
<StatesTitle>{t('Something_went_wrong')}</StatesTitle>
<StatesActions data-qa='CardErrorState'>
<StatesAction onClick={onRetry}>{t('Retry')}</StatesAction>
</StatesActions>
</States>
) : (
children
)}
</>
);
};

@ -0,0 +1,60 @@
import { Pie } from '@nivo/pie';
import { Tooltip } from '@rocket.chat/fuselage';
import { useBreakpoints } from '@rocket.chat/fuselage-hooks';
import type { ComponentProps } from 'react';
import React from 'react';
import { REPORTS_CHARTS_THEME } from './constants';
const legendItemHeight = 20;
const legendItemWidth = 200;
const legendItemsSpacing = 8;
const legendSpacing = 24;
const legendInlineSize = legendItemWidth + legendSpacing;
export const PieChart = ({
data,
width,
height,
colors,
}: {
data: { label: string; value: number; id: string; color?: string }[];
width: number;
height: number;
colors?: ComponentProps<typeof Pie>['colors'];
}) => {
const breakpoints = useBreakpoints();
const isSmallScreen = !breakpoints.includes('md');
const legendBlockSize = data.length * (legendItemHeight + legendItemsSpacing) + legendSpacing;
return (
<Pie
data={data}
innerRadius={0.6}
colors={colors ?? { datum: 'data.color' }}
motionConfig='stiff'
theme={REPORTS_CHARTS_THEME}
enableArcLinkLabels={false}
enableArcLabels={false}
tooltip={({ datum }) => <Tooltip>{datum.label}</Tooltip>}
width={isSmallScreen ? width : width + legendInlineSize}
height={isSmallScreen ? height + legendBlockSize : height}
margin={isSmallScreen ? { top: legendBlockSize } : { right: legendInlineSize }}
legends={[
{
direction: 'column',
justify: false,
symbolSize: 12,
itemDirection: 'left-to-right',
symbolShape: 'circle',
anchor: isSmallScreen ? 'top' : 'right',
translateX: isSmallScreen ? 0 : legendInlineSize,
translateY: isSmallScreen ? legendBlockSize * -1 : 0,
itemWidth: legendItemWidth,
itemHeight: legendItemHeight,
itemsSpacing: legendItemsSpacing,
},
]}
/>
);
};

@ -0,0 +1,97 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { Box, Skeleton, States, StatesIcon, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage';
import { Card } from '@rocket.chat/ui-client';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { ReactNode, ComponentProps, ReactElement } from 'react';
import React from 'react';
import DownloadDataButton from '../../../components/dashboards/DownloadDataButton';
import PeriodSelector from '../../../components/dashboards/PeriodSelector';
import { useIsResizing } from '../hooks/useIsResizing';
import { CardErrorState } from './CardErrorState';
type ReportCardProps = {
title: string;
children: ReactNode;
periodSelectorProps: ComponentProps<typeof PeriodSelector>;
downloadProps: ComponentProps<typeof DownloadDataButton>;
isLoading?: boolean;
isDataFound?: boolean;
minHeight?: number;
loadingSkeleton?: ReactElement;
subtitle?: string;
emptyStateSubtitle?: string;
full?: boolean;
isError?: boolean;
onRetry?: () => void;
};
export const ReportCard = ({
title,
children,
periodSelectorProps,
downloadProps,
isLoading: isLoadingData,
isDataFound,
minHeight,
subtitle,
emptyStateSubtitle,
full,
isError,
onRetry,
loadingSkeleton: LoadingSkeleton = <Skeleton style={{ transform: 'none' }} height='100%' />,
}: ReportCardProps) => {
const t = useTranslation();
const width = full ? '100%' : '50%';
const isResizing = useIsResizing();
const isLoading = isLoadingData || isResizing;
return (
<Box
is={Card}
maxWidth='calc(100% - 16px)'
minWidth={`calc(${width} - 16px)`}
height='initial'
flexGrow={1}
flexShrink={0}
flexBasis='auto'
margin={8}
>
<Card.Title>
<Box display='flex' justifyContent='space-between' alignItems='center' flexWrap='wrap' style={{ rowGap: 8 }}>
<Box display='flex' flexDirection='column' flexShrink={1} mie={16}>
<Box is='span' withTruncatedText>
{title}
</Box>
<Box is='span' color='hint' fontScale='c1'>
{subtitle}
</Box>
</Box>
<Box flexGrow={0} flexShrink={0} display='flex' alignItems='center'>
<PeriodSelector {...periodSelectorProps} />
<DownloadDataButton {...downloadProps} title='Download CSV' size={32} />
</Box>
</Box>
</Card.Title>
<Card.Body>
<Card.Col>
<Box minHeight={minHeight}>
<CardErrorState isError={isError} onRetry={onRetry}>
{isLoading && LoadingSkeleton}
{!isLoading && !isDataFound && (
<States style={{ height: '100%' }}>
<StatesIcon name='dashboard' />
<StatesTitle>{t('No_data_available_for_the_selected_period')}</StatesTitle>
<StatesSubtitle>{emptyStateSubtitle}</StatesSubtitle>
</States>
)}
{!isLoading && isDataFound && children}
</CardErrorState>
</Box>
</Card.Col>
</Card.Body>
</Box>
);
};

@ -0,0 +1,14 @@
import type { ReactElement } from 'react';
import React, { cloneElement, createContext } from 'react';
import { useResizeObserver } from '../hooks/useResizeObserver';
export const ResizeContext = createContext(false);
type ResizeProviderProps = { children: ReactElement };
export const ResizeObserver = ({ children }: ResizeProviderProps) => {
const { ref, isResizing } = useResizeObserver();
return <ResizeContext.Provider value={isResizing}>{cloneElement(children, { ref })}</ResizeContext.Provider>;
};

@ -0,0 +1,44 @@
import { Palette } from '@rocket.chat/fuselage';
import type { Period } from '../../../components/dashboards/periods';
export const REPORTS_CHARTS_THEME = {
labels: {
text: { fontSize: 12 },
},
legends: {
text: {
fill: Palette.text['font-annotation'].toString(),
},
},
axis: {
domain: {
line: {
stroke: Palette.text['font-annotation'].toString(),
strokeWidth: 1,
},
},
ticks: {
text: {
fill: Palette.text['font-annotation'].toString(),
fontFamily:
'Inter, -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Meiryo UI", Arial, sans-serif',
fontSize: 12,
fontStyle: 'normal',
letterSpacing: '0.2px',
lineHeight: '16px',
},
},
},
};
export const COLORS = {
service2: Palette.statusColor['status-font-on-service-2'].toString(),
danger: Palette.statusColor['status-font-on-danger'].toString(),
success: Palette.statusColor['status-font-on-success'].toString(),
info: Palette.statusColor['status-font-on-info'].toString(),
warning: Palette.statusColor['status-font-on-warning'].toString(),
warning2: Palette.statusColor['status-font-on-warning-2'].toString(),
};
export const PERIOD_OPTIONS: Period['key'][] = ['today', 'this week', 'last 15 days', 'this month', 'last 6 months', 'last year'];

@ -0,0 +1,5 @@
export * from './AgentsTable';
export * from './BarChart';
export * from './PieChart';
export * from './ReportCard';
export * from './CardErrorState';

@ -0,0 +1,6 @@
export * from './useAgentsSection';
export * from './useDepartmentsSection';
export * from './useStatusSection';
export * from './useTagsSection';
export * from './useChannelsSection';
export * from './useDefaultDownload';

@ -0,0 +1,95 @@
import { useEndpoint, useTranslation } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useSort } from '../../../../../client/components/GenericTable/hooks/useSort';
import { getPeriodRange } from '../../../components/dashboards/periods';
import { usePeriodSelectorStorage } from '../../../components/dashboards/usePeriodSelectorStorage';
import { COLORS, PERIOD_OPTIONS } from '../components/constants';
import { formatPeriodDescription } from '../utils/formatPeriodDescription';
import { useDefaultDownload } from './useDefaultDownload';
const formatChartData = (data: { label: string; value: number }[] | undefined = []) =>
data.map((item) => ({
...item,
color: COLORS.info,
}));
export const useAgentsSection = () => {
const t = useTranslation();
const [period, periodSelectorProps] = usePeriodSelectorStorage('reports-agents-period', PERIOD_OPTIONS);
const getConversationsByAgent = useEndpoint('GET', '/v1/livechat/analytics/dashboards/conversations-by-agent');
const { sortBy, sortDirection, setSort } = useSort<'name' | 'total'>('total', 'desc');
const {
data: { data, total = 0, unspecified = 0 } = { data: [], total: 0 },
refetch,
isLoading,
isError,
isSuccess,
} = useQuery(
['omnichannel-reports', 'conversations-by-agent', period, sortBy, sortDirection],
async () => {
const { start, end } = getPeriodRange(period);
const response = await getConversationsByAgent({
start: start.toISOString(),
end: end.toISOString(),
sort: JSON.stringify({ [sortBy]: sortDirection === 'asc' ? 1 : -1 }),
});
return { ...response, data: formatChartData(response.data) };
},
{
refetchInterval: 5 * 60 * 1000,
},
);
const title = t('Conversations_by_agents');
const subtitleTotals = t('__agents__agents_and__count__conversations__period__', {
agents: data.length ?? 0,
count: total,
period: formatPeriodDescription(period, t),
});
const subtitleUnspecified = unspecified > 0 ? `(${t('__count__without__assignee__', { count: unspecified })})` : '';
const subtitle = `${subtitleTotals} ${subtitleUnspecified}`;
const emptyStateSubtitle = t('Omnichannel_Reports_Agents_Empty_Subtitle');
const downloadProps = useDefaultDownload({ columnName: t('Agents'), title, data, period });
return useMemo(
() => ({
title,
subtitle,
emptyStateSubtitle,
data,
total,
isLoading,
isError,
isDataFound: isSuccess && data.length > 0,
periodSelectorProps,
period,
downloadProps,
sortBy,
sortDirection,
setSort,
onRetry: refetch,
}),
[
title,
subtitle,
emptyStateSubtitle,
data,
total,
isLoading,
isError,
isSuccess,
periodSelectorProps,
period,
downloadProps,
sortBy,
sortDirection,
setSort,
refetch,
],
);
};

@ -0,0 +1,90 @@
import { capitalize } from '@rocket.chat/string-helpers';
import type { TranslationContextValue, TranslationKey } from '@rocket.chat/ui-contexts';
import { useEndpoint, useTranslation } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { getPeriodRange } from '../../../components/dashboards/periods';
import { usePeriodSelectorStorage } from '../../../components/dashboards/usePeriodSelectorStorage';
import { PERIOD_OPTIONS } from '../components/constants';
import { formatPeriodDescription } from '../utils/formatPeriodDescription';
import { getTop } from '../utils/getTop';
import { round } from '../utils/round';
import { useDefaultDownload } from './useDefaultDownload';
type DataItem = { label: string; value: number; id: string; rawLabel: string };
const TYPE_LABEL: Record<string, TranslationKey> = {
'widget': 'Livechat',
'email-inbox': 'Email',
'twilio': 'SMS',
'api': 'Custom_Integration',
};
const formatItem = (item: { label: string; value: number }, total: number, t: TranslationContextValue['translate']): DataItem => {
const percentage = total > 0 ? round((item.value / total) * 100) : 0;
const label = `${t(TYPE_LABEL[item.label]) || capitalize(item.label)}`;
return {
...item,
label: `${label} ${item.value} (${percentage}%)`,
rawLabel: label,
id: item.label,
};
};
const formatChartData = (data: { label: string; value: number }[] | undefined = [], total = 0, t: TranslationContextValue['translate']) => {
return data.map((item) => formatItem(item, total, t));
};
export const useChannelsSection = () => {
const t = useTranslation();
const [period, periodSelectorProps] = usePeriodSelectorStorage('reports-channels-period', PERIOD_OPTIONS);
const getConversationsBySource = useEndpoint('GET', '/v1/livechat/analytics/dashboards/conversations-by-source');
const {
data: { data, rawData, total } = { data: [], rawData: [], total: 0 },
refetch,
isLoading,
isError,
isSuccess,
} = useQuery(
['omnichannel-reports', 'conversations-by-source', period],
async () => {
const { start, end } = getPeriodRange(period);
const response = await getConversationsBySource({ start: start.toISOString(), end: end.toISOString() });
const data = formatChartData(response.data, response.total, t);
const displayData: DataItem[] = getTop<DataItem>(5, data, (value) => formatItem({ label: t('Others'), value }, response.total, t));
return { ...response, data: displayData, rawData: data };
},
{
refetchInterval: 5 * 60 * 1000,
},
);
const title = t('Conversations_by_channel');
const subtitle = t('__count__conversations__period__', {
count: total ?? 0,
period: formatPeriodDescription(period, t),
});
const emptyStateSubtitle = t('Omnichannel_Reports_Channels_Empty_Subtitle');
const downloadProps = useDefaultDownload({ columnName: t('Channel'), title, data: rawData, period });
return useMemo(
() => ({
title,
subtitle,
emptyStateSubtitle,
data,
total,
isLoading,
isError,
isDataFound: isSuccess && data.length > 0,
periodSelectorProps,
period,
downloadProps,
onRetry: refetch,
}),
[title, subtitle, emptyStateSubtitle, data, total, isLoading, isError, isSuccess, periodSelectorProps, period, downloadProps, refetch],
);
};

@ -0,0 +1,29 @@
import { useTranslation } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import type { Period } from '../../../components/dashboards/periods';
import { formatAttachmentName } from '../utils/formatAttachmentName';
import { formatPeriodRange } from '../utils/formatPeriodRange';
type DefaultDownloadHookProps = {
columnName: string;
title: string;
period: Period['key'];
data: { rawLabel?: string; label: string; value: number }[];
};
export const useDefaultDownload = ({ columnName, title, period, data }: DefaultDownloadHookProps) => {
const t = useTranslation();
return useMemo(() => {
const { start, end } = formatPeriodRange(period);
return {
attachmentName: formatAttachmentName(title, start, end),
headers: [t('From'), t('To'), columnName, t('Total')],
dataAvailable: data.length > 0,
dataExtractor() {
return data?.map(({ label, rawLabel, value }) => [start, end, rawLabel || label, value]);
},
};
}, [columnName, data, period, t, title]);
};

@ -0,0 +1,67 @@
import { useEndpoint, useTranslation } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { getPeriodRange } from '../../../components/dashboards/periods';
import { usePeriodSelectorStorage } from '../../../components/dashboards/usePeriodSelectorStorage';
import { COLORS, PERIOD_OPTIONS } from '../components/constants';
import { formatPeriodDescription } from '../utils/formatPeriodDescription';
import { useDefaultDownload } from './useDefaultDownload';
const formatChartData = (data: { label: string; value: number }[] | undefined = []) =>
data.map((item) => ({
...item,
color: COLORS.info,
}));
export const useDepartmentsSection = () => {
const t = useTranslation();
const [period, periodSelectorProps] = usePeriodSelectorStorage('reports-department-period', PERIOD_OPTIONS);
const getConversationsByDepartment = useEndpoint('GET', '/v1/livechat/analytics/dashboards/conversations-by-department');
const {
data: { data, total = 0, unspecified = 0 } = { data: [], total: 0 },
isLoading,
isError,
isSuccess,
} = useQuery(
['omnichannel-reports', 'conversations-by-department', period],
async () => {
const { start, end } = getPeriodRange(period);
const response = await getConversationsByDepartment({ start: start.toISOString(), end: end.toISOString() });
return { ...response, data: formatChartData(response.data) };
},
{
refetchInterval: 5 * 60 * 1000,
},
);
const title = t('Conversations_by_department');
const subtitleTotals = t('__departments__departments_and__count__conversations__period__', {
departments: data.length ?? 0,
count: total,
period: formatPeriodDescription(period, t),
});
const subtitleUnspecified = unspecified > 0 ? `(${t('__count__without__department__', { count: unspecified })})` : '';
const subtitle = `${subtitleTotals} ${subtitleUnspecified}`;
const emptyStateSubtitle = t('Omnichannel_Reports_Departments_Empty_Subtitle');
const downloadProps = useDefaultDownload({ columnName: t('Departments'), title, data, period });
return useMemo(
() => ({
title,
subtitle,
emptyStateSubtitle,
data,
total,
isLoading,
isError,
isDataFound: isSuccess && data.length > 0,
periodSelectorProps,
period,
downloadProps,
}),
[title, subtitle, emptyStateSubtitle, data, total, isLoading, isError, isSuccess, periodSelectorProps, period, downloadProps],
);
};

@ -0,0 +1,7 @@
import { useContext } from 'react';
import { ResizeContext } from '../components/ResizeObserver';
export const useIsResizing = () => {
return useContext(ResizeContext);
};

@ -0,0 +1,44 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useEffect, useRef, useState } from 'react';
export const useResizeObserver = <T extends Element>() => {
const [isResizing, setResizing] = useState(false);
const ref = useRef<T>(null);
const timeoutId = useRef<NodeJS.Timeout | null>(null);
const lastWidth = useRef<number | null>(null);
useEffect(() => {
const { current: element } = ref;
if (!element) {
return;
}
const resizeObserver = new ResizeObserver(([firstEntry]) => {
const currentWidth = firstEntry.borderBoxSize[0].inlineSize;
if (currentWidth === lastWidth.current) {
return;
}
if (timeoutId.current) {
clearTimeout(timeoutId.current);
}
if (!isResizing) {
setResizing(true);
}
lastWidth.current = currentWidth;
timeoutId.current = setTimeout(() => {
setResizing(false);
timeoutId.current = null;
}, 200);
});
resizeObserver.observe(element);
return () => resizeObserver.unobserve(element);
}, []);
return { isResizing, ref };
};

@ -0,0 +1,85 @@
import type { TranslationContextValue, TranslationKey } from '@rocket.chat/ui-contexts';
import { useEndpoint, useTranslation } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { getPeriodRange } from '../../../components/dashboards/periods';
import { usePeriodSelectorStorage } from '../../../components/dashboards/usePeriodSelectorStorage';
import { COLORS, PERIOD_OPTIONS } from '../components/constants';
import { formatPeriodDescription } from '../utils/formatPeriodDescription';
import { round } from '../utils/round';
import { useDefaultDownload } from './useDefaultDownload';
const STATUSES: Record<string, { label: TranslationKey; color: string }> = {
Open: { label: 'Omnichannel_Reports_Status_Open', color: COLORS.success },
Queued: { label: 'Queued', color: COLORS.warning2 },
On_Hold: { label: 'On_Hold', color: COLORS.warning },
Closed: { label: 'Omnichannel_Reports_Status_Closed', color: COLORS.danger },
};
const formatChartData = (data: { label: string; value: number }[] | undefined = [], total = 0, t: TranslationContextValue['translate']) => {
return data.map((item) => {
const status = STATUSES[item.label];
const percentage = total > 0 ? round((item.value / total) * 100) : 0;
const label = t(status.label);
return {
...item,
id: item.label,
label: `${label} ${item.value} (${percentage}%)`,
rawLabel: label,
color: status.color,
};
});
};
export const useStatusSection = () => {
const t = useTranslation();
const [period, periodSelectorProps] = usePeriodSelectorStorage('reports-status-period', PERIOD_OPTIONS);
const getConversationsByStatus = useEndpoint('GET', '/v1/livechat/analytics/dashboards/conversations-by-status');
const { start, end } = getPeriodRange(period);
const {
data: { data, total } = { data: [], total: 0 },
isLoading,
isError,
isSuccess,
refetch,
} = useQuery(
['omnichannel-reports', 'conversations-by-status', period, t],
async () => {
const response = await getConversationsByStatus({ start: start.toISOString(), end: end.toISOString() });
return { ...response, data: formatChartData(response.data, response.total, t) };
},
{
refetchInterval: 5 * 60 * 1000,
},
);
const title = t('Conversations_by_status');
const subtitle = t('__count__conversations__period__', {
count: total ?? 0,
period: formatPeriodDescription(period, t),
});
const emptyStateSubtitle = t('Omnichannel_Reports_Status_Empty_Subtitle');
const downloadProps = useDefaultDownload({ columnName: t('Status'), title, data, period });
return useMemo(
() => ({
title,
subtitle,
emptyStateSubtitle,
data,
total,
period,
periodSelectorProps,
downloadProps,
isLoading,
isError,
isDataFound: isSuccess && data.length > 0,
onRetry: refetch,
}),
[title, subtitle, emptyStateSubtitle, data, total, period, periodSelectorProps, downloadProps, isLoading, isError, isSuccess, refetch],
);
};

@ -0,0 +1,77 @@
import { Palette } from '@rocket.chat/fuselage';
import { useEndpoint, useTranslation } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { getPeriodRange } from '../../../components/dashboards/periods';
import { usePeriodSelectorStorage } from '../../../components/dashboards/usePeriodSelectorStorage';
import { PERIOD_OPTIONS } from '../components/constants';
import { formatPeriodDescription } from '../utils/formatPeriodDescription';
import { useDefaultDownload } from './useDefaultDownload';
const colors = {
warning: Palette.statusColor['status-font-on-warning'].toString(),
danger: Palette.statusColor['status-font-on-danger'].toString(),
success: Palette.statusColor['status-font-on-success'].toString(),
info: Palette.statusColor['status-font-on-info'].toString(),
};
const formatChartData = (data: { label: string; value: number }[] | undefined = []) =>
data.map((item) => ({
...item,
color: colors.info,
}));
export const useTagsSection = () => {
const t = useTranslation();
const [period, periodSelectorProps] = usePeriodSelectorStorage('reports-tags-period', PERIOD_OPTIONS);
const getConversationsByTags = useEndpoint('GET', '/v1/livechat/analytics/dashboards/conversations-by-tags');
const {
data: { data, total = 0, unspecified = 0 } = { data: [], total: 0 },
refetch,
isLoading,
isError,
isSuccess,
} = useQuery(
['omnichannel-reports', 'conversations-by-tags', period],
async () => {
const { start, end } = getPeriodRange(period);
const response = await getConversationsByTags({ start: start.toISOString(), end: end.toISOString() });
return { ...response, data: formatChartData(response.data) };
},
{
refetchInterval: 5 * 60 * 1000,
},
);
const title = t('Conversations_by_tag');
const subtitleTotals = t('__count__tags__and__count__conversations__period__', {
count: data.length,
conversations: total,
period: formatPeriodDescription(period, t),
});
const subtitleUnspecified = unspecified > 0 ? `(${t('__count__without__tags__', { count: unspecified })})` : '';
const subtitle = `${subtitleTotals} ${subtitleUnspecified}`;
const emptyStateSubtitle = t('Omnichannel_Reports_Tags_Empty_Subtitle');
const downloadProps = useDefaultDownload({ columnName: t('Tags'), title, data, period });
return useMemo(
() => ({
title,
subtitle,
emptyStateSubtitle,
data,
total,
period,
periodSelectorProps,
downloadProps,
isError,
isLoading,
isDataFound: isSuccess && data.length > 0,
onRetry: refetch,
}),
[title, subtitle, emptyStateSubtitle, data, total, isError, isLoading, isSuccess, periodSelectorProps, period, downloadProps, refetch],
);
};

@ -0,0 +1,69 @@
/* eslint-disable react/no-multi-comp */
import { Box, Flex, Skeleton } from '@rocket.chat/fuselage';
import { useBreakpoints } from '@rocket.chat/fuselage-hooks';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React from 'react';
import { AgentsTable, BarChart, ReportCard } from '../components';
import { useAgentsSection } from '../hooks';
import { ellipsis } from '../utils/ellipsis';
const LoadingSkeleton = () => (
<Box display='flex' height='100%' width='100%'>
<Box flexGrow={1}>
<Skeleton style={{ transform: 'none' }} height='100%' mb={8} mie={16} />
</Box>
<Box flexGrow={1}>
<Skeleton height={28} />
<Skeleton height={28} />
<Skeleton height={28} />
<Skeleton height={28} />
<Skeleton height={28} />
</Box>
</Box>
);
export const AgentsSection = () => {
const { data, sortBy, sortDirection, setSort, ...config } = useAgentsSection();
const t = useTranslation();
const breakpoints = useBreakpoints();
const isSmallScreen = !breakpoints.includes('lg');
return (
<ReportCard {...config} full minHeight={360} loadingSkeleton={<LoadingSkeleton />}>
<Box display='flex' style={{ gap: '1rem' }} flexWrap='wrap' flexDirection={isSmallScreen ? 'column' : 'row'}>
<Flex.Item grow={1} shrink={0} basis='auto'>
<Box>
<Box is='p' fontScale='p2' mbe={8}>
{t('Top_5_agents_with_the_most_conversations')}
</Box>
<BarChart
data={data.slice(0, 5)}
height={360}
indexBy='label'
keys={['value']}
margins={{ top: 45, right: 0, bottom: 50, left: 50 }}
axis={{
axisBottom: {
tickSize: 0,
tickRotation: 0,
format: (v) => ellipsis(v, 10),
},
axisLeft: {
tickSize: 0,
tickRotation: 0,
tickValues: 4,
},
}}
/>
</Box>
</Flex.Item>
<Flex.Item grow={1} shrink={0} basis='auto'>
<Box display='flex' minWidth='50%' height={390}>
<AgentsTable data={data} sortBy={sortBy} sortDirection={sortDirection} setSort={setSort} />
</Box>
</Flex.Item>
</Box>
</ReportCard>
);
};

@ -0,0 +1,16 @@
import React, { useMemo } from 'react';
import { PieChart, ReportCard } from '../components';
import { COLORS } from '../components/constants';
import { useChannelsSection } from '../hooks';
export const ChannelsSection = () => {
const { data, ...config } = useChannelsSection();
const colors = useMemo(() => Object.values(COLORS), []);
return (
<ReportCard {...config} minHeight={200}>
<PieChart data={data} width={200} height={200} colors={colors} />
</ReportCard>
);
};

@ -0,0 +1,33 @@
import React from 'react';
import { BarChart, ReportCard } from '../components';
import { useDepartmentsSection } from '../hooks';
import { ellipsis } from '../utils/ellipsis';
export const DepartmentsSection = () => {
const { data, ...config } = useDepartmentsSection();
return (
<ReportCard {...config} minHeight={360}>
<BarChart
data={data}
direction='horizontal'
height={360}
margins={{ left: 90, top: 30, right: 8 }}
axis={{
axisLeft: {
tickSize: 0,
tickRotation: 0,
format: (v) => ellipsis(v, 10),
},
axisTop: {
tickSize: 0,
tickRotation: 0,
tickValues: 4,
format: (v) => ellipsis(v, 10),
},
}}
/>
</ReportCard>
);
};

@ -0,0 +1,14 @@
import React from 'react';
import { PieChart, ReportCard } from '../components';
import { useStatusSection } from '../hooks';
export const StatusSection = () => {
const { data, ...config } = useStatusSection();
return (
<ReportCard {...config} minHeight={200}>
<PieChart data={data} width={200} height={200} />
</ReportCard>
);
};

@ -0,0 +1,33 @@
import React from 'react';
import { BarChart, ReportCard } from '../components';
import { useTagsSection } from '../hooks';
import { ellipsis } from '../utils/ellipsis';
export const TagsSection = () => {
const { data, ...config } = useTagsSection();
return (
<ReportCard {...config} minHeight={360}>
<BarChart
data={data}
direction='horizontal'
height={360}
margins={{ left: 90, top: 30, right: 8 }}
axis={{
axisLeft: {
tickSize: 0,
tickRotation: 0,
format: (v) => ellipsis(v, 10),
},
axisTop: {
tickSize: 0,
tickRotation: 0,
tickValues: 4,
format: (v) => ellipsis(v, 10),
},
}}
/>
</ReportCard>
);
};

@ -0,0 +1,5 @@
export * from './AgentsSection';
export * from './DepartmentsSection';
export * from './StatusSection';
export * from './TagsSection';
export * from './ChannelsSection';

@ -0,0 +1,3 @@
export const ellipsis = (value: string | number, max: number) => {
return String(value).length > max ? `${String(value).substring(0, max)}...` : value;
};

@ -0,0 +1,2 @@
export const formatAttachmentName = (attachmentName: string, start: string, end: string): string =>
`${attachmentName.toLocaleLowerCase().replace(/ /g, '_')}_${start}_${end}`;

@ -0,0 +1,8 @@
import type { TranslationContextValue } from '@rocket.chat/ui-contexts';
import { getPeriod, type Period } from '../../../components/dashboards/periods';
export const formatPeriodDescription = (periodKey: Period['key'], t: TranslationContextValue['translate']) => {
const { label } = getPeriod(periodKey);
return t(...label).toLocaleLowerCase();
};

@ -0,0 +1,18 @@
import type { Period } from '../../../components/dashboards/periods';
import { getPeriodRange } from '../../../components/dashboards/periods';
const formatDate = (date: Date): string => {
const day = String(date.getUTCDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
return `${year}-${month}-${day}`;
};
export const formatPeriodRange = (period: Period['key']): { start: string; end: string } => {
const { start, end } = getPeriodRange(period);
return {
start: formatDate(start),
end: formatDate(end),
};
};

@ -0,0 +1,10 @@
export const getTop = <T extends { label: string; value: number }>(limit = 5, data: T[], formatter: (others: number) => T): T[] => {
if (data.length < limit) {
return data;
}
const topItems = data.slice(0, limit);
const othersValue = data.slice(limit).reduce((total, item) => total + item.value, 0);
return othersValue > 0 ? [...topItems, formatter(othersValue)] : topItems;
};

@ -0,0 +1,4 @@
export const round = (value: number, decimals = 2) => {
const multiplier = Math.pow(10, decimals);
return Math.round(value * multiplier) / multiplier;
};

@ -20,6 +20,10 @@ declare module '@rocket.chat/ui-contexts' {
pattern: '/omnichannel/canned-responses/:context?/:id?';
pathname: `/omnichannel/canned-responses${`/${string}` | ''}${`/${string}` | ''}`;
};
'omnichannel-reports': {
pattern: '/omnichannel/reports';
pathname: `/omnichannel/reports`;
};
}
}
@ -42,3 +46,8 @@ registerOmnichannelRoute('/canned-responses/:context?/:id?', {
name: 'omnichannel-canned-responses',
component: lazy(() => import('./cannedResponses/CannedResponsesRoute')),
});
registerOmnichannelRoute('/reports', {
name: 'omnichannel-reports',
component: lazy(() => import('./reports/ReportsPage')),
});

@ -5,10 +5,10 @@ import type { ReactElement } from 'react';
import React, { useMemo, useState } from 'react';
import Growth from '../../../../../../client/components/dataView/Growth';
import DownloadDataButton from '../../../../components/dashboards/DownloadDataButton';
import PeriodSelector from '../../../../components/dashboards/PeriodSelector';
import { usePeriodSelectorState } from '../../../../components/dashboards/usePeriodSelectorState';
import EngagementDashboardCardFilter from '../EngagementDashboardCardFilter';
import DownloadDataButton from '../dataView/DownloadDataButton';
import PeriodSelector from '../dataView/PeriodSelector';
import { usePeriodSelectorState } from '../dataView/usePeriodSelectorState';
import { useChannelsList } from './useChannelsList';
const ChannelsOverview = (): ReactElement => {

@ -1,8 +1,8 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { Period } from '../dataView/periods';
import { getPeriodRange } from '../dataView/periods';
import type { Period } from '../../../../components/dashboards/periods';
import { getPeriodRange } from '../../../../components/dashboards/periods';
type UseChannelsListOptions = {
period: Period['key'];

@ -18,11 +18,11 @@ import { useTranslation } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import React, { useMemo } from 'react';
import DownloadDataButton from '../../../../components/dashboards/DownloadDataButton';
import PeriodSelector from '../../../../components/dashboards/PeriodSelector';
import { usePeriodSelectorState } from '../../../../components/dashboards/usePeriodSelectorState';
import EngagementDashboardCardFilter from '../EngagementDashboardCardFilter';
import DownloadDataButton from '../dataView/DownloadDataButton';
import LegendSymbol from '../dataView/LegendSymbol';
import PeriodSelector from '../dataView/PeriodSelector';
import { usePeriodSelectorState } from '../dataView/usePeriodSelectorState';
import { useMessageOrigins } from './useMessageOrigins';
import { useTopFivePopularChannels } from './useTopFivePopularChannels';

@ -7,11 +7,11 @@ import type { ReactElement } from 'react';
import React, { useMemo } from 'react';
import CounterSet from '../../../../../../client/components/dataView/CounterSet';
import DownloadDataButton from '../../../../components/dashboards/DownloadDataButton';
import PeriodSelector from '../../../../components/dashboards/PeriodSelector';
import { usePeriodLabel } from '../../../../components/dashboards/usePeriodLabel';
import { usePeriodSelectorState } from '../../../../components/dashboards/usePeriodSelectorState';
import EngagementDashboardCardFilter from '../EngagementDashboardCardFilter';
import DownloadDataButton from '../dataView/DownloadDataButton';
import PeriodSelector from '../dataView/PeriodSelector';
import { usePeriodLabel } from '../dataView/usePeriodLabel';
import { usePeriodSelectorState } from '../dataView/usePeriodSelectorState';
import { useMessagesSent } from './useMessagesSent';
const MessagesSentSection = (): ReactElement => {

@ -1,8 +1,8 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { Period } from '../dataView/periods';
import { getPeriodRange } from '../dataView/periods';
import type { Period } from '../../../../components/dashboards/periods';
import { getPeriodRange } from '../../../../components/dashboards/periods';
type UseMessageOriginsOptions = { period: Period['key'] };

@ -1,8 +1,8 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { Period } from '../dataView/periods';
import { getPeriodRange } from '../dataView/periods';
import type { Period } from '../../../../components/dashboards/periods';
import { getPeriodRange } from '../../../../components/dashboards/periods';
type UseMessagesSentOptions = { period: Period['key'] };

@ -1,8 +1,8 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { Period } from '../dataView/periods';
import { getPeriodRange } from '../dataView/periods';
import type { Period } from '../../../../components/dashboards/periods';
import { getPeriodRange } from '../../../../components/dashboards/periods';
type UseTopFivePopularChannelsOptions = { period: Period['key'] };

@ -8,8 +8,8 @@ import React, { useMemo } from 'react';
import CounterSet from '../../../../../../client/components/dataView/CounterSet';
import { useFormatDate } from '../../../../../../client/hooks/useFormatDate';
import DownloadDataButton from '../../../../components/dashboards/DownloadDataButton';
import EngagementDashboardCardFilter from '../EngagementDashboardCardFilter';
import DownloadDataButton from '../dataView/DownloadDataButton';
import LegendSymbol from '../dataView/LegendSymbol';
import { useActiveUsers } from './useActiveUsers';

@ -9,11 +9,11 @@ import React, { useMemo } from 'react';
import CounterSet from '../../../../../../client/components/dataView/CounterSet';
import { useFormatDate } from '../../../../../../client/hooks/useFormatDate';
import DownloadDataButton from '../../../../components/dashboards/DownloadDataButton';
import PeriodSelector from '../../../../components/dashboards/PeriodSelector';
import { usePeriodLabel } from '../../../../components/dashboards/usePeriodLabel';
import { usePeriodSelectorState } from '../../../../components/dashboards/usePeriodSelectorState';
import EngagementDashboardCardFilter from '../EngagementDashboardCardFilter';
import DownloadDataButton from '../dataView/DownloadDataButton';
import PeriodSelector from '../dataView/PeriodSelector';
import { usePeriodLabel } from '../dataView/usePeriodLabel';
import { usePeriodSelectorState } from '../dataView/usePeriodSelectorState';
import { useNewUsers } from './useNewUsers';
const TICK_WIDTH = 45;

@ -6,10 +6,10 @@ import moment from 'moment';
import type { ReactElement } from 'react';
import React, { useMemo } from 'react';
import DownloadDataButton from '../../../../components/dashboards/DownloadDataButton';
import PeriodSelector from '../../../../components/dashboards/PeriodSelector';
import { usePeriodSelectorState } from '../../../../components/dashboards/usePeriodSelectorState';
import EngagementDashboardCardFilter from '../EngagementDashboardCardFilter';
import DownloadDataButton from '../dataView/DownloadDataButton';
import PeriodSelector from '../dataView/PeriodSelector';
import { usePeriodSelectorState } from '../dataView/usePeriodSelectorState';
import { useUsersByTimeOfTheDay } from './useUsersByTimeOfTheDay';
type UsersByTimeOfTheDaySectionProps = {

@ -2,7 +2,7 @@ import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import moment from 'moment';
import { getPeriodRange } from '../dataView/periods';
import { getPeriodRange } from '../../../../components/dashboards/periods';
type UseActiveUsersOptions = { utc: boolean };

@ -1,8 +1,8 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { Period } from '../dataView/periods';
import { getPeriodRange } from '../dataView/periods';
import type { Period } from '../../../../components/dashboards/periods';
import { getPeriodRange } from '../../../../components/dashboards/periods';
export const useNewUsers = ({ period, utc }: { period: Period['key']; utc: boolean }) => {
const getNewUsers = useEndpoint('GET', '/v1/engagement-dashboard/users/new-users');

@ -1,8 +1,8 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { Period } from '../dataView/periods';
import { getPeriodRange } from '../dataView/periods';
import type { Period } from '../../../../components/dashboards/periods';
import { getPeriodRange } from '../../../../components/dashboards/periods';
type UseUsersByTimeOfTheDayOptions = { period: Period['key']; utc: boolean };

@ -3,11 +3,13 @@ import type {
IOmnichannelRoom,
IOmnichannelServiceLevelAgreements,
RocketChatRecordDeleted,
ReportResult,
} from '@rocket.chat/core-typings';
import { LivechatPriorityWeight, DEFAULT_SLA_CONFIG } from '@rocket.chat/core-typings';
import type { ILivechatRoomsModel } from '@rocket.chat/model-typings';
import type { FindCursor, UpdateResult, Document, FindOptions, Db, Collection, Filter } from 'mongodb';
import type { FindCursor, UpdateResult, Document, FindOptions, Db, Collection, Filter, AggregationCursor } from 'mongodb';
import { readSecondaryPreferred } from '../../../../server/database/readSecondaryPreferred';
import { LivechatRoomsRaw } from '../../../../server/models/raw/LivechatRooms';
import { queriesLogger } from '../../../app/livechat-enterprise/server/lib/logger';
import { addQueryRestrictionsToRoomsModel } from '../../../app/livechat-enterprise/server/lib/query.helper';
@ -41,6 +43,29 @@ declare module '@rocket.chat/model-typings' {
countRoomsWithSla(): Promise<number>;
countRoomsWithPdfTranscriptRequested(): Promise<number>;
countRoomsWithTranscriptSent(): Promise<number>;
getConversationsBySource(start: Date, end: Date, extraQuery: Filter<IOmnichannelRoom>): AggregationCursor<ReportResult>;
getConversationsByStatus(start: Date, end: Date, extraQuery: Filter<IOmnichannelRoom>): AggregationCursor<ReportResult>;
getConversationsByDepartment(
start: Date,
end: Date,
sort: Record<string, 1 | -1>,
extraQuery: Filter<IOmnichannelRoom>,
): AggregationCursor<ReportResult>;
getConversationsByTags(
start: Date,
end: Date,
sort: Record<string, 1 | -1>,
extraQuery: Filter<IOmnichannelRoom>,
): AggregationCursor<ReportResult>;
getConversationsByAgents(
start: Date,
end: Date,
sort: Record<string, 1 | -1>,
extraQuery: Filter<IOmnichannelRoom>,
): AggregationCursor<ReportResult>;
getConversationsWithoutTagsBetweenDate(start: Date, end: Date, extraQuery: Filter<IOmnichannelRoom>): Promise<number>;
getTotalConversationsWithoutAgentsBetweenDate(start: Date, end: Date, extraQuery: Filter<IOmnichannelRoom>): Promise<number>;
getTotalConversationsWithoutDepartmentBetweenDates(start: Date, end: Date, extraQuery: Filter<IOmnichannelRoom>): Promise<number>;
}
}
@ -313,4 +338,417 @@ export class LivechatRoomsRawEE extends LivechatRoomsRaw implements ILivechatRoo
queriesLogger.debug({ msg: 'LivechatRoomsRawEE.updateMany', query: restrictedQuery });
return super.updateMany(restrictedQuery, ...restArgs);
}
getConversationsBySource(start: Date, end: Date, extraQuery: Filter<IOmnichannelRoom>): AggregationCursor<ReportResult> {
return this.col.aggregate(
[
{
$match: {
source: {
$exists: true,
},
t: 'l',
ts: {
$gte: start,
$lt: end,
},
...extraQuery,
},
},
{
$group: {
_id: '$source',
value: { $sum: 1 },
},
},
{
$sort: { value: -1 },
},
{
$group: {
_id: null,
total: { $sum: '$value' },
data: {
$push: {
label: {
$ifNull: ['$_id.alias', '$_id.type'],
},
value: '$value',
},
},
},
},
{
$project: {
_id: 0,
},
},
],
{ hint: 'source_1_ts_1', readPreference: readSecondaryPreferred() },
);
}
getConversationsByStatus(start: Date, end: Date, extraQuery: Filter<IOmnichannelRoom>): AggregationCursor<ReportResult> {
return this.col.aggregate(
[
{
$match: {
t: 'l',
ts: {
$gte: start,
$lt: end,
},
...extraQuery,
},
},
{
$group: {
_id: null,
total: { $sum: 1 },
open: {
$sum: {
$cond: [
{
$and: [
{ $eq: ['$open', true] },
{
$or: [{ $not: ['$onHold'] }, { $eq: ['$onHold', false] }],
},
{ $ifNull: ['$servedBy', false] },
],
},
1,
0,
],
},
},
closed: {
$sum: {
$cond: [
{
$ifNull: ['$metrics.chatDuration', false],
},
1,
0,
],
},
},
queued: {
$sum: {
$cond: [
{
$and: [
{ $eq: ['$open', true] },
{
$eq: [
{
$ifNull: ['$servedBy', null],
},
null,
],
},
],
},
1,
0,
],
},
},
onhold: {
$sum: {
$cond: [{ $eq: ['$onHold', true] }, 1, 0],
},
},
},
},
{
$project: {
total: 1,
data: [
{ label: 'Open', value: '$open' },
{ label: 'Closed', value: '$closed' },
{ label: 'Queued', value: '$queued' },
{ label: 'On_Hold', value: '$onhold' },
],
},
},
{
$unwind: '$data',
},
{
$sort: { 'data.value': -1 },
},
{
$group: {
_id: '$_id',
total: { $first: '$total' },
data: { $push: '$data' },
},
},
{
$project: {
_id: 0,
},
},
],
{ readPreference: readSecondaryPreferred() },
);
}
getConversationsByDepartment(
start: Date,
end: Date,
sort: Record<string, 1 | -1>,
extraQuery: Filter<IOmnichannelRoom>,
): AggregationCursor<ReportResult> {
return this.col.aggregate(
[
{
$match: {
t: 'l',
departmentId: {
$exists: true,
},
ts: {
$lt: end,
$gte: start,
},
...extraQuery,
},
},
{
$group: {
_id: '$departmentId',
total: { $sum: 1 },
},
},
{
$lookup: {
from: 'rocketchat_livechat_department',
localField: '_id',
foreignField: '_id',
as: 'department',
},
},
{
$group: {
_id: {
$arrayElemAt: ['$department.name', 0],
},
total: {
$sum: '$total',
},
},
},
{
$sort: sort || { total: 1 },
},
{
$group: {
_id: null,
total: { $sum: '$total' },
data: {
$push: {
label: '$_id',
value: '$total',
},
},
},
},
{
$project: {
_id: 0,
},
},
],
{ hint: 'departmentId_1_ts_1', readPreference: readSecondaryPreferred() },
);
}
getTotalConversationsWithoutDepartmentBetweenDates(start: Date, end: Date, extraQuery: Filter<IOmnichannelRoom>): Promise<number> {
return this.col.countDocuments({
t: 'l',
departmentId: {
$exists: false,
},
ts: {
$gte: start,
$lt: end,
},
...extraQuery,
});
}
getConversationsByTags(
start: Date,
end: Date,
sort: Record<string, 1 | -1>,
extraQuery: Filter<IOmnichannelRoom>,
): AggregationCursor<ReportResult> {
return this.col.aggregate(
[
{
$match: {
t: 'l',
ts: {
$lt: end,
$gte: start,
},
tags: {
$exists: true,
$ne: [],
},
...extraQuery,
},
},
{
$group: {
_id: '$tags',
total: {
$sum: 1,
},
},
},
{
$unwind: '$_id',
},
{
$group: {
_id: '$_id',
total: { $sum: '$total' },
},
},
{
$sort: sort || { total: 1 },
},
{
$group: {
_id: null,
total: { $sum: '$total' },
data: {
$push: {
label: '$_id',
value: '$total',
},
},
},
},
{
$project: {
_id: 0,
},
},
],
{ hint: 'tags.0_1_ts_1', readPreference: readSecondaryPreferred() },
);
}
getConversationsWithoutTagsBetweenDate(start: Date, end: Date, extraQuery: Filter<IOmnichannelRoom>): Promise<number> {
return this.col.countDocuments({
t: 'l',
ts: {
$gte: start,
$lt: end,
},
$or: [
{
tags: {
$exists: false,
},
},
{
tags: {
$eq: [],
},
},
],
...extraQuery,
});
}
getConversationsByAgents(
start: Date,
end: Date,
sort: Record<string, 1 | -1>,
extraQuery: Filter<IOmnichannelRoom>,
): AggregationCursor<ReportResult> {
return this.col.aggregate(
[
{
$match: {
t: 'l',
ts: {
$gte: start,
$lt: end,
},
servedBy: {
$exists: true,
},
...extraQuery,
},
},
{
$group: {
_id: '$servedBy._id',
total: { $sum: 1 },
},
},
{
$lookup: {
from: 'users',
localField: '_id',
foreignField: '_id',
as: 'agent',
},
},
{
$set: {
agent: { $first: '$agent' },
},
},
{
$addFields: {
name: {
$ifNull: ['$agent.name', '$_id'],
},
},
},
{
$sort: sort || { name: 1 },
},
{
$group: {
_id: null,
total: { $sum: '$total' },
data: {
$push: {
label: '$name',
value: '$total',
},
},
},
},
{
$project: {
_id: 0,
},
},
],
{ hint: 'servedBy_1_ts_1', readPreference: readSecondaryPreferred() },
);
}
getTotalConversationsWithoutAgentsBetweenDate(start: Date, end: Date, extraQuery: Filter<IOmnichannelRoom>): Promise<number> {
return this.col.countDocuments({
t: 'l',
ts: {
$gte: start,
$lt: end,
},
servedBy: {
$exists: false,
},
...extraQuery,
});
}
}

@ -1,14 +1,21 @@
{
"500": "Internal Server Error",
"__agents__agents_and__count__conversations__period__": "{{agents}} agents and {{count}} conversations, {{period}}",
"__count__empty_rooms_will_be_removed_automatically": "{{count}} empty rooms will be removed automatically.",
"__count__empty_rooms_will_be_removed_automatically__rooms__": "{{count}} empty rooms will be removed automatically:<br/> {{rooms}}.",
"__count__message_pruned": "{{count}} message pruned",
"__count__message_pruned_plural": "{{count}} messages pruned",
"__count__conversations__period__": "{{count}} conversations, {{period}}",
"__count__tags__and__count__conversations__period__": "{{count}} tags and {{conversations}} conversations, {{period}}",
"__departments__departments_and__count__conversations__period__": "{{departments}} departments and {{count}} conversations, {{period}}",
"__usersCount__member_joined": "+ {{usersCount}} member joined",
"__usersCount__member_joined_plural": "+ {{usersCount}} members joined",
"__usersCount__people_will_be_invited": "{{usersCount}} people will be invited",
"__username__is_no_longer__role__defined_by__user_by_": "{{username}} is no longer {{role}} by {{user_by}}",
"__username__was_set__role__by__user_by_": "{{username}} was set {{role}} by {{user_by}}",
"__count__without__department__": "{{count}} without department",
"__count__without__tags__": "{{count}} without tags",
"__count__without__assignee__": "{{count}} without assignee",
"removed__username__as__role_": "removed {{username}} as {{role}}",
"set__username__as__role_": "set {{username}} as {{role}}",
"This_room_encryption_has_been_enabled_by__username_": "This room's encryption has been enabled by {{username}}",
@ -2787,8 +2794,11 @@
"Language_Swedish": "Swedish",
"Language_Version": "English Version",
"Last_7_days": "Last 7 Days",
"Last_15_days": "Last 15 Days",
"Last_30_days": "Last 30 Days",
"Last_90_days": "Last 90 Days",
"Last_6_months": "Last 6 months",
"Last_year": "Last year",
"Last_active": "Last active",
"Last_Call": "Last Call",
"Last_Chat": "Last Chat",
@ -3652,6 +3662,7 @@
"No_managers_yet_description": "Managers have access to all omnichannel controls, being able to monitor and take actions.",
"No_content_was_provided": "No content was provided",
"No_data_found": "No data found",
"No_data_available_for_the_selected_period": "No data available for the selected period",
"No_direct_messages_yet": "No Direct Messages.",
"No_Discussions_found": "No discussions found",
"No_discussions_yet": "No discussions yet",
@ -3793,6 +3804,14 @@
"omnichannel_sla_change_history": "SLA Policy changed: {{user}} changed the SLA Policy to {{sla}}",
"Omnichannel_enable_department_removal": "Enable department removal",
"Omnichannel_enable_department_removal_alert": "Departments removed cannot be restored, we recommend archiving the department instead.",
"Omnichannel_Reports_Status_Open": "Open",
"Omnichannel_Reports_Status_Closed": "Closed",
"Omnichannel_Reports_Channels_Empty_Subtitle": "This chart shows the most used channels.",
"Omnichannel_Reports_Departments_Empty_Subtitle": "This chart displays the departments that receive the most conversations.",
"Omnichannel_Reports_Status_Empty_Subtitle": "This chart will update as soon as conversations start.",
"Omnichannel_Reports_Tags_Empty_Subtitle": "This chart shows the most frequently used tags.",
"Omnichannel_Reports_Agents_Empty_Subtitle": "This chart displays which agents receive the highest volume of conversations.",
"Omnichannel_Reports_Summary": "Gain insights into your operation and export your metrics.",
"On": "On",
"on-hold-livechat-room": "On Hold Omnichannel Room",
"on-hold-livechat-room_description": "Permission to on hold omnichannel room",
@ -4087,6 +4106,7 @@
"Query_description": "Additional conditions for determining which users to send the email to. Unsubscribed users are automatically removed from the query. It must be a valid JSON. Example: \"{\"createdAt\":{\"$gt\":{\"$date\": \"2015-01-01T00:00:00.000Z\"}}}\"",
"Query_is_not_valid_JSON": "Query is not valid JSON",
"Queue": "Queue",
"Queued": "Queued",
"Queues": "Queues",
"Queue_delay_timeout": "Queue processing delay timeout",
"Queue_Time": "Queue Time",
@ -4208,6 +4228,7 @@
"Reply_via_Email": "Reply via email",
"ReplyTo": "Reply-To",
"Report": "Report",
"Reports": "Reports",
"Report_Abuse": "Report Abuse",
"Report_exclamation_mark": "Report!",
"Report_has_been_sent": "Report has been sent",
@ -5091,6 +5112,7 @@
"Tokens_Required_Input_Error": "Invalid typed tokens.",
"Tokens_Required_Input_Placeholder": "Tokens asset names",
"Topic": "Topic",
"Top_5_agents_with_the_most_conversations": "Top 5 agents with the most conversations",
"Total": "Total",
"Total_abandoned_chats": "Total Abandoned Chats",
"Total_conversations": "Total Conversations",
@ -5910,6 +5932,11 @@
"Private_apps_are_side-loaded": "Private apps are side-loaded and are not available on the Marketplace.",
"Chat_transcript": "Chat transcript",
"Conversational_transcript": "Conversational transcript",
"Conversations_by_agents": "Conversations by agents",
"Conversations_by_channel": "Conversations by channel",
"Conversations_by_department": "Conversations by department",
"Conversations_by_status": "Conversations by status",
"Conversations_by_tag": "Conversations by tag",
"Send_conversation_transcript_via_email": "Send conversation transcript via email",
"Always_send_the_transcript_to_contacts_at_the_end_of_the_conversations": "Always send the transcript to contacts at the end of the conversations.",
"Export_conversation_transcript_as_PDF": "Export conversation transcript as PDF",

@ -1,5 +1,6 @@
{
"500": "Erro interno do servidor",
"__agents__agents_and__count__conversations__period__": "{{agents}} agentes e {{count}} conversas, {{period}}",
"__count__empty_rooms_will_be_removed_automatically": "{{count}} salas vazias serão removidas automaticamente.",
"__count__empty_rooms_will_be_removed_automatically__rooms__": "{{count}} salas vazias serão removidas automaticamente:<br/> {{rooms}}.",
"__count__message_pruned": "{{count}} mensagem apagada",
@ -8,6 +9,12 @@
"__usersCount__people_will_be_invited": "{{usersCount}} usuários vão ser convidados",
"__username__is_no_longer__role__defined_by__user_by_": "{{username}} não pertence mais a {{role}}, por {{user_by}}",
"__username__was_set__role__by__user_by_": "{{username}} foi definido como {{role}} por {{user_by}}",
"__count__conversations__period__": "{{count}} conversas, {{period}}",
"__count__tags__and__count__conversations__period__": "{{count}} tags e {{conversations}} conversas, {{period}}",
"__departments__departments_and__count__conversations__period__": "{{departments}} departmentos e {{count}} conversas, {{period}}",
"__count__without__department__": "{{count}} sem departamento",
"__count__without__tags__": "{{count}} sem tags",
"__count__without__assignee__": "{{count}} sem responsável",
"This_room_encryption_has_been_enabled_by__username_": "A criptografia desta sala foi ativada por {{username}}",
"This_room_encryption_has_been_disabled_by__username_": "A criptografia desta sala foi desativada por {{username}}",
"Enabled_E2E_Encryption_for_this_room": "Encriptação E2E habilitada para essa sala",
@ -985,6 +992,11 @@
"conversation_with_s": "a conversa com %s",
"Conversations": "Conversas",
"Conversations_per_day": "Conversas por dia",
"Conversations_by_agents": "Conversas por agente",
"Conversations_by_channel": "Conversar por canal",
"Conversations_by_department": "Conversas por departamento",
"Conversations_by_status": "Conversas por status",
"Conversations_by_tag": "Conversas por tag",
"Convert": "Converter",
"Convert_Ascii_Emojis": "Converter ASCII em emoji",
"Convert_to_channel": "Converter em canal",
@ -2419,8 +2431,11 @@
"Language_Swedish": "Sueco",
"Language_Version": "Versão em português",
"Last_7_days": "Últimos 7 dias",
"Last_15_days": "Últimos 15 dias",
"Last_30_days": "Últimos 30 dias",
"Last_90_days": "Últimos 90 dias",
"Last_6_months": "Últimos 6 meses",
"Last_year": "Último ano",
"Last_active": "Ativo pela última vez",
"Last_Call": "Última chamada",
"Last_Chat": "Última conversa",
@ -3092,6 +3107,7 @@
"No_channels_in_team": "Nenhum canal nesta equipe",
"No_channels_yet": "Você não faz parte de nenhum canal ainda",
"No_data_found": "Nenhum dado encontrado",
"No_data_available_for_the_selected_period": "Não há dados disponíveis para o período selecionado",
"No_direct_messages_yet": "Nenhuma mensagem direta.",
"No_Discussions_found": "Nenhuma discussão encontrada",
"No_discussions_yet": "Ainda sem discussões",
@ -3206,6 +3222,14 @@
"Omnichannel_External_Frame_Encryption_JWK": "Chave de criptografia (JWK)",
"Omnichannel_External_Frame_Encryption_JWK_Description": "Se fornecida, o token do usuário será criptografado com essa chave e o ouro aplicativo precisará descriptografar o campo de token para ter acesso ao token",
"Omnichannel_External_Frame_URL": "URL do frame externo",
"Omnichannel_Reports_Status_Open": "Abertas",
"Omnichannel_Reports_Status_Closed": "Fechadas",
"Omnichannel_Reports_Channels_Empty_Subtitle": "Este gráfico mostra os canais mais usados.",
"Omnichannel_Reports_Departments_Empty_Subtitle": "Este gráfico exibe os departamentos que recebem mais conversas.",
"Omnichannel_Reports_Status_Empty_Subtitle": "Este gráfico será atualizado assim que as conversas começarem.",
"Omnichannel_Reports_Tags_Empty_Subtitle": "Este gráfico mostra as tags usadas com mais frequência.",
"Omnichannel_Reports_Agents_Empty_Subtitle": "Este gráfico exibe quais agentes recebem o maior volume de conversas.",
"Omnichannel_Reports_Summary": "Obtenha insights sobre a sua operação e exporte suas métricas.",
"On": "Em",
"On_Hold": "Em espera",
"On_Hold_Chats": "Em Espera",
@ -3436,6 +3460,7 @@
"Query_description": "Condições adicionais para determinar para quais usuários enviar e-mail. Usuários não inscritos serão automaticamente removidos da consulta. Deve ser um JSON válido. Exemplo: \"{\"createdAt\":{\"$gt\":{\"$date\": \"2015-01-01T00:00:00.000Z\"}}}\"",
"Query_is_not_valid_JSON": "A consulta não é um JSON válido",
"Queue": "Fila",
"Queued": "Em fila",
"Queues": "Filas",
"Queue_delay_timeout": "Tempo limite de atraso para processamento da fila",
"Queue_Time": "Tempo na fila",
@ -3532,6 +3557,7 @@
"Reply_via_Email": "Responder por e-mail",
"ReplyTo": "Responder para",
"Report": "Relatar",
"Reports": "Relatórios",
"Report_Abuse": "Denunciar abuso",
"Report_exclamation_mark": "Relatar!",
"Report_Number": "Número do relatório",
@ -4289,6 +4315,7 @@
"Tokens_Required_Input_Error": "Tokens digitados inválidos.",
"Tokens_Required_Input_Placeholder": "Nomes dos ativos de tokens",
"Topic": "Tópico",
"Top_5_agents_with_the_most_conversations": "Top 5 agentes com mais conversas",
"Total": "Total",
"Total_abandoned_chats": "Total de conversas abandonadas",
"Total_conversations": "Total de conversas",
@ -4941,4 +4968,4 @@
"RegisterWorkspace_Features_Omnichannel_Title": "Omnichannel",
"RegisterWorkspace_Setup_Label": "E-mail da conta da nuvem",
"cloud.RegisterWorkspace_Setup_Terms_Privacy": "Eu concordo com os <1>Termos e condições</1> e a <3>Política de privacidade</3>"
}
}

@ -7,6 +7,7 @@ import type {
IMessage,
ILivechatPriority,
IOmnichannelServiceLevelAgreements,
ReportResult,
} from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import type { ILivechatRoomsModel } from '@rocket.chat/model-typings';
@ -23,6 +24,7 @@ import type {
SortDirection,
FindCursor,
UpdateResult,
AggregationCursor,
} from 'mongodb';
import { getValue } from '../../../app/settings/server/raw';
@ -68,6 +70,10 @@ export class LivechatRoomsRaw extends BaseRaw<IOmnichannelRoom> implements ILive
{ key: { callStatus: 1 }, sparse: true }, // used on statistics
{ key: { priorityId: 1 }, sparse: true },
{ key: { slaId: 1 }, sparse: true },
{ key: { source: 1, ts: 1 }, partialFilterExpression: { source: { $exists: true }, t: 'l' } },
{ key: { departmentId: 1, ts: 1 }, partialFilterExpression: { departmentId: { $exists: true }, t: 'l' } },
{ key: { 'tags.0': 1, 'ts': 1 }, partialFilterExpression: { 'tags.0': { $exists: true }, 't': 'l' } },
{ key: { servedBy: 1, ts: 1 }, partialFilterExpression: { servedBy: { $exists: true }, t: 'l' } },
];
}
@ -2532,4 +2538,51 @@ export class LivechatRoomsRaw extends BaseRaw<IOmnichannelRoom> implements ILive
countRoomsWithTranscriptSent(): Promise<number> {
throw new Error('Method not implemented.');
}
getConversationsBySource(_start: Date, _end: Date, _extraQuery: Filter<IOmnichannelRoom>): AggregationCursor<ReportResult> {
throw new Error('Method not implemented.');
}
getConversationsByStatus(_start: Date, _end: Date, _extraQuery: Filter<IOmnichannelRoom>): AggregationCursor<ReportResult> {
throw new Error('Method not implemented.');
}
getConversationsByDepartment(
_start: Date,
_end: Date,
_sort: Record<string, 1 | -1>,
_extraQuery: Filter<IOmnichannelRoom>,
): AggregationCursor<ReportResult> {
throw new Error('Method not implemented.');
}
getConversationsByTags(
_start: Date,
_end: Date,
_sort: Record<string, 1 | -1>,
_extraQuery: Filter<IOmnichannelRoom>,
): AggregationCursor<ReportResult> {
throw new Error('Method not implemented.');
}
getConversationsByAgents(
_start: Date,
_end: Date,
_sort: Record<string, 1 | -1>,
_extraQuery: Filter<IOmnichannelRoom>,
): AggregationCursor<ReportResult> {
throw new Error('Method not implemented.');
}
getConversationsWithoutTagsBetweenDate(_start: Date, _end: Date, _extraQuery: Filter<IOmnichannelRoom>): Promise<number> {
throw new Error('Method not implemented.');
}
getTotalConversationsWithoutAgentsBetweenDate(_start: Date, _end: Date, _extraQuery: Filter<IOmnichannelRoom>): Promise<number> {
throw new Error('Method not implemented.');
}
getTotalConversationsWithoutDepartmentBetweenDates(_start: Date, _end: Date, _extraQuery: Filter<IOmnichannelRoom>): Promise<number> {
throw new Error('Method not implemented.');
}
}

@ -95,6 +95,7 @@ export class RoomsRaw extends BaseRaw<IRoom> implements IRoomsModel {
},
sparse: true,
},
{ key: { t: 1, ts: 1 } },
];
}

@ -0,0 +1,576 @@
import type { IUser } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { after, before, describe, it } from 'mocha';
import { api, request, credentials, getCredentials } from '../../../data/api-data';
import { createDepartment, addOrRemoveAgentFromDepartment } from '../../../data/livechat/department';
import { startANewLivechatRoomAndTakeIt, createAgent } from '../../../data/livechat/rooms';
import { createMonitor, createUnit } from '../../../data/livechat/units';
import { restorePermissionToRoles, updatePermission } from '../../../data/permissions.helper';
import { password } from '../../../data/user';
import { createUser, deleteUser, login } from '../../../data/users.helper';
import { IS_EE } from '../../../e2e/config/constants';
(IS_EE ? describe : describe.skip)('LIVECHAT - reports', () => {
before((done) => getCredentials(done));
let agent2: { user: IUser; credentials: { 'X-Auth-Token': string; 'X-User-Id': string } };
let agent3: { user: IUser; credentials: { 'X-Auth-Token': string; 'X-User-Id': string } };
before(async () => {
const user: IUser = await createUser();
const userCredentials = await login(user.username, password);
if (!user.username) {
throw new Error('user not created');
}
await createMonitor(user.username);
agent2 = {
user,
credentials: userCredentials,
};
});
before(async () => {
const user: IUser = await createUser();
const userCredentials = await login(user.username, password);
await createAgent();
if (!user.username) {
throw new Error('user not created');
}
await createMonitor(user.username);
const dep1 = await createDepartment();
await addOrRemoveAgentFromDepartment(
dep1._id,
{ agentId: 'rocketchat.internal.admin.test', username: 'rocketchat.internal.admin.test', count: 0, order: 0 },
true,
);
const { room, visitor } = await startANewLivechatRoomAndTakeIt({ departmentId: dep1._id });
await request
.post(api('livechat/room.saveInfo'))
.set(credentials)
.send({
roomData: {
_id: room._id,
topic: 'new topic',
tags: ['tag1', 'tag2'],
},
guestData: {
_id: visitor._id,
},
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
});
await createUnit(user._id, user.username, [dep1._id]);
agent3 = {
user,
credentials: userCredentials,
};
});
after(async () => {
await deleteUser(agent2.user);
await deleteUser(agent3.user);
});
describe('livechat/analytics/dashboards/conversations-by-source', () => {
it('should return an error when the user does not have the necessary permission', async () => {
await updatePermission('view-livechat-reports', []);
await request
.get(api('livechat/analytics/dashboards/conversations-by-source'))
.set(credentials)
.query({ start: 'test', end: 'test' })
.expect(403);
});
it('should return an error when the start and end parameters are not provided', async () => {
await restorePermissionToRoles('view-livechat-reports');
await request.get(api('livechat/analytics/dashboards/conversations-by-source')).set(credentials).expect(400);
});
it('should return an error when the start parameter is not provided', async () => {
await request.get(api('livechat/analytics/dashboards/conversations-by-source')).set(credentials).query({ end: 'test' }).expect(400);
});
it('should return an error when the end parameter is not provided', async () => {
await request.get(api('livechat/analytics/dashboards/conversations-by-source')).set(credentials).query({ start: 'test' }).expect(400);
});
it('should return an error when the start parameter is not a valid date', async () => {
await request
.get(api('livechat/analytics/dashboards/conversations-by-source'))
.set(credentials)
.query({ start: 'test', end: 'test' })
.expect(400);
});
it('should fail if dates are more than 1 year apart', async () => {
const oneYearAgo = new Date(Date.now() - 380 * 24 * 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
await request
.get(api('livechat/analytics/dashboards/conversations-by-source'))
.set(credentials)
.query({ start: oneYearAgo, end: now })
.expect(400);
});
it('should return an error when start is after end', async () => {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
await request
.get(api('livechat/analytics/dashboards/conversations-by-source'))
.set(credentials)
.query({ start: now, end: oneHourAgo })
.expect(400);
});
it('should return an error when the end parameter is not a valid date', async () => {
await request
.get(api('livechat/analytics/dashboards/conversations-by-source'))
.set(credentials)
.query({ start: '2020-01-01', end: 'test' })
.expect(400);
});
it('should return the proper data when the parameters are valid', async () => {
// Note: this way all data will come as 0
const now = new Date().toISOString();
const { body } = await request
.get(api('livechat/analytics/dashboards/conversations-by-source'))
.set(credentials)
.query({ start: now, end: now })
.expect(200);
expect(body).to.have.property('data').and.to.be.an('array');
expect(body.data).to.have.lengthOf(0);
expect(body.total).to.be.equal(0);
expect(body.success).to.be.true;
});
it('should return empty set for a monitor with no units', async () => {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
const { body } = await request
.get(api('livechat/analytics/dashboards/conversations-by-source'))
.set(agent2.credentials)
.query({ start: oneHourAgo, end: now })
.expect(200);
expect(body).to.have.property('data').and.to.be.an('array');
expect(body.data).to.have.lengthOf(0);
expect(body.success).to.be.true;
});
it('should return only the data from the unit the monitor belongs to', async () => {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
const { body } = await request
.get(api('livechat/analytics/dashboards/conversations-by-source'))
.set(agent3.credentials)
.query({ start: oneHourAgo, end: now })
.expect(200);
expect(body).to.have.property('data').and.to.be.an('array');
expect(body.data).to.have.lengthOf.greaterThan(0);
expect(body.data.every((item: { value: number }) => item.value >= 0)).to.be.true;
expect(body.total).to.be.greaterThan(0);
expect(body.success).to.be.true;
});
it('should return valid data when login as a manager', async () => {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
const { body } = await request
.get(api('livechat/analytics/dashboards/conversations-by-source'))
.set(credentials)
.query({ start: oneHourAgo, end: now })
.expect(200);
expect(body).to.have.property('data').and.to.be.an('array');
expect(body.data).to.have.lengthOf.greaterThan(0);
expect(body.data.every((item: { value: number }) => item.value >= 0)).to.be.true;
expect(body.total).to.be.greaterThan(0);
});
});
describe('livechat/analytics/dashboards/conversations-by-status', () => {
it('should return an error when the user does not have the necessary permission', async () => {
await updatePermission('view-livechat-reports', []);
await request
.get(api('livechat/analytics/dashboards/conversations-by-status'))
.set(credentials)
.query({ start: 'test', end: 'test' })
.expect(403);
});
it('should return an error when the start and end parameters are not provided', async () => {
await restorePermissionToRoles('view-livechat-reports');
await request.get(api('livechat/analytics/dashboards/conversations-by-status')).set(credentials).expect(400);
});
it('should return an error when the start parameter is not provided', async () => {
await request.get(api('livechat/analytics/dashboards/conversations-by-status')).set(credentials).query({ end: 'test' }).expect(400);
});
it('should return an error when the end parameter is not provided', async () => {
await request.get(api('livechat/analytics/dashboards/conversations-by-status')).set(credentials).query({ start: 'test' }).expect(400);
});
it('should return an error when the start parameter is not a valid date', async () => {
await request
.get(api('livechat/analytics/dashboards/conversations-by-status'))
.set(credentials)
.query({ start: 'test', end: 'test' })
.expect(400);
});
it('should return an error when the end parameter is not a valid date', async () => {
await request
.get(api('livechat/analytics/dashboards/conversations-by-status'))
.set(credentials)
.query({ start: '2020-01-01', end: 'test' })
.expect(400);
});
it('should return an error if dates are more than 1 year apart', async () => {
const oneYearAgo = new Date(Date.now() - 380 * 24 * 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
await request
.get(api('livechat/analytics/dashboards/conversations-by-status'))
.set(credentials)
.query({ start: oneYearAgo, end: now })
.expect(400);
});
it('should return an error when start is after end', async () => {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
await request
.get(api('livechat/analytics/dashboards/conversations-by-status'))
.set(credentials)
.query({ start: now, end: oneHourAgo })
.expect(400);
});
it('should return the proper data when the parameters are valid', async () => {
// Note: this way all data will come as 0
const now = new Date().toISOString();
const { body } = await request
.get(api('livechat/analytics/dashboards/conversations-by-status'))
.set(credentials)
.query({ start: now, end: now })
.expect(200);
expect(body).to.have.property('data').and.to.be.an('array');
expect(body.data).to.have.lengthOf(0);
expect(body.success).to.be.true;
});
it('should return empty set for a monitor with no units', async () => {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
const { body } = await request
.get(api('livechat/analytics/dashboards/conversations-by-status'))
.set(agent2.credentials)
.query({ start: oneHourAgo, end: now })
.expect(200);
expect(body).to.have.property('data').and.to.be.an('array');
expect(body.data).to.have.lengthOf(0);
expect(body.success).to.be.true;
});
it('should return the proper data when login as a manager', async () => {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
const { body } = await request
.get(api('livechat/analytics/dashboards/conversations-by-status'))
.set(credentials)
.query({ start: oneHourAgo, end: now })
.expect(200);
expect(body).to.have.property('data').and.to.be.an('array');
expect(body.data).to.have.lengthOf.greaterThan(0);
expect(body.data.every((item: { value: number }) => item.value >= 0)).to.be.true;
});
});
describe('livechat/analytics/dashboards/conversations-by-department', () => {
it('should return an error when the user does not have the necessary permission', async () => {
await updatePermission('view-livechat-reports', []);
await request
.get(api('livechat/analytics/dashboards/conversations-by-department'))
.set(credentials)
.query({ start: 'test', end: 'test' })
.expect(403);
});
it('should return an error when the start and end parameters are not provided', async () => {
await restorePermissionToRoles('view-livechat-reports');
await request.get(api('livechat/analytics/dashboards/conversations-by-department')).set(credentials).expect(400);
});
it('should return an error when the start parameter is not provided', async () => {
await request
.get(api('livechat/analytics/dashboards/conversations-by-department'))
.set(credentials)
.query({ end: 'test' })
.expect(400);
});
it('should return an error when the end parameter is not provided', async () => {
await request
.get(api('livechat/analytics/dashboards/conversations-by-department'))
.set(credentials)
.query({ start: 'test' })
.expect(400);
});
it('should return an error when the start parameter is not a valid date', async () => {
await request
.get(api('livechat/analytics/dashboards/conversations-by-department'))
.set(credentials)
.query({ start: 'test', end: 'test' })
.expect(400);
});
it('should return an error when the end parameter is not a valid date', async () => {
await request
.get(api('livechat/analytics/dashboards/conversations-by-department'))
.set(credentials)
.query({ start: '2020-01-01', end: 'test' })
.expect(400);
});
it('should return an error if dates are more than 1 year apart', async () => {
const oneYearAgo = new Date(Date.now() - 380 * 24 * 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
await request
.get(api('livechat/analytics/dashboards/conversations-by-department'))
.set(credentials)
.query({ start: oneYearAgo, end: now })
.expect(400);
});
it('should return an error when start is after end', async () => {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
await request
.get(api('livechat/analytics/dashboards/conversations-by-department'))
.set(credentials)
.query({ start: now, end: oneHourAgo })
.expect(400);
});
it('should return the proper data when the parameters are valid', async () => {
// Note: this way all data will come as 0
const now = new Date().toISOString();
const { body } = await request
.get(api('livechat/analytics/dashboards/conversations-by-department'))
.set(credentials)
.query({ start: now, end: now })
.expect(200);
expect(body).to.have.property('data').and.to.be.an('array');
expect(body.data).to.have.lengthOf(0);
expect(body.success).to.be.true;
});
it('should return empty set for a monitor with no units', async () => {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
const { body } = await request
.get(api('livechat/analytics/dashboards/conversations-by-department'))
.set(agent2.credentials)
.query({ start: oneHourAgo, end: now })
.expect(200);
expect(body).to.have.property('data').and.to.be.an('array');
expect(body.data).to.have.lengthOf(0);
expect(body.success).to.be.true;
});
it('should return the proper data when login as a manager', async () => {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
const { body } = await request
.get(api('livechat/analytics/dashboards/conversations-by-department'))
.set(credentials)
.query({ start: oneHourAgo, end: now })
.expect(200);
expect(body).to.have.property('data').and.to.be.an('array');
expect(body.data).to.have.lengthOf.greaterThan(0);
expect(body.data.every((item: { value: number }) => item.value >= 0)).to.be.true;
});
});
describe('livechat/analytics/dashboards/conversations-by-tags', () => {
it('should return an error when the user does not have the necessary permission', async () => {
await updatePermission('view-livechat-reports', []);
await request
.get(api('livechat/analytics/dashboards/conversations-by-tags'))
.set(credentials)
.query({ start: 'test', end: 'test' })
.expect(403);
});
it('should return an error when the start and end parameters are not provided', async () => {
await restorePermissionToRoles('view-livechat-reports');
await request.get(api('livechat/analytics/dashboards/conversations-by-tags')).set(credentials).expect(400);
});
it('should return an error when the start parameter is not provided', async () => {
await request.get(api('livechat/analytics/dashboards/conversations-by-tags')).set(credentials).query({ end: 'test' }).expect(400);
});
it('should return an error when the end parameter is not provided', async () => {
await request.get(api('livechat/analytics/dashboards/conversations-by-tags')).set(credentials).query({ start: 'test' }).expect(400);
});
it('should return an error when the start parameter is not a valid date', async () => {
await request
.get(api('livechat/analytics/dashboards/conversations-by-tags'))
.set(credentials)
.query({ start: 'test', end: 'test' })
.expect(400);
});
it('should return an error when the end parameter is not a valid date', async () => {
await request
.get(api('livechat/analytics/dashboards/conversations-by-tags'))
.set(credentials)
.query({ start: '2020-01-01', end: 'test' })
.expect(400);
});
it('should return an error if dates are more than 1 year apart', async () => {
const oneYearAgo = new Date(Date.now() - 380 * 24 * 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
await request
.get(api('livechat/analytics/dashboards/conversations-by-tags'))
.set(credentials)
.query({ start: oneYearAgo, end: now })
.expect(400);
});
it('should return an error when start is after end', async () => {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
await request
.get(api('livechat/analytics/dashboards/conversations-by-tags'))
.set(credentials)
.query({ start: now, end: oneHourAgo })
.expect(400);
});
it('should return the proper data when the parameters are valid', async () => {
// Note: this way all data will come as 0
const now = new Date().toISOString();
const { body } = await request
.get(api('livechat/analytics/dashboards/conversations-by-tags'))
.set(credentials)
.query({ start: now, end: now })
.expect(200);
expect(body).to.have.property('data').and.to.be.an('array');
expect(body.data).to.have.lengthOf(0);
expect(body.success).to.be.true;
});
it('should return empty set for a monitor with no units', async () => {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
const { body } = await request
.get(api('livechat/analytics/dashboards/conversations-by-tags'))
.set(agent2.credentials)
.query({ start: oneHourAgo, end: now })
.expect(200);
expect(body).to.have.property('data').and.to.be.an('array');
expect(body.data).to.have.lengthOf(0);
expect(body.success).to.be.true;
});
it('should return the proper data when login as a manager', async () => {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
const { body } = await request
.get(api('livechat/analytics/dashboards/conversations-by-tags'))
.set(credentials)
.query({ start: oneHourAgo, end: now })
.expect(200);
expect(body).to.have.property('data').and.to.be.an('array');
expect(body.data).to.have.lengthOf.greaterThan(0);
expect(body.data.every((item: { value: number }) => item.value >= 0)).to.be.true;
});
});
describe('livechat/analytics/dashboards/conversations-by-agent', () => {
it('should return an error when the user does not have the necessary permission', async () => {
await updatePermission('view-livechat-reports', []);
await request
.get(api('livechat/analytics/dashboards/conversations-by-agent'))
.set(credentials)
.query({ start: 'test', end: 'test' })
.expect(403);
});
it('should return an error when the start and end parameters are not provided', async () => {
await restorePermissionToRoles('view-livechat-reports');
await request.get(api('livechat/analytics/dashboards/conversations-by-agent')).set(credentials).expect(400);
});
it('should return an error when the start parameter is not provided', async () => {
await request.get(api('livechat/analytics/dashboards/conversations-by-agent')).set(credentials).query({ end: 'test' }).expect(400);
});
it('should return an error when the end parameter is not provided', async () => {
await request.get(api('livechat/analytics/dashboards/conversations-by-agent')).set(credentials).query({ start: 'test' }).expect(400);
});
it('should return an error when the start parameter is not a valid date', async () => {
await request
.get(api('livechat/analytics/dashboards/conversations-by-agent'))
.set(credentials)
.query({ start: 'test', end: 'test' })
.expect(400);
});
it('should return an error when the end parameter is not a valid date', async () => {
await request
.get(api('livechat/analytics/dashboards/conversations-by-agent'))
.set(credentials)
.query({ start: '2020-01-01', end: 'test' })
.expect(400);
});
it('should return an error if dates are more than 1 year apart', async () => {
const oneYearAgo = new Date(Date.now() - 380 * 24 * 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
await request
.get(api('livechat/analytics/dashboards/conversations-by-agent'))
.set(credentials)
.query({ start: oneYearAgo, end: now })
.expect(400);
});
it('should return an error when start is after end', async () => {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
await request
.get(api('livechat/analytics/dashboards/conversations-by-agent'))
.set(credentials)
.query({ start: now, end: oneHourAgo })
.expect(400);
});
it('should return the proper data when the parameters are valid', async () => {
// Note: this way all data will come as 0
const now = new Date().toISOString();
const { body } = await request
.get(api('livechat/analytics/dashboards/conversations-by-agent'))
.set(credentials)
.query({ start: now, end: now })
.expect(200);
expect(body).to.have.property('data').and.to.be.an('array');
expect(body.data).to.have.lengthOf(0);
});
it('should return empty set for a monitor with no units', async () => {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
const { body } = await request
.get(api('livechat/analytics/dashboards/conversations-by-agent'))
.set(agent2.credentials)
.query({ start: oneHourAgo, end: now })
.expect(200);
expect(body).to.have.property('data').and.to.be.an('array');
expect(body.data).to.have.lengthOf(0);
expect(body.success).to.be.true;
});
it('should return the proper data when login as a manager', async () => {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
const { body } = await request
.get(api('livechat/analytics/dashboards/conversations-by-agent'))
.set(credentials)
.query({ start: oneHourAgo, end: now })
.expect(200);
expect(body).to.have.property('data').and.to.be.an('array');
expect(body.data).to.have.lengthOf.greaterThan(0);
expect(body.data.every((item: { value: number }) => item.value >= 0)).to.be.true;
});
});
});

@ -1,2 +1,3 @@
export * from './sms';
export * from './routing';
export * from './reports';

@ -0,0 +1,3 @@
export type ReportResult = { total: number; data: { label: string; value: number }[] };
export type ReportWithUnmatchingElements = ReportResult & { unspecified: number };

@ -23,6 +23,8 @@ import type {
LivechatDepartmentDTO,
ILivechatTriggerCondition,
ILivechatTriggerAction,
ReportResult,
ReportWithUnmatchingElements,
} from '@rocket.chat/core-typings';
import { ILivechatAgentStatus } from '@rocket.chat/core-typings';
import Ajv from 'ajv';
@ -3121,6 +3123,31 @@ const POSTLivechatAppearanceParamsSchema = {
export const isPOSTLivechatAppearanceParams = ajv.compile<POSTLivechatAppearanceParams>(POSTLivechatAppearanceParamsSchema);
type GETDashboardConversationsByType = {
start: string;
end: string;
sort?: string;
};
const GETDashboardConversationsByTypeSchema = {
type: 'object',
properties: {
start: {
type: 'string',
},
end: {
type: 'string',
},
sort: {
type: 'string',
},
},
required: ['start', 'end'],
additionalProperties: false,
};
export const isGETDashboardConversationsByType = ajv.compile<GETDashboardConversationsByType>(GETDashboardConversationsByTypeSchema);
type LivechatAnalyticsAgentOverviewProps = {
name: string;
from: string;
@ -3762,4 +3789,19 @@ export type OmnichannelEndpoints = {
'/v1/livechat/inquiry.setSLA': {
PUT: (params: { roomId: string; sla: string }) => void;
};
'/v1/livechat/analytics/dashboards/conversations-by-source': {
GET: (params: GETDashboardConversationsByType) => ReportResult;
};
'/v1/livechat/analytics/dashboards/conversations-by-status': {
GET: (params: GETDashboardConversationsByType) => ReportResult;
};
'/v1/livechat/analytics/dashboards/conversations-by-department': {
GET: (params: GETDashboardConversationsByType) => ReportWithUnmatchingElements;
};
'/v1/livechat/analytics/dashboards/conversations-by-tags': {
GET: (params: GETDashboardConversationsByType) => ReportWithUnmatchingElements;
};
'/v1/livechat/analytics/dashboards/conversations-by-agent': {
GET: (params: GETDashboardConversationsByType) => ReportWithUnmatchingElements;
};
};

@ -7,7 +7,7 @@ import CardDivider from './CardDivider';
import CardFooter from './CardFooter';
import CardFooterWrapper from './CardFooterWrapper';
import CardIcon from './CardIcon';
import Title from './CardTitle';
import CardTitle from './CardTitle';
export const DOUBLE_COLUMN_CARD_WIDTH = 552;
@ -15,7 +15,7 @@ export const DOUBLE_COLUMN_CARD_WIDTH = 552;
* @deprecated Avoid default usage, use named imports instead
*/
export default Object.assign(Card, {
Title,
Title: CardTitle,
Body: CardBody,
Col: Object.assign(CardCol, {
Title: CardColTitle,
@ -26,3 +26,5 @@ export default Object.assign(Card, {
Divider: CardDivider,
Icon: CardIcon,
});
export { Card, CardBody, CardCol, CardColSection, CardColTitle, CardDivider, CardFooter, CardFooterWrapper, CardIcon, CardTitle };

Loading…
Cancel
Save