feat(reports): Painel Diretoria — Onda 1A (leitura)

Primeira onda do roadmap de indicadores executivos do Grupo Nova. Mede
ADOÇÃO DO CANAL DIGITAL, não a operação total — banner explícito alerta
que reservas fechadas manualmente na recepção ainda não estão capturadas
(Onda 1B vai adicionar marcação manual via botão na conversa).

Backend:
- V2::Reports::ConversionFunnelBuilder — leads (novo/retorno/total),
  reservas (criadas != draft, pagas in active/completed/confirmed),
  taxas de conversão. Filtro opcional por inbox.
- V2::Reports::InboxBenchmarkingBuilder — uma linha por inbox com
  brand_name (via Captain::UnitInbox -> Unit -> Brand)
- Endpoints GET /reports/conversion_funnel e /reports/inbox_benchmarking
- RSpec do ConversionFunnelBuilder

Frontend:
- Rota top-level Reports → Painel Diretoria
- DirectoryDashboard.vue: banner de adoção + filtros + cards + funil + tabela
  benchmarking agrupada por marca com variação vs média
- API client getConversionFunnel + getInboxBenchmarking
- i18n EN + PT

Memórias suporte: feedback_metricas_adocao_canal.md + project_painel_diretoria_roadmap.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-04-26 12:44:59 -03:00
parent 617eadbfe4
commit d831ee4d33
13 changed files with 736 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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();

View File

@ -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'),
},
],
},
{

View File

@ -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"
}
}
}

View File

@ -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",

View File

@ -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"
}
}
}

View File

@ -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",

View File

