feat: Display trends in report metrics (#4144)
This commit is contained in:
parent
5edf0f2bbe
commit
c62d74a01d
11 changed files with 235 additions and 29 deletions
|
@ -41,12 +41,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||||
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
|
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
|
||||||
end
|
end
|
||||||
|
|
||||||
def summary_params
|
def current_summary_params
|
||||||
{
|
{
|
||||||
type: params[:type].to_sym,
|
type: params[:type].to_sym,
|
||||||
since: params[:since],
|
|
||||||
until: params[:until],
|
|
||||||
id: params[:id],
|
id: params[:id],
|
||||||
|
since: range[:current][:since],
|
||||||
|
until: range[:current][:until],
|
||||||
|
group_by: params[:group_by]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def previous_summary_params
|
||||||
|
{
|
||||||
|
type: params[:type].to_sym,
|
||||||
|
id: params[:id],
|
||||||
|
since: range[:previous][:since],
|
||||||
|
until: range[:previous][:until],
|
||||||
group_by: params[:group_by]
|
group_by: params[:group_by]
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
@ -63,8 +73,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def range
|
||||||
|
{
|
||||||
|
current: {
|
||||||
|
since: params[:since],
|
||||||
|
until: params[:until]
|
||||||
|
},
|
||||||
|
previous: {
|
||||||
|
since: (params[:since].to_i - (params[:until].to_i - params[:since].to_i)).to_s,
|
||||||
|
until: params[:since]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def summary_metrics
|
def summary_metrics
|
||||||
builder = V2::ReportBuilder.new(Current.account, summary_params)
|
summary = V2::ReportBuilder.new(Current.account, current_summary_params).summary
|
||||||
builder.summary
|
summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary
|
||||||
|
summary
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -20,12 +20,30 @@
|
||||||
color: $color-heading;
|
color: $color-heading;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metric-wrap {
|
||||||
|
align-items: baseline;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
.metric {
|
.metric {
|
||||||
font-size: $font-size-big;
|
font-size: $font-size-big;
|
||||||
font-weight: $font-weight-feather;
|
font-weight: $font-weight-feather;
|
||||||
margin-top: $space-smaller;
|
margin-top: $space-smaller;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metric-trend {
|
||||||
|
font-size: $font-size-small;
|
||||||
|
margin-left: $space-small;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-up {
|
||||||
|
color: $success-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-down {
|
||||||
|
color: $alert-color;
|
||||||
|
}
|
||||||
|
|
||||||
.desc {
|
.desc {
|
||||||
@include margin($zero);
|
@include margin($zero);
|
||||||
font-size: $font-size-small;
|
font-size: $font-size-small;
|
||||||
|
|
|
@ -7,9 +7,12 @@
|
||||||
<h3 class="heading">
|
<h3 class="heading">
|
||||||
{{ heading }}
|
{{ heading }}
|
||||||
</h3>
|
</h3>
|
||||||
<h4 class="metric">
|
<div class="metric-wrap">
|
||||||
{{ point }}
|
<h4 class="metric">
|
||||||
</h4>
|
{{ point }}
|
||||||
|
</h4>
|
||||||
|
<span v-if="trend !== 0" :class="trendClass">{{ trendValue }}</span>
|
||||||
|
</div>
|
||||||
<p class="desc">
|
<p class="desc">
|
||||||
{{ desc }}
|
{{ desc }}
|
||||||
</p>
|
</p>
|
||||||
|
@ -20,10 +23,27 @@ export default {
|
||||||
props: {
|
props: {
|
||||||
heading: { type: String, default: '' },
|
heading: { type: String, default: '' },
|
||||||
point: { type: [Number, String], default: '' },
|
point: { type: [Number, String], default: '' },
|
||||||
|
trend: { type: Number, default: null },
|
||||||
index: { type: Number, default: null },
|
index: { type: Number, default: null },
|
||||||
desc: { type: String, default: '' },
|
desc: { type: String, default: '' },
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
onClick: { type: Function, default: () => {} },
|
onClick: { type: Function, default: () => {} },
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
trendClass() {
|
||||||
|
if (this.trend > 0) {
|
||||||
|
return 'metric-trend metric-up';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'metric-trend metric-down';
|
||||||
|
},
|
||||||
|
trendValue() {
|
||||||
|
if (this.trend > 0) {
|
||||||
|
return `+${this.trend}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${this.trend}%`;
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
33
app/javascript/dashboard/mixins/reportMixin.js
Normal file
33
app/javascript/dashboard/mixins/reportMixin.js
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import { formatTime } from '@chatwoot/utils';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
accountSummary: 'getAccountSummary',
|
||||||
|
}),
|
||||||
|
calculateTrend() {
|
||||||
|
return metric_key => {
|
||||||
|
if (!this.accountSummary.previous[metric_key]) return 0;
|
||||||
|
return Math.round(
|
||||||
|
((this.accountSummary[metric_key] -
|
||||||
|
this.accountSummary.previous[metric_key]) /
|
||||||
|
this.accountSummary.previous[metric_key]) *
|
||||||
|
100
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
displayMetric() {
|
||||||
|
return metric_key => {
|
||||||
|
if (
|
||||||
|
['avg_first_response_time', 'avg_resolution_time'].includes(
|
||||||
|
metric_key
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return formatTime(this.accountSummary[metric_key]);
|
||||||
|
}
|
||||||
|
return this.accountSummary[metric_key];
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
41
app/javascript/dashboard/mixins/specs/reportMixin.spec.js
Normal file
41
app/javascript/dashboard/mixins/specs/reportMixin.spec.js
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||||
|
import reportMixin from '../reportMixin';
|
||||||
|
import reportFixtures from './reportMixinFixtures';
|
||||||
|
import Vuex from 'vuex';
|
||||||
|
const localVue = createLocalVue();
|
||||||
|
localVue.use(Vuex);
|
||||||
|
|
||||||
|
describe('reportMixin', () => {
|
||||||
|
let getters;
|
||||||
|
let store;
|
||||||
|
beforeEach(() => {
|
||||||
|
getters = {
|
||||||
|
getAccountSummary: () => reportFixtures.summary,
|
||||||
|
};
|
||||||
|
store = new Vuex.Store({ getters });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('display the metric', () => {
|
||||||
|
const Component = {
|
||||||
|
render() {},
|
||||||
|
title: 'TestComponent',
|
||||||
|
mixins: [reportMixin],
|
||||||
|
};
|
||||||
|
const wrapper = shallowMount(Component, { store, localVue });
|
||||||
|
expect(wrapper.vm.displayMetric('conversations_count')).toEqual(5);
|
||||||
|
expect(wrapper.vm.displayMetric('avg_first_response_time')).toEqual(
|
||||||
|
'3 Min'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculate the trend', () => {
|
||||||
|
const Component = {
|
||||||
|
render() {},
|
||||||
|
title: 'TestComponent',
|
||||||
|
mixins: [reportMixin],
|
||||||
|
};
|
||||||
|
const wrapper = shallowMount(Component, { store, localVue });
|
||||||
|
expect(wrapper.vm.calculateTrend('conversations_count')).toEqual(25);
|
||||||
|
expect(wrapper.vm.calculateTrend('resolutions_count')).toEqual(0);
|
||||||
|
});
|
||||||
|
});
|
18
app/javascript/dashboard/mixins/specs/reportMixinFixtures.js
Normal file
18
app/javascript/dashboard/mixins/specs/reportMixinFixtures.js
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
export default {
|
||||||
|
summary: {
|
||||||
|
avg_first_response_time: '198.6666666666667',
|
||||||
|
avg_resolution_time: '208.3333333333333',
|
||||||
|
conversations_count: 5,
|
||||||
|
incoming_messages_count: 5,
|
||||||
|
outgoing_messages_count: 3,
|
||||||
|
previous: {
|
||||||
|
avg_first_response_time: '89.0',
|
||||||
|
avg_resolution_time: '145.0',
|
||||||
|
conversations_count: 4,
|
||||||
|
incoming_messages_count: 5,
|
||||||
|
outgoing_messages_count: 4,
|
||||||
|
resolutions_count: 0,
|
||||||
|
},
|
||||||
|
resolutions_count: 3,
|
||||||
|
},
|
||||||
|
};
|
|
@ -24,7 +24,8 @@
|
||||||
:heading="metric.NAME"
|
:heading="metric.NAME"
|
||||||
:index="index"
|
:index="index"
|
||||||
:on-click="changeSelection"
|
:on-click="changeSelection"
|
||||||
:point="accountSummary[metric.KEY]"
|
:point="displayMetric(metric.KEY)"
|
||||||
|
:trend="calculateTrend(metric.KEY)"
|
||||||
:selected="index === currentSelection"
|
:selected="index === currentSelection"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -49,6 +50,7 @@ import fromUnixTime from 'date-fns/fromUnixTime';
|
||||||
import format from 'date-fns/format';
|
import format from 'date-fns/format';
|
||||||
import ReportFilterSelector from './components/FilterSelector';
|
import ReportFilterSelector from './components/FilterSelector';
|
||||||
import { GROUP_BY_FILTER } from './constants';
|
import { GROUP_BY_FILTER } from './constants';
|
||||||
|
import reportMixin from '../../../../mixins/reportMixin';
|
||||||
|
|
||||||
const REPORTS_KEYS = {
|
const REPORTS_KEYS = {
|
||||||
CONVERSATIONS: 'conversations_count',
|
CONVERSATIONS: 'conversations_count',
|
||||||
|
@ -63,6 +65,7 @@ export default {
|
||||||
components: {
|
components: {
|
||||||
ReportFilterSelector,
|
ReportFilterSelector,
|
||||||
},
|
},
|
||||||
|
mixins: [reportMixin],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
from: 0,
|
from: 0,
|
||||||
|
|
|
@ -27,7 +27,8 @@
|
||||||
:heading="metric.NAME"
|
:heading="metric.NAME"
|
||||||
:index="index"
|
:index="index"
|
||||||
:on-click="changeSelection"
|
:on-click="changeSelection"
|
||||||
:point="accountSummary[metric.KEY]"
|
:point="displayMetric(metric.KEY)"
|
||||||
|
:trend="calculateTrend(metric.KEY)"
|
||||||
:selected="index === currentSelection"
|
:selected="index === currentSelection"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -55,6 +56,7 @@ import ReportFilters from './ReportFilters';
|
||||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||||
import format from 'date-fns/format';
|
import format from 'date-fns/format';
|
||||||
import { GROUP_BY_FILTER } from '../constants';
|
import { GROUP_BY_FILTER } from '../constants';
|
||||||
|
import reportMixin from '../../../../../mixins/reportMixin';
|
||||||
|
|
||||||
const REPORTS_KEYS = {
|
const REPORTS_KEYS = {
|
||||||
CONVERSATIONS: 'conversations_count',
|
CONVERSATIONS: 'conversations_count',
|
||||||
|
@ -68,6 +70,7 @@ export default {
|
||||||
components: {
|
components: {
|
||||||
ReportFilters,
|
ReportFilters,
|
||||||
},
|
},
|
||||||
|
mixins: [reportMixin],
|
||||||
props: {
|
props: {
|
||||||
type: {
|
type: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|
|
@ -5,7 +5,6 @@ import * as types from '../mutation-types';
|
||||||
import Report from '../../api/reports';
|
import Report from '../../api/reports';
|
||||||
|
|
||||||
import { downloadCsvFile } from '../../helper/downloadCsvFile';
|
import { downloadCsvFile } from '../../helper/downloadCsvFile';
|
||||||
import { formatTime } from '@chatwoot/utils';
|
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
fetchingStatus: false,
|
fetchingStatus: false,
|
||||||
|
@ -21,6 +20,7 @@ const state = {
|
||||||
incoming_messages_count: 0,
|
incoming_messages_count: 0,
|
||||||
outgoing_messages_count: 0,
|
outgoing_messages_count: 0,
|
||||||
resolutions_count: 0,
|
resolutions_count: 0,
|
||||||
|
previous: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -125,18 +125,6 @@ const mutations = {
|
||||||
},
|
},
|
||||||
[types.default.SET_ACCOUNT_SUMMARY](_state, summaryData) {
|
[types.default.SET_ACCOUNT_SUMMARY](_state, summaryData) {
|
||||||
_state.accountSummary = summaryData;
|
_state.accountSummary = summaryData;
|
||||||
// Average First Response Time
|
|
||||||
let avgFirstResTimeInHr = 0;
|
|
||||||
if (summaryData.avg_first_response_time) {
|
|
||||||
avgFirstResTimeInHr = formatTime(summaryData.avg_first_response_time);
|
|
||||||
}
|
|
||||||
// Average Resolution Time
|
|
||||||
let avgResolutionTimeInHr = 0;
|
|
||||||
if (summaryData.avg_resolution_time) {
|
|
||||||
avgResolutionTimeInHr = formatTime(summaryData.avg_resolution_time);
|
|
||||||
}
|
|
||||||
_state.accountSummary.avg_first_response_time = avgFirstResTimeInHr;
|
|
||||||
_state.accountSummary.avg_resolution_time = avgResolutionTimeInHr;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,29 @@
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
value:
|
avg_first_response_time:
|
||||||
type: number
|
|
||||||
timestamp:
|
|
||||||
type: string
|
type: string
|
||||||
|
avg_resolution_time:
|
||||||
|
type: string
|
||||||
|
conversations_count:
|
||||||
|
type: number
|
||||||
|
incoming_messages_count:
|
||||||
|
type: number
|
||||||
|
outgoing_messages_count:
|
||||||
|
type: number
|
||||||
|
resolutions_count:
|
||||||
|
type: number
|
||||||
|
previous:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
avg_first_response_time:
|
||||||
|
type: string
|
||||||
|
avg_resolution_time:
|
||||||
|
type: string
|
||||||
|
conversations_count:
|
||||||
|
type: number
|
||||||
|
incoming_messages_count:
|
||||||
|
type: number
|
||||||
|
outgoing_messages_count:
|
||||||
|
type: number
|
||||||
|
resolutions_count:
|
||||||
|
type: number
|
|
@ -4692,11 +4692,46 @@
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"value": {
|
"avg_first_response_time": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"avg_resolution_time": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"conversations_count": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
"timestamp": {
|
"incoming_messages_count": {
|
||||||
"type": "string"
|
"type": "number"
|
||||||
|
},
|
||||||
|
"outgoing_messages_count": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"resolutions_count": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"previous": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"avg_first_response_time": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"avg_resolution_time": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"conversations_count": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"incoming_messages_count": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"outgoing_messages_count": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"resolutions_count": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue