Merge branch 'develop' into feature/cw-2248
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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>
|
||||
+49
@@ -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>
|
||||
+41
-43
@@ -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>
|
||||
|
||||
+167
-165
@@ -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];
|
||||
},
|
||||
|
||||
+27
-159
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Reference in New Issue
Block a user