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:
parent
3336c68ee3
commit
3897db325e
111
app/builders/v2/reports/inbox_leads_summary_builder.rb
Normal file
111
app/builders/v2/reports/inbox_leads_summary_builder.rb
Normal 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
|
||||||
@ -87,6 +87,13 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
|||||||
render json: builder.build
|
render json: builder.build
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def generate_csv(filename, template)
|
def generate_csv(filename, template)
|
||||||
@ -188,4 +195,13 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
|||||||
until: params[:until]
|
until: params[:until]
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def inbox_leads_summary_params
|
||||||
|
{
|
||||||
|
inbox_id: params[:inbox_id],
|
||||||
|
group_by: params[:group_by],
|
||||||
|
since: params[:since],
|
||||||
|
until: params[:until]
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -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();
|
export default new ReportsAPI();
|
||||||
|
|||||||
@ -281,6 +281,33 @@
|
|||||||
"FILTER_DROPDOWN_LABEL": "Select Inbox",
|
"FILTER_DROPDOWN_LABEL": "Select Inbox",
|
||||||
"ALL_INBOXES": "All Inboxes",
|
"ALL_INBOXES": "All Inboxes",
|
||||||
"SEARCH_INBOX": "Search Inbox",
|
"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": {
|
"FILTERS": {
|
||||||
"INPUT_PLACEHOLDER": {
|
"INPUT_PLACEHOLDER": {
|
||||||
"INBOXES": "Search inboxes"
|
"INBOXES": "Search inboxes"
|
||||||
|
|||||||
@ -269,8 +269,35 @@
|
|||||||
"NO_ENOUGH_DATA": "Não existem dados suficientes para gerar o relatório. Tente novamente mais tarde.",
|
"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",
|
"DOWNLOAD_INBOX_REPORTS": "Baixar relatórios de entrada",
|
||||||
"FILTER_DROPDOWN_LABEL": "Selecionar caixa de entrada",
|
"FILTER_DROPDOWN_LABEL": "Selecionar caixa de entrada",
|
||||||
"ALL_INBOXES": "All Inboxes",
|
"ALL_INBOXES": "Todas as caixas",
|
||||||
"SEARCH_INBOX": "Search Inbox",
|
"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": {
|
"METRICS": {
|
||||||
"CONVERSATIONS": {
|
"CONVERSATIONS": {
|
||||||
"NAME": "Conversas",
|
"NAME": "Conversas",
|
||||||
|
|||||||
@ -1,26 +1,71 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useFunctionGetter } from 'dashboard/composables/store';
|
import { useFunctionGetter } from 'dashboard/composables/store';
|
||||||
|
|
||||||
import WootReports from './components/WootReports.vue';
|
import WootReports from './components/WootReports.vue';
|
||||||
|
import InboxLeadsReport from './components/InboxLeadsReport.vue';
|
||||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const inbox = useFunctionGetter('inboxes/getInboxById', route.params.id);
|
const inbox = useFunctionGetter('inboxes/getInboxById', route.params.id);
|
||||||
|
|
||||||
|
const TABS = {
|
||||||
|
OVERVIEW: 'overview',
|
||||||
|
LEADS: 'leads',
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeTab = ref(TABS.OVERVIEW);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<WootReports
|
<div v-if="inbox.id" class="flex flex-col w-full">
|
||||||
v-if="inbox.id"
|
<div class="flex items-center gap-6 px-6 pt-4 border-b border-n-weak">
|
||||||
:key="inbox.id"
|
<button
|
||||||
type="inbox"
|
type="button"
|
||||||
getter-key="inboxes/getInboxes"
|
class="py-3 text-sm font-medium border-b-2 transition-colors"
|
||||||
action-key="inboxes/get"
|
:class="
|
||||||
:selected-item="inbox"
|
activeTab === TABS.OVERVIEW
|
||||||
:download-button-label="$t('INBOX_REPORTS.DOWNLOAD_INBOX_REPORTS')"
|
? 'border-n-brand text-n-brand'
|
||||||
:report-title="$t('INBOX_REPORTS.HEADER')"
|
: 'border-transparent text-n-slate-11 hover:text-n-slate-12'
|
||||||
has-back-button
|
"
|
||||||
/>
|
@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">
|
<div v-else class="w-full py-20">
|
||||||
<Spinner class="mx-auto" />
|
<Spinner class="mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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>
|
||||||
@ -67,6 +67,10 @@ const state = {
|
|||||||
agentConversationMetric: [],
|
agentConversationMetric: [],
|
||||||
teamConversationMetric: [],
|
teamConversationMetric: [],
|
||||||
},
|
},
|
||||||
|
inboxLeadsSummary: {
|
||||||
|
isFetching: false,
|
||||||
|
data: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const getters = {
|
const getters = {
|
||||||
@ -103,6 +107,12 @@ const getters = {
|
|||||||
getOverviewUIFlags($state) {
|
getOverviewUIFlags($state) {
|
||||||
return $state.overview.uiFlags;
|
return $state.overview.uiFlags;
|
||||||
},
|
},
|
||||||
|
getInboxLeadsSummary(_state) {
|
||||||
|
return _state.inboxLeadsSummary.data;
|
||||||
|
},
|
||||||
|
getInboxLeadsSummaryFetching(_state) {
|
||||||
|
return _state.inboxLeadsSummary.isFetching;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
@ -286,6 +296,20 @@ export const actions = {
|
|||||||
console.error(error);
|
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) {
|
downloadAccountConversationHeatmap(_, reportObj) {
|
||||||
Report.getConversationTrafficCSV({ daysBefore: reportObj.daysBefore })
|
Report.getConversationTrafficCSV({ daysBefore: reportObj.daysBefore })
|
||||||
.then(response => {
|
.then(response => {
|
||||||
@ -357,6 +381,12 @@ const mutations = {
|
|||||||
[types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING](_state, flag) {
|
[types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING](_state, flag) {
|
||||||
_state.overview.uiFlags.isFetchingTeamConversationMetric = 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 {
|
export default {
|
||||||
|
|||||||
@ -209,6 +209,8 @@ export default {
|
|||||||
SET_AGENT_CONVERSATION_METRIC: 'SET_AGENT_CONVERSATION_METRIC',
|
SET_AGENT_CONVERSATION_METRIC: 'SET_AGENT_CONVERSATION_METRIC',
|
||||||
TOGGLE_AGENT_CONVERSATION_METRIC_LOADING:
|
TOGGLE_AGENT_CONVERSATION_METRIC_LOADING:
|
||||||
'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
|
// Conversation Metadata
|
||||||
SET_CONVERSATION_METADATA: 'SET_CONVERSATION_METADATA',
|
SET_CONVERSATION_METADATA: 'SET_CONVERSATION_METADATA',
|
||||||
|
|||||||
@ -515,6 +515,7 @@ Rails.application.routes.draw do
|
|||||||
get :inbox_label_matrix
|
get :inbox_label_matrix
|
||||||
get :first_response_time_distribution
|
get :first_response_time_distribution
|
||||||
get :outgoing_messages_count
|
get :outgoing_messages_count
|
||||||
|
get :inbox_leads_summary
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
resource :year_in_review, only: [:show]
|
resource :year_in_review, only: [:show]
|
||||||
|
|||||||
141
spec/builders/v2/reports/inbox_leads_summary_builder_spec.rb
Normal file
141
spec/builders/v2/reports/inbox_leads_summary_builder_spec.rb
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user