@ -0,0 +1,296 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import ReportsAPI from 'dashboard/api/reports';
import ReportFilters from './components/ReportFilters.vue';
import ReportMetricCard from './components/ReportMetricCard.vue';
import ReportHeader from './components/ReportHeader.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const filters = ref({ from: 0, to: 0, inboxId: null });
const isLoadingFunnel = ref(false);
const isLoadingBenchmark = ref(false);
const funnel = ref({
leads: { total: 0, new: 0, returning: 0 },
reservations: { created: 0, paid: 0 },
conversion_rates: {
lead_to_paid_reservation: 0,
lead_to_any_reservation: 0,
created_to_paid: 0,
},
});
const benchmark = ref([]);
const fetchFunnel = () => {
if (!filters.value.from || !filters.value.to) return;
isLoadingFunnel.value = true;
ReportsAPI.getConversionFunnel(filters.value)
.then(({ data }) => {
funnel.value = data;
})
.finally(() => {
isLoadingFunnel.value = false;
});
};
const fetchBenchmark = () => {
if (!filters.value.from || !filters.value.to) return;
isLoadingBenchmark.value = true;
ReportsAPI.getInboxBenchmarking({
from: filters.value.from,
to: filters.value.to,
})
.then(({ data }) => {
benchmark.value = data;
})
.finally(() => {
isLoadingBenchmark.value = false;
});
};
const onFilterChange = ({ from, to, inboxes }) => {
filters.value = {
from,
to,
inboxId: inboxes?.id || null,
};
fetchFunnel();
fetchBenchmark();
};
const benchmarkGrouped = computed(() => {
const groups = new Map();
benchmark.value.forEach(row => {
const brand = row.brand_name || '— Sem marca —';
if (!groups.has(brand)) groups.set(brand, []);
groups.get(brand).push(row);
});
return [...groups.entries()].map(([brand, rows]) => {
const totalLeads = rows.reduce((s, r) => s + r.leads_total, 0);
const totalPaid = rows.reduce((s, r) => s + r.reservations_paid, 0);
const brandRate = totalLeads === 0 ? 0 : (totalPaid / totalLeads) * 100;
return { brand, rows, brandRate: Math.round(brandRate * 10) / 10 };
});
});
const formatNumber = n => Number(n || 0).toLocaleString();
const formatPct = n => `${Number(n || 0).toFixed(1)}%`;
const variationFromBrand = (rowRate, brandRate) => {
if (brandRate === 0) return null;
return Math.round((rowRate - brandRate) * 10) / 10;
};
onMounted(() => {});
</script>
<template>
<ReportHeader :header-title="$t('DIRECTORY_DASHBOARD.HEADER')" />
<div
class="bg-n-amber-3 border border-n-amber-7 rounded-lg p-3 mb-4 text-sm text-n-slate-12"
>
<strong>{{ $t('DIRECTORY_DASHBOARD.BANNER.TITLE') }}</strong>
{{ $t('DIRECTORY_DASHBOARD.BANNER.BODY') }}
</div>
<div class="flex flex-col gap-4">
<ReportFilters
filter-type="inboxes"
:show-group-by="false"
:show-business-hours="false"
:navigate-on-entity-filter="false"
@filter-change="onFilterChange"
/>
<div
class="bg-n-solid-2 shadow outline-1 outline outline-n-container rounded-xl px-6 py-5"
>
<h3 class="text-base font-semibold m-0 mb-4">
{{ $t('DIRECTORY_DASHBOARD.HEADLINE_NUMBERS') }}
</h3>
<Spinner v-if="isLoadingFunnel" />
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<ReportMetricCard
:label="$t('DIRECTORY_DASHBOARD.METRICS.LEADS_TOTAL.LABEL')"
:value="formatNumber(funnel.leads.total)"
:info-text="$t('DIRECTORY_DASHBOARD.METRICS.LEADS_TOTAL.TOOLTIP')"
/>
<ReportMetricCard
:label="$t('DIRECTORY_DASHBOARD.METRICS.LEADS_NEW.LABEL')"
:value="formatNumber(funnel.leads.new)"
:info-text="$t('DIRECTORY_DASHBOARD.METRICS.LEADS_NEW.TOOLTIP')"
/>
<ReportMetricCard
:label="$t('DIRECTORY_DASHBOARD.METRICS.LEADS_RETURNING.LABEL')"
:value="formatNumber(funnel.leads.returning)"
:info-text="$t('DIRECTORY_DASHBOARD.METRICS.LEADS_RETURNING.TOOLTIP')"
/>
<ReportMetricCard
:label="$t('DIRECTORY_DASHBOARD.METRICS.CONVERSION_RATE.LABEL')"
:value="formatPct(funnel.conversion_rates.lead_to_paid_reservation)"
:info-text="$t('DIRECTORY_DASHBOARD.METRICS.CONVERSION_RATE.TOOLTIP')"
/>
</div>
</div>
<div
class="bg-n-solid-2 shadow outline-1 outline outline-n-container rounded-xl px-6 py-5"
>
<h3 class="text-base font-semibold m-0 mb-4">
{{ $t('DIRECTORY_DASHBOARD.FUNNEL.TITLE') }}
</h3>
<Spinner v-if="isLoadingFunnel" />
<div v-else class="space-y-3">
<div class="flex items-center gap-4">
<div class="w-32 text-sm text-n-slate-11">
{{ $t('DIRECTORY_DASHBOARD.FUNNEL.STAGE_LEADS') }}
</div>
<div class="flex-1 bg-n-blue-9 text-white px-4 py-3 rounded-lg">
<span class="text-lg font-semibold">{{
formatNumber(funnel.leads.total)
}}</span>
</div>
<div class="w-24 text-sm text-n-slate-11 text-right">
{{ formatPct(100) }}
</div>
</div>
<div class="flex items-center gap-4">
<div class="w-32 text-sm text-n-slate-11">
{{ $t('DIRECTORY_DASHBOARD.FUNNEL.STAGE_RESERVATIONS') }}
</div>
<div
class="flex-1 bg-n-blue-7 text-white px-4 py-3 rounded-lg"
:style="{
maxWidth:
funnel.leads.total === 0
? '100%'
: `${Math.max(20, (funnel.reservations.created / Math.max(1, funnel.leads.total)) * 100)}%`,
}"
>
<span class="text-lg font-semibold">{{
formatNumber(funnel.reservations.created)
}}</span>
</div>
<div class="w-24 text-sm text-n-slate-11 text-right">
{{ formatPct(funnel.conversion_rates.lead_to_any_reservation) }}
</div>
</div>
<div class="flex items-center gap-4">
<div class="w-32 text-sm text-n-slate-11">
{{ $t('DIRECTORY_DASHBOARD.FUNNEL.STAGE_PAID') }}
</div>
<div
class="flex-1 bg-n-teal-9 text-white px-4 py-3 rounded-lg"
:style="{
maxWidth:
funnel.leads.total === 0
? '100%'
: `${Math.max(20, (funnel.reservations.paid / Math.max(1, funnel.leads.total)) * 100)}%`,
}"
>
<span class="text-lg font-semibold">{{
formatNumber(funnel.reservations.paid)
}}</span>
</div>
<div class="w-24 text-sm text-n-slate-11 text-right">
{{ formatPct(funnel.conversion_rates.lead_to_paid_reservation) }}
</div>
</div>
</div>
</div>
<div
class="bg-n-solid-2 shadow outline-1 outline outline-n-container rounded-xl px-6 py-5"
>
<h3 class="text-base font-semibold m-0 mb-4">
{{ $t('DIRECTORY_DASHBOARD.BENCHMARK.TITLE') }}
</h3>
<Spinner v-if="isLoadingBenchmark" />
<div v-else class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-n-slate-11 border-b border-n-weak">
<th class="py-2 pr-4">
{{ $t('DIRECTORY_DASHBOARD.BENCHMARK.COL_INBOX') }}
</th>
<th class="py-2 px-2 text-right">
{{ $t('DIRECTORY_DASHBOARD.BENCHMARK.COL_LEADS') }}
</th>
<th class="py-2 px-2 text-right">
{{ $t('DIRECTORY_DASHBOARD.BENCHMARK.COL_CREATED') }}
</th>
<th class="py-2 px-2 text-right">
{{ $t('DIRECTORY_DASHBOARD.BENCHMARK.COL_PAID') }}
</th>
<th class="py-2 px-2 text-right">
{{ $t('DIRECTORY_DASHBOARD.BENCHMARK.COL_RATE') }}
</th>
<th class="py-2 pl-2 text-right">
{{ $t('DIRECTORY_DASHBOARD.BENCHMARK.COL_VS_BRAND') }}
</th>
</tr>
</thead>
<template v-for="group in benchmarkGrouped" :key="group.brand">
<tbody>
<tr class="bg-n-slate-3">
<td colspan="6" class="py-2 px-2 font-semibold text-n-slate-12">
{{ group.brand }}
<span class="text-n-slate-11 font-normal text-xs ms-2">
({{ $t('DIRECTORY_DASHBOARD.BENCHMARK.BRAND_AVG') }}
{{ formatPct(group.brandRate) }})
</span>
</td>
</tr>
<tr
v-for="row in group.rows"
:key="row.inbox_id"
class="border-b border-n-weak"
>
<td class="py-2 pr-4 text-n-slate-12">{{ row.inbox_name }}</td>
<td class="py-2 px-2 text-right">
{{ formatNumber(row.leads_total) }}
</td>
<td class="py-2 px-2 text-right">
{{ formatNumber(row.reservations_created) }}
</td>
<td class="py-2 px-2 text-right">
{{ formatNumber(row.reservations_paid) }}
</td>
<td class="py-2 px-2 text-right font-medium">
{{ formatPct(row.conversion_rate) }}
</td>
<td class="py-2 pl-2 text-right">
<span
v-if="
variationFromBrand(row.conversion_rate, group.brandRate) >
0
"
class="text-n-teal-11"
>
+{{
variationFromBrand(row.conversion_rate, group.brandRate)
}}
</span>
<span
v-else-if="
variationFromBrand(row.conversion_rate, group.brandRate) <
0
"
class="text-n-ruby-11"
>
{{
variationFromBrand(row.conversion_rate, group.brandRate)
}}
</span>
<span v-else class="text-n-slate-11"></span>
</td>
</tr>
</tbody>
</template>
</table>
</div>
</div>
</div>
</template>

View File

@ -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,
},
],
},
],

View File

@ -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]

View File

@ -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