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;
}
}
.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);
}
.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 {
margin-bottom: var(--space-zero);

View File

@@ -1,16 +1,20 @@
import { Bar } from 'vue-chartjs';
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 = {
responsive: true,
maintainAspectRatio: false,
legend: {
display: false,
labels: {
fontFamily,
},
},
animation: {
duration: 0,
},
datasets: {
bar: {
barPercentage: 1.0,
@@ -46,11 +50,11 @@ export default {
props: {
collection: {
type: Object,
default: () => {},
default: () => ({}),
},
chartOptions: {
type: Object,
default: () => {},
default: () => ({}),
},
},
mounted() {

View File

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

View File

@@ -10,42 +10,36 @@ export default {
calculateTrend() {
return metric_key => {
if (!this.accountSummary.previous[metric_key]) return 0;
const diff =
this.accountSummary[metric_key] -
this.accountSummary.previous[metric_key];
return Math.round(
((this.accountSummary[metric_key] -
this.accountSummary.previous[metric_key]) /
this.accountSummary.previous[metric_key]) *
100
(diff / 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) {
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(metric_key)) {
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() {
return metric_key => {
return ['avg_first_response_time', 'avg_resolution_time'].includes(
metric_key
);
};
isAverageMetricType(key) {
return ['avg_first_response_time', 'avg_resolution_time'].includes(key);
},
},
};

View File

@@ -23,7 +23,7 @@ describe('reportMixin', () => {
mixins: [reportMixin],
};
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(
'3 Min 18 Sec'
);
@@ -36,7 +36,7 @@ describe('reportMixin', () => {
mixins: [reportMixin],
};
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);
});

View File

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

View File

@@ -13,36 +13,7 @@
:show-group-by-filter="true"
@filter-change="onFilterChange"
/>
<div 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"
:collection="collection"
:chart-options="chartOptions"
/>
<span v-else class="empty-state">
{{ $t('REPORT.NO_ENOUGH_DATA') }}
</span>
</div>
</div>
<report-container :group-by="groupBy" />
</div>
</template>
@@ -51,11 +22,11 @@ import { mapGetters } from 'vuex';
import fromUnixTime from 'date-fns/fromUnixTime';
import format from 'date-fns/format';
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 alertMixin from 'shared/mixins/alertMixin';
import { formatTime } from '@chatwoot/utils';
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import ReportContainer from './ReportContainer.vue';
const REPORTS_KEYS = {
CONVERSATIONS: 'conversations_count',
@@ -70,13 +41,13 @@ export default {
name: 'ConversationReports',
components: {
ReportFilterSelector,
ReportContainer,
},
mixins: [reportMixin, alertMixin],
data() {
return {
from: 0,
to: 0,
currentSelection: 0,
groupBy: GROUP_BY_FILTER[1],
businessHours: false,
};
@@ -86,104 +57,6 @@ export default {
accountSummary: 'getAccountSummary',
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: {
fetchAllData() {
@@ -198,14 +71,23 @@ export default {
}
},
fetchChartData() {
[
'CONVERSATIONS',
'INCOMING_MESSAGES',
'OUTGOING_MESSAGES',
'FIRST_RESPONSE_TIME',
'RESOLUTION_TIME',
'RESOLUTION_COUNT',
].forEach(async key => {
try {
this.$store.dispatch('fetchAccountReport', {
metric: this.metrics[this.currentSelection].KEY,
await this.$store.dispatch('fetchAccountReport', {
metric: REPORTS_KEYS[key],
...this.getRequestPayload(),
});
} catch {
this.showAlert(this.$t('REPORT.DATA_FETCHING_FAILED'));
}
});
},
getRequestPayload() {
const { from, to, groupBy, businessHours } = this;
@@ -225,10 +107,6 @@ export default {
)}.csv`;
this.$store.dispatch('downloadAgentReports', { from, to, fileName });
},
changeSelection(index) {
this.currentSelection = index;
this.fetchChartData();
},
onFilterChange({ from, to, groupBy, businessHours }) {
this.from = from;
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,5 +1,6 @@
<template>
<div class="filter-container">
<div class="flex flex-col md:flex-row justify-between mb-4">
<div class="md:grid flex flex-col filter-container gap-3 w-full">
<reports-filters-date-range @on-range-change="onDateRangeChange" />
<woot-date-range-picker
v-if="isDateRangeSelected"
@@ -36,8 +37,9 @@
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') }}
</span>
<span>
@@ -230,10 +232,6 @@ export default {
<style scoped>
.filter-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-gap: var(--space-slab);
margin-bottom: var(--space-normal);
}
</style>

View File

@@ -1,10 +1,11 @@
<template>
<div class="flex-container flex-dir-column medium-flex-dir-row">
<div class="flex flex-col md:flex-row">
<div class="flex items-center w-full flex-col md:flex-row">
<div
v-if="type === 'agent'"
class="small-12 medium-3 pull-right multiselect-wrap--small"
class="md:w-[240px] w-full multiselect-wrap--small"
>
<p>
<p class="text-xs mb-2 font-medium">
{{ $t('AGENT_REPORTS.FILTER_DROPDOWN_LABEL') }}
</p>
<multiselect
@@ -26,7 +27,9 @@
size="22px"
/>
<span class="reports-option__desc">
<span class="reports-option__title">{{ props.option.name }}</span>
<span class="reports-option__title">{{
props.option.name
}}</span>
</span>
</div>
</template>
@@ -45,9 +48,9 @@
</div>
<div
v-else-if="type === 'label'"
class="small-12 medium-3 pull-right multiselect-wrap--small"
class="md:w-[240px] w-full multiselect-wrap--small"
>
<p>
<p class="text-xs mb-2 font-medium">
{{ $t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL') }}
</p>
<multiselect
@@ -92,8 +95,8 @@
</template>
</multiselect>
</div>
<div v-else class="small-12 medium-3 pull-right multiselect-wrap--small">
<p>
<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>
@@ -119,10 +122,8 @@
@input="changeFilterSelection"
/>
</div>
<div
class="small-12 medium-3 pull-right margin-right-1 margin-left-1 multiselect-wrap--small"
>
<p>
<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
@@ -140,7 +141,7 @@
/>
</div>
<div v-if="isDateRangeSelected" class="">
<p>
<p class="text-xs mb-2 font-medium">
{{ $t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER') }}
</p>
<woot-date-range-picker
@@ -153,9 +154,9 @@
</div>
<div
v-if="notLast7Days"
class="small-12 medium-3 pull-right margin-right-1 margin-left-1 multiselect-wrap--small"
class="mx-1 md:w-[240px] w-full multiselect-wrap--small"
>
<p>
<p class="text-xs mb-2 font-medium">
{{ $t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL') }}
</p>
<multiselect
@@ -169,8 +170,9 @@
@input="changeGroupByFilterSelection"
/>
</div>
<div class="small-12 medium-3 business-hours">
<span class="business-hours-text">
</div>
<div class="flex items-center my-2">
<span class="text-sm mx-2 whitespace-nowrap">
{{ $t('REPORT.BUSINESS_HOURS') }}
</span>
<span>
@@ -266,7 +268,7 @@ export default {
1: GROUP_BY_FILTER[2].period,
2: 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];
},

View File

@@ -19,48 +19,15 @@
@group-by-filter-change="onGroupByFilterChange"
@business-hours-toggle="onBusinessHoursToggle"
/>
<div>
<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>
<report-container v-if="filterItemsList.length" :group-by="groupBy" />
</div>
</template>
<script>
import ReportFilters from './ReportFilters';
import fromUnixTime from 'date-fns/fromUnixTime';
import format from 'date-fns/format';
import { GROUP_BY_FILTER, METRIC_CHART } from '../constants';
import ReportContainer from '../ReportContainer.vue';
import { GROUP_BY_FILTER } from '../constants';
import reportMixin from '../../../../../mixins/reportMixin';
import { formatTime } from '@chatwoot/utils';
import { generateFileName } from '../../../../../helper/downloadHelper';
import { REPORTS_EVENTS } from '../../../../../helper/AnalyticsHelper/events';
@@ -72,9 +39,11 @@ const REPORTS_KEYS = {
RESOLUTION_TIME: 'avg_resolution_time',
RESOLUTION_COUNT: 'resolutions_count',
};
export default {
components: {
ReportFilters,
ReportContainer,
},
mixins: [reportMixin],
props: {
@@ -99,7 +68,6 @@ export default {
return {
from: 0,
to: 0,
currentSelection: 0,
selectedFilter: null,
groupBy: GROUP_BY_FILTER[1],
groupByfilterItemsList: this.$t('REPORT.GROUP_BY_DAY_OPTIONS'),
@@ -111,115 +79,6 @@ export default {
filterItemsList() {
return this.$store.getters[this.getterKey] || [];
},
accountSummary() {
return this.$store.getters.getAccountSummary || [];
},
accountReport() {
return this.$store.getters.getAccountReports || [];
},
collection() {
if (this.accountReport.isFetching) {
return {};
}
if (!this.accountReport.data.length) return {};
const labels = this.accountReport.data.map(element => {
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() {
this.$store.dispatch(this.actionKey);
@@ -240,9 +99,18 @@ export default {
}
},
fetchChartData() {
[
'CONVERSATIONS',
'INCOMING_MESSAGES',
'OUTGOING_MESSAGES',
'FIRST_RESPONSE_TIME',
'RESOLUTION_TIME',
'RESOLUTION_COUNT',
].forEach(async key => {
try {
const { from, to, groupBy, businessHours } = this;
this.$store.dispatch('fetchAccountReport', {
metric: this.metrics[this.currentSelection].KEY,
metric: REPORTS_KEYS[key],
from,
to,
type: this.type,
@@ -250,6 +118,10 @@ export default {
groupBy: groupBy.period,
businessHours,
});
} catch {
this.showAlert(this.$t('REPORT.DATA_FETCHING_FAILED'));
}
});
},
downloadReports() {
const { from, to, type, businessHours } = this;
@@ -265,10 +137,6 @@ export default {
this.$store.dispatch(dispatchMethods[type], params);
}
},
changeSelection(index) {
this.currentSelection = index;
this.fetchChartData();
},
onDateRangeChange({ from, to, groupBy }) {
// do not track filter change on inital load
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 = {
1: { id: 1, period: 'day' },
@@ -57,21 +78,13 @@ export const DATE_RANGE_OPTIONS = {
id: 'LAST_6_MONTHS',
translationKey: 'REPORT.DATE_RANGE_OPTIONS.LAST_6_MONTHS',
offset: 179,
groupByOptions: [
GROUP_BY_OPTIONS.DAY,
GROUP_BY_OPTIONS.WEEK,
GROUP_BY_OPTIONS.MONTH,
],
groupByOptions: [GROUP_BY_OPTIONS.WEEK, GROUP_BY_OPTIONS.MONTH],
},
LAST_YEAR: {
id: 'LAST_YEAR',
translationKey: 'REPORT.DATE_RANGE_OPTIONS.LAST_YEAR',
offset: 364,
groupByOptions: [
GROUP_BY_OPTIONS.DAY,
GROUP_BY_OPTIONS.WEEK,
GROUP_BY_OPTIONS.MONTH,
],
groupByOptions: [GROUP_BY_OPTIONS.WEEK, GROUP_BY_OPTIONS.MONTH],
},
CUSTOM_DATE_RANGE: {
id: 'CUSTOM_DATE_RANGE',
@@ -87,7 +100,7 @@ export const DATE_RANGE_OPTIONS = {
};
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 = {
type: 'line',
@@ -123,6 +136,12 @@ export const DEFAULT_CHART = {
fontFamily: CHART_FONT_FAMILY,
beginAtZero: true,
stepSize: 1,
callback: (value, index, values) => {
if (!index || index === values.length - 1) {
return value;
}
return '';
},
},
gridLines: {
drawOnChartArea: false,
@@ -156,8 +175,11 @@ export const METRIC_CHART = {
position: 'left',
ticks: {
fontFamily: CHART_FONT_FAMILY,
callback(value) {
callback: (value, index, values) => {
if (!index || index === values.length - 1) {
return formatTime(value);
}
return '';
},
},
gridLines: {
@@ -187,8 +209,11 @@ export const METRIC_CHART = {
position: 'left',
ticks: {
fontFamily: CHART_FONT_FAMILY,
callback(value) {
callback: (value, index, values) => {
if (!index || index === values.length - 1) {
return formatTime(value);
}
return '';
},
},
gridLines: {

View File

@@ -11,10 +11,23 @@ import {
const state = {
fetchingStatus: false,
reportData: [],
accountReport: {
isFetching: false,
data: [],
isFetching: {
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: {
avg_first_response_time: 0,
@@ -60,12 +73,22 @@ const getters = {
export const actions = {
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 => {
let { data } = accountReport;
data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to);
commit(types.default.SET_ACCOUNT_REPORTS, data);
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
commit(types.default.SET_ACCOUNT_REPORTS, {
metric,
data,
});
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, {
metric,
value: false,
});
});
},
fetchAccountConversationHeatmap({ commit }, reportObj) {
@@ -202,14 +225,14 @@ export const actions = {
};
const mutations = {
[types.default.SET_ACCOUNT_REPORTS](_state, accountReport) {
_state.accountReport.data = accountReport;
[types.default.SET_ACCOUNT_REPORTS](_state, { metric, data }) {
_state.accountReport.data[metric] = data;
},
[types.default.SET_HEATMAP_DATA](_state, heatmapData) {
_state.overview.accountConversationHeatmap = heatmapData;
},
[types.default.TOGGLE_ACCOUNT_REPORT_LOADING](_state, flag) {
_state.accountReport.isFetching = flag;
[types.default.TOGGLE_ACCOUNT_REPORT_LOADING](_state, { metric, value }) {
_state.accountReport.isFetching[metric] = value;
},
[types.default.TOGGLE_HEATMAP_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingAccountConversationsHeatmap = flag;