feat: Consider business hours while generating the reports (#4330)
* feat: Consider business hours while generating the reports
This commit is contained in:
parent
57359be37e
commit
d5536d65f7
19 changed files with 241 additions and 48 deletions
3
Gemfile
3
Gemfile
|
@ -125,6 +125,9 @@ gem 'procore-sift'
|
|||
gem 'email_reply_trimmer'
|
||||
gem 'html2text'
|
||||
|
||||
# to calculate working hours
|
||||
gem 'working_hours'
|
||||
|
||||
group :production, :staging do
|
||||
# we dont want request timing out in development while using byebug
|
||||
gem 'rack-timeout'
|
||||
|
|
|
@ -636,6 +636,9 @@ GEM
|
|||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
wisper (2.0.0)
|
||||
working_hours (1.4.1)
|
||||
activesupport (>= 3.2)
|
||||
tzinfo
|
||||
zeitwerk (2.5.4)
|
||||
|
||||
PLATFORMS
|
||||
|
@ -746,6 +749,7 @@ DEPENDENCIES
|
|||
webpacker (~> 5.x)
|
||||
webpush
|
||||
wisper (= 2.0.0)
|
||||
working_hours
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.0.2p107
|
||||
|
|
|
@ -47,36 +47,36 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
|||
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
|
||||
end
|
||||
|
||||
def current_summary_params
|
||||
def common_params
|
||||
{
|
||||
type: params[:type].to_sym,
|
||||
id: params[:id],
|
||||
since: range[:current][:since],
|
||||
until: range[:current][:until],
|
||||
group_by: params[:group_by]
|
||||
group_by: params[:group_by],
|
||||
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
|
||||
}
|
||||
end
|
||||
|
||||
def current_summary_params
|
||||
common_params.merge({
|
||||
since: range[:current][:since],
|
||||
until: range[:current][:until]
|
||||
})
|
||||
end
|
||||
|
||||
def previous_summary_params
|
||||
{
|
||||
type: params[:type].to_sym,
|
||||
id: params[:id],
|
||||
since: range[:previous][:since],
|
||||
until: range[:previous][:until],
|
||||
group_by: params[:group_by]
|
||||
}
|
||||
common_params.merge({
|
||||
since: range[:previous][:since],
|
||||
until: range[:previous][:until]
|
||||
})
|
||||
end
|
||||
|
||||
def report_params
|
||||
{
|
||||
metric: params[:metric],
|
||||
type: params[:type].to_sym,
|
||||
since: params[:since],
|
||||
until: params[:until],
|
||||
id: params[:id],
|
||||
group_by: params[:group_by],
|
||||
timezone_offset: params[:timezone_offset]
|
||||
}
|
||||
common_params.merge({
|
||||
metric: params[:metric],
|
||||
since: params[:since],
|
||||
until: params[:until],
|
||||
timezone_offset: params[:timezone_offset]
|
||||
})
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
|
|
|
@ -33,17 +33,23 @@ module ReportHelper
|
|||
end
|
||||
|
||||
def avg_first_response_time
|
||||
(get_grouped_values scope.reporting_events.where(name: 'first_response')).average(:value)
|
||||
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response'))
|
||||
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
|
||||
|
||||
grouped_reporting_events.average(:value)
|
||||
end
|
||||
|
||||
def avg_resolution_time
|
||||
(get_grouped_values scope.reporting_events.where(name: 'conversation_resolved')).average(:value)
|
||||
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'conversation_resolved'))
|
||||
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
|
||||
|
||||
grouped_reporting_events.average(:value)
|
||||
end
|
||||
|
||||
def avg_resolution_time_summary
|
||||
avg_rt = scope.reporting_events
|
||||
.where(name: 'conversation_resolved', created_at: range)
|
||||
.average(:value)
|
||||
reporting_events = scope.reporting_events
|
||||
.where(name: 'conversation_resolved', created_at: range)
|
||||
avg_rt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value)
|
||||
|
||||
return 0 if avg_rt.blank?
|
||||
|
||||
|
@ -51,9 +57,9 @@ module ReportHelper
|
|||
end
|
||||
|
||||
def avg_first_response_time_summary
|
||||
avg_frt = scope.reporting_events
|
||||
.where(name: 'first_response', created_at: range)
|
||||
.average(:value)
|
||||
reporting_events = scope.reporting_events
|
||||
.where(name: 'first_response', created_at: range)
|
||||
avg_frt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value)
|
||||
|
||||
return 0 if avg_frt.blank?
|
||||
|
||||
|
|
50
app/helpers/reporting_event_helper.rb
Normal file
50
app/helpers/reporting_event_helper.rb
Normal file
|
@ -0,0 +1,50 @@
|
|||
module ReportingEventHelper
|
||||
def business_hours(inbox, from, to)
|
||||
return 0 unless inbox.working_hours_enabled?
|
||||
|
||||
inbox_working_hours = configure_working_hours(inbox.working_hours)
|
||||
return 0 if inbox_working_hours.blank?
|
||||
|
||||
# Configure working hours
|
||||
WorkingHours::Config.working_hours = inbox_working_hours
|
||||
|
||||
# Configure timezone
|
||||
WorkingHours::Config.time_zone = inbox.timezone
|
||||
|
||||
# Use inbox timezone to change from & to values.
|
||||
from_in_inbox_timezone = from.in_time_zone(inbox.timezone).to_time
|
||||
to_in_inbox_timezone = to.in_time_zone(inbox.timezone).to_time
|
||||
from_in_inbox_timezone.working_time_until(to_in_inbox_timezone)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def configure_working_hours(working_hours)
|
||||
working_hours.each_with_object({}) do |working_hour, object|
|
||||
object[day(working_hour.day_of_week)] = working_hour_range(working_hour) unless working_hour.closed_all_day?
|
||||
end
|
||||
end
|
||||
|
||||
def day(day_of_week)
|
||||
week_days = {
|
||||
0 => :sun,
|
||||
1 => :mon,
|
||||
2 => :tue,
|
||||
3 => :wed,
|
||||
4 => :thu,
|
||||
5 => :fri,
|
||||
6 => :sat
|
||||
}
|
||||
week_days[day_of_week]
|
||||
end
|
||||
|
||||
def working_hour_range(working_hour)
|
||||
{ format_time(working_hour.open_hour, working_hour.open_minutes) => format_time(working_hour.close_hour, working_hour.close_minutes) }
|
||||
end
|
||||
|
||||
def format_time(hour, minute)
|
||||
hour = hour < 10 ? "0#{hour}" : hour
|
||||
minute = minute < 10 ? "0#{minute}" : minute
|
||||
"#{hour}:#{minute}"
|
||||
end
|
||||
end
|
|
@ -8,7 +8,15 @@ class ReportsAPI extends ApiClient {
|
|||
super('reports', { accountScoped: true, apiVersion: 'v2' });
|
||||
}
|
||||
|
||||
getReports(metric, since, until, type = 'account', id, group_by) {
|
||||
getReports(
|
||||
metric,
|
||||
since,
|
||||
until,
|
||||
type = 'account',
|
||||
id,
|
||||
group_by,
|
||||
business_hours
|
||||
) {
|
||||
return axios.get(`${this.url}`, {
|
||||
params: {
|
||||
metric,
|
||||
|
@ -17,12 +25,13 @@ class ReportsAPI extends ApiClient {
|
|||
type,
|
||||
id,
|
||||
group_by,
|
||||
business_hours,
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getSummary(since, until, type = 'account', id, group_by) {
|
||||
getSummary(since, until, type = 'account', id, group_by, business_hours) {
|
||||
return axios.get(`${this.url}/summary`, {
|
||||
params: {
|
||||
since,
|
||||
|
@ -30,6 +39,7 @@ class ReportsAPI extends ApiClient {
|
|||
type,
|
||||
id,
|
||||
group_by,
|
||||
business_hours,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -78,5 +78,10 @@
|
|||
font-size: $font-size-default;
|
||||
color: $color-gray;
|
||||
}
|
||||
|
||||
.business-hours {
|
||||
margin: $space-normal;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,3 +25,21 @@
|
|||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.business-hours {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
margin-bottom: var(--space-normal);
|
||||
margin-left: auto;
|
||||
padding-right: var(--space-normal);
|
||||
}
|
||||
|
||||
.business-hours-text {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.switch {
|
||||
margin-bottom: var(--space-zero);
|
||||
margin-left: var(--space-small);
|
||||
}
|
||||
|
|
|
@ -78,7 +78,8 @@
|
|||
{ "id": 2, "groupBy": "Week" },
|
||||
{ "id": 3, "groupBy": "Month" },
|
||||
{ "id": 4, "groupBy": "Year" }
|
||||
]
|
||||
],
|
||||
"BUSINESS_HOURS": "Business Hours"
|
||||
},
|
||||
"AGENT_REPORTS": {
|
||||
"HEADER": "Agents Overview",
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
:filter-items-list="filterItemsList"
|
||||
@date-range-change="onDateRangeChange"
|
||||
@filter-change="onFilterChange"
|
||||
@business-hours-toggle="onBusinessHoursToggle"
|
||||
/>
|
||||
<div class="row">
|
||||
<woot-report-stats-card
|
||||
|
@ -79,6 +80,7 @@ export default {
|
|||
groupBy: GROUP_BY_FILTER[1],
|
||||
filterItemsList: this.$t('REPORT.GROUP_BY_DAY_OPTIONS'),
|
||||
selectedGroupByFilter: {},
|
||||
businessHours: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -166,21 +168,23 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
fetchAllData() {
|
||||
const { from, to, groupBy } = this;
|
||||
const { from, to, groupBy, businessHours } = this;
|
||||
this.$store.dispatch('fetchAccountSummary', {
|
||||
from,
|
||||
to,
|
||||
groupBy: groupBy.period,
|
||||
businessHours,
|
||||
});
|
||||
this.fetchChartData();
|
||||
},
|
||||
fetchChartData() {
|
||||
const { from, to, groupBy } = this;
|
||||
const { from, to, groupBy, businessHours } = this;
|
||||
this.$store.dispatch('fetchAccountReport', {
|
||||
metric: this.metrics[this.currentSelection].KEY,
|
||||
from,
|
||||
to,
|
||||
groupBy: groupBy.period,
|
||||
businessHours,
|
||||
});
|
||||
},
|
||||
downloadAgentReports() {
|
||||
|
@ -226,6 +230,10 @@ export default {
|
|||
return this.$t('REPORT.GROUP_BY_DAY_OPTIONS');
|
||||
}
|
||||
},
|
||||
onBusinessHoursToggle(value) {
|
||||
this.businessHours = value;
|
||||
this.fetchAllData();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -61,6 +61,12 @@
|
|||
@input="handleAgentsFilterSelection"
|
||||
/>
|
||||
</div>
|
||||
<div class="small-12 medium-3 business-hours">
|
||||
<span class="business-hours-text">{{ $t('REPORT.BUSINESS_HOURS') }}</span>
|
||||
<span>
|
||||
<woot-switch v-model="businessHoursSelected" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
@ -105,6 +111,7 @@ export default {
|
|||
customDateRange: [new Date(), new Date()],
|
||||
currentSelectedFilter: null,
|
||||
selectedAgents: [],
|
||||
businessHoursSelected: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -153,6 +160,9 @@ export default {
|
|||
filterItemsList() {
|
||||
this.currentSelectedFilter = this.selectedGroupByFilter;
|
||||
},
|
||||
businessHoursSelected() {
|
||||
this.$emit('business-hours-toggle', this.businessHoursSelected);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.onDateRangeChange();
|
||||
|
|
|
@ -145,6 +145,12 @@
|
|||
@input="changeGroupByFilterSelection"
|
||||
/>
|
||||
</div>
|
||||
<div class="small-12 medium-3 business-hours">
|
||||
<span class="business-hours-text">{{ $t('REPORT.BUSINESS_HOURS') }}</span>
|
||||
<span>
|
||||
<woot-switch v-model="businessHoursSelected" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
@ -188,6 +194,7 @@ export default {
|
|||
dateRange: this.$t('REPORT.DATE_RANGE'),
|
||||
customDateRange: [new Date(), new Date()],
|
||||
currentSelectedGroupByFilter: null,
|
||||
businessHoursSelected: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -249,6 +256,9 @@ export default {
|
|||
groupByFilterItemsList() {
|
||||
this.currentSelectedGroupByFilter = this.selectedGroupByFilter;
|
||||
},
|
||||
businessHoursSelected() {
|
||||
this.$emit('business-hours-toggle', this.businessHoursSelected);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.onDateRangeChange();
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
@date-range-change="onDateRangeChange"
|
||||
@filter-change="onFilterChange"
|
||||
@group-by-filter-change="onGroupByFilterChange"
|
||||
@business-hours-toggle="onBusinessHoursToggle"
|
||||
/>
|
||||
<div>
|
||||
<div v-if="filterItemsList.length" class="row">
|
||||
|
@ -100,6 +101,7 @@ export default {
|
|||
groupBy: GROUP_BY_FILTER[1],
|
||||
groupByfilterItemsList: this.$t('REPORT.GROUP_BY_DAY_OPTIONS'),
|
||||
selectedGroupByFilter: null,
|
||||
businessHours: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -202,19 +204,20 @@ export default {
|
|||
methods: {
|
||||
fetchAllData() {
|
||||
if (this.selectedFilter) {
|
||||
const { from, to, groupBy } = this;
|
||||
const { from, to, groupBy, businessHours } = this;
|
||||
this.$store.dispatch('fetchAccountSummary', {
|
||||
from,
|
||||
to,
|
||||
type: this.type,
|
||||
id: this.selectedFilter.id,
|
||||
groupBy: groupBy.period,
|
||||
businessHours,
|
||||
});
|
||||
this.fetchChartData();
|
||||
}
|
||||
},
|
||||
fetchChartData() {
|
||||
const { from, to, groupBy } = this;
|
||||
const { from, to, groupBy, businessHours } = this;
|
||||
this.$store.dispatch('fetchAccountReport', {
|
||||
metric: this.metrics[this.currentSelection].KEY,
|
||||
from,
|
||||
|
@ -222,6 +225,7 @@ export default {
|
|||
type: this.type,
|
||||
id: this.selectedFilter.id,
|
||||
groupBy: groupBy.period,
|
||||
businessHours,
|
||||
});
|
||||
},
|
||||
downloadReports() {
|
||||
|
@ -288,6 +292,10 @@ export default {
|
|||
return this.$t('REPORT.GROUP_BY_DAY_OPTIONS');
|
||||
}
|
||||
},
|
||||
onBusinessHoursToggle(value) {
|
||||
this.businessHours = value;
|
||||
this.fetchAllData();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -42,7 +42,8 @@ export const actions = {
|
|||
reportObj.to,
|
||||
reportObj.type,
|
||||
reportObj.id,
|
||||
reportObj.groupBy
|
||||
reportObj.groupBy,
|
||||
reportObj.businessHours
|
||||
).then(accountReport => {
|
||||
let { data } = accountReport;
|
||||
data = data.filter(
|
||||
|
@ -69,7 +70,8 @@ export const actions = {
|
|||
reportObj.to,
|
||||
reportObj.type,
|
||||
reportObj.id,
|
||||
reportObj.groupBy
|
||||
reportObj.groupBy,
|
||||
reportObj.businessHours
|
||||
)
|
||||
.then(accountSummary => {
|
||||
commit(types.default.SET_ACCOUNT_SUMMARY, accountSummary.data);
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
class ReportingEventListener < BaseListener
|
||||
include ReportingEventHelper
|
||||
def conversation_resolved(event)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
time_to_resolve = conversation.updated_at.to_i - conversation.created_at.to_i
|
||||
|
@ -6,10 +7,14 @@ class ReportingEventListener < BaseListener
|
|||
reporting_event = ReportingEvent.new(
|
||||
name: 'conversation_resolved',
|
||||
value: time_to_resolve,
|
||||
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at,
|
||||
conversation.updated_at),
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: conversation.created_at,
|
||||
event_end_time: conversation.updated_at
|
||||
)
|
||||
reporting_event.save
|
||||
end
|
||||
|
@ -22,10 +27,14 @@ class ReportingEventListener < BaseListener
|
|||
reporting_event = ReportingEvent.new(
|
||||
name: 'first_response',
|
||||
value: first_response_time,
|
||||
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at,
|
||||
message.created_at),
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: conversation.created_at,
|
||||
event_end_time: message.created_at
|
||||
)
|
||||
reporting_event.save
|
||||
end
|
||||
|
|
|
@ -2,15 +2,18 @@
|
|||
#
|
||||
# Table name: reporting_events
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# name :string
|
||||
# value :float
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer
|
||||
# conversation_id :integer
|
||||
# inbox_id :integer
|
||||
# user_id :integer
|
||||
# id :bigint not null, primary key
|
||||
# event_end_time :datetime
|
||||
# event_start_time :datetime
|
||||
# name :string
|
||||
# value :float
|
||||
# value_in_business_hours :float
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer
|
||||
# conversation_id :integer
|
||||
# inbox_id :integer
|
||||
# user_id :integer
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
class AddValueInBusinessHoursToReportingEvent < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
change_table :reporting_events, bulk: true do |t|
|
||||
t.float :value_in_business_hours, default: nil
|
||||
t.datetime :event_start_time, default: nil
|
||||
t.datetime :event_end_time, default: nil
|
||||
end
|
||||
end
|
||||
end
|
|
@ -657,6 +657,9 @@ ActiveRecord::Schema.define(version: 2022_04_05_092033) do
|
|||
t.integer "conversation_id"
|
||||
t.datetime "created_at", precision: 6, null: false
|
||||
t.datetime "updated_at", precision: 6, null: false
|
||||
t.float "value_in_business_hours"
|
||||
t.datetime "event_start_time"
|
||||
t.datetime "event_end_time"
|
||||
t.index ["account_id"], name: "index_reporting_events_on_account_id"
|
||||
t.index ["created_at"], name: "index_reporting_events_on_created_at"
|
||||
t.index ["inbox_id"], name: "index_reporting_events_on_inbox_id"
|
||||
|
|
|
@ -17,6 +17,21 @@ describe ReportingEventListener do
|
|||
listener.conversation_resolved(event)
|
||||
expect(account.reporting_events.where(name: 'conversation_resolved').count).to be 1
|
||||
end
|
||||
|
||||
context 'when business hours enabled for inbox' do
|
||||
let(:created_at) { Time.zone.parse('March 20, 2022 00:00') }
|
||||
let(:updated_at) { Time.zone.parse('March 26, 2022 23:59') }
|
||||
let!(:new_inbox) { create(:inbox, working_hours_enabled: true, account: account) }
|
||||
let!(:new_conversation) do
|
||||
create(:conversation, created_at: created_at, updated_at: updated_at, account: account, inbox: new_inbox, assignee: user)
|
||||
end
|
||||
|
||||
it 'creates conversation_resolved event with business hour value' do
|
||||
event = Events::Base.new('conversation.resolved', Time.zone.now, conversation: new_conversation)
|
||||
listener.conversation_resolved(event)
|
||||
expect(account.reporting_events.where(name: 'conversation_resolved')[0]['value_in_business_hours']).to be 144_000.0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#first_reply_created' do
|
||||
|
@ -26,5 +41,24 @@ describe ReportingEventListener do
|
|||
listener.first_reply_created(event)
|
||||
expect(account.reporting_events.where(name: 'first_response').count).to eql previous_count + 1
|
||||
end
|
||||
|
||||
context 'when business hours enabled for inbox' do
|
||||
let(:conversation_created_at) { Time.zone.parse('March 20, 2022 00:00') }
|
||||
let(:message_created_at) { Time.zone.parse('March 26, 2022 23:59') }
|
||||
let!(:new_inbox) { create(:inbox, working_hours_enabled: true, account: account) }
|
||||
let!(:new_conversation) do
|
||||
create(:conversation, created_at: conversation_created_at, account: account, inbox: new_inbox, assignee: user)
|
||||
end
|
||||
let!(:new_message) do
|
||||
create(:message, message_type: 'outgoing', created_at: message_created_at,
|
||||
account: account, inbox: new_inbox, conversation: new_conversation)
|
||||
end
|
||||
|
||||
it 'creates first_response event with business hour value' do
|
||||
event = Events::Base.new('first.reply.created', Time.zone.now, message: new_message)
|
||||
listener.first_reply_created(event)
|
||||
expect(account.reporting_events.where(name: 'first_response')[0]['value_in_business_hours']).to be 144_000.0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue