feat - Add filter for reports by agent, label and inboxes (#3084)

* Adds filter for agents, labels and inboxes

* Added Inboxes Reports Feature

* Fixed populating of filter dropdown issue

* If applied, fixes code climate style-lint warnings

* Fixes codeclimate warnings

* if applied, Refactors sidebar file to fix codclimate warnings

* if applied, fixes the download reports button for filtered report-data

* If applied, replaces native img tag with thumbnail component

* If applied, replaces hardcoded color string with variable

* If applied, adds a11y labels to multiselect dropdowns

* If applied, Renames reports methods to generic names

* If applied, Adds test cases for Labels and Inboxes

* If applied, write a test spec for fileDownload helper

* if applied, Moves fileDownload method to a utils folder

* If applied, Fixes the report file name type

* Test Spec for Reports Store module

* Fix specs - add restoreAllMocks

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
Fayaz Ahmed
2021-09-30 13:13:45 +05:30
committed by GitHub
parent 57abdc4d5f
commit a1563917ba
21 changed files with 1215 additions and 283 deletions

View File

@@ -6,15 +6,15 @@ class ReportsAPI extends ApiClient {
super('reports', { accountScoped: true, apiVersion: 'v2' });
}
getAccountReports(metric, since, until) {
getReports(metric, since, until, type = 'account', id) {
return axios.get(`${this.url}`, {
params: { metric, since, until, type: 'account' },
params: { metric, since, until, type, id },
});
}
getAccountSummary(since, until) {
getSummary(since, until, type = 'account', id) {
return axios.get(`${this.url}/summary`, {
params: { since, until, type: 'account' },
params: { since, until, type, id },
});
}
@@ -23,6 +23,18 @@ class ReportsAPI extends ApiClient {
params: { since, until },
});
}
getLabelReports(since, until) {
return axios.get(`${this.url}/labels`, {
params: { since, until },
});
}
getInboxReports(since, until) {
return axios.get(`${this.url}/inboxes`, {
params: { since, until },
});
}
}
export default new ReportsAPI();

View File

@@ -11,39 +11,34 @@ describe('#Reports API', () => {
expect(reportsAPI).toHaveProperty('create');
expect(reportsAPI).toHaveProperty('update');
expect(reportsAPI).toHaveProperty('delete');
expect(reportsAPI).toHaveProperty('getAccountReports');
expect(reportsAPI).toHaveProperty('getAccountSummary');
expect(reportsAPI).toHaveProperty('getReports');
expect(reportsAPI).toHaveProperty('getSummary');
expect(reportsAPI).toHaveProperty('getAgentReports');
expect(reportsAPI).toHaveProperty('getLabelReports');
expect(reportsAPI).toHaveProperty('getInboxReports');
});
describeWithAPIMock('API calls', context => {
it('#getAccountReports', () => {
reportsAPI.getAccountReports(
'conversations_count',
1621103400,
1621621800
);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports',
{
params: {
metric: 'conversations_count',
since: 1621103400,
until: 1621621800,
type: 'account'
},
}
);
reportsAPI.getReports('conversations_count', 1621103400, 1621621800);
expect(context.axiosMock.get).toHaveBeenCalledWith('/api/v2/reports', {
params: {
metric: 'conversations_count',
since: 1621103400,
until: 1621621800,
type: 'account',
},
});
});
it('#getAccountSummary', () => {
reportsAPI.getAccountSummary(1621103400, 1621621800);
reportsAPI.getSummary(1621103400, 1621621800);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/summary',
{
params: {
since: 1621103400,
until: 1621621800,
type: 'account'
type: 'account',
},
}
);
@@ -61,5 +56,31 @@ describe('#Reports API', () => {
}
);
});
it('#getLabelReports', () => {
reportsAPI.getLabelReports(1621103400, 1621621800);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/labels',
{
params: {
since: 1621103400,
until: 1621621800,
},
}
);
});
it('#getInboxReports', () => {
reportsAPI.getInboxReports(1621103400, 1621621800);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/inboxes',
{
params: {
since: 1621103400,
until: 1621621800,
},
}
);
});
});
});

View File

@@ -32,7 +32,6 @@
}
}
.report-bar {
@include margin(-1px $zero);
@include background-white;

View File

@@ -0,0 +1,30 @@
.date-picker {
margin-left: var(--space-smaller);
}
.margin-left-small {
margin-left: var(--space-smaller);
}
.reports-option__rounded--item {
border-radius: 100%;
height: var(--space-two);
width: var(--space-two);
}
.reports-option__item {
flex-shrink: 0;
margin-right: var(--space-small);
}
.reports-option__label--swatch {
border: 1px solid var(--color-border);
}
.margin-right-small {
margin-right: var(--space-small);
}
.display-flex {
display: flex;
}

View File

@@ -1,245 +1,13 @@
import { frontendURL } from '../helper/URLHelper';
import common from './sidebarItems/common';
import contacts from './sidebarItems/contacts';
import reports from './sidebarItems/reports';
import campaigns from './sidebarItems/campaigns';
import settings from './sidebarItems/settings';
export const getSidebarItems = accountId => ({
common: {
routes: [
'home',
'inbox_dashboard',
'inbox_conversation',
'conversation_through_inbox',
'notifications_dashboard',
'profile_settings',
'profile_settings_index',
'label_conversations',
'conversations_through_label',
'team_conversations',
'conversations_through_team',
'notifications_index',
],
menuItems: {
assignedToMe: {
icon: 'ion-chatbox-working',
label: 'CONVERSATIONS',
hasSubMenu: false,
key: '',
toState: frontendURL(`accounts/${accountId}/dashboard`),
toolTip: 'Conversation from all subscribed inboxes',
toStateName: 'home',
},
contacts: {
icon: 'ion-person',
label: 'CONTACTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard',
},
notifications: {
icon: 'ion-ios-bell',
label: 'NOTIFICATIONS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/notifications`),
toStateName: 'notifications_dashboard',
},
report: {
icon: 'ion-arrow-graph-up-right',
label: 'REPORTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports`),
toStateName: 'settings_account_reports',
},
campaigns: {
icon: 'ion-speakerphone',
label: 'CAMPAIGNS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/campaigns`),
toStateName: 'settings_account_campaigns',
},
settings: {
icon: 'ion-settings',
label: 'SETTINGS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings`),
toStateName: 'settings_home',
},
},
},
contacts: {
routes: [
'contacts_dashboard',
'contacts_dashboard_manage',
'contacts_labels_dashboard',
],
menuItems: {
back: {
icon: 'ion-ios-arrow-back',
label: 'HOME',
hasSubMenu: false,
toStateName: 'home',
toState: frontendURL(`accounts/${accountId}/dashboard`),
},
contacts: {
icon: 'ion-person',
label: 'ALL_CONTACTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard',
},
},
},
reports: {
routes: ['settings_account_reports', 'csat_reports'],
menuItems: {
back: {
icon: 'ion-ios-arrow-back',
label: 'HOME',
hasSubMenu: false,
toStateName: 'home',
toState: frontendURL(`accounts/${accountId}/dashboard`),
},
reportOverview: {
icon: 'ion-arrow-graph-up-right',
label: 'REPORTS_OVERVIEW',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/overview`),
toStateName: 'settings_account_reports',
},
csatReports: {
icon: 'ion-happy',
label: 'CSAT',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/csat`),
toStateName: 'csat_reports',
},
},
},
campaigns: {
routes: ['settings_account_campaigns', 'one_off'],
menuItems: {
back: {
icon: 'ion-ios-arrow-back',
label: 'HOME',
hasSubMenu: false,
toStateName: 'home',
toState: frontendURL(`accounts/${accountId}/dashboard`),
},
ongoingCampaigns: {
icon: 'ion-arrow-swap',
label: 'ONGOING',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/campaigns/ongoing`),
toStateName: 'settings_account_campaigns',
},
onOffCampaigns: {
icon: 'ion-radio-waves',
label: 'ONE_OFF',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/campaigns/one_off`),
toStateName: 'one_off',
},
},
},
settings: {
routes: [
'agent_list',
'canned_list',
'labels_list',
'settings_inbox',
'attributes_list',
'settings_inbox_new',
'settings_inbox_list',
'settings_inbox_show',
'settings_inboxes_page_channel',
'settings_inboxes_add_agents',
'settings_inbox_finish',
'settings_integrations',
'settings_integrations_webhook',
'settings_integrations_integration',
'settings_applications',
'settings_applications_webhook',
'settings_applications_integration',
'general_settings',
'general_settings_index',
'settings_teams_list',
'settings_teams_new',
'settings_teams_add_agents',
'settings_teams_finish',
'settings_teams_edit',
'settings_teams_edit_members',
'settings_teams_edit_finish',
],
menuItems: {
back: {
icon: 'ion-ios-arrow-back',
label: 'HOME',
hasSubMenu: false,
toStateName: 'home',
toState: frontendURL(`accounts/${accountId}/dashboard`),
},
agents: {
icon: 'ion-person-stalker',
label: 'AGENTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/agents/list`),
toStateName: 'agent_list',
},
teams: {
icon: 'ion-ios-people',
label: 'TEAMS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/teams/list`),
toStateName: 'settings_teams_list',
},
inboxes: {
icon: 'ion-archive',
label: 'INBOXES',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/inboxes/list`),
toStateName: 'settings_inbox_list',
},
labels: {
icon: 'ion-pricetags',
label: 'LABELS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/labels/list`),
toStateName: 'labels_list',
},
attributes: {
icon: 'ion-code',
label: 'ATTRIBUTES',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/attributes/list`),
toStateName: 'attributes_list',
},
cannedResponses: {
icon: 'ion-chatbox-working',
label: 'CANNED_RESPONSES',
hasSubMenu: false,
toState: frontendURL(
`accounts/${accountId}/settings/canned-response/list`
),
toStateName: 'canned_list',
},
settings_integrations: {
icon: 'ion-flash',
label: 'INTEGRATIONS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/integrations`),
toStateName: 'settings_integrations',
},
settings_applications: {
icon: 'ion-asterisk',
label: 'APPLICATIONS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/applications`),
toStateName: 'settings_applications',
},
general_settings_index: {
icon: 'ion-gear-a',
label: 'ACCOUNT_SETTINGS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/general`),
toStateName: 'general_settings_index',
},
},
},
common: common(accountId),
contacts: contacts(accountId),
reports: reports(accountId),
campaigns: campaigns(accountId),
settings: settings(accountId),
});

