feat(reports): aba "Novas × Retorno" no Inbox Report

Mede por inbox/período: leads novos (1ª conversa do contato em qualquer
inbox da rede), retorno (conversa anterior resolved há >24h) e outras
(conversa anterior open ou resolved <24h). Categorias somadas batem com
o conversations_count nativo do report — bucket "outras" garante o
fechamento.

- Novo builder V2::Reports::InboxLeadsSummaryBuilder com CTE única
- Endpoint GET /api/v2/accounts/:id/reports/inbox_leads_summary
- Tabs no InboxReportsShow (Visão Geral | Novas × Retorno)
- Componente InboxLeadsReport com 3 metric cards + barras empilhadas
- API client + Pinia (state/getters/actions/mutations)
- i18n en + pt_BR
- RSpec do builder cobrindo classificação e isolamento por inbox

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-04-25 20:22:43 -03:00
parent 3336c68ee3
commit 3897db325e
11 changed files with 581 additions and 13 deletions

View File

@ -0,0 +1,111 @@
class V2::Reports::InboxLeadsSummaryBuilder
include DateRangeHelper
ALLOWED_GROUP_BY = %w[day week month].freeze
RETURN_THRESHOLD = '24 hours'.freeze
attr_reader :account, :params
def initialize(account, params)
@account = account
@params = params
end
def build
return [] if range.blank? || inbox.blank?
rows = ActiveRecord::Base.connection.exec_query(
ActiveRecord::Base.sanitize_sql_array([sql, sql_bindings])
)
rows.map do |row|
{
period: row['period'].iso8601,
new_leads: row['new_leads'].to_i,
returning: row['returning'].to_i,
others: row['others'].to_i
}
end
end
private
def inbox
@inbox ||= account.inboxes.find_by(id: params[:inbox_id])
end
def group_by
value = params[:group_by].to_s
ALLOWED_GROUP_BY.include?(value) ? value : 'day'
end
def sql_bindings
{
account_id: account.id,
inbox_id: inbox.id,
since: range.begin,
until_t: range.end,
group_by: group_by,
return_threshold: RETURN_THRESHOLD
}
end
# Single CTE to classify each conversation in the period as:
# * new_leads: contact has no prior conversation in any inbox of the account
# * returning: contact had a prior conversation whose latest 'conversation_resolved'
# event occurred more than 24h before the new conversation
# * others: prior conversation existed but was not resolved or was resolved <24h ago
# rubocop:disable Metrics/MethodLength
def sql
<<~SQL.squish
WITH period_conversations AS (
SELECT id, contact_id, created_at
FROM conversations
WHERE account_id = :account_id
AND inbox_id = :inbox_id
AND created_at >= :since
AND created_at < :until_t
),
classified AS (
SELECT
c.id,
c.created_at,
EXISTS (
SELECT 1 FROM conversations prev
WHERE prev.contact_id = c.contact_id
AND prev.account_id = :account_id
AND prev.id < c.id
) AS has_prior,
(
SELECT MAX(re.created_at)
FROM reporting_events re
INNER JOIN conversations prev ON prev.id = re.conversation_id
WHERE re.name = 'conversation_resolved'
AND prev.contact_id = c.contact_id
AND prev.account_id = :account_id
AND prev.id < c.id
) AS latest_prior_resolution_at
FROM period_conversations c
)
SELECT
date_trunc(:group_by, created_at) AS period,
COUNT(*) FILTER (WHERE NOT has_prior) AS new_leads,
COUNT(*) FILTER (
WHERE has_prior
AND latest_prior_resolution_at IS NOT NULL
AND latest_prior_resolution_at < created_at - (:return_threshold)::interval
) AS returning,
COUNT(*) FILTER (
WHERE has_prior
AND (
latest_prior_resolution_at IS NULL
OR latest_prior_resolution_at >= created_at - (:return_threshold)::interval
)
) AS others
FROM classified
GROUP BY period
ORDER BY period
SQL
end
# rubocop:enable Metrics/MethodLength
end

View File

@ -87,6 +87,13 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
render json: builder.build
end
def inbox_leads_summary
return head :unprocessable_entity if params[:inbox_id].blank?
builder = V2::Reports::InboxLeadsSummaryBuilder.new(Current.account, inbox_leads_summary_params)
render json: builder.build
end
private
def generate_csv(filename, template)
@ -188,4 +195,13 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
until: params[:until]
}
end
def inbox_leads_summary_params
{
inbox_id: params[:inbox_id],
group_by: params[:group_by],
since: params[:since],
until: params[:until]
}
end
end

