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
parent
ba18325806
commit
ebab8c4dd8
@ -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 |
||||
@ -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); |
||||
}, |
||||
}, |
||||
); |
||||
@ -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; |
||||
}; |
||||
@ -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 }; |
||||
Loading…
Reference in new issue