feat: Add Reports for teams (#3116)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
parent
3e99088fe3
commit
1c6a539c0a
15 changed files with 240 additions and 59 deletions
|
@ -68,15 +68,14 @@ class V2::ReportBuilder
|
|||
.count
|
||||
end
|
||||
|
||||
# unscoped removes all scopes added to a model previously
|
||||
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)
|
||||
.count
|
||||
end
|
||||
|
||||
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)
|
||||
.count
|
||||
end
|
||||
|
|
|
@ -35,6 +35,12 @@ class ReportsAPI extends ApiClient {
|
|||
params: { since, until },
|
||||
});
|
||||
}
|
||||
|
||||
getTeamReports(since, until) {
|
||||
return axios.get(`${this.url}/teams`, {
|
||||
params: { since, until },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new ReportsAPI();
|
||||
|
|
|
@ -16,6 +16,7 @@ describe('#Reports API', () => {
|
|||
expect(reportsAPI).toHaveProperty('getAgentReports');
|
||||
expect(reportsAPI).toHaveProperty('getLabelReports');
|
||||
expect(reportsAPI).toHaveProperty('getInboxReports');
|
||||
expect(reportsAPI).toHaveProperty('getTeamReports');
|
||||
});
|
||||
describeWithAPIMock('API calls', context => {
|
||||
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,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
6
app/javascript/dashboard/helper/downloadCsvFile.js
Normal file
6
app/javascript/dashboard/helper/downloadCsvFile.js
Normal 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();
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -250,6 +250,69 @@
|
|||
"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": {
|
||||
"HEADER": "CSAT Reports",
|
||||
"NO_RECORDS": "There are no CSAT survey responses available.",
|
||||
|
|
|
@ -149,7 +149,8 @@
|
|||
"ONE_OFF": "One off",
|
||||
"REPORTS_AGENT": "Agents",
|
||||
"REPORTS_LABEL": "Labels",
|
||||
"REPORTS_INBOX": "Inbox"
|
||||
"REPORTS_INBOX": "Inbox",
|
||||
"REPORTS_TEAM": "Team"
|
||||
},
|
||||
"CREATE_ACCOUNT": {
|
||||
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",
|
||||
|
|
|
@ -7,6 +7,7 @@ const reports = accountId => ({
|
|||
'agent_reports',
|
||||
'label_reports',
|
||||
'inbox_reports',
|
||||
'team_reports',
|
||||
],
|
||||
menuItems: {
|
||||
back: {
|
||||
|
@ -31,7 +32,7 @@ const reports = accountId => ({
|
|||
toStateName: 'csat_reports',
|
||||
},
|
||||
agentReports: {
|
||||
icon: 'ion-ios-people',
|
||||
icon: 'ion-person-stalker',
|
||||
label: 'REPORTS_AGENT',
|
||||
hasSubMenu: false,
|
||||
toState: frontendURL(`accounts/${accountId}/reports/agent`),
|
||||
|
@ -51,6 +52,13 @@ const reports = accountId => ({
|
|||
toState: frontendURL(`accounts/${accountId}/reports/inboxes`),
|
||||
toStateName: 'inbox_reports',
|
||||
},
|
||||
teamReports: {
|
||||
icon: 'ion-ios-people',
|
||||
label: 'REPORTS_TEAM',
|
||||
hasSubMenu: false,
|
||||
toState: frontendURL(`accounts/${accountId}/reports/teams`),
|
||||
toStateName: 'team_reports',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -6,7 +6,7 @@
|
|||
</p>
|
||||
<multiselect
|
||||
v-model="currentSelectedFilter"
|
||||
:placeholder="$t('AGENT_REPORTS.FILTER_DROPDOWN_LABEL')"
|
||||
:placeholder="multiselectLabel"
|
||||
label="name"
|
||||
track-by="id"
|
||||
:options="filterItemsList"
|
||||
|
@ -40,13 +40,13 @@
|
|||
</template>
|
||||
</multiselect>
|
||||
</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">
|
||||
{{ $t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL') }}
|
||||
</p>
|
||||
<multiselect
|
||||
v-model="currentSelectedFilter"
|
||||
:placeholder="$t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL')"
|
||||
:placeholder="multiselectLabel"
|
||||
label="title"
|
||||
track-by="id"
|
||||
:options="filterItemsList"
|
||||
|
@ -59,11 +59,11 @@
|
|||
<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 class="reports-option__title">
|
||||
{{ props.option.title }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -78,15 +78,15 @@
|
|||
"
|
||||
></div>
|
||||
<span class="reports-option__desc">
|
||||
<span class="reports-option__title">{{
|
||||
props.option.title
|
||||
}}</span>
|
||||
<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">
|
||||
<div v-else class="small-12 medium-3 pull-right">
|
||||
<p aria-hidden="true" class="hide">
|
||||
{{ $t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL') }}
|
||||
</p>
|
||||
|
@ -94,7 +94,7 @@
|
|||
v-model="currentSelectedFilter"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:placeholder="$t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL')"
|
||||
:placeholder="multiselectLabel"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
|
@ -185,12 +185,19 @@ export default {
|
|||
const fromDate = subDays(new Date(), diff);
|
||||
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: {
|
||||
filterItemsList(val) {
|
||||
this.currentSelectedFilter = val[0];
|
||||
},
|
||||
currentSelectedFilter() {
|
||||
this.changeFilterSelection();
|
||||
},
|
||||
},
|
||||
|
|
|
@ -15,8 +15,8 @@
|
|||
@date-range-change="onDateRangeChange"
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
<div v-if="selectedFilter">
|
||||
<div class="row">
|
||||
<div>
|
||||
<div v-if="filterItemsList.length" class="row">
|
||||
<woot-report-stats-card
|
||||
v-for="(metric, index) in metrics"
|
||||
:key="metric.NAME"
|
||||
|
@ -34,7 +34,10 @@
|
|||
:message="$t('REPORT.LOADING_CHART')"
|
||||
/>
|
||||
<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">
|
||||
{{ $t('REPORT.NO_ENOUGH_DATA') }}
|
||||
</span>
|
||||
|
@ -118,9 +121,15 @@ export default {
|
|||
};
|
||||
},
|
||||
metrics() {
|
||||
const reportKeys = [
|
||||
'CONVERSATIONS',
|
||||
'INCOMING_MESSAGES',
|
||||
let reportKeys = ['CONVERSATIONS'];
|
||||
// If report type is agent, we don't need to show
|
||||
// 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',
|
||||
'FIRST_RESPONSE_TIME',
|
||||
'RESOLUTION_TIME',
|
||||
|
@ -175,6 +184,9 @@ export default {
|
|||
case 'inbox':
|
||||
this.$store.dispatch('downloadInboxReports', { from, to, fileName });
|
||||
break;
|
||||
case 'team':
|
||||
this.$store.dispatch('downloadTeamReports', { from, to, fileName });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import Index from './Index';
|
|||
import AgentReports from './AgentReports';
|
||||
import LabelReports from './LabelReports';
|
||||
import InboxReports from './InboxReports';
|
||||
import TeamReports from './TeamReports';
|
||||
import CsatResponses from './CsatResponses';
|
||||
import SettingsContent from '../Wrapper';
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
@ -7,6 +7,8 @@ import fromUnixTime from 'date-fns/fromUnixTime';
|
|||
import * as types from '../mutation-types';
|
||||
import Report from '../../api/reports';
|
||||
|
||||
import { downloadCsvFile } from '../../helper/downloadCsvFile';
|
||||
|
||||
const state = {
|
||||
fetchingStatus: false,
|
||||
reportData: [],
|
||||
|
@ -78,15 +80,7 @@ export const actions = {
|
|||
downloadAgentReports(_, reportObj) {
|
||||
return Report.getAgentReports(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);
|
||||
downloadCsvFile(reportObj.fileName, response.data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
|
@ -95,15 +89,7 @@ export const actions = {
|
|||
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);
|
||||
downloadCsvFile(reportObj.fileName, response.data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
|
@ -112,15 +98,16 @@ export const actions = {
|
|||
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);
|
||||
downloadCsvFile(reportObj.fileName, response.data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
});
|
||||
},
|
||||
downloadTeamReports(_, reportObj) {
|
||||
return Report.getTeamReports(reportObj.from, reportObj.to)
|
||||
.then(response => {
|
||||
downloadCsvFile(reportObj.fileName, response.data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
|
|
|
@ -78,4 +78,25 @@ describe('#actions', () => {
|
|||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -171,14 +171,14 @@ describe ::V2::ReportBuilder do
|
|||
type: :label,
|
||||
id: label_1.id,
|
||||
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)
|
||||
metrics = builder.timeseries
|
||||
|
||||
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
|
||||
|
||||
it 'return outgoing messages count' do
|
||||
|
@ -187,14 +187,14 @@ describe ::V2::ReportBuilder do
|
|||
type: :label,
|
||||
id: label_1.id,
|
||||
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)
|
||||
metrics = builder.timeseries
|
||||
|
||||
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
|
||||
|
||||
it 'return resolutions count' do
|
||||
|
@ -203,7 +203,7 @@ describe ::V2::ReportBuilder do
|
|||
type: :label,
|
||||
id: label_2.id,
|
||||
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)
|
||||
|
@ -242,8 +242,8 @@ describe ::V2::ReportBuilder do
|
|||
metrics = builder.summary
|
||||
|
||||
expect(metrics[:conversations_count]).to be 5
|
||||
expect(metrics[:incoming_messages_count]).to be 25
|
||||
expect(metrics[:outgoing_messages_count]).to be 65
|
||||
expect(metrics[:incoming_messages_count]).to be 5
|
||||
expect(metrics[:outgoing_messages_count]).to be 15
|
||||
expect(metrics[:avg_resolution_time]).to be 0
|
||||
expect(metrics[:resolutions_count]).to be 0
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue