feat: Add Reports for teams (#3116)

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
Fayaz Ahmed 2021-10-06 23:53:51 +05:30 committed by GitHub
parent 3e99088fe3
commit 1c6a539c0a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 240 additions and 59 deletions

View file

@ -68,15 +68,14 @@ class V2::ReportBuilder
.count .count
end end
# unscoped removes all scopes added to a model previously
def incoming_messages_count def incoming_messages_count
scope.messages.unscoped.where(account_id: account.id).incoming scope.messages.incoming.unscope(:order)
.group_by_day(:created_at, range: range, default_value: 0) .group_by_day(:created_at, range: range, default_value: 0)
.count .count
end end
def outgoing_messages_count def outgoing_messages_count
scope.messages.unscoped.where(account_id: account.id).outgoing scope.messages.outgoing.unscope(:order)
.group_by_day(:created_at, range: range, default_value: 0) .group_by_day(:created_at, range: range, default_value: 0)
.count .count
end end

View file

@ -35,6 +35,12 @@ class ReportsAPI extends ApiClient {
params: { since, until }, params: { since, until },
}); });
} }
getTeamReports(since, until) {
return axios.get(`${this.url}/teams`, {
params: { since, until },
});
}
} }
export default new ReportsAPI(); export default new ReportsAPI();

View file

@ -16,6 +16,7 @@ describe('#Reports API', () => {
expect(reportsAPI).toHaveProperty('getAgentReports'); expect(reportsAPI).toHaveProperty('getAgentReports');
expect(reportsAPI).toHaveProperty('getLabelReports'); expect(reportsAPI).toHaveProperty('getLabelReports');
expect(reportsAPI).toHaveProperty('getInboxReports'); expect(reportsAPI).toHaveProperty('getInboxReports');
expect(reportsAPI).toHaveProperty('getTeamReports');
}); });
describeWithAPIMock('API calls', context => { describeWithAPIMock('API calls', context => {
it('#getAccountReports', () => { it('#getAccountReports', () => {
@ -82,5 +83,18 @@ describe('#Reports API', () => {
} }
); );
}); });
it('#getTeamReports', () => {
reportsAPI.getTeamReports(1621103400, 1621621800);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/teams',
{
params: {
since: 1621103400,
until: 1621621800,
},
}
);
});
}); });
}); });

View file

@ -0,0 +1,6 @@
export const downloadCsvFile = (fileName, fileContent) => {
const link = document.createElement('a');
link.download = fileName;
link.href = `data:text/csv;charset=utf-8,` + encodeURI(fileContent);
link.click();
};

View file

@ -0,0 +1,21 @@
import { downloadCsvFile } from '../downloadCsvFile';
const fileName = 'test.csv';
const fileData = `Agent name,Conversations count,Avg first response time (Minutes),Avg resolution time (Minutes)
Pranav,36,114,28411`;
describe('#downloadCsvFile', () => {
it('should download the csv file', () => {
const link = {
click: jest.fn(),
};
jest.spyOn(document, 'createElement').mockImplementation(() => link);
downloadCsvFile(fileName, fileData);
expect(link.download).toEqual(fileName);
expect(link.href).toEqual(
`data:text/csv;charset=utf-8,${encodeURI(fileData)}`
);
expect(link.click).toHaveBeenCalledTimes(1);
});
});

View file

@ -250,6 +250,69 @@
"PLACEHOLDER": "Select date range" "PLACEHOLDER": "Select date range"
} }
}, },
"TEAM_REPORTS": {
"HEADER": "Team Overview",
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_TEAM_REPORTS": "Download team reports",
"FILTER_DROPDOWN_LABEL": "Select Team",
"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": { "CSAT_REPORTS": {
"HEADER": "CSAT Reports", "HEADER": "CSAT Reports",
"NO_RECORDS": "There are no CSAT survey responses available.", "NO_RECORDS": "There are no CSAT survey responses available.",

View file

@ -149,7 +149,8 @@
"ONE_OFF": "One off", "ONE_OFF": "One off",
"REPORTS_AGENT": "Agents", "REPORTS_AGENT": "Agents",
"REPORTS_LABEL": "Labels", "REPORTS_LABEL": "Labels",
"REPORTS_INBOX": "Inbox" "REPORTS_INBOX": "Inbox",
"REPORTS_TEAM": "Team"
}, },
"CREATE_ACCOUNT": { "CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.", "NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",

View file

@ -7,6 +7,7 @@ const reports = accountId => ({
'agent_reports', 'agent_reports',
'label_reports', 'label_reports',
'inbox_reports', 'inbox_reports',
'team_reports',
], ],
menuItems: { menuItems: {
back: { back: {
@ -31,7 +32,7 @@ const reports = accountId => ({
toStateName: 'csat_reports', toStateName: 'csat_reports',
}, },
agentReports: { agentReports: {
icon: 'ion-ios-people', icon: 'ion-person-stalker',
label: 'REPORTS_AGENT', label: 'REPORTS_AGENT',
hasSubMenu: false, hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/agent`), toState: frontendURL(`accounts/${accountId}/reports/agent`),
@ -51,6 +52,13 @@ const reports = accountId => ({
toState: frontendURL(`accounts/${accountId}/reports/inboxes`), toState: frontendURL(`accounts/${accountId}/reports/inboxes`),
toStateName: 'inbox_reports', toStateName: 'inbox_reports',
}, },
teamReports: {
icon: 'ion-ios-people',
label: 'REPORTS_TEAM',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/teams`),
toStateName: 'team_reports',
},
}, },
}); });

View file

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

View file

@ -6,7 +6,7 @@
</p> </p>
<multiselect <multiselect
v-model="currentSelectedFilter" v-model="currentSelectedFilter"
:placeholder="$t('AGENT_REPORTS.FILTER_DROPDOWN_LABEL')" :placeholder="multiselectLabel"
label="name" label="name"
track-by="id" track-by="id"
:options="filterItemsList" :options="filterItemsList"
@ -40,13 +40,13 @@
</template> </template>
</multiselect> </multiselect>
</div> </div>
<div v-if="type === 'label'" class="small-12 medium-3 pull-right"> <div v-else-if="type === 'label'" class="small-12 medium-3 pull-right">
<p aria-hidden="true" class="hide"> <p aria-hidden="true" class="hide">
{{ $t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL') }} {{ $t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL') }}
</p> </p>
<multiselect <multiselect
v-model="currentSelectedFilter" v-model="currentSelectedFilter"
:placeholder="$t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL')" :placeholder="multiselectLabel"
label="title" label="title"
track-by="id" track-by="id"
:options="filterItemsList" :options="filterItemsList"
@ -59,11 +59,11 @@
<div <div
:style="{ backgroundColor: props.option.color }" :style="{ backgroundColor: props.option.color }"
class="reports-option__rounded--item margin-right-small" class="reports-option__rounded--item margin-right-small"
></div> />
<span class="reports-option__desc"> <span class="reports-option__desc">
<span class="reports-option__title">{{ <span class="reports-option__title">
props.option.title {{ props.option.title }}
}}</span> </span>
</span> </span>
</div> </div>
</template> </template>
@ -78,15 +78,15 @@
" "
></div> ></div>
<span class="reports-option__desc"> <span class="reports-option__desc">
<span class="reports-option__title">{{ <span class="reports-option__title">
props.option.title {{ props.option.title }}
}}</span> </span>
</span> </span>
</div> </div>
</template> </template>
</multiselect> </multiselect>
</div> </div>
<div v-if="type === 'inbox'" class="small-12 medium-3 pull-right"> <div v-else class="small-12 medium-3 pull-right">
<p aria-hidden="true" class="hide"> <p aria-hidden="true" class="hide">
{{ $t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL') }} {{ $t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL') }}
</p> </p>
@ -94,7 +94,7 @@
v-model="currentSelectedFilter" v-model="currentSelectedFilter"
track-by="id" track-by="id"
label="name" label="name"
:placeholder="$t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL')" :placeholder="multiselectLabel"
selected-label selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')" :select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label="" deselect-label=""
@ -185,12 +185,19 @@ export default {
const fromDate = subDays(new Date(), diff); const fromDate = subDays(new Date(), diff);
return this.fromCustomDate(fromDate); return this.fromCustomDate(fromDate);
}, },
multiselectLabel() {
const typeLabels = {
agent: this.$t('AGENT_REPORTS.FILTER_DROPDOWN_LABEL'),
label: this.$t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL'),
inbox: this.$t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL'),
team: this.$t('TEAM_REPORTS.FILTER_DROPDOWN_LABEL'),
};
return typeLabels[this.type] || this.$t('FORMS.MULTISELECT.SELECT_ONE');
},
}, },
watch: { watch: {
filterItemsList(val) { filterItemsList(val) {
this.currentSelectedFilter = val[0]; this.currentSelectedFilter = val[0];
},
currentSelectedFilter() {
this.changeFilterSelection(); this.changeFilterSelection();
}, },
}, },

View file

