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:
parent
aed6d62640
commit
6fa2f621fa
@ -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();
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -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>
|
||||
@ -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...",
|
||||
|
||||
@ -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...",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
138
docs/captain-retention-indicators.md
Normal file
138
docs/captain-retention-indicators.md
Normal 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`).
|
||||
@ -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
|
||||
Loading…
Reference in New Issue
Block a user