diff --git a/app/builders/v2/reports/conversion_funnel_builder.rb b/app/builders/v2/reports/conversion_funnel_builder.rb new file mode 100644 index 000000000..c907d3ccb --- /dev/null +++ b/app/builders/v2/reports/conversion_funnel_builder.rb @@ -0,0 +1,97 @@ +class V2::Reports::ConversionFunnelBuilder + include DateRangeHelper + + # Reservation statuses we treat as "paid" — covers PIX (Inter), payments at the + # reception, card on arrival, etc. Anything that means the booking went through. + PAID_STATUSES = %w[active completed confirmed].freeze + + # Statuses we ignore from "created" (drafts are pre-save, never went live) + IGNORED_CREATED_STATUSES = %w[draft].freeze + + attr_reader :account, :params + + def initialize(account, params) + @account = account + @params = params + end + + def metrics + { + leads: leads_breakdown, + reservations: reservations_breakdown, + conversion_rates: conversion_rates_breakdown + } + end + + private + + def filter_inbox_id + @filter_inbox_id ||= params[:inbox_id].presence&.to_i + end + + def conversations_in_period + @conversations_in_period ||= begin + scope = account.conversations.where(created_at: range) + scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id + scope + end + end + + def reservations_in_period + @reservations_in_period ||= begin + scope = account.captain_reservations.where(created_at: range) + scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id + scope.where.not(status: IGNORED_CREATED_STATUSES) + end + end + + # Leads classified using the same logic as the "Novas × Retorno" tab: + # new = no prior conversation in any inbox of the account + # returning = had a prior conversation + def leads_breakdown + total = conversations_in_period.count + return { total: 0, new: 0, returning: 0 } if total.zero? + + conv_with_prior_ids = conversations_in_period + .joins('INNER JOIN conversations prev ON prev.contact_id = conversations.contact_id ' \ + 'AND prev.account_id = conversations.account_id ' \ + 'AND prev.id < conversations.id') + .distinct + .pluck(:id) + + returning = conv_with_prior_ids.size + { + total: total, + new: total - returning, + returning: returning + } + end + + def reservations_breakdown + created = reservations_in_period.count + paid = reservations_in_period.where(status: PAID_STATUSES).count + + { + created: created, + paid: paid + } + end + + def conversion_rates_breakdown + leads_total = conversations_in_period.count + reservations_paid = reservations_in_period.where(status: PAID_STATUSES).count + reservations_created = reservations_in_period.count + + { + lead_to_paid_reservation: percent(reservations_paid, leads_total), + lead_to_any_reservation: percent(reservations_created, leads_total), + created_to_paid: percent(reservations_paid, reservations_created) + } + end + + def percent(numerator, denominator) + return 0 if denominator.to_i.zero? + + (numerator.to_f / denominator * 100).round(1) + end +end diff --git a/app/builders/v2/reports/inbox_benchmarking_builder.rb b/app/builders/v2/reports/inbox_benchmarking_builder.rb new file mode 100644 index 000000000..cd076e498 --- /dev/null +++ b/app/builders/v2/reports/inbox_benchmarking_builder.rb @@ -0,0 +1,79 @@ +class V2::Reports::InboxBenchmarkingBuilder + include DateRangeHelper + + PAID_STATUSES = V2::Reports::ConversionFunnelBuilder::PAID_STATUSES + IGNORED_CREATED_STATUSES = V2::Reports::ConversionFunnelBuilder::IGNORED_CREATED_STATUSES + + attr_reader :account, :params + + def initialize(account, params) + @account = account + @params = params + end + + # Returns one row per inbox of the account, with leads + reservations + rate, + # plus the brand name so the frontend can group by brand for benchmarking. + def build + return [] if range.blank? + + inbox_brand_lookup = build_inbox_brand_lookup + + account.inboxes.map do |inbox| + brand_name = inbox_brand_lookup[inbox.id] + + leads_total = leads_count_by_inbox[inbox.id] || 0 + reservations_created = reservations_created_by_inbox[inbox.id] || 0 + reservations_paid = reservations_paid_by_inbox[inbox.id] || 0 + + { + inbox_id: inbox.id, + inbox_name: inbox.name, + brand_name: brand_name, + leads_total: leads_total, + reservations_created: reservations_created, + reservations_paid: reservations_paid, + conversion_rate: percent(reservations_paid, leads_total) + } + end + end + + private + + def leads_count_by_inbox + @leads_count_by_inbox ||= account.conversations + .where(created_at: range) + .group(:inbox_id) + .count + end + + def reservations_created_by_inbox + @reservations_created_by_inbox ||= account.captain_reservations + .where(created_at: range) + .where.not(status: IGNORED_CREATED_STATUSES) + .group(:inbox_id) + .count + end + + def reservations_paid_by_inbox + @reservations_paid_by_inbox ||= account.captain_reservations + .where(created_at: range, status: PAID_STATUSES) + .group(:inbox_id) + .count + end + + # inbox_id => brand_name (or nil when there is no brand mapped for this inbox) + def build_inbox_brand_lookup + rows = Captain::UnitInbox + .joins(captain_unit: :brand) + .where(inbox_id: account.inboxes.select(:id)) + .pluck('captain_unit_inboxes.inbox_id, captain_brands.name') + + rows.to_h + end + + def percent(numerator, denominator) + return 0 if denominator.to_i.zero? + + (numerator.to_f / denominator * 100).round(1) + end +end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index d16451973..f1de67f84 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -95,6 +95,16 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController render json: builder.build end + def conversion_funnel + builder = V2::Reports::ConversionFunnelBuilder.new(Current.account, conversion_funnel_params) + render json: builder.metrics + end + + def inbox_benchmarking + builder = V2::Reports::InboxBenchmarkingBuilder.new(Current.account, inbox_benchmarking_params) + render json: builder.build + end + private def generate_csv(filename, template) @@ -213,5 +223,20 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController until: params[:until] } end + + def conversion_funnel_params + { + inbox_id: params[:inbox_id], + since: params[:since], + until: params[:until] + } + end + + def inbox_benchmarking_params + { + since: params[:since], + until: params[:until] + } + end end # rubocop:enable Metrics/ClassLength diff --git a/app/javascript/dashboard/api/reports.js b/app/javascript/dashboard/api/reports.js index ade6a030c..4166d67a7 100644 --- a/app/javascript/dashboard/api/reports.js +++ b/app/javascript/dashboard/api/reports.js @@ -121,6 +121,27 @@ class ReportsAPI extends ApiClient { }, }); } + + getConversionFunnel({ inboxId, from, to } = {}) { + return axios.get(`${this.url}/conversion_funnel`, { + params: { + inbox_id: inboxId, + since: from, + until: to, + timezone_offset: getTimeOffset(), + }, + }); + } + + getInboxBenchmarking({ from, to } = {}) { + return axios.get(`${this.url}/inbox_benchmarking`, { + params: { + since: from, + until: to, + timezone_offset: getTimeOffset(), + }, + }); + } } export default new ReportsAPI(); diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index 138c93c9f..e346e138b 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -550,6 +550,11 @@ const menuItems = computed(() => { label: t('SIDEBAR.REPORTS_BOT'), to: accountScopedRoute('bot_reports'), }, + { + name: 'Reports Directory Dashboard', + label: t('SIDEBAR.REPORTS_DIRECTORY_DASHBOARD'), + to: accountScopedRoute('directory_dashboard_reports'), + }, ], }, { diff --git a/app/javascript/dashboard/i18n/locale/en/report.json b/app/javascript/dashboard/i18n/locale/en/report.json index fe4b0b0c1..9700dd360 100644 --- a/app/javascript/dashboard/i18n/locale/en/report.json +++ b/app/javascript/dashboard/i18n/locale/en/report.json @@ -687,5 +687,47 @@ "AVG_REPLY_TIME": "Avg. Customer Waiting Time", "RESOLUTION_COUNT": "Resolution Count", "CONVERSATIONS": "No. of conversations" + }, + "DIRECTORY_DASHBOARD": { + "HEADER": "Directory Dashboard", + "BANNER": { + "TITLE": "Channel adoption — not the full picture.", + "BODY": "These numbers measure the digital channel only (Jasmine + reservations created via app). Conversations attended manually that closed at the reception are not yet captured (manual marking is in progress)." + }, + "HEADLINE_NUMBERS": "Headline numbers", + "METRICS": { + "LEADS_TOTAL": { + "LABEL": "Leads (total)", + "TOOLTIP": "All conversations created in the period (new + returning)" + }, + "LEADS_NEW": { + "LABEL": "New leads", + "TOOLTIP": "First-ever conversation of the contact in any inbox of the network" + }, + "LEADS_RETURNING": { + "LABEL": "Returning leads", + "TOOLTIP": "Contact had at least one prior conversation" + }, + "CONVERSION_RATE": { + "LABEL": "Lead → Paid reservation", + "TOOLTIP": "Paid reservations ÷ total leads × 100. Adoption proxy, not full operation." + } + }, + "FUNNEL": { + "TITLE": "Funnel", + "STAGE_LEADS": "Leads", + "STAGE_RESERVATIONS": "Reservations created", + "STAGE_PAID": "Paid" + }, + "BENCHMARK": { + "TITLE": "Inbox benchmarking by brand", + "BRAND_AVG": "brand avg.", + "COL_INBOX": "Inbox", + "COL_LEADS": "Leads", + "COL_CREATED": "Created", + "COL_PAID": "Paid", + "COL_RATE": "Conv. rate", + "COL_VS_BRAND": "vs brand" + } } } diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 6a3f2dc35..065b4e0f3 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -388,6 +388,7 @@ "ONE_OFF": "One off", "REPORTS_SLA": "SLA", "REPORTS_BOT": "Bot", + "REPORTS_DIRECTORY_DASHBOARD": "Directory Dashboard", "REPORTS_AGENT": "Agents", "REPORTS_LABEL": "Labels", "REPORTS_INBOX": "Inbox", diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/report.json b/app/javascript/dashboard/i18n/locale/pt_BR/report.json index c7a80a6fb..1868c1a2a 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/report.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/report.json @@ -619,5 +619,47 @@ "AVG_REPLY_TIME": "Tempo Médio de Espera do Cliente", "RESOLUTION_COUNT": "Contagem de Resolução", "CONVERSATIONS": "Nº de Conversas" + }, + "DIRECTORY_DASHBOARD": { + "HEADER": "Painel Diretoria", + "BANNER": { + "TITLE": "Adoção do canal digital — não é a operação completa.", + "BODY": "Esses números medem só o canal digital (Jasmine + reservas via app). Conversas atendidas manualmente que fecharam na recepção ainda não estão capturadas (marcação manual em construção)." + }, + "HEADLINE_NUMBERS": "Números principais", + "METRICS": { + "LEADS_TOTAL": { + "LABEL": "Leads (total)", + "TOOLTIP": "Todas as conversas criadas no período (novos + retorno)" + }, + "LEADS_NEW": { + "LABEL": "Leads novos", + "TOOLTIP": "Primeira conversa do contato em qualquer caixa da rede" + }, + "LEADS_RETURNING": { + "LABEL": "Leads de retorno", + "TOOLTIP": "Contato com pelo menos uma conversa anterior" + }, + "CONVERSION_RATE": { + "LABEL": "Lead → Reserva paga", + "TOOLTIP": "Reservas pagas ÷ total de leads × 100. Proxy de adoção, não retrato da operação." + } + }, + "FUNNEL": { + "TITLE": "Funil", + "STAGE_LEADS": "Leads", + "STAGE_RESERVATIONS": "Reservas criadas", + "STAGE_PAID": "Pagas" + }, + "BENCHMARK": { + "TITLE": "Comparativo entre unidades por marca", + "BRAND_AVG": "média da marca", + "COL_INBOX": "Caixa de Entrada", + "COL_LEADS": "Leads", + "COL_CREATED": "Criadas", + "COL_PAID": "Pagas", + "COL_RATE": "Taxa conv.", + "COL_VS_BRAND": "vs marca" + } } } diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/settings.json b/app/javascript/dashboard/i18n/locale/pt_BR/settings.json index 8483fd8df..ccb476592 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/settings.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/settings.json @@ -387,6 +387,7 @@ "ONE_OFF": "Única", "REPORTS_SLA": "SLA", "REPORTS_BOT": "Robôs", + "REPORTS_DIRECTORY_DASHBOARD": "Painel Diretoria", "REPORTS_AGENT": "Agentes", "REPORTS_LABEL": "Etiquetas", "REPORTS_INBOX": "Caixa de Entrada", diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/DirectoryDashboard.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/DirectoryDashboard.vue new file mode 100644 index 000000000..f2d83b464 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/DirectoryDashboard.vue @@ -0,0 +1,296 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js b/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js index af01f6d74..5c8432ee1 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js @@ -23,6 +23,7 @@ import CsatResponses from './CsatResponses.vue'; import BotReports from './BotReports.vue'; import LiveReports from './LiveReports.vue'; import SLAReports from './SLAReports.vue'; +import DirectoryDashboard from './DirectoryDashboard.vue'; const meta = { featureFlag: FEATURE_FLAGS.REPORTS, @@ -168,6 +169,12 @@ export default { meta, component: BotReports, }, + { + path: 'directory_dashboard', + name: 'directory_dashboard_reports', + meta, + component: DirectoryDashboard, + }, ], }, ], diff --git a/config/routes.rb b/config/routes.rb index ab55dee1d..77cc3c176 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -516,6 +516,8 @@ Rails.application.routes.draw do get :first_response_time_distribution get :outgoing_messages_count get :inbox_leads_summary + get :conversion_funnel + get :inbox_benchmarking end end resource :year_in_review, only: [:show] diff --git a/spec/builders/v2/reports/conversion_funnel_builder_spec.rb b/spec/builders/v2/reports/conversion_funnel_builder_spec.rb new file mode 100644 index 000000000..def8e7a71 --- /dev/null +++ b/spec/builders/v2/reports/conversion_funnel_builder_spec.rb @@ -0,0 +1,118 @@ +require 'rails_helper' + +RSpec.describe V2::Reports::ConversionFunnelBuilder do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:other_inbox) { create(:inbox, account: account) } + + let(:since_t) { 30.days.ago.beginning_of_day } + let(:until_t) { Time.current.end_of_day } + + let(:base_params) do + { + since: since_t.to_i.to_s, + until: until_t.to_i.to_s + } + end + + def build(params = base_params) + described_class.new(account, params).metrics + end + + describe '#metrics' do + context 'with empty period' do + it 'returns zeroed metrics' do + result = build + expect(result[:leads]).to eq({ total: 0, new: 0, returning: 0 }) + expect(result[:reservations]).to eq({ created: 0, paid: 0 }) + expect(result[:conversion_rates][:lead_to_paid_reservation]).to eq(0) + end + end + + context 'with leads only (no reservations)' do + it 'counts conversations as leads' do + contact = create(:contact, account: account) + create(:conversation, account: account, inbox: inbox, contact: contact, created_at: 1.day.ago) + + result = build + expect(result[:leads][:total]).to eq(1) + expect(result[:leads][:new]).to eq(1) + expect(result[:reservations][:paid]).to eq(0) + end + end + + context 'when classifying new vs returning leads' do + it 'separates contacts with prior history' do + contact = create(:contact, account: account) + create(:conversation, account: account, inbox: inbox, contact: contact, created_at: 5.days.ago) + create(:conversation, account: account, inbox: inbox, contact: contact, created_at: 1.day.ago) + + result = build + expect(result[:leads][:total]).to eq(2) + expect(result[:leads][:new]).to eq(1) + expect(result[:leads][:returning]).to eq(1) + end + end + + context 'when classifying reservation statuses' do + let(:contact) { create(:contact, account: account) } + let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox) } + + it 'ignores draft reservations from "created"' do + create(:captain_reservation, account: account, inbox: inbox, contact: contact, + contact_inbox: contact_inbox, status: :draft, created_at: 1.day.ago) + result = build + expect(result[:reservations][:created]).to eq(0) + end + + it 'counts non-draft as created' do + create(:captain_reservation, account: account, inbox: inbox, contact: contact, + contact_inbox: contact_inbox, status: :pending_payment, created_at: 1.day.ago) + result = build + expect(result[:reservations][:created]).to eq(1) + expect(result[:reservations][:paid]).to eq(0) + end + + %i[active completed confirmed].each do |paid_status| + it "counts #{paid_status} as paid" do + create(:captain_reservation, account: account, inbox: inbox, contact: contact, + contact_inbox: contact_inbox, status: paid_status, created_at: 1.day.ago) + result = build + expect(result[:reservations][:paid]).to eq(1) + end + end + end + + context 'with inbox_id filter' do + it 'restricts both conversations and reservations to that inbox' do + contact = create(:contact, account: account) + contact_inbox = create(:contact_inbox, contact: contact, inbox: inbox) + contact_inbox_other = create(:contact_inbox, contact: contact, inbox: other_inbox) + + create(:conversation, account: account, inbox: inbox, contact: contact, created_at: 1.day.ago) + create(:conversation, account: account, inbox: other_inbox, contact: contact, created_at: 1.day.ago) + create(:captain_reservation, account: account, inbox: inbox, contact: contact, + contact_inbox: contact_inbox, status: :confirmed, created_at: 1.day.ago) + create(:captain_reservation, account: account, inbox: other_inbox, contact: contact, + contact_inbox: contact_inbox_other, status: :confirmed, created_at: 1.day.ago) + + result = build(base_params.merge(inbox_id: inbox.id)) + expect(result[:leads][:total]).to eq(1) + expect(result[:reservations][:paid]).to eq(1) + end + end + + context 'when computing conversion rates' do + it 'rounds to one decimal' do + contact = create(:contact, account: account) + contact_inbox = create(:contact_inbox, contact: contact, inbox: inbox) + 3.times { create(:conversation, account: account, inbox: inbox, contact: contact, created_at: 1.day.ago) } + create(:captain_reservation, account: account, inbox: inbox, contact: contact, + contact_inbox: contact_inbox, status: :confirmed, created_at: 1.day.ago) + + result = build + expect(result[:conversion_rates][:lead_to_paid_reservation]).to eq(33.3) + end + end + end +end