Merge branch 'develop' into feature/cw-2248

This commit is contained in:
Shivam Mishra
2023-07-20 08:41:03 +05:30
committed by GitHub
38 changed files with 788 additions and 671 deletions
+3 -2
View File
@@ -108,9 +108,9 @@ gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', require: false
gem 'scout_apm', require: false
gem 'sentry-rails', require: false
gem 'sentry-rails', '>= 5.10.0', require: false
gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', require: false
gem 'sentry-sidekiq', '>= 5.10.0', require: false
##-- background job processing --##
gem 'sidekiq'
@@ -213,6 +213,7 @@ group :development, :test do
gem 'bundle-audit', require: false
gem 'byebug', platform: :mri
gem 'climate_control'
gem 'debug', '~> 1.8'
gem 'factory_bot_rails'
gem 'listen'
gem 'mock_redis'
+25 -15
View File
@@ -164,7 +164,7 @@ GEM
coderay (1.1.3)
commonmarker (0.23.9)
concurrent-ruby (1.2.2)
connection_pool (2.4.0)
connection_pool (2.4.1)
crack (0.4.5)
rexml
crass (1.0.6)
@@ -184,6 +184,9 @@ GEM
libddwaf (~> 1.8.2.0.0)
msgpack
debase-ruby_core_source (3.2.0)
debug (1.8.0)
irb (>= 1.5.0)
reline (>= 0.3.1)
declarative (0.0.20)
devise (4.9.2)
bcrypt (~> 3.0)
@@ -366,6 +369,9 @@ GEM
blingfire (>= 0.1.7)
numo-narray
onnxruntime (>= 0.5.1)
io-console (0.6.0)
irb (1.7.2)
reline (>= 0.3.6)
jbuilder (2.11.5)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
@@ -470,14 +476,14 @@ GEM
sidekiq
newrelic_rpm (8.16.0)
nio4r (2.5.9)
nokogiri (1.15.2)
nokogiri (1.15.3)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.15.2-arm64-darwin)
nokogiri (1.15.3-arm64-darwin)
racc (~> 1.4)
nokogiri (1.15.2-x86_64-darwin)
nokogiri (1.15.3-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.15.2-x86_64-linux)
nokogiri (1.15.3-x86_64-linux)
racc (~> 1.4)
numo-narray (0.9.2.1)
oauth (1.1.0)
@@ -568,8 +574,9 @@ GEM
activesupport (= 7.0.5.1)
bundler (>= 1.15.0)
railties (= 7.0.5.1)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
rails-dom-testing (2.1.1)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
@@ -593,6 +600,8 @@ GEM
redis-namespace (1.10.0)
redis (>= 4)
regexp_parser (2.8.0)
reline (0.3.6)
io-console (~> 0.5)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
@@ -683,18 +692,18 @@ GEM
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (5.9.0)
sentry-rails (5.10.0)
railties (>= 5.0)
sentry-ruby (~> 5.9.0)
sentry-ruby (5.9.0)
sentry-ruby (~> 5.10.0)
sentry-ruby (5.10.0)
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.9.0)
sentry-ruby (~> 5.9.0)
sentry-sidekiq (5.10.0)
sentry-ruby (~> 5.10.0)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shoulda-matchers (5.3.0)
activesupport (>= 5.2.0)
sidekiq (7.1.0)
sidekiq (7.1.2)
concurrent-ruby (< 2)
connection_pool (>= 2.3.0)
rack (>= 2.2.4)
@@ -835,6 +844,7 @@ DEPENDENCIES
cypress-on-rails
database_cleaner
ddtrace
debug (~> 1.8)
devise
devise-secure_password!
devise_token_auth
@@ -904,9 +914,9 @@ DEPENDENCIES
scout_apm
scss_lint
seed_dump
sentry-rails
sentry-rails (>= 5.10.0)
sentry-ruby
sentry-sidekiq
sentry-sidekiq (>= 5.10.0)
shoulda-matchers
sidekiq
sidekiq-cron
+15 -1
View File
@@ -30,9 +30,23 @@ burn:
bundle && yarn
run:
@if [ -f ./.overmind.sock ]; then \
echo "Overmind is already running. Use 'make force_run' to start a new instance."; \
else \
overmind start -f Procfile.dev; \
fi
force_run:
rm -f ./.overmind.sock
overmind start -f Procfile.dev
debug:
overmind connect backend
debug_worker:
overmind connect worker
docker:
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
.PHONY: setup db_create db_migrate db_seed db console server burn docker run
.PHONY: setup db_create db_migrate db_seed db console server burn docker run force_run debug debug_worker
+7 -7
View File
@@ -33,18 +33,18 @@ class V2::ReportBuilder
def summary
{
conversations_count: conversations_count.values.sum,
incoming_messages_count: incoming_messages_count.values.sum,
outgoing_messages_count: outgoing_messages_count.values.sum,
conversations_count: conversations.count,
incoming_messages_count: incoming_messages.count,
outgoing_messages_count: outgoing_messages.count,
avg_first_response_time: avg_first_response_time_summary,
avg_resolution_time: avg_resolution_time_summary,
resolutions_count: resolutions_count.values.sum
resolutions_count: resolutions.count
}
end
def conversation_metrics
if params[:type].equal?(:account)
conversations
live_conversations
else
agent_metrics.sort_by { |hash| hash[:metric][:open] }.reverse
end
@@ -89,12 +89,12 @@ class V2::ReportBuilder
email: @user.email,
thumbnail: @user.avatar_url,
availability: account_user.availability_status,
metric: conversations
metric: live_conversations
}
end
end
def conversations
def live_conversations
@open_conversations = scope.conversations.where(account_id: @account.id).open
metric = {
open: @open_conversations.count,
+31 -8
View File
@@ -17,21 +17,36 @@ module ReportHelper
end
def conversations_count
(get_grouped_values scope.conversations.where(account_id: account.id)).count
(get_grouped_values conversations).count
end
def incoming_messages_count
(get_grouped_values scope.messages.where(account_id: account.id).incoming.unscope(:order)).count
(get_grouped_values incoming_messages).count
end
def outgoing_messages_count
(get_grouped_values scope.messages.where(account_id: account.id).outgoing.unscope(:order)).count
(get_grouped_values outgoing_messages).count
end
def resolutions_count
object_scope = scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_resolved,
conversations: { status: :resolved }).distinct
(get_grouped_values object_scope).count
(get_grouped_values resolutions).count
end
def conversations
scope.conversations.where(account_id: account.id, created_at: range)
end
def incoming_messages
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
end
def outgoing_messages
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
end
def resolutions
scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_resolved,
conversations: { status: :resolved }, created_at: range).distinct
end
def avg_first_response_time
@@ -51,7 +66,11 @@ module ReportHelper
def avg_resolution_time_summary
reporting_events = scope.reporting_events
.where(name: 'conversation_resolved', account_id: account.id, created_at: range)
avg_rt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value)
avg_rt = if params[:business_hours].present?
reporting_events.average(:value_in_business_hours)
else
reporting_events.average(:value)
end
return 0 if avg_rt.blank?
@@ -61,7 +80,11 @@ module ReportHelper
def avg_first_response_time_summary
reporting_events = scope.reporting_events
.where(name: 'first_response', account_id: account.id, created_at: range)
avg_frt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value)
avg_frt = if params[:business_hours].present?
reporting_events.average(:value_in_business_hours)
else
reporting_events.average(:value)
end
return 0 if avg_frt.blank?
@@ -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() {
@@ -30,6 +30,7 @@ const translationKeys = {
'accountuser:create': `AUDIT_LOGS.ACCOUNT_USER.ADD`,
'accountuser:update:self': `AUDIT_LOGS.ACCOUNT_USER.EDIT.SELF`,
'accountuser:update:other': `AUDIT_LOGS.ACCOUNT_USER.EDIT.OTHER`,
'account:update': `AUDIT_LOGS.ACCOUNT.EDIT`,
};
function extractAttrChange(attrChange) {
@@ -55,6 +55,9 @@
"ADD": "%{agentName} created a new macro (#%{id})",
"EDIT": "%{agentName} updated a macro (#%{id})",
"DELETE": "%{agentName} deleted a macro (#%{id})"
},
"ACCOUNT": {
"EDIT": "%{agentName} updated the account configuration (#%{id})"
}
}
}
@@ -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" }
+27 -33
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
);
};
},
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
(diff / this.accountSummary.previous[metric_key]) * 100
);
};
},
},
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);
},
},
};
@@ -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() {
try {
this.$store.dispatch('fetchAccountReport', {
metric: this.metrics[this.currentSelection].KEY,
...this.getRequestPayload(),
});
} catch {
this.showAlert(this.$t('REPORT.DATA_FETCHING_FAILED'));
}
[
'CONVERSATIONS',
'INCOMING_MESSAGES',
'OUTGOING_MESSAGES',
'FIRST_RESPONSE_TIME',
'RESOLUTION_TIME',
'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() {
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,43 +1,45 @@
<template>
<div class="filter-container">
<reports-filters-date-range @on-range-change="onDateRangeChange" />
<woot-date-range-picker
v-if="isDateRangeSelected"
show-range
class="no-margin auto-width"
:value="customDateRange"
:confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')"
:placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')"
@change="onCustomDateRangeChange"
/>
<reports-filters-date-group-by
v-if="showGroupByFilter && isGroupByPossible"
:valid-group-options="validGroupOptions"
:selected-option="selectedGroupByFilter"
@on-grouping-change="onGroupingChange"
/>
<reports-filters-agents
v-if="showAgentsFilter"
@agents-filter-selection="handleAgentsFilterSelection"
/>
<reports-filters-labels
v-if="showLabelsFilter"
@labels-filter-selection="handleLabelsFilterSelection"
/>
<reports-filters-teams
v-if="showTeamFilter"
@team-filter-selection="handleTeamFilterSelection"
/>
<reports-filters-inboxes
v-if="showInboxFilter"
@inbox-filter-selection="handleInboxFilterSelection"
/>
<reports-filters-ratings
v-if="showRatingFilter"
@rating-filter-selection="handleRatingFilterSelection"
/>
<div v-if="showBusinessHoursSwitch" class="business-hours">
<span class="business-hours-text ">
<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"
show-range
class="no-margin auto-width"
:value="customDateRange"
:confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')"
:placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')"
@change="onCustomDateRangeChange"
/>
<reports-filters-date-group-by
v-if="showGroupByFilter && isGroupByPossible"
:valid-group-options="validGroupOptions"
:selected-option="selectedGroupByFilter"
@on-grouping-change="onGroupingChange"
/>
<reports-filters-agents
v-if="showAgentsFilter"
@agents-filter-selection="handleAgentsFilterSelection"
/>
<reports-filters-labels
v-if="showLabelsFilter"
@labels-filter-selection="handleLabelsFilterSelection"
/>
<reports-filters-teams
v-if="showTeamFilter"
@team-filter-selection="handleTeamFilterSelection"
/>
<reports-filters-inboxes
v-if="showInboxFilter"
@inbox-filter-selection="handleInboxFilterSelection"
/>
<reports-filters-ratings
v-if="showRatingFilter"
@rating-filter-selection="handleRatingFilterSelection"
/>
</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,176 +1,178 @@
<template>
<div class="flex-container flex-dir-column medium-flex-dir-row">
<div
v-if="type === 'agent'"
class="small-12 medium-3 pull-right 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"
<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="md:w-[240px] w-full multiselect-wrap--small"
>
<template slot="singleLabel" slot-scope="props">
<div class="reports-option__wrap">
<thumbnail
:src="props.option.thumbnail"
:status="props.option.availability_status"
:username="props.option.name"
size="22px"
/>
<span class="reports-option__desc">
<span class="reports-option__title">{{ props.option.name }}</span>
</span>
</div>
</template>
<template slot="option" slot-scope="props">
<div class="reports-option__wrap">
<thumbnail
:src="props.option.thumbnail"
:status="props.option.availability_status"
: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="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 }}
<p class="text-xs mb-2 font-medium">
{{ $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">
<div class="reports-option__wrap">
<thumbnail
:src="props.option.thumbnail"
:status="props.option.availability_status"
:username="props.option.name"
size="22px"
/>
<span class="reports-option__desc">
<span class="reports-option__title">{{
props.option.name
}}</span>
</span>
</span>
</div>
</template>
<template slot="option" slot-scope="props">
<div class="reports-option__wrap">
<div
:style="{ backgroundColor: props.option.color }"
class="
</div>
</template>
<template slot="option" slot-scope="props">
<div class="reports-option__wrap">
<thumbnail
:src="props.option.thumbnail"
:status="props.option.availability_status"
: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__item
reports-option__label--swatch
"
/>
<span class="reports-option__desc">
<span class="reports-option__title">
{{ props.option.title }}
/>
<span class="reports-option__desc">
<span class="reports-option__title">
{{ props.option.title }}
</span>
</span>
</span>
</div>
</template>
</multiselect>
</div>
</template>
</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 v-else class="small-12 medium-3 pull-right multiselect-wrap--small">
<p>
<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">
<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,15 +99,28 @@ export default {
}
},
fetchChartData() {
const { from, to, groupBy, businessHours } = this;
this.$store.dispatch('fetchAccountReport', {
metric: this.metrics[this.currentSelection].KEY,
from,
to,
type: this.type,
id: this.selectedFilter.id,
groupBy: groupBy.period,
businessHours,
[
'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: 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() {
@@ -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) {
return formatTime(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) {
return formatTime(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;
+20
View File
@@ -43,6 +43,26 @@ class ReportingEventListener < BaseListener
reporting_event.save!
end
def reply_created(event)
message = extract_message_and_account(event)[0]
conversation = message.conversation
waiting_since = event.data[:waiting_since]
reply_time = message.created_at.to_i - waiting_since.to_i
reporting_event = ReportingEvent.new(
name: 'reply_time',
value: reply_time,
value_in_business_hours: business_hours(conversation.inbox, waiting_since, message.created_at),
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id,
conversation_id: conversation.id,
event_start_time: waiting_since,
event_end_time: message.created_at
)
reporting_event.save!
end
def conversation_bot_handoff(event)
conversation = extract_conversation_and_account(event)[0]
+5 -1
View File
@@ -1,3 +1,5 @@
# rubocop:disable Layout/LineLength
# == Schema Information
#
# Table name: contacts
@@ -19,12 +21,14 @@
# index_contacts_on_account_id (account_id)
# index_contacts_on_lower_email_account_id (lower((email)::text), account_id)
# index_contacts_on_name_email_phone_number_identifier (name,email,phone_number,identifier) USING gin
# index_contacts_on_nonempty_fields (account_id,email,phone_number,identifier) WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text)) # rubocop:disable Layout/LineLength
# index_contacts_on_nonempty_fields (account_id,email,phone_number,identifier) WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))
# index_contacts_on_phone_number_and_account_id (phone_number,account_id)
# uniq_email_per_account_contact (email,account_id) UNIQUE
# uniq_identifier_per_account_contact (identifier,account_id) UNIQUE
#
# rubocop:enable Layout/LineLength
class Contact < ApplicationRecord
include Avatarable
include AvailabilityStatusable
+6 -2
View File
@@ -260,8 +260,12 @@ class Message < ApplicationRecord
end
def update_waiting_since
conversation.update(waiting_since: nil) if human_response? && !private && conversation.waiting_since.present?
if human_response? && !private && conversation.waiting_since.present?
Rails.configuration.dispatcher.dispatch(
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
)
conversation.update(waiting_since: nil)
end
conversation.update(waiting_since: Time.now.utc) if incoming? && conversation.waiting_since.blank?
end
+1 -1
View File
@@ -587,8 +587,8 @@ ActiveRecord::Schema[7.0].define(version: 2023_07_17_075819) do
t.jsonb "auto_assignment_config", default: {}
t.boolean "lock_to_single_conversation", default: false, null: false
t.bigint "portal_id"
t.string "business_name"
t.integer "sender_name_type", default: 0, null: false
t.string "business_name"
t.index ["account_id"], name: "index_inboxes_on_account_id"
t.index ["channel_id", "channel_type"], name: "index_inboxes_on_channel_id_and_channel_type"
t.index ["portal_id"], name: "index_inboxes_on_portal_id"
@@ -1,5 +1,5 @@
class Enterprise::SentimentAnalysisJob < ApplicationJob
queue_as :default
queue_as :low
def perform(message)
return if message.account.locale != 'en'
@@ -27,8 +27,11 @@ class Enterprise::SentimentAnalysisJob < ApplicationJob
# Model initializes OnnxRuntime::Model, with given file for inference session and to create the tensor
def model
model_path = ENV.fetch('SENTIMENT_FILE_PATH', nil)
Informers::SentimentAnalysis.new(model_path) if model_path.present?
model_file = save_and_open_sentiment_file
return if File.empty?(model_file)
Informers::SentimentAnalysis.new(model_file)
end
def label_val(sentiment)
@@ -38,4 +41,18 @@ class Enterprise::SentimentAnalysisJob < ApplicationJob
def valid_incoming_message?(message)
!message.incoming? || message.private?
end
# returns the sentiment file from vendor folder else download it to the path from AWS-S3
def save_and_open_sentiment_file
model_path = ENV.fetch('SENTIMENT_FILE_PATH', nil)
sentiment_file = Rails.root.join('vendor/db/sentiment-analysis.onnx')
return sentiment_file if File.exist?(sentiment_file)
source_file = Down.download(model_path) # Download file from AWS-S3
File.rename(source_file, sentiment_file) # Change the file path
sentiment_file
end
end
@@ -2,6 +2,7 @@ module Enterprise::Audit::Account
extend ActiveSupport::Concern
included do
audited except: :updated_at, on: [:update]
has_associated_audits
end
end
@@ -5,7 +5,11 @@ class Enterprise::AuditLog < Audited::Audit
def log_additional_information
# rubocop:disable Rails/SkipsModelValidations
update_columns(username: user&.email)
if auditable_type == 'Account' && auditable_id.present?
update_columns(associated_type: auditable_type, associated_id: auditable_id, username: user&.email)
else
update_columns(username: user&.email)
end
# rubocop:enable Rails/SkipsModelValidations
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
module Enterprise::Message
def update_message_sentiments
::Enterprise::SentimentAnalysisJob.perform_later(self)
::Enterprise::SentimentAnalysisJob.perform_later(self) if ENV.fetch('SENTIMENT_FILE_PATH', nil)
end
end
+1
View File
@@ -33,6 +33,7 @@ module Events::Types
# message events
MESSAGE_CREATED = 'message.created'
FIRST_REPLY_CREATED = 'first.reply.created'
REPLY_CREATED = 'reply.created'
MESSAGE_UPDATED = 'message.updated'
# contact events
+6 -2
View File
@@ -6,6 +6,8 @@ describe V2::ReportBuilder do
let_it_be(:label_1) { create(:label, title: 'Label_1', account: account) }
let_it_be(:label_2) { create(:label, title: 'Label_2', account: account) }
# Update this spec to use travel_to
# This spec breaks in certain timezone
describe '#timeseries' do
before_all do
user = create(:user, account: account)
@@ -156,13 +158,14 @@ describe V2::ReportBuilder do
it 'returns argument error for incorrect group by' do
params = {
type: :account,
metric: 'avg_first_response_time',
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.end_of_day.to_time.to_i.to_s,
group_by: 'test'.to_s
}
builder = described_class.new(account, params)
expect { builder.summary }.to raise_error(ArgumentError)
expect { builder.timeseries }.to raise_error(ArgumentError)
end
end
@@ -296,6 +299,7 @@ describe V2::ReportBuilder do
it 'returns argument error for incorrect group by' do
params = {
metric: 'avg_first_response_time',
type: :label,
id: label_2.id,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
@@ -304,7 +308,7 @@ describe V2::ReportBuilder do
}
builder = described_class.new(account, params)
expect { builder.summary }.to raise_error(ArgumentError)
expect { builder.timeseries }.to raise_error(ArgumentError)
end
end
end
@@ -47,13 +47,14 @@ RSpec.describe 'Enterprise Audit API', type: :request do
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response['audit_logs'][0]['auditable_type']).to eql('Inbox')
expect(json_response['audit_logs'][0]['action']).to eql('create')
expect(json_response['audit_logs'][0]['audited_changes']['name']).to eql(inbox.name)
expect(json_response['audit_logs'][0]['associated_id']).to eql(account.id)
expect(json_response['audit_logs'][1]['auditable_type']).to eql('Inbox')
expect(json_response['audit_logs'][1]['action']).to eql('create')
expect(json_response['audit_logs'][1]['audited_changes']['name']).to eql(inbox.name)
expect(json_response['audit_logs'][1]['associated_id']).to eql(account.id)
expect(json_response['current_page']).to be(1)
# contains audit log for account user as well
expect(json_response['total_entries']).to be(2)
# contains audit logs for account update(enable audit logs)
expect(json_response['total_entries']).to be(3)
end
end
end
@@ -6,7 +6,7 @@ RSpec.describe Enterprise::SentimentAnalysisJob do
let(:message) { build(:message, content_type: nil, account: account) }
context 'when update the message sentiments' do
let(:model_path) { 'sentiment-analysis.onnx' }
let(:model_path) { Rails.root.join('vendor/db/sentiment-analysis.onnx') }
let(:model) { double }
before do
@@ -50,6 +50,41 @@ RSpec.describe Enterprise::SentimentAnalysisJob do
end
end
context 'with download sentiment files' do
let(:model_path) { nil }
let(:model) { double }
before do
allow(Informers::SentimentAnalysis).to receive(:new).with(model_path).and_return(model)
allow(model).to receive(:predict).and_return({ label: 'positive', score: '0.6' })
end
it 'fetch saved file in the server' do
with_modified_env SENTIMENT_FILE_PATH: 'sentiment-analysis.onnx' do
message.update(message_type: :incoming, content: 'I did not like your product')
described_class.new(message).save_and_open_sentiment_file
sentiment_file = Rails.root.join('vendor/db/sentiment-analysis.onnx')
expect(File).to exist(sentiment_file)
end
end
it 'fetch file from the storage' do
with_modified_env SENTIMENT_FILE_PATH: 'sentiment-analysis.onnx' do
message.update(message_type: :incoming, content: 'I did not like your product')
allow(File).to receive(:exist?).and_return(false)
allow(Down).to receive(:download).and_return('./sentiment-analysis.onnx')
allow(File).to receive(:rename).and_return(100)
described_class.new(message).save_and_open_sentiment_file
sentiment_file = Rails.root.join('vendor/db/sentiment-analysis.onnx')
expect(sentiment_file).to be_present
end
end
end
context 'when does not update the message sentiments' do
it 'with outgoing message' do
message.update(message_type: :outgoing)
+5
View File
@@ -33,6 +33,11 @@ RSpec.describe Account do
# checking whether associated_audits method is present
expect(account.associated_audits.present?).to be false
end
it 'creates audit logs when account is updated' do
account.update(name: 'New Name')
expect(Audited::Audit.where(auditable_type: 'Account', action: 'update').count).to eq 1
end
end
it 'returns max limits from global config when enterprise version' do
+5 -3
View File
@@ -8,11 +8,13 @@ RSpec.describe Message do
let(:message) { build(:message, message_type: :incoming, content_type: nil, account: create(:account)) }
it 'calls SentimentAnalysisJob' do
allow(Enterprise::SentimentAnalysisJob).to receive(:perform_later).and_return(:perform_later).with(message)
with_modified_env SENTIMENT_FILE_PATH: 'sentiment-analysis.onnx' do
allow(Enterprise::SentimentAnalysisJob).to receive(:perform_later).and_return(:perform_later).with(message)
message.save!
message.save!
expect(Enterprise::SentimentAnalysisJob).to have_received(:perform_later)
expect(Enterprise::SentimentAnalysisJob).to have_received(:perform_later)
end
end
end
end
@@ -34,6 +34,17 @@ describe ReportingEventListener do
end
end
describe '#reply_created' do
it 'creates reply created event' do
event = Events::Base.new('reply.created', Time.zone.now, waiting_since: 2.hours.ago, message: message)
listener.reply_created(event)
events = account.reporting_events.where(name: 'reply_time', conversation_id: message.conversation_id)
expect(events.length).to be 1
expect(events.first.value).to eq 7200
end
end
describe '#first_reply_created' do
it 'creates first_response event' do
previous_count = account.reporting_events.where(name: 'first_response').count
Binary file not shown.