feat(retention): UI layer — badge, filters, cohort matrix, KPI dashboard

- RetentionSummaryBadge in the "Previous conversations" sidebar:
  tiered status (First contact / Active / Recurring / Sleeping /
  At risk / Inactive) + counts of interactions, one-shots, Pix.

- Retention tab in Captain Reports: KpiCards, FlowCard, CohortMatrix
  (12x13 heatmap with CSV export).

- Five new filters on the contacts list: recurring, last interaction,
  days since, interactions count, reservations paid.

- Full pt_BR + en i18n under CAPTAIN_REPORTS.RETENTION.*

- Spec for InteractionCalculatorService covering gap behavior,
  one-shot classification, internal-label exclusion, multi-conversation
  grouping across the 30h window.

- Docs: docs/captain-retention-indicators.md with business rules,
  column reference, endpoint shape, and backup SQL queries.
This commit is contained in:
Rodribm10 2026-04-22 10:30:19 -03:00
parent aed6d62640
commit 6fa2f621fa
14 changed files with 1145 additions and 4 deletions

View File

@ -33,6 +33,14 @@ class CaptainReportsAPI extends ApiClient {
deliverExecutive(params = {}) {
return axios.post(`${this.url}/executive/deliver`, params);
}
getRetention(params = {}) {
return axios.get(`${this.url}/retention`, { params });
}
getRetentionCohort(params = {}) {
return axios.get(`${this.url}/retention/cohort`, { params });
}
}
export default new CaptainReportsAPI();

View File

@ -199,6 +199,61 @@ export function useContactFilterContext() {
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
// --- Retenção / recorrência ---
{
attributeKey: CONTACT_ATTRIBUTES.IS_RECURRING,
value: CONTACT_ATTRIBUTES.IS_RECURRING,
attributeName: 'Cliente recorrente',
label: 'Cliente recorrente',
inputType: 'searchSelect',
options: [
{ id: 'true', name: 'Sim' },
{ id: 'false', name: 'Não' },
],
dataType: 'text',
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.LAST_INTERACTION_AT,
value: CONTACT_ATTRIBUTES.LAST_INTERACTION_AT,
attributeName: 'Última interação',
label: 'Última interação',
inputType: 'date',
dataType: 'text',
filterOperators: dateOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.DAYS_SINCE_LAST_INTERACTION,
value: CONTACT_ATTRIBUTES.DAYS_SINCE_LAST_INTERACTION,
attributeName: 'Dias sem interagir',
label: 'Dias sem interagir',
inputType: 'plainText',
dataType: 'number',
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.INTERACTIONS_COUNT,
value: CONTACT_ATTRIBUTES.INTERACTIONS_COUNT,
attributeName: 'Nº de interações',
label: 'Nº de interações',
inputType: 'plainText',
dataType: 'number',
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.RESERVATIONS_PAID_COUNT,
value: CONTACT_ATTRIBUTES.RESERVATIONS_PAID_COUNT,
attributeName: 'Reservas pagas',
label: 'Reservas pagas',
inputType: 'plainText',
dataType: 'number',
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
...customFilterTypes.value,
]);

View File

@ -28,6 +28,12 @@ export const CONTACT_ATTRIBUTES = {
REFERER: 'referer',
BLOCKED: 'blocked',
LABELS: 'labels',
// Retenção / recorrência (stats desnormalizados em contacts)
IS_RECURRING: 'is_recurring',
LAST_INTERACTION_AT: 'last_interaction_at',
DAYS_SINCE_LAST_INTERACTION: 'days_since_last_interaction',
INTERACTIONS_COUNT: 'interactions_count',
RESERVATIONS_PAID_COUNT: 'reservations_paid_count',
};
/**

View File

@ -0,0 +1,130 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
const props = defineProps({
contactId: { type: [String, Number], required: true },
});
const { t } = useI18n();
const store = useStore();
const contact = computed(() =>
store.getters['contacts/getContact'](props.contactId)
);
const summary = computed(() => {
if (!contact.value) return null;
return {
interactions: contact.value.interactions_count ?? 0,
oneShots: contact.value.one_shot_consultations_count ?? 0,
pixGenerated: contact.value.pix_generated_count ?? 0,
reservationsPaid: contact.value.reservations_paid_count ?? 0,
lastInteractionAt: contact.value.last_interaction_at ?? null,
daysSince: contact.value.days_since_last_interaction ?? null,
isRecurring: contact.value.is_recurring ?? false,
};
});
const status = computed(() => {
const s = summary.value;
const B = 'CAPTAIN_REPORTS.RETENTION.BADGE';
if (!s || s.interactions === 0)
return { label: t(`${B}.STATUS_FIRST`), tone: 'slate' };
if (s.daysSince !== null && s.daysSince > 180)
return { label: t(`${B}.STATUS_INACTIVE`), tone: 'rose' };
if (s.daysSince !== null && s.daysSince > 90)
return { label: t(`${B}.STATUS_AT_RISK`), tone: 'orange' };
if (s.daysSince !== null && s.daysSince > 30)
return { label: t(`${B}.STATUS_SLEEPING`), tone: 'amber' };
if (s.isRecurring)
return { label: t(`${B}.STATUS_RECURRING`), tone: 'emerald' };
return { label: t(`${B}.STATUS_ACTIVE`), tone: 'sky' };
});
const toneClass = {
slate: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
emerald:
'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
sky: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300',
amber: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200',
orange:
'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-200',
rose: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300',
};
function formatDaysSince(days) {
const B = 'CAPTAIN_REPORTS.RETENTION.BADGE';
if (days === null || days === undefined) return '';
if (days === 0) return t(`${B}.DAYS_TODAY`);
if (days === 1) return t(`${B}.DAYS_YESTERDAY`);
if (days < 30) return t(`${B}.DAYS_RECENT`, { days });
if (days < 60) return t(`${B}.DAYS_ONE_MONTH`);
if (days < 365)
return t(`${B}.DAYS_MONTHS`, { months: Math.round(days / 30) });
return t(`${B}.DAYS_YEARS`, { years: Math.round(days / 365) });
}
const interactionsLabel = computed(() => {
const s = summary.value;
if (!s) return '';
const parts = t('CAPTAIN_REPORTS.RETENTION.BADGE.INTERACTIONS_LABEL').split(
' | '
);
return s.interactions === 1 ? parts[0] : parts[1] || parts[0];
});
</script>
<template>
<div
v-if="summary"
class="mb-3 rounded-lg border border-slate-200 bg-slate-50/50 px-3 py-2.5 dark:border-slate-700 dark:bg-slate-800/40"
>
<div class="mb-1.5 flex items-center justify-between">
<span
:class="toneClass[status.tone]"
class="rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide"
>
{{ status.label }}
</span>
<span v-if="summary.lastInteractionAt" class="text-[10px] text-slate-500">
{{
$t('CAPTAIN_REPORTS.RETENTION.BADGE.LAST_INTERACTION', {
days: formatDaysSince(summary.daysSince),
})
}}
</span>
</div>
<div
class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-600 dark:text-slate-300"
>
<span :title="$t('CAPTAIN_REPORTS.RETENTION.BADGE.INTERACTIONS_TITLE')">
<strong class="text-slate-900 dark:text-slate-100">{{
summary.interactions
}}</strong>
{{ interactionsLabel }}
</span>
<span
v-if="summary.oneShots > 0"
:title="$t('CAPTAIN_REPORTS.RETENTION.BADGE.ONE_SHOT_TITLE')"
>
<strong class="text-slate-900 dark:text-slate-100">{{
summary.oneShots
}}</strong>
{{ $t('CAPTAIN_REPORTS.RETENTION.BADGE.ONE_SHOT_LABEL') }}
</span>
<span
v-if="summary.pixGenerated > 0"
:title="$t('CAPTAIN_REPORTS.RETENTION.BADGE.PIX_TITLE')"
>
<strong class="text-slate-900 dark:text-slate-100">{{
summary.reservationsPaid
}}</strong
>/{{ summary.pixGenerated }}
{{ $t('CAPTAIN_REPORTS.RETENTION.BADGE.PIX_LABEL') }}
</span>
</div>
</div>
<div v-else />
</template>

View File

@ -531,7 +531,74 @@
"INSIGHTS": "AI Insights",
"OPERATIONAL": "Operational",
"EXECUTIVE": "Executive",
"LANDING_PAGES": "Landing Pages"
"LANDING_PAGES": "Landing Pages",
"RETENTION": "Retention"
},
"RETENTION": {
"PERIOD_LABEL": "Period",
"PERIOD_THIS_MONTH": "This month",
"PERIOD_LAST_30": "Last 30 days",
"PERIOD_LAST_90": "Last 90 days",
"PERIOD_CUSTOM": "Custom",
"APPLY": "Apply",
"NO_DATA": "No data.",
"KPI": {
"ACTIVE": "Active customers",
"ACTIVE_HINT": "last interaction within 30 days",
"RECURRING": "Recurring",
"RECURRING_HINT": "≥2 qualified interactions in 90 days",
"RETURN_30D": "30-day return rate",
"RETURN_30D_HINT": "returned to interact within 7 days",
"PIX_CONVERSION": "Pix conversion",
"PIX_CONVERSION_HINT": "{paid} paid out of {generated} generated"
},
"FLOW": {
"TITLE": "Period flow",
"NEW_IN_PERIOD": "new in period",
"RETURNED_IN_PERIOD": "returned in period",
"TOTAL_TOUCHES": "total interactions",
"BASE_STATUS": "Current base status",
"SLEEPING": "{count} sleeping",
"SLEEPING_HINT": "30-90d without contact",
"AT_RISK": "{count} at risk",
"AT_RISK_HINT": "90-180d without contact",
"CHURNED": "{count} inactive",
"CHURNED_HINT": "180d+ without contact"
},
"COHORT": {
"TITLE": "Cohort matrix",
"SUBTITLE": "% of each cohort's customers who returned to interact in M+N months.",
"EXPORT_CSV": "Export CSV",
"EMPTY": "No cohort data yet.",
"COL_COHORT": "Cohort",
"COL_SIZE": "Size",
"CELL_TITLE": "{count} active contacts ({rate}%)"
},
"BADGE": {
"STATUS_FIRST": "First contact",
"STATUS_INACTIVE": "Inactive",
"STATUS_AT_RISK": "At risk",
"STATUS_SLEEPING": "Sleeping",
"STATUS_RECURRING": "Recurring",
"STATUS_ACTIVE": "Active",
"LAST_INTERACTION": "last {days}",
"INTERACTIONS_LABEL": "interaction | interactions",
"INTERACTIONS_TITLE": "Qualified interactions (≥2+2 messages)",
"ONE_SHOT_LABEL": "one-shot",
"ONE_SHOT_TITLE": "One-shot consultations (≥1+1)",
"PIX_LABEL": "Pix paid",
"PIX_TITLE": "Pix generated / reservations paid",
"DAYS_TODAY": "today",
"DAYS_YESTERDAY": "yesterday",
"DAYS_RECENT": "{days} days ago",
"DAYS_ONE_MONTH": "about 1 month ago",
"DAYS_MONTHS": "{months} months ago",
"DAYS_YEARS": "{years} years ago"
},
"ERRORS": {
"SUMMARY": "Failed to load retention KPIs",
"COHORT": "Failed to load cohort"
}
},
"EXECUTIVE": {
"LOADING": "Loading executive digest...",

View File

@ -533,7 +533,74 @@
"INSIGHTS": "Insights IA",
"OPERATIONAL": "Operacional",
"EXECUTIVE": "Executivo",
"LANDING_PAGES": "Landing Pages"
"LANDING_PAGES": "Landing Pages",
"RETENTION": "Retenção"
},
"RETENTION": {
"PERIOD_LABEL": "Período",
"PERIOD_THIS_MONTH": "Este mês",
"PERIOD_LAST_30": "Últimos 30 dias",
"PERIOD_LAST_90": "Últimos 90 dias",
"PERIOD_CUSTOM": "Personalizado",
"APPLY": "Aplicar",
"NO_DATA": "Sem dados.",
"KPI": {
"ACTIVE": "Clientes ativos",
"ACTIVE_HINT": "última interação nos últimos 30 dias",
"RECURRING": "Recorrentes",
"RECURRING_HINT": "≥2 interações qualificadas em 90 dias",
"RETURN_30D": "Taxa de retorno 30d",
"RETURN_30D_HINT": "voltaram a interagir em 7 dias",
"PIX_CONVERSION": "Conversão Pix",
"PIX_CONVERSION_HINT": "{paid} pagos de {generated} gerados"
},
"FLOW": {
"TITLE": "Fluxo do período",
"NEW_IN_PERIOD": "novos no período",
"RETURNED_IN_PERIOD": "retornaram no período",
"TOTAL_TOUCHES": "interações totais",
"BASE_STATUS": "Situação atual da base",
"SLEEPING": "{count} adormecidos",
"SLEEPING_HINT": "30-90d sem contato",
"AT_RISK": "{count} em risco",
"AT_RISK_HINT": "90-180d sem contato",
"CHURNED": "{count} inativos",
"CHURNED_HINT": "180d+ sem contato"
},
"COHORT": {
"TITLE": "Matriz de cohort",
"SUBTITLE": "% de clientes de cada cohort que voltaram a interagir em M+N meses.",
"EXPORT_CSV": "Exportar CSV",
"EMPTY": "Ainda não há cohorts com dados.",
"COL_COHORT": "Cohort",
"COL_SIZE": "Tamanho",
"CELL_TITLE": "{count} contatos ativos ({rate}%)"
},
"BADGE": {
"STATUS_FIRST": "Primeiro contato",
"STATUS_INACTIVE": "Inativo",
"STATUS_AT_RISK": "Em risco",
"STATUS_SLEEPING": "Adormecido",
"STATUS_RECURRING": "Recorrente",
"STATUS_ACTIVE": "Ativo",
"LAST_INTERACTION": "última {days}",
"INTERACTIONS_LABEL": "interação | interações",
"INTERACTIONS_TITLE": "Interações qualificadas (≥2+2 mensagens)",
"ONE_SHOT_LABEL": "one-shot",
"ONE_SHOT_TITLE": "Consultas one-shot (≥1+1)",
"PIX_LABEL": "Pix pagos",
"PIX_TITLE": "Pix gerados / reservas pagas",
"DAYS_TODAY": "hoje",
"DAYS_YESTERDAY": "ontem",
"DAYS_RECENT": "há {days} dias",
"DAYS_ONE_MONTH": "há cerca de 1 mês",
"DAYS_MONTHS": "há {months} meses",
"DAYS_YEARS": "há {years} anos"
},
"ERRORS": {
"SUMMARY": "Falha ao carregar KPIs de retenção",
"COHORT": "Falha ao carregar cohort"
}
},
"EXECUTIVE": {
"LOADING": "Carregando digest executivo...",

View File

@ -1,11 +1,13 @@
<script>
import ConversationCard from 'dashboard/components/widgets/conversation/ConversationCard.vue';
import RetentionSummaryBadge from 'dashboard/components/widgets/conversation/RetentionSummaryBadge.vue';
import { mapGetters } from 'vuex';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
export default {
components: {
ConversationCard,
RetentionSummaryBadge,
Spinner,
},
props: {
@ -47,8 +49,9 @@ export default {
</script>
<template>
<div v-if="!uiFlags.isFetching" class="">
<div v-if="!previousConversations.length" class="no-label-message px-4 p-3">
<div v-if="!uiFlags.isFetching" class="px-2">
<RetentionSummaryBadge :contact-id="contactId" />
<div v-if="!previousConversations.length" class="no-label-message p-3">
<span>
{{ $t('CONTACT_PANEL.CONVERSATIONS.NO_RECORDS_FOUND') }}
</span>

View File

@ -8,6 +8,7 @@ import SettingsLayout from '../../SettingsLayout.vue';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CaptainReportsAPI from 'dashboard/api/captain/reports';
import RetentionTab from './retention/RetentionTab.vue';
const { t } = useI18n();
const store = useStore();
@ -31,6 +32,7 @@ const tabs = [
{ key: 'operational' },
{ key: 'executive' },
{ key: 'landing_pages' },
{ key: 'retention' },
];
const lpStats = ref(null);
@ -426,6 +428,7 @@ const tabLabel = key => {
operational: t('CAPTAIN_REPORTS.TABS.OPERATIONAL'),
executive: t('CAPTAIN_REPORTS.TABS.EXECUTIVE'),
landing_pages: 'Landing Pages',
retention: 'Retenção',
};
return map[key] || key;
};
@ -2725,6 +2728,11 @@ const maxHandoffCount = computed(() =>
</div>
</div>
</div>
<!-- Tab: Retenção -->
<div v-else-if="activeTab === 'retention'" class="p-4">
<RetentionTab />
</div>
</div>
</template>
</SettingsLayout>

View File

@ -0,0 +1,172 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
data: { type: Object, default: null },
loading: { type: Boolean, default: false },
});
const { t } = useI18n();
const cohorts = computed(() => props.data?.cohorts ?? []);
const maxOffset = computed(() => props.data?.max_offset_months ?? 12);
const offsetHeaders = computed(() =>
Array.from({ length: maxOffset.value + 1 }, (_, i) => `M+${i}`)
);
const MONTH_KEYS = [
'jan',
'fev',
'mar',
'abr',
'mai',
'jun',
'jul',
'ago',
'set',
'out',
'nov',
'dez',
];
function formatMonth(iso) {
if (!iso) return '';
const date = new Date(iso);
return `${MONTH_KEYS[date.getMonth()]}/${String(date.getFullYear()).slice(2)}`;
}
function cellStyle(rate) {
if (rate === null || rate === undefined) return {};
if (rate === 0) {
return {
background: 'rgba(148, 163, 184, 0.12)',
color: 'var(--text-muted, #94a3b8)',
};
}
const pct = Math.min(rate, 1);
const intensity = 0.2 + pct * 0.8;
return {
background: `rgba(16, 185, 129, ${intensity})`,
color: pct > 0.5 ? '#fff' : '#065f46',
};
}
function cellTitle(cell) {
return t('CAPTAIN_REPORTS.RETENTION.COHORT.CELL_TITLE', {
count: cell.count,
rate: (cell.rate * 100).toFixed(1),
});
}
function exportCsv() {
if (!cohorts.value.length) return;
const header = ['cohort,size,' + offsetHeaders.value.join(',')];
const rows = cohorts.value.map(c => {
const cells = c.retention
.map(r => `${(r.rate * 100).toFixed(1)}%`)
.join(',');
return `${formatMonth(c.cohort)},${c.size},${cells}`;
});
const csv = header.concat(rows).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'retention-cohort.csv';
a.click();
URL.revokeObjectURL(url);
}
</script>
<template>
<div
class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-700 dark:bg-slate-800"
>
<div class="mb-3 flex items-center justify-between">
<div>
<h3 class="text-base font-semibold text-slate-800 dark:text-slate-100">
{{ $t('CAPTAIN_REPORTS.RETENTION.COHORT.TITLE') }}
</h3>
<p class="text-xs text-slate-500">
{{ $t('CAPTAIN_REPORTS.RETENTION.COHORT.SUBTITLE') }}
</p>
</div>
<button
v-if="cohorts.length"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:hover:bg-slate-700"
@click="exportCsv"
>
{{ $t('CAPTAIN_REPORTS.RETENTION.COHORT.EXPORT_CSV') }}
</button>
</div>
<div
v-if="loading"
class="h-48 animate-pulse rounded bg-slate-100 dark:bg-slate-900"
/>
<div
v-else-if="!cohorts.length"
class="py-8 text-center text-sm text-slate-500"
>
{{ $t('CAPTAIN_REPORTS.RETENTION.COHORT.EMPTY') }}
</div>
<div v-else class="overflow-x-auto">
<table class="cohort-table min-w-full border-separate">
<thead>
<tr>
<th
class="px-3 py-2 text-left text-xs font-semibold text-slate-500"
>
{{ $t('CAPTAIN_REPORTS.RETENTION.COHORT.COL_COHORT') }}
</th>
<th
class="px-3 py-2 text-right text-xs font-semibold text-slate-500"
>
{{ $t('CAPTAIN_REPORTS.RETENTION.COHORT.COL_SIZE') }}
</th>
<th
v-for="h in offsetHeaders"
:key="h"
class="px-2 py-2 text-center text-xs font-semibold text-slate-500"
>
{{ h }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in cohorts" :key="row.cohort">
<td
class="whitespace-nowrap px-3 py-1 text-sm font-medium text-slate-700 dark:text-slate-200"
>
{{ formatMonth(row.cohort) }}
</td>
<td
class="px-3 py-1 text-right text-sm tabular-nums text-slate-600 dark:text-slate-300"
>
{{ row.size }}
</td>
<td
v-for="cell in row.retention"
:key="cell.month_offset"
class="rounded px-2 py-1 text-center text-xs font-semibold tabular-nums"
:style="cellStyle(cell.rate)"
:title="cellTitle(cell)"
>
{{ (cell.rate * 100).toFixed(0) }}%
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.cohort-table {
border-spacing: 2px;
}
</style>

View File

@ -0,0 +1,133 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
summary: { type: Object, default: null },
loading: { type: Boolean, default: false },
});
const data = computed(() => {
if (!props.summary) return null;
const flow = props.summary.flow || {};
const status = props.summary.status || {};
return {
newCount: flow.new ?? 0,
returnedCount: flow.returned ?? 0,
totalTouches: flow.total_touches ?? 0,
sleeping: status.sleeping ?? 0,
atRisk: status.at_risk ?? 0,
churned: status.churned ?? 0,
};
});
const newPct = computed(() => {
if (!data.value || data.value.totalTouches === 0) return 0;
return Math.round((data.value.newCount / data.value.totalTouches) * 100);
});
const newBarStyle = computed(() => ({ width: `${newPct.value}%` }));
const returnedBarStyle = computed(() => ({ width: `${100 - newPct.value}%` }));
</script>
<template>
<div
class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-700 dark:bg-slate-800"
>
<h3 class="mb-3 text-base font-semibold text-slate-800 dark:text-slate-100">
{{ $t('CAPTAIN_REPORTS.RETENTION.FLOW.TITLE') }}
</h3>
<div
v-if="loading"
class="h-24 animate-pulse rounded bg-slate-100 dark:bg-slate-900"
/>
<template v-else-if="data">
<div class="mb-4 grid grid-cols-3 gap-4">
<div>
<div
class="text-2xl font-bold text-emerald-600 dark:text-emerald-400"
>
{{ data.newCount }}
</div>
<div class="text-xs text-slate-500">
{{ $t('CAPTAIN_REPORTS.RETENTION.FLOW.NEW_IN_PERIOD') }}
</div>
</div>
<div>
<div class="text-2xl font-bold text-sky-600 dark:text-sky-400">
{{ data.returnedCount }}
</div>
<div class="text-xs text-slate-500">
{{ $t('CAPTAIN_REPORTS.RETENTION.FLOW.RETURNED_IN_PERIOD') }}
</div>
</div>
<div>
<div class="text-2xl font-bold text-slate-900 dark:text-slate-100">
{{ data.totalTouches }}
</div>
<div class="text-xs text-slate-500">
{{ $t('CAPTAIN_REPORTS.RETENTION.FLOW.TOTAL_TOUCHES') }}
</div>
</div>
</div>
<!-- Barra de proporção novos vs recorrentes -->
<div
class="mb-4 h-2 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-900"
>
<div class="flex h-full">
<div class="bg-emerald-500" :style="newBarStyle" />
<div class="bg-sky-500" :style="returnedBarStyle" />
</div>
</div>
<!-- Status absoluto -->
<div class="mt-5 border-t border-slate-100 pt-4 dark:border-slate-700">
<div
class="mb-2 text-xs font-medium uppercase tracking-wide text-slate-500"
>
{{ $t('CAPTAIN_REPORTS.RETENTION.FLOW.BASE_STATUS') }}
</div>
<div class="grid grid-cols-3 gap-3 text-sm">
<div class="rounded bg-amber-50 px-3 py-2 dark:bg-amber-900/10">
<div class="font-semibold text-amber-700 dark:text-amber-300">
{{
$t('CAPTAIN_REPORTS.RETENTION.FLOW.SLEEPING', {
count: data.sleeping,
})
}}
</div>
<div class="text-xs text-slate-500">
{{ $t('CAPTAIN_REPORTS.RETENTION.FLOW.SLEEPING_HINT') }}
</div>
</div>
<div class="rounded bg-orange-50 px-3 py-2 dark:bg-orange-900/10">
<div class="font-semibold text-orange-700 dark:text-orange-300">
{{
$t('CAPTAIN_REPORTS.RETENTION.FLOW.AT_RISK', {
count: data.atRisk,
})
}}
</div>
<div class="text-xs text-slate-500">
{{ $t('CAPTAIN_REPORTS.RETENTION.FLOW.AT_RISK_HINT') }}
</div>
</div>
<div class="rounded bg-rose-50 px-3 py-2 dark:bg-rose-900/10">
<div class="font-semibold text-rose-700 dark:text-rose-300">
{{
$t('CAPTAIN_REPORTS.RETENTION.FLOW.CHURNED', {
count: data.churned,
})
}}
</div>
<div class="text-xs text-slate-500">
{{ $t('CAPTAIN_REPORTS.RETENTION.FLOW.CHURNED_HINT') }}
</div>
</div>
</div>
</div>
</template>
</div>
</template>

View File

@ -0,0 +1,105 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
summary: { type: Object, default: null },
loading: { type: Boolean, default: false },
});
const { t } = useI18n();
const cards = computed(() => {
if (!props.summary) return [];
const s = props.summary.status || {};
const r = props.summary.retention || {};
const p = props.summary.pix || {};
const K = 'CAPTAIN_REPORTS.RETENTION.KPI';
return [
{
label: t(`${K}.ACTIVE`),
value: s.active ?? 0,
hint: t(`${K}.ACTIVE_HINT`),
tone: 'green',
},
{
label: t(`${K}.RECURRING`),
value: s.recurring ?? 0,
hint: t(`${K}.RECURRING_HINT`),
tone: 'blue',
},
{
label: t(`${K}.RETURN_30D`),
value: `${Math.round((r.last_30d ?? 0) * 100)}%`,
hint: t(`${K}.RETURN_30D_HINT`),
tone: 'indigo',
},
{
label: t(`${K}.PIX_CONVERSION`),
value: `${Math.round((p.conversion_rate ?? 0) * 100)}%`,
hint: t(`${K}.PIX_CONVERSION_HINT`, {
paid: p.paid ?? 0,
generated: p.generated ?? 0,
}),
tone: 'amber',
},
];
});
const toneClass = {
green:
'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-300',
blue: 'bg-sky-50 text-sky-700 dark:bg-sky-900/20 dark:text-sky-300',
indigo:
'bg-indigo-50 text-indigo-700 dark:bg-indigo-900/20 dark:text-indigo-300',
amber: 'bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-300',
};
</script>
<template>
<div
v-if="loading"
class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"
>
<div
v-for="i in 4"
:key="i"
class="h-28 animate-pulse rounded-lg bg-slate-100 dark:bg-slate-800"
/>
</div>
<div
v-else-if="summary"
class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"
>
<div
v-for="card in cards"
:key="card.label"
class="rounded-lg border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-800"
>
<div class="flex items-center justify-between">
<span
class="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400"
>
{{ card.label }}
</span>
<span
:class="toneClass[card.tone]"
class="rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase"
>
{{ card.tone }}
</span>
</div>
<div class="mt-3 text-3xl font-bold text-slate-900 dark:text-slate-50">
{{ card.value }}
</div>
<div class="mt-1 text-xs text-slate-500 dark:text-slate-400">
{{ card.hint }}
</div>
</div>
</div>
<div v-else class="text-sm text-slate-500">
{{ $t('CAPTAIN_REPORTS.RETENTION.NO_DATA') }}
</div>
</template>

View File

@ -0,0 +1,135 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import CaptainReportsAPI from 'dashboard/api/captain/reports';
import KpiCards from './KpiCards.vue';
import FlowCard from './FlowCard.vue';
import CohortMatrix from './CohortMatrix.vue';
const { t } = useI18n();
const summary = ref(null);
const cohort = ref(null);
const loadingSummary = ref(false);
const loadingCohort = ref(false);
const periodPreset = ref('this_month');
const customStart = ref('');
const customEnd = ref('');
function formatLocalDate(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function getPeriodDates() {
const end = new Date();
const start = new Date();
switch (periodPreset.value) {
case 'this_month':
start.setDate(1);
break;
case 'last_30_days':
start.setDate(end.getDate() - 29);
break;
case 'last_90_days':
start.setDate(end.getDate() - 89);
break;
case 'custom':
return { start: customStart.value, end: customEnd.value };
default:
start.setDate(1);
}
return { start: formatLocalDate(start), end: formatLocalDate(end) };
}
async function fetchSummary() {
loadingSummary.value = true;
try {
const dates = getPeriodDates();
const { data } = await CaptainReportsAPI.getRetention({
start_date: dates.start,
end_date: dates.end,
});
summary.value = data;
} catch (err) {
useAlert(t('CAPTAIN_REPORTS.RETENTION.ERRORS.SUMMARY'));
} finally {
loadingSummary.value = false;
}
}
async function fetchCohort() {
loadingCohort.value = true;
try {
const { data } = await CaptainReportsAPI.getRetentionCohort({
history_months: 12,
});
cohort.value = data;
} catch (err) {
useAlert(t('CAPTAIN_REPORTS.RETENTION.ERRORS.COHORT'));
} finally {
loadingCohort.value = false;
}
}
function refresh() {
fetchSummary();
fetchCohort();
}
onMounted(refresh);
</script>
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-wrap items-center gap-3">
<label class="text-sm font-medium text-slate-600 dark:text-slate-300">
{{ $t('CAPTAIN_REPORTS.RETENTION.PERIOD_LABEL') }}:
</label>
<select
v-model="periodPreset"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-800"
@change="fetchSummary"
>
<option value="this_month">
{{ $t('CAPTAIN_REPORTS.RETENTION.PERIOD_THIS_MONTH') }}
</option>
<option value="last_30_days">
{{ $t('CAPTAIN_REPORTS.RETENTION.PERIOD_LAST_30') }}
</option>
<option value="last_90_days">
{{ $t('CAPTAIN_REPORTS.RETENTION.PERIOD_LAST_90') }}
</option>
<option value="custom">
{{ $t('CAPTAIN_REPORTS.RETENTION.PERIOD_CUSTOM') }}
</option>
</select>
<template v-if="periodPreset === 'custom'">
<input
v-model="customStart"
type="date"
class="rounded-md border border-slate-200 bg-white px-2 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-800"
/>
<input
v-model="customEnd"
type="date"
class="rounded-md border border-slate-200 bg-white px-2 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-800"
/>
<button
class="rounded-md bg-woot-500 px-3 py-1.5 text-sm text-white hover:bg-woot-600"
@click="fetchSummary"
>
{{ $t('CAPTAIN_REPORTS.RETENTION.APPLY') }}
</button>
</template>
</div>
<KpiCards :summary="summary" :loading="loadingSummary" />
<FlowCard :summary="summary" :loading="loadingSummary" />
<CohortMatrix :data="cohort" :loading="loadingCohort" />
</div>
</template>

View File

@ -0,0 +1,138 @@
# Captain — Indicadores de Retenção e Recorrência
Design e implementação do sistema de métricas de retenção para motel (negociado
com Rodrigo em 2026-04-22).
## Regras de negócio
### Definição de "interação"
- Todas as mensagens públicas (não-privadas, status != failed) do contato e do
atendimento (Captain::Assistant ou User) são consideradas.
- **Gap de 30h** entre a última msg de uma interação e a próxima msg abre uma
nova interação. Abaixo disso, mesma "sessão".
- Motivo: motel tem frequência alta (múltiplas visitas na mesma semana); 30h
evita que manhã+tarde virem 2 interações mas captura corretamente
"voltou-no-dia-seguinte".
### Classificação de cada grupo
| Cliente | Atendimento | Tipo |
|---------|-------------|------|
| ≥ 2 msgs textuais | ≥ 2 msgs textuais | **qualificada** (conta em `interactions_count`) |
| ≥ 1 msg textual | ≥ 1 msg textual | **one-shot** (conta em `one_shot_consultations_count`) |
| 0 de um dos lados | — | ignorado |
**Textual** = pelo menos 2 letras (`\p{L}`). Exclui emoji-only, sticker,
"Message without content".
### Exclusão
Contatos com o label `equipe_interna` (manual) são ignorados em todas as
métricas. Aplica-se a gerentes, testes, números internos.
### Recorrência
`is_recurring = interactions_count >= 2`.
### Reservas vs Pix
- `pix_generated_count`: toda `Captain::PixCharge` criada (sinal de intenção).
- `reservations_paid_count`: `Captain::PixCharge.where(status: 'paid')` (reserva real).
- Conversão = paid / generated.
## Colunas em `contacts`
| Coluna | Tipo | Atualizada por |
|--------|------|----------------|
| `interactions_count` | integer | `Captain::Retention::RecalculateContactStatsJob` |
| `one_shot_consultations_count` | integer | idem |
| `first_interaction_at` | datetime | idem |
| `last_interaction_at` | datetime | idem |
| `pix_generated_count` | integer | idem (conta PixCharges) |
| `reservations_paid_count` | integer | idem |
| `is_recurring` | boolean | idem |
| `days_since_last_interaction` | integer | idem (recalculado no job) |
## Fluxo de atualização
- **Job diário** (`captain_retention_recalculate_all_contact_stats_job`, 6h UTC
/ 3h BRT): itera todos os contatos com conversa nos últimos 120 dias e
enfileira `RecalculateContactStatsJob` por contact.
- **Evento conversa resolvida**: `CaptainListener#conversation_resolved`
enfileira recalc incremental do contato.
- **Evento PixCharge**: `after_create_commit` e `after_update_commit`
enfileiram recalc (cobre geração e mudança pra paid/expired/failed).
## Endpoints
### `GET /api/v1/accounts/:id/captain/reports/retention`
Params: `start_date`, `end_date` (ISO, opcional, default = mês corrente).
Resposta:
```json
{
"period": { "start": "2026-04-01", "end": "2026-04-22" },
"status": {
"active": 340, "recurring": 85, "sleeping": 62,
"at_risk": 30, "churned": 15, "never_interacted": 0
},
"flow": { "new": 120, "returned": 40, "total_touches": 160 },
"pix": { "generated": 180, "paid": 45, "conversion_rate": 0.25 },
"retention": { "last_30d": 0.28, "last_90d": 0.12 }
}
```
Cortes de status (relativos a `Time.current`):
- **active**: `last_interaction_at >= now - 30d`
- **recurring**: `is_recurring=true AND last_interaction_at >= now - 90d`
- **sleeping**: `last_interaction_at` entre 30 e 90 dias atrás
- **at_risk**: entre 90 e 180 dias atrás
- **churned**: mais de 180 dias atrás
- **never_interacted**: `last_interaction_at IS NULL`
### `GET /api/v1/accounts/:id/captain/reports/retention/cohort`
Params: `history_months` (default 12, max 24).
Resposta: matriz com `cohorts` (linha por mês), cada linha tem
`size` + `retention[]` (array de 13 objetos: offset 0..12 com count e rate).
Cohort = mês da `first_interaction_at`. Célula [cohort, M+N] = % de contatos
da cohort com qualquer msg de cliente no mês M+N.
## UI
- **Aba Retenção em Relatórios do Captain** (`retention/RetentionTab.vue`):
- KpiCards: 4 cards (ativos, recorrentes, retorno 30d, conversão Pix)
- FlowCard: novos vs retornaram no período + situação absoluta (sleeping/
at_risk/churned)
- CohortMatrix: tabela cohort 12x13 com heatmap verde + export CSV
- **Badge no card de contato** (`RetentionSummaryBadge.vue`): mostra tier
(Primeiro contato/Ativo/Recorrente/Adormecido/Em risco/Inativo) + contadores
+ "última há X dias".
- **Filtros na lista de contatos** (`contactProvider.js`): 5 filtros novos —
cliente recorrente, última interação, dias sem interagir, nº de interações,
reservas pagas.
## Queries SQL de backup
Contagem rápida de ativos no mês:
```sql
SELECT COUNT(*) FROM contacts
WHERE account_id = $1 AND last_interaction_at >= NOW() - INTERVAL '30 days';
```
Taxa de retorno 30d da cohort de 30-37 dias atrás:
```sql
WITH cohort AS (
SELECT id FROM contacts
WHERE account_id = $1
AND first_interaction_at BETWEEN NOW() - INTERVAL '37 days' AND NOW() - INTERVAL '30 days'
)
SELECT (COUNT(*) FILTER (WHERE c.last_interaction_at >= NOW() - INTERVAL '7 days'))::float
/ NULLIF(COUNT(*), 0) AS rate
FROM contacts c WHERE c.id IN (SELECT id FROM cohort);
```
## Próximos passos possíveis
- Recalcular `first_interaction_at` / `last_interaction_at` via SQL direto
no job (atualmente é via service Ruby — OK até ~10k contatos ativos).
- Widget "Top recorrentes" (ranking dos contatos com mais reservas pagas).
- Alerta quando taxa de retorno cai > 10% mês a mês.
- Histórico da métrica para rodar tendências (tabela `retention_snapshots`).

View File

@ -0,0 +1,114 @@
require 'rails_helper'
RSpec.describe Captain::ContactStats::InteractionCalculatorService do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:contact) { create(:contact, account: account) }
def open_conversation(created_at: Time.current)
create(:conversation, account: account, inbox: inbox, contact: contact, created_at: created_at)
end
def msg(conversation, kind, content, at)
attrs = { conversation: conversation, content: content, created_at: at }
case kind
when :customer
create(:message, message_type: :incoming, sender: contact, **attrs)
when :bot
# Agent/user outgoing — qualifica como "Jasmine" pelo nosso critério
user = create(:user, account: account)
create(:message, message_type: :outgoing, sender: user, **attrs)
when :system
create(:message, message_type: :outgoing, sender: nil, **attrs)
end
end
describe '#call' do
it 'returns empty stats when there are no conversations' do
expect(described_class.new(contact: contact).call).to include(
interactions_count: 0, one_shot_consultations_count: 0, is_recurring: false
)
end
it 'returns empty stats when contact is labelled equipe_interna' do
contact.update!(label_list: 'equipe_interna')
conv = open_conversation
now = Time.current
msg(conv, :customer, 'Oi bom dia', now)
msg(conv, :bot, 'Oi, em que posso ajudar?', now + 1.minute)
expect(described_class.new(contact: contact).call).to include(interactions_count: 0)
end
it 'counts a qualified interaction when there are >=2 msgs per side' do
conv = open_conversation
t = Time.current
msg(conv, :customer, 'Oi bom dia', t)
msg(conv, :bot, 'Oi! Posso ajudar?', t + 1.minute)
msg(conv, :customer, 'Qual preço da Stilo?', t + 2.minutes)
msg(conv, :bot, 'Stilo pernoite R$ 140', t + 3.minutes)
result = described_class.new(contact: contact).call
expect(result[:interactions_count]).to eq(1)
expect(result[:one_shot_consultations_count]).to eq(0)
expect(result[:is_recurring]).to be(false)
end
it 'classifies 1+1 as one-shot consultation' do
conv = open_conversation
t = Time.current
msg(conv, :customer, 'Quanto custa Alexa?', t)
msg(conv, :bot, 'R$ 100 por 4hrs', t + 1.minute)
result = described_class.new(contact: contact).call
expect(result[:interactions_count]).to eq(0)
expect(result[:one_shot_consultations_count]).to eq(1)
end
it 'ignores interactions where one side has no textual content' do
conv = open_conversation
t = Time.current
msg(conv, :customer, '👍', t) # emoji, não conta
msg(conv, :customer, 'Oi', t + 5.minutes) # < 2 letras → não conta
msg(conv, :bot, 'R$ 100', t + 10.minutes) # só dígitos, não conta
result = described_class.new(contact: contact).call
expect(result[:interactions_count]).to eq(0)
expect(result[:one_shot_consultations_count]).to eq(0)
end
it 'groups msgs within 30h into the same interaction' do
conv = open_conversation
t = 2.days.ago
msg(conv, :customer, 'Oi bom dia', t)
msg(conv, :bot, 'Oi!', t + 5.minutes)
msg(conv, :customer, 'Qual preço?', t + 25.hours) # dentro da janela 30h
msg(conv, :bot, 'R$ 140', t + 25.hours + 1.minute)
result = described_class.new(contact: contact).call
expect(result[:interactions_count]).to eq(1)
end
it 'opens a new interaction when gap > 30h' do
conv1 = open_conversation(created_at: 3.days.ago)
conv2 = open_conversation(created_at: 1.hour.ago)
t1 = 3.days.ago
msg(conv1, :customer, 'Oi bom dia', t1)
msg(conv1, :bot, 'Oi!', t1 + 5.minutes)
msg(conv1, :customer, 'Reservar hoje', t1 + 10.minutes)
msg(conv1, :bot, 'Ok confirmo', t1 + 15.minutes)
t2 = 1.hour.ago
msg(conv2, :customer, 'Voltei pra marcar outra', t2)
msg(conv2, :bot, 'Claro, qual data?', t2 + 5.minutes)
msg(conv2, :customer, 'Sexta que vem', t2 + 10.minutes)
msg(conv2, :bot, 'Anotado', t2 + 15.minutes)
result = described_class.new(contact: contact).call
expect(result[:interactions_count]).to eq(2)
expect(result[:is_recurring]).to be(true)
end
end
end