View File

@@ -61,6 +61,195 @@
"PLACEHOLDER": "Select date range"
}
},
"AGENT_REPORTS": {
"HEADER": "Agents Overview",
"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",
"FILTER_DROPDOWN_LABEL": "Select Agent",
"METRICS": {
"CONVERSATIONS": {
"NAME": "Conversations",
"DESC": "( Total )"
},
"INCOMING_MESSAGES": {
"NAME": "Incoming Messages",
"DESC": "( Total )"
},
"OUTGOING_MESSAGES": {
"NAME": "Outgoing Messages",
"DESC": "( Total )"
},
"FIRST_RESPONSE_TIME": {
"NAME": "First response time",
"DESC": "( Avg )"
},
"RESOLUTION_TIME": {
"NAME": "Resolution Time",
"DESC": "( Avg )"
},
"RESOLUTION_COUNT": {
"NAME": "Resolution Count",
"DESC": "( Total )"
}
},
"DATE_RANGE": [
{
"id": 0,
"name": "Last 7 days"
},
{
"id": 1,
"name": "Last 30 days"
},
{
"id": 2,
"name": "Last 3 months"
},
{
"id": 3,
"name": "Last 6 months"
},
{
"id": 4,
"name": "Last year"
},
{
"id": 5,
"name": "Custom date range"
}
],
"CUSTOM_DATE_RANGE": {
"CONFIRM": "Apply",
"PLACEHOLDER": "Select date range"
}
},
"LABEL_REPORTS": {
"HEADER": "Labels Overview",
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_LABEL_REPORTS": "Download label reports",
"FILTER_DROPDOWN_LABEL": "Select Label",
"METRICS": {
"CONVERSATIONS": {
"NAME": "Conversations",
"DESC": "( Total )"
},
"INCOMING_MESSAGES": {
"NAME": "Incoming Messages",
"DESC": "( Total )"
},
"OUTGOING_MESSAGES": {
"NAME": "Outgoing Messages",
"DESC": "( Total )"
},
"FIRST_RESPONSE_TIME": {
"NAME": "First response time",
"DESC": "( Avg )"
},
"RESOLUTION_TIME": {
"NAME": "Resolution Time",
"DESC": "( Avg )"
},
"RESOLUTION_COUNT": {
"NAME": "Resolution Count",
"DESC": "( Total )"
}
},
"DATE_RANGE": [
{
"id": 0,
"name": "Last 7 days"
},
{
"id": 1,
"name": "Last 30 days"
},
{
"id": 2,
"name": "Last 3 months"
},
{
"id": 3,
"name": "Last 6 months"
},
{
"id": 4,
"name": "Last year"
},
{
"id": 5,
"name": "Custom date range"
}
],
"CUSTOM_DATE_RANGE": {
"CONFIRM": "Apply",
"PLACEHOLDER": "Select date range"
}
},
"INBOX_REPORTS": {
"HEADER": "Inbox Overview",
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
"FILTER_DROPDOWN_LABEL": "Select Inbox",
"METRICS": {
"CONVERSATIONS": {
"NAME": "Conversations",
"DESC": "( Total )"
},
"INCOMING_MESSAGES": {
"NAME": "Incoming Messages",
"DESC": "( Total )"
},
"OUTGOING_MESSAGES": {
"NAME": "Outgoing Messages",
"DESC": "( Total )"
},
"FIRST_RESPONSE_TIME": {
"NAME": "First response time",
"DESC": "( Avg )"
},
"RESOLUTION_TIME": {
"NAME": "Resolution Time",
"DESC": "( Avg )"
},
"RESOLUTION_COUNT": {
"NAME": "Resolution Count",
"DESC": "( Total )"
}
},
"DATE_RANGE": [
{
"id": 0,
"name": "Last 7 days"
},
{
"id": 1,
"name": "Last 30 days"
},
{
"id": 2,
"name": "Last 3 months"
},
{
"id": 3,
"name": "Last 6 months"
},
{
"id": 4,
"name": "Last year"
},
{
"id": 5,
"name": "Custom date range"
}
],
"CUSTOM_DATE_RANGE": {
"CONFIRM": "Apply",
"PLACEHOLDER": "Select date range"
}
},
"CSAT_REPORTS": {
"HEADER": "CSAT Reports",
"NO_RECORDS": "There are no CSAT survey responses available.",
@@ -78,13 +267,13 @@
"TOOLTIP": "Total number of responses collected"
},
"SATISFACTION_SCORE": {
"LABEL": "Satisfaction score",
"TOOLTIP": "Total number of positive responses / Total number of responses * 100"
"LABEL": "Satisfaction score",
"TOOLTIP": "Total number of positive responses / Total number of responses * 100"
},
"RESPONSE_RATE": {
"LABEL": "Response rate",
"TOOLTIP": "Total number of responses / Total number of CSAT survey messages sent * 100"
"LABEL": "Response rate",
"TOOLTIP": "Total number of responses / Total number of CSAT survey messages sent * 100"
}
}
}
}
}

View File

@@ -146,7 +146,10 @@
"CSAT": "CSAT",
"CAMPAIGNS": "Campaigns",
"ONGOING": "Ongoing",
"ONE_OFF": "One off"
"ONE_OFF": "One off",
"REPORTS_AGENT": "Agents",
"REPORTS_LABEL": "Labels",
"REPORTS_INBOX": "Inbox"
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",

View File

@@ -0,0 +1,30 @@
import { frontendURL } from '../../helper/URLHelper';
const campaigns = accountId => ({
routes: ['settings_account_campaigns', 'one_off'],
menuItems: {
back: {
icon: 'ion-ios-arrow-back',
label: 'HOME',
hasSubMenu: false,
toStateName: 'home',
toState: frontendURL(`accounts/${accountId}/dashboard`),
},
ongoingCampaigns: {
icon: 'ion-arrow-swap',
label: 'ONGOING',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/campaigns/ongoing`),
toStateName: 'settings_account_campaigns',
},
onOffCampaigns: {
icon: 'ion-radio-waves',
label: 'ONE_OFF',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/campaigns/one_off`),
toStateName: 'one_off',
},
},
});
export default campaigns;

View File

@@ -0,0 +1,66 @@
import { frontendURL } from '../../helper/URLHelper';
const common = accountId => ({
routes: [
'home',
'inbox_dashboard',
'inbox_conversation',
'conversation_through_inbox',
'notifications_dashboard',
'profile_settings',
'profile_settings_index',
'label_conversations',
'conversations_through_label',
'team_conversations',
'conversations_through_team',
'notifications_index',
],
menuItems: {
assignedToMe: {
icon: 'ion-chatbox-working',
label: 'CONVERSATIONS',
hasSubMenu: false,
key: '',
toState: frontendURL(`accounts/${accountId}/dashboard`),
toolTip: 'Conversation from all subscribed inboxes',
toStateName: 'home',
},
contacts: {
icon: 'ion-person',
label: 'CONTACTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard',
},
notifications: {
icon: 'ion-ios-bell',
label: 'NOTIFICATIONS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/notifications`),
toStateName: 'notifications_dashboard',
},
report: {
icon: 'ion-arrow-graph-up-right',
label: 'REPORTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports`),
toStateName: 'settings_account_reports',
},
campaigns: {
icon: 'ion-speakerphone',
label: 'CAMPAIGNS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/campaigns`),
toStateName: 'settings_account_campaigns',
},
settings: {
icon: 'ion-settings',
label: 'SETTINGS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings`),
toStateName: 'settings_home',
},
},
});
export default common;

View File

@@ -0,0 +1,27 @@
import { frontendURL } from '../../helper/URLHelper';
const contacts = accountId => ({
routes: [
'contacts_dashboard',
'contacts_dashboard_manage',
'contacts_labels_dashboard',
],
menuItems: {
back: {
icon: 'ion-ios-arrow-back',
label: 'HOME',
hasSubMenu: false,
toStateName: 'home',
toState: frontendURL(`accounts/${accountId}/dashboard`),
},
contacts: {
icon: 'ion-person',
label: 'ALL_CONTACTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard',
},
},
});
export default contacts;

View File

@@ -0,0 +1,57 @@
import { frontendURL } from '../../helper/URLHelper';
const reports = accountId => ({
routes: [
'settings_account_reports',
'csat_reports',
'agent_reports',
'label_reports',
'inbox_reports',
],
menuItems: {
back: {
icon: 'ion-ios-arrow-back',
label: 'HOME',
hasSubMenu: false,
toStateName: 'home',
toState: frontendURL(`accounts/${accountId}/dashboard`),
},
reportOverview: {
icon: 'ion-arrow-graph-up-right',
label: 'REPORTS_OVERVIEW',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/overview`),
toStateName: 'settings_account_reports',
},
csatReports: {
icon: 'ion-happy',
label: 'CSAT',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/csat`),
toStateName: 'csat_reports',
},
agentReports: {
icon: 'ion-ios-people',
label: 'REPORTS_AGENT',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/agent`),
toStateName: 'agent_reports',
},
labelReports: {
icon: 'ion-pricetags',
label: 'REPORTS_LABEL',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/label`),
toStateName: 'label_reports',
},
inboxReports: {
icon: 'ion-archive',
label: 'REPORTS_INBOX',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/inboxes`),
toStateName: 'inbox_reports',
},
},
});
export default reports;

View File

@@ -0,0 +1,108 @@
import { frontendURL } from '../../helper/URLHelper';
const settings = accountId => ({
routes: [
'agent_list',
'canned_list',
'labels_list',
'settings_inbox',
'attributes_list',
'settings_inbox_new',
'settings_inbox_list',
'settings_inbox_show',
'settings_inboxes_page_channel',
'settings_inboxes_add_agents',
'settings_inbox_finish',
'settings_integrations',
'settings_integrations_webhook',
'settings_integrations_integration',
'settings_applications',
'settings_applications_webhook',
'settings_applications_integration',
'general_settings',
'general_settings_index',
'settings_teams_list',
'settings_teams_new',
'settings_teams_add_agents',
'settings_teams_finish',
'settings_teams_edit',
'settings_teams_edit_members',
'settings_teams_edit_finish',
],
menuItems: {
back: {
icon: 'ion-ios-arrow-back',
label: 'HOME',
hasSubMenu: false,
toStateName: 'home',
toState: frontendURL(`accounts/${accountId}/dashboard`),
},
agents: {
icon: 'ion-person-stalker',
label: 'AGENTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/agents/list`),
toStateName: 'agent_list',
},
teams: {
icon: 'ion-ios-people',
label: 'TEAMS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/teams/list`),
toStateName: 'settings_teams_list',
},
inboxes: {
icon: 'ion-archive',
label: 'INBOXES',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/inboxes/list`),
toStateName: 'settings_inbox_list',
},
labels: {
icon: 'ion-pricetags',
label: 'LABELS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/labels/list`),
toStateName: 'labels_list',
},
attributes: {
icon: 'ion-code',
label: 'ATTRIBUTES',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/attributes/list`),
toStateName: 'attributes_list',
},
cannedResponses: {
icon: 'ion-chatbox-working',
label: 'CANNED_RESPONSES',
hasSubMenu: false,
toState: frontendURL(
`accounts/${accountId}/settings/canned-response/list`
),
toStateName: 'canned_list',
},
settings_integrations: {
icon: 'ion-flash',
label: 'INTEGRATIONS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/integrations`),
toStateName: 'settings_integrations',
},
settings_applications: {
icon: 'ion-asterisk',
label: 'APPLICATIONS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/applications`),
toStateName: 'settings_applications',
},
general_settings_index: {
icon: 'ion-gear-a',
label: 'ACCOUNT_SETTINGS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/general`),
toStateName: 'general_settings_index',
},
},
});
export default settings;

View File

@@ -2,7 +2,7 @@
<div class="row app-wrapper">
<sidebar :route="currentRoute" :class="sidebarClassName"></sidebar>
<section class="app-content columns" :class="contentClassName">
<router-view></router-view>
<router-view :key="$route.path"></router-view>
</section>
</div>
</template>

View File

@@ -0,0 +1,19 @@
<template>
<woot-reports
key="agent-reports"
type="agent"
getter-key="agents/getAgents"
action-key="agents/get"
:download-button-label="$t('REPORT.DOWNLOAD_AGENT_REPORTS')"
/>
</template>
<script>
import WootReports from './components/WootReports';
export default {
components: {
WootReports,
},
};
</script>

View File

@@ -0,0 +1,19 @@
<template>
<woot-reports
key="inbox-reports"
type="inbox"
getter-key="inboxes/getInboxes"
action-key="inboxes/get"
:download-button-label="$t('INBOX_REPORTS.DOWNLOAD_INBOX_REPORTS')"
/>
</template>
<script>
import WootReports from './components/WootReports';
export default {
components: {
WootReports,
},
};
</script>

View File

@@ -0,0 +1,19 @@
<template>
<woot-reports
key="label-reports"
type="label"
getter-key="labels/getLabels"
action-key="labels/get"
:download-button-label="$t('LABEL_REPORTS.DOWNLOAD_LABEL_REPORTS')"
/>
</template>
<script>
import WootReports from './components/WootReports';
export default {
components: {
WootReports,
},
};
</script>

View File