View File

@ -108,6 +108,18 @@ class ReportsAPI extends ApiClient {
},
});
}
getInboxLeadsSummary({ inboxId, from, to, groupBy } = {}) {
return axios.get(`${this.url}/inbox_leads_summary`, {
params: {
inbox_id: inboxId,
since: from,
until: to,
group_by: groupBy,
timezone_offset: getTimeOffset(),
},
});
}
}
export default new ReportsAPI();

View File

@ -281,6 +281,33 @@
"FILTER_DROPDOWN_LABEL": "Select Inbox",
"ALL_INBOXES": "All Inboxes",
"SEARCH_INBOX": "Search Inbox",
"TABS": {
"OVERVIEW": "Overview",
"LEADS": "New × Returning"
},
"LEADS": {
"EMPTY": "No conversations in this period.",
"TOTAL": "Total conversations in the period: {count}",
"METRICS": {
"NEW_LEADS": {
"LABEL": "New leads",
"INFO": "Conversations from contacts who never spoke to any inbox of the network before."
},
"RETURNING": {
"LABEL": "Returning",
"INFO": "Conversations from contacts whose most recent prior conversation was resolved more than 24h ago."
},
"OTHERS": {
"LABEL": "Others",
"INFO": "Conversations from contacts whose prior conversation is still open or was resolved less than 24h ago."
}
},
"CHART": {
"NEW_LEADS": "New",
"RETURNING": "Returning",
"OTHERS": "Others"
}
},
"FILTERS": {
"INPUT_PLACEHOLDER": {
"INBOXES": "Search inboxes"

View File

@ -269,8 +269,35 @@
"NO_ENOUGH_DATA": "Não existem dados suficientes para gerar o relatório. Tente novamente mais tarde.",
"DOWNLOAD_INBOX_REPORTS": "Baixar relatórios de entrada",
"FILTER_DROPDOWN_LABEL": "Selecionar caixa de entrada",
"ALL_INBOXES": "All Inboxes",
"SEARCH_INBOX": "Search Inbox",
"ALL_INBOXES": "Todas as caixas",
"SEARCH_INBOX": "Buscar caixa",
"TABS": {
"OVERVIEW": "Visão Geral",
"LEADS": "Novas × Retorno"
},
"LEADS": {
"EMPTY": "Sem conversas no período.",
"TOTAL": "Total de conversas no período: {count}",
"METRICS": {
"NEW_LEADS": {
"LABEL": "Leads novos",
"INFO": "Conversas de contatos que nunca falaram em nenhuma caixa da rede antes."
},
"RETURNING": {
"LABEL": "Retorno",
"INFO": "Conversas de contatos cuja conversa anterior mais recente foi resolvida há mais de 24h."
},
"OTHERS": {
"LABEL": "Outras",
"INFO": "Conversas de contatos cuja conversa anterior ainda está aberta ou foi resolvida há menos de 24h."
}
},
"CHART": {
"NEW_LEADS": "Novas",
"RETURNING": "Retorno",
"OTHERS": "Outras"
}
},
"METRICS": {
"CONVERSATIONS": {
"NAME": "Conversas",

View File

@ -1,26 +1,71 @@
<script setup>
import { ref } from 'vue';
import { useRoute } from 'vue-router';
import { useFunctionGetter } from 'dashboard/composables/store';
import WootReports from './components/WootReports.vue';
import InboxLeadsReport from './components/InboxLeadsReport.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const route = useRoute();
const inbox = useFunctionGetter('inboxes/getInboxById', route.params.id);
const TABS = {
OVERVIEW: 'overview',
LEADS: 'leads',
};
const activeTab = ref(TABS.OVERVIEW);
</script>
<template>
<WootReports
v-if="inbox.id"
:key="inbox.id"
type="inbox"
getter-key="inboxes/getInboxes"
action-key="inboxes/get"
:selected-item="inbox"
:download-button-label="$t('INBOX_REPORTS.DOWNLOAD_INBOX_REPORTS')"
:report-title="$t('INBOX_REPORTS.HEADER')"
has-back-button
/>
<div v-if="inbox.id" class="flex flex-col w-full">
<div class="flex items-center gap-6 px-6 pt-4 border-b border-n-weak">
<button
type="button"
class="py-3 text-sm font-medium border-b-2 transition-colors"
:class="
activeTab === TABS.OVERVIEW
? 'border-n-brand text-n-brand'
: 'border-transparent text-n-slate-11 hover:text-n-slate-12'
"
@click="activeTab = TABS.OVERVIEW"
>
{{ $t('INBOX_REPORTS.TABS.OVERVIEW') }}
</button>
<button
type="button"
class="py-3 text-sm font-medium border-b-2 transition-colors"
:class="
activeTab === TABS.LEADS
? 'border-n-brand text-n-brand'
: 'border-transparent text-n-slate-11 hover:text-n-slate-12'
"
@click="activeTab = TABS.LEADS"
>
{{ $t('INBOX_REPORTS.TABS.LEADS') }}
</button>
</div>
<div class="px-6 py-4">
<WootReports
v-if="activeTab === TABS.OVERVIEW"
:key="`overview-${inbox.id}`"
type="inbox"
getter-key="inboxes/getInboxes"
action-key="inboxes/get"
:selected-item="inbox"
:download-button-label="$t('INBOX_REPORTS.DOWNLOAD_INBOX_REPORTS')"
:report-title="$t('INBOX_REPORTS.HEADER')"
has-back-button
/>
<InboxLeadsReport
v-else-if="activeTab === TABS.LEADS"
:key="`leads-${inbox.id}`"
:inbox-id="inbox.id"
/>
</div>
</div>
<div v-else class="w-full py-20">
<Spinner class="mx-auto" />
</div>

View File

@ -0,0 +1,156 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { format } from 'date-fns';
import ReportFilters from './ReportFilters.vue';
import ReportMetricCard from './ReportMetricCard.vue';
import BarChart from 'shared/components/charts/BarChart.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
inboxId: {
type: [Number, String],
required: true,
},
});
const store = useStore();
const { t } = useI18n();
const filters = ref({
from: null,
to: null,
groupBy: { id: 1, period: 'day' },
});
const isFetching = computed(() => store.getters.getInboxLeadsSummaryFetching);
const rows = computed(() => store.getters.getInboxLeadsSummary || []);
const totals = computed(() => {
return rows.value.reduce(
(acc, row) => {
acc.new_leads += row.new_leads || 0;
acc.returning += row.returning || 0;
acc.others += row.others || 0;
return acc;
},
{ new_leads: 0, returning: 0, others: 0 }
);
});
const totalConversations = computed(
() => totals.value.new_leads + totals.value.returning + totals.value.others
);
const formatPeriodLabel = (iso, period) => {
const date = new Date(iso);
if (period === 'month') return format(date, 'MMM/yy');
if (period === 'week') return format(date, "'S'II/yy");
return format(date, 'dd/MM');
};
const chartCollection = computed(() => {
const period = filters.value.groupBy?.period || 'day';
return {
labels: rows.value.map(r => formatPeriodLabel(r.period, period)),
datasets: [
{
label: t('INBOX_REPORTS.LEADS.CHART.NEW_LEADS'),
backgroundColor: '#10B981',
data: rows.value.map(r => r.new_leads),
},
{
label: t('INBOX_REPORTS.LEADS.CHART.RETURNING'),
backgroundColor: '#3B82F6',
data: rows.value.map(r => r.returning),
},
{
label: t('INBOX_REPORTS.LEADS.CHART.OTHERS'),
backgroundColor: '#9CA3AF',
data: rows.value.map(r => r.others),
},
],
};
});
const chartOptions = {
plugins: {
legend: { display: true, position: 'bottom' },
},
scales: {
x: { stacked: true, grid: { drawOnChartArea: false } },
y: { stacked: true, beginAtZero: true, ticks: { stepSize: 1 } },
},
};
const fetchData = () => {
if (!filters.value.from || !filters.value.to) return;
store.dispatch('fetchInboxLeadsSummary', {
inboxId: props.inboxId,
from: filters.value.from,
to: filters.value.to,
groupBy: filters.value.groupBy?.period || 'day',
});
};
const onFilterChange = payload => {
filters.value = {
from: payload.from,
to: payload.to,
groupBy: payload.groupBy || filters.value.groupBy,
};
fetchData();
};
watch(
() => props.inboxId,
() => fetchData()
);
</script>
<template>
<div class="flex flex-col gap-6">
<ReportFilters
filter-type="inboxes"
:selected-item="{ id: Number(inboxId) }"
:show-business-hours="false"
:show-entity-filter="false"
@filter-change="onFilterChange"
/>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<ReportMetricCard
:label="$t('INBOX_REPORTS.LEADS.METRICS.NEW_LEADS.LABEL')"
:value="String(totals.new_leads)"
:info-text="$t('INBOX_REPORTS.LEADS.METRICS.NEW_LEADS.INFO')"
/>
<ReportMetricCard
:label="$t('INBOX_REPORTS.LEADS.METRICS.RETURNING.LABEL')"
:value="String(totals.returning)"
:info-text="$t('INBOX_REPORTS.LEADS.METRICS.RETURNING.INFO')"
/>
<ReportMetricCard
:label="$t('INBOX_REPORTS.LEADS.METRICS.OTHERS.LABEL')"
:value="String(totals.others)"
:info-text="$t('INBOX_REPORTS.LEADS.METRICS.OTHERS.INFO')"
/>
</div>
<div
class="bg-n-solid-1 border border-n-weak rounded-lg p-4 min-h-[320px] flex items-center justify-center"
>
<Spinner v-if="isFetching" />
<div v-else-if="rows.length === 0" class="text-sm text-n-slate-11">
{{ $t('INBOX_REPORTS.LEADS.EMPTY') }}
</div>
<div v-else class="w-full h-[320px]">
<BarChart :collection="chartCollection" :chart-options="chartOptions" />
</div>
</div>
<div class="text-xs text-n-slate-11">
{{ $t('INBOX_REPORTS.LEADS.TOTAL', { count: totalConversations }) }}
</div>
</div>
</template>

View File

@ -67,6 +67,10 @@ const state = {
agentConversationMetric: [],
teamConversationMetric: [],
},
inboxLeadsSummary: {
isFetching: false,
data: [],
},
};
const getters = {
@ -103,6 +107,12 @@ const getters = {
getOverviewUIFlags($state) {
return $state.overview.uiFlags;
},
getInboxLeadsSummary(_state) {
return _state.inboxLeadsSummary.data;
},
getInboxLeadsSummaryFetching(_state) {
return _state.inboxLeadsSummary.isFetching;
},
};
export const actions = {
@ -286,6 +296,20 @@ export const actions = {
console.error(error);
});
},
fetchInboxLeadsSummary({ commit }, reportObj) {
commit(types.default.TOGGLE_INBOX_LEADS_SUMMARY_LOADING, true);
return Report.getInboxLeadsSummary(reportObj)
.then(response => {
commit(types.default.SET_INBOX_LEADS_SUMMARY, response.data || []);
})
.catch(error => {
console.error(error);
commit(types.default.SET_INBOX_LEADS_SUMMARY, []);
})
.finally(() => {
commit(types.default.TOGGLE_INBOX_LEADS_SUMMARY_LOADING, false);
});
},
downloadAccountConversationHeatmap(_, reportObj) {
Report.getConversationTrafficCSV({ daysBefore: reportObj.daysBefore })
.then(response => {
@ -357,6 +381,12 @@ const mutations = {
[types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingTeamConversationMetric = flag;
},
[types.default.SET_INBOX_LEADS_SUMMARY](_state, data) {
_state.inboxLeadsSummary.data = data;
},
[types.default.TOGGLE_INBOX_LEADS_SUMMARY_LOADING](_state, flag) {
_state.inboxLeadsSummary.isFetching = flag;
},
};
export default {

View File

@ -209,6 +209,8 @@ export default {
SET_AGENT_CONVERSATION_METRIC: 'SET_AGENT_CONVERSATION_METRIC',
TOGGLE_AGENT_CONVERSATION_METRIC_LOADING:
'TOGGLE_AGENT_CONVERSATION_METRIC_LOADING',
SET_INBOX_LEADS_SUMMARY: 'SET_INBOX_LEADS_SUMMARY',
TOGGLE_INBOX_LEADS_SUMMARY_LOADING: 'TOGGLE_INBOX_LEADS_SUMMARY_LOADING',
// Conversation Metadata
SET_CONVERSATION_METADATA: 'SET_CONVERSATION_METADATA',

View File

@ -515,6 +515,7 @@ Rails.application.routes.draw do
get :inbox_label_matrix
get :first_response_time_distribution
get :outgoing_messages_count
get :inbox_leads_summary
end
end
resource :year_in_review, only: [:show]

View File

@ -0,0 +1,141 @@
require 'rails_helper'
RSpec.describe V2::Reports::InboxLeadsSummaryBuilder 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
{
inbox_id: inbox.id,
since: since_t.to_i.to_s,
until: until_t.to_i.to_s,
group_by: 'day'
}
end
def build(params = base_params)
described_class.new(account, params).build
end
def total_for(rows, key)
rows.sum { |r| r[key] }
end
describe '#build' do
context 'when no inbox is provided or invalid' do
it 'returns empty array when inbox_id missing' do
expect(build(base_params.except(:inbox_id))).to eq([])
end
it 'returns empty array when range missing' do
expect(build(base_params.except(:since))).to eq([])
end
it 'returns empty array when inbox does not belong to account' do
foreign_inbox = create(:inbox, account: create(:account))
expect(build(base_params.merge(inbox_id: foreign_inbox.id))).to eq([])
end
end
context 'when classifying conversations' do
it 'counts as new_lead when contact has no prior conversation' do
contact = create(:contact, account: account)
create(:conversation, account: account, inbox: inbox, contact: contact, created_at: 2.days.ago)
rows = build
expect(total_for(rows, :new_leads)).to eq(1)
expect(total_for(rows, :returning)).to eq(0)
expect(total_for(rows, :others)).to eq(0)
end
it 'counts as returning when prior conversation was resolved >24h ago' do
contact = create(:contact, account: account)
prior = create(:conversation, account: account, inbox: other_inbox, contact: contact, created_at: 10.days.ago)
create(:reporting_event, account: account, inbox: other_inbox, conversation: prior,
name: 'conversation_resolved', value: 100, value_in_business_hours: 50,
created_at: 5.days.ago)
create(:conversation, account: account, inbox: inbox, contact: contact, created_at: 1.day.ago)
rows = build
expect(total_for(rows, :new_leads)).to eq(0)
expect(total_for(rows, :returning)).to eq(1)
expect(total_for(rows, :others)).to eq(0)
end
it 'counts as others when prior conversation was resolved <24h ago' do
contact = create(:contact, account: account)
prior = create(:conversation, account: account, inbox: inbox, contact: contact, created_at: 2.days.ago)
create(:reporting_event, account: account, inbox: inbox, conversation: prior,
name: 'conversation_resolved', value: 100, value_in_business_hours: 50,
created_at: 3.hours.ago)
create(:conversation, account: account, inbox: inbox, contact: contact, created_at: 1.hour.ago)
rows = build
expect(total_for(rows, :new_leads)).to eq(0)
expect(total_for(rows, :returning)).to eq(0)
expect(total_for(rows, :others)).to eq(1)
end
it 'counts as others when contact had prior conversation but it was never resolved' 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)
rows = build
expect(total_for(rows, :new_leads)).to eq(1) # the older one
expect(total_for(rows, :returning)).to eq(0)
expect(total_for(rows, :others)).to eq(1) # the newer one
end
it 'considers prior conversations from any inbox of the account (network-wide)' do
contact = create(:contact, account: account)
prior = create(:conversation, account: account, inbox: other_inbox, contact: contact, created_at: 10.days.ago)
create(:reporting_event, account: account, inbox: other_inbox, conversation: prior,
name: 'conversation_resolved', value: 100, value_in_business_hours: 50,
created_at: 5.days.ago)
create(:conversation, account: account, inbox: inbox, contact: contact, created_at: 1.day.ago)
rows = build
expect(total_for(rows, :new_leads)).to eq(0)
expect(total_for(rows, :returning)).to eq(1)
end
end
context 'when scoped to a specific inbox' do
it 'only counts conversations of the requested inbox' do
contact_a = create(:contact, account: account)
contact_b = create(:contact, account: account)
create(:conversation, account: account, inbox: inbox, contact: contact_a, created_at: 1.day.ago)
create(:conversation, account: account, inbox: other_inbox, contact: contact_b, created_at: 1.day.ago)
rows = build
expect(total_for(rows, :new_leads)).to eq(1)
end
end
context 'when filtering by period range' do
it 'ignores conversations outside the range' do
contact = create(:contact, account: account)
create(:conversation, account: account, inbox: inbox, contact: contact, created_at: 60.days.ago)
rows = build
expect(total_for(rows, :new_leads)).to eq(0)
end
end
context 'when validating response shape' do
it 'returns rows with iso8601 period and integer counts' do
contact = create(:contact, account: account)
create(:conversation, account: account, inbox: inbox, contact: contact, created_at: 1.day.ago)
rows = build
expect(rows).to all(include(:period, :new_leads, :returning, :others))
expect(rows.first[:period]).to match(/\d{4}-\d{2}-\d{2}T/)
end
end
end
end