From 35f06f30e7bbcddc4456f35e600376d80bb2b5c8 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 11 Jun 2025 14:35:46 +0530 Subject: [PATCH] feat: label reports overview (#11194) Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Muhsin Keloth --- .../v2/reports/label_summary_builder.rb | 101 ++++++ .../v2/accounts/summary_reports_controller.rb | 6 +- app/helpers/api/v2/accounts/reports_helper.rb | 10 +- .../dashboard/api/summaryReports.js | 10 + .../components-next/sidebar/Sidebar.vue | 2 +- .../dashboard/i18n/locale/en/report.json | 2 + .../settings/reports/LabelReportsIndex.vue | 35 ++ .../settings/reports/LabelReportsShow.vue | 31 ++ .../reports/components/SummaryReports.vue | 5 +- .../settings/reports/reports.routes.js | 18 + .../dashboard/store/modules/labels.js | 3 + .../modules/specs/summaryReports.spec.js | 40 +++ .../dashboard/store/modules/summaryReports.js | 17 + config/routes.rb | 1 + lib/seeders/reports/conversation_creator.rb | 105 ++++++ lib/seeders/reports/message_creator.rb | 141 ++++++++ lib/seeders/reports/report_data_seeder.rb | 234 +++++++++++++ lib/tasks/seed_reports_data.rake | 24 ++ .../v2/reports/label_summary_builder_spec.rb | 317 ++++++++++++++++++ 19 files changed, 1095 insertions(+), 7 deletions(-) create mode 100644 app/builders/v2/reports/label_summary_builder.rb create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsIndex.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsShow.vue create mode 100644 lib/seeders/reports/conversation_creator.rb create mode 100644 lib/seeders/reports/message_creator.rb create mode 100644 lib/seeders/reports/report_data_seeder.rb create mode 100644 lib/tasks/seed_reports_data.rake create mode 100644 spec/builders/v2/reports/label_summary_builder_spec.rb diff --git a/app/builders/v2/reports/label_summary_builder.rb b/app/builders/v2/reports/label_summary_builder.rb new file mode 100644 index 000000000..abc68b26b --- /dev/null +++ b/app/builders/v2/reports/label_summary_builder.rb @@ -0,0 +1,101 @@ +class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder + attr_reader :account, :params + + # rubocop:disable Lint/MissingSuper + # the parent class has no initialize + def initialize(account:, params:) + @account = account + @params = params + + timezone_offset = (params[:timezone_offset] || 0).to_f + @timezone = ActiveSupport::TimeZone[timezone_offset]&.name + end + # rubocop:enable Lint/MissingSuper + + def build + labels = account.labels.to_a + return [] if labels.empty? + + report_data = collect_report_data + labels.map { |label| build_label_report(label, report_data) } + end + + private + + def collect_report_data + conversation_filter = build_conversation_filter + use_business_hours = use_business_hours? + + { + conversation_counts: fetch_conversation_counts(conversation_filter), + resolved_counts: fetch_resolved_counts(conversation_filter), + resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours), + first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours), + reply_metrics: fetch_metrics(conversation_filter, 'reply', use_business_hours) + } + end + + def build_label_report(label, report_data) + { + id: label.id, + name: label.title, + conversations_count: report_data[:conversation_counts][label.title] || 0, + avg_resolution_time: report_data[:resolution_metrics][label.title] || 0, + avg_first_response_time: report_data[:first_response_metrics][label.title] || 0, + avg_reply_time: report_data[:reply_metrics][label.title] || 0, + resolved_conversations_count: report_data[:resolved_counts][label.title] || 0 + } + end + + def use_business_hours? + ActiveModel::Type::Boolean.new.cast(params[:business_hours]) + end + + def build_conversation_filter + conversation_filter = { account_id: account.id } + conversation_filter[:created_at] = range if range.present? + + conversation_filter + end + + def fetch_conversation_counts(conversation_filter) + fetch_counts(conversation_filter) + end + + def fetch_resolved_counts(conversation_filter) + fetch_counts(conversation_filter.merge(status: :resolved)) + end + + def fetch_counts(conversation_filter) + ActsAsTaggableOn::Tagging + .joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id') + .joins('INNER JOIN tags ON taggings.tag_id = tags.id') + .where( + taggable_type: 'Conversation', + context: 'labels', + conversations: conversation_filter + ) + .select('tags.name, COUNT(taggings.*) AS count') + .group('tags.name') + .each_with_object({}) { |record, hash| hash[record.name] = record.count } + end + + def fetch_metrics(conversation_filter, event_name, use_business_hours) + ReportingEvent + .joins('INNER JOIN conversations ON reporting_events.conversation_id = conversations.id') + .joins('INNER JOIN taggings ON taggings.taggable_id = conversations.id') + .joins('INNER JOIN tags ON taggings.tag_id = tags.id') + .where( + conversations: conversation_filter, + name: event_name, + taggings: { taggable_type: 'Conversation', context: 'labels' } + ) + .group('tags.name') + .order('tags.name') + .select( + 'tags.name', + use_business_hours ? 'AVG(reporting_events.value_in_business_hours) as avg_value' : 'AVG(reporting_events.value) as avg_value' + ) + .each_with_object({}) { |record, hash| hash[record.name] = record.avg_value.to_f } + end +end diff --git a/app/controllers/api/v2/accounts/summary_reports_controller.rb b/app/controllers/api/v2/accounts/summary_reports_controller.rb index 989952cfd..f31a53c7e 100644 --- a/app/controllers/api/v2/accounts/summary_reports_controller.rb +++ b/app/controllers/api/v2/accounts/summary_reports_controller.rb @@ -1,6 +1,6 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController before_action :check_authorization - before_action :prepare_builder_params, only: [:agent, :team, :inbox] + before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label] def agent render_report_with(V2::Reports::AgentSummaryBuilder) @@ -14,6 +14,10 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr render_report_with(V2::Reports::InboxSummaryBuilder) end + def label + render_report_with(V2::Reports::LabelSummaryBuilder) + end + private def check_authorization diff --git a/app/helpers/api/v2/accounts/reports_helper.rb b/app/helpers/api/v2/accounts/reports_helper.rb index 22c51b6ef..23694d08d 100644 --- a/app/helpers/api/v2/accounts/reports_helper.rb +++ b/app/helpers/api/v2/accounts/reports_helper.rb @@ -36,9 +36,13 @@ module Api::V2::Accounts::ReportsHelper end def generate_labels_report - Current.account.labels.map do |label| - label_report = report_builder({ type: :label, id: label.id }).short_summary - [label.title] + generate_readable_report_metrics(label_report) + reports = V2::Reports::LabelSummaryBuilder.new( + account: Current.account, + params: build_params({}) + ).build + + reports.map do |report| + [report[:name]] + generate_readable_report_metrics(report) end end diff --git a/app/javascript/dashboard/api/summaryReports.js b/app/javascript/dashboard/api/summaryReports.js index f772ef86f..fad26bf6f 100644 --- a/app/javascript/dashboard/api/summaryReports.js +++ b/app/javascript/dashboard/api/summaryReports.js @@ -35,6 +35,16 @@ class SummaryReportsAPI extends ApiClient { }, }); } + + getLabelReports({ since, until, businessHours } = {}) { + return axios.get(`${this.url}/label`, { + params: { + since, + until, + business_hours: businessHours, + }, + }); + } } export default new SummaryReportsAPI(); diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index 5d7aac3c3..171f4a4d8 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -87,7 +87,7 @@ const newReportRoutes = () => [ { name: 'Reports Label', label: t('SIDEBAR.REPORTS_LABEL'), - to: accountScopedRoute('label_reports'), + to: accountScopedRoute('label_reports_index'), }, { name: 'Reports Inbox', diff --git a/app/javascript/dashboard/i18n/locale/en/report.json b/app/javascript/dashboard/i18n/locale/en/report.json index 294ca2e7b..7c42fdfba 100644 --- a/app/javascript/dashboard/i18n/locale/en/report.json +++ b/app/javascript/dashboard/i18n/locale/en/report.json @@ -193,6 +193,7 @@ }, "LABEL_REPORTS": { "HEADER": "Labels Overview", + "DESCRIPTION": "Track label performance with key metrics including conversations, response times, resolution times, and resolved cases. Click a label name for detailed insights.", "LOADING_CHART": "Loading chart data...", "NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.", "DOWNLOAD_LABEL_REPORTS": "Download label reports", @@ -559,6 +560,7 @@ "INBOX": "Inbox", "AGENT": "Agent", "TEAM": "Team", + "LABEL": "Label", "AVG_RESOLUTION_TIME": "Avg. Resolution Time", "AVG_FIRST_RESPONSE_TIME": "Avg. First Response Time", "AVG_REPLY_TIME": "Avg. Customer Waiting Time", diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsIndex.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsIndex.vue new file mode 100644 index 000000000..956b30974 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsIndex.vue @@ -0,0 +1,35 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsShow.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsShow.vue new file mode 100644 index 000000000..678028410 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsShow.vue @@ -0,0 +1,31 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/SummaryReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/SummaryReports.vue index 4c0c4fe64..7e0b9be8f 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/SummaryReports.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/SummaryReports.vue @@ -108,7 +108,8 @@ const tableData = computed(() => } = rowMetrics; return { id: row.id, - name: row.name, + // we fallback on title, label for instance does not have a name property + name: row.name ?? row.title, type: props.type, conversationsCount: renderCount(conversationsCount), avgFirstResponseTime: renderAvgTime(avgFirstResponseTime), @@ -177,7 +178,7 @@ defineExpose({ downloadReports });