feat: Add reports about live agent load (#4537)

* feat: Add reports about live agent load
This commit is contained in:
Aswin Dev P.S 2022-04-25 20:04:41 +05:30 committed by GitHub
parent 899176a793
commit 676796ddc7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 758 additions and 48 deletions

View file

@ -4,6 +4,7 @@ class V2::ReportBuilder
attr_reader :account, :params
DEFAULT_GROUP_BY = 'day'.freeze
AGENT_RESULTS_PER_PAGE = 10
def initialize(account, params)
@account = account
@ -79,12 +80,15 @@ class V2::ReportBuilder
end
def agent_metrics
users = @account.users
users = users.where(id: params[:user_id]) if params[:user_id].present?
users.each_with_object([]) do |user, arr|
@user = user
account_users = @account.account_users.page(params[:page]).per(AGENT_RESULTS_PER_PAGE)
account_users.each_with_object([]) do |account_user, arr|
@user = account_user.user
arr << {
user: { id: user.id, name: user.name, thumbnail: user.avatar_url },
id: @user.id,
name: @user.name,
email: @user.email,
thumbnail: @user.avatar_url,
availability: account_user.availability_status,
metric: conversations
}
end
@ -94,7 +98,7 @@ class V2::ReportBuilder
@open_conversations = scope.conversations.open
first_response_count = scope.reporting_events.where(name: 'first_response', conversation_id: @open_conversations.pluck('id')).count
metric = {
open: @open_conversations.count,
total: @open_conversations.count,
unattended: @open_conversations.count - first_response_count
}
metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account)

View file

@ -82,7 +82,8 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
def conversation_params
{
type: params[:type].to_sym,
user_id: params[:user_id]
user_id: params[:user_id],
page: params[:page].presence || 1
}
end

View file

@ -44,6 +44,15 @@ class ReportsAPI extends ApiClient {
});
}
getConversationMetric(type = 'account', page = 1) {
return axios.get(`${this.url}/conversations`, {
params: {
type,
page,
},
});
}
getAgentReports(since, until) {
return axios.get(`${this.url}/agents`, {
params: { since, until },

View file

@ -97,5 +97,18 @@ describe('#Reports API', () => {
}
);
});
it('#getConversationMetric', () => {
reportsAPI.getConversationMetric('account');
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/conversations',
{
params: {
type: 'account',
page: 1,
},
}
);
});
});
});

View file

@ -3,7 +3,8 @@ import { frontendURL } from '../../../../helper/URLHelper';
const reports = accountId => ({
parentNav: 'reports',
routes: [
'settings_account_reports',
'account_overview_reports',
'conversation_reports',
'csat_reports',
'agent_reports',
'label_reports',
@ -16,7 +17,14 @@ const reports = accountId => ({
label: 'REPORTS_OVERVIEW',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/overview`),
toStateName: 'settings_account_reports',
toStateName: 'account_overview_reports',
},
{
icon: 'chat',
label: 'REPORTS_CONVERSATION',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/conversation`),
toStateName: 'conversation_reports',
},
{
icon: 'emoji',

View file

@ -23,6 +23,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'contact.updated': this.onContactUpdate,
'conversation.mentioned': this.onConversationMentioned,
'notification.created': this.onNotificationCreated,
'first.reply.created': this.onFirstReplyCreated,
'conversation.read': this.onConversationRead,
};
}
@ -128,6 +129,7 @@ class ActionCableConnector extends BaseActionCableConnector {
fetchConversationStats = () => {
bus.$emit('fetch_conversation_stats');
bus.$emit('fetch_overview_reports');
};
onContactDelete = data => {
@ -145,6 +147,10 @@ class ActionCableConnector extends BaseActionCableConnector {
onNotificationCreated = data => {
this.app.$store.dispatch('notifications/addNotification', data);
};
onFirstReplyCreated = () => {
bus.$emit('fetch_overview_reports');
};
}
export default {

View file

@ -108,6 +108,7 @@
"GO_TO_CONVERSATION_DASHBOARD": "Go to Conversation Dashboard",
"GO_TO_CONTACTS_DASHBOARD": "Go to Contacts Dashboard",
"GO_TO_REPORTS_OVERVIEW": "Go to Reports Overview",
"GO_TO_CONVERSATION_REPORTS": "Go to Conversation Reports",
"GO_TO_AGENT_REPORTS": "Go to Agent Reports",
"GO_TO_LABEL_REPORTS": "Go to Label Reports",
"GO_TO_INBOX_REPORTS": "Go to Inbox Reports",

View file

@ -1,6 +1,6 @@
{
"REPORT": {
"HEADER": "Overview",
"HEADER": "Conversations",
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_AGENT_REPORTS": "Download agent reports",
@ -381,5 +381,33 @@
"TOOLTIP": "Total number of responses / Total number of CSAT survey messages sent * 100"
}
}
},
"OVERVIEW_REPORTS": {
"HEADER": "Overview",
"LIVE": "Live",
"ACCOUNT_CONVERSATIONS": {
"HEADER": "Open Conversations",
"LOADING_MESSAGE": "Conversations Loading...",
"TOTAL" : "Total",
"UNATTENDED": "Unattended",
"UNASSIGNED": "Unassigned"
},
"AGENT_CONVERSATIONS": {
"HEADER": "Conversations by agents",
"LOADING_MESSAGE": "Agents Loading...",
"NO_AGENTS": "There are no conversations by agents",
"TABLE_HEADER": {
"AGENT": "Agent",
"TOTAL": "Total",
"UNATTENDED": "Unattended",
"STATUS": "Status"
}
},
"AGENT_STATUS": {
"HEADER": "Agent status",
"ONLINE": "Online",
"BUSY": "Busy",
"OFFLINE": "Offline"
}
}
}

View file

@ -23,7 +23,7 @@
"TITLE": "Personal message signature",
"NOTE": "Create a personal message signature that would be added to all the messages you send from the platform. Use the rich content editor to create a highly personalised signature.",
"BTN_TEXT": "Save message signature",
"API_ERROR":"Couldn't save signature! Try again",
"API_ERROR": "Couldn't save signature! Try again",
"API_SUCCESS": "Signature saved successfully"
},
"MESSAGE_SIGNATURE": {
@ -173,7 +173,7 @@
"NEW_LABEL": "New label",
"NEW_TEAM": "New team",
"NEW_INBOX": "New inbox",
"REPORTS_OVERVIEW": "Overview",
"REPORTS_CONVERSATION": "Conversations",
"CSAT": "CSAT",
"CAMPAIGNS": "Campaigns",
"ONGOING": "Ongoing",
@ -183,7 +183,8 @@
"REPORTS_INBOX": "Inbox",
"REPORTS_TEAM": "Team",
"SET_AVAILABILITY_TITLE": "Set yourself as",
"BETA": "Beta"
"BETA": "Beta",
"REPORTS_OVERVIEW": "Overview"
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",

View file

@ -13,6 +13,7 @@ export const ICON_SNOOZE_UNTIL_TOMORRROW = `<svg role="img" class="ninja-icon ni
export const ICON_CONVERSATION_DASHBOARD = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M10.55 2.532a2.25 2.25 0 0 1 2.9 0l6.75 5.692c.507.428.8 1.057.8 1.72v9.803a1.75 1.75 0 0 1-1.75 1.75h-3.5a1.75 1.75 0 0 1-1.75-1.75v-5.5a.25.25 0 0 0-.25-.25h-3.5a.25.25 0 0 0-.25.25v5.5a1.75 1.75 0 0 1-1.75 1.75h-3.5A1.75 1.75 0 0 1 3 19.747V9.944c0-.663.293-1.292.8-1.72l6.75-5.692zm1.933 1.147a.75.75 0 0 0-.966 0L4.767 9.37a.75.75 0 0 0-.267.573v9.803c0 .138.112.25.25.25h3.5a.25.25 0 0 0 .25-.25v-5.5c0-.967.784-1.75 1.75-1.75h3.5c.966 0 1.75.783 1.75 1.75v5.5c0 .138.112.25.25.25h3.5a.25.25 0 0 0 .25-.25V9.944a.75.75 0 0 0-.267-.573l-6.75-5.692z" fill="currentColor"></path></g></svg>`;
export const ICON_CONTACT_DASHBOARD = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M17.754 14a2.249 2.249 0 0 1 2.25 2.249v.575c0 .894-.32 1.76-.902 2.438c-1.57 1.834-3.957 2.739-7.102 2.739c-3.146 0-5.532-.905-7.098-2.74a3.75 3.75 0 0 1-.898-2.435v-.577a2.249 2.249 0 0 1 2.249-2.25h11.501zm0 1.5H6.253a.749.749 0 0 0-.75.749v.577c0 .536.192 1.054.54 1.461c1.253 1.468 3.219 2.214 5.957 2.214s4.706-.746 5.962-2.214a2.25 2.25 0 0 0 .541-1.463v-.575a.749.749 0 0 0-.749-.75zM12 2.004a5 5 0 1 1 0 10a5 5 0 0 1 0-10zm0 1.5a3.5 3.5 0 1 0 0 7a3.5 3.5 0 0 0 0-7z" fill="currentColor"></path></g></svg>`;
export const ICON_REPORTS_OVERVIEW = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M16.749 2h4.554l.1.014l.099.028l.06.026c.08.034.153.085.219.15l.04.044l.044.057l.054.09l.039.09l.019.064l.014.064l.009.095v4.532a.75.75 0 0 1-1.493.102l-.007-.102V4.559l-6.44 6.44a.75.75 0 0 1-.976.073L13 11L9.97 8.09l-5.69 5.689a.75.75 0 0 1-1.133-.977l.073-.084l6.22-6.22a.75.75 0 0 1 .976-.072l.084.072l3.03 2.91L19.438 3.5h-2.69a.75.75 0 0 1-.742-.648l-.007-.102a.75.75 0 0 1 .648-.743L16.75 2zM3.75 17a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5a.75.75 0 0 1 .75-.75zm5.75-3.25a.75.75 0 0 0-1.5 0v7.5a.75.75 0 0 0 1.5 0v-7.5zM13.75 15a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0v-5.5a.75.75 0 0 1 .75-.75zm5.75-4.25a.75.75 0 0 0-1.5 0v10.5a.75.75 0 0 0 1.5 0v-10.5z" fill="currentColor"></path></g></svg>`;
export const ICON_CONVERSATION_REPORTS = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a9.96 9.96 0 0 1-4.587-1.112l-3.826 1.067a1.25 1.25 0 0 1-1.54-1.54l1.068-3.823A9.96 9.96 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 1.5A8.5 8.5 0 0 0 3.5 12c0 1.47.373 2.883 1.073 4.137l.15.27-1.112 3.984 3.987-1.112.27.15A8.5 8.5 0 1 0 12 3.5ZM8.75 13h4.498a.75.75 0 0 1 .102 1.493l-.102.007H8.75a.75.75 0 0 1-.102-1.493L8.75 13h4.498H8.75Zm0-3.5h6.505a.75.75 0 0 1 .101 1.493l-.101.007H8.75a.75.75 0 0 1-.102-1.493L8.75 9.5h6.505H8.75Z" fill="currentColor"/></svg>`;
export const ICON_AGENT_REPORTS = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M4 13.999L13 14a2 2 0 0 1 1.995 1.85L15 16v1.5C14.999 21 11.284 22 8.5 22c-2.722 0-6.335-.956-6.495-4.27L2 17.5v-1.501c0-1.054.816-1.918 1.85-1.995L4 14zM15.22 14H20c1.054 0 1.918.816 1.994 1.85L22 16v1c-.001 3.062-2.858 4-5 4a7.16 7.16 0 0 1-2.14-.322c.336-.386.607-.827.802-1.327A6.19 6.19 0 0 0 17 19.5l.267-.006c.985-.043 3.086-.363 3.226-2.289L20.5 17v-1a.501.501 0 0 0-.41-.492L20 15.5h-4.051a2.957 2.957 0 0 0-.595-1.34L15.22 14H20h-4.78zM4 15.499l-.1.01a.51.51 0 0 0-.254.136a.506.506 0 0 0-.136.253l-.01.101V17.5c0 1.009.45 1.722 1.417 2.242c.826.445 2.003.714 3.266.753l.317.005l.317-.005c1.263-.039 2.439-.308 3.266-.753c.906-.488 1.359-1.145 1.412-2.057l.005-.186V16a.501.501 0 0 0-.41-.492L13 15.5l-9-.001zM8.5 3a4.5 4.5 0 1 1 0 9a4.5 4.5 0 0 1 0-9zm9 2a3.5 3.5 0 1 1 0 7a3.5 3.5 0 0 1 0-7zm-9-.5c-1.654 0-3 1.346-3 3s1.346 3 3 3s3-1.346 3-3s-1.346-3-3-3zm9 2c-1.103 0-2 .897-2 2s.897 2 2 2s2-.897 2-2s-.897-2-2-2z" fill="currentColor"></path></g></svg>`;
export const ICON_LABEL_REPORTS = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M19.75 2A2.25 2.25 0 0 1 22 4.25v5.462a3.25 3.25 0 0 1-.952 2.298l-8.5 8.503a3.255 3.255 0 0 1-4.597.001L3.489 16.06a3.25 3.25 0 0 1-.003-4.596l8.5-8.51A3.25 3.25 0 0 1 14.284 2h5.465zm0 1.5h-5.465c-.465 0-.91.185-1.239.513l-8.512 8.523a1.75 1.75 0 0 0 .015 2.462l4.461 4.454a1.755 1.755 0 0 0 2.477 0l8.5-8.503a1.75 1.75 0 0 0 .513-1.237V4.25a.75.75 0 0 0-.75-.75zM17 5.502a1.5 1.5 0 1 1 0 3a1.5 1.5 0 0 1 0-3z" fill="currentColor"></path></g></svg>`;
export const ICON_INBOX_REPORTS = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3h11.5h-11.5zM4.5 14.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188H4.5v3.25v-3.25zm13.25-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5z" fill="currentColor"></path></g></svg>`;

View file

@ -13,6 +13,7 @@ import {
ICON_REPORTS_OVERVIEW,
ICON_TEAM_REPORTS,
ICON_USER_PROFILE,
ICON_CONVERSATION_REPORTS,
} from './CommandBarIcons';
import { frontendURL } from '../../../helper/URLHelper';
@ -41,6 +42,14 @@ const GO_TO_COMMANDS = [
path: accountId => `accounts/${accountId}/reports/overview`,
role: ['administrator'],
},
{
id: 'open_conversation_reports',
section: 'COMMAND_BAR.SECTIONS.REPORTS',
title: 'COMMAND_BAR.COMMANDS.GO_TO_CONVERSATION_REPORTS',
icon: ICON_CONVERSATION_REPORTS,
path: accountId => `accounts/${accountId}/reports/conversation`,
role: ['administrator'],
},
{
id: 'open_agent_reports',
section: 'COMMAND_BAR.SECTIONS.REPORTS',

View file

@ -0,0 +1,129 @@
<template>
<div class="column content-box">
<div class="row">
<div class="column small-12 medium-8 conversation-metric">
<metric-card
:header="this.$t('OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.HEADER')"
:is-loading="uiFlags.isFetchingAccountConversationMetric"
:loading-message="
$t('OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.LOADING_MESSAGE')
"
>
<div
v-for="(metric, name, index) in conversationMetrics"
:key="index"
class="metric-content column"
>
<h3 class="heading">
{{ name }}
</h3>
<p class="metric">{{ metric }}</p>
</div>
</metric-card>
</div>
<div class="column small-12 medium-4">
<metric-card :header="this.$t('OVERVIEW_REPORTS.AGENT_STATUS.HEADER')">
<div
v-for="(metric, name, index) in agentStatusMetrics"
:key="index"
class="metric-content column"
>
<h3 class="heading">
{{ name }}
</h3>
<p class="metric">{{ metric }}</p>
</div>
</metric-card>
</div>
</div>
<div class="row">
<metric-card
:header="this.$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.HEADER')"
>
<agent-table
:total-agents="agentsCount"
:agent-metrics="agentConversationMetric"
:page-index="pageIndex"
:is-loading="uiFlags.isFetchingAgentConversationMetric"
@page-change="onPageNumberChange"
/>
</metric-card>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import AgentTable from './components/overview/AgentTable';
import MetricCard from './components/overview/MetricCard';
import { OVERVIEW_METRICS } from './constants';
export default {
name: 'LiveReports',
components: {
AgentTable,
MetricCard,
},
data() {
return {
pageIndex: 1,
};
},
computed: {
...mapGetters({
agentStatus: 'agents/getAgentStatus',
agentsCount: 'agents/getAgentsCount',
accountConversationMetric: 'getAccountConversationMetric',
agentConversationMetric: 'getAgentConversationMetric',
uiFlags: 'getOverviewUIFlags',
}),
agentStatusMetrics() {
let metric = {};
Object.keys(this.agentStatus).forEach(key => {
const metricName = this.$t(
`OVERVIEW_REPORTS.AGENT_STATUS.${OVERVIEW_METRICS[key]}`
);
metric[metricName] = this.agentStatus[key];
});
return metric;
},
conversationMetrics() {
let metric = {};
Object.keys(this.accountConversationMetric).forEach(key => {
const metricName = this.$t(
`OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.${OVERVIEW_METRICS[key]}`
);
metric[metricName] = this.accountConversationMetric[key];
});
return metric;
},
},
mounted() {
this.$store.dispatch('agents/get');
this.fetchAllData();
bus.$on('fetch_overview_reports', () => {
this.fetchAllData();
});
},
methods: {
fetchAllData() {
this.fetchAccountConversationMetric();
this.fetchAgentConversationMetric();
},
fetchAccountConversationMetric() {
this.$store.dispatch('fetchAccountConversationMetric', {
type: 'account',
});
},
fetchAgentConversationMetric() {
this.$store.dispatch('fetchAgentConversationMetric', {
type: 'agent',
page: this.pageIndex,
});
},
onPageNumberChange(pageIndex) {
this.pageIndex = pageIndex;
this.fetchAgentConversationMetric();
},
},
};
</script>

View file

@ -0,0 +1,199 @@
<template>
<div class="agent-table-container">
<ve-table
max-height="calc(100vh - 35rem)"
:fixed-header="true"
:columns="columns"
:table-data="tableData"
/>
<div v-if="isLoading" class="agents-loader">
<spinner />
<span>{{
$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.LOADING_MESSAGE')
}}</span>
</div>
<empty-state
v-else-if="!isLoading && !agentMetrics.length"
:title="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.NO_AGENTS')"
/>
<div v-if="agentMetrics.length > 0" class="table-pagination">
<ve-pagination
:total="totalAgents"
:page-index="pageIndex"
:page-size="10"
:page-size-option="[10]"
@on-page-number-change="onPageNumberChange"
/>
</div>
</div>
</template>
<script>
import { VeTable, VePagination } from 'vue-easytable';
import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
export default {
name: 'AgentTable',
components: {
EmptyState,
Spinner,
VeTable,
VePagination,
},
props: {
totalAgents: {
type: Number,
default: 0,
},
agentMetrics: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
pageIndex: {
type: Number,
default: 1,
},
},
data() {
return {};
},
computed: {
tableData() {
return this.agentMetrics.map(agent => {
return {
agent: agent.name,
email: agent.email,
thumbnail: agent.thumbnail,
total: agent.metric.total || 0,
unattended: agent.metric.unattended || 0,
status: agent.availability,
};
});
},
columns() {
return [
{
field: 'agent',
key: 'agent',
title: this.$t(
'OVERVIEW_REPORTS.AGENT_CONVERSATIONS.TABLE_HEADER.AGENT'
),
fixed: 'left',
align: 'left',
width: 25,
renderBodyCell: ({ row }) => (
<div class="row-user-block">
<Thumbnail
src={row.thumbnail}
size="32px"
username={row.agent}
status={row.status}
/>
<div class="user-block">
<h6 class="title text-truncate">{row.agent}</h6>
<span class="sub-title">{row.email}</span>
</div>
</div>
),
},
{
field: 'total',
key: 'total',
title: this.$t(
'OVERVIEW_REPORTS.AGENT_CONVERSATIONS.TABLE_HEADER.TOTAL'
),
align: 'left',
width: 10,
},
{
field: 'unattended',
key: 'unattended',
title: this.$t(
'OVERVIEW_REPORTS.AGENT_CONVERSATIONS.TABLE_HEADER.UNATTENDED'
),
align: 'left',
width: 10,
},
];
},
},
mounted() {},
methods: {
onPageNumberChange(pageIndex) {
this.$emit('page-change', pageIndex);
},
},
};
</script>
<style lang="scss" scoped>
.agent-table-container {
display: flex;
flex-direction: column;
flex: 1;
.ve-table {
&::v-deep {
th.ve-table-header-th {
font-size: var(--font-size-mini) !important;
padding: var(--space-small) var(--space-two) !important;
}
td.ve-table-body-td {
padding: var(--space-one) var(--space-two) !important;
}
}
}
&::v-deep .ve-pagination {
background-color: transparent;
}
&::v-deep .ve-pagination-select {
display: none;
}
.row-user-block {
align-items: center;
display: flex;
text-align: left;
.user-block {
display: flex;
flex-direction: column;
min-width: 0;
.title {
font-size: var(--font-size-small);
margin: var(--zero);
line-height: 1;
}
.sub-title {
font-size: var(--font-size-mini);
}
}
.user-thumbnail-box {
margin-right: var(--space-small);
}
}
.table-pagination {
margin-top: var(--space-normal);
text-align: right;
}
}
.agents-loader {
align-items: center;
display: flex;
font-size: var(--font-size-default);
justify-content: center;
padding: var(--space-large);
}
</style>

View file

@ -0,0 +1,98 @@
<template>
<div class="card">
<div class="card-header">
<h5>{{ header }}</h5>
<span class="live">
<span class="ellipse" /><span>{{ $t('OVERVIEW_REPORTS.LIVE') }}</span>
</span>
</div>
<div v-if="!isLoading" class="card-body row">
<slot></slot>
</div>
<div v-else-if="isLoading" class="conversation-metric-loader">
<spinner />
<span>{{ loadingMessage }}</span>
</div>
</div>
</template>
<script>
import Spinner from 'shared/components/Spinner.vue';
export default {
name: 'MetricCard',
components: {
Spinner,
},
props: {
header: {
type: String,
default: '',
},
isLoading: {
type: Boolean,
default: false,
},
loadingMessage: {
type: String,
default: '',
},
},
};
</script>
<style lang="scss" scoped>
.card {
margin: var(--space-small) !important;
}
.card-header {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: var(--space-medium);
h5 {
margin-bottom: var(--zero);
}
.live {
display: flex;
flex-direction: row;
align-items: center;
padding-right: var(--space-small);
padding-left: var(--space-small);
margin: var(--space-smaller);
background: rgba(37, 211, 102, 0.1);
color: var(--g-400);
font-size: var(--font-size-mini);
.ellipse {
background-color: var(--g-400);
height: var(--space-smaller);
width: var(--space-smaller);
border-radius: var(--border-radius-rounded);
margin-right: var(--space-smaller);
}
}
}
.card-body {
.metric-content {
padding-bottom: var(--space-small);
.heading {
font-size: var(--font-size-default);
}
.metric {
color: var(--w-800);
font-size: var(--font-size-bigger);
margin-bottom: var(--zero);
margin-top: var(--space-smaller);
}
}
}
.conversation-metric-loader {
align-items: center;
display: flex;
font-size: var(--font-size-default);
justify-content: center;
padding: var(--space-large);
}
</style>

View file

@ -121,3 +121,12 @@ export const METRIC_CHART = {
},
resolutions_count: DEFAULT_CHART,
};
export const OVERVIEW_METRICS = {
total: 'TOTAL',
unattended: 'UNATTENDED',
unassigned: 'UNASSIGNED',
online: 'ONLINE',
busy: 'BUSY',
offline: 'OFFLINE',
};

View file

@ -4,6 +4,7 @@ import LabelReports from './LabelReports';
import InboxReports from './InboxReports';
import TeamReports from './TeamReports';
import CsatResponses from './CsatResponses';
import LiveReports from './LiveReports';
import SettingsContent from '../Wrapper';
import { frontendURL } from '../../../../helper/URLHelper';
@ -13,7 +14,7 @@ export default {
path: frontendURL('accounts/:accountId/reports'),
component: SettingsContent,
props: {
headerTitle: 'REPORT.HEADER',
headerTitle: 'OVERVIEW_REPORTS.HEADER',
icon: 'arrow-trending-lines',
keepAlive: false,
},
@ -24,7 +25,24 @@ export default {
},
{
path: 'overview',
name: 'settings_account_reports',
name: 'account_overview_reports',
roles: ['administrator'],
component: LiveReports,
},
],
},
{
path: frontendURL('accounts/:accountId/reports'),
component: SettingsContent,
props: {
headerTitle: 'REPORT.HEADER',
icon: 'chat',
keepAlive: false,
},
children: [
{
path: 'conversation',
name: 'conversation_reports',
roles: ['administrator'],
component: Index,
},

View file

@ -22,6 +22,22 @@ export const getters = {
getUIFlags($state) {
return $state.uiFlags;
},
getAgentStatus($state) {
let status = {
online: $state.records.filter(
agent => agent.availability_status === 'online'
).length,
busy: $state.records.filter(agent => agent.availability_status === 'busy')
.length,
offline: $state.records.filter(
agent => agent.availability_status === 'offline'
).length,
};
return status;
},
getAgentsCount($state) {
return $state.records.length;
},
};
export const actions = {
@ -58,9 +74,10 @@ export const actions = {
}
},
updatePresence: async ({ commit }, data) => {
updatePresence: async ({ commit, dispatch }, data) => {
commit(types.default.SET_AGENT_UPDATING_STATUS, true);
commit(types.default.UPDATE_AGENTS_PRESENCE, data);
dispatch('updateReportAgentStatus', data, { root: true });
commit(types.default.SET_AGENT_UPDATING_STATUS, false);
},

View file

@ -3,6 +3,7 @@
/* eslint no-shadow: 0 */
import * as types from '../mutation-types';
import Report from '../../api/reports';
import Vue from 'vue';
import { downloadCsvFile } from '../../helper/downloadCsvFile';
@ -22,6 +23,14 @@ const state = {
resolutions_count: 0,
previous: {},
},
overview: {
uiFlags: {
isFetchingAccountConversationMetric: false,
isFetchingAgentConversationMetric: false,
},
accountConversationMetric: {},
agentConversationMetric: [],
},
};
const getters = {
@ -31,6 +40,15 @@ const getters = {
getAccountSummary(_state) {
return _state.accountSummary;
},
getAccountConversationMetric(_state) {
return _state.overview.accountConversationMetric;
},
getAgentConversationMetric(_state) {
return _state.overview.agentConversationMetric;
},
getOverviewUIFlags($state) {
return $state.overview.uiFlags;
},
};
export const actions = {
@ -70,6 +88,37 @@ export const actions = {
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
});
},
fetchAccountConversationMetric({ commit }, reportObj) {
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, true);
Report.getConversationMetric(reportObj.type)
.then(accountConversationMetric => {
commit(
types.default.SET_ACCOUNT_CONVERSATION_METRIC,
accountConversationMetric.data
);
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, false);
})
.catch(() => {
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, false);
});
},
fetchAgentConversationMetric({ commit }, reportObj) {
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, true);
Report.getConversationMetric(reportObj.type, reportObj.page)
.then(agentConversationMetric => {
commit(
types.default.SET_AGENT_CONVERSATION_METRIC,
agentConversationMetric.data
);
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, false);
})
.catch(() => {
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, false);
});
},
updateReportAgentStatus({ commit }, data) {
commit(types.default.UPDATE_REPORT_AGENTS_STATUS, data);
},
downloadAgentReports(_, reportObj) {
return Report.getAgentReports(reportObj.from, reportObj.to)
.then(response => {
@ -118,6 +167,28 @@ const mutations = {
[types.default.SET_ACCOUNT_SUMMARY](_state, summaryData) {
_state.accountSummary = summaryData;
},
[types.default.SET_ACCOUNT_CONVERSATION_METRIC](_state, metricData) {
_state.overview.accountConversationMetric = metricData;
},
[types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingAccountConversationMetric = flag;
},
[types.default.SET_AGENT_CONVERSATION_METRIC](_state, metricData) {
_state.overview.agentConversationMetric = metricData;
},
[types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingAgentConversationMetric = flag;
},
[types.default.UPDATE_REPORT_AGENTS_STATUS](_state, data) {
_state.overview.agentConversationMetric.forEach((element, index) => {
const availabilityStatus = data[element.id];
Vue.set(
_state.overview.agentConversationMetric[index],
'availability',
availabilityStatus || 'offline'
);
});
},
};
export default {

View file

@ -4,6 +4,7 @@ import * as types from '../../../mutation-types';
import agentList from './fixtures';
const commit = jest.fn();
const dispatch = jest.fn();
global.axios = axios;
jest.mock('axios');
@ -97,7 +98,7 @@ describe('#actions', () => {
describe('#updatePresence', () => {
it('sends correct actions if API is success', async () => {
const data = { users: { 1: 'online' }, contacts: { 2: 'online' } };
actions.updatePresence({ commit }, data);
actions.updatePresence({ commit, dispatch }, data);
expect(commit.mock.calls).toEqual([
[types.default.SET_AGENT_UPDATING_STATUS, true],
[types.default.UPDATE_AGENTS_PRESENCE, data],

View file

@ -77,4 +77,71 @@ describe('#getters', () => {
isDeleting: false,
});
});
it('getAgentStatus', () => {
const state = {
records: [
{
id: 1,
name: 'Agent 1',
email: 'agent1@chatwoot.com',
confirmed: true,
availability_status: 'online',
},
{
id: 2,
name: 'Agent 2',
email: 'agent2@chatwoot.com',
confirmed: false,
availability_status: 'offline',
},
],
};
expect(getters.getAgentStatus(state)).toEqual({
online: 1,
busy: 0,
offline: 1,
});
});
it('getAgentStatus', () => {
const state = {
records: [
{
id: 1,
name: 'Agent 1',
email: 'agent1@chatwoot.com',
confirmed: true,
availability_status: 'online',
},
{
id: 2,
name: 'Agent 2',
email: 'agent2@chatwoot.com',
confirmed: false,
availability_status: 'offline',
},
],
};
expect(getters.getAgentStatus(state)).toEqual({
online: 1,
busy: 0,
offline: 1,
});
});
it('getAgentStatus', () => {
const state = {
records: [
{
id: 1,
name: 'Agent 1',
email: 'agent1@chatwoot.com',
confirmed: true,
availability_status: 'online',
},
],
};
expect(getters.getAgentsCount(state)).toEqual(1);
});
});

View file

@ -92,6 +92,7 @@ describe('#mutations', () => {
id: 2,
name: 'Agent1',
email: 'agent1@chatwoot.com',
availability_status: 'offline',
},
]);
});

View file

@ -144,6 +144,13 @@ export default {
SET_ACCOUNT_REPORTS: 'SET_ACCOUNT_REPORTS',
SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY',
TOGGLE_ACCOUNT_REPORT_LOADING: 'TOGGLE_ACCOUNT_REPORT_LOADING',
SET_ACCOUNT_CONVERSATION_METRIC: 'SET_ACCOUNT_CONVERSATION_METRIC',
TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING:
'TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING',
SET_AGENT_CONVERSATION_METRIC: 'SET_AGENT_CONVERSATION_METRIC',
TOGGLE_AGENT_CONVERSATION_METRIC_LOADING:
'TOGGLE_AGENT_CONVERSATION_METRIC_LOADING',
UPDATE_REPORT_AGENTS_STATUS: 'UPDATE_AGENTS_STATUS',
// Conversation Metadata
SET_CONVERSATION_METADATA: 'SET_CONVERSATION_METADATA',

View file

@ -37,11 +37,11 @@ export const updateAttributes = (state, data) => {
export const updatePresence = (state, data) => {
state.records.forEach((element, index) => {
const availabilityStatus = data[element.id];
if (availabilityStatus) {
Vue.set(state.records[index], 'availability_status', availabilityStatus);
} else {
Vue.delete(state.records[index], 'availability_status');
}
Vue.set(
state.records[index],
'availability_status',
availabilityStatus || 'offline'
);
});
};

View file

@ -47,11 +47,13 @@ describe('#mutations', () => {
id: 3,
name: 'Pranav',
avatar_url: '',
availability_status: 'offline',
},
{
id: 4,
name: 'Nithin',
avatar_url: '',
availability_status: 'offline',
},
]);
});

View file

@ -23,6 +23,14 @@ class ActionCableListener < BaseListener
broadcast(account, tokens, MESSAGE_UPDATED, message.push_event_data)
end
def first_reply_created(event)
message, account = extract_message_and_account(event)
conversation = message.conversation
tokens = user_tokens(account, conversation.inbox.members)
broadcast(account, tokens, FIRST_REPLY_CREATED, message.push_event_data)
end
def conversation_created(event)
conversation, account = extract_conversation_and_account(event)
tokens = user_tokens(account, conversation.inbox.members) + contact_inbox_tokens(conversation.contact_inbox)

View file

@ -73,7 +73,7 @@ RSpec.describe 'Reports API', type: :request do
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response['open']).to eq(11)
expect(json_response['total']).to eq(11)
expect(json_response['unattended']).to eq(11)
expect(json_response['unassigned']).to eq(1)
end
@ -93,10 +93,10 @@ RSpec.describe 'Reports API', type: :request do
json_response = JSON.parse(response.body)
expect(json_response.blank?).to be false
user_metrics = json_response.find { |item| item['user']['name'] == admin[:name] }
user_metrics = json_response.find { |item| item['name'] == admin[:name] }
expect(user_metrics.present?).to be true
expect(user_metrics['metric']['open']).to eq(2)
expect(user_metrics['metric']['total']).to eq(2)
expect(user_metrics['metric']['unattended']).to eq(2)
end
@ -116,7 +116,7 @@ RSpec.describe 'Reports API', type: :request do
json_response = JSON.parse(response.body)
expect(json_response.blank?).to be false
expect(json_response[0]['metric']['open']).to eq(10)
expect(json_response[0]['metric']['total']).to eq(10)
expect(json_response[0]['metric']['unattended']).to eq(10)
end
end

View file

@ -1,14 +1,15 @@
type: object
properties:
user:
type: object
properties:
id:
type: number
name:
type: string
thumbnail:
type: string
id:
type: number
name:
type: string
email:
type: string
thumbnail:
type: string
availability:
type: string
metric:
type: object
properties:

View file

@ -5629,19 +5629,20 @@
"agent_conversation_metrics": {
"type": "object",
"properties": {
"user": {
"type": "object",
"properties": {
"id": {
"type": "number"
},
"name": {
"type": "string"
},
"thumbnail": {
"type": "string"
}
}
"id": {
"type": "number"
},
"name": {
"type": "string"
},
"email": {
"type": "string"
},
"thumbnail": {
"type": "string"
},
"availability": {
"type": "string"
},
"metric": {
"type": "object",