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 = {}) {
|
deliverExecutive(params = {}) {
|
||||||
return axios.post(`${this.url}/executive/deliver`, 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();
|
export default new CaptainReportsAPI();
|
||||||
|
|||||||
@ -199,6 +199,61 @@ export function useContactFilterContext() {
|
|||||||
filterOperators: equalityOperators.value,
|
filterOperators: equalityOperators.value,
|
||||||
attributeModel: 'standard',
|
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,
|
...customFilterTypes.value,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,12 @@ export const CONTACT_ATTRIBUTES = {
|
|||||||
REFERER: 'referer',
|
REFERER: 'referer',
|
||||||
BLOCKED: 'blocked',
|
BLOCKED: 'blocked',
|
||||||
LABELS: 'labels',
|
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",
|
"INSIGHTS": "AI Insights",
|
||||||
"OPERATIONAL": "Operational",
|
"OPERATIONAL": "Operational",
|
||||||
"EXECUTIVE": "Executive",
|
"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": {
|
"EXECUTIVE": {
|
||||||
"LOADING": "Loading executive digest...",
|
"LOADING": "Loading executive digest...",
|
||||||
|
|||||||
@ -533,7 +533,74 @@
|
|||||||
"INSIGHTS": "Insights IA",
|
"INSIGHTS": "Insights IA",
|
||||||
"OPERATIONAL": "Operacional",
|
"OPERATIONAL": "Operacional",
|
||||||
"EXECUTIVE": "Executivo",
|
"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": {
|
"EXECUTIVE": {
|
||||||
"LOADING": "Carregando digest executivo...",
|
"LOADING": "Carregando digest executivo...",
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
<script>
|
<script>
|
||||||
import ConversationCard from 'dashboard/components/widgets/conversation/ConversationCard.vue';
|
import ConversationCard from 'dashboard/components/widgets/conversation/ConversationCard.vue';
|
||||||
|
import RetentionSummaryBadge from 'dashboard/components/widgets/conversation/RetentionSummaryBadge.vue';
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
ConversationCard,
|
ConversationCard,
|
||||||
|
RetentionSummaryBadge,
|
||||||
Spinner,
|
Spinner,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
@ -47,8 +49,9 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="!uiFlags.isFetching" class="">
|
<div v-if="!uiFlags.isFetching" class="px-2">
|
||||||
<div v-if="!previousConversations.length" class="no-label-message px-4 p-3">
|
<RetentionSummaryBadge :contact-id="contactId" />
|
||||||
|
<div v-if="!previousConversations.length" class="no-label-message p-3">
|
||||||
<span>
|
<span>
|
||||||
{{ $t('CONTACT_PANEL.CONVERSATIONS.NO_RECORDS_FOUND') }}
|
{{ $t('CONTACT_PANEL.CONVERSATIONS.NO_RECORDS_FOUND') }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import SettingsLayout from '../../SettingsLayout.vue';
|
|||||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import CaptainReportsAPI from 'dashboard/api/captain/reports';
|
import CaptainReportsAPI from 'dashboard/api/captain/reports';
|
||||||
|
import RetentionTab from './retention/RetentionTab.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@ -31,6 +32,7 @@ const tabs = [
|
|||||||
{ key: 'operational' },
|
{ key: 'operational' },
|
||||||
{ key: 'executive' },
|
{ key: 'executive' },
|
||||||
{ key: 'landing_pages' },
|
{ key: 'landing_pages' },
|
||||||
|
{ key: 'retention' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const lpStats = ref(null);
|
const lpStats = ref(null);
|
||||||
@ -426,6 +428,7 @@ const tabLabel = key => {
|
|||||||
operational: t('CAPTAIN_REPORTS.TABS.OPERATIONAL'),
|
operational: t('CAPTAIN_REPORTS.TABS.OPERATIONAL'),
|
||||||
executive: t('CAPTAIN_REPORTS.TABS.EXECUTIVE'),
|
executive: t('CAPTAIN_REPORTS.TABS.EXECUTIVE'),
|
||||||
landing_pages: 'Landing Pages',
|
landing_pages: 'Landing Pages',
|
||||||
|
retention: 'Retenção',
|
||||||
};
|
};
|
||||||
return map[key] || key;
|
return map[key] || key;
|
||||||
};
|
};
|
||||||
@ -2725,6 +2728,11 @@ const maxHandoffCount = computed(() =>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Retenção -->
|
||||||
|
<div v-else-if="activeTab === 'retention'" class="p-4">
|
||||||
|
<RetentionTab />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</SettingsLayout>
|
</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