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:
parent
617eadbfe4
commit
d831ee4d33
97
app/builders/v2/reports/conversion_funnel_builder.rb
Normal file
97
app/builders/v2/reports/conversion_funnel_builder.rb
Normal 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
|
||||
79
app/builders/v2/reports/inbox_benchmarking_builder.rb
Normal file
79
app/builders/v2/reports/inbox_benchmarking_builder.rb
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@ -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]
|
||||
|
||||
118
spec/builders/v2/reports/conversion_funnel_builder_spec.rb
Normal file
118
spec/builders/v2/reports/conversion_funnel_builder_spec.rb
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user