diff --git a/app/livechat/client/route.js b/app/livechat/client/route.js index 98be3912c977..2e1b970d2b72 100644 --- a/app/livechat/client/route.js +++ b/app/livechat/client/route.js @@ -26,14 +26,6 @@ AccountBox.addRoute({ pageTemplate: 'livechatAnalytics', }, livechatManagerRoutes, load); -AccountBox.addRoute({ - name: 'livechat-real-time-monitoring', - path: '/real-time-monitoring', - sideNav: 'omnichannelFlex', - i18nPageTitle: 'Real_Time_Monitoring', - pageTemplate: 'livechatRealTimeMonitoring', -}, livechatManagerRoutes, load); - AccountBox.addRoute({ name: 'livechat-departments', path: '/departments', diff --git a/app/livechat/client/views/admin.js b/app/livechat/client/views/admin.js index 495de3e9d6d7..0d27714086d3 100644 --- a/app/livechat/client/views/admin.js +++ b/app/livechat/client/views/admin.js @@ -1,7 +1,6 @@ import './app/analytics/livechatAnalytics'; import './app/analytics/livechatAnalyticsCustomDaterange'; import './app/analytics/livechatAnalyticsDaterange'; -import './app/analytics/livechatRealTimeMonitoring'; import './app/livechatDashboard.html'; import './app/livechatDepartmentForm'; import './app/livechatDepartments'; diff --git a/app/livechat/client/views/app/analytics/livechatRealTimeMonitoring.html b/app/livechat/client/views/app/analytics/livechatRealTimeMonitoring.html deleted file mode 100644 index 04818769e352..000000000000 --- a/app/livechat/client/views/app/analytics/livechatRealTimeMonitoring.html +++ /dev/null @@ -1,164 +0,0 @@ - - {{#requiresPermission 'view-livechat-real-time-monitoring'}} - - - - 5 {{_ "seconds"}} - 10 {{_ "seconds"}} - 30 {{_ "seconds"}} - 1 {{_ "minute"}} - - - - {{#if hasDepartments }} - - {{> livechatAutocompleteUser - onClickTag=onClickTagDepartment - list=selectedDepartments - onSelect=onSelectDepartments - collection='CachedDepartmentList' - endpoint='livechat/department.autocomplete' - field='name' - sort='name' - placeholder="Select_a_department" - name="department" - icon="queue" - noMatchTemplate="userSearchEmpty" - templateItem="popupList_item_channel" - template="roomSearch" - noMatchTemplate="roomSearchEmpty" - modifier=departmentModifier - }} - - {{/if}} - - {{#if isLoading}} - {{> loading }} - {{else}} - - - - - {{#each conversationsOverview}} - - - {{_ title}} - {{value}} - - - {{/each}} - - - - - {{/if}} - - - - - - - - - - - - - - - - - - - - {{#if isLoading}} - {{> loading }} - {{else}} - - {{#each chatsOverview}} - - - {{_ title}} - {{value}} - - - {{/each}} - - {{/if}} - - - - - - - - - - - - - - - - - - - - - - - {{#if isLoading}} - {{> loading }} - {{else}} - - {{#each agentsOverview}} - - - {{_ title}} - {{value}} - - - {{/each}} - - {{/if}} - - - - - - - - - - - - - - - - {{#if isLoading}} - {{> loading }} - {{else}} - - {{#each timingOverview}} - - - {{_ title}} - {{value}} - - - {{/each}} - - {{/if}} - - - - - - - - - - - {{/requiresPermission}} - diff --git a/app/livechat/client/views/app/analytics/livechatRealTimeMonitoring.js b/app/livechat/client/views/app/analytics/livechatRealTimeMonitoring.js deleted file mode 100644 index 584e0f003b1f..000000000000 --- a/app/livechat/client/views/app/analytics/livechatRealTimeMonitoring.js +++ /dev/null @@ -1,343 +0,0 @@ -import { Template } from 'meteor/templating'; -import moment from 'moment'; -import { ReactiveVar } from 'meteor/reactive-var'; - -import { drawLineChart, drawDoughnutChart, updateChart } from '../../../lib/chartHandler'; -import { APIClient } from '../../../../../utils/client'; -import './livechatRealTimeMonitoring.html'; - -const chartContexts = {}; // stores context of current chart, used to clean when redrawing -let templateInstance; - -const initChart = { - 'lc-chats-chart'() { - return drawDoughnutChart( - document.getElementById('lc-chats-chart'), - 'Chats', - chartContexts['lc-chats-chart'], - ['Open', 'Queue', 'Closed'], [0, 0, 0]); - }, - - 'lc-agents-chart'() { - return drawDoughnutChart( - document.getElementById('lc-agents-chart'), - 'Agents', - chartContexts['lc-agents-chart'], - ['Available', 'Away', 'Busy', 'Offline'], [0, 0, 0, 0]); - }, - - 'lc-chats-per-agent-chart'() { - return drawLineChart( - document.getElementById('lc-chats-per-agent-chart'), - chartContexts['lc-chats-per-agent-chart'], - ['Open', 'Closed'], - [], [[], []], { legends: true, anim: true, smallTicks: true }); - }, - - 'lc-chats-per-dept-chart'() { - if (!document.getElementById('lc-chats-per-dept-chart')) { - return null; - } - - return drawLineChart( - document.getElementById('lc-chats-per-dept-chart'), - chartContexts['lc-chats-per-dept-chart'], - ['Open', 'Closed'], - [], [[], []], { legends: true, anim: true, smallTicks: true }); - }, - - 'lc-reaction-response-times-chart'() { - const timingLabels = []; - const initData = []; - const today = moment().startOf('day'); - for (let m = today; m.diff(moment(), 'hours') < 0; m.add(1, 'hours')) { - const hour = m.format('H'); - timingLabels.push(`${ moment(hour, ['H']).format('hA') }-${ moment((parseInt(hour) + 1) % 24, ['H']).format('hA') }`); - initData.push(0); - } - - return drawLineChart( - document.getElementById('lc-reaction-response-times-chart'), - chartContexts['lc-reaction-response-times-chart'], - ['Avg_reaction_time', 'Longest_reaction_time', 'Avg_response_time', 'Longest_response_time'], - timingLabels.slice(), - [initData.slice(), initData.slice(), initData.slice(), initData.slice()], { legends: true, anim: true, smallTicks: true }); - }, - - 'lc-chat-duration-chart'() { - const timingLabels = []; - const initData = []; - const today = moment().startOf('day'); - for (let m = today; m.diff(moment(), 'hours') < 0; m.add(1, 'hours')) { - const hour = m.format('H'); - timingLabels.push(`${ moment(hour, ['H']).format('hA') }-${ moment((parseInt(hour) + 1) % 24, ['H']).format('hA') }`); - initData.push(0); - } - - return drawLineChart( - document.getElementById('lc-chat-duration-chart'), - chartContexts['lc-chat-duration-chart'], - ['Avg_chat_duration', 'Longest_chat_duration'], - timingLabels.slice(), - [initData.slice(), initData.slice()], { legends: true, anim: true, smallTicks: true }); - }, -}; - -const initAllCharts = async () => { - chartContexts['lc-chats-chart'] = await initChart['lc-chats-chart'](); - chartContexts['lc-agents-chart'] = await initChart['lc-agents-chart'](); - chartContexts['lc-chats-per-agent-chart'] = await initChart['lc-chats-per-agent-chart'](); - chartContexts['lc-chats-per-dept-chart'] = await initChart['lc-chats-per-dept-chart'](); - chartContexts['lc-reaction-response-times-chart'] = await initChart['lc-reaction-response-times-chart'](); - chartContexts['lc-chat-duration-chart'] = await initChart['lc-chat-duration-chart'](); -}; - -const updateChartData = async (chartId, label, data) => { - if (!chartContexts[chartId]) { - chartContexts[chartId] = await initChart[chartId](); - } - - await updateChart(chartContexts[chartId], label, data); -}; - -let timer; - -const getChartDepartment = (department) => department?._id; - -const getDaterange = () => { - const today = moment(new Date()); - return { - start: `${ moment(new Date(today.year(), today.month(), today.date(), 0, 0, 0)).utc().format('YYYY-MM-DDTHH:mm:ss') }Z`, - end: `${ moment(new Date(today.year(), today.month(), today.date(), 23, 59, 59)).utc().format('YYYY-MM-DDTHH:mm:ss') }Z`, - }; -}; - -const parseAdditionalParams = (options = {}, prefix = '') => `${ prefix }${ Object.keys(options).map((key) => `${ key }=${ options[key] }`).join('&') }`; - -const loadConversationOverview = async ({ start, end, ...options }) => { - const additionalParams = parseAdditionalParams(options, '&'); - const { totalizers } = await APIClient.v1.get(`livechat/analytics/dashboards/conversation-totalizers?start=${ start }&end=${ end }${ additionalParams }`); - return totalizers; -}; - -const updateConversationOverview = async (totalizers) => { - if (totalizers && Array.isArray(totalizers)) { - templateInstance.conversationsOverview.set(totalizers); - } -}; - -const loadAgentsOverview = async ({ start, end, ...options }) => { - const additionalParams = parseAdditionalParams(options, '&'); - const { totalizers } = await APIClient.v1.get(`livechat/analytics/dashboards/agents-productivity-totalizers?start=${ start }&end=${ end }${ additionalParams }`); - return totalizers; -}; - -const updateAgentsOverview = async (totalizers) => { - if (totalizers && Array.isArray(totalizers)) { - templateInstance.agentsOverview.set(totalizers); - } -}; -const loadChatsOverview = async ({ start, end, ...options }) => { - const additionalParams = parseAdditionalParams(options, '&'); - const { totalizers } = await APIClient.v1.get(`livechat/analytics/dashboards/chats-totalizers?start=${ start }&end=${ end }${ additionalParams }`); - return totalizers; -}; - -const updateChatsOverview = async (totalizers) => { - if (totalizers && Array.isArray(totalizers)) { - templateInstance.chatsOverview.set(totalizers); - } -}; - -const loadProductivityOverview = async ({ start, end, ...options }) => { - const additionalParams = parseAdditionalParams(options, '&'); - const { totalizers } = await APIClient.v1.get(`livechat/analytics/dashboards/productivity-totalizers?start=${ start }&end=${ end }${ additionalParams }`); - return totalizers; -}; - -const updateProductivityOverview = async (totalizers) => { - if (totalizers && Array.isArray(totalizers)) { - templateInstance.timingOverview.set(totalizers); - } -}; - -const loadChatsChartData = ({ start, end, ...options }) => { - const additionalParams = parseAdditionalParams(options, '&'); - return APIClient.v1.get(`livechat/analytics/dashboards/charts/chats?start=${ start }&end=${ end }${ additionalParams }`); -}; - -const updateChatsChart = async ({ open, closed, queued }) => { - await updateChartData('lc-chats-chart', 'Open', [open]); - await updateChartData('lc-chats-chart', 'Closed', [closed]); - await updateChartData('lc-chats-chart', 'Queue', [queued]); -}; - -const loadChatsPerAgentChartData = async ({ start, end, ...options }) => { - const additionalParams = parseAdditionalParams(options, '&'); - const result = await APIClient.v1.get(`livechat/analytics/dashboards/charts/chats-per-agent?start=${ start }&end=${ end }${ additionalParams }`); - delete result.success; - return result; -}; - -const updateChatsPerAgentChart = async (agents) => { - // this chart need to reset before new updates - chartContexts['lc-chats-per-agent-chart'] = await initChart['lc-chats-per-agent-chart'](); - - Object - .keys(agents) - .forEach((agent) => updateChartData('lc-chats-per-agent-chart', agent, [agents[agent].open, agents[agent].closed])); -}; - -const loadAgentsStatusChartData = ({ departmentId }) => { - const additionalParams = parseAdditionalParams({ departmentId }, '?'); - return APIClient.v1.get(`livechat/analytics/dashboards/charts/agents-status${ additionalParams }`); -}; - -const updateAgentStatusChart = async (statusData) => { - if (!statusData) { - return; - } - - await updateChartData('lc-agents-chart', 'Offline', [statusData.offline]); - await updateChartData('lc-agents-chart', 'Available', [statusData.available]); - await updateChartData('lc-agents-chart', 'Away', [statusData.away]); - await updateChartData('lc-agents-chart', 'Busy', [statusData.busy]); -}; - -const loadChatsPerDepartmentChartData = async ({ start, end, ...options }) => { - const additionalParams = parseAdditionalParams(options, '&'); - const result = await APIClient.v1.get(`livechat/analytics/dashboards/charts/chats-per-department?start=${ start }&end=${ end }${ additionalParams }`); - delete result.success; - return result; -}; - -const updateDepartmentsChart = async (departments) => { - // this chart need to reset before new updates - chartContexts['lc-chats-per-dept-chart'] = await initChart['lc-chats-per-dept-chart'](); - - Object - .keys(departments) - .forEach((department) => updateChartData('lc-chats-per-dept-chart', department, [departments[department].open, departments[department].closed])); -}; - -const loadTimingsChartData = ({ start, end, ...options }) => { - const additionalParams = parseAdditionalParams(options, '&'); - return APIClient.v1.get(`livechat/analytics/dashboards/charts/timings?start=${ start }&end=${ end }${ additionalParams }`); -}; - -const updateTimingsChart = async (timingsData) => { - const hour = moment(new Date()).format('H'); - const label = `${ moment(hour, ['H']).format('hA') }-${ moment((parseInt(hour) + 1) % 24, ['H']).format('hA') }`; - - await updateChartData('lc-reaction-response-times-chart', label, [timingsData.reaction.avg, timingsData.reaction.longest, timingsData.response.avg, timingsData.response.longest]); - await updateChartData('lc-chat-duration-chart', label, [timingsData.chatDuration.avg, timingsData.chatDuration.longest]); -}; - -const getIntervalInMS = () => templateInstance.interval.get() * 1000; - -Template.livechatRealTimeMonitoring.helpers({ - selected(value) { - return value === templateInstance.analyticsOptions.get().value || value === templateInstance.chartOptions.get().value ? 'selected' : false; - }, - conversationsOverview() { - return templateInstance.conversationsOverview.get(); - }, - timingOverview() { - return templateInstance.timingOverview.get(); - }, - agentsOverview() { - return templateInstance.agentsOverview.get(); - }, - chatsOverview() { - return templateInstance.chatsOverview.get(); - }, - isLoading() { - return Template.instance().isLoading.get(); - }, - departmentModifier() { - return (filter, text = '') => { - const f = filter.get(); - return `${ f.length === 0 ? text : text.replace(new RegExp(filter.get(), 'i'), (part) => `${ part }`) }`; - }; - }, - onClickTagDepartment() { - return Template.instance().onClickTagDepartment; - }, - selectedDepartments() { - return Template.instance().selectedDepartments.get(); - }, - onSelectDepartments() { - return Template.instance().onSelectDepartments; - }, - hasDepartments() { - return Template.instance().hasDepartments.get(); - }, -}); - -Template.livechatRealTimeMonitoring.onCreated(async function() { - templateInstance = Template.instance(); - this.isLoading = new ReactiveVar(false); - this.conversationsOverview = new ReactiveVar(); - this.timingOverview = new ReactiveVar(); - this.chatsOverview = new ReactiveVar(); - this.agentsOverview = new ReactiveVar(); - this.conversationTotalizers = new ReactiveVar([]); - this.interval = new ReactiveVar(5); - this.selectedDepartments = new ReactiveVar([]); - this.hasDepartments = new ReactiveVar(false); - - this.onSelectDepartments = ({ item: department }) => { - department.text = department.name; - this.selectedDepartments.set([department]); - }; - - this.onClickTagDepartment = () => { - this.selectedDepartments.set([]); - }; - - const { departments } = await APIClient.v1.get('livechat/department?count=1'); - this.hasDepartments.set(departments?.length > 0); -}); - -Template.livechatRealTimeMonitoring.onRendered(async function() { - await initAllCharts(); - - this.updateDashboard = async () => { - const [department] = this.selectedDepartments.get(); - const departmentId = getChartDepartment(department); - const daterange = getDaterange(); - const filters = Object.assign( - { ...daterange }, - departmentId && { departmentId }, - ); - - updateConversationOverview(await loadConversationOverview(filters)); - updateProductivityOverview(await loadProductivityOverview(filters)); - updateChatsChart(await loadChatsChartData(filters)); - updateChatsPerAgentChart(await loadChatsPerAgentChartData(filters)); - updateAgentStatusChart(await loadAgentsStatusChartData(filters)); - updateDepartmentsChart(await loadChatsPerDepartmentChartData(filters)); - updateTimingsChart(await loadTimingsChartData(filters)); - updateAgentsOverview(await loadAgentsOverview(filters)); - updateChatsOverview(await loadChatsOverview(filters)); - }; - this.autorun(() => { - if (timer) { - clearInterval(timer); - } - timer = setInterval(() => this.updateDashboard(), getIntervalInMS()); - }); - this.isLoading.set(true); - await this.updateDashboard(); - this.isLoading.set(false); -}); - -Template.livechatRealTimeMonitoring.events({ - 'change .js-interval': (event, instance) => { - instance.interval.set(event.target.value); - }, -}); - -Template.livechatRealTimeMonitoring.onDestroyed(function() { - clearInterval(timer); -}); diff --git a/client/helpers/getDateRange.js b/client/helpers/getDateRange.js new file mode 100644 index 000000000000..84dd9ce73fcc --- /dev/null +++ b/client/helpers/getDateRange.js @@ -0,0 +1,9 @@ +import moment from 'moment'; + +export const getDateRange = () => { + const today = moment(new Date()); + return { + start: `${ moment(new Date(today.year(), today.month(), today.date(), 0, 0, 0)).utc().format('YYYY-MM-DDTHH:mm:ss') }Z`, + end: `${ moment(new Date(today.year(), today.month(), today.date(), 23, 59, 59)).utc().format('YYYY-MM-DDTHH:mm:ss') }Z`, + }; +}; diff --git a/client/omnichannel/DepartmentAutoComplete.js b/client/omnichannel/DepartmentAutoComplete.js new file mode 100644 index 000000000000..499e62b21a3f --- /dev/null +++ b/client/omnichannel/DepartmentAutoComplete.js @@ -0,0 +1,24 @@ +import React, { useMemo, useState } from 'react'; +import { AutoComplete, Option, Icon } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; + +import { useEndpointDataExperimental } from '../hooks/useEndpointDataExperimental'; + +const query = (term = '') => ({ selector: JSON.stringify({ term }) }); + +const DepartmentAutoComplete = React.memo((props) => { + const [filter, setFilter] = useState(''); + const { data } = useEndpointDataExperimental('livechat/department.autocomplete', useMemo(() => query(filter), [filter])); + const options = useMemo(() => (data && data.items.map((department) => ({ value: department._id, label: department.name }))) || [], [data]); + const onClickRemove = useMutableCallback(() => props.onChange('')); + return {label}} + renderItem={({ value, label, ...props }) => {label}} + options={ options } + />; +}); + +export default DepartmentAutoComplete; diff --git a/client/omnichannel/realTimeMonitoring/RealTimeMonitoringPage.js b/client/omnichannel/realTimeMonitoring/RealTimeMonitoringPage.js new file mode 100644 index 000000000000..7e991f74f076 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/RealTimeMonitoringPage.js @@ -0,0 +1,109 @@ +import React, { useRef, useState, useMemo, useEffect } from 'react'; +import { Box, Select, Field, Margins } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; + +import Page from '../../components/basic/Page'; +import ChatsChart from './charts/ChatsChart'; +import ChatsPerAgentChart from './charts/ChatsPerAgentChart'; +import AgentStatusChart from './charts/AgentStatusChart'; +import ChatsPerDepartmentChart from './charts/ChatsPerDepartmentChart'; +import ChatDurationChart from './charts/ChatDurationChart'; +import ResponseTimesChart from './charts/ResponseTimesChart'; +import ConversationOverview from './overviews/ConversationOverview'; +import AgentsOverview from './overviews/AgentsOverview'; +import ChatsOverview from './overviews/ChatsOverview'; +import ProductivityOverview from './overviews/ProductivityOverview'; +import DepartmentAutoComplete from '../DepartmentAutoComplete'; +import { getDateRange } from '../../helpers/getDateRange'; +import { useTranslation } from '../../contexts/TranslationContext'; + +const dateRange = getDateRange(); + +const RealTimeMonitoringPage = () => { + const t = useTranslation(); + + const [reloadFrequency, setReloadFrequency] = useState(5); + const [department, setDepartment] = useState(''); + + const reloadRef = useRef({}); + + const departmentParams = useMemo(() => ({ + ...department && { departmentId: department }, + }), [department]); + + const allParams = useMemo(() => ({ + ...departmentParams, + ...dateRange, + }), [departmentParams]); + + const reloadCharts = useMutableCallback(() => { + Object.values(reloadRef.current).forEach((reload) => { + reload(); + }); + }); + + useEffect(() => { + const interval = setInterval(reloadCharts, reloadFrequency * 1000); + return () => { + clearInterval(interval); + }; + }, [reloadCharts, reloadFrequency]); + + const reloadOptions = useMemo(() => [ + [5, <>5 {t('seconds')}>], + [10, <>10 {t('seconds')}>], + [30, <>30 {t('seconds')}>], + [60, <>1 {t('minute')}>], + ], [t]); + + return + + + + + + + {t('Department')} + + + + + + {t('Update_every')} + + setReloadFrequency(val))} value={reloadFrequency}/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ; +}; + +export default RealTimeMonitoringPage; diff --git a/client/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js b/client/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js new file mode 100644 index 000000000000..8a9d96b96201 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js @@ -0,0 +1,72 @@ +import React, { useRef, useEffect } from 'react'; + +import Chart from './Chart'; +import { useEndpointDataExperimental, ENDPOINT_STATES } from '../../../hooks/useEndpointDataExperimental'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import { drawDoughnutChart } from '../../../../app/livechat/client/lib/chartHandler'; +import { useUpdateChartData } from './useUpdateChartData'; + +const labels = ['Available', 'Away', 'Busy', 'Offline']; + +const initialData = { + available: 0, + away: 0, + busy: 0, + offline: 0, +}; + +const init = (canvas, context, t) => drawDoughnutChart( + canvas, + t('Agents'), + context, + labels, + Object.values(initialData), +); + +const AgentStatusChart = ({ params, reloadRef, ...props }) => { + const t = useTranslation(); + + const canvas = useRef(); + const context = useRef(); + + const updateChartData = useUpdateChartData({ + context, + canvas, + t, + init, + }); + + const { data, state, reload } = useEndpointDataExperimental( + 'livechat/analytics/dashboards/charts/agents-status', + params, + ); + + reloadRef.current.agentStatusChart = reload; + + const { + offline = 0, + available = 0, + away = 0, + busy = 0, + } = data ?? initialData; + + useEffect(() => { + const initChart = async () => { + context.current = await init(canvas.current, context.current, t); + }; + initChart(); + }, [t]); + + useEffect(() => { + if (state === ENDPOINT_STATES.DONE) { + updateChartData('Offline', [offline]); + updateChartData('Available', [available]); + updateChartData('Away', [away]); + updateChartData('Busy', [busy]); + } + }, [available, away, busy, offline, state, t, updateChartData]); + + return ; +}; + +export default AgentStatusChart; diff --git a/client/omnichannel/realTimeMonitoring/charts/Chart.js b/client/omnichannel/realTimeMonitoring/charts/Chart.js new file mode 100644 index 000000000000..19d66f066fc3 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/charts/Chart.js @@ -0,0 +1,17 @@ +import React, { forwardRef } from 'react'; +import { Box } from '@rocket.chat/fuselage'; + +const style = { + minHeight: '250px', +}; +const Chart = forwardRef(function Chart(props, ref) { + return + + ; +}); + +export default Chart; diff --git a/client/omnichannel/realTimeMonitoring/charts/ChatDurationChart.js b/client/omnichannel/realTimeMonitoring/charts/ChatDurationChart.js new file mode 100644 index 000000000000..38cfa0598744 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/charts/ChatDurationChart.js @@ -0,0 +1,71 @@ +import React, { useRef, useEffect } from 'react'; + +import Chart from './Chart'; +import { useUpdateChartData } from './useUpdateChartData'; +import { useEndpointDataExperimental, ENDPOINT_STATES } from '../../../hooks/useEndpointDataExperimental'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import { drawLineChart } from '../../../../app/livechat/client/lib/chartHandler'; +import { getMomentChartLabelsAndData } from './getMomentChartLabelsAndData'; +import { getMomentCurrentLabel } from './getMomentCurrentLabel'; + +const [labels, initialData] = getMomentChartLabelsAndData(); + +const init = (canvas, context, t) => drawLineChart( + canvas, + context, + [t('Avg_chat_duration'), t('Longest_chat_duration')], + labels, + [initialData, initialData], + { legends: true, anim: true, smallTicks: true }, +); + +const ChatDurationChart = ({ params, reloadRef, ...props }) => { + const t = useTranslation(); + + const canvas = useRef(); + const context = useRef(); + + const updateChartData = useUpdateChartData({ + context, + canvas, + t, + init, + }); + + const { data, state, reload } = useEndpointDataExperimental( + 'livechat/analytics/dashboards/charts/timings', + params, + ); + + reloadRef.current.chatDurationChart = reload; + + const { + chatDuration: { + avg, + longest, + }, + } = data ?? { + chatDuration: { + avg: 0, + longest: 0, + }, + }; + + useEffect(() => { + const initChart = async () => { + context.current = await init(canvas.current, context.current, t); + }; + initChart(); + }, [t]); + + useEffect(() => { + if (state === ENDPOINT_STATES.DONE) { + const label = getMomentCurrentLabel(); + updateChartData(label, [avg, longest]); + } + }, [avg, longest, state, t, updateChartData]); + + return ; +}; + +export default ChatDurationChart; diff --git a/client/omnichannel/realTimeMonitoring/charts/ChatsChart.js b/client/omnichannel/realTimeMonitoring/charts/ChatsChart.js new file mode 100644 index 000000000000..032a9439ba64 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/charts/ChatsChart.js @@ -0,0 +1,73 @@ +import React, { useRef, useEffect } from 'react'; + +import Chart from './Chart'; +import { useEndpointDataExperimental, ENDPOINT_STATES } from '../../../hooks/useEndpointDataExperimental'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import { drawDoughnutChart } from '../../../../app/livechat/client/lib/chartHandler'; +import { useUpdateChartData } from './useUpdateChartData'; + +const labels = [ + 'Open', + 'Queued', + 'Closed', +]; + +const initialData = { + open: 0, + queued: 0, + closed: 0, +}; + +const init = (canvas, context, t) => drawDoughnutChart( + canvas, + t('Chats'), + context, + labels, + Object.values(initialData), +); + +const ChatsChart = ({ params, reloadRef, ...props }) => { + const t = useTranslation(); + + const canvas = useRef(); + const context = useRef(); + + const updateChartData = useUpdateChartData({ + context, + canvas, + t, + init, + }); + + const { data, state, reload } = useEndpointDataExperimental( + 'livechat/analytics/dashboards/charts/chats', + params, + ); + + reloadRef.current.chatsChart = reload; + + const { + open, + queued, + closed, + } = data ?? initialData; + + useEffect(() => { + const initChart = async () => { + context.current = await init(canvas.current, context.current, t); + }; + initChart(); + }, [t]); + + useEffect(() => { + if (state === ENDPOINT_STATES.DONE) { + updateChartData(t('Open'), [open]); + updateChartData(t('Closed'), [closed]); + updateChartData(t('Queued'), [queued]); + } + }, [closed, open, queued, state, t, updateChartData]); + + return ; +}; + +export default ChatsChart; diff --git a/client/omnichannel/realTimeMonitoring/charts/ChatsPerAgentChart.js b/client/omnichannel/realTimeMonitoring/charts/ChatsPerAgentChart.js new file mode 100644 index 000000000000..3b18b98e19ee --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/charts/ChatsPerAgentChart.js @@ -0,0 +1,64 @@ +import React, { useRef, useEffect } from 'react'; + +import Chart from './Chart'; +import { useUpdateChartData } from './useUpdateChartData'; +import { useEndpointDataExperimental, ENDPOINT_STATES } from '../../../hooks/useEndpointDataExperimental'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import { drawLineChart } from '../../../../app/livechat/client/lib/chartHandler'; + +const initialData = { + agents: {}, +}; + +const init = (canvas, context, t) => drawLineChart( + canvas, + context, + [t('Open'), t('Closed')], + [], + [[], []], + { legends: true, anim: true, smallTicks: true }, +); + +const ChatsPerAgentChart = ({ params, reloadRef, ...props }) => { + const t = useTranslation(); + + const canvas = useRef(); + const context = useRef(); + + const updateChartData = useUpdateChartData({ + context, + canvas, + t, + init, + }); + + const { data, state, reload } = useEndpointDataExperimental( + 'livechat/analytics/dashboards/charts/chats-per-agent', + params, + ); + + reloadRef.current.chatsPerAgentChart = reload; + + const { + agents = {}, + } = data ?? initialData; + + useEffect(() => { + const initChart = async () => { + context.current = await init(canvas.current, context.current, t); + }; + initChart(); + }, [t]); + + useEffect(() => { + if (state === ENDPOINT_STATES.DONE) { + Object.entries(agents).forEach(([name, value]) => { + updateChartData(name, [value.open, value.closed]); + }); + } + }, [agents, state, t, updateChartData]); + + return ; +}; + +export default ChatsPerAgentChart; diff --git a/client/omnichannel/realTimeMonitoring/charts/ChatsPerDepartmentChart.js b/client/omnichannel/realTimeMonitoring/charts/ChatsPerDepartmentChart.js new file mode 100644 index 000000000000..493c7d9288a7 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/charts/ChatsPerDepartmentChart.js @@ -0,0 +1,64 @@ +import React, { useRef, useEffect } from 'react'; + +import Chart from './Chart'; +import { useUpdateChartData } from './useUpdateChartData'; +import { useEndpointDataExperimental, ENDPOINT_STATES } from '../../../hooks/useEndpointDataExperimental'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import { drawLineChart } from '../../../../app/livechat/client/lib/chartHandler'; + +const initialData = { + departments: {}, +}; + +const init = (canvas, context, t) => drawLineChart( + canvas, + context, + [t('Open'), t('Closed')], + [], + [[], []], + { legends: true, anim: true, smallTicks: true }, +); + +const ChatsPerDepartmentChart = ({ params, reloadRef, ...props }) => { + const t = useTranslation(); + + const canvas = useRef(); + const context = useRef(); + + const updateChartData = useUpdateChartData({ + context, + canvas, + t, + init, + }); + + const { data, state, reload } = useEndpointDataExperimental( + 'livechat/analytics/dashboards/charts/chats-per-department', + params, + ); + + reloadRef.current.chatsPerDepartmentChart = reload; + + const { + departments = {}, + } = data ?? initialData; + + useEffect(() => { + const initChart = async () => { + context.current = await init(canvas.current, context.current, t); + }; + initChart(); + }, [t]); + + useEffect(() => { + if (state === ENDPOINT_STATES.DONE) { + Object.entries(departments).forEach(([name, value]) => { + updateChartData(name, [value.open, value.closed]); + }); + } + }, [departments, state, t, updateChartData]); + + return ; +}; + +export default ChatsPerDepartmentChart; diff --git a/client/omnichannel/realTimeMonitoring/charts/ResponseTimesChart.js b/client/omnichannel/realTimeMonitoring/charts/ResponseTimesChart.js new file mode 100644 index 000000000000..5f34715280c2 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/charts/ResponseTimesChart.js @@ -0,0 +1,79 @@ +import React, { useRef, useEffect } from 'react'; + +import Chart from './Chart'; +import { useUpdateChartData } from './useUpdateChartData'; +import { useEndpointDataExperimental, ENDPOINT_STATES } from '../../../hooks/useEndpointDataExperimental'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import { drawLineChart } from '../../../../app/livechat/client/lib/chartHandler'; +import { getMomentChartLabelsAndData } from './getMomentChartLabelsAndData'; +import { getMomentCurrentLabel } from './getMomentCurrentLabel'; + +const [labels, initialData] = getMomentChartLabelsAndData(); + +const init = (canvas, context, t) => drawLineChart( + canvas, + context, + [t('Avg_reaction_time'), t('Longest_reaction_time'), t('Avg_response_time'), t('Longest_response_time')], + labels, + [initialData, initialData, initialData, initialData], + { legends: true, anim: true, smallTicks: true }, +); + +const ResponseTimesChart = ({ params, reloadRef, ...props }) => { + const t = useTranslation(); + + const canvas = useRef(); + const context = useRef(); + + const updateChartData = useUpdateChartData({ + context, + canvas, + t, + init, + }); + + const { data, state, reload } = useEndpointDataExperimental( + 'livechat/analytics/dashboards/charts/timings', + params, + ); + + reloadRef.current.responseTimesChart = reload; + + const { + reaction: { + avg: reactionAvg, + longest: reactionLongest, + }, + response: { + avg: responseAvg, + longest: responseLongest, + }, + } = data ?? { + reaction: { + avg: 0, + longest: 0, + }, + response: { + avg: 0, + longest: 0, + }, + }; + + useEffect(() => { + const initChart = async () => { + context.current = await init(canvas.current, context.current, t); + }; + initChart(); + }, [t]); + + useEffect(() => { + if (state === ENDPOINT_STATES.DONE) { + const label = getMomentCurrentLabel(); + updateChartData(label, [reactionAvg, reactionLongest, responseAvg, responseLongest]); + } + }, [reactionAvg, reactionLongest, responseAvg, responseLongest, state, t, updateChartData]); + + return ; +}; + +export default ResponseTimesChart; diff --git a/client/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.js b/client/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.js new file mode 100644 index 000000000000..64b1ace72cb5 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.js @@ -0,0 +1,14 @@ +import moment from 'moment'; + +export const getMomentChartLabelsAndData = () => { + const timingLabels = []; + const initData = []; + const today = moment().startOf('day'); + for (let m = today; m.diff(moment(), 'hours') < 0; m.add(1, 'hours')) { + const hour = m.format('H'); + timingLabels.push(`${ moment(hour, ['H']).format('hA') }-${ moment((parseInt(hour) + 1) % 24, ['H']).format('hA') }`); + initData.push(0); + } + + return [timingLabels, initData]; +}; diff --git a/client/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.js b/client/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.js new file mode 100644 index 000000000000..964a21b42985 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.js @@ -0,0 +1,8 @@ + +import moment from 'moment'; + +export const getMomentCurrentLabel = () => { + const hour = moment(new Date()).format('H'); + + return `${ moment(hour, ['H']).format('hA') }-${ moment((parseInt(hour) + 1) % 24, ['H']).format('hA') }`; +}; diff --git a/client/omnichannel/realTimeMonitoring/charts/useUpdateChartData.js b/client/omnichannel/realTimeMonitoring/charts/useUpdateChartData.js new file mode 100644 index 000000000000..7deaf9d1f651 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/charts/useUpdateChartData.js @@ -0,0 +1,10 @@ +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; + +import { updateChart } from '../../../../app/livechat/client/lib/chartHandler'; + +export const useUpdateChartData = ({ context, canvas, init, t }) => useMutableCallback(async (label, data) => { + if (!context.current) { + context.current = await init(canvas.current, context.current, t); + } + await updateChart(context.current, label, data); +}); diff --git a/client/omnichannel/realTimeMonitoring/counter/CounterContainer.js b/client/omnichannel/realTimeMonitoring/counter/CounterContainer.js new file mode 100644 index 000000000000..e260e80585a0 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/counter/CounterContainer.js @@ -0,0 +1,29 @@ +import React, { useEffect, useState } from 'react'; +import { Skeleton } from '@rocket.chat/fuselage'; + +import { ENDPOINT_STATES } from '../../../hooks/useEndpointDataExperimental'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import CounterRow from './CounterRow'; +import CounterItem from './CounterItem'; + +const CounterContainer = ({ data, state, initialData, ...props }) => { + const t = useTranslation(); + + const [displayData, setDisplayData] = useState(initialData); + + const { + totalizers, + } = data || { totalizers: initialData }; + + useEffect(() => { + if (state === ENDPOINT_STATES.DONE) { + setDisplayData(totalizers); + } + }, [state, t, totalizers]); + + return + {displayData.map(({ title, value }, i) => } count={value}/>)} + ; +}; + +export default CounterContainer; diff --git a/client/omnichannel/realTimeMonitoring/counter/CounterItem.js b/client/omnichannel/realTimeMonitoring/counter/CounterItem.js new file mode 100644 index 000000000000..8cfc7016d926 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/counter/CounterItem.js @@ -0,0 +1,20 @@ +import React from 'react'; +import { Box } from '@rocket.chat/fuselage'; + +const CounterItem = ({ title = '', count = '-', ...props }) => + + {title} + + + {count} + +; + +export default CounterItem; diff --git a/client/omnichannel/realTimeMonitoring/counter/CounterRow.js b/client/omnichannel/realTimeMonitoring/counter/CounterRow.js new file mode 100644 index 000000000000..526372e1580e --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/counter/CounterRow.js @@ -0,0 +1,23 @@ +import React, { Fragment } from 'react'; +import { Box, Divider } from '@rocket.chat/fuselage'; +import flattenChildren from 'react-keyed-flatten-children'; + +const CounterRow = ({ children, ...props }) => + {children && flattenChildren(children).reduce((acc, child, i) => { + acc = children.length - 1 !== i + ? [...acc, {child}, ] + : [...acc, child]; + return acc; + }, [])} +; + +export default CounterRow; diff --git a/client/omnichannel/realTimeMonitoring/counter/CounterRow.stories.js b/client/omnichannel/realTimeMonitoring/counter/CounterRow.stories.js new file mode 100644 index 000000000000..ca1bd8d550e9 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/counter/CounterRow.stories.js @@ -0,0 +1,16 @@ +import React from 'react'; + +import CounterRow from './CounterRow'; +import CounterItem from './CounterItem'; + +export default { + title: 'omnichannel/RealtimeMonitoring/Counter', + component: CounterRow, +}; + +export const Default = () => + + + + +; diff --git a/client/omnichannel/realTimeMonitoring/overviews/AgentsOverview.js b/client/omnichannel/realTimeMonitoring/overviews/AgentsOverview.js new file mode 100644 index 000000000000..29dc5457d219 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/overviews/AgentsOverview.js @@ -0,0 +1,28 @@ +import React from 'react'; + +import { useEndpointDataExperimental } from '../../../hooks/useEndpointDataExperimental'; +import CounterContainer from '../counter/CounterContainer'; + +const overviewInitalValue = { + title: '', + value: '-', +}; + +const initialData = [ + overviewInitalValue, + overviewInitalValue, + overviewInitalValue, +]; + +const AgentsOverview = ({ params, reloadRef, ...props }) => { + const { data, state, reload } = useEndpointDataExperimental( + 'livechat/analytics/dashboards/agents-productivity-totalizers', + params, + ); + + reloadRef.current.agentsOverview = reload; + + return ; +}; + +export default AgentsOverview; diff --git a/client/omnichannel/realTimeMonitoring/overviews/ChatsOverview.js b/client/omnichannel/realTimeMonitoring/overviews/ChatsOverview.js new file mode 100644 index 000000000000..08f6cec7a375 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/overviews/ChatsOverview.js @@ -0,0 +1,23 @@ +import React from 'react'; + +import { useEndpointDataExperimental } from '../../../hooks/useEndpointDataExperimental'; +import CounterContainer from '../counter/CounterContainer'; + +const initialData = [ + { title: '', value: 0 }, + { title: '', value: '0%' }, + { title: '', value: '00:00:00' }, +]; + +const ChatsOverview = ({ params, reloadRef, ...props }) => { + const { data, state, reload } = useEndpointDataExperimental( + 'livechat/analytics/dashboards/chats-totalizers', + params, + ); + + reloadRef.current.chatsOverview = reload; + + return ; +}; + +export default ChatsOverview; diff --git a/client/omnichannel/realTimeMonitoring/overviews/ConversationOverview.js b/client/omnichannel/realTimeMonitoring/overviews/ConversationOverview.js new file mode 100644 index 000000000000..6fe604dd9fee --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/overviews/ConversationOverview.js @@ -0,0 +1,29 @@ +import React from 'react'; + +import { useEndpointDataExperimental } from '../../../hooks/useEndpointDataExperimental'; +import CounterContainer from '../counter/CounterContainer'; + +const overviewInitalValue = { + title: '', + value: 0, +}; + +const initialData = [ + overviewInitalValue, + overviewInitalValue, + overviewInitalValue, + overviewInitalValue, +]; + +const ConversationOverview = ({ params, reloadRef, ...props }) => { + const { data, state, reload } = useEndpointDataExperimental( + 'livechat/analytics/dashboards/conversation-totalizers', + params, + ); + + reloadRef.current.conversationOverview = reload; + + return ; +}; + +export default ConversationOverview; diff --git a/client/omnichannel/realTimeMonitoring/overviews/ProductivityOverview.js b/client/omnichannel/realTimeMonitoring/overviews/ProductivityOverview.js new file mode 100644 index 000000000000..a86cadf5c9a8 --- /dev/null +++ b/client/omnichannel/realTimeMonitoring/overviews/ProductivityOverview.js @@ -0,0 +1,27 @@ +import React from 'react'; + +import { useEndpointDataExperimental } from '../../../hooks/useEndpointDataExperimental'; +import CounterContainer from '../counter/CounterContainer'; + +const defaultValue = { title: '', value: '00:00:00' }; + + +const initialData = [ + defaultValue, + defaultValue, + defaultValue, + defaultValue, +]; + +const ProductivityOverview = ({ params, reloadRef, ...props }) => { + const { data, state, reload } = useEndpointDataExperimental( + 'livechat/analytics/dashboards/productivity-totalizers', + params, + ); + + reloadRef.current.productivityOverview = reload; + + return ; +}; + +export default ProductivityOverview; diff --git a/client/omnichannel/routes.js b/client/omnichannel/routes.js index 9def7135deab..fe9cd83a93c5 100644 --- a/client/omnichannel/routes.js +++ b/client/omnichannel/routes.js @@ -73,3 +73,8 @@ registerOmnichannelRoute('/current', { name: 'omnichannel-current-chats', lazyRouteComponent: () => import('./currentChats/CurrentChatsRoute'), }); + +registerOmnichannelRoute('/realtime-monitoring', { + name: 'omnichannel-realTime', + lazyRouteComponent: () => import('./realTimeMonitoring/RealTimeMonitoringPage'), +}); diff --git a/client/omnichannel/sidebarItems.js b/client/omnichannel/sidebarItems.js index 5aee7ef1d01f..82586bc50daf 100644 --- a/client/omnichannel/sidebarItems.js +++ b/client/omnichannel/sidebarItems.js @@ -15,7 +15,7 @@ export const { i18nLabel: 'Analytics', permissionGranted: () => hasPermission('view-livechat-analytics'), }, { - href: 'omnichannel/real-time-monitoring', + href: 'omnichannel-realTime', i18nLabel: 'Real_Time_Monitoring', permissionGranted: () => hasPermission('view-livechat-real-time-monitoring'), }, { diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 36e5dd92e21a..d6e264059b4e 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -3682,6 +3682,7 @@ "Unread_Tray_Icon_Alert": "Unread Tray Icon Alert", "Unstar_Message": "Remove Star", "Update": "Update", + "Update_every": "Update every", "Update_LatestAvailableVersion": "Update Latest Available Version", "Update_EnableChecker": "Enable the Update Checker", "Update_to_version": "Update to __version__",