@ -15,8 +15,8 @@
@date-range-change="onDateRangeChange" @date-range-change="onDateRangeChange"
@filter-change="onFilterChange" @filter-change="onFilterChange"
/> />
<div v-if="selectedFilter"> <div>
<div class="row"> <div v-if="filterItemsList.length" class="row">
<woot-report-stats-card <woot-report-stats-card
v-for="(metric, index) in metrics" v-for="(metric, index) in metrics"
:key="metric.NAME" :key="metric.NAME"
@ -34,7 +34,10 @@
:message="$t('REPORT.LOADING_CHART')" :message="$t('REPORT.LOADING_CHART')"
/> />
<div v-else class="chart-container"> <div v-else class="chart-container">
<woot-bar v-if="accountReport.data.length" :collection="collection" /> <woot-bar
v-if="accountReport.data.length && filterItemsList.length"
:collection="collection"
/>
<span v-else class="empty-state"> <span v-else class="empty-state">
{{ $t('REPORT.NO_ENOUGH_DATA') }} {{ $t('REPORT.NO_ENOUGH_DATA') }}
</span> </span>
@ -118,9 +121,15 @@ export default {
}; };
}, },
metrics() { metrics() {
const reportKeys = [ let reportKeys = ['CONVERSATIONS'];
'CONVERSATIONS', // If report type is agent, we don't need to show
'INCOMING_MESSAGES', // incoming messages count, as there will not be any message
// sent by an agent which is incoming.
if (this.type !== 'agent') {
reportKeys.push('INCOMING_MESSAGES');
}
reportKeys = [
...reportKeys,
'OUTGOING_MESSAGES', 'OUTGOING_MESSAGES',
'FIRST_RESPONSE_TIME', 'FIRST_RESPONSE_TIME',
'RESOLUTION_TIME', 'RESOLUTION_TIME',
@ -175,6 +184,9 @@ export default {
case 'inbox': case 'inbox':
this.$store.dispatch('downloadInboxReports', { from, to, fileName }); this.$store.dispatch('downloadInboxReports', { from, to, fileName });
break; break;
case 'team':
this.$store.dispatch('downloadTeamReports', { from, to, fileName });
break;
default: default:
break; break;
} }

View file

@ -2,6 +2,7 @@ import Index from './Index';
import AgentReports from './AgentReports'; import AgentReports from './AgentReports';
import LabelReports from './LabelReports'; import LabelReports from './LabelReports';
import InboxReports from './InboxReports'; import InboxReports from './InboxReports';
import TeamReports from './TeamReports';
import CsatResponses from './CsatResponses'; import CsatResponses from './CsatResponses';
import SettingsContent from '../Wrapper'; import SettingsContent from '../Wrapper';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
@ -97,5 +98,21 @@ export default {
}, },
], ],
}, },
{
path: frontendURL('accounts/:accountId/reports'),
component: SettingsContent,
props: {
headerTitle: 'TEAM_REPORTS.HEADER',
icon: 'ion-ios-people',
},
children: [
{
path: 'teams',
name: 'team_reports',
roles: ['administrator'],
component: TeamReports,
},
],
},
], ],
}; };

View file

@ -7,6 +7,8 @@ import fromUnixTime from 'date-fns/fromUnixTime';
import * as types from '../mutation-types'; import * as types from '../mutation-types';
import Report from '../../api/reports'; import Report from '../../api/reports';
import { downloadCsvFile } from '../../helper/downloadCsvFile';
const state = { const state = {
fetchingStatus: false, fetchingStatus: false,
reportData: [], reportData: [],
@ -78,15 +80,7 @@ export const actions = {
downloadAgentReports(_, reportObj) { downloadAgentReports(_, reportObj) {
return Report.getAgentReports(reportObj.from, reportObj.to) return Report.getAgentReports(reportObj.from, reportObj.to)
.then(response => { .then(response => {
let csvContent = 'data:text/csv;charset=utf-8,' + response.data; downloadCsvFile(reportObj.fileName, 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 => { .catch(error => {
console.error(error); console.error(error);
@ -95,15 +89,7 @@ export const actions = {
downloadLabelReports(_, reportObj) { downloadLabelReports(_, reportObj) {
return Report.getLabelReports(reportObj.from, reportObj.to) return Report.getLabelReports(reportObj.from, reportObj.to)
.then(response => { .then(response => {
let csvContent = 'data:text/csv;charset=utf-8,' + response.data; downloadCsvFile(reportObj.fileName, 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 => { .catch(error => {
console.error(error); console.error(error);
@ -112,15 +98,16 @@ export const actions = {
downloadInboxReports(_, reportObj) { downloadInboxReports(_, reportObj) {
return Report.getInboxReports(reportObj.from, reportObj.to) return Report.getInboxReports(reportObj.from, reportObj.to)
.then(response => { .then(response => {
let csvContent = 'data:text/csv;charset=utf-8,' + response.data; downloadCsvFile(reportObj.fileName, response.data);
var encodedUri = encodeURI(csvContent); })
var downloadLink = document.createElement('a'); .catch(error => {
downloadLink.href = encodedUri; console.error(error);
downloadLink.download = reportObj.fileName; });
},
document.body.appendChild(downloadLink); downloadTeamReports(_, reportObj) {
downloadLink.click(); return Report.getTeamReports(reportObj.from, reportObj.to)
// document.body.removeChild(downloadLink); .then(response => {
downloadCsvFile(reportObj.fileName, response.data);
}) })
.catch(error => { .catch(error => {
console.error(error); console.error(error);

View file

@ -78,4 +78,25 @@ describe('#actions', () => {
expect(mockInboxDownloadElement.download).toEqual(param.fileName); expect(mockInboxDownloadElement.download).toEqual(param.fileName);
}); });
}); });
describe('#downloadTeamReports', () => {
it('open CSV download prompt if API is success', async () => {
axios.get.mockResolvedValue({
data: `Team name,Conversations count,Avg first response time (Minutes),Avg resolution time (Minutes)
sales team,0,0,0
Reporting period 2021-09-23 to 2021-09-29`,
});
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,Team%20name,Conversations%20count,Avg%20first%20response%20time%20(Minutes),Avg%20resolution%20time%20(Minutes)%0A%20%20%20%20%20%20%20%20sales%20team,0,0,0%0A%20%20%20%20%20%20%20%20Reporting%20period%202021-09-23%20to%202021-09-29'
);
expect(mockInboxDownloadElement.download).toEqual(param.fileName);
});
});
}); });

View file

@ -171,14 +171,14 @@ describe ::V2::ReportBuilder do
type: :label, type: :label,
id: label_1.id, id: label_1.id,
since: (Time.zone.today - 3.days).to_time.to_i.to_s, since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.to_time.to_i.to_s until: (Time.zone.today + 1.day).to_time.to_i.to_s
} }
builder = V2::ReportBuilder.new(account, params) builder = V2::ReportBuilder.new(account, params)
metrics = builder.timeseries metrics = builder.timeseries
expect(metrics[Time.zone.today]).to be 20 expect(metrics[Time.zone.today]).to be 20
expect(metrics[Time.zone.today - 2.days]).to be 5 expect(metrics[Time.zone.today - 2.days]).to be 0
end end
it 'return outgoing messages count' do it 'return outgoing messages count' do
@ -187,14 +187,14 @@ describe ::V2::ReportBuilder do
type: :label, type: :label,
id: label_1.id, id: label_1.id,
since: (Time.zone.today - 3.days).to_time.to_i.to_s, since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.to_time.to_i.to_s until: (Time.zone.today + 1.day).to_time.to_i.to_s
} }
builder = V2::ReportBuilder.new(account, params) builder = V2::ReportBuilder.new(account, params)
metrics = builder.timeseries metrics = builder.timeseries
expect(metrics[Time.zone.today]).to be 50 expect(metrics[Time.zone.today]).to be 50
expect(metrics[Time.zone.today - 2.days]).to be 15 expect(metrics[Time.zone.today - 2.days]).to be 0
end end
it 'return resolutions count' do it 'return resolutions count' do
@ -203,7 +203,7 @@ describe ::V2::ReportBuilder do
type: :label, type: :label,
id: label_2.id, id: label_2.id,
since: (Time.zone.today - 3.days).to_time.to_i.to_s, since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.to_time.to_i.to_s until: (Time.zone.today + 1.day).to_time.to_i.to_s
} }
conversations = account.conversations.where('created_at < ?', 1.day.ago) conversations = account.conversations.where('created_at < ?', 1.day.ago)
@ -242,8 +242,8 @@ describe ::V2::ReportBuilder do
metrics = builder.summary metrics = builder.summary
expect(metrics[:conversations_count]).to be 5 expect(metrics[:conversations_count]).to be 5
expect(metrics[:incoming_messages_count]).to be 25 expect(metrics[:incoming_messages_count]).to be 5
expect(metrics[:outgoing_messages_count]).to be 65 expect(metrics[:outgoing_messages_count]).to be 15
expect(metrics[:avg_resolution_time]).to be 0 expect(metrics[:avg_resolution_time]).to be 0
expect(metrics[:resolutions_count]).to be 0 expect(metrics[:resolutions_count]).to be 0
end end