mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-03 04:27:53 +00:00
feat: Update reports UI to make it better (#7544)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user