feat: Update reports UI to make it better (#7544)

This commit is contained in:
Pranav Raj S
2023-07-19 12:12:15 -07:00
committed by GitHub
parent f72be94323
commit 25ed66edf5
15 changed files with 574 additions and 618 deletions

View File

@@ -58,31 +58,3 @@
text-transform: capitalize; text-transform: capitalize;
} }
} }
.report-bar {
@include background-white;
@include border-light;
margin: var(--space-minus-micro) 0;
padding: var(--space-small) var(--space-medium);
.chart-container {
@include flex;
@include flex-align(center, middle);
flex-direction: column;
div {
width: 100%;
}
.empty-state {
color: $color-gray;
font-size: var(--font-size-default);
margin: var(--space-jumbo);
}
.business-hours {
margin: var(--space-normal);
text-align: center;
}
}
}

View File

@@ -22,18 +22,6 @@
margin: 0 var(--space-small); margin: 0 var(--space-small);
} }
.business-hours {
align-items: center;
display: flex;
justify-content: flex-start;
margin-left: auto;
padding-right: var(--space-normal);
}
.business-hours-text {
font-size: var(--font-size-small);
margin: 0 var(--space-small);
}
.switch { .switch {
margin-bottom: var(--space-zero); margin-bottom: var(--space-zero);

View File

@@ -1,16 +1,20 @@
import { Bar } from 'vue-chartjs'; import { Bar } from 'vue-chartjs';
const fontFamily = const fontFamily =
'-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif'; 'PlusJakarta,-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
const defaultChartOptions = { const defaultChartOptions = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
legend: { legend: {
display: false,
labels: { labels: {
fontFamily, fontFamily,
}, },
}, },
animation: {
duration: 0,
},
datasets: { datasets: {
bar: { bar: {
barPercentage: 1.0, barPercentage: 1.0,
@@ -46,11 +50,11 @@ export default {
props: { props: {
collection: { collection: {
type: Object, type: Object,
default: () => {}, default: () => ({}),
}, },
chartOptions: { chartOptions: {
type: Object, type: Object,
default: () => {}, default: () => ({}),
}, },
}, },
mounted() { mounted() {

View File

@@ -12,11 +12,11 @@
"DESC": "( Total )" "DESC": "( Total )"
}, },
"INCOMING_MESSAGES": { "INCOMING_MESSAGES": {
"NAME": "Incoming Messages", "NAME": "Messages received",
"DESC": "( Total )" "DESC": "( Total )"
}, },
"OUTGOING_MESSAGES": { "OUTGOING_MESSAGES": {
"NAME": "Outgoing Messages", "NAME": "Messages sent",
"DESC": "( Total )" "DESC": "( Total )"
}, },
"FIRST_RESPONSE_TIME": { "FIRST_RESPONSE_TIME": {
@@ -93,7 +93,6 @@
{ "id": 3, "groupBy": "Month" } { "id": 3, "groupBy": "Month" }
], ],
"GROUP_BY_YEAR_OPTIONS": [ "GROUP_BY_YEAR_OPTIONS": [
{ "id": 1, "groupBy": "Day" },
{ "id": 2, "groupBy": "Week" }, { "id": 2, "groupBy": "Week" },
{ "id": 3, "groupBy": "Month" }, { "id": 3, "groupBy": "Month" },
{ "id": 4, "groupBy": "Year" } { "id": 4, "groupBy": "Year" }

View File

@@ -10,42 +10,36 @@ export default {
calculateTrend() { calculateTrend() {
return metric_key => { return metric_key => {
if (!this.accountSummary.previous[metric_key]) return 0; if (!this.accountSummary.previous[metric_key]) return 0;
const diff =
this.accountSummary[metric_key] -
this.accountSummary.previous[metric_key];
return Math.round( return Math.round(
((this.accountSummary[metric_key] - (diff / this.accountSummary.previous[metric_key]) * 100
this.accountSummary.previous[metric_key]) /
this.accountSummary.previous[metric_key]) *
100
);
};
},
displayMetric() {
return metric_key => {
if (this.isAverageMetricType(metric_key)) {
return formatTime(this.accountSummary[metric_key]);
}
return this.accountSummary[metric_key];
};
},
displayInfoText() {
return metric_key => {
if (this.metrics[this.currentSelection].KEY !== metric_key) {
return '';
}
if (this.isAverageMetricType(metric_key)) {
const total = this.accountReport.data
.map(item => item.count)
.reduce((prev, curr) => prev + curr, 0);
return `${this.metrics[this.currentSelection].INFO_TEXT} ${total}`;
}
return '';
};
},
isAverageMetricType() {
return metric_key => {
return ['avg_first_response_time', 'avg_resolution_time'].includes(
metric_key
); );
}; };
}, },
}, },
methods: {
displayMetric(key) {
if (this.isAverageMetricType(key)) {
return formatTime(this.accountSummary[key]);
}
return Number(this.accountSummary[key] || '').toLocaleString();
},
displayInfoText(key) {
if (this.metrics[this.currentSelection].KEY !== key) {
return '';
}
if (this.isAverageMetricType(key)) {
const total = this.accountReport.data
.map(item => item.count)
.reduce((prev, curr) => prev + curr, 0);
return `${this.metrics[this.currentSelection].INFO_TEXT} ${total}`;
}
return '';
},
isAverageMetricType(key) {
return ['avg_first_response_time', 'avg_resolution_time'].includes(key);
},
},
}; };

View File

@@ -23,7 +23,7 @@ describe('reportMixin', () => {
mixins: [reportMixin], mixins: [reportMixin],
}; };
const wrapper = shallowMount(Component, { store, localVue }); const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.displayMetric('conversations_count')).toEqual(5); expect(wrapper.vm.displayMetric('conversations_count')).toEqual('5,000');
expect(wrapper.vm.displayMetric('avg_first_response_time')).toEqual( expect(wrapper.vm.displayMetric('avg_first_response_time')).toEqual(
'3 Min 18 Sec' '3 Min 18 Sec'
); );
@@ -36,7 +36,7 @@ describe('reportMixin', () => {
mixins: [reportMixin], mixins: [reportMixin],
}; };
const wrapper = shallowMount(Component, { store, localVue }); const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.calculateTrend('conversations_count')).toEqual(25); expect(wrapper.vm.calculateTrend('conversations_count')).toEqual(124900);
expect(wrapper.vm.calculateTrend('resolutions_count')).toEqual(0); expect(wrapper.vm.calculateTrend('resolutions_count')).toEqual(0);
}); });

View File

@@ -2,7 +2,7 @@ export default {
summary: { summary: {
avg_first_response_time: '198.6666666666667', avg_first_response_time: '198.6666666666667',
avg_resolution_time: '208.3333333333333', avg_resolution_time: '208.3333333333333',
conversations_count: 5, conversations_count: 5000,
incoming_messages_count: 5, incoming_messages_count: 5,
outgoing_messages_count: 3, outgoing_messages_count: 3,
previous: { previous: {

View File

@@ -13,36 +13,7 @@
:show-group-by-filter="true" :show-group-by-filter="true"
@filter-change="onFilterChange" @filter-change="onFilterChange"
/> />
<div class="row"> <report-container :group-by="groupBy" />
<woot-report-stats-card
v-for="(metric, index) in metrics"
:key="metric.NAME"
:desc="metric.DESC"
:heading="metric.NAME"
:info-text="displayInfoText(metric.KEY)"
:index="index"
:on-click="changeSelection"
:point="displayMetric(metric.KEY)"
:trend="calculateTrend(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"
:chart-options="chartOptions"
/>
<span v-else class="empty-state">
{{ $t('REPORT.NO_ENOUGH_DATA') }}
</span>
</div>
</div>
</div> </div>
</template> </template>
@@ -51,11 +22,11 @@ import { mapGetters } from 'vuex';
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 ReportFilterSelector from './components/FilterSelector'; import ReportFilterSelector from './components/FilterSelector';
import { GROUP_BY_FILTER, METRIC_CHART } from './constants'; import { GROUP_BY_FILTER } from './constants';
import reportMixin from 'dashboard/mixins/reportMixin'; import reportMixin from 'dashboard/mixins/reportMixin';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import { formatTime } from '@chatwoot/utils';
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events'; import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import ReportContainer from './ReportContainer.vue';
const REPORTS_KEYS = { const REPORTS_KEYS = {
CONVERSATIONS: 'conversations_count', CONVERSATIONS: 'conversations_count',
@@ -70,13 +41,13 @@ export default {
name: 'ConversationReports', name: 'ConversationReports',
components: { components: {
ReportFilterSelector, ReportFilterSelector,
ReportContainer,
}, },
mixins: [reportMixin, alertMixin], mixins: [reportMixin, alertMixin],
data() { data() {
return { return {
from: 0, from: 0,
to: 0, to: 0,
currentSelection: 0,
groupBy: GROUP_BY_FILTER[1], groupBy: GROUP_BY_FILTER[1],
businessHours: false, businessHours: false,
}; };
@@ -86,104 +57,6 @@ export default {
accountSummary: 'getAccountSummary', accountSummary: 'getAccountSummary',
accountReport: 'getAccountReports', accountReport: 'getAccountReports',
}), }),
collection() {
if (this.accountReport.isFetching) {
return {};
}
if (!this.accountReport.data.length) return {};
const labels = this.accountReport.data.map(element => {
if (this.groupBy?.period === GROUP_BY_FILTER[2].period) {
let week_date = new Date(fromUnixTime(element.timestamp));
const first_day = week_date.getDate() - week_date.getDay();
const last_day = first_day + 6;
const week_first_date = new Date(week_date.setDate(first_day));
const week_last_date = new Date(week_date.setDate(last_day));
return `${format(week_first_date, 'dd/MM/yy')} - ${format(
week_last_date,
'dd/MM/yy'
)}`;
}
if (this.groupBy?.period === GROUP_BY_FILTER[3].period) {
return format(fromUnixTime(element.timestamp), 'MMM-yyyy');
}
if (this.groupBy?.period === GROUP_BY_FILTER[4].period) {
return format(fromUnixTime(element.timestamp), 'yyyy');
}
return format(fromUnixTime(element.timestamp), 'dd-MMM-yyyy');
});
const datasets = METRIC_CHART[
this.metrics[this.currentSelection].KEY
].datasets.map(dataset => {
switch (dataset.type) {
case 'bar':
return {
...dataset,
yAxisID: 'y-left',
label: this.metrics[this.currentSelection].NAME,
data: this.accountReport.data.map(element => element.value),
};
case 'line':
return {
...dataset,
yAxisID: 'y-right',
label: this.metrics[0].NAME,
data: this.accountReport.data.map(element => element.count),
};
default:
return dataset;
}
});
return {
labels,
datasets,
};
},
chartOptions() {
let tooltips = {};
if (this.isAverageMetricType(this.metrics[this.currentSelection].KEY)) {
tooltips.callbacks = {
label: tooltipItem => {
return this.$t(this.metrics[this.currentSelection].TOOLTIP_TEXT, {
metricValue: formatTime(tooltipItem.yLabel),
conversationCount: this.accountReport.data[tooltipItem.index]
.count,
});
},
};
}
return {
scales: METRIC_CHART[this.metrics[this.currentSelection].KEY].scales,
tooltips: tooltips,
};
},
metrics() {
const reportKeys = [
'CONVERSATIONS',
'INCOMING_MESSAGES',
'OUTGOING_MESSAGES',
'FIRST_RESPONSE_TIME',
'RESOLUTION_TIME',
'RESOLUTION_COUNT',
];
const infoText = {
FIRST_RESPONSE_TIME: this.$t(
`REPORT.METRICS.FIRST_RESPONSE_TIME.INFO_TEXT`
),
RESOLUTION_TIME: this.$t(`REPORT.METRICS.RESOLUTION_TIME.INFO_TEXT`),
};
return reportKeys.map(key => ({
NAME: this.$t(`REPORT.METRICS.${key}.NAME`),
KEY: REPORTS_KEYS[key],
DESC: this.$t(`REPORT.METRICS.${key}.DESC`),
INFO_TEXT: infoText[key],
TOOLTIP_TEXT: `REPORT.METRICS.${key}.TOOLTIP_TEXT`,
}));
},
}, },
methods: { methods: {
fetchAllData() { fetchAllData() {
@@ -198,14 +71,23 @@ export default {
} }
}, },
fetchChartData() { fetchChartData() {
try { [
this.$store.dispatch('fetchAccountReport', { 'CONVERSATIONS',
metric: this.metrics[this.currentSelection].KEY, 'INCOMING_MESSAGES',
...this.getRequestPayload(), 'OUTGOING_MESSAGES',
}); 'FIRST_RESPONSE_TIME',
} catch { 'RESOLUTION_TIME',
this.showAlert(this.$t('REPORT.DATA_FETCHING_FAILED')); 'RESOLUTION_COUNT',
} ].forEach(async key => {
try {
await this.$store.dispatch('fetchAccountReport', {
metric: REPORTS_KEYS[key],
...this.getRequestPayload(),
});
} catch {
this.showAlert(this.$t('REPORT.DATA_FETCHING_FAILED'));
}
});
}, },
getRequestPayload() { getRequestPayload() {
const { from, to, groupBy, businessHours } = this; const { from, to, groupBy, businessHours } = this;
@@ -225,10 +107,6 @@ export default {
)}.csv`; )}.csv`;
this.$store.dispatch('downloadAgentReports', { from, to, fileName }); this.$store.dispatch('downloadAgentReports', { from, to, fileName });
}, },
changeSelection(index) {
this.currentSelection = index;
this.fetchChartData();
},
onFilterChange({ from, to, groupBy, businessHours }) { onFilterChange({ from, to, groupBy, businessHours }) {
this.from = from; this.from = from;
this.to = to; this.to = to;

View File

@@ -0,0 +1,156 @@
<template>
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 bg-white dark:bg-slate-800 p-2 border border-slate-100 dark:border-slate-700 rounded-md"
>
<div
v-for="metric in metrics"
:key="metric.KEY"
class="p-4 rounded-md mb-3"
>
<chart-stats :metric="metric" />
<div class="mt-4 h-72">
<woot-loading-state
v-if="accountReport.isFetching[metric.KEY]"
class="text-xs"
:message="$t('REPORT.LOADING_CHART')"
/>
<div v-else class="h-72 flex items-center justify-center">
<woot-bar
v-if="accountReport.data[metric.KEY].length"
:collection="getCollection(metric)"
:chart-options="getChartOptions(metric)"
class="h-72 w-full"
/>
<span v-else class="text-sm text-slate-600">
{{ $t('REPORT.NO_ENOUGH_DATA') }}
</span>
</div>
</div>
</div>
</div>
</template>
<script>
import { GROUP_BY_FILTER, METRIC_CHART } from './constants';
import fromUnixTime from 'date-fns/fromUnixTime';
import format from 'date-fns/format';
import { formatTime } from '@chatwoot/utils';
import reportMixin from 'dashboard/mixins/reportMixin';
import ChartStats from './components/ChartElements/ChartStats.vue';
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: { ChartStats },
mixins: [reportMixin],
props: {
groupBy: {
type: Object,
default: () => ({}),
},
},
computed: {
metrics() {
const reportKeys = [
'CONVERSATIONS',
'FIRST_RESPONSE_TIME',
'RESOLUTION_TIME',
'RESOLUTION_COUNT',
'INCOMING_MESSAGES',
'OUTGOING_MESSAGES',
];
const infoText = {
FIRST_RESPONSE_TIME: this.$t(
`REPORT.METRICS.FIRST_RESPONSE_TIME.INFO_TEXT`
),
RESOLUTION_TIME: this.$t(`REPORT.METRICS.RESOLUTION_TIME.INFO_TEXT`),
};
return reportKeys.map(key => ({
NAME: this.$t(`REPORT.METRICS.${key}.NAME`),
KEY: REPORTS_KEYS[key],
DESC: this.$t(`REPORT.METRICS.${key}.DESC`),
INFO_TEXT: infoText[key],
TOOLTIP_TEXT: `REPORT.METRICS.${key}.TOOLTIP_TEXT`,
trend: this.calculateTrend(REPORTS_KEYS[key]),
}));
},
},
methods: {
getCollection(metric) {
if (!this.accountReport.data[metric.KEY]) {
return {};
}
const data = this.accountReport.data[metric.KEY];
const labels = data.map(element => {
if (this.groupBy?.period === GROUP_BY_FILTER[2].period) {
let week_date = new Date(fromUnixTime(element.timestamp));
const first_day = week_date.getDate() - week_date.getDay();
const last_day = first_day + 6;
const week_first_date = new Date(week_date.setDate(first_day));
const week_last_date = new Date(week_date.setDate(last_day));
return `${format(week_first_date, 'dd-MMM')} - ${format(
week_last_date,
'dd-MMM'
)}`;
}
if (this.groupBy?.period === GROUP_BY_FILTER[3].period) {
return format(fromUnixTime(element.timestamp), 'MMM-yyyy');
}
if (this.groupBy?.period === GROUP_BY_FILTER[4].period) {
return format(fromUnixTime(element.timestamp), 'yyyy');
}
return format(fromUnixTime(element.timestamp), 'dd-MMM');
});
const datasets = METRIC_CHART[metric.KEY].datasets.map(dataset => {
switch (dataset.type) {
case 'bar':
return {
...dataset,
yAxisID: 'y-left',
label: metric.NAME,
data: data.map(element => element.value),
};
case 'line':
return {
...dataset,
yAxisID: 'y-right',
label: this.metrics[0].NAME,
data: data.map(element => element.count),
};
default:
return dataset;
}
});
return {
labels,
datasets,
};
},
getChartOptions(metric) {
let tooltips = {};
if (this.isAverageMetricType(metric.KEY)) {
tooltips.callbacks = {
label: tooltipItem => {
return this.$t(metric.TOOLTIP_TEXT, {
metricValue: formatTime(tooltipItem.yLabel),
conversationCount: this.accountReport.data[metric.KEY][
tooltipItem.index
].count,
});
},
};
}
return {
scales: METRIC_CHART[metric.KEY].scales,
tooltips: tooltips,
};
},
},
};
</script>

View File

@@ -0,0 +1,49 @@
<template>
<div>
<span class="text-sm">{{ metric.NAME }}</span>
<div class="flex items-end">
<div class="font-medium text-xl">
{{ displayMetric(metric.KEY) }}
</div>
<div v-if="metric.trend" class="text-xs ml-4 flex items-center mb-0.5">
<div
v-if="metric.trend < 0"
class="h-0 w-0 border-x-4 medium border-x-transparent border-t-[8px] mr-1 "
:class="trendColor(metric.trend, metric.KEY)"
/>
<div
v-else
class="h-0 w-0 border-x-4 medium border-x-transparent border-b-[8px] mr-1 "
:class="trendColor(metric.trend, metric.KEY)"
/>
<span class="font-medium" :class="trendColor(metric.trend, metric.KEY)">
{{ calculateTrend(metric.KEY) }}%
</span>
</div>
</div>
</div>
</template>
<script>
import reportMixin from 'dashboard/mixins/reportMixin';
export default {
mixins: [reportMixin],
props: {
metric: {
type: Object,
default: () => ({}),
},
},
methods: {
trendColor(value, key) {
if (this.isAverageMetricType(key)) {
return value > 0
? 'border-red-500 text-red-500'
: 'border-green-500 text-green-500';
}
return value < 0
? 'border-red-500 text-red-500'
: 'border-green-500 text-green-500';
},
},
};
</script>

View File

@@ -1,43 +1,45 @@
<template> <template>
<div class="filter-container"> <div class="flex flex-col md:flex-row justify-between mb-4">
<reports-filters-date-range @on-range-change="onDateRangeChange" /> <div class="md:grid flex flex-col filter-container gap-3 w-full">
<woot-date-range-picker <reports-filters-date-range @on-range-change="onDateRangeChange" />
v-if="isDateRangeSelected" <woot-date-range-picker
show-range v-if="isDateRangeSelected"
class="no-margin auto-width" show-range
:value="customDateRange" class="no-margin auto-width"
:confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')" :value="customDateRange"
:placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')" :confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')"
@change="onCustomDateRangeChange" :placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')"
/> @change="onCustomDateRangeChange"
<reports-filters-date-group-by />
v-if="showGroupByFilter && isGroupByPossible" <reports-filters-date-group-by
:valid-group-options="validGroupOptions" v-if="showGroupByFilter && isGroupByPossible"
:selected-option="selectedGroupByFilter" :valid-group-options="validGroupOptions"
@on-grouping-change="onGroupingChange" :selected-option="selectedGroupByFilter"
/> @on-grouping-change="onGroupingChange"
<reports-filters-agents />
v-if="showAgentsFilter" <reports-filters-agents
@agents-filter-selection="handleAgentsFilterSelection" v-if="showAgentsFilter"
/> @agents-filter-selection="handleAgentsFilterSelection"
<reports-filters-labels />
v-if="showLabelsFilter" <reports-filters-labels
@labels-filter-selection="handleLabelsFilterSelection" v-if="showLabelsFilter"
/> @labels-filter-selection="handleLabelsFilterSelection"
<reports-filters-teams />
v-if="showTeamFilter" <reports-filters-teams
@team-filter-selection="handleTeamFilterSelection" v-if="showTeamFilter"
/> @team-filter-selection="handleTeamFilterSelection"
<reports-filters-inboxes />
v-if="showInboxFilter" <reports-filters-inboxes
@inbox-filter-selection="handleInboxFilterSelection" v-if="showInboxFilter"
/> @inbox-filter-selection="handleInboxFilterSelection"
<reports-filters-ratings />
v-if="showRatingFilter" <reports-filters-ratings
@rating-filter-selection="handleRatingFilterSelection" v-if="showRatingFilter"
/> @rating-filter-selection="handleRatingFilterSelection"
<div v-if="showBusinessHoursSwitch" class="business-hours"> />
<span class="business-hours-text "> </div>
<div v-if="showBusinessHoursSwitch" class="flex items-center">
<span class="text-sm whitespace-nowrap mx-2">
{{ $t('REPORT.BUSINESS_HOURS') }} {{ $t('REPORT.BUSINESS_HOURS') }}
</span> </span>
<span> <span>
@@ -230,10 +232,6 @@ export default {
<style scoped> <style scoped>
.filter-container { .filter-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-gap: var(--space-slab);
margin-bottom: var(--space-normal);
} }
</style> </style>

View File

@@ -1,176 +1,178 @@
<template> <template>
<div class="flex-container flex-dir-column medium-flex-dir-row"> <div class="flex flex-col md:flex-row">
<div <div class="flex items-center w-full flex-col md:flex-row">
v-if="type === 'agent'" <div
class="small-12 medium-3 pull-right multiselect-wrap--small" v-if="type === 'agent'"
> class="md:w-[240px] w-full multiselect-wrap--small"
<p>
{{ $t('AGENT_REPORTS.FILTER_DROPDOWN_LABEL') }}
</p>
<multiselect
v-model="currentSelectedFilter"
:placeholder="multiselectLabel"
label="name"
track-by="id"
:options="filterItemsList"
:option-height="24"
:show-labels="false"
@input="changeFilterSelection"
> >
<template slot="singleLabel" slot-scope="props"> <p class="text-xs mb-2 font-medium">
<div class="reports-option__wrap"> {{ $t('AGENT_REPORTS.FILTER_DROPDOWN_LABEL') }}
<thumbnail </p>
:src="props.option.thumbnail" <multiselect
:status="props.option.availability_status" v-model="currentSelectedFilter"
:username="props.option.name" :placeholder="multiselectLabel"
size="22px" label="name"
/> track-by="id"
<span class="reports-option__desc"> :options="filterItemsList"
<span class="reports-option__title">{{ props.option.name }}</span> :option-height="24"
</span> :show-labels="false"
</div> @input="changeFilterSelection"
</template> >
<template slot="option" slot-scope="props"> <template slot="singleLabel" slot-scope="props">
<div class="reports-option__wrap"> <div class="reports-option__wrap">
<thumbnail <thumbnail
:src="props.option.thumbnail" :src="props.option.thumbnail"
:status="props.option.availability_status" :status="props.option.availability_status"
:username="props.option.name" :username="props.option.name"
size="22px" size="22px"
/> />
<p class="reports-option__title">{{ props.option.name }}</p> <span class="reports-option__desc">
</div> <span class="reports-option__title">{{
</template> props.option.name
</multiselect> }}</span>
</div>
<div
v-else-if="type === 'label'"
class="small-12 medium-3 pull-right multiselect-wrap--small"
>
<p>
{{ $t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL') }}
</p>
<multiselect
v-model="currentSelectedFilter"
:placeholder="multiselectLabel"
label="title"
track-by="id"
:options="filterItemsList"
:option-height="24"
:show-labels="false"
@input="changeFilterSelection"
>
<template slot="singleLabel" slot-scope="props">
<div class="reports-option__wrap">
<div
:style="{ backgroundColor: props.option.color }"
class="reports-option__rounded--item"
/>
<span class="reports-option__desc">
<span class="reports-option__title">
{{ props.option.title }}
</span> </span>
</span> </div>
</div> </template>
</template> <template slot="option" slot-scope="props">
<template slot="option" slot-scope="props"> <div class="reports-option__wrap">
<div class="reports-option__wrap"> <thumbnail
<div :src="props.option.thumbnail"
:style="{ backgroundColor: props.option.color }" :status="props.option.availability_status"
class=" :username="props.option.name"
size="22px"
/>
<p class="reports-option__title">{{ props.option.name }}</p>
</div>
</template>
</multiselect>
</div>
<div
v-else-if="type === 'label'"
class="md:w-[240px] w-full multiselect-wrap--small"
>
<p class="text-xs mb-2 font-medium">
{{ $t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL') }}
</p>
<multiselect
v-model="currentSelectedFilter"
:placeholder="multiselectLabel"
label="title"
track-by="id"
:options="filterItemsList"
:option-height="24"
:show-labels="false"
@input="changeFilterSelection"
>
<template slot="singleLabel" slot-scope="props">
<div class="reports-option__wrap">
<div
:style="{ backgroundColor: props.option.color }"
class="reports-option__rounded--item"
/>
<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="reports-option__wrap">
<div
:style="{ backgroundColor: props.option.color }"
class="
reports-option__rounded--item reports-option__rounded--item
reports-option__item reports-option__item
reports-option__label--swatch reports-option__label--swatch
" "
/> />
<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 v-else class="md:w-[240px] w-full multiselect-wrap--small">
<p class="text-xs mb-2 font-medium">
<template v-if="type === 'inbox'">
{{ $t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL') }}
</template>
<template v-else-if="type === 'team'">
{{ $t('TEAM_REPORTS.FILTER_DROPDOWN_LABEL') }}
</template>
<!-- handle default condition because the prop is not limited to the given 4 values -->
<template v-else>
{{ $t('FORMS.MULTISELECT.SELECT_ONE') }}
</template>
</p>
<multiselect
v-model="currentSelectedFilter"
track-by="id"
label="name"
:placeholder="multiselectLabel"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:options="filterItemsList"
:searchable="false"
:allow-empty="false"
@input="changeFilterSelection"
/>
</div>
<div class="mx-1 md:w-[240px] w-full multiselect-wrap--small">
<p class="text-xs mb-2 font-medium">
{{ $t('REPORT.DURATION_FILTER_LABEL') }}
</p>
<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>
<div v-if="isDateRangeSelected" class="">
<p class="text-xs mb-2 font-medium">
{{ $t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER') }}
</p>
<woot-date-range-picker
show-range
:value="customDateRange"
:confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')"
:placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')"
@change="onChange"
/>
</div>
<div
v-if="notLast7Days"
class="mx-1 md:w-[240px] w-full multiselect-wrap--small"
>
<p class="text-xs mb-2 font-medium">
{{ $t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL') }}
</p>
<multiselect
v-model="currentSelectedGroupByFilter"
track-by="id"
label="groupBy"
:placeholder="$t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')"
:options="groupByFilterItemsList"
:allow-empty="false"
:show-labels="false"
@input="changeGroupByFilterSelection"
/>
</div>
</div> </div>
<div v-else class="small-12 medium-3 pull-right multiselect-wrap--small"> <div class="flex items-center my-2">
<p> <span class="text-sm mx-2 whitespace-nowrap">
<template v-if="type === 'inbox'">
{{ $t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL') }}
</template>
<template v-else-if="type === 'team'">
{{ $t('TEAM_REPORTS.FILTER_DROPDOWN_LABEL') }}
</template>
<!-- handle default condition because the prop is not limited to the given 4 values -->
<template v-else>
{{ $t('FORMS.MULTISELECT.SELECT_ONE') }}
</template>
</p>
<multiselect
v-model="currentSelectedFilter"
track-by="id"
label="name"
:placeholder="multiselectLabel"
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-right-1 margin-left-1 multiselect-wrap--small"
>
<p>
{{ $t('REPORT.DURATION_FILTER_LABEL') }}
</p>
<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>
<div v-if="isDateRangeSelected" class="">
<p>
{{ $t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER') }}
</p>
<woot-date-range-picker
show-range
:value="customDateRange"
:confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')"
:placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')"
@change="onChange"
/>
</div>
<div
v-if="notLast7Days"
class="small-12 medium-3 pull-right margin-right-1 margin-left-1 multiselect-wrap--small"
>
<p>
{{ $t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL') }}
</p>
<multiselect
v-model="currentSelectedGroupByFilter"
track-by="id"
label="groupBy"
:placeholder="$t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')"
:options="groupByFilterItemsList"
:allow-empty="false"
:show-labels="false"
@input="changeGroupByFilterSelection"
/>
</div>
<div class="small-12 medium-3 business-hours">
<span class="business-hours-text">
{{ $t('REPORT.BUSINESS_HOURS') }} {{ $t('REPORT.BUSINESS_HOURS') }}
</span> </span>
<span> <span>
@@ -266,7 +268,7 @@ export default {
1: GROUP_BY_FILTER[2].period, 1: GROUP_BY_FILTER[2].period,
2: GROUP_BY_FILTER[3].period, 2: GROUP_BY_FILTER[3].period,
3: GROUP_BY_FILTER[3].period, 3: GROUP_BY_FILTER[3].period,
4: GROUP_BY_FILTER[3].period, 4: GROUP_BY_FILTER[4].period,
}; };
return groupRange[this.currentDateRangeSelection.id]; return groupRange[this.currentDateRangeSelection.id];
}, },

View File

@@ -19,48 +19,15 @@
@group-by-filter-change="onGroupByFilterChange" @group-by-filter-change="onGroupByFilterChange"
@business-hours-toggle="onBusinessHoursToggle" @business-hours-toggle="onBusinessHoursToggle"
/> />
<div> <report-container v-if="filterItemsList.length" :group-by="groupBy" />
<div v-if="filterItemsList.length" class="row">
<woot-report-stats-card
v-for="(metric, index) in metrics"
:key="metric.NAME"
:desc="metric.DESC"
:heading="metric.NAME"
:info-text="displayInfoText(metric.KEY)"
:index="index"
:on-click="changeSelection"
:point="displayMetric(metric.KEY)"
:trend="calculateTrend(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 && filterItemsList.length"
:collection="collection"
:chart-options="chartOptions"
/>
<span v-else class="empty-state">
{{ $t('REPORT.NO_ENOUGH_DATA') }}
</span>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script> <script>
import ReportFilters from './ReportFilters'; import ReportFilters from './ReportFilters';
import fromUnixTime from 'date-fns/fromUnixTime'; import ReportContainer from '../ReportContainer.vue';
import format from 'date-fns/format'; import { GROUP_BY_FILTER } from '../constants';
import { GROUP_BY_FILTER, METRIC_CHART } from '../constants';
import reportMixin from '../../../../../mixins/reportMixin'; import reportMixin from '../../../../../mixins/reportMixin';
import { formatTime } from '@chatwoot/utils';
import { generateFileName } from '../../../../../helper/downloadHelper'; import { generateFileName } from '../../../../../helper/downloadHelper';
import { REPORTS_EVENTS } from '../../../../../helper/AnalyticsHelper/events'; import { REPORTS_EVENTS } from '../../../../../helper/AnalyticsHelper/events';
@@ -72,9 +39,11 @@ const REPORTS_KEYS = {
RESOLUTION_TIME: 'avg_resolution_time', RESOLUTION_TIME: 'avg_resolution_time',
RESOLUTION_COUNT: 'resolutions_count', RESOLUTION_COUNT: 'resolutions_count',
}; };
export default { export default {
components: { components: {
ReportFilters, ReportFilters,
ReportContainer,
}, },
mixins: [reportMixin], mixins: [reportMixin],
props: { props: {
@@ -99,7 +68,6 @@ export default {
return { return {
from: 0, from: 0,
to: 0, to: 0,
currentSelection: 0,
selectedFilter: null, selectedFilter: null,
groupBy: GROUP_BY_FILTER[1], groupBy: GROUP_BY_FILTER[1],
groupByfilterItemsList: this.$t('REPORT.GROUP_BY_DAY_OPTIONS'), groupByfilterItemsList: this.$t('REPORT.GROUP_BY_DAY_OPTIONS'),
@@ -111,115 +79,6 @@ export default {
filterItemsList() { filterItemsList() {
return this.$store.getters[this.getterKey] || []; 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 => {
if (this.groupBy.period === GROUP_BY_FILTER[2].period) {
let week_date = new Date(fromUnixTime(element.timestamp));
const first_day = week_date.getDate() - week_date.getDay();
const last_day = first_day + 6;
const week_first_date = new Date(week_date.setDate(first_day));
const week_last_date = new Date(week_date.setDate(last_day));
return `${format(week_first_date, 'dd/MM/yy')} - ${format(
week_last_date,
'dd/MM/yy'
)}`;
}
if (this.groupBy.period === GROUP_BY_FILTER[3].period) {
return format(fromUnixTime(element.timestamp), 'MMM-yyyy');
}
if (this.groupBy.period === GROUP_BY_FILTER[4].period) {
return format(fromUnixTime(element.timestamp), 'yyyy');
}
return format(fromUnixTime(element.timestamp), 'dd-MMM-yyyy');
});
const datasets = METRIC_CHART[
this.metrics[this.currentSelection].KEY
].datasets.map(dataset => {
switch (dataset.type) {
case 'bar':
return {
...dataset,
yAxisID: 'y-left',
label: this.metrics[this.currentSelection].NAME,
data: this.accountReport.data.map(element => element.value),
};
case 'line':
return {
...dataset,
yAxisID: 'y-right',
label: this.metrics[0].NAME,
data: this.accountReport.data.map(element => element.count),
};
default:
return dataset;
}
});
return {
labels,
datasets,
};
},
chartOptions() {
let tooltips = {};
if (this.isAverageMetricType(this.metrics[this.currentSelection].KEY)) {
tooltips.callbacks = {
label: tooltipItem => {
return this.$t(this.metrics[this.currentSelection].TOOLTIP_TEXT, {
metricValue: formatTime(tooltipItem.yLabel),
conversationCount: this.accountReport.data[tooltipItem.index]
.count,
});
},
};
}
return {
scales: METRIC_CHART[this.metrics[this.currentSelection].KEY].scales,
tooltips: tooltips,
};
},
metrics() {
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',
'RESOLUTION_COUNT',
];
const infoText = {
FIRST_RESPONSE_TIME: this.$t(
`REPORT.METRICS.FIRST_RESPONSE_TIME.INFO_TEXT`
),
RESOLUTION_TIME: this.$t(`REPORT.METRICS.RESOLUTION_TIME.INFO_TEXT`),
};
return reportKeys.map(key => ({
NAME: this.$t(`REPORT.METRICS.${key}.NAME`),
KEY: REPORTS_KEYS[key],
DESC: this.$t(`REPORT.METRICS.${key}.DESC`),
INFO_TEXT: infoText[key],
TOOLTIP_TEXT: `REPORT.METRICS.${key}.TOOLTIP_TEXT`,
}));
},
}, },
mounted() { mounted() {
this.$store.dispatch(this.actionKey); this.$store.dispatch(this.actionKey);
@@ -240,15 +99,28 @@ export default {
} }
}, },
fetchChartData() { fetchChartData() {
const { from, to, groupBy, businessHours } = this; [
this.$store.dispatch('fetchAccountReport', { 'CONVERSATIONS',
metric: this.metrics[this.currentSelection].KEY, 'INCOMING_MESSAGES',
from, 'OUTGOING_MESSAGES',
to, 'FIRST_RESPONSE_TIME',
type: this.type, 'RESOLUTION_TIME',
id: this.selectedFilter.id, 'RESOLUTION_COUNT',
groupBy: groupBy.period, ].forEach(async key => {
businessHours, try {
const { from, to, groupBy, businessHours } = this;
this.$store.dispatch('fetchAccountReport', {
metric: REPORTS_KEYS[key],
from,
to,
type: this.type,
id: this.selectedFilter.id,
groupBy: groupBy.period,
businessHours,
});
} catch {
this.showAlert(this.$t('REPORT.DATA_FETCHING_FAILED'));
}
}); });
}, },
downloadReports() { downloadReports() {
@@ -265,10 +137,6 @@ export default {
this.$store.dispatch(dispatchMethods[type], params); this.$store.dispatch(dispatchMethods[type], params);
} }
}, },
changeSelection(index) {
this.currentSelection = index;
this.fetchChartData();
},
onDateRangeChange({ from, to, groupBy }) { onDateRangeChange({ from, to, groupBy }) {
// do not track filter change on inital load // do not track filter change on inital load
if (this.from !== 0 && this.to !== 0) { if (this.from !== 0 && this.to !== 0) {

View File

@@ -1,4 +1,25 @@
import { formatTime } from '@chatwoot/utils'; export const formatTime = timeInSeconds => {
if (!timeInSeconds) {
return '';
}
if (timeInSeconds < 60) {
return `${timeInSeconds}s`;
}
if (timeInSeconds < 3600) {
const minutes = Math.floor(timeInSeconds / 60);
return `${minutes}m`;
}
if (timeInSeconds < 86400) {
const hours = Math.floor(timeInSeconds / 3600);
return `${hours}h`;
}
const days = Math.floor(timeInSeconds / 86400);
return `${days}d`;
};
export const GROUP_BY_FILTER = { export const GROUP_BY_FILTER = {
1: { id: 1, period: 'day' }, 1: { id: 1, period: 'day' },
@@ -57,21 +78,13 @@ export const DATE_RANGE_OPTIONS = {
id: 'LAST_6_MONTHS', id: 'LAST_6_MONTHS',
translationKey: 'REPORT.DATE_RANGE_OPTIONS.LAST_6_MONTHS', translationKey: 'REPORT.DATE_RANGE_OPTIONS.LAST_6_MONTHS',
offset: 179, offset: 179,
groupByOptions: [ groupByOptions: [GROUP_BY_OPTIONS.WEEK, GROUP_BY_OPTIONS.MONTH],
GROUP_BY_OPTIONS.DAY,
GROUP_BY_OPTIONS.WEEK,
GROUP_BY_OPTIONS.MONTH,
],
}, },
LAST_YEAR: { LAST_YEAR: {
id: 'LAST_YEAR', id: 'LAST_YEAR',
translationKey: 'REPORT.DATE_RANGE_OPTIONS.LAST_YEAR', translationKey: 'REPORT.DATE_RANGE_OPTIONS.LAST_YEAR',
offset: 364, offset: 364,
groupByOptions: [ groupByOptions: [GROUP_BY_OPTIONS.WEEK, GROUP_BY_OPTIONS.MONTH],
GROUP_BY_OPTIONS.DAY,
GROUP_BY_OPTIONS.WEEK,
GROUP_BY_OPTIONS.MONTH,
],
}, },
CUSTOM_DATE_RANGE: { CUSTOM_DATE_RANGE: {
id: 'CUSTOM_DATE_RANGE', id: 'CUSTOM_DATE_RANGE',
@@ -87,7 +100,7 @@ export const DATE_RANGE_OPTIONS = {
}; };
export const CHART_FONT_FAMILY = export const CHART_FONT_FAMILY =
'-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif'; 'PlusJakarta,-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
export const DEFAULT_LINE_CHART = { export const DEFAULT_LINE_CHART = {
type: 'line', type: 'line',
@@ -123,6 +136,12 @@ export const DEFAULT_CHART = {
fontFamily: CHART_FONT_FAMILY, fontFamily: CHART_FONT_FAMILY,
beginAtZero: true, beginAtZero: true,
stepSize: 1, stepSize: 1,
callback: (value, index, values) => {
if (!index || index === values.length - 1) {
return value;
}
return '';
},
}, },
gridLines: { gridLines: {
drawOnChartArea: false, drawOnChartArea: false,
@@ -156,8 +175,11 @@ export const METRIC_CHART = {
position: 'left', position: 'left',
ticks: { ticks: {
fontFamily: CHART_FONT_FAMILY, fontFamily: CHART_FONT_FAMILY,
callback(value) { callback: (value, index, values) => {
return formatTime(value); if (!index || index === values.length - 1) {
return formatTime(value);
}
return '';
}, },
}, },
gridLines: { gridLines: {
@@ -187,8 +209,11 @@ export const METRIC_CHART = {
position: 'left', position: 'left',
ticks: { ticks: {
fontFamily: CHART_FONT_FAMILY, fontFamily: CHART_FONT_FAMILY,
callback(value) { callback: (value, index, values) => {
return formatTime(value); if (!index || index === values.length - 1) {
return formatTime(value);
}
return '';
}, },
}, },
gridLines: { gridLines: {

View File

@@ -11,10 +11,23 @@ import {
const state = { const state = {
fetchingStatus: false, fetchingStatus: false,
reportData: [],
accountReport: { accountReport: {
isFetching: false, isFetching: {
data: [], conversations_count: false,
incoming_messages_count: false,
outgoing_messages_count: false,
avg_first_response_time: false,
avg_resolution_time: false,
resolutions_count: false,
},
data: {
conversations_count: [],
incoming_messages_count: [],
outgoing_messages_count: [],
avg_first_response_time: [],
avg_resolution_time: [],
resolutions_count: [],
},
}, },
accountSummary: { accountSummary: {
avg_first_response_time: 0, avg_first_response_time: 0,
@@ -60,12 +73,22 @@ const getters = {
export const actions = { export const actions = {
fetchAccountReport({ commit }, reportObj) { fetchAccountReport({ commit }, reportObj) {
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, true); const { metric } = reportObj;
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, {
metric,
value: true,
});
Report.getReports(reportObj).then(accountReport => { Report.getReports(reportObj).then(accountReport => {
let { data } = accountReport; let { data } = accountReport;
data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to); data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to);
commit(types.default.SET_ACCOUNT_REPORTS, data); commit(types.default.SET_ACCOUNT_REPORTS, {
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false); metric,
data,
});
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, {
metric,
value: false,
});
}); });
}, },
fetchAccountConversationHeatmap({ commit }, reportObj) { fetchAccountConversationHeatmap({ commit }, reportObj) {
@@ -202,14 +225,14 @@ export const actions = {
}; };
const mutations = { const mutations = {
[types.default.SET_ACCOUNT_REPORTS](_state, accountReport) { [types.default.SET_ACCOUNT_REPORTS](_state, { metric, data }) {
_state.accountReport.data = accountReport; _state.accountReport.data[metric] = data;
}, },
[types.default.SET_HEATMAP_DATA](_state, heatmapData) { [types.default.SET_HEATMAP_DATA](_state, heatmapData) {
_state.overview.accountConversationHeatmap = heatmapData; _state.overview.accountConversationHeatmap = heatmapData;
}, },
[types.default.TOGGLE_ACCOUNT_REPORT_LOADING](_state, flag) { [types.default.TOGGLE_ACCOUNT_REPORT_LOADING](_state, { metric, value }) {
_state.accountReport.isFetching = flag; _state.accountReport.isFetching[metric] = value;
}, },
[types.default.TOGGLE_HEATMAP_LOADING](_state, flag) { [types.default.TOGGLE_HEATMAP_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingAccountConversationsHeatmap = flag; _state.overview.uiFlags.isFetchingAccountConversationsHeatmap = flag;