@@ -0,0 +1,224 @@
<template>
<div class="flex-container flex-dir-column medium-flex-dir-row">
<div v-if="type === 'agent'" class="small-12 medium-3 pull-right">
<p aria-hidden="true" class="hide">
{{ $t('AGENT_REPORTS.FILTER_DROPDOWN_LABEL') }}
</p>
<multiselect
v-model="currentSelectedFilter"
:placeholder="$t('AGENT_REPORTS.FILTER_DROPDOWN_LABEL')"
label="name"
track-by="id"
:options="filterItemsList"
:option-height="24"
:show-labels="false"
@input="changeFilterSelection"
>
<template slot="singleLabel" slot-scope="props">
<div class="display-flex">
<thumbnail
src="props.option.thumbnail"
:username="props.option.name"
size="22px"
class="margin-right-small"
/>
<span class="reports-option__desc">
<span class="reports-option__title">{{ props.option.name }}</span>
</span>
</div>
</template>
<template slot="option" slot-scope="props">
<div class="display-flex">
<thumbnail
src="props.option.thumbnail"
:username="props.option.name"
size="22px"
class="margin-right-small"
/>
<p>{{ props.option.name }}</p>
</div>
</template>
</multiselect>
</div>
<div v-if="type === 'label'" class="small-12 medium-3 pull-right">
<p aria-hidden="true" class="hide">
{{ $t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL') }}
</p>
<multiselect
v-model="currentSelectedFilter"
:placeholder="$t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL')"
label="title"
track-by="id"
:options="filterItemsList"
:option-height="24"
:show-labels="false"
@input="changeFilterSelection"
>
<template slot="singleLabel" slot-scope="props">
<div class="display-flex">
<div
:style="{ backgroundColor: props.option.color }"
class="reports-option__rounded--item margin-right-small"
></div>
<span class="reports-option__desc">
<span class="reports-option__title">{{
props.option.title
}}</span>
</span>
</div>
</template>
<template slot="option" slot-scope="props">
<div class="display-flex">
<div
:style="{ backgroundColor: props.option.color }"
class="
reports-option__rounded--item
reports-option__item
reports-option__label--swatch
"
></div>
<span class="reports-option__desc">
<span class="reports-option__title">{{
props.option.title
}}</span>
</span>
</div>
</template>
</multiselect>
</div>
<div v-if="type === 'inbox'" class="small-12 medium-3 pull-right">
<p aria-hidden="true" class="hide">
{{ $t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL') }}
</p>
<multiselect
v-model="currentSelectedFilter"
track-by="id"
label="name"
:placeholder="$t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:options="filterItemsList"
:searchable="false"
:allow-empty="false"
@input="changeFilterSelection"
/>
</div>
<div class="small-12 medium-3 pull-right margin-left-small">
<multiselect
v-model="currentDateRangeSelection"
track-by="name"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT_ONE')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:options="dateRange"
:searchable="false"
:allow-empty="false"
@select="changeDateSelection"
/>
</div>
<woot-date-range-picker
v-if="isDateRangeSelected"
show-range
:value="customDateRange"
:confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')"
:placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')"
@change="onChange"
/>
</div>
</template>
<script>
import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue';
const CUSTOM_DATE_RANGE_ID = 5;
import subDays from 'date-fns/subDays';
import startOfDay from 'date-fns/startOfDay';
import getUnixTime from 'date-fns/getUnixTime';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
export default {
components: {
WootDateRangePicker,
Thumbnail,
},
props: {
filterItemsList: {
type: Array,
default: () => [],
},
type: {
type: String,
default: 'agent',
},
},
data() {
return {
currentSelectedFilter: null,
currentDateRangeSelection: this.$t('REPORT.DATE_RANGE')[0],
dateRange: this.$t('REPORT.DATE_RANGE'),
customDateRange: [new Date(), new Date()],
};
},
computed: {
isDateRangeSelected() {
return this.currentDateRangeSelection.id === CUSTOM_DATE_RANGE_ID;
},
to() {
if (this.isDateRangeSelected) {
return this.fromCustomDate(this.customDateRange[1]);
}
return this.fromCustomDate(new Date());
},
from() {
if (this.isDateRangeSelected) {
return this.fromCustomDate(this.customDateRange[0]);
}
const dateRange = {
0: 6,
1: 29,
2: 89,
3: 179,
4: 364,
};
const diff = dateRange[this.currentDateRangeSelection.id];
const fromDate = subDays(new Date(), diff);
return this.fromCustomDate(fromDate);
},
},
watch: {
filterItemsList(val) {
this.currentSelectedFilter = val[0];
},
currentSelectedFilter() {
this.changeFilterSelection();
},
},
mounted() {
this.onDateRangeChange();
},
methods: {
onDateRangeChange() {
this.$emit('date-range-change', { from: this.from, to: this.to });
},
fromCustomDate(date) {
return getUnixTime(startOfDay(date));
},
changeDateSelection(selectedRange) {
this.currentDateRangeSelection = selectedRange;
this.onDateRangeChange();
},
changeFilterSelection() {
this.$emit('filter-change', this.currentSelectedFilter);
},
onChange(value) {
this.customDateRange = value;
this.onDateRangeChange();
},
},
};
</script>
<style lang="scss">
@import '~dashboard/assets/scss/widgets/_reports';
</style>

View File

@@ -0,0 +1,199 @@
<template>
<div class="column content-box">
<woot-button
color-scheme="success"
class-names="button--fixed-right-top"
icon="ion-android-download"
@click="downloadReports"
>
{{ downloadButtonLabel }}
</woot-button>
<report-filters
v-if="filterItemsList"
:type="type"
:filter-items-list="filterItemsList"
@date-range-change="onDateRangeChange"
@filter-change="onFilterChange"
/>
<div v-if="selectedFilter">
<div class="row">
<woot-report-stats-card
v-for="(metric, index) in metrics"
:key="metric.NAME"
:desc="metric.DESC"
:heading="metric.NAME"
:index="index"
:on-click="changeSelection"
:point="accountSummary[metric.KEY]"
:selected="index === currentSelection"
/>
</div>
<div class="report-bar">
<woot-loading-state
v-if="accountReport.isFetching"
:message="$t('REPORT.LOADING_CHART')"
/>
<div v-else class="chart-container">
<woot-bar v-if="accountReport.data.length" :collection="collection" />
<span v-else class="empty-state">
{{ $t('REPORT.NO_ENOUGH_DATA') }}
</span>
</div>
</div>
</div>
</div>
</template>
<script>
import ReportFilters from './ReportFilters';
import fromUnixTime from 'date-fns/fromUnixTime';
import format from 'date-fns/format';
const REPORTS_KEYS = {
CONVERSATIONS: 'conversations_count',
INCOMING_MESSAGES: 'incoming_messages_count',
OUTGOING_MESSAGES: 'outgoing_messages_count',
FIRST_RESPONSE_TIME: 'avg_first_response_time',
RESOLUTION_TIME: 'avg_resolution_time',
RESOLUTION_COUNT: 'resolutions_count',
};
export default {
components: {
ReportFilters,
},
props: {
type: {
type: String,
default: 'account',
},
getterKey: {
type: String,
default: '',
},
actionKey: {
type: String,
default: '',
},
downloadButtonLabel: {
type: String,
default: 'Download Reports',
},
},
data() {
return {
from: 0,
to: 0,
currentSelection: 0,
selectedFilter: null,
};
},
computed: {
filterItemsList() {
return this.$store.getters[this.getterKey] || [];
},
accountSummary() {
return this.$store.getters.getAccountSummary || [];
},
accountReport() {
return this.$store.getters.getAccountReports || [];
},
collection() {
if (this.accountReport.isFetching) {
return {};
}
if (!this.accountReport.data.length) return {};
const labels = this.accountReport.data.map(element =>
format(fromUnixTime(element.timestamp), 'dd/MMM')
);
const data = this.accountReport.data.map(element => element.value);
return {
labels,
datasets: [
{
label: this.metrics[this.currentSelection].NAME,
backgroundColor: '#1f93ff',
data,
},
],
};
},
metrics() {
const reportKeys = [
'CONVERSATIONS',
'INCOMING_MESSAGES',
'OUTGOING_MESSAGES',
'FIRST_RESPONSE_TIME',
'RESOLUTION_TIME',
'RESOLUTION_COUNT',
];
return reportKeys.map(key => ({
NAME: this.$t(`REPORT.METRICS.${key}.NAME`),
KEY: REPORTS_KEYS[key],
DESC: this.$t(`REPORT.METRICS.${key}.DESC`),
}));
},
},
mounted() {
this.$store.dispatch(this.actionKey);
},
methods: {
fetchAllData() {
if (this.selectedFilter) {
const { from, to } = this;
this.$store.dispatch('fetchAccountSummary', {
from,
to,
type: this.type,
id: this.selectedFilter.id,
});
this.fetchChartData();
}
},
fetchChartData() {
const { from, to } = this;
this.$store.dispatch('fetchAccountReport', {
metric: this.metrics[this.currentSelection].KEY,
from,
to,
type: this.type,
id: this.selectedFilter.id,
});
},
downloadReports() {
const { from, to } = this;
const fileName = `${this.type}-report-${format(
fromUnixTime(to),
'dd-MM-yyyy'
)}.csv`;
switch (this.type) {
case 'agent':
this.$store.dispatch('downloadAgentReports', { from, to, fileName });
break;
case 'label':
this.$store.dispatch('downloadLabelReports', { from, to, fileName });
break;
case 'inbox':
this.$store.dispatch('downloadInboxReports', { from, to, fileName });
break;
default:
break;
}
},
changeSelection(index) {
this.currentSelection = index;
this.fetchChartData();
},
onDateRangeChange({ from, to }) {
this.from = from;
this.to = to;
this.fetchAllData();
},
onFilterChange(payload) {
if (payload) {
this.selectedFilter = payload;
this.fetchAllData();
}
},
},
};
</script>

View File

@@ -1,4 +1,7 @@
import Index from './Index';
import AgentReports from './AgentReports';
import LabelReports from './LabelReports';
import InboxReports from './InboxReports';
import CsatResponses from './CsatResponses';
import SettingsContent from '../Wrapper';
import { frontendURL } from '../../../../helper/URLHelper';
@@ -41,5 +44,53 @@ export default {
},
],
},
{
path: frontendURL('accounts/:accountId/reports'),
component: SettingsContent,
props: {
headerTitle: 'AGENT_REPORTS.HEADER',
icon: 'ion-people',
},
children: [
{
path: 'agent',
name: 'agent_reports',
roles: ['administrator'],
component: AgentReports,
},
],
},
{
path: frontendURL('accounts/:accountId/reports'),
component: SettingsContent,
props: {
headerTitle: 'LABEL_REPORTS.HEADER',
icon: 'ion-pricetags',
},
children: [
{
path: 'label',
name: 'label_reports',
roles: ['administrator'],
component: LabelReports,
},
],
},
{
path: frontendURL('accounts/:accountId/reports'),
component: SettingsContent,
props: {
headerTitle: 'INBOX_REPORTS.HEADER',
icon: 'ion-archive',
},
children: [
{
path: 'inboxes',
name: 'inbox_reports',
roles: ['administrator'],
component: InboxReports,
},
],
},
],
};

View File

@@ -36,10 +36,12 @@ const getters = {
export const actions = {
fetchAccountReport({ commit }, reportObj) {
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, true);
Report.getAccountReports(
Report.getReports(
reportObj.metric,
reportObj.from,
reportObj.to
reportObj.to,
reportObj.type,
reportObj.id
).then(accountReport => {
let { data } = accountReport;
data = data.filter(
@@ -60,7 +62,12 @@ export const actions = {
});
},
fetchAccountSummary({ commit }, reportObj) {
Report.getAccountSummary(reportObj.from, reportObj.to)
Report.getSummary(
reportObj.from,
reportObj.to,
reportObj.type,
reportObj.id
)
.then(accountSummary => {
commit(types.default.SET_ACCOUNT_SUMMARY, accountSummary.data);
})
@@ -85,6 +92,40 @@ export const actions = {
console.error(error);
});
},
downloadLabelReports(_, reportObj) {
return Report.getLabelReports(reportObj.from, reportObj.to)
.then(response => {
let csvContent = 'data:text/csv;charset=utf-8,' + response.data;
var encodedUri = encodeURI(csvContent);
var downloadLink = document.createElement('a');
downloadLink.href = encodedUri;
downloadLink.download = reportObj.fileName;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
})
.catch(error => {
console.error(error);
});
},
downloadInboxReports(_, reportObj) {
return Report.getInboxReports(reportObj.from, reportObj.to)
.then(response => {
let csvContent = 'data:text/csv;charset=utf-8,' + response.data;
var encodedUri = encodeURI(csvContent);
var downloadLink = document.createElement('a');
downloadLink.href = encodedUri;
downloadLink.download = reportObj.fileName;
document.body.appendChild(downloadLink);
downloadLink.click();
// document.body.removeChild(downloadLink);
})
.catch(error => {
console.error(error);
});
},
};
const mutations = {

View File

@@ -5,7 +5,17 @@ global.open = jest.fn();
global.axios = axios;
jest.mock('axios');
const createElementSpy = () => {
const element = document.createElement('a');
jest.spyOn(document, 'createElement').mockImplementation(() => element);
return element;
};
describe('#actions', () => {
afterEach(() => {
jest.restoreAllMocks();
});
describe('#downloadAgentReports', () => {
it('open CSV download prompt if API is success', async () => {
axios.get.mockResolvedValue({
@@ -17,15 +27,55 @@ describe('#actions', () => {
to: 1630504922510,
fileName: 'agent-report-01-09-2021.csv',
};
const mockDownloadElement = document.createElement('a');
jest
.spyOn(document, 'createElement')
.mockImplementation(() => mockDownloadElement);
const mockAgentDownloadElement = createElementSpy();
await actions.downloadAgentReports(1, param);
expect(mockDownloadElement.href).toEqual(
expect(mockAgentDownloadElement.href).toEqual(
'data:text/csv;charset=utf-8,Agent%20name,Conversations%20count,Avg%20first%20response%20time%20(Minutes),Avg%20resolution%20time%20(Minutes)%0A%20%20%20%20%20%20%20%20Pranav,36,114,28411'
);
expect(mockDownloadElement.download).toEqual(param.fileName);
expect(mockAgentDownloadElement.download).toEqual(param.fileName);
});
});
describe('#downloadLabelReports', () => {
it('open CSV download prompt if API is success', async () => {
axios.get.mockResolvedValue({
data: `Label Title,Conversations count,Avg first response time (Minutes),Avg resolution time (Minutes)
website,0,0,0`,
});
const param = {
from: 1632335400,
to: 1632853800,
type: 'label',
fileName: 'label-report-01-09-2021.csv',
};
const mockLabelDownloadElement = createElementSpy();
await actions.downloadLabelReports(1, param);
expect(mockLabelDownloadElement.href).toEqual(
'data:text/csv;charset=utf-8,Label%20Title,Conversations%20count,Avg%20first%20response%20time%20(Minutes),Avg%20resolution%20time%20(Minutes)%0A%20%20%20%20%20%20%20%20website,0,0,0'
);
expect(mockLabelDownloadElement.download).toEqual(param.fileName);
});
});
describe('#downloadInboxReports', () => {
it('open CSV download prompt if API is success', async () => {
axios.get.mockResolvedValue({
data: `Inbox name,Conversations count,Avg first response time (Minutes),Avg resolution time (Minutes)
Fayaz,2,127,0
EMa,0,0,0
Twillio WA,0,0,0`,
});
const param = {
from: 1631039400,
to: 1635013800,
fileName: 'inbox-report-24-10-2021.csv',
};
const mockInboxDownloadElement = createElementSpy();
await actions.downloadInboxReports(1, param);
expect(mockInboxDownloadElement.href).toEqual(
'data:text/csv;charset=utf-8,Inbox%20name,Conversations%20count,Avg%20first%20response%20time%20(Minutes),Avg%20resolution%20time%20(Minutes)%0A%20%20%20%20%20%20%20%20Fayaz,2,127,0%0A%20%20%20%20%20%20%20%20EMa,0,0,0%0A%20%20%20%20%20%20%20%20Twillio%20WA,0,0,0'
);
expect(mockInboxDownloadElement.download).toEqual(param.fileName);
});
});
});