Compare commits

...
Author SHA1 Message Date
Pranav 924d3ef2df Merge branch 'develop' into deploy-apr-15 2024-07-10 20:29:13 -07:00
Pranav 4ef06530e5 Merge branch 'develop' into deploy-apr-15 2024-07-10 20:28:53 -07:00
Pranav 3f10e89dba Merge branch 'develop' into deploy-apr-15 2024-07-08 19:47:10 -07:00
Pranav 184325dcae Remove channel auth error 2024-05-23 11:18:19 -07:00
Pranav ce2a4f5c34 Fix minor issues 2024-05-22 21:01:26 -07:00
Pranav 7c2f4ec334 Merge branch 'develop' into deploy-apr-15 2024-05-22 18:18:55 -07:00
Pranav a69894e0fc Reset data 2024-05-15 20:52:08 -07:00
Pranav 6f36a02519 Fix missing short summary 2024-05-15 20:45:11 -07:00
Pranav 54e697ec51 Fix report average 2024-05-15 20:43:06 -07:00
Pranav ee044e8602 Fix timezone issues 2024-05-15 20:42:55 -07:00
Pranav 3b7421221c new_report -> conversation_report 2024-05-15 20:42:43 -07:00
Pranav bc9327c09f Revert changes unrelated to performance 2024-05-15 20:42:32 -07:00
Pranav ac71dacc61 Update records 2024-05-15 20:41:44 -07:00
Pranav 072c607062 Update 2024-05-15 20:41:08 -07:00
Pranav 4b67066027 Add range filter 2024-05-15 20:38:01 -07:00
Pranav 08834b6016 Update report controller to use the new reports builder 2024-05-15 20:37:54 -07:00
PranavandPranav cbdc5b1ab0 chore: Temporarily disable the disconnect logic for inboxes (#9321)
* chore: Temporarily disable the disconnect logic for inboxes

* Remove unused spec
2024-05-09 19:01:49 -07:00
Pranav 25c6adbd07 Update the duplicate count event 2024-05-09 19:00:23 -07:00
Pranav d552b41fac fix: Terminate IMAP connection after the job is complete (#9225) 2024-04-15 17:43:18 -07:00
Pranav a315321c4f Fix the issues with dropdown 2024-04-15 17:42:12 -07:00
Pranav 2e768e2042 inbox view 2024-02-13 19:12:56 -08:00
Pranav bd481ad314 Update title 2024-02-13 18:39:59 -08:00
Pranav b90737ee5d capitalize 2024-02-13 18:36:50 -08:00
Pranav 6e57e76358 update copy 2024-02-13 18:31:39 -08:00
Pranav 75998945a5 Add agent reports 2024-02-13 18:31:07 -08:00
Pranav cc80e4122a update team reports 2024-02-13 18:16:52 -08:00
Pranav 8aa0a347d6 commit 2 2024-02-13 17:32:57 -08:00
Pranav 2a34ceb66b Commit 2024-02-13 17:32:31 -08:00
Pranav db30a05b19 Update heatmap report 2024-02-13 15:23:46 -08:00
Pranav 73fcb91ca3 Update filter properly 2024-02-13 14:49:49 -08:00
Pranav bcd5633d2a Filter for live conversations 2024-02-13 14:36:04 -08:00
Pranav 1a272e7a18 Remove CSV Safe 2024-02-13 12:32:02 -08:00
Pranav 7d4374a3bf Added a section for group by teams 2024-02-13 12:26:48 -08:00
Pranav 2617d9b2b7 Merge branch 'develop' into report-rewrite 2024-02-13 10:13:10 -08:00
Pranav 375d216d12 Re-write live-conversations API 2024-02-10 18:41:11 -08:00
37 changed files with 1250 additions and 203 deletions
+16 -56
View File
@@ -148,7 +148,7 @@ GEM
barnes (0.0.9) barnes (0.0.9)
multi_json (~> 1) multi_json (~> 1)
statsd-ruby (~> 1.1) statsd-ruby (~> 1.1)
base64 (0.2.0) base64 (0.1.1)
bcrypt (3.1.20) bcrypt (3.1.20)
bigdecimal (3.1.8) bigdecimal (3.1.8)
bindex (0.8.1) bindex (0.8.1)
@@ -242,8 +242,9 @@ GEM
railties (>= 5.0.0) railties (>= 5.0.0)
faker (3.2.0) faker (3.2.0)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (2.9.0) faraday (2.7.4)
faraday-net_http (>= 2.0, < 3.2) faraday-net_http (>= 2.0, < 3.1)
ruby2_keywords (>= 0.0.4)
faraday-follow_redirects (0.3.0) faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3) faraday (>= 1, < 3)
faraday-mashify (0.1.1) faraday-mashify (0.1.1)
@@ -251,8 +252,7 @@ GEM
hashie hashie
faraday-multipart (1.0.4) faraday-multipart (1.0.4)
multipart-post (~> 2) multipart-post (~> 2)
faraday-net_http (3.1.0) faraday-net_http (3.0.2)
net-http
faraday-net_http_persistent (2.1.0) faraday-net_http_persistent (2.1.0)
faraday (~> 2.5) faraday (~> 2.5)
net-http-persistent (~> 4.0) net-http-persistent (~> 4.0)
@@ -321,10 +321,7 @@ GEM
google-cloud-translate-v3 (0.6.0) google-cloud-translate-v3 (0.6.0)
gapic-common (>= 0.17.1, < 2.a) gapic-common (>= 0.17.1, < 2.a)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-protobuf (3.25.3)
google-protobuf (3.25.3-arm64-darwin) google-protobuf (3.25.3-arm64-darwin)
google-protobuf (3.25.3-x86_64-darwin)
google-protobuf (3.25.3-x86_64-linux)
googleapis-common-protos (1.4.0) googleapis-common-protos (1.4.0)
google-protobuf (~> 3.14) google-protobuf (~> 3.14)
googleapis-common-protos-types (~> 1.2) googleapis-common-protos-types (~> 1.2)
@@ -340,16 +337,7 @@ GEM
signet (>= 0.16, < 2.a) signet (>= 0.16, < 2.a)
groupdate (6.2.1) groupdate (6.2.1)
activesupport (>= 5.2) activesupport (>= 5.2)
grpc (1.62.0) grpc (1.63.0-arm64-darwin)
google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0)
grpc (1.62.0-arm64-darwin)
google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0)
grpc (1.62.0-x86_64-darwin)
google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0)
grpc (1.62.0-x86_64-linux)
google-protobuf (~> 3.25) google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
haikunator (1.1.1) haikunator (1.1.1)
@@ -373,7 +361,7 @@ GEM
mini_mime (>= 1.0.0) mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2) multi_xml (>= 0.5.2)
httpclient (2.8.3) httpclient (2.8.3)
i18n (1.14.5) i18n (1.14.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.12.2) image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5) mini_magick (>= 4.9.5, < 5)
@@ -397,8 +385,7 @@ GEM
hana (~> 1.3) hana (~> 1.3)
regexp_parser (~> 2.0) regexp_parser (~> 2.0)
uri_template (~> 0.7) uri_template (~> 0.7)
jwt (2.8.1) jwt (2.7.0)
base64
kaminari (1.2.2) kaminari (1.2.2)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2) kaminari-actionview (= 1.2.2)
@@ -422,15 +409,8 @@ GEM
letter_opener (1.8.1) letter_opener (1.8.1)
launchy (>= 2.2, < 3) launchy (>= 2.2, < 3)
libdatadog (7.0.0.1.0) libdatadog (7.0.0.1.0)
libdatadog (7.0.0.1.0-x86_64-linux)
libddwaf (1.14.0.0.0)
ffi (~> 1.0)
libddwaf (1.14.0.0.0-arm64-darwin) libddwaf (1.14.0.0.0-arm64-darwin)
ffi (~> 1.0) ffi (~> 1.0)
libddwaf (1.14.0.0.0-x86_64-darwin)
ffi (~> 1.0)
libddwaf (1.14.0.0.0-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.28.0) line-bot-api (1.28.0)
liquid (5.4.0) liquid (5.4.0)
listen (3.8.0) listen (3.8.0)
@@ -459,13 +439,12 @@ GEM
meta_request (0.8.2) meta_request (0.8.2)
rack-contrib (>= 1.1, < 3) rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 8) railties (>= 3.0.0, < 8)
method_source (1.1.0) method_source (1.0.0)
mime-types (3.4.1) mime-types (3.4.1)
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
mime-types-data (3.2023.0218.1) mime-types-data (3.2023.0218.1)
mini_magick (4.12.0) mini_magick (4.12.0)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.7)
minitest (5.24.1) minitest (5.24.1)
mock_redis (0.36.0) mock_redis (0.36.0)
ruby2_keywords ruby2_keywords
@@ -475,8 +454,6 @@ GEM
multipart-post (2.3.0) multipart-post (2.3.0)
neighbor (0.2.3) neighbor (0.2.3)
activerecord (>= 5.2) activerecord (>= 5.2)
net-http (0.4.1)
uri
net-http-persistent (4.0.2) net-http-persistent (4.0.2)
connection_pool (~> 2.2) connection_pool (~> 2.2)
net-imap (0.4.12) net-imap (0.4.12)
@@ -495,15 +472,8 @@ GEM
newrelic_rpm (9.6.0) newrelic_rpm (9.6.0)
base64 base64
nio4r (2.7.3) nio4r (2.7.3)
nokogiri (1.16.6)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.16.6-arm64-darwin) nokogiri (1.16.6-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.6-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.6-x86_64-linux)
racc (~> 1.4)
oauth (1.1.0) oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1) oauth-tty (~> 1.0, >= 1.0.1)
snaky_hash (~> 2.0) snaky_hash (~> 2.0)
@@ -566,8 +536,7 @@ GEM
rack (>= 2.0.0) rack (>= 2.0.0)
rack-mini-profiler (3.2.0) rack-mini-profiler (3.2.0)
rack (>= 1.2.0) rack (>= 1.2.0)
rack-protection (3.2.0) rack-protection (3.1.0)
base64 (>= 0.1.0)
rack (~> 2.2, >= 2.2.4) rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.6) rack-proxy (0.7.6)
rack rack
@@ -603,7 +572,7 @@ GEM
thor (~> 1.0) thor (~> 1.0)
zeitwerk (~> 2.5) zeitwerk (~> 2.5)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.2.1) rake (13.1.0)
rb-fsevent (0.11.2) rb-fsevent (0.11.2)
rb-inotify (0.10.1) rb-inotify (0.10.1)
ffi (~> 1.0) ffi (~> 1.0)
@@ -633,8 +602,8 @@ GEM
retriable (3.1.2) retriable (3.1.2)
reverse_markdown (2.1.1) reverse_markdown (2.1.1)
nokogiri nokogiri
rexml (3.2.8) rexml (3.3.1)
strscan (>= 3.0.9) strscan
rspec-core (3.13.0) rspec-core (3.13.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-expectations (3.13.1) rspec-expectations (3.13.1)
@@ -768,7 +737,7 @@ GEM
strscan (3.1.0) strscan (3.1.0)
telephone_number (1.4.20) telephone_number (1.4.20)
test-prof (1.2.1) test-prof (1.2.1)
thor (1.3.1) thor (1.3.0)
tilt (2.3.0) tilt (2.3.0)
time_diff (0.3.0) time_diff (0.3.0)
activesupport activesupport
@@ -793,12 +762,11 @@ GEM
unf_ext (0.0.8.2) unf_ext (0.0.8.2)
unicode-display_width (2.4.2) unicode-display_width (2.4.2)
uniform_notifier (1.16.0) uniform_notifier (1.16.0)
uri (0.13.0)
uri_template (0.7.0) uri_template (0.7.0)
valid_email2 (4.0.6) valid_email2 (4.0.6)
activemodel (>= 3.2) activemodel (>= 3.2)
mail (~> 2.5) mail (~> 2.5)
version_gem (1.1.4) version_gem (1.1.3)
warden (1.2.9) warden (1.2.9)
rack (>= 2.0.9) rack (>= 2.0.9)
web-console (4.2.1) web-console (4.2.1)
@@ -829,15 +797,7 @@ GEM
zeitwerk (2.6.16) zeitwerk (2.6.16)
PLATFORMS PLATFORMS
arm64-darwin-20 arm64-darwin-23
arm64-darwin-21
arm64-darwin-22
ruby
x86_64-darwin-18
x86_64-darwin-20
x86_64-darwin-21
x86_64-darwin-22
x86_64-linux
DEPENDENCIES DEPENDENCIES
active_record_query_trace active_record_query_trace
@@ -0,0 +1,30 @@
class V2::BaseConversationReportBuilder
pattr_initialize :account, :params
private
AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze
COUNT_METRICS = %w[
conversations_count
incoming_messages_count
outgoing_messages_count
resolutions_count
bot_resolutions_count
bot_handoffs_count
].freeze
def builder_class(metric)
case metric
when *AVG_METRICS
V2::Reports::Timeseries::AverageReportBuilder
when *COUNT_METRICS
V2::Reports::Timeseries::CountReportBuilder
end
end
def log_invalid_metric
Rails.logger.error "ReportBuilder: Invalid metric - #{params[:metric]}"
{}
end
end
@@ -0,0 +1,30 @@
class V2::ConversationMetricBuilder < V2::BaseConversationReportBuilder
def summary
{
conversations_count: count('conversations_count'),
incoming_messages_count: count('incoming_messages_count'),
outgoing_messages_count: count('outgoing_messages_count'),
avg_first_response_time: count('avg_first_response_time'),
avg_resolution_time: count('avg_resolution_time'),
resolutions_count: count('resolutions_count'),
reply_time: count('reply_time')
}
end
def bot_summary
{
bot_resolutions_count: count('bot_resolutions_count'),
bot_handoffs_count: count('bot_handoffs_count')
}
end
private
def count(metric)
builder_class(metric).new(account, builder_params(metric)).aggregate_value
end
def builder_params(metric)
params.merge({ metric: metric })
end
end
@@ -0,0 +1,25 @@
class V2::ConversationReportBuilder < V2::BaseConversationReportBuilder
include DateRangeHelper
pattr_initialize :account, :params
def timeseries
perform_action(:timeseries)
end
def aggregate_value
perform_action(:aggregate_value)
end
private
def perform_action(method_name)
return builder.new(account, params).public_send(method_name) if builder.present?
log_invalid_metric
end
def builder
builder_class(params[:metric])
end
end
@@ -3,6 +3,7 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
def build def build
set_grouped_conversations_count set_grouped_conversations_count
set_grouped_resolved_conversations_count
set_grouped_avg_reply_time set_grouped_avg_reply_time
set_grouped_avg_first_response_time set_grouped_avg_first_response_time
set_grouped_avg_resolution_time set_grouped_avg_resolution_time
@@ -27,6 +28,15 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time')) @grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
end end
def set_grouped_resolved_conversations_count
@grouped_resolved_conversations_count = reporting_events
.where(name: 'conversation_resolved')
.select(group_by_key, :conversation_id)
.distinct
.group(group_by_key)
.count(:conversation_id)
end
def group_by_key def group_by_key
:user_id :user_id
end end
@@ -37,12 +47,14 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
def prepare_report def prepare_report
account.account_users.each_with_object([]) do |account_user, arr| account.account_users.each_with_object([]) do |account_user, arr|
user_id = account_user.user_id
arr << { arr << {
id: account_user.user_id, id: user_id,
conversations_count: @grouped_conversations_count[account_user.user_id], conversations_count: @grouped_conversations_count[user_id],
avg_resolution_time: @grouped_avg_resolution_time[account_user.user_id], resolved_conversations_count: @grouped_resolved_conversations_count[user_id],
avg_first_response_time: @grouped_avg_first_response_time[account_user.user_id], avg_resolution_time: @grouped_avg_resolution_time[user_id],
avg_reply_time: @grouped_avg_reply_time[account_user.user_id] avg_first_response_time: @grouped_avg_first_response_time[user_id],
avg_reply_time: @grouped_avg_reply_time[user_id]
} }
end end
end end
@@ -0,0 +1,65 @@
class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!]
def build
set_grouped_conversations_count
set_grouped_resolved_conversations_count
set_grouped_avg_reply_time
set_grouped_avg_first_response_time
set_grouped_avg_resolution_time
prepare_report
end
private
def set_grouped_conversations_count
@grouped_conversations_count = Current.account.conversations.where(created_at: range).group('inbox_id').count
end
def set_grouped_avg_resolution_time
@grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
end
def set_grouped_avg_first_response_time
@grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
end
def set_grouped_avg_reply_time
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
end
def set_grouped_resolved_conversations_count
@grouped_resolved_conversations_count = reporting_events
.where(name: 'conversation_resolved')
.select(group_by_key, :conversation_id)
.distinct
.group(group_by_key)
.count(:conversation_id)
end
def group_by_key
:inbox_id
end
def reporting_events
@reporting_events ||= Current.account.reporting_events.where(created_at: range)
end
def prepare_report
account.inboxes.each_with_object([]) do |inbox, arr|
inbox_id = inbox.id
arr << {
id: inbox_id,
conversations_count: @grouped_conversations_count[inbox_id],
resolved_conversations_count: @grouped_resolved_conversations_count[inbox_id],
avg_resolution_time: @grouped_avg_resolution_time[inbox_id],
avg_first_response_time: @grouped_avg_first_response_time[inbox_id],
avg_reply_time: @grouped_avg_reply_time[inbox_id]
}
end
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
end
end
@@ -3,6 +3,7 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
def build def build
set_grouped_conversations_count set_grouped_conversations_count
set_grouped_resolved_conversations_count
set_grouped_avg_reply_time set_grouped_avg_reply_time
set_grouped_avg_first_response_time set_grouped_avg_first_response_time
set_grouped_avg_resolution_time set_grouped_avg_resolution_time
@@ -27,6 +28,15 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time')) @grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
end end
def set_grouped_resolved_conversations_count
@grouped_resolved_conversations_count = reporting_events
.where(name: 'conversation_resolved')
.select(group_by_key, :conversation_id)
.distinct
.group(group_by_key)
.count(:conversation_id)
end
def reporting_events def reporting_events
@reporting_events ||= Current.account.reporting_events.where(created_at: range).joins(:conversation) @reporting_events ||= Current.account.reporting_events.where(created_at: range).joins(:conversation)
end end
@@ -40,6 +50,7 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
arr << { arr << {
id: team.id, id: team.id,
conversations_count: @grouped_conversations_count[team.id], conversations_count: @grouped_conversations_count[team.id],
resolved_conversations_count: @grouped_resolved_conversations_count[team.id],
avg_resolution_time: @grouped_avg_resolution_time[team.id], avg_resolution_time: @grouped_avg_resolution_time[team.id],
avg_first_response_time: @grouped_avg_first_response_time[team.id], avg_first_response_time: @grouped_avg_first_response_time[team.id],
avg_reply_time: @grouped_avg_reply_time[team.id] avg_reply_time: @grouped_avg_reply_time[team.id]
@@ -0,0 +1,57 @@
class Api::V2::Accounts::LiveReportsController < Api::V1::Accounts::BaseController
before_action :load_conversations, only: [:conversation_metrics, :grouped_conversation_metrics]
before_action :set_group_scope, only: [:grouped_conversation_metrics]
def conversation_metrics
render json: {
open: @conversations.open.count,
unattended: @conversations.open.unattended.count,
unassigned: @conversations.open.unassigned.count
}
end
def grouped_conversation_metrics
count_by_group = @conversations.open.group(@group_scope).count
unattended_by_group = @conversations.open.unattended.group(@group_scope).count
unassigned_by_group = @conversations.open.unassigned.group(@group_scope).count
group_metrics = count_by_group.map do |group_id, count|
metric = {
open: count,
unattended: unattended_by_group[group_id] || 0,
unassigned: unassigned_by_group[group_id] || 0
}
metric[@group_scope] = group_id
metric
end
render json: group_metrics
end
private
def set_group_scope
render json: { error: 'invalid group_by' }, status: :unprocessable_entity and return unless %w[
team_id
assignee_id
].include?(permitted_params[:group_by])
@group_scope = permitted_params[:group_by]
end
def team
return unless permitted_params[:team_id]
@team ||= Current.account.teams.find(permitted_params[:team_id])
end
def load_conversations
scope = Current.account.conversations
scope = scope.where(team_id: team.id) if team.present?
@conversations = scope
end
def permitted_params
params.permit(:team_id, :group_by)
end
end
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team] before_action :prepare_builder_params, only: [:agent, :team, :inbox]
def agent def agent
render_report_with(V2::Reports::AgentSummaryBuilder) render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -10,6 +10,10 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::TeamSummaryBuilder) render_report_with(V2::Reports::TeamSummaryBuilder)
end end
def inbox
render_report_with(V2::Reports::InboxSummaryBuilder)
end
private private
def check_authorization def check_authorization
@@ -94,7 +94,8 @@ module Api::V2::Accounts::HeatmapHelper
end end
def since_timestamp(date) def since_timestamp(date)
(date - 6.days).to_i.to_s number_of_days = params[:days_before].present? ? params[:days_before].to_i.days : 6.days
(date - number_of_days).to_i.to_s
end end
def until_timestamp(date) def until_timestamp(date)
@@ -0,0 +1,20 @@
/* global axios */
import ApiClient from './ApiClient';
class LiveReportsAPI extends ApiClient {
constructor() {
super('live_reports', { accountScoped: true, apiVersion: 'v2' });
}
getConversationMetric(params = {}) {
return axios.get(`${this.url}/conversation_metrics`, { params });
}
getGroupedConversations({ groupBy } = { groupBy: 'assignee_id' }) {
return axios.get(`${this.url}/grouped_conversation_metrics`, {
params: { group_by: groupBy },
});
}
}
export default new LiveReportsAPI();
+2 -2
View File
@@ -61,9 +61,9 @@ class ReportsAPI extends ApiClient {
}); });
} }
getConversationTrafficCSV() { getConversationTrafficCSV({ daysBefore = 6 } = {}) {
return axios.get(`${this.url}/conversation_traffic`, { return axios.get(`${this.url}/conversation_traffic`, {
params: { timezone_offset: getTimeOffset() }, params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
}); });
} }
@@ -0,0 +1,40 @@
/* global axios */
import ApiClient from './ApiClient';
class SummaryReportsAPI extends ApiClient {
constructor() {
super('summary_reports', { accountScoped: true, apiVersion: 'v2' });
}
getTeamReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/team`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
getAgentReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/agent`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
getInboxReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/inbox`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
}
export default new SummaryReportsAPI();
@@ -494,6 +494,17 @@
"STATUS": "Status" "STATUS": "Status"
} }
}, },
"TEAM_CONVERSATIONS": {
"HEADER": "Conversations by teams",
"LOADING_MESSAGE": "Loading team metrics...",
"NO_AGENTS": "There is no data available",
"TABLE_HEADER": {
"AGENT": "Team",
"OPEN": "open",
"UNATTENDED": "Unattended",
"STATUS": "Status"
}
},
"AGENT_STATUS": { "AGENT_STATUS": {
"HEADER": "Agent status", "HEADER": "Agent status",
"ONLINE": "Online", "ONLINE": "Online",
@@ -1,19 +1,44 @@
<template> <template>
<woot-reports <div class="column overflow-auto">
key="agent-reports" <metric-card :is-live="false" header="All agents overview">
type="agent" <woot-summary-reports
getter-key="agents/getAgents" key="agent-summary-reports"
action-key="agents/get" class="!p-0"
:download-button-label="$t('REPORT.DOWNLOAD_AGENT_REPORTS')" type="agent"
/> getter-key="agents/getAgents"
attribute-key="team_id"
action-key="summaryReports/fetchAgentSummaryReports"
summary-key="summaryReports/getAgentSummaryReports"
:download-button-label="$t('REPORT.DOWNLOAD_AGENT_REPORTS')"
/>
</metric-card>
<metric-card
:is-live="false"
header="Agent-wise reports"
class="overflow-visible"
>
<woot-reports
key="agent-reports"
:show-download-button="false"
class="!p-0"
type="agent"
getter-key="agents/getAgents"
action-key="agents/get"
/>
</metric-card>
</div>
</template> </template>
<script> <script>
import WootSummaryReports from './components/WootSummaryReports.vue';
import MetricCard from './components/overview/MetricCard.vue';
import WootReports from './components/WootReports.vue'; import WootReports from './components/WootReports.vue';
export default { export default {
components: { components: {
WootReports, WootReports,
MetricCard,
WootSummaryReports,
}, },
}; };
</script> </script>
@@ -1,19 +1,46 @@
<template> <template>
<woot-reports <div class="column overflow-auto">
key="inbox-reports" <metric-card :is-live="false" header="All inboxes overview">
type="inbox" <woot-summary-reports
getter-key="inboxes/getInboxes" key="inbox-view-reports"
action-key="inboxes/get" class="!p-0"
:download-button-label="$t('INBOX_REPORTS.DOWNLOAD_INBOX_REPORTS')" type="inbox"
/> getter-key="inboxes/getInboxes"
action-key="summaryReports/fetchInboxSummaryReports"
summary-key="summaryReports/getInboxSummaryReports"
:download-button-label="$t('INBOX_REPORTS.DOWNLOAD_INBOX_REPORTS')"
/>
</metric-card>
<metric-card
:is-live="false"
header="Inbox-wise reports"
class="overflow-visible"
>
<woot-reports
key="inbox-reports"
:show-download-button="false"
class="!p-0"
type="inbox"
getter-key="inboxes/getInboxes"
action-key="inboxes/get"
/>
</metric-card>
</div>
</template> </template>
<script> <script>
import WootSummaryReports from './components/WootSummaryReports.vue';
import MetricCard from './components/overview/MetricCard.vue';
import WootReports from './components/WootReports.vue'; import WootReports from './components/WootReports.vue';
export default { export default {
components: { components: {
WootReports, WootReports,
MetricCard,
WootSummaryReports,
},
mounted() {
this.$store.dispatch('agents/get');
}, },
}; };
</script> </script>
@@ -1,30 +1,13 @@
<template> <template>
<div class="flex-1 overflow-auto p-4"> <div class="flex-1 overflow-auto p-4">
<div class="flex flex-col md:flex-row items-center"> <div class="flex">
<div <open-conversations />
class="flex-1 w-full max-w-full md:w-[65%] md:max-w-[65%] conversation-metric"
> <div class="flex-1 w-full max-w-full md:w-[35%] md:max-w-[35%] flex">
<metric-card <metric-card
:header="$t('OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.HEADER')" :header="$t('OVERVIEW_REPORTS.AGENT_STATUS.HEADER')"
:is-loading="uiFlags.isFetchingAccountConversationMetric" class="flex-1"
:loading-message="
$t('OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.LOADING_MESSAGE')
"
> >
<div
v-for="(metric, name, index) in conversationMetrics"
:key="index"
class="metric-content flex-1 min-w-0"
>
<h3 class="heading">
{{ name }}
</h3>
<p class="metric">{{ metric }}</p>
</div>
</metric-card>
</div>
<div class="flex-1 w-full max-w-full md:w-[35%] md:max-w-[35%]">
<metric-card :header="$t('OVERVIEW_REPORTS.AGENT_STATUS.HEADER')">
<div <div
v-for="(metric, name, index) in agentStatusMetrics" v-for="(metric, name, index) in agentStatusMetrics"
:key="index" :key="index"
@@ -41,6 +24,18 @@
<div class="max-w-full flex flex-wrap flex-row ml-auto mr-auto"> <div class="max-w-full flex flex-wrap flex-row ml-auto mr-auto">
<metric-card :header="$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')"> <metric-card :header="$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')">
<template #control> <template #control>
<multiselect-dropdown
class="!mb-0 !w-1/3"
:options="dayFilterOptions"
:selected-item="selectedDayFilter"
multiselector-title=""
multiselector-placeholder="Date filter"
no-search-result="No filter found"
input-placeholder="Search for a filter"
:is-filter="true"
:has-thumbnail="false"
@click="onSelectDateFilter"
/>
<woot-button <woot-button
icon="arrow-download" icon="arrow-download"
size="small" size="small"
@@ -52,6 +47,7 @@
</woot-button> </woot-button>
</template> </template>
<report-heatmap <report-heatmap
:selected-day-filter="selectedDayFilter"
:heat-data="accountConversationHeatmap" :heat-data="accountConversationHeatmap"
:is-loading="uiFlags.isFetchingAccountConversationsHeatmap" :is-loading="uiFlags.isFetchingAccountConversationsHeatmap"
/> />
@@ -64,7 +60,16 @@
:agent-metrics="agentConversationMetric" :agent-metrics="agentConversationMetric"
:page-index="pageIndex" :page-index="pageIndex"
:is-loading="uiFlags.isFetchingAgentConversationMetric" :is-loading="uiFlags.isFetchingAgentConversationMetric"
@page-change="onPageNumberChange" />
</metric-card>
</div>
<div class="row">
<metric-card :header="$t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.HEADER')">
<team-table
:teams="teams"
:team-metrics="teamConversationMetric"
:page-index="pageIndex"
:is-loading="uiFlags.isFetchingTeamConversationMetric"
/> />
</metric-card> </metric-card>
</div> </div>
@@ -72,34 +77,54 @@
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import OpenConversations from './components/LiveReports/OpenConversations.vue';
import AgentTable from './components/overview/AgentTable.vue'; import AgentTable from './components/overview/AgentTable.vue';
import TeamTable from './components/overview/TeamTable.vue';
import MetricCard from './components/overview/MetricCard.vue'; import MetricCard from './components/overview/MetricCard.vue';
import { OVERVIEW_METRICS } from './constants';
import ReportHeatmap from './components/Heatmap.vue'; import ReportHeatmap from './components/Heatmap.vue';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
import endOfDay from 'date-fns/endOfDay'; import endOfDay from 'date-fns/endOfDay';
import getUnixTime from 'date-fns/getUnixTime'; import getUnixTime from 'date-fns/getUnixTime';
import startOfDay from 'date-fns/startOfDay'; import startOfDay from 'date-fns/startOfDay';
import subDays from 'date-fns/subDays'; import subDays from 'date-fns/subDays';
import { OVERVIEW_METRICS } from './constants';
const dayFilterOptions = [
{
id: 1,
name: 'Last 7 days',
},
{
id: 2,
name: 'Last 30 days',
},
];
export default { export default {
name: 'LiveReports', name: 'LiveReports',
components: { components: {
OpenConversations,
TeamTable,
AgentTable, AgentTable,
MetricCard, MetricCard,
ReportHeatmap, ReportHeatmap,
MultiselectDropdown,
}, },
data() { data() {
return { return {
pageIndex: 1, pageIndex: 1,
dayFilterOptions: dayFilterOptions,
selectedDayFilter: dayFilterOptions[0],
}; };
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({
agentStatus: 'agents/getAgentStatus', agentStatus: 'agents/getAgentStatus',
agents: 'agents/getAgents', agents: 'agents/getAgents',
accountConversationMetric: 'getAccountConversationMetric', teams: 'teams/getTeams',
agentConversationMetric: 'getAgentConversationMetric', agentConversationMetric: 'getAgentConversationMetric',
teamConversationMetric: 'getTeamConversationMetric',
accountConversationHeatmap: 'getAccountConversationHeatmapData', accountConversationHeatmap: 'getAccountConversationHeatmapData',
uiFlags: 'getOverviewUIFlags', uiFlags: 'getOverviewUIFlags',
}), }),
@@ -113,19 +138,10 @@ export default {
}); });
return metric; return metric;
}, },
conversationMetrics() {
let metric = {};
Object.keys(this.accountConversationMetric).forEach(key => {
const metricName = this.$t(
`OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.${OVERVIEW_METRICS[key]}`
);
metric[metricName] = this.accountConversationMetric[key];
});
return metric;
},
}, },
mounted() { mounted() {
this.$store.dispatch('agents/get'); this.$store.dispatch('agents/get');
this.$store.dispatch('teams/get');
this.fetchAllData(); this.fetchAllData();
this.$emitter.on('fetch_overview_reports', () => { this.$emitter.on('fetch_overview_reports', () => {
@@ -133,18 +149,24 @@ export default {
}); });
}, },
methods: { methods: {
onSelectDateFilter(dayFilter) {
this.selectedDayFilter = dayFilter;
this.fetchHeatmapData();
},
fetchAllData() { fetchAllData() {
this.fetchAccountConversationMetric(); this.$store.dispatch('fetchAgentConversationMetric');
this.fetchAgentConversationMetric(); this.$store.dispatch('fetchTeamConversationMetric');
this.fetchHeatmapData(); this.fetchHeatmapData();
}, },
downloadHeatmapData() { downloadHeatmapData() {
let to = endOfDay(new Date()); let to = endOfDay(new Date());
this.$store.dispatch('downloadAccountConversationHeatmap', { this.$store.dispatch('downloadAccountConversationHeatmap', {
daysBefore: this.selectedDayFilter?.id === 1 ? 6 : 29,
to: getUnixTime(to), to: getUnixTime(to),
}); });
}, },
fetchHeatmapData() { fetchHeatmapData() {
if (this.uiFlags.isFetchingAccountConversationsHeatmap) { if (this.uiFlags.isFetchingAccountConversationsHeatmap) {
return; return;
@@ -157,12 +179,9 @@ export default {
// and reconcile it with the rest of the data // and reconcile it with the rest of the data
// this will reduce the load on the server doing number crunching // this will reduce the load on the server doing number crunching
let to = endOfDay(new Date()); let to = endOfDay(new Date());
let from = startOfDay(subDays(to, 6)); let from = startOfDay(
subDays(to, this.selectedDayFilter?.id === 1 ? 6 : 29)
if (this.accountConversationHeatmap.length) { );
to = endOfDay(new Date());
from = startOfDay(to);
}
this.$store.dispatch('fetchAccountConversationHeatmap', { this.$store.dispatch('fetchAccountConversationHeatmap', {
metric: 'conversations_count', metric: 'conversations_count',
@@ -172,21 +191,6 @@ export default {
businessHours: false, businessHours: false,
}); });
}, },
fetchAccountConversationMetric() {
this.$store.dispatch('fetchAccountConversationMetric', {
type: 'account',
});
},
fetchAgentConversationMetric() {
this.$store.dispatch('fetchAgentConversationMetric', {
type: 'agent',
page: this.pageIndex,
});
},
onPageNumberChange(pageIndex) {
this.pageIndex = pageIndex;
this.fetchAgentConversationMetric();
},
}, },
}; };
</script> </script>
@@ -1,19 +1,47 @@
<template> <template>
<woot-reports <div class="column overflow-auto">
key="team-reports" <metric-card :is-live="false" header="All teams overview">
type="team" <woot-summary-reports
getter-key="teams/getTeams" key="team-reports"
action-key="teams/get" class="!p-0"
:download-button-label="$t('TEAM_REPORTS.DOWNLOAD_TEAM_REPORTS')" type="team"
/> getter-key="teams/getTeams"
attribute-key="team_id"
action-key="summaryReports/fetchTeamSummaryReports"
summary-key="summaryReports/getTeamSummaryReports"
:download-button-label="$t('TEAM_REPORTS.DOWNLOAD_TEAM_REPORTS')"
/>
</metric-card>
<metric-card
:is-live="false"
header="Team-wise reports"
class="overflow-visible"
>
<woot-reports
key="team-reports"
:show-download-button="false"
class="!p-0"
type="team"
getter-key="teams/getTeams"
action-key="teams/get"
/>
</metric-card>
</div>
</template> </template>
<script> <script>
import WootSummaryReports from './components/WootSummaryReports.vue';
import MetricCard from './components/overview/MetricCard.vue';
import WootReports from './components/WootReports.vue'; import WootReports from './components/WootReports.vue';
export default { export default {
components: { components: {
WootReports, WootReports,
MetricCard,
WootSummaryReports,
},
mounted() {
this.$store.dispatch('agents/get');
}, },
}; };
</script> </script>
@@ -0,0 +1,141 @@
<template>
<div class="agent-table-container">
<ve-table
max-height="calc(100vh - 21.875rem)"
:fixed-header="true"
:columns="columns"
:table-data="tableData"
/>
</div>
</template>
<script>
import { VeTable } from 'vue-easytable';
import rtlMixin from 'shared/mixins/rtlMixin';
import { mapGetters } from 'vuex';
export default {
name: 'TeamTable',
components: {
VeTable,
},
mixins: [rtlMixin],
computed: {
...mapGetters({
teams: 'teams/getTeams',
teamMetrics: 'summaryReports/getTeamSummaryReports',
}),
columns() {
return [
{
field: 'agent',
key: 'agent',
title: 'Team',
fixed: 'left',
align: this.isRTLView ? 'right' : 'left',
width: 25,
renderBodyCell: ({ row }) => (
<div class="row-user-block">
<div class="user-block">
<h6 class="title overflow-hidden whitespace-nowrap text-ellipsis">
{row.name}
</h6>
</div>
</div>
),
},
{
field: 'conversationsCount',
key: 'conversationsCount',
title: 'No. of conversations',
align: this.isRTLView ? 'right' : 'left',
width: 20,
},
{
field: 'resolutionsCount',
key: 'resolutionsCount',
title: 'No. of resolved conversations',
align: this.isRTLView ? 'right' : 'left',
width: 20,
},
{
field: 'avgFirstResponseTime',
key: 'avgFirstResponseTime',
title: 'Average first response time',
align: this.isRTLView ? 'right' : 'left',
width: 20,
},
{
field: 'avgResolutionTime',
key: 'avgResolutionTime',
title: 'Average resolution time',
align: this.isRTLView ? 'right' : 'left',
width: 20,
},
];
},
},
mounted() {
this.$store.dispatch('summaryReports/fetchTeamSummaryReports');
},
methods: {
getTeamMetrics(id) {
return (
this.teamMetrics.find(metrics => metrics.team_id === Number(id)) || {}
);
},
},
};
</script>
<style lang="scss" scoped>
.agent-table-container {
@apply flex flex-col flex-1;
.ve-table {
&::v-deep {
th.ve-table-header-th {
font-size: var(--font-size-mini) !important;
padding: var(--space-small) var(--space-two) !important;
}
td.ve-table-body-td {
padding: var(--space-one) var(--space-two) !important;
}
}
}
&::v-deep .ve-pagination {
@apply bg-transparent dark:bg-transparent;
}
&::v-deep .ve-pagination-select {
@apply hidden;
}
.row-user-block {
@apply items-center flex text-left;
.user-block {
@apply items-start flex flex-col min-w-0 my-0 mx-2;
.title {
@apply text-sm m-0 leading-[1.2] text-slate-800 dark:text-slate-100;
}
.sub-title {
@apply text-xs text-slate-600 dark:text-slate-200;
}
}
}
.table-pagination {
@apply mt-4 text-right;
}
}
.agents-loader {
@apply items-center flex text-base justify-center p-8;
}
</style>
@@ -0,0 +1,87 @@
<script>
import { mapGetters } from 'vuex';
import { OVERVIEW_METRICS } from '../../constants';
import MetricCard from '../overview/MetricCard.vue';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
const noneTeam = { team_id: 0, name: 'All teams' };
export default {
components: {
MultiselectDropdown,
MetricCard,
},
data() {
return {
selectedTeam: noneTeam,
};
},
computed: {
...mapGetters({
teams: 'teams/getTeams',
accountConversationMetric: 'getAccountConversationMetric',
uiFlags: 'getOverviewUIFlags',
}),
conversationMetrics() {
let metric = {};
Object.keys(this.accountConversationMetric).forEach(key => {
const metricName = this.$t(
`OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.${OVERVIEW_METRICS[key]}`
);
metric[metricName] = this.accountConversationMetric[key];
});
return metric;
},
teamsList() {
return [noneTeam, ...this.teams];
},
},
mounted() {
this.$store.dispatch('fetchLiveConversationMetric');
},
methods: {
onSelectTeam(team) {
this.$store.dispatch('fetchLiveConversationMetric', {
team_id: !team.id ? null : team.id,
});
this.selectedTeam = team;
},
},
};
</script>
<template>
<div
class="flex-1 w-full max-w-full md:w-[65%] md:max-w-[65%] conversation-metric"
>
<metric-card
class="!overflow-visible min-h-[150px]"
:header="$t('OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.HEADER')"
:is-loading="uiFlags.isFetchingAccountConversationMetric"
loading-message="Loading metrics"
>
<template #control>
<multiselect-dropdown
class="!mb-0 !w-1/2"
:options="teamsList"
:selected-item="selectedTeam"
multiselector-title=""
multiselector-placeholder="All teams"
no-search-result="No teams found"
input-placeholder="Search for a team"
:is-filter="true"
@click="onSelectTeam"
/>
</template>
<div
v-for="(metric, name, index) in conversationMetrics"
:key="index"
class="metric-content column"
>
<h3 class="heading">
{{ name }}
</h3>
<p class="metric">{{ metric }}</p>
</div>
</metric-card>
</div>
</template>
@@ -110,6 +110,7 @@
v-model="currentSelectedFilter" v-model="currentSelectedFilter"
track-by="id" track-by="id"
label="name" label="name"
open-direction="below"
:placeholder="multiselectLabel" :placeholder="multiselectLabel"
selected-label selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')" :select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
@@ -127,6 +128,7 @@
<multiselect <multiselect
v-model="currentDateRangeSelection" v-model="currentDateRangeSelection"
track-by="name" track-by="name"
open-direction="below"
label="name" label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT_ONE')" :placeholder="$t('FORMS.MULTISELECT.SELECT_ONE')"
selected-label selected-label
@@ -1,6 +1,7 @@
<template> <template>
<div class="flex-1 overflow-auto p-4"> <div class="flex-1 overflow-auto p-4">
<woot-button <woot-button
v-if="showDownloadButton"
color-scheme="success" color-scheme="success"
class-names="button--fixed-top" class-names="button--fixed-top"
icon="arrow-download" icon="arrow-download"
@@ -64,6 +65,10 @@ export default {
type: String, type: String,
default: 'Download Reports', default: 'Download Reports',
}, },
showDownloadButton: {
type: Boolean,
default: true,
},
}, },
data() { data() {
return { return {
@@ -0,0 +1,180 @@
<template>
<div class="flex-1 overflow-auto p-4">
<woot-button
color-scheme="success"
class-names="button--fixed-top"
icon="arrow-download"
@click="downloadReports"
>
{{ downloadButtonLabel }}
</woot-button>
<report-filter-selector
:show-agents-filter="false"
:show-group-by-filter="false"
@filter-change="onFilterChange"
/>
<ve-table
max-height="calc(100vh - 21.875rem)"
:fixed-header="true"
:columns="columns"
:table-data="tableData"
/>
</div>
</template>
<script>
import ReportFilterSelector from './FilterSelector.vue';
import { formatTime } from '@chatwoot/utils';
import reportMixin from '../../../../../mixins/reportMixin';
import { generateFileName } from '../../../../../helper/downloadHelper';
import { VeTable } from 'vue-easytable';
export default {
components: {
VeTable,
ReportFilterSelector,
},
mixins: [reportMixin],
props: {
type: {
type: String,
default: 'account',
},
getterKey: {
type: String,
default: '',
},
actionKey: {
type: String,
default: '',
},
summaryKey: {
type: String,
default: '',
},
downloadButtonLabel: {
type: String,
default: 'Download Reports',
},
},
data() {
return {
from: 0,
to: 0,
selectedFilter: null,
businessHours: false,
};
},
computed: {
columns() {
return [
{
field: 'agent',
key: 'agent',
title: this.type,
fixed: 'left',
align: this.isRTLView ? 'right' : 'left',
width: 25,
renderBodyCell: ({ row }) => (
<div class="row-user-block">
<div class="user-block">
<h6 class="title overflow-hidden whitespace-nowrap text-ellipsis text-sm capitalize">
{row.name}
</h6>
</div>
</div>
),
},
{
field: 'conversationsCount',
key: 'conversationsCount',
title: 'Assigned',
align: this.isRTLView ? 'right' : 'left',
width: 20,
},
{
field: 'resolutionsCount',
key: 'resolutionsCount',
title: 'Resolved',
align: this.isRTLView ? 'right' : 'left',
width: 20,
},
{
field: 'avgFirstResponseTime',
key: 'avgFirstResponseTime',
title: 'Avg. first response time',
align: this.isRTLView ? 'right' : 'left',
width: 20,
},
{
field: 'avgResolutionTime',
key: 'avgResolutionTime',
title: 'Avg. resolution time',
align: this.isRTLView ? 'right' : 'left',
width: 20,
},
];
},
tableData() {
return this.filterItemsList.map(team => {
const typeMetrics = this.getMetrics(team.id);
return {
name: team.name,
conversationsCount: typeMetrics.conversations_count || '--',
avgFirstResponseTime:
this.renderContent(typeMetrics.avg_first_response_time) || '--',
avgResolutionTime:
this.renderContent(typeMetrics.avg_resolution_time) || '--',
resolutionsCount: typeMetrics.resolved_conversations_count || '--',
};
});
},
filterItemsList() {
return this.$store.getters[this.getterKey] || [];
},
typeMetrics() {
return this.$store.getters[this.summaryKey] || [];
},
},
mounted() {
this.fetchAllData();
},
methods: {
renderContent(value) {
return value ? formatTime(value) : '--';
},
getMetrics(id) {
return this.typeMetrics.find(metrics => metrics.id === Number(id)) || {};
},
fetchAllData() {
const { from, to, businessHours } = this;
this.$store.dispatch(this.actionKey, {
since: from,
until: to,
businessHours,
});
},
downloadReports() {
const { from, to, type, businessHours } = this;
const dispatchMethods = {
agent: 'downloadAgentReports',
label: 'downloadLabelReports',
inbox: 'downloadInboxReports',
team: 'downloadTeamReports',
};
if (dispatchMethods[type]) {
const fileName = generateFileName({ type, to, businessHours });
const params = { from, to, fileName, businessHours };
this.$store.dispatch(dispatchMethods[type], params);
}
},
onFilterChange({ from, to, businessHours }) {
this.from = from;
this.to = to;
this.businessHours = businessHours;
this.fetchAllData();
},
},
};
</script>
@@ -12,17 +12,12 @@
$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.LOADING_MESSAGE') $t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.LOADING_MESSAGE')
}}</span> }}</span>
</div> </div>
<empty-state <div v-if="agents.length > 0" class="table-pagination">
v-else-if="!isLoading && !agentMetrics.length"
:title="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.NO_AGENTS')"
/>
<div v-if="agentMetrics.length > 0" class="table-pagination">
<ve-pagination <ve-pagination
:total="agents.length" :total="agents.length"
:page-index="pageIndex" :page-index="pageIndex"
:page-size="25" :page-size="25"
:page-size-option="[25]" :page-size-option="[25]"
@on-page-number-change="onPageNumberChange"
/> />
</div> </div>
</div> </div>
@@ -31,14 +26,12 @@
<script> <script>
import { VeTable, VePagination } from 'vue-easytable'; import { VeTable, VePagination } from 'vue-easytable';
import Spinner from 'shared/components/Spinner.vue'; import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import rtlMixin from 'shared/mixins/rtlMixin'; import rtlMixin from 'shared/mixins/rtlMixin';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
export default { export default {
name: 'AgentTable', name: 'AgentTable',
components: { components: {
EmptyState,
Spinner, Spinner,
VeTable, VeTable,
VePagination, VePagination,
@@ -64,19 +57,17 @@ export default {
}, },
computed: { computed: {
tableData() { tableData() {
return this.agentMetrics return this.agents.map(agent => {
.filter(agentMetric => this.getAgentInformation(agentMetric.id)) const agentMetrics = this.getAgentMetrics(agent.id);
.map(agent => { return {
const agentInformation = this.getAgentInformation(agent.id); agent: agent.name,
return { email: agent.email,
agent: agentInformation.name || agentInformation.available_name, thumbnail: agent.thumbnail,
email: agentInformation.email, open: agentMetrics.open || 0,
thumbnail: agentInformation.thumbnail, unattended: agentMetrics.unattended || 0,
open: agent.metric.open || 0, status: agent.availability_status,
unattended: agent.metric.unattended || 0, };
status: agentInformation.availability_status, });
};
});
}, },
columns() { columns() {
return [ return [
@@ -128,12 +119,15 @@ export default {
}, },
}, },
methods: { methods: {
getAgentMetrics(id) {
return (
this.agentMetrics.find(metrics => metrics.assignee_id === Number(id)) ||
{}
);
},
onPageNumberChange(pageIndex) { onPageNumberChange(pageIndex) {
this.$emit('page-change', pageIndex); this.$emit('page-change', pageIndex);
}, },
getAgentInformation(id) {
return this.agents?.find(agent => agent.id === Number(id));
},
}, },
}; };
</script> </script>
@@ -172,6 +166,7 @@ export default {
.title { .title {
@apply text-sm m-0 leading-[1.2] text-slate-800 dark:text-slate-100; @apply text-sm m-0 leading-[1.2] text-slate-800 dark:text-slate-100;
} }
.sub-title { .sub-title {
@apply text-xs text-slate-600 dark:text-slate-200; @apply text-xs text-slate-600 dark:text-slate-200;
} }
@@ -11,6 +11,7 @@
{{ header }} {{ header }}
</h5> </h5>
<span <span
v-if="isLive"
class="flex flex-row items-center pr-2 pl-2 m-1 rounded-sm text-green-400 dark:text-green-400 text-xs bg-green-100/30 dark:bg-green-100/20" class="flex flex-row items-center pr-2 pl-2 m-1 rounded-sm text-green-400 dark:text-green-400 text-xs bg-green-100/30 dark:bg-green-100/20"
> >
<span <span
@@ -29,6 +30,7 @@
<div <div
v-if="!isLoading" v-if="!isLoading"
class="card-body max-w-full w-full ml-auto mr-auto justify-between flex" class="card-body max-w-full w-full ml-auto mr-auto justify-between flex"
:class="!isLive ? 'px-0' : ''"
> >
<slot /> <slot />
</div> </div>
@@ -52,6 +54,10 @@ export default {
Spinner, Spinner,
}, },
props: { props: {
isLive: {
type: Boolean,
default: true,
},
header: { header: {
type: String, type: String,
default: '', default: '',
@@ -64,6 +70,10 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
isFilter: {
type: Boolean,
default: false,
},
}, },
}; };
</script> </script>
@@ -96,12 +106,18 @@ export default {
.card-body { .card-body {
.metric-content { .metric-content {
@apply pb-2; @apply pb-2;
.heading { .heading {
@apply text-base text-slate-700 dark:text-slate-100; @apply text-base text-slate-700 dark:text-slate-100;
} }
.metric { .metric {
@apply text-woot-800 dark:text-woot-300 text-3xl mb-0 mt-1; @apply text-woot-800 dark:text-woot-300 text-3xl mb-0 mt-1;
} }
} }
} }
.conversation-metric-loader {
@apply items-center flex text-base justify-center;
}
</style> </style>
@@ -0,0 +1,169 @@
<template>
<div class="agent-table-container">
<ve-table
max-height="calc(100vh - 21.875rem)"
:fixed-header="true"
:columns="columns"
:table-data="tableData"
/>
<div v-if="isLoading" class="agents-loader">
<spinner />
<span>{{
$t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.LOADING_MESSAGE')
}}</span>
</div>
<div v-if="teams.length > 0" class="table-pagination">
<ve-pagination
:total="teams.length"
:page-index="pageIndex"
:page-size="25"
:page-size-option="[25]"
/>
</div>
</div>
</template>
<script>
import { VeTable, VePagination } from 'vue-easytable';
import Spinner from 'shared/components/Spinner.vue';
import rtlMixin from 'shared/mixins/rtlMixin';
export default {
name: 'TeamTable',
components: {
Spinner,
VeTable,
VePagination,
},
mixins: [rtlMixin],
props: {
teams: {
type: Array,
default: () => [],
},
teamMetrics: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
pageIndex: {
type: Number,
default: 1,
},
},
computed: {
tableData() {
return this.teams.map(team => {
const teamMetrics = this.getTeamMetrics(team.id);
return {
agent: team.name,
open: teamMetrics.open || 0,
unattended: teamMetrics.unattended || 0,
};
});
},
columns() {
return [
{
field: 'agent',
key: 'agent',
title: this.$t(
'OVERVIEW_REPORTS.TEAM_CONVERSATIONS.TABLE_HEADER.AGENT'
),
fixed: 'left',
align: this.isRTLView ? 'right' : 'left',
width: 25,
renderBodyCell: ({ row }) => (
<div class="row-user-block">
<div class="user-block">
<h6 class="capitalize title overflow-hidden whitespace-nowrap text-ellipsis">
{row.agent}
</h6>
</div>
</div>
),
},
{
field: 'open',
key: 'open',
title: this.$t(
'OVERVIEW_REPORTS.TEAM_CONVERSATIONS.TABLE_HEADER.OPEN'
),
align: this.isRTLView ? 'right' : 'left',
width: 10,
},
{
field: 'unattended',
key: 'unattended',
title: this.$t(
'OVERVIEW_REPORTS.TEAM_CONVERSATIONS.TABLE_HEADER.UNATTENDED'
),
align: this.isRTLView ? 'right' : 'left',
width: 10,
},
];
},
},
methods: {
getTeamMetrics(id) {
return (
this.teamMetrics.find(metrics => metrics.team_id === Number(id)) || {}
);
},
},
};
</script>
<style lang="scss" scoped>
.agent-table-container {
@apply flex flex-col flex-1;
.ve-table {
&::v-deep {
th.ve-table-header-th {
font-size: var(--font-size-mini) !important;
padding: var(--space-small) var(--space-two) !important;
}
td.ve-table-body-td {
padding: var(--space-one) var(--space-two) !important;
}
}
}
&::v-deep .ve-pagination {
@apply bg-transparent dark:bg-transparent;
}
&::v-deep .ve-pagination-select {
@apply hidden;
}
.row-user-block {
@apply items-center flex text-left;
.user-block {
@apply items-start flex flex-col min-w-0 my-0 mx-2;
.title {
@apply text-sm m-0 leading-[1.2] text-slate-800 dark:text-slate-100;
}
.sub-title {
@apply text-xs text-slate-600 dark:text-slate-200;
}
}
}
.table-pagination {
@apply mt-4 text-right;
}
}
.agents-loader {
@apply items-center flex text-base justify-center p-8;
}
</style>
+2
View File
@@ -42,6 +42,7 @@ import sla from './modules/sla';
import teamMembers from './modules/teamMembers'; import teamMembers from './modules/teamMembers';
import teams from './modules/teams'; import teams from './modules/teams';
import userNotificationSettings from './modules/userNotificationSettings'; import userNotificationSettings from './modules/userNotificationSettings';
import summaryReports from './modules/summaryReports';
import webhooks from './modules/webhooks'; import webhooks from './modules/webhooks';
import draftMessages from './modules/draftMessages'; import draftMessages from './modules/draftMessages';
import SLAReports from './modules/SLAReports'; import SLAReports from './modules/SLAReports';
@@ -90,6 +91,7 @@ export default new Vuex.Store({
reports, reports,
teamMembers, teamMembers,
teams, teams,
summaryReports,
userNotificationSettings, userNotificationSettings,
webhooks, webhooks,
draftMessages, draftMessages,
@@ -4,10 +4,19 @@ import Report from '../../api/reports';
import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper'; import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper';
import AnalyticsHelper from '../../helper/AnalyticsHelper'; import AnalyticsHelper from '../../helper/AnalyticsHelper';
import { REPORTS_EVENTS } from '../../helper/AnalyticsHelper/events'; import { REPORTS_EVENTS } from '../../helper/AnalyticsHelper/events';
import { import { clampDataBetweenTimeline } from 'shared/helpers/ReportsDataHelper';
reconcileHeatmapData, import liveReports from '../../api/liveReports';
clampDataBetweenTimeline,
} from 'shared/helpers/ReportsDataHelper'; const accountSummaryInitialData = {
avg_first_response_time: 0,
avg_resolution_time: 0,
conversations_count: 0,
incoming_messages_count: 0,
outgoing_messages_count: 0,
reply_time: 0,
resolutions_count: 0,
previous: {},
};
const state = { const state = {
fetchingStatus: false, fetchingStatus: false,
@@ -35,18 +44,7 @@ const state = {
reply_time: [], reply_time: [],
}, },
}, },
accountSummary: { accountSummary: accountSummaryInitialData,
avg_first_response_time: 0,
avg_resolution_time: 0,
conversations_count: 0,
incoming_messages_count: 0,
outgoing_messages_count: 0,
reply_time: 0,
resolutions_count: 0,
bot_resolutions_count: 0,
bot_handoffs_count: 0,
previous: {},
},
botSummary: { botSummary: {
bot_resolutions_count: 0, bot_resolutions_count: 0,
bot_handoffs_count: 0, bot_handoffs_count: 0,
@@ -57,10 +55,12 @@ const state = {
isFetchingAccountConversationMetric: false, isFetchingAccountConversationMetric: false,
isFetchingAccountConversationsHeatmap: false, isFetchingAccountConversationsHeatmap: false,
isFetchingAgentConversationMetric: false, isFetchingAgentConversationMetric: false,
isFetchingTeamConversationMetric: false,
}, },
accountConversationMetric: {}, accountConversationMetric: {},
accountConversationHeatmap: [], accountConversationHeatmap: [],
agentConversationMetric: [], agentConversationMetric: [],
teamConversationMetric: [],
}, },
}; };
@@ -83,6 +83,9 @@ const getters = {
getAgentConversationMetric(_state) { getAgentConversationMetric(_state) {
return _state.overview.agentConversationMetric; return _state.overview.agentConversationMetric;
}, },
getTeamConversationMetric(_state) {
return _state.overview.teamConversationMetric;
},
getOverviewUIFlags($state) { getOverviewUIFlags($state) {
return $state.overview.uiFlags; return $state.overview.uiFlags;
}, },
@@ -114,16 +117,12 @@ export const actions = {
let { data } = heatmapData; let { data } = heatmapData;
data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to); data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to);
data = reconcileHeatmapData(
data,
state.overview.accountConversationHeatmap
);
commit(types.default.SET_HEATMAP_DATA, data); commit(types.default.SET_HEATMAP_DATA, data);
commit(types.default.TOGGLE_HEATMAP_LOADING, false); commit(types.default.TOGGLE_HEATMAP_LOADING, false);
}); });
}, },
fetchAccountSummary({ commit }, reportObj) { fetchAccountSummary({ commit }, reportObj) {
commit(types.default.SET_ACCOUNT_SUMMARY, accountSummaryInitialData);
Report.getSummary( Report.getSummary(
reportObj.from, reportObj.from,
reportObj.to, reportObj.to,
@@ -153,9 +152,10 @@ export const actions = {
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false); commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
}); });
}, },
fetchAccountConversationMetric({ commit }, reportObj) { fetchLiveConversationMetric({ commit }, params = {}) {
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, true); commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, true);
Report.getConversationMetric(reportObj.type) liveReports
.getConversationMetric(params)
.then(accountConversationMetric => { .then(accountConversationMetric => {
commit( commit(
types.default.SET_ACCOUNT_CONVERSATION_METRIC, types.default.SET_ACCOUNT_CONVERSATION_METRIC,
@@ -167,9 +167,10 @@ export const actions = {
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, false); commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, false);
}); });
}, },
fetchAgentConversationMetric({ commit }, reportObj) { fetchAgentConversationMetric({ commit }) {
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, true); commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, true);
Report.getConversationMetric(reportObj.type, reportObj.page) liveReports
.getGroupedConversations()
.then(agentConversationMetric => { .then(agentConversationMetric => {
commit( commit(
types.default.SET_AGENT_CONVERSATION_METRIC, types.default.SET_AGENT_CONVERSATION_METRIC,
@@ -181,6 +182,21 @@ export const actions = {
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, false); commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, false);
}); });
}, },
fetchTeamConversationMetric({ commit }) {
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, true);
liveReports
.getGroupedConversations({ groupBy: 'team_id' })
.then(agentConversationMetric => {
commit(
types.default.SET_TEAM_CONVERSATION_METRIC,
agentConversationMetric.data
);
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, false);
})
.catch(() => {
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, false);
});
},
downloadAgentReports(_, reportObj) { downloadAgentReports(_, reportObj) {
return Report.getAgentReports(reportObj) return Report.getAgentReports(reportObj)
.then(response => { .then(response => {
@@ -234,7 +250,7 @@ export const actions = {
}); });
}, },
downloadAccountConversationHeatmap(_, reportObj) { downloadAccountConversationHeatmap(_, reportObj) {
Report.getConversationTrafficCSV() Report.getConversationTrafficCSV({ daysBefore: reportObj.daysBefore })
.then(response => { .then(response => {
downloadCsvFile( downloadCsvFile(
generateFileName({ generateFileName({
@@ -283,9 +299,15 @@ const mutations = {
[types.default.SET_AGENT_CONVERSATION_METRIC](_state, metricData) { [types.default.SET_AGENT_CONVERSATION_METRIC](_state, metricData) {
_state.overview.agentConversationMetric = metricData; _state.overview.agentConversationMetric = metricData;
}, },
[types.default.SET_TEAM_CONVERSATION_METRIC](_state, metricData) {
_state.overview.teamConversationMetric = metricData;
},
[types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING](_state, flag) { [types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingAgentConversationMetric = flag; _state.overview.uiFlags.isFetchingAgentConversationMetric = flag;
}, },
[types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingTeamConversationMetric = flag;
},
}; };
export default { export default {
@@ -0,0 +1,68 @@
import SummaryReportsAPI from '../../api/summaryReports';
import Vue from 'vue';
export const state = {
teamSummaryReports: [],
agentSummaryReports: [],
inboxSummaryReports: [],
uiFlags: {},
};
export const getters = {
getAgentSummaryReports(_state) {
return _state.agentSummaryReports;
},
getTeamSummaryReports(_state) {
return _state.teamSummaryReports;
},
getInboxSummaryReports(_state) {
return _state.inboxSummaryReports;
},
};
export const actions = {
async fetchTeamSummaryReports({ commit }, params) {
try {
const response = await SummaryReportsAPI.getTeamReports(params);
commit('setTeamSummaryReport', response.data);
} catch (error) {
// Ignore error
}
},
async fetchAgentSummaryReports({ commit }, params) {
try {
const response = await SummaryReportsAPI.getAgentReports(params);
commit('setAgentSummaryReport', response.data);
} catch (error) {
// Ignore error
}
},
async fetchInboxSummaryReports({ commit }, params) {
try {
const response = await SummaryReportsAPI.getInboxReports(params);
commit('setInboxSummaryReport', response.data);
} catch (error) {
// Ignore error
}
},
};
export const mutations = {
setTeamSummaryReport(_state, data) {
Vue.set(_state, 'teamSummaryReports', data);
},
setAgentSummaryReport(_state, data) {
Vue.set(_state, 'agentSummaryReports', data);
},
setInboxSummaryReport(_state, data) {
Vue.set(_state, 'inboxSummaryReports', data);
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};
@@ -182,8 +182,11 @@ export default {
TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING: TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING:
'TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING', 'TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING',
SET_AGENT_CONVERSATION_METRIC: 'SET_AGENT_CONVERSATION_METRIC', SET_AGENT_CONVERSATION_METRIC: 'SET_AGENT_CONVERSATION_METRIC',
SET_TEAM_CONVERSATION_METRIC: 'SET_TEAM_CONVERSATION_METRIC',
TOGGLE_AGENT_CONVERSATION_METRIC_LOADING: TOGGLE_AGENT_CONVERSATION_METRIC_LOADING:
'TOGGLE_AGENT_CONVERSATION_METRIC_LOADING', 'TOGGLE_AGENT_CONVERSATION_METRIC_LOADING',
TOGGLE_TEAM_CONVERSATION_METRIC_LOADING:
'TOGGLE_TEAM_CONVERSATION_METRIC_LOADING',
// Conversation Metadata // Conversation Metadata
SET_CONVERSATION_METADATA: 'SET_CONVERSATION_METADATA', SET_CONVERSATION_METADATA: 'SET_CONVERSATION_METADATA',
+3 -3
View File
@@ -14,7 +14,7 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
rescue *ExceptionList::IMAP_EXCEPTIONS => e rescue *ExceptionList::IMAP_EXCEPTIONS => e
Rails.logger.error "Authorization error for email channel - #{channel.inbox.id} : #{e.message}" Rails.logger.error "Authorization error for email channel - #{channel.inbox.id} : #{e.message}"
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError => e rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError => e
Rails.logger.error "Error for email channel - #{channel.inbox.id} : #{e.message}" Rails.logger.error "Net::IMAP Error for email channel - #{channel.inbox.id} : #{e.message}"
rescue LockAcquisitionError rescue LockAcquisitionError
Rails.logger.error "Lock failed for #{channel.inbox.id}" Rails.logger.error "Lock failed for #{channel.inbox.id}"
rescue StandardError => e rescue StandardError => e
@@ -39,8 +39,8 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
process_mail(inbound_mail, channel) process_mail(inbound_mail, channel)
end end
rescue OAuth2::Error => e rescue OAuth2::Error => e
Rails.logger.error "Error for email channel - #{channel.inbox.id} : #{e.message}" Rails.logger.error "OAuth2::Error for email channel - #{channel.inbox.id} : #{e.message}"
channel.authorization_error! # channel.authorization_error!
end end
def process_mail(inbound_mail, channel) def process_mail(inbound_mail, channel)
@@ -11,5 +11,5 @@
%> %>
<%= CSVSafe.generate_line headers -%> <%= CSVSafe.generate_line headers -%>
<% @report_data.each do |row| %> <% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%> <%= CSV.generate_line row -%>
<% end %> <% end %>
@@ -1,5 +1,5 @@
<%= CSV.generate_line [I18n.t('reports.conversation_traffic_csv.timezone'), @timezone] %> <%= CSV.generate_line [I18n.t('reports.conversation_traffic_csv.timezone'), @timezone] %>
<% @report_data.each do |row| %> <% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%> <%= CSV.generate_line row -%>
<% end %> <% end %>
@@ -10,5 +10,5 @@
%> %>
<%= CSVSafe.generate_line headers -%> <%= CSVSafe.generate_line headers -%>
<% @report_data.each do |row| %> <% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%> <%= CSV.generate_line row -%>
<% end %> <% end %>
@@ -9,5 +9,5 @@
%> %>
<%= CSVSafe.generate_line headers -%> <%= CSVSafe.generate_line headers -%>
<% @report_data.each do |row| %> <% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%> <%= CSV.generate_line row -%>
<% end %> <% end %>
@@ -11,5 +11,5 @@
%> %>
<%= CSVSafe.generate_line headers -%> <%= CSVSafe.generate_line headers -%>
<% @report_data.each do |row| %> <% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%> <%= CSV.generate_line row -%>
<% end %> <% end %>
+7
View File
@@ -317,10 +317,17 @@ Rails.application.routes.draw do
namespace :v2 do namespace :v2 do
resources :accounts, only: [:create] do resources :accounts, only: [:create] do
scope module: :accounts do scope module: :accounts do
resources :live_reports, only: [] do
collection do
get :conversation_metrics
get :grouped_conversation_metrics
end
end
resources :summary_reports, only: [] do resources :summary_reports, only: [] do
collection do collection do
get :agent get :agent
get :team get :team
get :inbox
end end
end end
resources :reports, only: [:index] do resources :reports, only: [:index] do